diff options
Diffstat (limited to 'src/client/shell/overlays.rs')
| -rw-r--r-- | src/client/shell/overlays.rs | 1169 |
1 files changed, 1169 insertions, 0 deletions
diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs new file mode 100644 index 00000000..30ac78b9 --- /dev/null +++ b/src/client/shell/overlays.rs @@ -0,0 +1,1169 @@ +use super::*; + +mod settings_overlay; +mod worktree_overlays; + +#[derive(Default)] +pub(crate) struct OverlayRender { + pub(crate) primary: Rect, + pub(crate) clear: Rect, + pub(crate) cancel: Rect, + pub(crate) navigator_popup: Rect, + pub(crate) navigator_search: Rect, + pub(crate) navigator_rows: Vec<(Rect, usize)>, + pub(crate) worktree_search: Rect, + pub(crate) worktree_rows: Vec<(Rect, usize)>, + pub(crate) help_popup: Rect, + pub(crate) help_scrollbar: Rect, + pub(crate) help_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) help_max_scroll: usize, + pub(crate) settings_popup: Rect, + pub(crate) settings_tabs: Vec<(Rect, ClientSettingsSection)>, + pub(crate) settings_choices: Vec<(Rect, usize)>, + pub(crate) product_announcement_scrollbar: Rect, + pub(crate) product_announcement_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) product_announcement_max_scroll: usize, + pub(crate) release_notes_scrollbar: Rect, + pub(crate) release_notes_scroll_metrics: Option<crate::pane::ScrollMetrics>, + pub(crate) release_notes_max_scroll: usize, + pub(crate) cursor: Option<crate::protocol::CursorState>, +} + +pub(crate) fn render_client_overlay( + b: &mut Buffer, + o: &ClientShellOverlay, + s: &ClientShellSnapshot, + k: &LiveKeybindConfig, + p: &Palette, +) -> Option<OverlayRender> { + if !matches!( + o, + ClientShellOverlay::Navigator(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ) { + for y in b.area.y..b.area.bottom() { + for x in b.area.x..b.area.right() { + let c = &mut b[(x, y)]; + c.set_style(c.style().add_modifier(Modifier::DIM)); + } + } + } + match o { + ClientShellOverlay::Onboarding => render_onboarding_overlay(b, p), + ClientShellOverlay::ProductAnnouncement(v) => render_product_announcement_overlay(b, v, p), + ClientShellOverlay::ReleaseNotes(v) => { + render_release_notes_overlay(b, v, &s.update_install_command, p) + } + ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p), + ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p), + ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p), + ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p), + ClientShellOverlay::Settings(v) => { + settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p) + } + ClientShellOverlay::WorktreeCreate(v) => { + worktree_overlays::render_worktree_create_overlay(b, v, p) + } + ClientShellOverlay::WorktreeOpen(v) => { + worktree_overlays::render_worktree_open_overlay(b, v, p) + } + ClientShellOverlay::WorktreeRemove(v) => { + worktree_overlays::render_worktree_remove_overlay(b, v, p) + } + ClientShellOverlay::ContextMenu(_) | ClientShellOverlay::GlobalMenu(_) => None, + } +} + +pub(crate) fn render_global_menu( + buffer: &mut Buffer, + launcher: Rect, + menu: &ClientGlobalMenuOverlay, + snapshot: &ClientShellSnapshot, + palette: &Palette, +) -> Option<Vec<(Rect, usize)>> { + let items = super::super::global_menu::global_menu_items(snapshot); + let screen = buffer.area; + let width = items + .iter() + .map(|(label, action)| { + display_width(label) + + u16::from(super::super::global_menu::global_menu_item_has_badge( + snapshot, *action, + )) * 2 + }) + .max() + .unwrap_or(8) + .saturating_add(4) + .min(screen.width.max(1)); + let height = (items.len() as u16) + .saturating_add(2) + .min(screen.height.max(1)); + let x = launcher + .right() + .saturating_sub(width) + .min(screen.right().saturating_sub(width)); + let y = launcher.y.saturating_sub(height).max(screen.y); + let inner = panel( + buffer, + Rect::new(x, y, width, height), + palette.accent, + palette.panel_bg, + )?; + let mut rows = Vec::new(); + for (index, (label, action)) in items.iter().enumerate() { + let row_y = inner.y.saturating_add(index as u16); + if row_y >= inner.bottom() { + break; + } + let row = Rect::new(inner.x, row_y, inner.width, 1); + let highlighted = index == menu.highlighted; + let style = if highlighted { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + }; + buffer.set_style(row, style); + let has_badge = super::super::global_menu::global_menu_item_has_badge(snapshot, *action); + if has_badge { + let badge_style = if highlighted { + style + } else { + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD) + }; + put_text(buffer, row.x, row.y, row.width.min(2), " ●", badge_style); + put_text( + buffer, + row.x.saturating_add(2), + row.y, + row.width.saturating_sub(2), + &format!(" {label}"), + style, + ); + } else { + put_text(buffer, row.x, row.y, row.width, &format!(" {label}"), style); + } + rows.push((row, index)); + } + Some(rows) +} + +pub(crate) fn render_context_menu( + buffer: &mut Buffer, + menu: &ClientContextMenuOverlay, + palette: &Palette, +) -> Option<Vec<(Rect, usize)>> { + let items = menu.items(); + let screen = buffer.area; + let max_item_width = items + .iter() + .map(|item| display_width(item.label)) + .max() + .unwrap_or(0); + let width = max_item_width + .saturating_add(4) + .max(14) + .min(screen.width.max(1)); + let height = (items.len() as u16) + .saturating_add(2) + .min(screen.height.max(1)); + let x = menu + .x + .min(screen.x.saturating_add(screen.width.saturating_sub(width))); + let y = menu.y.min( + screen + .y + .saturating_add(screen.height.saturating_sub(height)), + ); + let rect = Rect::new(x, y, width, height); + let inner = panel(buffer, rect, palette.accent, palette.panel_bg)?; + let mut rows = Vec::new(); + for (index, item) in items.iter().enumerate() { + let row_y = inner.y.saturating_add(index as u16); + if row_y >= inner.bottom() { + break; + } + let row = Rect::new(inner.x, row_y, inner.width, 1); + let highlighted = index == menu.highlighted; + let style = if highlighted { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + }; + buffer.set_style(row, style); + put_text(buffer, row.x, row.y, row.width, item.label, style); + rows.push((row, index)); + } + Some(rows) +} + +fn panel( + b: &mut Buffer, + a: Rect, + c: ratatui::style::Color, + bg: ratatui::style::Color, +) -> Option<Rect> { + if a.width < 2 || a.height < 2 { + return None; + } + let background = Style::default().bg(bg).remove_modifier(Modifier::DIM); + let border = Style::default().fg(c).bg(bg).remove_modifier(Modifier::DIM); + for y in a.y..a.bottom() { + for x in a.x..a.right() { + b[(x, y)].set_symbol(" ").set_style(background); + } + } + for x in a.x..a.right() { + b[(x, a.y)] + .set_symbol(if x == a.x { + "┌" + } else if x + 1 == a.right() { + "┐" + } else { + "─" + }) + .set_style(border); + let y = a.bottom() - 1; + b[(x, y)] + .set_symbol(if x == a.x { + "└" + } else if x + 1 == a.right() { + "┘" + } else { + "─" + }) + .set_style(border); + } + for y in a.y + 1..a.bottom() - 1 { + b[(a.x, y)].set_symbol("│").set_style(border); + b[(a.right() - 1, y)].set_symbol("│").set_style(border); + } + Some(Rect::new(a.x + 1, a.y + 1, a.width - 2, a.height - 2)) +} +fn popup(a: Rect, w: u16, h: u16) -> Option<Rect> { + let w = w.min(a.width.saturating_sub(4)); + let h = h.min(a.height.saturating_sub(2)); + if w < 4 || h < 4 { + return None; + } + Some(Rect::new( + a.x + (a.width - w) / 2, + a.y + (a.height - h) / 2, + w, + h, + )) +} +fn button(b: &mut Buffer, r: Rect, t: &str, s: Style) { + b.set_style(r, s); + let w = display_width(t).min(r.width); + put_text(b, r.x + (r.width - w) / 2, r.y, w, t, s) +} +fn row(i: Rect, ws: &[u16], gap: u16, off: u16) -> Vec<Rect> { + let total = ws.iter().sum::<u16>() + gap * (ws.len().saturating_sub(1) as u16); + let mut x = i.x + i.width.saturating_sub(total) / 2; + ws.iter() + .map(|w| { + let r = Rect::new( + x, + i.y + off.min(i.height.saturating_sub(1)), + (*w).min(i.width.saturating_sub(x - i.x)), + 1, + ); + x += *w + gap; + r + }) + .collect() +} +fn contrast(p: &Palette) -> ratatui::style::Color { + match p.panel_bg { + ratatui::style::Color::Reset => p.surface_dim, + c => c, + } +} +fn render_release_notes_overlay( + b: &mut Buffer, + notes: &crate::app::state::ReleaseNotesState, + install_command: &str, + p: &Palette, +) -> Option<OverlayRender> { + let outer = popup( + b.area, + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + )?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 8 || inner.width < 20 { + return Some(OverlayRender::default()); + } + + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let title_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y, + stack.header.width.saturating_sub(2), + 1, + ); + let subtitle_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y.saturating_add(1), + stack.header.width.saturating_sub(2), + 1, + ); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + put_text( + b, + title_area.x, + title_area.y, + title_area.width, + &format!("v{}", notes.version), + base.fg(p.text).add_modifier(Modifier::BOLD), + ); + put_text( + b, + subtitle_area.x, + subtitle_area.y, + subtitle_area.width, + if notes.preview { + "update ready" + } else { + "what's new in this release" + }, + base.fg(p.overlay1), + ); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + button( + b, + close, + " esc close ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + + let body = stack.content; + let lines = crate::ui::release_notes_display_lines(notes, install_command, p); + let metrics = crate::ui::release_notes_scroll_metrics(notes, install_command, body, p); + let max_scroll = metrics.max_offset_from_bottom; + let scroll = usize::from(notes.scroll).min(max_scroll); + let track = crate::ui::release_notes_scrollbar_rect(body, metrics); + let text_area = track + .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)) + .unwrap_or(body); + let paragraph = ratatui::widgets::Paragraph::new( + lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>(), + ) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + ratatui::widgets::Widget::render(paragraph, text_area, b); + if let Some(track) = track { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } + + if let Some(footer_area) = stack.footer { + let footer_line = ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)), + ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)), + ratatui::text::Span::styled(" · ", base.fg(p.overlay0)), + ratatui::text::Span::styled("close", base.fg(p.overlay0)), + ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)), + ]); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(footer_line), + footer_area, + b, + ); + } + + Some(OverlayRender { + primary: close, + release_notes_scrollbar: track.unwrap_or_default(), + release_notes_scroll_metrics: Some(metrics), + release_notes_max_scroll: max_scroll, + ..OverlayRender::default() + }) +} + +fn render_product_announcement_overlay( + b: &mut Buffer, + announcement: &crate::app::state::ProductAnnouncementState, + p: &Palette, +) -> Option<OverlayRender> { + let outer = popup( + b.area, + crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, + crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, + )?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 8 || inner.width < 20 { + return Some(OverlayRender::default()); + } + + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let title_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y, + stack.header.width.saturating_sub(2), + 1, + ); + let subtitle_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y.saturating_add(1), + stack.header.width.saturating_sub(2), + 1, + ); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + put_text( + b, + title_area.x, + title_area.y, + title_area.width, + &announcement.title, + base.fg(p.text).add_modifier(Modifier::BOLD), + ); + let subtitle = if announcement.preview { + "product announcement preview" + } else { + "product announcement" + }; + put_text( + b, + subtitle_area.x, + subtitle_area.y, + subtitle_area.width, + &format!("{subtitle} · v{}", announcement.version), + base.fg(p.overlay1), + ); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + button( + b, + close, + " esc close ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + + let body = stack.content; + let lines = crate::ui::product_announcement_display_lines(announcement, p); + let metrics = crate::ui::product_announcement_scroll_metrics(announcement, body, p); + let max_scroll = metrics.max_offset_from_bottom; + let scroll = usize::from(announcement.scroll).min(max_scroll); + let track = crate::ui::release_notes_scrollbar_rect(body, metrics); + let text_area = track + .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)) + .unwrap_or(body); + let paragraph = ratatui::widgets::Paragraph::new( + lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>(), + ) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + ratatui::widgets::Widget::render(paragraph, text_area, b); + if let Some(track) = track { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } + + if let Some(footer_area) = stack.footer { + let footer_line = ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)), + ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)), + ratatui::text::Span::styled(" · ", base.fg(p.overlay0)), + ratatui::text::Span::styled("close", base.fg(p.overlay0)), + ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)), + ]); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(footer_line), + footer_area, + b, + ); + } + + Some(OverlayRender { + primary: close, + product_announcement_scrollbar: track.unwrap_or_default(), + product_announcement_scroll_metrics: Some(metrics), + product_announcement_max_scroll: max_scroll, + ..OverlayRender::default() + }) +} + +fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option<OverlayRender> { + let outer = popup(b.area, 64, 16)?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 11 { + return Some(OverlayRender::default()); + } + let stack = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + let title = base.fg(p.text).add_modifier(Modifier::BOLD); + let muted = base.fg(p.overlay0); + let text = base.fg(p.overlay1); + let accent = base.fg(p.accent).add_modifier(Modifier::BOLD); + + put_text( + b, + stack.header.x, + stack.header.y, + stack.header.width, + crate::ui::ONBOARDING_TITLE, + title, + ); + put_text( + b, + stack.header.x, + stack.header.y.saturating_add(1), + stack.header.width, + crate::ui::ONBOARDING_SUBTITLE, + muted, + ); + + let content = stack.content; + for (offset, line) in crate::ui::ONBOARDING_DESCRIPTION.iter().enumerate() { + put_text( + b, + content.x, + content.y.saturating_add(offset as u16), + content.width, + line, + text, + ); + } + + let key_y = content.y.saturating_add(4); + let mut key_x = content.x; + for (value, style) in [ + (" ", base), + (crate::ui::ONBOARDING_PREFIX_LABEL, accent), + (crate::ui::ONBOARDING_PREFIX_SUFFIX, text), + (crate::ui::ONBOARDING_HELP_LABEL, accent), + (crate::ui::ONBOARDING_HELP_SUFFIX, text), + ] { + let width = display_width(value); + put_text( + b, + key_x, + key_y, + content.right().saturating_sub(key_x), + value, + style, + ); + key_x = key_x.saturating_add(width); + } + put_text( + b, + content.x, + content.y.saturating_add(5), + content.width, + crate::ui::ONBOARDING_NEXT, + text, + ); + + let primary = crate::ui::onboarding_welcome_continue_rect(stack.actions.unwrap_or_default()); + button( + b, + primary, + " ↵ continue ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + Some(OverlayRender { + primary, + ..OverlayRender::default() + }) +} + +fn render_rename_overlay( + b: &mut Buffer, + v: &ClientRenameOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let q = popup(b.area, 56, 7)?; + let i = panel(b, q, p.accent, p.panel_bg)?; + put_text( + b, + i.x, + i.y, + i.width, + v.title, + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let input = Rect::new(i.x, i.y + 2, i.width, 1); + b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); + put_text( + b, + input.x, + input.y, + input.width.saturating_sub(1), + &format!(" {}", v.input), + Style::default().fg(p.text).bg(p.surface0), + ); + let rs = row(i, &[8, 10, 12], 2, 3); + let [save, clear, cancel] = rs.as_slice() else { + return None; + }; + button( + b, + *save, + " ↵ save ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + let n = Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD); + button(b, *clear, " ^c clear ", n); + button(b, *cancel, " esc cancel ", n); + Some(OverlayRender { + primary: *save, + clear: *clear, + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: Some(crate::protocol::CursorState { + x: (input.x + 1 + display_width(&v.input)).min(input.right() - 1), + y: input.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(crate) fn client_navigator_rows( + s: &ClientShellSnapshot, + n: &ClientNavigatorOverlay, +) -> Vec<ClientNavigatorRow> { + let q = n.query.trim().to_lowercase(); + let filter = |st| match n.filter { + Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked, + Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working, + Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle, + Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done, + None => true, + }; + let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q); + let filtering = n.filter.is_some() || !q.is_empty(); + let mut out = Vec::new(); + for w in &s.workspaces { + let mut children = Vec::new(); + for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) { + let mut panes = Vec::new(); + for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() { + let a = s.agents.iter().find(|a| a.pane_id == p.pane_id); + let st = a + .map(|a| a.agent_status) + .unwrap_or(crate::api::schema::AgentStatus::Unknown); + let label = p + .label + .clone() + .or_else(|| a.and_then(|a| a.name.clone())) + .or_else(|| a.and_then(|a| a.display_agent.clone())) + .or_else(|| a.and_then(|a| a.title.clone())) + .unwrap_or_else(|| format!("pane {}", ix + 1)); + let meta = p + .foreground_cwd + .clone() + .or_else(|| p.cwd.clone()) + .unwrap_or_default(); + if !filtering || filter(st) && (text(&label) || text(&meta)) { + panes.push(ClientNavigatorRow { + depth: 2, + label, + meta, + status: st, + current: s.focused_pane_id.as_deref() == Some(&p.pane_id), + target: ClientNavigatorTarget::Pane(p.pane_id.clone()), + }) + } + } + if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() { + children.push(ClientNavigatorRow { + depth: 1, + label: t.label.clone(), + meta: format!( + "{} panes", + s.panes.iter().filter(|p| p.tab_id == t.tab_id).count() + ), + status: t.agent_status, + current: s.focused_tab_id.as_deref() == Some(&t.tab_id), + target: ClientNavigatorTarget::Tab(t.tab_id.clone()), + }); + children.extend(panes) + } + } + let wm = + filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text)); + if !filtering || wm || !children.is_empty() { + out.push(ClientNavigatorRow { + depth: 0, + label: w.label.clone(), + meta: w.branch.clone().unwrap_or_default(), + status: w.agent_status, + current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id), + target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()), + }); + if n.expanded_workspaces.contains(&w.workspace_id) || filtering { + out.extend(children) + } + } + } + out +} + +fn render_navigator_overlay( + b: &mut Buffer, + n: &ClientNavigatorOverlay, + s: &ClientShellSnapshot, + p: &Palette, +) -> Option<OverlayRender> { + let a = b.area; + let mx = (a.width / 16).max(2); + let my = (a.height / 10).max(1); + 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), + ); + let i = panel(b, q, p.accent, p.panel_bg)?; + let rows = client_navigator_rows(s, n); + let search = if n.search_focused { + format!(" / {}", n.query) + } else if let Some(f) = n.filter { + format!( + " / {}", + match f { + ClientNavigatorFilter::Blocked => "blocked", + ClientNavigatorFilter::Working => "working", + ClientNavigatorFilter::Idle => "idle", + ClientNavigatorFilter::Done => "done", + } + ) + } else if n.query.is_empty() { + " / search panes".to_owned() + } else { + format!(" / {}", n.query) + }; + put_text( + b, + i.x, + i.y, + i.width, + &search, + Style::default() + .fg(if n.search_focused { p.text } else { p.overlay0 }) + .bg(p.panel_bg), + ); + put_right_text( + b, + i, + i.y, + &format!("{} panes", s.panes.len()), + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + i.x, + i.y + 1, + i.width, + &"─".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 max = rows.len().saturating_sub(body.height as usize); + let scroll = n + .scroll + .max( + n.selected + .saturating_sub(body.height.saturating_sub(1) as usize), + ) + .min(n.selected) + .min(max); + let mut row_hits = Vec::new(); + for (vis, (ix, r)) in rows + .iter() + .enumerate() + .skip(scroll) + .take(body.height as usize) + .enumerate() + { + let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1); + row_hits.push((rect, ix)); + let st = if ix == n.selected { + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(if r.current { p.text } else { p.subtext0 }) + .bg(p.panel_bg) + }; + b.set_style(rect, st); + let tree = match r.depth { + 0 => match &r.target { + ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾", + _ => "▸", + }, + 1 => "└──", + _ => " └──", + }; + let label = format!( + " {} {} {} {}", + if r.current { "◆" } else { " " }, + tree, + status_dot(r.status), + r.label + ); + put_text(b, rect.x, rect.y, rect.width, &label, st); + if !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), + rect.y, + rect.width.saturating_sub(label_width.saturating_add(1)), + 1, + ); + put_right_text(b, meta, rect.y, &r.meta, st) + } + } + let dy = i.bottom() - 2; + if let Some(r) = rows.get(n.selected) { + put_text( + b, + i.x, + dy, + i.width, + &format!(" {} · {}", r.label, r.meta), + Style::default().fg(p.overlay0).bg(p.panel_bg), + ) + } + put_text( + b, + i.x, + i.bottom() - 1, + i.width, + 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" + }, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + Some(OverlayRender { + primary: Rect::default(), + clear: Rect::default(), + cancel: Rect::default(), + navigator_popup: q, + navigator_search: Rect::new(i.x, i.y, i.width, 1), + navigator_rows: row_hits, + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: n.search_focused.then(|| crate::protocol::CursorState { + x: i.x + 3 + display_width(&n.query), + y: i.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +fn help_lines( + keybinds: &LiveKeybindConfig, + query: &str, + palette: &Palette, +) -> Vec<(usize, ratatui::text::Line<'static>)> { + use ratatui::text::{Line, Span}; + + let groups = crate::input::filter_keybind_help_groups( + crate::input::keybind_help_groups(&keybinds.keybinds, keybinds.prefix), + query, + ); + let key_width = groups + .iter() + .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) + .max() + .unwrap_or(8); + if groups.is_empty() { + let message = " no matching keybinds"; + return vec![( + message.chars().count(), + Line::from(Span::styled( + message, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + )), + )]; + } + + let mut lines = Vec::new(); + for (group, entries) in groups { + lines.push(( + group.len() + 1, + Line::from(Span::styled( + format!(" {group}"), + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + )), + )); + for (key, label) in entries { + let padded_key = format!(" {key:<key_width$} "); + let width = padded_key.chars().count() + label.chars().count(); + lines.push(( + width, + Line::from(vec![ + Span::styled( + padded_key, + Style::default() + .fg(palette.mauve) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + label.into_owned(), + Style::default().fg(palette.text).bg(palette.panel_bg), + ), + ]), + )); + } + lines.push((0, Line::raw(""))); + } + lines +} + +fn render_help_overlay( + b: &mut Buffer, + h: &ClientHelpOverlay, + k: &LiveKeybindConfig, + p: &Palette, +) -> Option<OverlayRender> { + use ratatui::widgets::{Paragraph, Widget, Wrap}; + + let q = popup(b.area, 76, 22)?; + let i = panel(b, q, p.accent, p.panel_bg)?; + if i.width < 20 || i.height < 6 { + return None; + } + put_text( + b, + i.x, + i.y, + i.width, + "keybinds", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let close = Rect::new(i.right() - 13, i.y, 13, 1); + button( + b, + close, + if h.search_focused { + " esc back " + } else { + " esc close " + }, + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + let sy = i.y + 1; + put_text( + b, + i.x, + sy, + i.width, + &if h.search_focused { + format!(" / {}", h.query) + } else { + " / press / to filter by command or shortcut".to_owned() + }, + Style::default() + .fg(if h.search_focused { p.text } else { p.overlay0 }) + .bg(p.panel_bg), + ); + + let body = Rect::new(i.x, i.y + 3, i.width, i.height.saturating_sub(5)); + let lines = help_lines(k, &h.query, p); + let viewport_rows = usize::from(body.height.max(1)); + let wrapped_rows = |width: u16| { + let width = usize::from(width.max(1)); + lines + .iter() + .map(|(line_width, _)| line_width.max(&1).div_ceil(width)) + .sum::<usize>() + }; + let needs_scrollbar = wrapped_rows(body.width) > viewport_rows; + let text_area = if needs_scrollbar { + Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height) + } else { + body + }; + let total_rows = wrapped_rows(text_area.width); + let max_scroll = total_rows.saturating_sub(viewport_rows); + let scroll = h.scroll.min(max_scroll); + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: max_scroll.saturating_sub(scroll), + max_offset_from_bottom: max_scroll, + viewport_rows, + }; + let scrollbar = needs_scrollbar.then_some(Rect::new( + body.right().saturating_sub(1), + body.y, + 1, + body.height, + )); + Widget::render( + Paragraph::new(lines.into_iter().map(|(_, line)| line).collect::<Vec<_>>()) + .wrap(Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)), + text_area, + b, + ); + if let Some(track) = scrollbar { + if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) { + for y in track.y..track.bottom() { + b[(track.x, y)] + .set_symbol("▐") + .set_style(Style::default().fg(p.overlay0).bg(p.panel_bg)); + } + for y in thumb.top..thumb.top.saturating_add(thumb.len) { + b[(track.x, y)] + .set_symbol("▐") + .set_style(Style::default().fg(p.overlay1).bg(p.panel_bg)); + } + } + } + + put_text( + b, + i.x, + i.bottom() - 1, + i.width, + if h.search_focused { + " filter type/backspace · clear ctrl+u · scroll ↑↓/pgup/pgdn · back esc" + } else { + " search / · scroll j/k/↑↓/pgup/pgdn · close esc/enter" + }, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + Some(OverlayRender { + cancel: close, + help_popup: q, + help_scrollbar: scrollbar.unwrap_or_default(), + help_scroll_metrics: Some(metrics), + help_max_scroll: max_scroll, + cursor: h.search_focused.then(|| crate::protocol::CursorState { + x: (i.x + 3 + display_width(&h.query)).min(i.right() - 1), + y: sy, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} +fn render_confirm_close_overlay( + b: &mut Buffer, + c: &ClientConfirmCloseOverlay, + p: &Palette, +) -> Option<OverlayRender> { + let q = popup(b.area, 64, 6)?; + let i = panel(b, q, p.red, p.panel_bg)?; + put_text( + b, + i.x, + i.y, + i.width, + &format!(" {}", c.title), + Style::default() + .fg(p.red) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + i.x, + i.y + 1, + i.width, + &format!(" {}", c.detail), + Style::default().fg(p.text).bg(p.panel_bg), + ); + let rs = row(i, &[13, 12], 2, 3); + let [ok, cancel] = rs.as_slice() else { + return None; + }; + button( + b, + *ok, + " ↵ confirm ", + Style::default() + .fg(contrast(p)) + .bg(p.red) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *ok, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: None, + ..OverlayRender::default() + }) +} |
