perf(ui): patch only visible spinner cells

This commit is contained in:
Jonathan Liebig
2026-09-15 03:30:28 +02:00
parent 39d3a188b0
commit 0b24a21779
11 changed files with 208 additions and 143 deletions
+24 -1
View File
@@ -83,7 +83,7 @@ pub(super) fn render_agent_panel(
|row| row.rows.len(),
|buffer, rect, row, hits| {
hits.agents.push((rect, row.pane_id.clone()));
render_agent_row(buffer, rect, row, config, spinner_frame);
render_agent_row(buffer, rect, row, config, spinner_frame, hits);
},
);
}
@@ -325,6 +325,7 @@ pub(super) fn render_agent_row(
row: &AgentRow,
config: &ClientShellConfig,
spinner_frame: Option<usize>,
hits: &mut ShellHitMap,
) {
let palette = &config.palette;
let row_style = if row.focused {
@@ -355,9 +356,11 @@ pub(super) fn render_agent_row(
} else {
row.rows.clone()
};
let mut spinner_positions = Vec::new();
for (index, tokens) in rows.iter().take(rect.height as usize).enumerate() {
let indent = if index == 0 { 1 } else { 3 };
let mut spans = vec![ratatui::text::Span::raw(" ".repeat(indent))];
let mut state_icon_offsets = Vec::new();
spans.extend(crate::ui::resolved_token_spans(
tokens,
icon,
@@ -367,12 +370,32 @@ pub(super) fn render_agent_row(
secondary,
palette,
rect.width.saturating_sub(indent as u16) as usize,
Some(&mut state_icon_offsets),
));
spinner_positions.extend(
state_icon_offsets
.into_iter()
.filter_map(|offset| u16::try_from(offset).ok())
.map(|offset| {
(
rect.x.saturating_add(indent as u16).saturating_add(offset),
rect.y + index as u16,
)
}),
);
Paragraph::new(Line::from(spans)).style(row_style).render(
Rect::new(rect.x, rect.y + index as u16, rect.width, 1),
buffer,
);
}
super::render::record_animated_status_cells(
buffer,
hits,
row.status,
config.status_indicators,
spinner_frame,
spinner_positions,
);
}
fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) {
+17 -52
View File
@@ -42,61 +42,26 @@ impl ClientShellState {
}
let snapshot = self.snapshot.as_deref()?;
let surface = self.pane_surface.as_ref()?;
if snapshot.revision != surface.projection_revision {
if snapshot.revision != surface.projection_revision
|| self.hits.animated_status_cells.is_empty()
{
return None;
}
let sidebar = self.layout(cols, rows).sidebar;
if sidebar.is_empty() {
return None;
}
let mut buffer = Buffer::empty(sidebar);
let mut hits = ShellHitMap::default();
let mut render_state = render::ShellRenderState {
endpoints: &self.endpoints,
active_endpoint_id: &self.active_endpoint_id,
collapsed_endpoints: &self.collapsed_endpoints,
collapsed_groups: &self.collapsed_groups,
remote_collapsed_groups: &self.remote_collapsed_groups,
workspace_scroll: &mut self.workspace_scroll,
agent_scroll: &mut self.agent_scroll,
tab_scroll: &mut self.tab_scroll,
reveal_focused_workspace: &mut self.reveal_focused_workspace,
reveal_focused_tab: &mut self.reveal_focused_tab,
sidebar_collapsed: self.sidebar_collapsed,
sidebar_section_split: self.sidebar_section_split,
tab_drag_insert_index: None,
selected_workspace_id: None,
reveal_navigation_workspace: &mut self.reveal_navigation_workspace,
dragged_workspace_id: None,
workspace_drop_indicator_row: None,
spinner_frame: Some(self.spinner_frame),
};
render::render_shell_sidebar(
&mut buffer,
sidebar,
snapshot,
&self.config,
&mut render_state,
&mut hits,
);
let rows = (sidebar.y..sidebar.bottom())
.map(|y| {
let cells = (sidebar.x..sidebar.right())
.map(|x| {
buffer
.cell((x, y))
.map(crate::protocol::CellData::from_ratatui_cell)
})
.collect::<Option<Vec<_>>>()?;
Some(crate::protocol::PaneSurfacePatchRow {
x: sidebar.x,
y,
cells,
})
let symbol = STATUS_SPINNER_FRAMES.get(self.spinner_frame)?;
let rows = self
.hits
.animated_status_cells
.iter()
.map(|spinner| {
let mut cell = spinner.cell.clone();
cell.symbol = (*symbol).to_owned();
crate::protocol::PaneSurfacePatchRow {
x: spinner.x,
y: spinner.y,
cells: vec![cell],
}
})
.collect::<Option<Vec<_>>>()?;
.collect();
Some(ClientComposedSurfacePatch {
rows,
cursor: None,
+32 -21
View File
@@ -16,31 +16,41 @@ pub(super) fn render_collapsed(
if row.agent.focused {
buffer.set_style(rect, Style::default().bg(config.palette.active_row_bg));
}
let initial = row.machine_label.chars().next().unwrap_or('?');
let initial = row.machine_label.chars().next().unwrap_or('?').to_string();
let initial_width = super::render::display_width(&initial)
.max(1)
.min(rect.width);
let style = Style::default()
.fg(if row.stale {
config.palette.overlay0
} else {
status_color(row.agent.status, &config.palette)
})
.add_modifier(if row.stale {
Modifier::DIM
} else {
Modifier::empty()
});
put_text(buffer, rect.x, rect.y, initial_width, &initial, style);
put_text(
buffer,
rect.x,
rect.x.saturating_add(initial_width),
rect.y,
rect.width,
&format!(
"{initial}{}",
status_icon(
row.agent.status,
config.status_indicators,
spinner_frame.filter(|_| !row.stale),
)
rect.width.saturating_sub(initial_width),
status_icon(
row.agent.status,
config.status_indicators,
spinner_frame.filter(|_| !row.stale),
),
Style::default()
.fg(if row.stale {
config.palette.overlay0
} else {
status_color(row.agent.status, &config.palette)
})
.add_modifier(if row.stale {
Modifier::DIM
} else {
Modifier::empty()
}),
style,
);
super::render::record_animated_status_cells(
buffer,
hits,
row.agent.status,
config.status_indicators,
spinner_frame.filter(|_| !row.stale),
(rect.width > initial_width).then_some((rect.x.saturating_add(initial_width), rect.y)),
);
hits.endpoint_agents
.push((rect, row.endpoint_id, row.agent.pane_id));
@@ -84,6 +94,7 @@ pub(super) fn render_expanded(
&row.agent,
config,
spinner_frame.filter(|_| !row.stale),
hits,
);
if row.stale {
buffer.set_style(
+16
View File
@@ -170,6 +170,15 @@ pub(super) fn render_collapsed(
})
.add_modifier(dim),
);
super::render::record_animated_status_cells(
buffer,
hits,
workspace.agent_status,
config.status_indicators,
state.spinner_frame.filter(|_| !stale),
(rect.width > number_width)
.then_some((rect.x.saturating_add(number_width), rect.y)),
);
hits.workspaces.push(WorkspaceHit {
rect,
endpoint_id: endpoint.endpoint_id.clone(),
@@ -441,6 +450,7 @@ pub(super) fn render_expanded(
let selected = state.selected_workspace_id.is_some_and(|target| {
target.matches(&endpoint.endpoint_id, &workspace.workspace_id)
});
let spinner_start = hits.animated_status_cells.len();
super::sidebar::render_workspace_rows(
buffer,
nested,
@@ -451,6 +461,7 @@ pub(super) fn render_expanded(
state
.spinner_frame
.filter(|_| endpoint.status == ClientEndpointStatus::Online),
hits,
),
entry,
tokens,
@@ -461,6 +472,11 @@ pub(super) fn render_expanded(
);
if selected && palette.selection_bg == ratatui::style::Color::Reset {
buffer.set_style(nested, Style::default().bg(palette.active_row_bg));
for spinner in &mut hits.animated_status_cells[spinner_start..] {
if let Some(cell) = buffer.cell((spinner.x, spinner.y)) {
spinner.cell = crate::protocol::CellData::from_ratatui_cell(cell);
}
}
}
if endpoint.status != ClientEndpointStatus::Online {
buffer.set_style(
+21 -60
View File
@@ -288,66 +288,28 @@ pub(super) fn render_shell_sidebar(
}
}
fn animated_status_visible(
snapshot: &ClientShellSnapshot,
state: &ShellRenderState<'_>,
hits: &ShellHitMap,
) -> bool {
let working = crate::api::schema::AgentStatus::Working;
let empty_collapsed_groups = HashSet::new();
let endpoint_snapshot = |endpoint_id: &ClientEndpointId| {
state
.endpoints
.iter()
.find(|endpoint| {
&endpoint.endpoint_id == endpoint_id
&& endpoint.status == ClientEndpointStatus::Online
pub(super) fn record_animated_status_cells(
buffer: &Buffer,
hits: &mut ShellHitMap,
status: crate::api::schema::AgentStatus,
indicators: crate::config::StatusIndicatorStyle,
spinner_frame: Option<usize>,
positions: impl IntoIterator<Item = (u16, u16)>,
) {
if status != crate::api::schema::AgentStatus::Working
|| indicators != crate::config::StatusIndicatorStyle::Animated
|| spinner_frame.is_none()
{
return;
}
hits.animated_status_cells
.extend(positions.into_iter().filter_map(|(x, y)| {
buffer.cell((x, y)).map(|cell| ClientAnimatedStatusCell {
x,
y,
cell: crate::protocol::CellData::from_ratatui_cell(cell),
})
.and_then(|endpoint| endpoint.snapshot.as_deref())
};
hits.workspaces.iter().any(|hit| {
endpoint_snapshot(&hit.endpoint_id).is_some_and(|snapshot| {
let collapsed_groups = if hit.endpoint_id.is_local() {
state.collapsed_groups
} else {
state
.remote_collapsed_groups
.get(&hit.endpoint_id)
.unwrap_or(&empty_collapsed_groups)
};
snapshot
.workspaces
.iter()
.find(|workspace| workspace.workspace_id == hit.workspace_id)
.is_some_and(|workspace| {
let status = if state.sidebar_collapsed {
workspace.agent_status
} else {
super::sidebar::displayed_workspace_status(
snapshot,
workspace,
collapsed_groups,
)
};
status == working
})
})
}) || hits.agents.iter().any(|(_, pane_id)| {
snapshot
.agents
.iter()
.any(|agent| agent.pane_id == *pane_id && agent.agent_status == working)
}) || hits
.endpoint_agents
.iter()
.any(|(_, endpoint_id, pane_id)| {
endpoint_snapshot(endpoint_id).is_some_and(|snapshot| {
snapshot
.agents
.iter()
.any(|agent| agent.pane_id == *pane_id && agent.agent_status == working)
})
})
}));
}
pub(super) fn render_shell(
@@ -389,7 +351,6 @@ pub(super) fn render_shell(
&mut hits,
);
}
hits.animated_status_visible = animated_status_visible(snapshot, &state, &hits);
if !config.mouse_capture {
hits.sidebar_divider = Rect::default();
hits.sidebar_section_divider = Rect::default();
+41 -3
View File
@@ -86,6 +86,14 @@ pub(crate) fn render_collapsed_sidebar(
status_icon(status, config.status_indicators, spinner_frame),
Style::default().fg(status_color(status, palette)),
);
super::render::record_animated_status_cells(
buffer,
hits,
status,
config.status_indicators,
spinner_frame,
(rect.width > 2).then_some((rect.x + 2, rect.y)),
);
hits.workspaces.push(WorkspaceHit {
rect,
endpoint_id: ClientEndpointId::Local,
@@ -153,6 +161,14 @@ pub(crate) fn render_collapsed_sidebar(
status_icon(agent.agent_status, config.status_indicators, spinner_frame),
Style::default().fg(status_color(agent.agent_status, palette)),
);
super::render::record_animated_status_cells(
buffer,
hits,
agent.agent_status,
config.status_indicators,
spinner_frame,
(rect.width > 2).then_some((rect.x + 2, rect.y)),
);
hits.agents.push((rect, pane_id));
}
hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 {
@@ -311,7 +327,7 @@ pub(crate) fn render_sidebar(
rect,
workspace,
status,
(config.status_indicators, state.spinner_frame),
(config.status_indicators, state.spinner_frame, hits),
entry,
rows,
true,
@@ -643,7 +659,11 @@ pub(in crate::client::shell) fn render_workspace_rows(
area: Rect,
workspace: &ClientShellWorkspace,
status: crate::api::schema::AgentStatus,
indicators: (crate::config::StatusIndicatorStyle, Option<usize>),
indicators: (
crate::config::StatusIndicatorStyle,
Option<usize>,
&mut ShellHitMap,
),
entry: &WorkspaceEntry,
rows: Vec<Vec<crate::ui::ResolvedToken>>,
endpoint_active: bool,
@@ -651,6 +671,8 @@ pub(in crate::client::shell) fn render_workspace_rows(
dragged: bool,
palette: &Palette,
) {
let (indicator_style, spinner_frame, hits) = indicators;
let mut spinner_positions = Vec::new();
for (row_index, row) in rows.iter().enumerate() {
let y = area.y + row_index as u16;
if y >= area.bottom() {
@@ -699,10 +721,11 @@ pub(in crate::client::shell) fn render_workspace_rows(
} else {
palette.overlay0
});
let mut state_icon_offsets = Vec::new();
let spans = crate::ui::resolved_token_spans(
row,
(
status_icon(status, indicators.0, indicators.1),
status_icon(status, indicator_style, spinner_frame),
Style::default().fg(status_color(status, palette)),
),
Style::default().fg(status_color(status, palette)),
@@ -711,6 +734,13 @@ pub(in crate::client::shell) fn render_workspace_rows(
Style::default().fg(palette.overlay1),
palette,
area.right().saturating_sub(2).saturating_sub(x) as usize,
Some(&mut state_icon_offsets),
);
spinner_positions.extend(
state_icon_offsets
.into_iter()
.filter_map(|offset| u16::try_from(offset).ok())
.map(|offset| (x.saturating_add(offset), y)),
);
Paragraph::new(Line::from(spans)).render(
Rect::new(x, y, area.right().saturating_sub(2).saturating_sub(x), 1),
@@ -734,4 +764,12 @@ pub(in crate::client::shell) fn render_workspace_rows(
}
}
}
super::render::record_animated_status_cells(
buffer,
hits,
status,
indicator_style,
spinner_frame,
spinner_positions,
);
}
+8 -2
View File
@@ -152,7 +152,7 @@ pub(super) struct ShellHitMap {
pub(super) pane_splits: Vec<PaneSplitHit>,
pub(super) agents: Vec<(Rect, String)>,
pub(super) endpoint_agents: Vec<(Rect, ClientEndpointId, String)>,
pub(super) animated_status_visible: bool,
pub(super) animated_status_cells: Vec<ClientAnimatedStatusCell>,
pub(super) agent_body: Rect,
pub(super) agent_scrollbar: Rect,
pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>,
@@ -196,6 +196,12 @@ pub(super) struct ShellHitMap {
pub(super) release_notes_max_scroll: usize,
}
pub(super) struct ClientAnimatedStatusCell {
pub(super) x: u16,
pub(super) y: u16,
pub(super) cell: crate::protocol::CellData,
}
#[derive(Clone)]
pub(super) struct PaneHit {
pub(super) rect: Rect,
@@ -1919,7 +1925,7 @@ impl ClientShellState {
&& self
.last_composed_size
.is_some_and(|(cols, rows)| self.layout(cols, rows).sidebar.width > 0)
&& self.hits.animated_status_visible
&& !self.hits.animated_status_cells.is_empty()
}
fn sync_spinner_deadline(&mut self, now: std::time::Instant) {
@@ -638,6 +638,7 @@ fn animated_status_advances_only_on_its_desktop_deadline() {
projected.workspaces[0].agent_status = AgentStatus::Working;
let mut config = Config::default();
config.ui.status_indicators = Animated;
config.ui.sidebar.agents.rows = vec![vec![crate::config::AgentSidebarToken::StateIcon; 16]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
state.set_snapshot(Box::new(projected.clone()));
state.set_pane_surface(surface());
@@ -663,6 +664,7 @@ fn animated_status_advances_only_on_its_desktop_deadline() {
let patch = state
.compose_spinner_patch(106, 30)
.expect("animated sidebar patch");
assert!(patch.rows.iter().all(|row| row.cells.len() == 1));
let patched = apply_composed_surface_patch(&first, patch).expect("applicable sidebar patch");
assert_eq!((state.workspace_scroll, state.agent_scroll), scroll_state);
let second = full_state.compose(106, 30).expect("advanced full frame");
+30
View File
@@ -109,6 +109,36 @@ fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) {
(state, remote)
}
#[test]
fn collapsed_wide_machine_initial_records_the_spinner_cell() {
let (mut state, remote) = state_with_remote();
state.config.status_indicators = crate::config::StatusIndicatorStyle::Animated;
state.sidebar_collapsed = true;
let endpoint = state
.endpoints
.iter_mut()
.find(|endpoint| endpoint.endpoint_id == remote)
.expect("remote endpoint");
endpoint.label = "東京".into();
let snapshot = endpoint.snapshot.as_mut().expect("remote snapshot");
snapshot.workspaces[0].agent_status = AgentStatus::Working;
snapshot.agents = vec![agent("worker", AgentStatus::Working, 1)];
state.compose(100, 28).expect("collapsed endpoint sidebar");
let rect = state
.hits
.endpoint_agents
.iter()
.find(|(_, endpoint_id, _)| endpoint_id == &remote)
.expect("remote agent row")
.0;
assert!(state
.hits
.animated_status_cells
.iter()
.any(|spinner| spinner.x == rect.x + 2 && spinner.y == rect.y));
}
#[test]
fn switching_machines_preserves_aggregate_agent_scroll_and_visible_rows() {
let (mut state, remote) = state_with_scrollable_agents();
+16 -4
View File
@@ -112,6 +112,7 @@ pub(crate) fn resolved_token_spans(
custom_style: Style,
palette: &Palette,
max_width: usize,
mut state_icon_offsets: Option<&mut Vec<usize>>,
) -> Vec<Span<'static>> {
let fixed_widths = resolved
.iter()
@@ -223,10 +224,21 @@ pub(crate) fn resolved_token_spans(
));
}
match &token.kind {
ResolvedTokenKind::StateIcon => spans.push(Span::styled(
state_icon.0.to_string(),
apply_token_style(state_icon.1, token.style),
)),
ResolvedTokenKind::StateIcon => {
if let Some(offsets) = state_icon_offsets.as_deref_mut() {
let offset = spans
.iter()
.map(|span| display_width(span.content.as_ref()))
.sum::<usize>();
if offset.saturating_add(fixed_widths[index]) <= max_width {
offsets.push(offset);
}
}
spans.push(Span::styled(
state_icon.0.to_string(),
apply_token_style(state_icon.1, token.style),
));
}
ResolvedTokenKind::StateText(text) => spans.push(Span::styled(
truncate_end(text, budgets[index]),
apply_token_style(state_text_style, token.style),
+1
View File
@@ -283,6 +283,7 @@ rows = [[{ token = "workspace", rules = [{ equals = "long-workspace-name", fg =
theme,
&super::super::Palette::catppuccin(),
width,
None,
);
assert_eq!(spans.len(), 1);
assert!(super::super::display_width(&spans[0].content) <= width);