aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/overlays.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/shell/overlays.rs')
-rw-r--r--src/client/shell/overlays.rs253
1 files changed, 167 insertions, 86 deletions
diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs
index 07417bd0..c8eee5f4 100644
--- a/src/client/shell/overlays.rs
+++ b/src/client/shell/overlays.rs
@@ -13,6 +13,8 @@ pub(crate) struct OverlayRender {
pub(crate) navigator_popup: Rect,
pub(crate) navigator_search: Rect,
pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
+ pub(crate) navigator_scrollbar: Rect,
+ pub(crate) navigator_scroll_metrics: Option<crate::pane::ScrollMetrics>,
pub(crate) worktree_search: Rect,
pub(crate) worktree_rows: Vec<(Rect, usize)>,
pub(crate) help_popup: Rect,
@@ -685,24 +687,6 @@ fn render_rename_overlay(
})
}
-/// Mark following siblings in preorder without rescanning descendants for each row.
-/// The reverse stack contains at most one entry per depth; every entry is pushed
-/// and popped at most once, so this pass is linear in the number of rows.
-fn navigator_following_siblings(rows: &[ClientNavigatorRow]) -> Vec<bool> {
- let mut following = vec![false; rows.len()];
- let mut depths = Vec::new();
- for (index, row) in rows.iter().enumerate().rev() {
- while depths.last().is_some_and(|depth| *depth > row.depth) {
- depths.pop();
- }
- following[index] = depths.last() == Some(&row.depth);
- if !following[index] {
- depths.push(row.depth);
- }
- }
- following
-}
-
fn render_navigator_overlay(
b: &mut Buffer,
n: &ClientNavigatorOverlay,
@@ -711,16 +695,27 @@ fn render_navigator_overlay(
p: &Palette,
) -> Option<OverlayRender> {
let a = b.area;
- let mx = (a.width / 16).max(2);
- let my = (a.height / 10).max(1);
+ let width = a.width.saturating_sub(4).min(116);
+ let height = a.height.saturating_sub(2).min(42);
+ if width < 4 || height < 9 {
+ return None;
+ }
let q = Rect::new(
- a.x + mx,
- a.y + my,
- a.width.saturating_sub(mx * 2).max(4),
- a.height.saturating_sub(my * 2).max(4),
+ a.x + (a.width - width) / 2,
+ a.y + (a.height - height) / 2,
+ width,
+ height,
)
.intersection(a);
let i = panel(b, q, p.accent, p.panel_bg)?;
+ put_text(
+ b,
+ q.x + 2,
+ q.y,
+ q.width.saturating_sub(4),
+ " Go to ",
+ Style::default().fg(p.accent).bg(p.panel_bg),
+ );
let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n);
let search = if n.search_focused {
" / ".to_owned()
@@ -735,26 +730,32 @@ fn render_navigator_overlay(
}
)
} else if n.query.is_empty() {
- " / search panes".to_owned()
+ " / search agents and terminals".to_owned()
} else {
format!(" / {}", n.query)
};
+ let terminal_count = rows
+ .iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .count();
+ let count = format!(
+ "{terminal_count} {}",
+ if terminal_count == 1 {
+ "terminal"
+ } else {
+ "terminals"
+ }
+ );
put_text(
b,
i.x,
i.y,
- i.width,
+ i.width.saturating_sub(display_width(&count) + 1),
&search,
Style::default()
.fg(if n.search_focused { p.text } else { p.overlay0 })
.bg(p.panel_bg),
);
- let count = format!(
- "{} panes",
- rows.iter()
- .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
- .count()
- );
let cursor = if n.search_focused {
text_editor::render(
b,
@@ -785,7 +786,7 @@ fn render_navigator_overlay(
&"─".repeat(i.width as usize),
Style::default().fg(p.surface1).bg(p.panel_bg),
);
- let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4));
+ let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(5));
let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0);
let max = rows.len().saturating_sub(body.height as usize);
let scroll = n
@@ -793,17 +794,32 @@ fn render_navigator_overlay(
.max(selected.saturating_sub(body.height.saturating_sub(1) as usize))
.min(selected)
.min(max);
- let following_siblings = navigator_following_siblings(&rows);
- let mut ancestor_siblings = Vec::new();
- let federated = endpoints.len() > 1;
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: max.saturating_sub(scroll),
+ max_offset_from_bottom: max,
+ viewport_rows: usize::from(body.height),
+ };
+ let scrollbar =
+ (max > 0 && body.width > 1).then_some(Rect::new(body.right() - 1, body.y, 1, body.height));
+ let row_width = body.width.saturating_sub(u16::from(scrollbar.is_some()));
let mut row_hits = Vec::new();
- for (ix, r) in rows.iter().enumerate().take(scroll + body.height as usize) {
- ancestor_siblings.truncate(usize::from(r.depth));
- ancestor_siblings.push(following_siblings[ix]);
- if ix < scroll {
- continue;
- }
- let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, body.width, 1);
+ if rows.is_empty() {
+ put_text(
+ b,
+ body.x,
+ body.y,
+ body.width,
+ " No matching agents or terminals",
+ Style::default().fg(p.overlay0).bg(p.panel_bg),
+ );
+ }
+ for (ix, r) in rows
+ .iter()
+ .enumerate()
+ .skip(scroll)
+ .take(body.height as usize)
+ {
+ let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, row_width, 1);
row_hits.push((rect, r.target.clone()));
let st = if r.stale {
Style::default()
@@ -821,50 +837,74 @@ fn render_navigator_overlay(
.add_modifier(Modifier::BOLD)
} else {
Style::default()
- .fg(if r.current { p.text } else { p.subtext0 })
+ .fg(
+ if matches!(r.target, ClientNavigatorTarget::Machine { .. }) {
+ p.subtext0
+ } else {
+ p.text
+ },
+ )
.bg(p.panel_bg)
};
- b.set_style(rect, st);
- let tree = match &r.target {
- ClientNavigatorTarget::Machine { .. } => "▾".to_owned(),
- ClientNavigatorTarget::Workspace {
- endpoint_id,
- workspace_id,
- } if n
- .expanded_workspaces
- .contains(&(endpoint_id.clone(), workspace_id.clone())) =>
- {
- if r.depth == 0 { "▾" } else { " ▾" }.to_owned()
- }
- ClientNavigatorTarget::Workspace { .. } => {
- if r.depth == 0 { "▸" } else { " ▸" }.to_owned()
- }
- ClientNavigatorTarget::Tab { .. } | ClientNavigatorTarget::Pane { .. } => {
- // Machines and workspaces keep their existing caret decoration.
- // Connected branches begin below each workspace.
- let mut prefix = if federated { " " } else { "" }.to_owned();
- for &following in ancestor_siblings
- .iter()
- .take(usize::from(r.depth))
- .skip(if federated { 2 } else { 1 })
- {
- prefix.push_str(if following { "│ " } else { " " });
- }
- prefix.push_str(if following_siblings[ix] {
- "├──"
- } else {
- "└──"
- });
- prefix
- }
+ let is_pane = matches!(r.target, ClientNavigatorTarget::Pane { .. });
+ let connector = if !is_pane {
+ ""
+ } else if rows
+ .get(ix + 1)
+ .is_some_and(|next| matches!(next.target, ClientNavigatorTarget::Pane { .. }))
+ {
+ "├─ "
+ } else {
+ "└─ "
};
+ let padding = u16::from(r.depth.saturating_sub(u8::from(is_pane))) * 2 + 1;
+ let connector_x = rect.x + padding;
+ let indent = format!("{:width$}{connector}", "", width = usize::from(padding));
let current = if r.current { "◆ " } else { "" };
let status = r.status.map(status_dot).unwrap_or_default();
let status_separator = if status.is_empty() { "" } else { " " };
- let label = format!(" {tree} {current}{status}{status_separator}{}", r.label);
- put_text(b, rect.x, rect.y, rect.width, &label, st);
+ let label = format!("{indent}{current}{status}{status_separator}{}", r.label);
+ let st = if r.status.is_none() {
+ st.add_modifier(Modifier::BOLD)
+ } else {
+ st
+ };
+ b.set_style(rect, st);
+ let columns = if r.status.is_some() {
+ if rect.width >= 64 {
+ 24
+ } else if rect.width >= 36 {
+ 12
+ } else {
+ 0
+ }
+ } else {
+ 0
+ };
+ put_text(
+ b,
+ rect.x,
+ rect.y,
+ rect.width.saturating_sub(columns),
+ &label,
+ st,
+ );
+ if is_pane {
+ put_text(
+ b,
+ connector_x,
+ rect.y,
+ rect.right().saturating_sub(connector_x).min(2),
+ connector,
+ if r.stale || ix == selected {
+ st
+ } else {
+ st.fg(p.overlay0)
+ },
+ );
+ }
if let Some(status) = r.status {
- let prefix = format!(" {tree} {current}");
+ let prefix = format!("{indent}{current}");
let status_style = if r.stale || ix == selected {
st
} else {
@@ -878,6 +918,35 @@ fn render_navigator_overlay(
status_dot(status),
status_style,
);
+ let meta_style = if r.stale || ix == selected {
+ st
+ } else {
+ st.fg(p.overlay0)
+ };
+ if columns > 0 {
+ put_text(
+ b,
+ rect.right() - columns + 1,
+ rect.y,
+ 11,
+ r.agent.as_deref().unwrap_or("terminal"),
+ meta_style,
+ );
+ }
+ if columns == 24 {
+ put_text(
+ b,
+ rect.right() - 11,
+ rect.y,
+ 11,
+ if r.agent.is_some() {
+ status_text(status)
+ } else {
+ "shell"
+ },
+ meta_style,
+ );
+ }
}
let machine_status = match &r.target {
ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints
@@ -906,7 +975,7 @@ fn render_navigator_overlay(
})
};
put_right_text(b, rect, rect.y, &signal, signal_style);
- } else if !r.meta.is_empty() {
+ } else if r.status.is_none() && !r.meta.is_empty() {
let label_width = display_width(&label).min(rect.width);
let meta = Rect::new(
rect.x.saturating_add(label_width).saturating_add(1),
@@ -917,16 +986,26 @@ fn render_navigator_overlay(
put_right_text(b, meta, rect.y, &r.meta, st)
}
}
- let dy = i.bottom() - 2;
+ if let Some(track) = scrollbar {
+ crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐");
+ }
if let Some(r) = rows.get(selected) {
put_text(
b,
i.x,
- dy,
+ i.bottom() - 3,
+ i.width,
+ &format!(" {}", r.detail),
+ Style::default().fg(p.subtext0).bg(p.panel_bg),
+ );
+ put_text(
+ b,
+ i.x,
+ i.bottom() - 2,
i.width,
- &format!(" {} · {}", r.label, r.meta),
+ &format!(" {}", r.meta),
Style::default().fg(p.overlay0).bg(p.panel_bg),
- )
+ );
}
put_text(
b,
@@ -936,7 +1015,7 @@ fn render_navigator_overlay(
if n.search_focused {
" search type · move ↑↓/ctrl+n/p · open enter · back esc"
} else {
- " move j/k · expand space · filter a/b/w/i/d · search / · open enter · close esc"
+ " ↑↓/j/k rows · ←→ workspace · / search · a/b/w/i/d filter · enter open · esc close"
},
Style::default().fg(p.overlay0).bg(p.panel_bg),
);
@@ -948,6 +1027,8 @@ fn render_navigator_overlay(
navigator_popup: q,
navigator_search: Rect::new(i.x, i.y, i.width, 1),
navigator_rows: row_hits,
+ navigator_scrollbar: scrollbar.unwrap_or_default(),
+ navigator_scroll_metrics: Some(metrics),
worktree_search: Rect::default(),
worktree_rows: Vec::new(),
cursor,