diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/ui | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/dialogs.rs | 1158 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 426 | ||||
| -rw-r--r-- | src/ui/menus.rs | 315 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 1626 | ||||
| -rw-r--r-- | src/ui/navigator.rs | 643 | ||||
| -rw-r--r-- | src/ui/onboarding.rs | 127 | ||||
| -rw-r--r-- | src/ui/panes.rs | 306 | ||||
| -rw-r--r-- | src/ui/release_notes.rs | 289 | ||||
| -rw-r--r-- | src/ui/scrollbar.rs | 50 | ||||
| -rw-r--r-- | src/ui/settings.rs | 424 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 3042 | ||||
| -rw-r--r-- | src/ui/sidebar/tokens.rs | 131 | ||||
| -rw-r--r-- | src/ui/status.rs | 294 | ||||
| -rw-r--r-- | src/ui/tab_surface.rs | 108 | ||||
| -rw-r--r-- | src/ui/tabs.rs | 788 | ||||
| -rw-r--r-- | src/ui/text.rs | 42 | ||||
| -rw-r--r-- | src/ui/widgets.rs | 247 |
17 files changed, 374 insertions, 9642 deletions
diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs deleted file mode 100644 index ff6ced3d..00000000 --- a/src/ui/dialogs.rs +++ /dev/null @@ -1,1158 +0,0 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph, Wrap}, - Frame, -}; - -use super::text::{display_width_u16, truncate_end}; -use super::widgets::{ - action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button, - render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec, -}; -use crate::app::{state::WorktreeOpenState, AppState, Mode}; -use crate::terminal::TerminalRuntimeRegistry; - -const NEW_LINKED_WORKTREE_POPUP_WIDTH: u16 = 68; -const NEW_LINKED_WORKTREE_POPUP_HEIGHT: u16 = 12; - -pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "save", - }, - ActionButtonSpec { - hint: Some("^c"), - label: "clear", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - 3, - ); - (rects[0], rects[1], rects[2]) -} - -/// Draws the shared `name_input` field and puts the host cursor on its caret. -/// -/// IMEs draw their composition preview at the host terminal cursor. Without an -/// explicit cursor the frame carries none, the client keeps the position last -/// reported by the focused pane, and composition lands behind the dialog. -fn render_name_input_field(app: &AppState, frame: &mut Frame, input_rect: Rect) { - frame.render_widget(Clear, input_rect); - - // The text stops one column short of the field so the clamped caret always - // lands on a blank cell: a host terminal inverts the cell under its cursor, - // and an IME composes there. - let text_rect = Rect { - width: input_rect.width.saturating_sub(1), - ..input_rect - }; - frame.render_widget( - Paragraph::new(format!(" {}", app.name_input)).style( - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0), - ), - text_rect, - ); - - if input_rect.width == 0 { - return; - } - let caret_x = input_rect - .x - .saturating_add(1) - .saturating_add(display_width_u16(&app.name_input)) - .min(input_rect.right().saturating_sub(1)); - frame.set_cursor_position((caret_x, input_rect.y)); -} - -pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - super::dim_background(frame, area); - - let title = match app.mode { - Mode::RenameWorkspace if app.pending_workspace_create_cwd.is_some() => "new workspace", - Mode::RenameWorkspace => "rename workspace", - Mode::RenameTab if app.creating_new_tab => "new tab", - Mode::RenameTab => "rename tab", - Mode::RenamePane => "rename pane", - _ => return, - }; - - let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else { - return; - }; - if inner.height < 4 { - return; - } - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<5>(inner); - - render_modal_header(frame, rows[0], title, &app.palette); - - let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); - render_name_input_field(app, frame, input_rect); - - let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner); - - render_action_button( - frame, - save_rect, - Some("↵"), - "save", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - clear_rect, - Some("^c"), - "clear", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(crate) fn new_linked_worktree_inner_rect(area: Rect) -> Option<Rect> { - centered_popup_rect( - area, - NEW_LINKED_WORKTREE_POPUP_WIDTH, - NEW_LINKED_WORKTREE_POPUP_HEIGHT, - ) - .map(|popup| { - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - }) -} - -pub(crate) fn new_linked_worktree_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "create and open", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(crate) fn remove_worktree_popup_rect(area: Rect) -> Option<Rect> { - centered_popup_rect(area, 72, 10) -} - -pub(crate) fn remove_worktree_button_rects(inner: Rect, force_confirmation: bool) -> (Rect, Rect) { - let primary_label = if force_confirmation { - "delete anyway" - } else { - "remove" - }; - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: primary_label, - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(crate) fn open_existing_worktree_inner_rect(area: Rect, entry_count: usize) -> Option<Rect> { - let height = (entry_count as u16) - .saturating_mul(2) - .saturating_add(7) - .clamp(12, 26); - centered_popup_rect(area, 96, height).map(|popup| { - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - }) -} - -pub(crate) fn open_existing_worktree_max_visible_rows(inner: Rect) -> usize { - usize::from(inner.height.saturating_sub(5) / 2) -} - -pub(crate) fn open_existing_worktree_visible_start( - open: &WorktreeOpenState, - max_rows: usize, -) -> usize { - let filtered = open.filtered_indices(); - let selected = open.selected_entry_index().unwrap_or(open.selected); - let selected_pos = filtered - .iter() - .position(|idx| *idx == selected) - .unwrap_or(0); - selected_pos.saturating_sub(max_rows.saturating_sub(1)) -} - -pub(crate) fn open_existing_worktree_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "open", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(create) = app.worktree_create.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let Some(inner) = render_modal_shell( - frame, - area, - NEW_LINKED_WORKTREE_POPUP_WIDTH, - NEW_LINKED_WORKTREE_POPUP_HEIGHT, - &app.palette, - ) else { - return; - }; - if inner.height < 9 { - return; - } - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<8>(inner); - - render_modal_header(frame, rows[0], "new worktree", &app.palette); - - frame.render_widget( - Paragraph::new(" branch").style(Style::default().fg(app.palette.overlay0)), - rows[1], - ); - let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); - render_name_input_field(app, frame, input_rect); - - let checkout = create.checkout_path.display().to_string(); - frame.render_widget( - Paragraph::new(" checkout").style(Style::default().fg(app.palette.overlay0)), - rows[3], - ); - frame.render_widget( - Paragraph::new(format!(" {checkout}")).style(Style::default().fg(app.palette.subtext0)), - rows[4], - ); - - if create.creating { - frame.render_widget( - Paragraph::new(" creating…").style(Style::default().fg(app.palette.overlay0)), - rows[5], - ); - } else if let Some(error) = &create.error { - frame.render_widget( - Paragraph::new(format!(" {error}")) - .style(Style::default().fg(app.palette.red)) - .wrap(Wrap { trim: false }), - rows[5], - ); - } - - let (create_rect, cancel_rect) = new_linked_worktree_button_rects(inner); - render_action_button( - frame, - create_rect, - Some("↵"), - "create and open", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(super) fn render_remove_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(remove) = app.worktree_remove.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let Some(popup) = remove_worktree_popup_rect(area) else { - return; - }; - let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) - else { - return; - }; - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<8>(inner); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " delete worktree checkout?", - Style::default() - .fg(app.palette.red) - .add_modifier(Modifier::BOLD), - )])), - rows[0], - ); - frame.render_widget( - Paragraph::new(" This removes the checkout folder:") - .style(Style::default().fg(app.palette.overlay0)), - rows[1], - ); - frame.render_widget( - Paragraph::new(format!(" {}", remove.path.display())) - .style(Style::default().fg(app.palette.text)), - rows[2], - ); - frame.render_widget( - Paragraph::new(" The branch is not deleted. The Herdr workspace will close.") - .style(Style::default().fg(app.palette.overlay0)), - rows[3], - ); - if remove.force_confirmation { - frame.render_widget( - Paragraph::new(" Dirty or untracked files will be permanently deleted.") - .style(Style::default().fg(app.palette.red)), - rows[4], - ); - } - if remove.removing { - frame.render_widget( - Paragraph::new(" removing…").style(Style::default().fg(app.palette.overlay0)), - rows[5], - ); - } else if let Some(error) = &remove.error { - frame.render_widget( - Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), - rows[5], - ); - } - - let (remove_rect, cancel_rect) = remove_worktree_button_rects(inner, remove.force_confirmation); - let remove_label = if remove.force_confirmation { - "delete anyway" - } else { - "remove" - }; - render_action_button( - frame, - remove_rect, - Some("↵"), - remove_label, - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.red) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(open) = app.worktree_open.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let height = (open.entries.len() as u16) - .saturating_mul(2) - .saturating_add(7) - .clamp(12, 26); - let Some(inner) = render_modal_shell(frame, area, 96, height, &app.palette) else { - return; - }; - if inner.height < 8 { - return; - } - - render_modal_header( - frame, - Rect::new(inner.x, inner.y, inner.width, 1), - "open worktree", - &app.palette, - ); - render_open_worktree_search( - app, - frame, - Rect::new(inner.x, inner.y + 1, inner.width, 1), - open, - ); - frame.render_widget( - Paragraph::new("─".repeat(inner.width as usize)) - .style(Style::default().fg(app.palette.surface1)), - Rect::new(inner.x, inner.y.saturating_add(2), inner.width, 1), - ); - - let filtered = open.filtered_indices(); - let max_rows = open_existing_worktree_max_visible_rows(inner); - let start = open_existing_worktree_visible_start(open, max_rows); - for (visible_idx, entry_idx) in filtered.iter().skip(start).take(max_rows).enumerate() { - let Some(entry) = open.entries.get(*entry_idx) else { - continue; - }; - let selected = Some(*entry_idx) == open.selected_entry_index(); - let y = inner.y.saturating_add(3 + (visible_idx as u16 * 2)); - let marker = if selected { "›" } else { " " }; - let row_style = if selected { - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.palette.subtext0) - }; - let path_style = if selected { - Style::default() - .fg(app.palette.subtext0) - .bg(app.palette.surface0) - } else { - Style::default().fg(app.palette.overlay0) - }; - let status = entry.status_label(); - let title_width = inner - .width - .saturating_sub(display_width_u16(status)) - .saturating_sub(4) as usize; - let mut title = format!( - "{marker} {}", - truncate_end(&entry.display_name(), title_width) - ); - if !status.is_empty() { - let pad = inner - .width - .saturating_sub(display_width_u16(&title)) - .saturating_sub(display_width_u16(status)) - .max(1); - title.push_str(&" ".repeat(pad as usize)); - title.push_str(status); - } - frame.render_widget( - Paragraph::new(truncate_end(&title, inner.width as usize)).style(row_style), - Rect::new(inner.x, y, inner.width, 1), - ); - frame.render_widget( - Paragraph::new(truncate_end( - &format!(" {}", entry.path.display()), - inner.width as usize, - )) - .style(path_style), - Rect::new(inner.x, y.saturating_add(1), inner.width, 1), - ); - } - - if filtered.is_empty() { - frame.render_widget( - Paragraph::new(" no matching worktrees") - .style(Style::default().fg(app.palette.overlay0)), - Rect::new(inner.x, inner.y.saturating_add(3), inner.width, 1), - ); - } - - if let Some(error) = &open.error { - frame.render_widget( - Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(2), - inner.width, - 1, - ), - ); - } - - let (open_rect, cancel_rect) = open_existing_worktree_button_rects(inner); - render_action_button( - frame, - open_rect, - Some("↵"), - "open", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -fn render_open_worktree_search( - app: &AppState, - frame: &mut Frame, - area: Rect, - open: &WorktreeOpenState, -) { - let focus_style = if open.search_focused { - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.palette.overlay0) - }; - let filtered_count = open.filtered_indices().len(); - let count = if open.query.trim().is_empty() { - format!("{} checkouts", open.entries.len()) - } else { - format!("{filtered_count}/{} checkouts", open.entries.len()) - }; - let mut spans = vec![Span::styled(" / ", focus_style)]; - if open.query.trim().is_empty() { - spans.push(Span::styled( - "filter worktrees", - Style::default().fg(app.palette.overlay0), - )); - } else { - spans.push(Span::styled( - open.query.clone(), - Style::default().fg(app.palette.text), - )); - } - spans.push(Span::styled( - format!( - "{count:>width$}", - width = area.width.saturating_sub(18) as usize - ), - Style::default().fg(app.palette.overlay0), - )); - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -fn confirm_close_overlay_text( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> (String, String) { - let ws_name = app - .workspaces - .get(app.selected) - .map(|ws| ws.display_name_from(&app.terminals, terminal_runtimes)) - .unwrap_or_else(|| "?".to_string()); - let selected_space = app - .workspaces - .get(app.selected) - .and_then(|ws| ws.worktree_space()); - let group_member_indices = selected_space - .filter(|space| !space.is_linked_worktree) - .map(|space| { - app.workspaces - .iter() - .enumerate() - .filter_map(|(idx, ws)| { - ws.worktree_space() - .is_some_and(|member| member.key == space.key) - .then_some(idx) - }) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - let closes_group = group_member_indices.len() > 1; - let pane_count = if closes_group { - group_member_indices - .iter() - .filter_map(|idx| app.workspaces.get(*idx)) - .map(|ws| ws.layout.pane_count()) - .sum() - } else { - app.workspaces - .get(app.selected) - .map(|ws| ws.layout.pane_count()) - .unwrap_or(0) - }; - - let pane_text = if pane_count == 1 { - "1 pane".to_string() - } else { - format!("{pane_count} panes") - }; - let workspace_text = if closes_group { - let count = group_member_indices.len(); - if count == 1 { - "1 workspace, ".to_string() - } else { - format!("{count} workspaces, ") - } - } else { - String::new() - }; - - let title = if closes_group { - "Close worktree group?" - } else { - "Close workspace?" - }; - let detail = format!("{ws_name} — {workspace_text}{pane_text}"); - (title.to_string(), detail) -} - -pub(super) fn render_confirm_close_overlay( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let (title, detail) = confirm_close_overlay_text(app, terminal_runtimes); - - super::dim_background(frame, area); - - let Some(popup) = confirm_close_popup_rect(area) else { - return; - }; - - let warn = Style::default() - .fg(app.palette.red) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let title_line = Line::from(vec![Span::styled(format!(" {title}"), warn)]); - - let detail_line = Line::from(vec![ - Span::styled( - format!(" {}", detail.split(" — ").next().unwrap_or(&detail)), - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - detail - .split_once(" — ") - .map(|(_, rest)| format!(" — {rest}")) - .unwrap_or_default(), - dim, - ), - ]); - - let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) - else { - return; - }; - - if inner.height >= 3 { - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<4>(inner); - - frame.render_widget(Paragraph::new(title_line), rows[0]); - frame.render_widget(Paragraph::new(detail_line), rows[1]); - - let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner); - render_action_button( - frame, - confirm_rect, - Some("↵"), - "confirm", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.red) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - } -} - -pub(crate) fn confirm_close_popup_rect(area: Rect) -> Option<Rect> { - centered_popup_rect(area, 64, 6) -} - -pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "confirm", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - 3, - ); - (rects[0], rects[1]) -} - -#[cfg(test)] -mod tests { - use crate::{ - app::{state::WorktreeCreateState, AppState, Mode}, - workspace::Workspace, - }; - use ratatui::{ - backend::TestBackend, - buffer::Buffer, - layout::{Position, Rect}, - Terminal, - }; - - use super::{ - confirm_close_overlay_text, render_new_linked_worktree_overlay, render_rename_overlay, - }; - - #[test] - fn confirm_close_text_uses_live_workspace_cwd_label() { - let mut app = AppState::test_new(); - let mut workspace = Workspace::test_new("initial"); - workspace.custom_name = None; - workspace.identity_cwd = "/projects/original".into(); - let root_pane = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.terminals.get_mut(&terminal_id).unwrap().cwd = "/projects/current".into(); - app.selected = 0; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(title, "Close workspace?"); - assert_eq!(detail, "current — 1 pane"); - } - - #[cfg(unix)] - #[tokio::test] - async fn confirm_close_text_prefers_live_runtime_cwd_over_stale_terminal_cwd() { - let root = std::env::temp_dir().join(format!( - "herdr-confirm-close-runtime-cwd-{}", - std::process::id() - )); - let stale_cwd = root.join("original"); - let live_cwd = root.join("current"); - std::fs::create_dir_all(&live_cwd).unwrap(); - - let mut app = AppState::test_new(); - let mut workspace = Workspace::test_new("initial"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let root_pane = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.selected = 0; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - root_pane, - 24, - 80, - live_cwd, - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - terminal_runtimes.insert(terminal_id, runtime); - - let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(detail, "current — 1 pane"); - - drop(terminal_runtimes); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn confirm_close_text_uses_selected_custom_name_instead_of_active_workspace_cwd() { - let mut app = AppState::test_new(); - let active = Workspace::test_new("active"); - let selected = Workspace::test_new("selected"); - let selected_root = selected.tabs[0].root_pane; - let selected_terminal_id = selected.tabs[0].panes[&selected_root] - .attached_terminal_id - .clone(); - app.workspaces = vec![active, selected]; - app.ensure_test_terminals(); - app.terminals.get_mut(&selected_terminal_id).unwrap().cwd = "/projects/current".into(); - app.active = Some(0); - app.selected = 1; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(detail, "selected — 1 pane"); - } - - #[test] - fn confirm_close_text_reports_parent_group_scope() { - let mut app = AppState::test_new(); - let mut parent = Workspace::test_new("main"); - parent.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - let mut child = Workspace::test_new("issue"); - child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - app.workspaces = vec![parent, child]; - app.selected = 0; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(title, "Close worktree group?"); - assert_eq!(detail, "main — 2 workspaces, 2 panes"); - } - - #[test] - fn new_worktree_error_renders_fatal_stderr_line() { - let mut app = AppState::test_new(); - app.name_input = "foo".into(); - app.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "foo".into(), - checkout_path: "/repo/.worktrees/herdr/foo".into(), - error: Some( - "Preparing worktree (new branch 'foo')\nfatal: a branch named 'foo' already exists" - .into(), - ), - creating: false, - }); - - let mut terminal = - Terminal::new(TestBackend::new(100, 30)).expect("test terminal should initialize"); - terminal - .draw(|frame| render_new_linked_worktree_overlay(&app, frame, Rect::new(0, 0, 100, 30))) - .expect("new worktree overlay should render"); - let rendered = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::<String>(); - - assert!(rendered.contains("fatal: a branch named 'foo' already exists")); - } - - #[test] - fn new_worktree_hit_test_geometry_matches_modal_size() { - let area = Rect::new(0, 0, 100, 30); - let inner = super::new_linked_worktree_inner_rect(area).unwrap(); - let (create, cancel) = super::new_linked_worktree_button_rects(inner); - - assert_eq!(inner.width, super::NEW_LINKED_WORKTREE_POPUP_WIDTH - 2); - assert_eq!(inner.height, super::NEW_LINKED_WORKTREE_POPUP_HEIGHT - 2); - assert_eq!(create.y, inner.y + inner.height - 1); - assert_eq!(cancel.y, inner.y + inner.height - 1); - } - - const RENAME_AREA: Rect = Rect { - x: 0, - y: 0, - width: 80, - height: 20, - }; - const WORKTREE_AREA: Rect = Rect { - x: 0, - y: 0, - width: 100, - height: 30, - }; - - /// Reproduces the input row that `render_rename_overlay` lays out: the - /// centred popup, the border inset, then the third row of the vertical - /// split. - fn rename_input_rect(area: Rect) -> Rect { - let popup = super::centered_popup_rect(area, 56, 7).expect("popup fits"); - let inner = Rect::new(popup.x + 1, popup.y + 1, popup.width - 2, popup.height - 2); - Rect::new(inner.x, inner.y + 2, inner.width, 1) - } - - fn rename_overlay_caret_in(mode: Mode, name: &str) -> (Position, Buffer) { - let mut app = AppState::test_new(); - app.mode = mode; - app.name_input = name.into(); - - let mut terminal = Terminal::new(TestBackend::new(RENAME_AREA.width, RENAME_AREA.height)) - .expect("test terminal"); - terminal - .draw(|frame| render_rename_overlay(&app, frame, RENAME_AREA)) - .expect("rename overlay should render"); - let caret = terminal.get_cursor_position().expect("cursor position"); - (caret, terminal.backend().buffer().clone()) - } - - fn rename_overlay_caret(name: &str) -> Position { - rename_overlay_caret_in(Mode::RenameWorkspace, name).0 - } - - fn worktree_overlay_caret(branch: &str) -> Position { - let mut app = AppState::test_new(); - app.name_input = branch.into(); - app.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: "/repo/.worktrees/herdr/foo".into(), - error: None, - creating: false, - }); - - let mut terminal = - Terminal::new(TestBackend::new(WORKTREE_AREA.width, WORKTREE_AREA.height)) - .expect("test terminal"); - terminal - .draw(|frame| render_new_linked_worktree_overlay(&app, frame, WORKTREE_AREA)) - .expect("new worktree overlay should render"); - terminal.get_cursor_position().expect("cursor position") - } - - #[test] - fn rename_overlay_anchors_the_host_cursor_to_the_input_caret() { - let input = rename_input_rect(RENAME_AREA); - - // Without an explicit cursor the frame carries none, the client parks the - // host cursor where the focused pane last reported it, and the IME - // composes there instead of in the dialog. - assert_eq!( - rename_overlay_caret(""), - Position::new(input.x + 1, input.y), - "empty input should put the caret past the one-column left padding" - ); - assert_eq!( - rename_overlay_caret("abcd"), - Position::new(input.x + 5, input.y) - ); - - // The cell under the caret has to be blank: a host terminal draws its - // cursor by inverting that cell, so a glyph there would swallow it. - let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, "ab"); - assert_eq!(caret, Position::new(input.x + 3, input.y)); - assert_eq!(buffer[(caret.x, caret.y)].symbol(), " "); - assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "b"); - } - - #[test] - fn rename_overlay_anchors_the_cursor_in_every_rename_mode() { - let input = rename_input_rect(RENAME_AREA); - let expected = Position::new(input.x + 3, input.y); - - for mode in [Mode::RenameWorkspace, Mode::RenameTab, Mode::RenamePane] { - assert_eq!( - rename_overlay_caret_in(mode, "ab").0, - expected, - "{mode:?} should anchor the caret like the other rename modes" - ); - } - } - - #[test] - fn rename_overlay_caret_counts_wide_characters_as_two_columns() { - let input = rename_input_rect(RENAME_AREA); - - // "あい" is two columns per character, so the caret sits two cells further - // right than the two-column "ab". - assert_eq!( - rename_overlay_caret("あい"), - Position::new(input.x + 5, input.y) - ); - assert_eq!( - rename_overlay_caret("aあ"), - Position::new(input.x + 4, input.y) - ); - } - - #[test] - fn rename_overlay_caret_stays_inside_the_input_when_the_name_overflows() { - let input = rename_input_rect(RENAME_AREA); - let last_column = input.right() - 1; - - // The field is 54 columns wide. 51 characters is the last name whose - // caret still lands strictly inside it; from 52 on the unclamped column - // would leave the field and gets pinned to the final cell. - assert_eq!( - rename_overlay_caret(&"a".repeat(51)), - Position::new(input.x + 52, input.y) - ); - assert_eq!( - rename_overlay_caret(&"a".repeat(53)), - Position::new(last_column, input.y) - ); - assert_eq!( - rename_overlay_caret(&"a".repeat(200)), - Position::new(last_column, input.y) - ); - - // The clamped cell has to stay blank as well, or the host cursor would - // sit on a glyph and the IME would compose over it. - let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, &"a".repeat(200)); - assert_eq!(caret, Position::new(last_column, input.y)); - assert_eq!(buffer[(caret.x, caret.y)].symbol(), " "); - assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "a"); - } - - #[test] - fn rename_overlay_caret_reaches_the_frame_the_server_sends() { - let input = rename_input_rect(RENAME_AREA); - let mut app = AppState::test_new(); - app.mode = Mode::RenameWorkspace; - app.name_input = "ab".into(); - - // The widget tests above stop at the ratatui frame. This one goes through - // the server's cursor resolution, which is where the bug lived: the frame - // used to leave here with `cursor: None`. - let (_, cursor) = - crate::server::render_stream::render_virtual(&mut app, RENAME_AREA, false); - let cursor = cursor.expect("the modal caret should survive cursor resolution"); - - assert_eq!((cursor.x, cursor.y), (input.x + 3, input.y)); - assert!(cursor.visible); - } - - #[test] - fn new_worktree_overlay_anchors_the_host_cursor_to_the_input_caret() { - let popup = super::new_linked_worktree_inner_rect(WORKTREE_AREA).expect("popup fits"); - let input = Rect::new(popup.x, popup.y + 2, popup.width, 1); - - assert_eq!( - worktree_overlay_caret(""), - Position::new(input.x + 1, input.y) - ); - assert_eq!( - worktree_overlay_caret("ab"), - Position::new(input.x + 3, input.y) - ); - assert_eq!( - worktree_overlay_caret("あい"), - Position::new(input.x + 5, input.y) - ); - } -} diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs deleted file mode 100644 index 1f01c60c..00000000 --- a/src/ui/keybind_help.rs +++ /dev/null @@ -1,426 +0,0 @@ -use std::borrow::Cow; - -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Paragraph, Wrap}, - Frame, -}; - -use super::release_notes::release_notes_close_button_rect; -use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; -use super::widgets::{ - modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header, - render_modal_shell, -}; -use crate::app::AppState; - -pub(super) type HelpEntry = (String, Cow<'static, str>); -pub(super) type HelpGroup = (&'static str, Vec<HelpEntry>); - -fn help_entry(key: impl Into<String>, label: &'static str) -> HelpEntry { - (key.into(), Cow::Borrowed(label)) -} - -fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String { - bindings.label().unwrap_or_else(|| "unset".to_string()) -} - -fn indexed_label(bindings: &[crate::config::IndexedKeybind]) -> String { - if bindings.is_empty() { - return "unset".to_string(); - } - - let mut parts = Vec::new(); - let mut index = 0; - while index < bindings.len() { - if let Some(prefix) = indexed_range_prefix(&bindings[index..]) { - parts.push(format!("{prefix}1..9")); - index += 9; - } else { - parts.push(bindings[index].label.clone()); - index += 1; - } - } - - parts.join(" / ") -} - -fn indexed_range_prefix(bindings: &[crate::config::IndexedKeybind]) -> Option<&str> { - let run = bindings.get(..9)?; - let prefix = run[0].label.strip_suffix('1')?; - for (offset, binding) in run.iter().enumerate() { - let digit = char::from(b'1' + offset as u8); - if binding.label.strip_suffix(digit) != Some(prefix) { - return None; - } - } - Some(prefix) -} - -pub(super) fn keybind_help_groups(app: &AppState) -> Vec<HelpGroup> { - let kb = &app.keybinds; - let mut groups = Vec::new(); - - groups.push(( - "global", - vec![ - help_entry( - crate::config::format_key_combo((app.prefix_code, app.prefix_mods)), - "prefix mode", - ), - help_entry(keybind_label(&kb.help), "keybinds"), - help_entry(keybind_label(&kb.settings), "settings"), - help_entry(keybind_label(&kb.detach), "detach"), - help_entry(keybind_label(&kb.reload_config), "reload config"), - help_entry( - keybind_label(&kb.open_notification_target), - "open notification target", - ), - ], - )); - - groups.push(( - "navigation", - vec![ - help_entry("esc", "back"), - help_entry( - format!( - "{} / {}", - keybind_label(&kb.navigate.workspace_up), - keybind_label(&kb.navigate.workspace_down) - ), - "workspace list", - ), - help_entry( - format!( - "{} / {} / {} / {} / left / right", - keybind_label(&kb.navigate.pane_left), - keybind_label(&kb.navigate.pane_down), - keybind_label(&kb.navigate.pane_up), - keybind_label(&kb.navigate.pane_right) - ), - "move focus", - ), - help_entry("tab / shift+tab", "cycle pane"), - help_entry("enter", "open workspace"), - help_entry("1..9", "switch workspace"), - ], - )); - - let workspace_tab = vec![ - help_entry(keybind_label(&kb.workspace_picker), "workspace navigation"), - help_entry(keybind_label(&kb.goto), "session navigator"), - help_entry(keybind_label(&kb.new_workspace), "new workspace"), - help_entry(keybind_label(&kb.new_worktree), "new worktree"), - help_entry(keybind_label(&kb.open_worktree), "open worktree"), - help_entry( - keybind_label(&kb.remove_worktree), - "delete worktree checkout", - ), - help_entry(keybind_label(&kb.rename_workspace), "rename workspace"), - help_entry(keybind_label(&kb.close_workspace), "close workspace"), - help_entry(keybind_label(&kb.previous_workspace), "previous workspace"), - help_entry(keybind_label(&kb.next_workspace), "next workspace"), - help_entry(indexed_label(&kb.switch_workspace), "switch workspace 1-9"), - help_entry(keybind_label(&kb.previous_agent), "previous agent"), - help_entry(keybind_label(&kb.next_agent), "next agent"), - help_entry(indexed_label(&kb.focus_agent), "focus agent 1-9"), - help_entry(keybind_label(&kb.new_tab), "new tab"), - help_entry(keybind_label(&kb.rename_tab), "rename tab"), - help_entry(keybind_label(&kb.previous_tab), "previous tab"), - help_entry(keybind_label(&kb.next_tab), "next tab"), - help_entry(keybind_label(&kb.move_tab_previous), "move tab left"), - help_entry(keybind_label(&kb.move_tab_next), "move tab right"), - help_entry(indexed_label(&kb.switch_tab), "switch tab 1-9"), - help_entry(keybind_label(&kb.close_tab), "close tab"), - ]; - groups.push(("workspaces / tabs", workspace_tab)); - - let panes = vec![ - help_entry(keybind_label(&kb.split_vertical), "split vertical"), - help_entry(keybind_label(&kb.split_horizontal), "split horizontal"), - help_entry(keybind_label(&kb.close_pane), "close pane"), - help_entry(keybind_label(&kb.rename_pane), "rename pane"), - help_entry(keybind_label(&kb.edit_scrollback), "edit scrollback"), - help_entry(keybind_label(&kb.copy_mode), "copy mode"), - help_entry(keybind_label(&kb.zoom), "zoom pane"), - help_entry(keybind_label(&kb.resize_mode), "resize mode"), - help_entry(keybind_label(&kb.resize_pane_left), "resize pane left"), - help_entry(keybind_label(&kb.resize_pane_down), "resize pane down"), - help_entry(keybind_label(&kb.resize_pane_up), "resize pane up"), - help_entry(keybind_label(&kb.resize_pane_right), "resize pane right"), - help_entry(keybind_label(&kb.toggle_sidebar), "toggle sidebar"), - help_entry(keybind_label(&kb.focus_pane_left), "focus pane left"), - help_entry(keybind_label(&kb.focus_pane_down), "focus pane down"), - help_entry(keybind_label(&kb.focus_pane_up), "focus pane up"), - help_entry(keybind_label(&kb.focus_pane_right), "focus pane right"), - help_entry(keybind_label(&kb.cycle_pane_next), "cycle pane next"), - help_entry( - keybind_label(&kb.cycle_pane_previous), - "cycle pane previous", - ), - help_entry(keybind_label(&kb.last_pane), "last pane"), - ]; - groups.push(("panes", panes)); - - if !kb.custom_commands.is_empty() { - groups.push(( - "custom", - kb.custom_commands - .iter() - .map(|binding| { - ( - binding.label.clone(), - binding - .description - .clone() - .map(Cow::Owned) - .unwrap_or(Cow::Borrowed("custom command")), - ) - }) - .collect(), - )); - } - - groups -} - -fn filter_keybind_help_groups(groups: Vec<HelpGroup>, query: &str) -> Vec<HelpGroup> { - if query.is_empty() { - return groups; - } - - let query = query.to_lowercase(); - groups - .into_iter() - .filter_map(|(group, entries)| { - let entries = entries - .into_iter() - .filter(|(key, label)| { - key.to_lowercase().contains(&query) || label.to_lowercase().contains(&query) - }) - .collect::<Vec<_>>(); - (!entries.is_empty()).then_some((group, entries)) - }) - .collect() -} - -pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> { - let heading_style = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let key_style = Style::default() - .fg(app.palette.mauve) - .add_modifier(Modifier::BOLD); - let label_style = Style::default().fg(app.palette.text); - - let groups = filter_keybind_help_groups(keybind_help_groups(app), &app.keybind_help.query); - let key_width = groups - .iter() - .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) - .max() - .unwrap_or(8); - - let mut lines = Vec::new(); - - if groups.is_empty() { - let message = " no matching keybinds"; - return vec![( - message.chars().count(), - Line::from(Span::styled( - message, - Style::default().fg(app.palette.overlay1), - )), - )]; - } - - for (group, entries) in groups { - lines.push(( - group.len() + 1, - Line::from(vec![Span::styled(format!(" {group}"), heading_style)]), - )); - for (key, label) in entries { - let padded_key = format!(" {:<width$} ", key, width = key_width); - let width = padded_key.chars().count() + label.chars().count(); - lines.push(( - width, - Line::from(vec![ - Span::styled(padded_key, key_style), - Span::styled(label.into_owned(), label_style), - ]), - )); - } - lines.push((0, Line::raw(""))); - } - - lines -} - -pub(super) fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) { - super::dim_background(frame, frame.area()); - - let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else { - return; - }; - if inner.height < 6 || inner.width < 20 { - return; - } - - let stack = modal_stack_areas(inner, 2, 1, 0, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - - render_modal_header(frame, header_rows[0], "keybinds", &app.palette); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - if app.keybind_help.search_focused { - "back" - } else { - "close" - }, - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - let search_line = if app.keybind_help.search_focused { - Line::from(vec![ - Span::styled( - " / ", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - app.keybind_help.query.as_str(), - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - ]) - } else { - Line::from(Span::styled( - " press / to filter by command or shortcut", - Style::default().fg(app.palette.overlay0), - )) - }; - frame.render_widget(Paragraph::new(search_line), header_rows[1]); - - let body_area = stack.content; - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app - .keybind_help_max_scroll() - .saturating_sub(app.keybind_help.scroll) as usize, - max_offset_from_bottom: app.keybind_help_max_scroll() as usize, - viewport_rows: body_area.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(body_area, metrics); - let text_area = track - .map(|_| { - Rect::new( - body_area.x, - body_area.y, - body_area.width.saturating_sub(1), - body_area.height, - ) - }) - .unwrap_or(body_area); - - let body = Paragraph::new( - keybind_help_lines(app) - .into_iter() - .map(|(_, line)| line) - .collect::<Vec<_>>(), - ) - .wrap(Wrap { trim: false }) - .scroll((app.keybind_help.scroll, 0)); - frame.render_widget(body, text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - let footer = if app.keybind_help.search_focused { - Line::from(vec![ - Span::styled(" filter ", Style::default().fg(app.palette.overlay0)), - Span::styled("type/backspace", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("clear ", Style::default().fg(app.palette.overlay0)), - Span::styled("ctrl+u", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("↑↓/pgup/pgdn", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("back ", Style::default().fg(app.palette.overlay0)), - Span::styled("esc", Style::default().fg(app.palette.text)), - ]) - } else { - Line::from(vec![ - Span::styled(" search ", Style::default().fg(app.palette.overlay0)), - Span::styled("/", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("j/k/↑↓/pgup/pgdn", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close ", Style::default().fg(app.palette.overlay0)), - Span::styled("esc/enter", Style::default().fg(app.palette.text)), - ]) - }; - frame.render_widget(Paragraph::new(footer), stack.footer.unwrap_or_default()); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn groups() -> Vec<HelpGroup> { - vec![ - ( - "workspaces / tabs", - vec![ - help_entry("w", "workspace navigation"), - help_entry("c", "new tab"), - ], - ), - ( - "panes", - vec![ - help_entry("v", "split vertical"), - help_entry("x", "close pane"), - ], - ), - ] - } - - #[test] - fn keybind_help_filter_matches_labels_case_insensitively() { - let filtered = filter_keybind_help_groups(groups(), "WoRk"); - - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].0, "workspaces / tabs"); - assert_eq!(filtered[0].1.len(), 1); - assert_eq!(filtered[0].1[0].1, "workspace navigation"); - } - - #[test] - fn keybind_help_filter_matches_shortcuts_without_matching_group_headings() { - let filtered = filter_keybind_help_groups(groups(), "x"); - - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].0, "panes"); - assert_eq!(filtered[0].1.len(), 1); - assert_eq!(filtered[0].1[0].1, "close pane"); - - assert!(filter_keybind_help_groups(groups(), "panes").is_empty()); - } -} diff --git a/src/ui/menus.rs b/src/ui/menus.rs deleted file mode 100644 index 763c0ea8..00000000 --- a/src/ui/menus.rs +++ /dev/null @@ -1,315 +0,0 @@ -use ratatui::{ - layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, List, ListItem, ListState, Paragraph}, - Frame, -}; - -use super::widgets::{panel_contrast_fg, render_panel_shell}; -use crate::app::AppState; - -fn prefix_rhs_label(bindings: &crate::config::ActionKeybinds) -> String { - bindings - .prefix_rhs_label() - .unwrap_or_else(|| "unset".to_string()) -} - -fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String { - bindings.label().unwrap_or_else(|| "unset".to_string()) -} - -fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) { - frame.render_widget(Clear, area); - let buf = frame.buffer_mut(); - for x in area.x..area.x + area.width { - buf[(x, area.y)].set_style(Style::default().bg(bg)); - } - frame.render_widget(Paragraph::new(line), area); -} - -pub(super) fn render_prefix_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let workspace_picker = prefix_rhs_label(&app.keybinds.workspace_picker); - let help = prefix_rhs_label(&app.keybinds.help); - let prefix = crate::config::format_key_combo((app.prefix_code, app.prefix_mods)); - - let line = Line::from(vec![ - Span::styled(" PREFIX ", mode_style), - Span::raw(" "), - Span::styled("esc", key), - Span::styled(" cancel ", dim), - Span::styled(prefix, key), - Span::styled(" send prefix ", dim), - Span::styled(workspace_picker, key), - Span::styled(" workspace nav ", dim), - Span::styled(help, key), - Span::styled(" keybinds", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_copy_mode_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - let line = if let Some(prompt) = copy_mode.search.prompt.as_ref() { - let marker = match prompt.direction { - crate::app::state::CopyModeSearchDirection::Forward => "/", - crate::app::state::CopyModeSearchDirection::Backward => "?", - }; - Line::from(vec![ - Span::styled(" COPY ", mode_style), - Span::raw(" "), - Span::styled(marker, key), - Span::styled(prompt.query.clone(), Style::default().fg(app.palette.text)), - Span::styled("█", key), - Span::styled(" enter search esc cancel", dim), - ]) - } else { - let select = if copy_mode.selection.is_some() { - "selecting" - } else { - "select" - }; - let match_status = copy_mode - .search - .current - .map(|current| format!(" {}/{}", current + 1, copy_mode.search.matches.len())) - .or_else(|| (!copy_mode.search.query.is_empty()).then(|| " 0/0".to_string())) - .unwrap_or_default(); - let (exit_keys, exit_label) = - if copy_mode.search.query.is_empty() && copy_mode.selection.is_none() { - ("q/esc", " exit") - } else { - ("esc", " clear q exit") - }; - Line::from(vec![ - Span::styled(" COPY ", mode_style), - Span::raw(" "), - Span::styled("h/j/k/l w/b/e { }", key), - Span::styled(" move ", dim), - Span::styled("/ ?", key), - Span::styled(" search ", dim), - Span::styled("n/N", key), - Span::styled(format!(" repeat{match_status} "), dim), - Span::styled("v/space", key), - Span::styled(format!(" {select} "), dim), - Span::styled("y/enter", key), - Span::styled(" copy ", dim), - Span::styled(exit_keys, key), - Span::styled(exit_label, dim), - ]) - }; - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let kb = &app.keybinds; - let new_tab = prefix_rhs_label(&kb.new_tab); - let split_vertical = prefix_rhs_label(&kb.split_vertical); - let split_horizontal = prefix_rhs_label(&kb.split_horizontal); - let close_pane = prefix_rhs_label(&kb.close_pane); - let zoom = prefix_rhs_label(&kb.zoom); - let resize = prefix_rhs_label(&kb.resize_mode); - let help = prefix_rhs_label(&kb.help); - let settings = prefix_rhs_label(&kb.settings); - let goto = prefix_rhs_label(&kb.goto); - let detach = prefix_rhs_label(&kb.detach); - let workspace_nav = format!( - "{} / {}", - keybind_label(&kb.navigate.workspace_up), - keybind_label(&kb.navigate.workspace_down) - ); - let line = Line::from(vec![ - Span::styled(" NAVIGATE ", mode_style), - Span::raw(" "), - Span::styled("esc", key), - Span::styled(" back ", dim), - Span::styled(workspace_nav, key), - Span::styled(" ws ", dim), - Span::styled("⇥", key), - Span::styled(" pane ", dim), - Span::styled(goto, key), - Span::styled(" navigator ", dim), - Span::styled(new_tab, key), - Span::styled(" new tab ", dim), - Span::styled(split_vertical, key), - Span::styled(" split│ ", dim), - Span::styled(split_horizontal, key), - Span::styled(" split─ ", dim), - Span::styled(close_pane, key), - Span::styled(" close ", dim), - Span::styled(zoom, key), - Span::styled(" zoom ", dim), - Span::styled(resize, key), - Span::styled(" resize ", dim), - Span::styled(help, key), - Span::styled(" keybinds ", dim), - Span::styled(settings, key), - Span::styled(" settings ", dim), - Span::styled(detach, key), - Span::styled(" detach", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); - - if app.update_available.is_some() { - let status = Line::from(vec![Span::styled( - " update ready", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - )]); - let width = 13u16.min(overlay_area.width); - let status_area = Rect::new( - overlay_area.x + overlay_area.width.saturating_sub(width), - overlay_area.y, - width, - overlay_area.height, - ); - frame.render_widget(Clear, status_area); - frame.render_widget( - Paragraph::new(status).alignment(Alignment::Right), - status_area, - ); - } -} - -pub(super) fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { - let rect = app.global_menu_rect(); - let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg) - else { - return; - }; - - let items = app.global_menu_labels(); - for (idx, item) in items.iter().enumerate() { - let y = inner.y + idx as u16; - if y >= inner.y + inner.height { - break; - } - let selected = idx == app.global_menu.highlighted; - let rect = Rect::new(inner.x, y, inner.width, 1); - - let selected_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let item_style = if selected { - selected_style - } else { - Style::default().fg(app.palette.text) - }; - let badge_style = if selected { - selected_style - } else { - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD) - }; - - let line = if app.global_menu_item_has_badge(item) { - Line::from(vec![ - Span::styled(" ●", badge_style), - Span::styled(format!(" {item} "), item_style), - ]) - } else { - Line::from(Span::styled(format!(" {item} "), item_style)) - }; - frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect); - } -} - -pub(super) fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.mauve) - .add_modifier(Modifier::BOLD); - - let line = Line::from(vec![ - Span::styled(" RESIZE ", mode_style), - Span::raw(" "), - Span::styled("h/l", key), - Span::styled(" width ", dim), - Span::styled("j/k", key), - Span::styled(" height ", dim), - Span::styled("esc", key), - Span::styled(" done", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_context_menu(app: &AppState, frame: &mut Frame) { - let Some(menu) = &app.context_menu else { - return; - }; - - let p = &app.palette; - let Some(menu_rect) = app.context_menu_rect() else { - return; - }; - let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else { - return; - }; - - let items: Vec<ListItem> = menu - .items() - .iter() - .map(|item| ListItem::new(Line::from(*item))) - .collect(); - let list = List::new(items) - .style(Style::default().fg(p.text)) - .highlight_style( - Style::default() - .bg(p.accent) - .fg(panel_contrast_fg(p)) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(" "); - let mut state = ListState::default().with_selected(Some(menu.list.highlighted)); - frame.render_stateful_widget(list, inner, &mut state); -} diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs deleted file mode 100644 index bdec6551..00000000 --- a/src/ui/mobile.rs +++ /dev/null @@ -1,1626 +0,0 @@ -use ratatui::{ - layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph}, - Frame, -}; - -use super::sidebar::{ - agent_panel_entries, agent_panel_entries_from, grouped_child_display_label, - next_entry_is_indented_workspace, workspace_list_entries_expanded, AgentPanelEntry, - WorkspaceListEntry, -}; -use super::status::{state_icon, state_icon_symbol}; -use super::text::{display_width_u16, truncate_end}; -use crate::app::state::{Palette, ToastKind, ToastNotification}; -use crate::app::AppState; -use crate::config::StatusIndicatorStyle; -use crate::detect::AgentState; -use crate::layout::PaneId; -use crate::terminal::TerminalRuntimeRegistry; - -const SWITCH_BUTTON_WIDTH: u16 = 10; - -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct MobileHeaderHitAreas { - pub menu: Rect, -} - -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct MobileSwitcherAreas { - pub close: Rect, - pub viewport: Rect, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MobileSwitcherTarget { - NewWorkspace, - Workspace(usize), - NewTab, - Tab(usize), - Agent { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - }, - Menu(usize), -} - -pub(crate) fn is_mobile_width(area: Rect, threshold: u16) -> bool { - area.width > 0 && area.width <= threshold -} - -pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas { - if area.width == 0 || area.height == 0 { - return MobileHeaderHitAreas::default(); - } - - let width = SWITCH_BUTTON_WIDTH.min(area.width); - let switch = Rect::new( - area.x + area.width.saturating_sub(width), - area.y, - width, - area.height, - ); - - MobileHeaderHitAreas { menu: switch } -} - -pub(crate) fn mobile_switcher_areas(app: &AppState) -> MobileSwitcherAreas { - let screen = mobile_screen_rect(app); - if screen.width == 0 || screen.height <= 2 { - return MobileSwitcherAreas::default(); - } - - let header_h = screen.height.min(2); - let close_w = 10u16.min(screen.width); - let close = Rect::new( - screen.x + screen.width.saturating_sub(close_w), - screen.y, - close_w, - header_h, - ); - let viewport = Rect::new( - screen.x, - screen.y + header_h + 1, - screen.width, - screen.height.saturating_sub(header_h + 1), - ); - - MobileSwitcherAreas { close, viewport } -} - -pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_height: u16) -> usize { - mobile_switcher_content_height(app).saturating_sub(viewport_height as usize) -} - -/// Doc-row height of the agents section. An active query keeps its title and an -/// empty-state row visible even when no agents match. -fn mobile_agents_block_height(app: &AppState) -> usize { - let count = agent_panel_entries(app).len(); - if count == 0 { - usize::from(app.agent_view_override.is_some()) * 2 - } else { - 1 + count * 2 - } -} - -pub(crate) fn mobile_switcher_workspace_doc_range( - app: &AppState, - idx: usize, -) -> std::ops::Range<usize> { - // Spaces render in grouped order, so a workspace's row position is its index - // in the entry list, not its raw array index. - let pos = workspace_list_entries_expanded(app) - .iter() - .position(|WorkspaceListEntry::Workspace { ws_idx, .. }| *ws_idx == idx) - .unwrap_or(idx); - // spaces sit after the agents block, then a title + "new workspace" row. - let start = mobile_agents_block_height(app) + 2 + pos * 2; - start..start + 2 -} - -pub(crate) fn mobile_switcher_max_scroll(app: &AppState) -> usize { - mobile_switcher_max_scroll_for_height(app, mobile_switcher_areas(app).viewport.height) -} - -pub(crate) fn mobile_switcher_target_at( - app: &AppState, - col: u16, - row: u16, -) -> Option<MobileSwitcherTarget> { - let areas = mobile_switcher_areas(app); - let content = inset_for_left_scrollbar(areas.viewport); - if !rect_contains(content, col, row) { - return None; - } - - let scroll = app - .mobile_switcher_scroll - .min(mobile_switcher_max_scroll_for_height( - app, - areas.viewport.height, - )); - let doc_row = scroll.saturating_add(row.saturating_sub(areas.viewport.y) as usize); - let mut cursor = 0usize; - - // Agents lead the switcher: the primary job is switching between running - // agents. Spaces/tabs/create actions follow for navigation and management. - let agents = agent_panel_entries(app); - if !agents.is_empty() || app.agent_view_override.is_some() { - cursor += 1; // agents title - if agents.is_empty() { - cursor += 1; // active-query empty state - } else { - let agents_end = cursor + agents.len() * 2; - if doc_row >= cursor && doc_row < agents_end { - let idx = (doc_row - cursor) / 2; - return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent { - ws_idx: entry.ws_idx, - tab_idx: entry.tab_idx, - pane_id: entry.pane_id, - }); - } - cursor = agents_end; - } - } - - cursor += 1; // spaces title - if doc_row == cursor { - return Some(MobileSwitcherTarget::NewWorkspace); - } - cursor += 1; - // Spaces render in grouped (worktree-tree) order, which differs from raw - // array order, so map the clicked row to the entry's real workspace index. - let space_entries = workspace_list_entries_expanded(app); - let spaces_end = cursor + space_entries.len() * 2; - if doc_row >= cursor && doc_row < spaces_end { - let entry_idx = (doc_row - cursor) / 2; - return space_entries.get(entry_idx).map( - |WorkspaceListEntry::Workspace { ws_idx, .. }| MobileSwitcherTarget::Workspace(*ws_idx), - ); - } - cursor = spaces_end; - - if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { - cursor += 1; // tabs title - if doc_row == cursor { - return Some(MobileSwitcherTarget::NewTab); - } - cursor += 1; - let tabs_end = cursor + ws.tabs.len(); - if doc_row >= cursor && doc_row < tabs_end { - return Some(MobileSwitcherTarget::Tab(doc_row - cursor)); - } - cursor = tabs_end; - } - - cursor += 1; // menu title - let menu_idx = doc_row.checked_sub(cursor)?; - (menu_idx < app.global_menu_labels().len()).then_some(MobileSwitcherTarget::Menu(menu_idx)) -} - -pub(crate) fn render_mobile_header( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.panel_bg)); - - let switch = app.view.mobile_menu_hit_area; - let status_w = switch.x.saturating_sub(area.x).saturating_sub(1); - let status = Rect::new(area.x, area.y, status_w, area.height); - - render_header_status(app, terminal_runtimes, frame, status); - render_switch_button(app, frame, switch); -} - -pub(crate) fn mobile_toast_banner_rect(area: Rect, offset_for_warning: bool) -> Rect { - if area.width == 0 || area.height == 0 { - return Rect::default(); - } - - let y = area.y - + area - .height - .saturating_sub(1 + if offset_for_warning { 1 } else { 0 }); - Rect::new(area.x, y, area.width, 1) -} - -pub(crate) fn render_mobile_toast_banner( - frame: &mut Frame, - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - p: &Palette, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let dot_color = match toast.kind { - ToastKind::NeedsAttention => p.red, - ToastKind::Finished => p.blue, - ToastKind::UpdateInstalled => p.accent, - }; - let banner = mobile_toast_banner_rect(area, offset_for_warning); - let bg = p.surface0; - - frame.render_widget(Clear, banner); - fill_rect(frame, banner, Style::default().bg(bg)); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled("●", Style::default().fg(dot_color).bg(bg)), - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - mobile_toast_title(toast), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" · ", Style::default().fg(p.overlay0).bg(bg)), - Span::styled(&toast.context, Style::default().fg(p.overlay0).bg(bg)), - ])), - banner, - ); -} - -pub(crate) fn render_mobile_panel( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let p = &app.palette; - frame.render_widget(Clear, area); - fill_rect(frame, area, Style::default().bg(p.panel_bg)); - - let areas = mobile_switcher_areas(app); - frame.render_widget( - Paragraph::new(" switch").style( - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - Rect::new(area.x, area.y, areas.close.x.saturating_sub(area.x), 1), - ); - render_close_button(app, frame, areas.close); - - if area.height > areas.close.height { - draw_horizontal_rule( - frame, - Rect::new(area.x, area.y + areas.close.height, area.width, 1), - p, - ); - } - - render_mobile_switcher_content(app, terminal_runtimes, frame, areas.viewport); -} - -fn render_header_status( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else { - frame.render_widget(Paragraph::new(" no workspace"), area); - return; - }; - - let (state, seen) = ws.aggregate_state(&app.terminals); - let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p); - let tab_label = mobile_tab_status(ws); - let row1 = Rect::new(area.x, area.y, area.width, 1); - let tab_w = display_width_u16(&tab_label) - .saturating_add(1) - .min(area.width); - let name_w = area.width.saturating_sub(tab_w); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::raw(" "), - Span::styled(dot, dot_style.bg(p.panel_bg)), - Span::raw(" "), - Span::styled( - truncate_end( - &ws.display_name_from(&app.terminals, terminal_runtimes), - name_w.saturating_sub(4) as usize, - ), - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - ])), - Rect::new(row1.x, row1.y, name_w, 1), - ); - frame.render_widget( - Paragraph::new(tab_label) - .style(Style::default().fg(p.overlay1).bg(p.panel_bg)) - .alignment(Alignment::Right), - Rect::new(row1.x + name_w, row1.y, tab_w, 1), - ); - - if area.height > 1 { - frame.render_widget( - Paragraph::new(agent_summary_line(app, p, area.width)), - Rect::new(area.x, area.y + 1, area.width, 1), - ); - } -} - -fn mobile_tab_status(ws: &crate::workspace::Workspace) -> String { - let tab_label = ws - .tab_display_name(ws.active_tab) - .unwrap_or_else(|| (ws.active_tab + 1).to_string()); - if ws.tabs.len() <= 1 { - format!("tab {tab_label}") - } else { - format!("tab {tab_label} · {}/{}", ws.active_tab + 1, ws.tabs.len()) - } -} - -fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.surface0)); - for y in area.y..area.y + area.height { - frame.buffer_mut()[(area.x, y)] - .set_symbol("│") - .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); - } - let label_y = if area.height > 1 { area.y + 1 } else { area.y }; - frame.render_widget( - Paragraph::new("switch") - .style( - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, label_y, area.width.saturating_sub(1), 1), - ); - - // Attention badge: a blocked agent anywhere makes the button itself read as - // "tap me" without the user reading the summary row. - if global_agent_counts(app).blocked > 0 { - let bx = area.x + area.width.saturating_sub(1); - let (symbol, style) = state_icon(AgentState::Blocked, true, app.status_indicators, p); - frame.buffer_mut()[(bx, area.y)] - .set_symbol(symbol) - .set_style(style.bg(p.surface0)); - } -} - -fn render_close_button(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.surface0)); - for y in area.y..area.y + area.height { - frame.buffer_mut()[(area.x, y)] - .set_symbol("│") - .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); - } - frame.render_widget( - Paragraph::new("close") - .style( - Style::default() - .fg(p.overlay1) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, area.y, area.width.saturating_sub(1), 1), - ); - if area.height > 1 { - frame.render_widget( - Paragraph::new("×") - .style( - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, area.y + 1, area.width.saturating_sub(1), 1), - ); - } -} - -fn mobile_switcher_content_height(app: &AppState) -> usize { - // Derive spaces height from the same entry list the render/hit-test use so - // the three never disagree. - let spaces_h = 2 + workspace_list_entries_expanded(app).len() * 2; - let tabs_h = app - .active - .and_then(|idx| app.workspaces.get(idx)) - .map(|ws| 2 + ws.tabs.len()) - .unwrap_or(0); - let agents_h = mobile_agents_block_height(app); - let menu_h = 1 + app.global_menu_labels().len(); - spaces_h + tabs_h + agents_h + menu_h -} - -fn render_mobile_switcher_content( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - viewport: Rect, -) { - if viewport.width == 0 || viewport.height == 0 { - return; - } - - let p = &app.palette; - let total_height = mobile_switcher_content_height(app); - render_left_scrollbar( - frame, - viewport, - total_height, - viewport.height as usize, - app.mobile_switcher_scroll, - p, - ); - let content = inset_for_left_scrollbar(viewport); - if content == Rect::default() { - return; - } - - let mut doc_y = 0usize; - - let entries = agent_panel_entries_from(app, terminal_runtimes); - if !entries.is_empty() || app.agent_view_override.is_some() { - let focused_agent = app.active.and_then(|ws_idx| { - let ws = app.workspaces.get(ws_idx)?; - ws.focused_pane_id() - .map(|pane_id| (ws_idx, ws.active_tab, pane_id)) - }); - let title = app - .agent_view_override - .as_ref() - .map(|view| format!("agents · {}", view.label.as_deref().unwrap_or("filtered"))) - .unwrap_or_else(|| "agents".to_string()); - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - &title, - p, - ); - doc_y += 1; - if entries.is_empty() { - render_one_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - ratatui::style::Color::Reset, - Line::from(Span::styled( - " no matching agents", - Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), - )), - ); - doc_y += 1; - } - for entry in &entries { - let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| { - entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id - }); - let bg = mobile_item_bg(false, active, p); - let (icon, icon_style) = state_icon(entry.state, entry.seen, app.status_indicators, p); - let title = Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled(icon, icon_style.bg(bg)), - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - truncate_end( - &entry.primary_label, - content.width.saturating_sub(5) as usize, - ), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - ]); - let detail = mobile_agent_detail(entry); - render_two_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - title, - truncate_end(&detail, content.width as usize), - p.overlay0, - ); - doc_y += 2; - } - } - - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "spaces", - p, - ); - doc_y += 1; - render_action_row_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "+ new workspace", - p, - ); - doc_y += 1; - let space_entries = workspace_list_entries_expanded(app); - for (entry_idx, WorkspaceListEntry::Workspace { ws_idx, indented }) in - space_entries.iter().enumerate() - { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - let active = Some(*ws_idx) == app.active; - let selected = *ws_idx == app.selected; - let bg = mobile_item_bg(selected, active, p); - let (state, seen) = ws.aggregate_state(&app.terminals); - let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p); - - let mut title_spans = vec![Span::styled(" ", Style::default().bg(bg))]; - // Worktrees of the same space render as branches off their parent, so a - // child gets an L/T connector on its name row and a matching vertical - // continuation on its detail row. - let detail_prefix = if *indented { - let last_child = !next_entry_is_indented_workspace(&space_entries, entry_idx); - title_spans.push(Span::styled( - if last_child { "└─ " } else { "├─ " }, - Style::default().fg(p.overlay0).bg(bg), - )); - if last_child { - " " - } else { - " │ " - } - } else { - " " - }; - - title_spans.push(Span::styled(dot, dot_style.bg(bg))); - title_spans.push(Span::styled(" ", Style::default().bg(bg))); - let raw_label = ws.display_name_from(&app.terminals, terminal_runtimes); - let name = if *indented { - grouped_child_display_label( - &raw_label, - ws.branch().as_deref(), - ws.custom_name.is_some(), - ) - } else { - raw_label - }; - let name_budget = content.width.saturating_sub(if *indented { 8 } else { 5 }) as usize; - title_spans.push(Span::styled( - truncate_end(&name, name_budget), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - )); - - let detail = format!( - "{detail_prefix}{} · {}", - ws.branch().unwrap_or_else(|| "shell".into()), - mobile_tab_status(ws) - ); - render_two_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - Line::from(title_spans), - truncate_end(&detail, content.width as usize), - p.overlay0, - ); - doc_y += 2; - } - - if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "tabs", - p, - ); - doc_y += 1; - render_action_row_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "+ new tab", - p, - ); - doc_y += 1; - for (idx, tab) in ws.tabs.iter().enumerate() { - let active = idx == ws.active_tab; - let bg = mobile_item_bg(false, active, p); - let display_name = ws - .tab_display_name(idx) - .unwrap_or_else(|| (idx + 1).to_string()); - let label = if tab.is_auto_named() { - format!("tab {display_name}") - } else { - format!("{} · {display_name}", idx + 1) - }; - let title = Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - truncate_end(&label, content.width.saturating_sub(3) as usize), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - ]); - render_one_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - title, - ); - doc_y += 1; - } - } - - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "menu", - p, - ); - doc_y += 1; - for label in app.global_menu_labels() { - if let Some(y) = visible_y(viewport, app.mobile_switcher_scroll, doc_y) { - frame.render_widget( - Paragraph::new(format!(" {label}")) - .style(Style::default().fg(p.overlay1).bg(p.panel_bg)), - Rect::new(content.x, y, content.width, 1), - ); - } - doc_y += 1; - } -} - -fn mobile_agent_detail(entry: &AgentPanelEntry) -> String { - let mut parts = Vec::new(); - if let Some(tab_label) = entry.primary_tab_label.as_deref() { - parts.push(tab_label.to_string()); - } - let status = entry - .state_labels - .get(super::sidebar::agent_panel_status_key( - entry.state, - entry.seen, - )) - .cloned() - .unwrap_or_else(|| super::status::state_label(entry.state, entry.seen).to_string()); - parts.push(status); - if let Some(agent_label) = entry.agent_label.as_deref() { - parts.push(agent_label.to_string()); - } - format!(" {}", parts.join(" · ")) -} - -fn render_section_title_at( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - title: &str, - p: &Palette, -) { - let Some(y) = visible_y(viewport, scroll, doc_y) else { - return; - }; - render_section_title( - frame, - Rect::new(content.x, y, content.width.saturating_sub(1), 1), - title, - p, - ); -} - -fn render_action_row_at( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - label: &str, - p: &Palette, -) { - let Some(y) = visible_y(viewport, scroll, doc_y) else { - return; - }; - render_action_row(frame, Rect::new(content.x, y, content.width, 1), label, p); -} - -fn render_one_line_item( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - bg: ratatui::style::Color, - title: Line<'_>, -) { - fill_visible_doc_rect( - frame, - viewport, - content, - doc_y, - 1, - Style::default().bg(bg), - scroll, - ); - if let Some(y) = visible_y(viewport, scroll, doc_y) { - frame.render_widget( - Paragraph::new(title), - Rect::new(content.x, y, content.width, 1), - ); - } -} - -fn render_two_line_item( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - bg: ratatui::style::Color, - title: Line<'_>, - detail: String, - detail_fg: ratatui::style::Color, -) { - fill_visible_doc_rect( - frame, - viewport, - content, - doc_y, - 2, - Style::default().bg(bg), - scroll, - ); - if let Some(y) = visible_y(viewport, scroll, doc_y) { - frame.render_widget( - Paragraph::new(title), - Rect::new(content.x, y, content.width, 1), - ); - } - if let Some(y) = visible_y(viewport, scroll, doc_y + 1) { - frame.render_widget( - Paragraph::new(detail).style(Style::default().fg(detail_fg).bg(bg)), - Rect::new(content.x, y, content.width, 1), - ); - } -} - -fn visible_y(viewport: Rect, scroll: usize, doc_y: usize) -> Option<u16> { - let offset = doc_y.checked_sub(scroll)?; - (offset < viewport.height as usize).then_some(viewport.y + offset as u16) -} - -fn fill_visible_doc_rect( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - height: usize, - style: Style, - scroll: usize, -) { - for offset in 0..height { - if let Some(y) = visible_y(viewport, scroll, doc_y + offset) { - fill_rect(frame, Rect::new(content.x, y, content.width, 1), style); - } - } -} - -fn mobile_item_bg(selected: bool, active: bool, p: &Palette) -> ratatui::style::Color { - if selected { - p.surface0 - } else if active { - p.surface_dim - } else { - p.panel_bg - } -} - -fn inset_for_left_scrollbar(area: Rect) -> Rect { - if area.width <= 1 { - return Rect::default(); - } - Rect::new(area.x + 1, area.y, area.width - 1, area.height) -} - -fn render_left_scrollbar( - frame: &mut Frame, - area: Rect, - total_rows: usize, - visible_rows: usize, - scroll: usize, - p: &Palette, -) { - if area.width == 0 || area.height == 0 || visible_rows == 0 || total_rows <= visible_rows { - return; - } - - let track = Rect::new(area.x, area.y, 1, area.height); - let max_scroll = total_rows.saturating_sub(visible_rows); - let thumb_len = ((track.height as usize * visible_rows).div_ceil(total_rows)) - .max(1) - .min(track.height as usize) as u16; - let travel = track.height.saturating_sub(thumb_len); - let thumb_top = track.y + ((travel as usize * scroll.min(max_scroll)) / max_scroll) as u16; - - for y in track.y..track.y + track.height { - let is_thumb = y >= thumb_top && y < thumb_top + thumb_len; - frame.buffer_mut()[(track.x, y)] - .set_symbol(if is_thumb { "▌" } else { "│" }) - .set_style( - Style::default() - .fg(if is_thumb { p.accent } else { p.surface_dim }) - .bg(p.panel_bg), - ); - } -} - -fn render_section_title(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - frame.render_widget( - Paragraph::new(format!(" {title} ")).style( - Style::default() - .fg(p.overlay1) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - ), - Rect::new(area.x, area.y, area.width, 1), - ); -} - -fn render_action_row(frame: &mut Frame, area: Rect, label: &str, p: &Palette) { - if area.width == 0 || area.height == 0 { - return; - } - frame.render_widget( - Paragraph::new(format!(" {label}")).style( - Style::default() - .fg(p.accent) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - area, - ); -} - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - rect.width > 0 - && rect.height > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height -} - -fn mobile_screen_rect(app: &AppState) -> Rect { - let header = app.view.mobile_header_rect; - let terminal = app.view.terminal_area; - let x = header.x.min(terminal.x); - let y = header.y.min(terminal.y); - let right = (header.x + header.width).max(terminal.x + terminal.width); - let bottom = (header.y + header.height).max(terminal.y + terminal.height); - Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) -} - -/// Agent state counts across every workspace. The mobile header is global on -/// purpose: while you stare at one terminal, a blocked agent anywhere should -/// still surface. -#[derive(Debug, Default, Clone, Copy)] -struct GlobalAgentCounts { - blocked: usize, - done: usize, - working: usize, - idle: usize, -} - -impl GlobalAgentCounts { - fn total(&self) -> usize { - self.blocked + self.done + self.working + self.idle - } - - fn any_pending(&self) -> bool { - self.blocked > 0 || self.done > 0 || self.working > 0 - } -} - -fn global_agent_counts(app: &AppState) -> GlobalAgentCounts { - let mut counts = GlobalAgentCounts::default(); - for entry in crate::ui::all_agent_panel_entries(app) { - match (entry.state, entry.seen) { - (AgentState::Blocked, _) => counts.blocked += 1, - (AgentState::Idle, false) => counts.done += 1, - (AgentState::Working, _) => counts.working += 1, - (AgentState::Idle, true) => counts.idle += 1, - (AgentState::Unknown, _) => {} - } - } - counts -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SummaryTone { - Blocked, - Done, - Working, - Idle, - Muted, -} - -/// Ordered, non-zero breakdown for the header roll-up: attention states lead -/// (blocked → done → working → idle). Pure so it can be unit-tested. -fn agent_summary_segments( - counts: GlobalAgentCounts, - indicator_style: StatusIndicatorStyle, -) -> Vec<(String, SummaryTone)> { - if counts.total() == 0 { - return vec![("no agents".to_string(), SummaryTone::Muted)]; - } - if !counts.any_pending() { - return vec![("all idle".to_string(), SummaryTone::Muted)]; - } - let mut segments = Vec::new(); - if counts.blocked > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Blocked, - true, - Some("◉"), - counts.blocked, - "blocked", - ), - SummaryTone::Blocked, - )); - } - if counts.done > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Idle, - false, - Some("●"), - counts.done, - "done", - ), - SummaryTone::Done, - )); - } - if counts.working > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Working, - true, - None, - counts.working, - "working", - ), - SummaryTone::Working, - )); - } - if counts.idle > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Idle, - true, - None, - counts.idle, - "idle", - ), - SummaryTone::Idle, - )); - } - segments -} - -fn agent_summary_text( - indicator_style: StatusIndicatorStyle, - state: AgentState, - seen: bool, - dot_style_symbol: Option<&str>, - count: usize, - label: &str, -) -> String { - let symbol = match indicator_style { - StatusIndicatorStyle::Dots => dot_style_symbol, - StatusIndicatorStyle::Symbols => Some(state_icon_symbol(state, seen, indicator_style)), - }; - match symbol { - Some(symbol) => format!("{symbol} {count} {label}"), - None => format!("{count} {label}"), - } -} - -/// Greedily keep the most-urgent segments that fit `max_width` (counting the -/// leading space and " · " separators) and report whether any were dropped. -/// Segments are ordered by urgency, so the dropped tail is always the least -/// important state. -fn fit_summary_segments( - segments: Vec<(String, SummaryTone)>, - max_width: usize, -) -> (Vec<(String, SummaryTone)>, bool) { - let mut shown = Vec::new(); - let mut used = 1usize; // leading space - for (idx, segment) in segments.iter().enumerate() { - let sep = if idx > 0 { 3 } else { 0 }; // " · " - let seg_w = segment.0.chars().count(); - if used + sep + seg_w > max_width { - break; - } - used += sep + seg_w; - shown.push(segment.clone()); - } - let truncated = shown.len() < segments.len(); - (shown, truncated) -} - -fn agent_summary_line(app: &AppState, p: &Palette, max_width: u16) -> Line<'static> { - let segments = agent_summary_segments(global_agent_counts(app), app.status_indicators); - let (shown, truncated) = fit_summary_segments(segments, max_width as usize); - - let mut spans = vec![Span::styled(" ", Style::default().bg(p.panel_bg))]; - let mut used = 1usize; - for (idx, (text, tone)) in shown.into_iter().enumerate() { - if idx > 0 { - spans.push(Span::styled( - " · ", - Style::default().fg(p.overlay0).bg(p.panel_bg), - )); - used += 3; - } - // Only the leading (most urgent) segment keeps its state color; the - // rest stay dim so the urgent count is the loud thing. - let style = if idx == 0 { - let color = match tone { - SummaryTone::Blocked => p.red, - SummaryTone::Done => p.blue, - SummaryTone::Working => p.yellow, - SummaryTone::Idle | SummaryTone::Muted => p.overlay1, - }; - let style = Style::default().fg(color).bg(p.panel_bg); - if tone == SummaryTone::Muted { - style - } else { - style.add_modifier(Modifier::BOLD) - } - } else { - Style::default().fg(p.overlay1).bg(p.panel_bg) - }; - used += text.chars().count(); - spans.push(Span::styled(text, style)); - } - if truncated && used + 2 <= max_width as usize { - spans.push(Span::styled( - " …", - Style::default().fg(p.overlay0).bg(p.panel_bg), - )); - } - Line::from(spans) -} - -fn mobile_toast_title(toast: &ToastNotification) -> String { - match toast.kind { - ToastKind::NeedsAttention => toast - .title - .strip_suffix(" needs attention") - .map(|agent| format!("{agent} waiting")) - .unwrap_or_else(|| toast.title.clone()), - ToastKind::Finished => toast - .title - .strip_suffix(" finished") - .map(|agent| format!("{agent} done")) - .unwrap_or_else(|| toast.title.clone()), - ToastKind::UpdateInstalled => "update ready".to_string(), - } -} - -fn fill_rect(frame: &mut Frame, area: Rect, style: Style) { - if area.width == 0 || area.height == 0 { - return; - } - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - for x in area.x..area.x + area.width { - buf[(x, y)].set_symbol(" "); - buf[(x, y)].set_style(style); - } - } -} - -fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) { - if area.width == 0 || area.height == 0 { - return; - } - let buf = frame.buffer_mut(); - for x in area.x..area.x + area.width { - buf[(x, area.y)] - .set_symbol("─") - .set_style(Style::default().fg(p.surface_dim).bg(p.panel_bg)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn agent_entry(primary_tab_label: Option<&str>, agent_label: Option<&str>) -> AgentPanelEntry { - AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: PaneId::from_raw(1), - primary_label: "herdr".into(), - primary_tab_label: primary_tab_label.map(str::to_string), - pane_label: None, - terminal_title: None, - terminal_title_stripped: None, - agent_label: agent_label.map(str::to_string), - agent_kind_label: agent_label.map(str::to_string), - agent: agent_label.and_then(crate::detect::parse_agent_label), - state: AgentState::Idle, - seen: true, - last_agent_state_change_seq: None, - state_labels: std::collections::HashMap::new(), - tokens: std::collections::HashMap::new(), - } - } - - #[test] - fn global_agent_counts_ignore_active_agent_view_filter() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - crate::workspace::Workspace::test_new("blocked"), - crate::workspace::Workspace::test_new("working"), - ]; - app.ensure_test_terminals(); - for (ws_idx, state) in [(0, AgentState::Blocked), (1, AgentState::Working)] { - let pane_id = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = state; - } - app.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::Status, - ), - value: crate::api::schema::AgentViewValue::String("working".to_string()), - }), - sort: Vec::new(), - }); - - let counts = global_agent_counts(&app); - assert_eq!(counts.blocked, 1); - assert_eq!(counts.working, 1); - } - - #[test] - fn agent_summary_leads_with_attention_states_in_priority_order() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let segments = agent_summary_segments(counts, StatusIndicatorStyle::Dots); - let labels: Vec<&str> = segments.iter().map(|(text, _)| text.as_str()).collect(); - assert_eq!( - labels, - vec!["◉ 2 blocked", "● 1 done", "2 working", "1 idle"] - ); - assert_eq!(segments[0].1, SummaryTone::Blocked); - } - - #[test] - fn distinct_agent_summary_uses_configured_symbols_for_every_state() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let labels: Vec<String> = agent_summary_segments(counts, StatusIndicatorStyle::Symbols) - .into_iter() - .map(|(text, _)| text) - .collect(); - assert_eq!( - labels, - ["× 2 blocked", "✓ 1 done", "◐ 2 working", "○ 1 idle"] - ); - } - - #[test] - fn distinct_status_style_updates_mobile_blocked_badge() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![crate::workspace::Workspace::test_new("blocked")]; - app.ensure_test_terminals(); - app.status_indicators = StatusIndicatorStyle::Symbols; - let pane_id = app.workspaces[0].tabs[0].root_pane; - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); - terminal_state.detected_agent = Some(crate::detect::Agent::Claude); - terminal_state.state = AgentState::Blocked; - - let area = Rect::new(0, 0, 12, 2); - let mut terminal = - ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height)) - .unwrap(); - terminal - .draw(|frame| render_switch_button(&app, frame, area)) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(area.width - 1, 0)].symbol(), - "×" - ); - } - - #[test] - fn agent_summary_hides_empty_categories() { - let counts = GlobalAgentCounts { - done: 1, - working: 2, - ..Default::default() - }; - let labels: Vec<String> = agent_summary_segments(counts, StatusIndicatorStyle::Dots) - .into_iter() - .map(|(text, _)| text) - .collect(); - assert_eq!( - labels, - vec!["● 1 done".to_string(), "2 working".to_string()] - ); - } - - #[test] - fn agent_summary_collapses_to_all_idle_without_attention() { - let counts = GlobalAgentCounts { - idle: 3, - ..Default::default() - }; - assert_eq!( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - vec![("all idle".to_string(), SummaryTone::Muted)] - ); - } - - #[test] - fn agent_summary_drops_least_urgent_segments_when_narrow() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let (shown, truncated) = fit_summary_segments( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - 24, - ); - let labels: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect(); - assert_eq!(labels, vec!["◉ 2 blocked", "● 1 done"]); - assert!(truncated); - } - - #[test] - fn agent_summary_keeps_all_segments_when_wide_enough() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let (shown, truncated) = fit_summary_segments( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - 60, - ); - assert_eq!(shown.len(), 4); - assert!(!truncated); - } - - #[test] - fn agent_summary_reports_no_agents_when_empty() { - assert_eq!( - agent_summary_segments(GlobalAgentCounts::default(), StatusIndicatorStyle::Dots,), - vec![("no agents".to_string(), SummaryTone::Muted)] - ); - } - - #[test] - fn switcher_leads_with_agents_and_shifts_spaces_below() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("agents-first"); - workspace.test_add_tab(None); // two tabs -> two agent panes - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - for terminal in app.terminals.values_mut() { - terminal.agent_name = Some("pi".to_string()); - terminal.state = AgentState::Working; - } - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - assert_eq!(agent_panel_entries(&app).len(), 2); - // agents title (1) + 2 agents * 2 rows = 5, then spaces title + "new - // workspace" (2) before the first workspace ribbon at doc row 7. - assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 7); - - let viewport = mobile_switcher_areas(&app).viewport; - app.mobile_switcher_scroll = 100; - let agent_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 1); - assert!(matches!( - agent_hit, - Some(MobileSwitcherTarget::Agent { .. }) - )); - let workspace_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 7); - assert_eq!(workspace_hit, Some(MobileSwitcherTarget::Workspace(0))); - } - - fn worktree_workspace(name: &str, key: &str, linked: bool) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - checkout_path: std::path::PathBuf::from(format!("/repo/{name}")), - is_linked_worktree: linked, - }); - ws - } - - #[test] - fn switcher_spaces_follow_grouped_worktree_order() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - worktree_workspace("main", "repo-key", false), - crate::workspace::Workspace::test_new("other"), - worktree_workspace("feature", "repo-key", true), - ]; - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - // Grouped order pulls the worktree (idx 2) up under its parent (idx 0), - // ahead of the unrelated "other" workspace (idx 1): rows are main, - // feature, other. - assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 1).start, 6); - - let viewport = mobile_switcher_areas(&app).viewport; - // The second space row on screen is the worktree, not workspaces[1]. - let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4); - assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2))); - - // Mobile ignores collapse: even with the space folded on desktop, the - // worktree child still renders in the same position. - app.collapsed_space_keys.insert("repo-key".to_string()); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4); - let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4); - assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2))); - } - - #[test] - fn switcher_without_agents_keeps_spaces_first() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![crate::workspace::Workspace::test_new("shell-only")]; - app.active = Some(0); - app.selected = 0; - - // No attached terminals -> no agents -> no agents header, spaces lead. - assert_eq!(agent_panel_entries(&app).len(), 0); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 2); - } - - #[test] - fn mobile_agent_detail_includes_tab_context_when_available() { - let entry = agent_entry(Some("mobile-state"), Some("pi")); - - assert_eq!(mobile_agent_detail(&entry), " mobile-state · idle · pi"); - } - - #[test] - fn mobile_agent_detail_keeps_existing_compact_detail_without_tab_context() { - let entry = agent_entry(None, Some("pi")); - - assert_eq!(mobile_agent_detail(&entry), " idle · pi"); - } - - #[test] - fn mobile_tab_status_uses_compact_tab_label_and_position() { - let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); - let removed_tab = workspace.test_add_tab(None); - workspace.test_add_tab(None); - assert!(workspace.close_tab(removed_tab)); - workspace.active_tab = 1; - - assert_eq!(mobile_tab_status(&workspace), "tab 2 · 2/2"); - } - - #[test] - fn mobile_switcher_uses_compact_tab_label_for_auto_tab_labels() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); - let removed_tab = workspace.test_add_tab(None); - workspace.test_add_tab(None); - assert!(workspace.close_tab(removed_tab)); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - let backend = ratatui::backend::TestBackend::new(40, 20); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| { - render_mobile_panel( - &app, - &TerminalRuntimeRegistry::new(), - frame, - Rect::new(0, 0, 40, 20), - ) - }) - .unwrap(); - - let row = (0..40) - .map(|x| terminal.backend().buffer()[(x, 10)].symbol()) - .collect::<String>(); - - assert!(row.contains("tab 2"), "mobile tab row: {row:?}"); - assert!(!row.contains("tab 3"), "mobile tab row: {row:?}"); - } - - #[cfg(unix)] - #[tokio::test] - async fn mobile_header_uses_live_root_runtime_cwd_for_workspace_label() { - let unique = format!( - "herdr-mobile-header-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; - app.active = Some(0); - app.selected = 0; - app.view.mobile_menu_hit_area = Rect::new(30, 0, 10, 2); - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - let backend = ratatui::backend::TestBackend::new(40, 2); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| { - render_mobile_header(&app, &runtime_registry, frame, Rect::new(0, 0, 40, 2)) - }) - .unwrap(); - let row = (0..40) - .map(|x| terminal.backend().buffer()[(x, 0)].symbol()) - .collect::<String>(); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - assert!(row.contains("herdr"), "header row: {row:?}"); - assert!( - !row.contains("issue-264-nix-support"), - "header row: {row:?}" - ); - } -} diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs deleted file mode 100644 index cb2f235e..00000000 --- a/src/ui/navigator.rs +++ /dev/null @@ -1,643 +0,0 @@ -use ratatui::{ - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph}, - Frame, -}; - -use super::{ - scrollbar::{render_scrollbar, should_show_scrollbar}, - status::{state_icon, state_label_color}, - text::{display_width_u16, middle_elide, truncate_end}, - widgets::{panel_contrast_fg, render_panel_shell}, -}; -use crate::app::state::{ - navigator_display_lines, AppState, NavigatorDisplayLine, NavigatorRow, NavigatorStateFilter, - NavigatorTarget, -}; -use crate::terminal::TerminalRuntimeRegistry; - -pub(super) fn render_navigator_overlay( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, -) { - let popup = app.navigator_popup_rect(); - let Some(inner) = render_panel_shell(frame, popup, app.palette.accent, app.palette.panel_bg) - else { - return; - }; - - let search = app.navigator_search_rect(); - let body = app.navigator_body_rect(); - let detail = app.navigator_detail_rect(); - let footer = app.navigator_footer_rect(); - render_search(app, frame, search); - - if body.height > 0 { - let rows = app.navigator_rows_from(terminal_runtimes); - let lines = navigator_display_lines(&rows); - render_separator(frame, Rect::new(inner.x, search.y + 1, inner.width, 1), app); - render_rows(app, &rows, &lines, frame, body); - render_navigator_scrollbar(app, lines.len(), frame, body); - } - render_detail(app, terminal_runtimes, frame, detail); - render_footer(app, frame, footer); -} - -fn render_search(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - let focus_style = if app.navigator.search_focused { - Style::default().fg(p.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay0) - }; - let count = app - .workspaces - .iter() - .flat_map(|workspace| workspace.tabs.iter()) - .map(|tab| tab.panes.len()) - .sum::<usize>(); - let mut spans = vec![Span::styled(" / ", focus_style)]; - let query = app.navigator.query.trim(); - match app.navigator.state_filter { - Some(NavigatorStateFilter::Blocked) => push_state_chip( - &mut spans, - crate::detect::AgentState::Blocked, - true, - "blocked", - app, - ), - Some(NavigatorStateFilter::Working) => push_state_chip( - &mut spans, - crate::detect::AgentState::Working, - true, - "working", - app, - ), - Some(NavigatorStateFilter::Idle) => push_state_chip( - &mut spans, - crate::detect::AgentState::Idle, - true, - "idle", - app, - ), - Some(NavigatorStateFilter::Done) => push_state_chip( - &mut spans, - crate::detect::AgentState::Idle, - false, - "done", - app, - ), - None if query.is_empty() => spans.push(Span::styled( - "search panes", - Style::default().fg(p.overlay0), - )), - None => spans.push(Span::styled(query.to_string(), Style::default().fg(p.text))), - } - spans.push(Span::styled( - format!( - "{count:>width$} panes", - width = area.width.saturating_sub(16) as usize - ), - Style::default().fg(p.overlay0), - )); - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -fn push_state_chip( - spans: &mut Vec<Span<'static>>, - state: crate::detect::AgentState, - seen: bool, - label: &'static str, - app: &AppState, -) { - let (icon, icon_style) = state_icon(state, seen, app.status_indicators, &app.palette); - spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD))); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - label, - Style::default() - .fg(state_label_color(state, seen, &app.palette)) - .add_modifier(Modifier::BOLD), - )); -} - -fn render_separator(frame: &mut Frame, area: Rect, app: &AppState) { - if area.height == 0 || area.width == 0 { - return; - } - let line = "─".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(line).style(Style::default().fg(app.palette.surface1)), - area, - ); -} - -fn render_rows( - app: &AppState, - rows: &[NavigatorRow], - lines: &[NavigatorDisplayLine], - frame: &mut Frame, - body: Rect, -) { - let start = app.navigator.scroll.min(lines.len()); - let end = lines.len().min(start.saturating_add(body.height as usize)); - for (visible_idx, line) in lines[start..end].iter().enumerate() { - let NavigatorDisplayLine::Row(idx) = *line else { - continue; - }; - let y = body.y + visible_idx as u16; - let rect = Rect::new(body.x, y, body.width, 1); - let selected = idx == app.navigator.selected; - render_row(app, frame, rect, rows, idx, selected); - } -} - -fn render_row( - app: &AppState, - frame: &mut Frame, - rect: Rect, - rows: &[NavigatorRow], - idx: usize, - selected: bool, -) { - let row = &rows[idx]; - let p = &app.palette; - frame.render_widget(Clear, rect); - let base_style = if selected { - Style::default().bg(p.accent).fg(panel_contrast_fg(p)) - } else { - Style::default().bg(p.panel_bg).fg(p.text) - }; - let dim_style = if selected { - base_style - } else { - Style::default().fg(p.overlay0).bg(p.panel_bg) - }; - let filter_active = - app.navigator.state_filter.is_some() || !app.navigator.query.trim().is_empty(); - let context_only = filter_active && !row.matched; - let text_style = if selected { - base_style.add_modifier(Modifier::BOLD) - } else if context_only { - let dimmed = Style::default().fg(p.overlay0).bg(p.panel_bg); - if row.is_workspace { - dimmed.add_modifier(Modifier::BOLD) - } else { - dimmed - } - } else if row.is_workspace { - Style::default() - .fg(p.accent) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD) - } else if row.is_current { - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0).bg(p.panel_bg) - }; - let (status_icon, status_style) = state_icon(row.status, row.seen, app.status_indicators, p); - let status_style = if selected { - base_style.add_modifier(Modifier::BOLD) - } else if context_only { - Style::default().fg(p.overlay0).bg(p.panel_bg) - } else { - status_style.bg(p.panel_bg) - }; - - let prefix = tree_prefix(rows, idx); - let current = if row.is_current { "◆" } else { " " }; - let gutter = format!(" {current} "); - let gutter_style = if selected { - base_style - } else if row.is_current { - Style::default().fg(p.accent).bg(p.panel_bg) - } else { - dim_style - }; - // Branch glyphs recede one shade below the workspace caret so the - // structure stays behind the labels. - let tree_style = if selected { - base_style - } else if row.is_workspace { - dim_style - } else { - Style::default().fg(p.surface1).bg(p.panel_bg) - }; - let meta_width = metadata_width(rect.width); - let left_budget = rect - .width - .saturating_sub(meta_width) - .saturating_sub(display_width_u16(&format!("{gutter}{prefix} "))) - .saturating_sub(3) as usize; - let title = truncate_end(&row.label, left_budget); - - let spans = vec![ - Span::styled(gutter, gutter_style), - Span::styled(prefix, tree_style), - Span::styled(" ", base_style), - Span::styled(status_icon, status_style), - Span::raw(" "), - Span::styled(title, text_style), - ]; - frame.render_widget(Paragraph::new(Line::from(spans)).style(base_style), rect); - - if meta_width > 0 { - let meta_rect = Rect::new( - rect.x + rect.width.saturating_sub(meta_width), - rect.y, - meta_width, - 1, - ); - let meta = truncate_end(&row.meta, meta_width.saturating_sub(2) as usize); - let meta_style = if selected { - base_style - } else if context_only || row.is_workspace || row.is_tab { - Style::default().fg(p.overlay0).bg(p.panel_bg) - } else { - Style::default() - .fg(state_label_color(row.status, row.seen, p)) - .bg(p.panel_bg) - }; - frame.render_widget( - Paragraph::new(format!(" {meta}")).style(meta_style), - meta_rect, - ); - } -} - -/// Tree prefix for a navigator row: expand caret for workspaces, connected -/// branch glyphs for children (`├──`, `└──` for the last sibling, with `│` -/// continuation lines under ancestors that have more siblings below). -fn tree_prefix(rows: &[NavigatorRow], idx: usize) -> String { - let row = &rows[idx]; - if row.is_workspace { - return if row.expanded { "▾" } else { "▸" }.to_string(); - } - if row.depth == 0 { - return " ".to_string(); - } - let mut prefix = String::new(); - for level in 1..row.depth { - prefix.push_str(if has_following_sibling_at_depth(rows, idx, level) { - "│ " - } else { - " " - }); - } - prefix.push_str(if has_following_sibling_at_depth(rows, idx, row.depth) { - "├──" - } else { - "└──" - }); - prefix -} - -/// Whether another row at `depth` follows `idx` before the subtree at that -/// depth ends (a row shallower than `depth` closes the subtree). -fn has_following_sibling_at_depth(rows: &[NavigatorRow], idx: usize, depth: u8) -> bool { - rows[idx + 1..] - .iter() - .take_while(|row| row.depth >= depth) - .any(|row| row.depth == depth) -} - -fn render_navigator_scrollbar(app: &AppState, line_count: usize, frame: &mut Frame, body: Rect) { - if body.width <= 1 || body.height == 0 { - return; - } - let viewport = body.height as usize; - if line_count <= viewport { - return; - } - let metrics = crate::pane::ScrollMetrics { - viewport_rows: viewport, - offset_from_bottom: line_count - .saturating_sub(viewport) - .saturating_sub(app.navigator.scroll), - max_offset_from_bottom: line_count.saturating_sub(viewport), - }; - if !should_show_scrollbar(metrics) { - return; - } - let track = Rect::new(body.x + body.width - 1, body.y, 1, body.height); - render_scrollbar( - frame, - metrics, - track, - app.palette.surface_dim, - app.palette.overlay0, - "▕", - ); -} - -fn metadata_width(width: u16) -> u16 { - if width >= 90 { - 28 - } else if width >= 68 { - 20 - } else if width >= 52 { - 14 - } else { - 0 - } -} - -fn render_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.height == 0 || area.width == 0 { - return; - } - render_separator(frame, area, app); - let detail = selected_detail(app, terminal_runtimes); - if detail.is_empty() { - return; - } - let text = middle_elide(&detail, area.width.saturating_sub(2) as usize); - frame.render_widget( - Paragraph::new(format!(" {text}")).style(Style::default().fg(app.palette.overlay0)), - area, - ); -} - -fn selected_detail(app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry) -> String { - let rows = app.navigator_rows_from(terminal_runtimes); - let Some(row) = rows.get(app.navigator.selected) else { - return String::new(); - }; - match row.target { - NavigatorTarget::Workspace { ws_idx } => workspace_detail(app, terminal_runtimes, ws_idx), - NavigatorTarget::Tab { ws_idx, tab_idx } => { - tab_detail(app, terminal_runtimes, ws_idx, tab_idx) - } - NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - } => pane_detail(app, terminal_runtimes, ws_idx, tab_idx, pane_id), - } -} - -fn workspace_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>(); - let mut parts = vec![label, format!("{pane_count} panes")]; - if !rowless_workspace_activity(app, terminal_runtimes, ws_idx).is_empty() { - parts.push(rowless_workspace_activity(app, terminal_runtimes, ws_idx)); - } - parts.join(" · ") -} - -fn tab_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, - tab_idx: usize, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return String::new(); - }; - let mut parts = vec![ - ws.display_name_from(&app.terminals, terminal_runtimes), - format!( - "tab: {}", - ws.tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()) - ), - format!("{} panes", tab.panes.len()), - ]; - let rows = app.navigator_rows_from(terminal_runtimes); - if let Some(meta) = rows - .into_iter() - .find(|row| matches!(row.target, NavigatorTarget::Tab { ws_idx: row_ws_idx, tab_idx: row_tab_idx } if row_ws_idx == ws_idx && row_tab_idx == tab_idx)) - .map(|row| row.meta) - .filter(|meta| !meta.is_empty()) - { - parts.push(meta); - } - parts.join(" · ") -} - -fn pane_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, - tab_idx: usize, - pane_id: crate::layout::PaneId, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return String::new(); - }; - let mut parts = vec![ws.display_name_from(&app.terminals, terminal_runtimes)]; - if ws.tabs.len() > 1 { - parts.push(format!( - "tab: {}", - ws.tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()) - )); - } - if let Some(pane_number) = ws.public_pane_number(pane_id) { - parts.push(format!("pane {pane_number}")); - } - if let Some(terminal_id) = tab.terminal_id(pane_id) { - if let Some(terminal) = app.terminals.get(terminal_id) { - let presentation = terminal.effective_presentation(); - if let Some(title) = presentation.title { - parts.push(title); - } - let display_agent = terminal.effective_display_agent(); - if let Some(agent) = display_agent.as_deref().or_else(|| { - terminal - .agent_name - .as_deref() - .or_else(|| terminal.effective_agent_label()) - }) { - parts.push(agent.to_string()); - let seen = tab - .panes - .get(&pane_id) - .map(|pane| pane.seen) - .unwrap_or(true); - let state = row_state(app, ws_idx, tab_idx, pane_id); - let status = presentation - .state_labels - .get(display_state(state, seen)) - .cloned() - .unwrap_or_else(|| display_state(state, seen).to_string()); - parts.push(status); - } else { - parts.push("shell".to_string()); - } - } - } - parts.join(" · ") -} - -fn rowless_workspace_activity( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> String { - app.navigator_rows_from(terminal_runtimes) - .into_iter() - .find(|row| matches!(row.target, NavigatorTarget::Workspace { ws_idx: row_ws_idx } if row_ws_idx == ws_idx)) - .map(|row| row.meta) - .unwrap_or_default() -} - -fn row_state( - app: &AppState, - ws_idx: usize, - tab_idx: usize, - pane_id: crate::layout::PaneId, -) -> crate::detect::AgentState { - app.workspaces - .get(ws_idx) - .and_then(|ws| ws.tabs.get(tab_idx)) - .and_then(|tab| tab.terminal_id(pane_id)) - .and_then(|terminal_id| app.terminals.get(terminal_id)) - .map(|terminal| terminal.state) - .unwrap_or(crate::detect::AgentState::Unknown) -} - -fn display_state(state: crate::detect::AgentState, seen: bool) -> &'static str { - match (state, seen) { - (crate::detect::AgentState::Blocked, _) => "blocked", - (crate::detect::AgentState::Working, _) => "working", - (crate::detect::AgentState::Idle, false) => "done", - (crate::detect::AgentState::Idle, true) => "idle", - (crate::detect::AgentState::Unknown, _) => "unknown", - } -} - -fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { - if area.height == 0 { - return; - } - let p = &app.palette; - let key = Style::default().fg(p.accent).add_modifier(Modifier::BOLD); - let dim = Style::default().fg(p.overlay0); - let line = if app.navigator.search_focused { - Line::from(vec![ - Span::styled(" enter", key), - Span::styled(" switch ", dim), - Span::styled("↑↓", key), - Span::styled(" move ", dim), - Span::styled("ctrl+u", key), - Span::styled(" clear ", dim), - Span::styled("esc", key), - Span::styled(" back", dim), - ]) - } else { - Line::from(vec![ - Span::styled(" enter", key), - Span::styled(" switch ", dim), - Span::styled("/", key), - Span::styled(" search ", dim), - Span::styled("b/w/i/d/a", key), - Span::styled(" states ", dim), - Span::styled("j/k/↑↓", key), - Span::styled(" move ", dim), - Span::styled("esc", key), - Span::styled(" close", dim), - ]) - }; - frame.render_widget(Paragraph::new(line), area); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::detect::AgentState; - - fn row(depth: u8, is_workspace: bool) -> NavigatorRow { - NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx: 0 }, - depth, - label: String::new(), - meta: String::new(), - status: AgentState::Idle, - seen: true, - is_current: false, - is_workspace, - is_tab: false, - expanded: true, - search_text: String::new(), - matched: true, - } - } - - fn multi_tab_rows() -> Vec<NavigatorRow> { - vec![ - row(0, true), // workspace - row(1, false), // tab a - row(2, false), // pane - row(2, false), // pane (last in tab a) - row(1, false), // tab b (last tab) - row(2, false), // pane (last in tab b) - row(0, true), // workspace - row(1, false), // pane (single child) - ] - } - - #[test] - fn workspace_rows_use_expand_caret() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 0), "▾"); - let mut collapsed = rows.clone(); - collapsed[0].expanded = false; - assert_eq!(tree_prefix(&collapsed, 0), "▸"); - } - - #[test] - fn middle_children_get_branch_glyph() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 1), "├──"); - assert_eq!(tree_prefix(&rows, 2), "│ ├──"); - } - - #[test] - fn last_children_get_terminator_glyph() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 3), "│ └──"); - assert_eq!(tree_prefix(&rows, 4), "└──"); - assert_eq!(tree_prefix(&rows, 7), "└──"); - } - - #[test] - fn spine_stops_after_last_ancestor_sibling() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 5), " └──"); - } - - #[test] - fn next_workspace_does_not_extend_previous_subtree() { - // The pane at idx 5 is last in its workspace even though another - // workspace with children follows. - let rows = multi_tab_rows(); - assert!(!has_following_sibling_at_depth(&rows, 5, 1)); - assert!(!has_following_sibling_at_depth(&rows, 5, 2)); - } -} diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 8829acc7..94c9b2a0 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -1,114 +1,19 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, - Frame, -}; - -use super::widgets::{ - action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, - render_modal_shell, -}; -use crate::app::AppState; - -const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; - -pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - super::dim_background(frame, area); - render_onboarding_welcome(app, frame, area); -} +use ratatui::layout::Rect; + +pub(crate) const ONBOARDING_TITLE: &str = " herdr"; +pub(crate) const ONBOARDING_SUBTITLE: &str = " terminal workspace manager for coding agents"; +pub(crate) const ONBOARDING_DESCRIPTION: [&str; 3] = [ + " this is a mouse-first terminal.", + " click the sidebar to switch workspaces, drag pane", + " borders to resize, right-click for context menus.", +]; +pub(crate) const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; +pub(crate) const ONBOARDING_PREFIX_SUFFIX: &str = " enters prefix mode · "; +pub(crate) const ONBOARDING_HELP_LABEL: &str = "?"; +pub(crate) const ONBOARDING_HELP_SUFFIX: &str = " shows keybinds and settings"; +pub(crate) const ONBOARDING_NEXT: &str = + " next: install optional agent integrations for more reliable state"; pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { - Rect::new( - area.x, - area.y, - action_button_width(Some("↵"), "continue"), - 1, - ) -} - -fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else { - return; - }; - if inner.height < 11 { - return; - } - - let stack = modal_stack_areas(inner, 2, 0, 1, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - let content_rows = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<4>(stack.content); - - frame.render_widget( - Paragraph::new(" herdr").style( - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - header_rows[0], - ); - frame.render_widget( - Paragraph::new(" terminal workspace manager for coding agents") - .style(Style::default().fg(app.palette.overlay0)), - header_rows[1], - ); - - frame.render_widget( - Paragraph::new( - " this is a mouse-first terminal.\n click the sidebar to switch workspaces, drag pane\n borders to resize, right-click for context menus.", - ) - .style(Style::default().fg(app.palette.overlay1)), - content_rows[0], - ); - - let key_line = Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - ONBOARDING_PREFIX_LABEL, - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " enters prefix mode · ", - Style::default().fg(app.palette.overlay1), - ), - Span::styled( - "?", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " shows keybinds and settings", - Style::default().fg(app.palette.overlay1), - ), - ]); - frame.render_widget(Paragraph::new(key_line), content_rows[2]); - - frame.render_widget( - Paragraph::new(" next: install optional agent integrations for more reliable state") - .style(Style::default().fg(app.palette.overlay1)), - content_rows[3], - ); - - let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default()); - render_action_button( - frame, - continue_rect, - Some("↵"), - "continue", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); + super::widgets::continue_button_rect(area) } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index f20b55b5..cd5ea34c 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -1,8 +1,8 @@ use ratatui::{ + buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders}, Frame, }; @@ -12,7 +12,7 @@ use super::text::display_width; use super::text::truncate_end; use super::widgets::panel_contrast_fg; use crate::app::state::Palette; -use crate::app::{AppState, Mode}; +use crate::app::AppState; use crate::layout::PaneInfo; use crate::popup_size::resolve_popup_geometry; use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry}; @@ -352,60 +352,13 @@ pub(super) fn render_panes( return; }; - let multi_pane = ws.layout.pane_count() > 1; - let terminal_active = app.mode == Mode::Terminal; - for info in pane_infos { if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { let show_cursor = info.is_focused - && terminal_active && !pane_is_scrolled_back(rt) && app.pane_exposes_host_cursor(ws_idx, info.id); rt.render(frame, info.inner_rect, show_cursor); render_pane_scrollbar(app, frame, info, rt); - - let should_dim = !info.is_focused && multi_pane && !terminal_active; - if should_dim { - let inner = info.inner_rect; - let buf = frame.buffer_mut(); - for y in inner.y..inner.y + inner.height { - for x in inner.x..inner.x + inner.width { - let cell = &mut buf[(x, y)]; - cell.set_style(cell.style().add_modifier(Modifier::DIM)); - } - } - } - - let (copy_search_top, copy_search_bottom, copy_search_matches) = - validated_copy_mode_search_matches(app, info, rt); - render_copy_mode_search_highlights( - app, - frame, - info, - copy_search_top, - copy_search_bottom, - ©_search_matches, - false, - ); - render_selection_highlight( - &app.selection, - frame, - info.id, - info.inner_rect, - rt.scroll_metrics(), - &app.palette, - app.host_terminal_theme, - ); - render_copy_mode_search_highlights( - app, - frame, - info, - copy_search_top, - copy_search_bottom, - ©_search_matches, - true, - ); - render_copy_mode_cursor(app, frame, info); } } @@ -443,36 +396,6 @@ pub(super) fn resize_popup_pane( } } -pub(super) fn render_popup_pane( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let Some(popup) = app.popup_pane.as_ref() else { - return; - }; - let Some((outer, inner)) = popup_pane_rects(app, area) else { - return; - }; - let Some(rt) = terminal_runtimes.get(&popup.terminal_id) else { - return; - }; - let title = app - .terminals - .get(&popup.terminal_id) - .and_then(|terminal| terminal.manual_label.as_deref()) - .unwrap_or("popup"); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.palette.accent)) - .title(pane_border_title(title, outer.width, true).unwrap_or_default()) - .style(Style::default().bg(app.palette.panel_bg)); - frame.render_widget(Clear, outer); - frame.render_widget(block, outer); - rt.render(frame, inner, !pane_is_scrolled_back(rt)); -} - #[derive(Clone, Copy, Default)] struct LineCell { up: bool, @@ -724,141 +647,25 @@ fn line_cell_symbol(line: LineCell) -> &'static str { } } -fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) { - if app.mode != Mode::Copy { - return; - } - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - if copy_mode.pane_id != info.id - || copy_mode.cursor_row >= info.inner_rect.height - || copy_mode.cursor_col >= info.inner_rect.width - { - return; - } - - let x = info.inner_rect.x + copy_mode.cursor_col; - let y = info.inner_rect.y + copy_mode.cursor_row; - let cell = &mut frame.buffer_mut()[(x, y)]; - cell.set_style( - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); -} - -fn validated_copy_mode_search_matches( - app: &AppState, - info: &PaneInfo, - rt: &crate::terminal::TerminalRuntime, -) -> (u32, u32, Vec<(usize, crate::pane::TerminalTextMatch)>) { - let Some(copy_mode) = app.copy_mode.as_ref() else { - return (0, 0, Vec::new()); - }; - if copy_mode.pane_id != info.id { - return (0, 0, Vec::new()); - } - let Some(metrics) = rt.scroll_metrics() else { - return (0, 0, Vec::new()); - }; - let top = metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - .min(u32::MAX as usize) as u32; - let bottom = top.saturating_add(u32::from(info.inner_rect.height.saturating_sub(1))); - let first_visible = copy_mode - .search - .matches - .partition_point(|text_match| text_match.end.row < top); - let visible = ©_mode.search.matches[first_visible..]; - let visible_len = visible.partition_point(|text_match| text_match.start.row <= bottom); - let candidates = visible[..visible_len].to_vec(); - let validity = rt.text_matches_are_current(&candidates); - - let matches = candidates - .into_iter() - .zip(validity) - .enumerate() - .filter_map(|(offset, (text_match, is_current))| { - is_current.then_some((first_visible + offset, text_match)) - }) - .collect(); - (top, bottom, matches) -} - -fn render_copy_mode_search_highlights( - app: &AppState, - frame: &mut Frame, - info: &PaneInfo, - top: u32, - bottom: u32, - matches: &[(usize, crate::pane::TerminalTextMatch)], - current_only: bool, -) { - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - let current = copy_mode.search.current; - let style = if current_only { - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface1) - }; - - for &(index, text_match) in matches { - if (current == Some(index)) != current_only { - continue; - } - let start_row = text_match.start.row.max(top); - let end_row = text_match.end.row.min(bottom); - for absolute_row in start_row..=end_row { - let viewport_row = absolute_row.saturating_sub(top) as u16; - let start_col = if absolute_row == text_match.start.row { - text_match.start.col - } else { - 0 - }; - let end_col = if absolute_row == text_match.end.row { - text_match.end.col - } else { - info.inner_rect.width.saturating_sub(1) - }; - for col in start_col..=end_col.min(info.inner_rect.width.saturating_sub(1)) { - let x = info.inner_rect.x.saturating_add(col); - let y = info.inner_rect.y.saturating_add(viewport_row); - frame.buffer_mut()[(x, y)].set_style(style); - } - } - } -} - -fn render_selection_highlight( - selection: &Option<crate::selection::Selection>, - frame: &mut Frame, - pane_id: crate::layout::PaneId, +pub(crate) fn render_selection_highlight<P: PartialEq>( + selection: Option<&crate::selection::Selection<P>>, + buffer: &mut Buffer, + pane_id: &P, inner: Rect, scroll_metrics: Option<crate::pane::ScrollMetrics>, p: &Palette, host_theme: crate::terminal_theme::TerminalTheme, ) { - if let Some(sel) = selection { - if sel.is_visible() && sel.pane_id == pane_id { - let buf = frame.buffer_mut(); - let style = automatic_selection_style(p, host_theme); - for y in 0..inner.height { - for x in 0..inner.width { - if sel.contains(y, x, scroll_metrics) { - let cell = &mut buf[(inner.x + x, inner.y + y)]; - cell.set_style(style); - } - } + let Some(selection) = + selection.filter(|selection| selection.is_visible() && &selection.pane_id == pane_id) + else { + return; + }; + let style = automatic_selection_style(p, host_theme); + for y in 0..inner.height { + for x in 0..inner.width { + if selection.contains(y, x, scroll_metrics) { + buffer[(inner.x + x, inner.y + y)].set_style(style); } } } @@ -959,47 +766,6 @@ fn color_to_rgb(color: Color) -> Option<Rgb> { } } -pub(super) fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - let lines = vec![ - Line::from(""), - Line::from(""), - Line::from(Span::styled( - " No workspaces yet", - Style::default().fg(p.overlay0), - )), - Line::from(""), - Line::from(Span::styled( - " A workspace is one project context.", - Style::default().fg(p.overlay1), - )), - Line::from(Span::styled( - " Its root pane (top-left) sets the default repo or folder name.", - Style::default().fg(p.overlay1), - )), - Line::from(""), - Line::from(vec![ - Span::styled(" Press ", Style::default().fg(p.overlay0)), - Span::styled( - app.keybinds - .new_workspace - .label() - .unwrap_or_else(|| "unset".to_string()), - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled(" to create one", Style::default().fg(p.overlay0)), - ]), - ]; - frame.render_widget( - Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(p.surface_dim)), - ), - area, - ); -} - #[cfg(test)] mod tests { use super::*; @@ -1009,14 +775,13 @@ mod tests { use crate::terminal::TerminalState; use crate::workspace::Workspace; - fn render_view_pane_borders(app: &AppState, ws: &Workspace, frame: &mut Frame) { - render_pane_borders( - app, - ws, - &app.view.pane_infos, - &app.view.split_borders, - frame, - ); + fn render_view_pane_borders( + app: &AppState, + ws: &Workspace, + split_borders: &[crate::layout::SplitBorder], + frame: &mut Frame, + ) { + render_pane_borders(app, ws, &app.view.pane_infos, split_borders, frame); } #[test] @@ -1052,7 +817,6 @@ mod tests { #[test] fn pane_border_renderer_places_adjacent_cjk_by_display_width() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.view.terminal_area = Rect::new(0, 0, 12, 3); let ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; @@ -1073,7 +837,7 @@ mod tests { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(12, 3)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &[], frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1208,7 +972,6 @@ mod tests { #[test] fn global_pane_border_renderer_composes_junctions_and_focus_style() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.view.terminal_area = Rect::new(0, 0, 4, 4); app.view.pane_infos = vec![ PaneInfo { @@ -1244,7 +1007,7 @@ mod tests { is_focused: false, }, ]; - app.view.split_borders = vec![ + let split_borders = vec![ crate::layout::SplitBorder { pos: 2, direction: ratatui::layout::Direction::Horizontal, @@ -1265,7 +1028,7 @@ mod tests { ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 4)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &split_borders, frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1278,7 +1041,6 @@ mod tests { #[test] fn gapped_pane_focus_does_not_color_neighbor_border() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.pane_gaps = true; app.view.terminal_area = Rect::new(0, 0, 4, 3); app.view.pane_infos = vec![ @@ -1304,7 +1066,7 @@ mod tests { ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 3)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &[], frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1532,7 +1294,11 @@ mod tests { ..Default::default() }; let expected_style = automatic_selection_style(&palette, host_theme); - let selection = Some(Selection::range(PaneId::from_raw(1), 0, 0, 2, None)); + let selection = Some(Selection::absolute_range( + PaneId::from_raw(1), + (0, 0), + (0, 2), + )); let backend = ratatui::backend::TestBackend::new(4, 1); let mut terminal = ratatui::Terminal::new(backend).unwrap(); @@ -1552,9 +1318,9 @@ mod tests { ); buf[(2, 0)].set_style(Style::default().fg(Color::Blue).bg(Color::Reset)); render_selection_highlight( - &selection, - frame, - PaneId::from_raw(1), + selection.as_ref(), + frame.buffer_mut(), + &PaneId::from_raw(1), Rect::new(0, 0, 4, 1), None, &palette, diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index 5274739c..5f6dc078 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -1,250 +1,15 @@ use ratatui::{ - layout::{Constraint, Layout, Rect}, + layout::Rect, style::{Modifier, Style}, text::{Line, Span}, widgets::{Paragraph, Wrap}, - Frame, }; -use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; -use super::widgets::{ - action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, - render_modal_header, render_modal_shell, -}; -use crate::app::{ - state::{Palette, ProductAnnouncementState, ReleaseNotesState}, - AppState, -}; +use crate::app::state::{Palette, ProductAnnouncementState, ReleaseNotesState}; pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24); pub(crate) const PRODUCT_ANNOUNCEMENT_MODAL_SIZE: (u16, u16) = (88, 24); -pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(notes) = &app.release_notes else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_modal_shell( - frame, - area, - RELEASE_NOTES_MODAL_SIZE.0, - RELEASE_NOTES_MODAL_SIZE.1, - &app.palette, - ) else { - return; - }; - if inner.height < 8 || inner.width < 20 { - return; - } - - let stack = modal_stack_areas(inner, 2, 1, 0, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - - let header_title_area = Rect::new( - header_rows[0].x + 1, - header_rows[0].y, - header_rows[0].width.saturating_sub(2), - header_rows[0].height, - ); - let header_subtitle_area = Rect::new( - header_rows[1].x + 1, - header_rows[1].y, - header_rows[1].width.saturating_sub(2), - header_rows[1].height, - ); - - render_modal_header( - frame, - header_title_area, - &format!("v{}", notes.version), - &app.palette, - ); - let subtitle = if notes.preview { - "update ready" - } else { - "what's new in this release" - }; - frame.render_widget( - Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)), - header_subtitle_area, - ); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - "close", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - - let notes_body = stack.content; - let display_lines = - release_notes_display_lines(notes, &app.update_install_command, &app.palette); - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize, - max_offset_from_bottom: app.release_notes_max_scroll() as usize, - viewport_rows: notes_body.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(notes_body, metrics); - let notes_text_area = track - .map(|_| { - Rect::new( - notes_body.x, - notes_body.y, - notes_body.width.saturating_sub(1), - notes_body.height, - ) - }) - .unwrap_or(notes_body); - - let body = Paragraph::new( - display_lines - .into_iter() - .map(|(_, line)| line) - .collect::<Vec<_>>(), - ) - .wrap(Wrap { trim: false }) - .scroll((notes.scroll, 0)); - frame.render_widget(body, notes_text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close", Style::default().fg(app.palette.overlay0)), - Span::styled(" esc / enter ", Style::default().fg(app.palette.text)), - ])), - stack.footer.unwrap_or_default(), - ); -} - -pub(super) fn render_product_announcement_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(announcement) = &app.product_announcement else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_modal_shell( - frame, - area, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, - &app.palette, - ) else { - return; - }; - if inner.height < 8 || inner.width < 20 { - return; - } - - let stack = modal_stack_areas(inner, 2, 1, 0, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - - let header_title_area = Rect::new( - header_rows[0].x + 1, - header_rows[0].y, - header_rows[0].width.saturating_sub(2), - header_rows[0].height, - ); - let header_subtitle_area = Rect::new( - header_rows[1].x + 1, - header_rows[1].y, - header_rows[1].width.saturating_sub(2), - header_rows[1].height, - ); - - render_modal_header(frame, header_title_area, &announcement.title, &app.palette); - let subtitle = if announcement.preview { - "product announcement preview" - } else { - "product announcement" - }; - frame.render_widget( - Paragraph::new(format!("{subtitle} · v{}", announcement.version)) - .style(Style::default().fg(app.palette.overlay1)), - header_subtitle_area, - ); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - "close", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - - let body_rect = stack.content; - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app - .product_announcement_max_scroll() - .saturating_sub(announcement.scroll) as usize, - max_offset_from_bottom: app.product_announcement_max_scroll() as usize, - viewport_rows: body_rect.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(body_rect, metrics); - let text_area = track - .map(|_| { - Rect::new( - body_rect.x, - body_rect.y, - body_rect.width.saturating_sub(1), - body_rect.height, - ) - }) - .unwrap_or(body_rect); - - let body = Paragraph::new( - product_announcement_display_lines(announcement, &app.palette) - .into_iter() - .map(|(_, line)| line) - .collect::<Vec<_>>(), - ) - .wrap(Wrap { trim: false }) - .scroll((announcement.scroll, 0)); - frame.render_widget(body, text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close", Style::default().fg(app.palette.overlay0)), - Span::styled(" esc / enter ", Style::default().fg(app.palette.text)), - ])), - stack.footer.unwrap_or_default(), - ); -} - fn release_notes_inline_spans<'a>( text: &str, base_style: Style, @@ -428,6 +193,53 @@ pub(crate) fn product_announcement_display_lines<'a>( release_notes_lines(announcement.body.as_str(), p) } +fn display_lines_scroll_metrics( + lines: &[(usize, Line<'_>)], + scroll: u16, + body: Rect, +) -> crate::pane::ScrollMetrics { + let viewport_rows = body.height.max(1) as usize; + let rows_for_width = |width: u16| release_notes_wrapped_line_count(lines, width.max(1)); + let full_width = body.width.max(1); + let initial_rows = rows_for_width(full_width); + let wrap_width = if initial_rows > viewport_rows && full_width > 1 { + full_width.saturating_sub(1) + } else { + full_width + }; + let max_offset_from_bottom = rows_for_width(wrap_width).saturating_sub(viewport_rows); + crate::pane::ScrollMetrics { + offset_from_bottom: max_offset_from_bottom.saturating_sub(usize::from(scroll)), + max_offset_from_bottom, + viewport_rows, + } +} + +pub(crate) fn release_notes_scroll_metrics( + notes: &ReleaseNotesState, + install_command: &str, + body: Rect, + p: &Palette, +) -> crate::pane::ScrollMetrics { + display_lines_scroll_metrics( + &release_notes_display_lines(notes, install_command, p), + notes.scroll, + body, + ) +} + +pub(crate) fn product_announcement_scroll_metrics( + announcement: &ProductAnnouncementState, + body: Rect, + p: &Palette, +) -> crate::pane::ScrollMetrics { + display_lines_scroll_metrics( + &product_announcement_display_lines(announcement, p), + announcement.scroll, + body, + ) +} + pub(crate) fn release_notes_wrapped_line_count(lines: &[(usize, Line<'_>)], width: u16) -> usize { Paragraph::new( lines @@ -440,8 +252,7 @@ pub(crate) fn release_notes_wrapped_line_count(lines: &[(usize, Line<'_>)], widt } pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect { - let width = action_button_width(Some("esc"), "close"); - Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) + super::widgets::close_button_rect(area) } #[cfg(test)] diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs index b73b2c50..cf700825 100644 --- a/src/ui/scrollbar.rs +++ b/src/ui/scrollbar.rs @@ -1,4 +1,5 @@ use ratatui::{ + buffer::Buffer, layout::Rect, style::{Color, Style}, Frame, @@ -75,7 +76,7 @@ pub(crate) fn scrollbar_thumb_grab_offset( row: u16, ) -> Option<u16> { let thumb = scrollbar_thumb(metrics, track)?; - (row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top) + (row >= thumb.top && row < thumb.top + thumb.len).then(|| row - thumb.top) } fn scrollbar_offset_from_thumb_top( @@ -132,8 +133,8 @@ pub(crate) fn scrollbar_offset_from_drag_row( scrollbar_offset_from_thumb_top(metrics, track, desired_top) } -pub(super) fn render_scrollbar( - frame: &mut Frame, +pub(crate) fn render_scrollbar_buffer( + buffer: &mut Buffer, metrics: crate::pane::ScrollMetrics, track: Rect, track_color: Color, @@ -148,19 +149,40 @@ pub(super) fn render_scrollbar( return; }; - let buf = frame.buffer_mut(); for y in track.y..track.y + track.height { - let cell = &mut buf[(track.x, y)]; + let cell = &mut buffer[(track.x, y)]; cell.set_symbol("▕"); cell.set_style(Style::default().fg(track_color)); } for y in thumb.top..thumb.top + thumb.len { - let cell = &mut buf[(track.x, y)]; + let cell = &mut buffer[(track.x, y)]; cell.set_symbol(thumb_symbol); cell.set_style(Style::default().fg(thumb_color)); } } +pub(crate) fn render_pane_scrollbar_buffer( + buffer: &mut Buffer, + metrics: crate::pane::ScrollMetrics, + track: Rect, + palette: &crate::app::state::Palette, + focused: bool, +) { + let (track_color, thumb_color, thumb_symbol) = if focused { + (palette.overlay0, palette.overlay1, "▐") + } else { + (palette.surface_dim, palette.overlay0, "▕") + }; + render_scrollbar_buffer( + buffer, + metrics, + track, + track_color, + thumb_color, + thumb_symbol, + ); +} + pub(super) fn render_pane_scrollbar( app: &AppState, frame: &mut Frame, @@ -173,19 +195,11 @@ pub(super) fn render_pane_scrollbar( let Some(track) = pane_scrollbar_rect(info) else { return; }; - - let (track_color, thumb_color, thumb_symbol) = if info.is_focused { - (app.palette.overlay0, app.palette.overlay1, "▐") - } else { - (app.palette.surface_dim, app.palette.overlay0, "▕") - }; - - render_scrollbar( - frame, + render_pane_scrollbar_buffer( + frame.buffer_mut(), metrics, track, - track_color, - thumb_color, - thumb_symbol, + &app.palette, + info.is_focused, ); } diff --git a/src/ui/settings.rs b/src/ui/settings.rs deleted file mode 100644 index 3cea6da3..00000000 --- a/src/ui/settings.rs +++ /dev/null @@ -1,424 +0,0 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{List, ListItem, ListState, Paragraph, Tabs}, - Frame, -}; - -use super::widgets::{ - action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg, - render_action_button, render_modal_choice_list, render_panel_shell, ActionButtonSpec, -}; -use crate::{ - app::{state::Palette, AppState}, - config::{StatusIndicatorStyle, ToastDelivery}, -}; - -pub(crate) const SETTINGS_POPUP_WIDTH: u16 = 76; -pub(crate) const SETTINGS_POPUP_BASE_HEIGHT: u16 = 22; - -pub(crate) fn settings_popup_height(app: &AppState) -> u16 { - if app.settings.section != crate::app::state::SettingsSection::Integrations { - return SETTINGS_POPUP_BASE_HEIGHT; - } - let list_rows = app.integration_recommendations.len().max(1) as u16; - let footer_rows = integrations_footer_height(app, SETTINGS_POPUP_WIDTH - 2); - // borders 2 + header 3 + stack gaps 2 + modal footer 2 - // + section title 1 + description 2 + spacers 2 - (14 + list_rows + footer_rows).max(SETTINGS_POPUP_BASE_HEIGHT) -} - -pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - use crate::app::state::SettingsSection; - - let p = &app.palette; - let Some(popup) = centered_popup_rect(area, SETTINGS_POPUP_WIDTH, settings_popup_height(app)) - else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else { - return; - }; - if inner.height < 4 || inner.width < 10 { - return; - } - - let stack = modal_stack_areas(inner, 3, 2, 0, 1); - let header_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<3>(stack.header); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " settings", - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - )])), - header_rows[0], - ); - - let tab_labels = SettingsSection::ALL.iter().map(|section| { - if app.settings_section_has_badge(*section) { - Line::from(vec![ - Span::styled( - "● ", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::raw(section.label()), - ]) - } else { - Line::from(section.label()) - } - }); - let tabs = Tabs::new(tab_labels) - .select( - SettingsSection::ALL - .iter() - .position(|section| *section == app.settings.section) - .unwrap_or(0), - ) - .style(Style::default().fg(p.overlay1)) - .highlight_style( - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - ) - .divider(" ") - .padding(" ", " "); - frame.render_widget(tabs, header_rows[1]); - - let sep = "─".repeat(inner.width as usize); - frame.render_widget( - Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))), - header_rows[2], - ); - - let content_area = stack.content; - - match app.settings.section { - SettingsSection::Theme => { - render_settings_theme(app, frame, content_area); - } - SettingsSection::Indicators => { - render_modal_choice_list( - frame, - content_area, - "agent status indicators", - "choose color dots or distinct symbols for each state", - &[ - ("color dots ● ● ● ○ ·", StatusIndicatorStyle::Dots), - ("distinct symbols × ◐ ✓ ○ ·", StatusIndicatorStyle::Symbols), - ], - app.status_indicators, - app.settings.list.selected, - p, - 1, - ); - } - SettingsSection::Sound => { - render_settings_toggle( - frame, - content_area, - p, - "sound alerts", - "play sounds when agents change state in background", - app.sound_enabled(), - app.settings.list.selected, - ); - } - SettingsSection::Toast => { - render_modal_choice_list( - frame, - content_area, - "notification popups", - "choose where background popup notifications should appear", - &[ - ("off", ToastDelivery::Off), - ("inside herdr", ToastDelivery::Herdr), - ("via terminal", ToastDelivery::Terminal), - ("via system", ToastDelivery::System), - ], - app.toast_delivery(), - app.settings.list.selected, - p, - 2, - ); - } - SettingsSection::PaneLabels => { - render_settings_toggle( - frame, - content_area, - p, - "agent border labels", - "show detected agent names in split pane borders", - app.agent_border_labels_enabled(), - app.settings.list.selected, - ); - } - SettingsSection::Integrations => { - render_settings_integrations(app, frame, content_area); - } - } - - if let Some(footer_area) = stack.footer { - let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]) - .areas::<2>(footer_area); - let primary_label = settings_primary_button_label(app.settings.section); - let show_primary = settings_show_primary_action(app); - let (apply_rect, close_rect) = - settings_button_rects(inner, app.settings.section, show_primary); - if let Some(apply_rect) = apply_rect { - render_action_button( - frame, - apply_rect, - Some("↵"), - primary_label, - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - ); - } - render_action_button( - frame, - close_rect, - Some("esc"), - "close", - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ↑↓", Style::default().fg(p.overlay0)), - Span::styled(" select ", Style::default().fg(p.overlay1)), - Span::styled("tab", Style::default().fg(p.overlay0)), - Span::styled(" section", Style::default().fg(p.overlay1)), - ])), - footer_rows[0], - ); - } -} - -pub(crate) fn settings_primary_button_label( - section: crate::app::state::SettingsSection, -) -> &'static str { - match section { - crate::app::state::SettingsSection::Integrations => "install", - _ => "apply", - } -} - -pub(crate) fn settings_show_primary_action(app: &AppState) -> bool { - match app.settings.section { - crate::app::state::SettingsSection::Integrations => app - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install), - _ => true, - } -} - -pub(crate) fn settings_button_rects( - inner: Rect, - section: crate::app::state::SettingsSection, - show_primary: bool, -) -> (Option<Rect>, Rect) { - if !show_primary { - let rects = action_button_row_rects( - inner, - &[ActionButtonSpec { - hint: Some("esc"), - label: "close", - }], - 2, - inner.height.saturating_sub(1), - ); - return (None, rects[0]); - } - - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: settings_primary_button_label(section), - }, - ActionButtonSpec { - hint: Some("esc"), - label: "close", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (Some(rects[0]), rects[1]) -} - -fn integrations_footer_paragraph(app: &AppState) -> Paragraph<'static> { - let p = &app.palette; - let mut footer_lines = Vec::new(); - if !app.integration_install_messages.is_empty() { - for message in &app.integration_install_messages { - footer_lines.push(Line::from(Span::styled( - format!(" {message}"), - Style::default().fg(p.overlay1), - ))); - } - } else { - let found_any = app.integration_recommendations.iter().any(|item| { - item.available || item.state != crate::integration::IntegrationStatusKind::NotInstalled - }); - let hint = if app - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install) - { - " press install to add available or outdated integrations" - } else if found_any { - " all detected integrations are installed" - } else { - " no supported agent CLIs found on PATH" - }; - footer_lines.push(Line::from(Span::styled( - hint.to_string(), - Style::default().fg(p.overlay1), - ))); - } - Paragraph::new(footer_lines).wrap(ratatui::widgets::Wrap { trim: false }) -} - -fn integrations_footer_height(app: &AppState, width: u16) -> u16 { - (integrations_footer_paragraph(app).line_count(width) as u16).min(6) -} - -fn render_settings_integrations(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - - let footer = integrations_footer_paragraph(app); - let footer_height = integrations_footer_height(app, area.width); - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(2), - Constraint::Length(1), - Constraint::Min(0), - Constraint::Length(1), - Constraint::Length(footer_height), - ]) - .areas::<6>(area); - - frame.render_widget( - Paragraph::new("agent integrations") - .style(Style::default().fg(p.text).add_modifier(Modifier::BOLD)), - rows[0], - ); - frame.render_widget( - Paragraph::new( - "let agents report state directly instead of relying only on process detection", - ) - .style(Style::default().fg(p.overlay1)) - .wrap(ratatui::widgets::Wrap { trim: false }), - rows[1], - ); - - let mut lines = Vec::new(); - for item in &app.integration_recommendations { - let marker = match item.state { - crate::integration::IntegrationStatusKind::Current => "✓", - crate::integration::IntegrationStatusKind::Outdated => "↻", - crate::integration::IntegrationStatusKind::NotInstalled if item.available => "+", - crate::integration::IntegrationStatusKind::NotInstalled => "–", - }; - let marker_style = match item.state { - crate::integration::IntegrationStatusKind::Current => Style::default().fg(p.green), - crate::integration::IntegrationStatusKind::Outdated => Style::default().fg(p.yellow), - crate::integration::IntegrationStatusKind::NotInstalled if item.available => { - Style::default().fg(p.accent) - } - crate::integration::IntegrationStatusKind::NotInstalled => { - Style::default().fg(p.overlay0) - } - }; - lines.push(Line::from(vec![ - Span::styled(format!(" {marker} "), marker_style), - Span::styled( - format!("{:<9}", item.label), - Style::default().fg(p.subtext0), - ), - Span::styled(item.status_label(), Style::default().fg(p.overlay1)), - ])); - } - - if lines.is_empty() { - lines.push(Line::from(Span::styled( - " no integration targets available", - Style::default().fg(p.overlay1), - ))); - } - - frame.render_widget(Paragraph::new(lines), rows[3]); - frame.render_widget(footer, rows[5]); -} - -fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) { - use crate::app::state::THEME_NAMES; - - let p = &app.palette; - let items: Vec<ListItem> = THEME_NAMES - .iter() - .map(|name| { - let is_current = name.to_lowercase().replace([' ', '_'], "-") - == app.theme_name.to_lowercase().replace([' ', '_'], "-"); - let marker = if is_current { " ✓" } else { "" }; - ListItem::new(Line::from(vec![ - Span::styled(*name, Style::default().fg(p.subtext0)), - Span::styled(marker, Style::default().fg(p.green)), - ])) - }) - .collect(); - - let list = List::new(items) - .highlight_style( - Style::default() - .bg(p.surface0) - .fg(p.text) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(" ▸ ") - .style(Style::default().fg(p.subtext0)); - - let mut state = ListState::default().with_selected(Some(app.settings.list.selected)); - frame.render_stateful_widget(list, area, &mut state); -} - -fn render_settings_toggle( - frame: &mut Frame, - area: Rect, - p: &Palette, - title: &str, - description: &str, - current_value: bool, - selected_idx: usize, -) { - render_modal_choice_list( - frame, - area, - title, - description, - &[("on", true), ("off", false)], - current_value, - selected_idx, - p, - 1, - ); -} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index a6dfbcd3..3da1e9f9 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1,71 +1,68 @@ mod tokens; use ratatui::{ - layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, - Frame, + layout::Rect, + style::{Modifier, Style}, + text::Span, }; -use self::tokens::{ResolvedToken, ResolvedTokenKind, SpaceTokenContext}; -use super::scrollbar::{render_scrollbar, should_show_scrollbar}; -use super::status::{state_icon, state_label, state_label_color}; -use super::text::{display_width, display_width_u16, truncate_end}; -use crate::app::state::{AgentPanelSort, Palette}; -use crate::app::{AppState, Mode}; +pub(crate) use self::tokens::{ + agent_rows as sidebar_agent_rows, space_rows as sidebar_space_rows, AgentTokenContext, + ResolvedToken, ResolvedTokenKind, SpaceTokenContext, +}; +use super::text::{display_width, truncate_end}; +use crate::app::state::Palette; +use crate::app::AppState; use crate::detect::AgentState; use crate::terminal::TerminalRuntimeRegistry; -const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; -const AGENT_PANEL_HEADER_ROWS: u16 = 3; - pub(crate) struct AgentPanelEntry { pub ws_idx: usize, pub tab_idx: usize, pub pane_id: crate::layout::PaneId, - pub primary_label: String, - pub primary_tab_label: Option<String>, - pub pane_label: Option<String>, - pub terminal_title: Option<String>, - pub terminal_title_stripped: Option<String>, - pub agent_label: Option<String>, pub agent_kind_label: Option<String>, - pub agent: Option<crate::detect::Agent>, pub state: AgentState, pub seen: bool, pub last_agent_state_change_seq: Option<u64>, - pub state_labels: std::collections::HashMap<String, String>, pub tokens: std::collections::HashMap<String, String>, } -fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) { - if total_h == 0 { +fn sidebar_section_heights(total_height: u16, split_ratio: f32) -> (u16, u16) { + if total_height == 0 { return (0, 0); } - - if total_h < 6 { - let ws_h = total_h.div_ceil(2); - return (ws_h, total_h.saturating_sub(ws_h)); + if total_height < 6 { + let workspace_height = total_height.div_ceil(2); + return ( + workspace_height, + total_height.saturating_sub(workspace_height), + ); } - let ratio = split_ratio.clamp(0.1, 0.9); - let ws_h = ((total_h as f32) * ratio).round() as u16; - let ws_h = ws_h.clamp(3, total_h.saturating_sub(3)); - let detail_h = total_h.saturating_sub(ws_h); - (ws_h, detail_h) + let workspace_height = ((total_height as f32) * split_ratio.clamp(0.1, 0.9)).round() as u16; + let workspace_height = workspace_height.clamp(3, total_height.saturating_sub(3)); + ( + workspace_height, + total_height.saturating_sub(workspace_height), + ) } pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) { let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - if content.width == 0 || content.height == 0 { + if content.is_empty() { return (Rect::default(), Rect::default()); } - let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio); - let ws_area = Rect::new(content.x, content.y, content.width, ws_h); - let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h); - (ws_area, detail_area) + let (workspace_height, detail_height) = sidebar_section_heights(content.height, split_ratio); + ( + Rect::new(content.x, content.y, content.width, workspace_height), + Rect::new( + content.x, + content.y + workspace_height, + content.width, + detail_height, + ), + ) } pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect { @@ -74,949 +71,46 @@ pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect return Rect::default(); } - let (ws_h, _) = sidebar_section_heights(content.height, split_ratio); - Rect::new(content.x, content.y + ws_h, content.width, 1) -} - -fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str { - match sort { - AgentPanelSort::Spaces => "grouped", - AgentPanelSort::Priority => "priority", - } -} - -pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect { - agent_panel_header_label_rect(area, agent_panel_sort_label(sort)) -} - -fn agent_panel_header_label_rect(area: Rect, label: &str) -> Rect { - if area.width == 0 || area.height < 2 { - return Rect::default(); - } - - let width = display_width_u16(label).min(area.width); - Rect::new( - area.x + area.width.saturating_sub(width), - area.y + 1, - width, - 1, - ) -} - -fn active_agent_view_label(app: &AppState) -> Option<&str> { - app.agent_view_override - .as_ref() - .map(|view| view.label.as_deref().unwrap_or("filtered")) -} - -pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { - agent_panel_entries_with_runtimes(app, None) -} - -pub(crate) fn all_agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { - collect_agent_panel_entries_with_runtimes(app, None) + let (workspace_height, _) = sidebar_section_heights(content.height, split_ratio); + Rect::new(content.x, content.y + workspace_height, content.width, 1) } pub(crate) fn agent_panel_entries_from( app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Vec<AgentPanelEntry> { - agent_panel_entries_with_runtimes(app, Some(terminal_runtimes)) -} - -fn agent_panel_entries_with_runtimes( - app: &AppState, - terminal_runtimes: Option<&TerminalRuntimeRegistry>, + _terminal_runtimes: &TerminalRuntimeRegistry, ) -> Vec<AgentPanelEntry> { - let mut entries = collect_agent_panel_entries_with_runtimes(app, terminal_runtimes); - crate::app::agent_view::apply_agent_view(app, &mut entries); - entries -} - -fn collect_agent_panel_entries_with_runtimes( - app: &AppState, - terminal_runtimes: Option<&TerminalRuntimeRegistry>, -) -> Vec<AgentPanelEntry> { - let empty_runtimes; - let terminal_runtimes = match terminal_runtimes { - Some(terminal_runtimes) => terminal_runtimes, - None => { - empty_runtimes = TerminalRuntimeRegistry::new(); - &empty_runtimes - } - }; - - app.workspaces + let mut entries = app + .workspaces .iter() .enumerate() - .flat_map(|(ws_idx, ws)| { - let multi_tab = ws.tabs.len() > 1; - let workspace_label = ws.display_name_from(&app.terminals, terminal_runtimes); - ws.pane_details(&app.terminals) + .flat_map(|(ws_idx, workspace)| { + workspace + .pane_details(&app.terminals) .into_iter() - .map(move |detail| { - let show_tab = multi_tab - || ws - .tabs - .get(detail.tab_idx) - .is_some_and(|tab| !tab.is_auto_named()); - AgentPanelEntry { - ws_idx, - tab_idx: detail.tab_idx, - pane_id: detail.pane_id, - primary_label: workspace_label.clone(), - primary_tab_label: show_tab.then_some(detail.tab_label), - pane_label: detail.pane_label, - terminal_title: detail.terminal_title, - terminal_title_stripped: detail.terminal_title_stripped, - agent_label: Some(detail.agent_label), - agent_kind_label: detail.agent_kind_label, - agent: detail.agent, - state: detail.state, - seen: detail.seen, - last_agent_state_change_seq: detail.last_agent_state_change_seq, - state_labels: detail.state_labels, - tokens: detail.tokens, - } - }) - }) - .collect() -} - -pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Working, _) => "working", - (AgentState::Blocked, _) => "blocked", - (AgentState::Unknown, _) => "unknown", - } -} - -fn workspace_row_height(app: &AppState, ws: &crate::workspace::Workspace, indented: bool) -> u16 { - let (state, seen) = ws.aggregate_state(&app.terminals); - let label = if indented { - grouped_child_display_label( - &ws.display_name_from_terminals(&app.terminals), - ws.branch().as_deref(), - ws.custom_name.is_some(), - ) - } else { - ws.display_name_from_terminals(&app.terminals) - }; - let token_values = ws.metadata_tokens.values(); - tokens::space_rows( - &app.sidebar_spaces, - SpaceTokenContext { - workspace: &label, - branch: ws.branch().as_deref(), - state_text: state_label(state, seen), - ahead_behind: ws.git_ahead_behind(), - tokens: &token_values, - suppress_git_details: indented, - }, - ) - .len() - .max(1) - .min(u16::MAX as usize) as u16 -} - -fn workspace_row_height_in_body( - app: &AppState, - workspace: &crate::workspace::Workspace, - indented: bool, - body_height: u16, -) -> u16 { - workspace_row_height(app, workspace, indented).min(body_height) -} - -fn workspace_entry_gap(app: &AppState, entries: &[WorkspaceListEntry], entry_idx: usize) -> u16 { - if entry_idx + 1 < entries.len() && !next_entry_is_indented_workspace(entries, entry_idx) { - app.sidebar_spaces.row_gap - } else { - 0 - } -} - -fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 { - match (state, seen) { - (AgentState::Blocked, _) => 4, - (AgentState::Idle, false) => 3, - (AgentState::Working, _) => 2, - (AgentState::Idle, true) => 1, - (AgentState::Unknown, _) => 0, - } -} - -fn space_aggregate_state(app: &AppState, key: &str) -> (AgentState, bool) { - app.workspaces - .iter() - .filter(|ws| ws.worktree_space().is_some_and(|space| space.key == key)) - .map(|ws| ws.aggregate_state(&app.terminals)) - .max_by_key(|(state, seen)| workspace_attention_priority(*state, *seen)) - .unwrap_or((AgentState::Unknown, true)) -} - -pub(crate) fn workspace_parent_group_state( - app: &AppState, - ws_idx: usize, -) -> Option<(String, bool)> { - let space = app.workspaces.get(ws_idx)?.worktree_space()?; - if space.is_linked_worktree { - return None; - } - let member_count = app - .workspaces - .iter() - .filter(|ws| { - ws.worktree_space() - .is_some_and(|member| member.key == space.key) - }) - .count(); - (member_count >= 2).then(|| { - ( - space.key.clone(), - app.collapsed_space_keys.contains(&space.key), - ) - }) -} - -pub(crate) fn grouped_child_display_label( - label: &str, - branch: Option<&str>, - has_custom_name: bool, -) -> String { - if has_custom_name { - return label.to_string(); - } - let Some(branch) = branch else { - return label.to_string(); - }; - branch - .strip_prefix("worktree/") - .unwrap_or(branch) - .to_string() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum WorkspaceListEntry { - Workspace { ws_idx: usize, indented: bool }, -} - -pub(crate) fn next_entry_is_indented_workspace(entries: &[WorkspaceListEntry], idx: usize) -> bool { - matches!( - entries.get(idx.saturating_add(1)), - Some(WorkspaceListEntry::Workspace { indented: true, .. }) - ) -} - -pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested: usize) -> usize { - let ws_area = workspace_list_rect(area, app.sidebar_section_split); - let body = workspace_list_body_rect(ws_area, false); - if body.height == 0 { - return requested; - } - - if workspace_list_entries(app).is_empty() { - 0 - } else { - requested.min(workspace_list_bottom_start(app, ws_area)) - } -} - -pub(crate) fn workspace_list_entries(app: &AppState) -> Vec<WorkspaceListEntry> { - workspace_list_entries_inner(app, false) -} - -/// Like [`workspace_list_entries`] but always expands worktree groups, ignoring -/// `collapsed_space_keys`. The mobile switcher has no collapse affordance and -/// always shows the full worktree tree. -pub(crate) fn workspace_list_entries_expanded(app: &AppState) -> Vec<WorkspaceListEntry> { - workspace_list_entries_inner(app, true) -} - -fn workspace_list_entries_inner(app: &AppState, force_expanded: bool) -> Vec<WorkspaceListEntry> { - let mut members_by_key = std::collections::HashMap::<String, Vec<usize>>::new(); - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - if let Some(space) = ws.worktree_space() { - members_by_key - .entry(space.key.clone()) - .or_default() - .push(ws_idx); - } - } - let grouped_keys = members_by_key - .iter() - .filter(|(_, members)| { - members.len() >= 2 - && members.iter().any(|idx| { - app.workspaces - .get(*idx) - .and_then(|ws| ws.worktree_space()) - .is_some_and(|space| !space.is_linked_worktree) + .map(move |detail| AgentPanelEntry { + ws_idx, + tab_idx: detail.tab_idx, + pane_id: detail.pane_id, + agent_kind_label: detail.agent_kind_label, + state: detail.state, + seen: detail.seen, + last_agent_state_change_seq: detail.last_agent_state_change_seq, + tokens: detail.tokens, }) }) - .map(|(key, _)| key.clone()) - .collect::<std::collections::HashSet<_>>(); - - let visible_group_idx = if matches!(app.mode, Mode::Navigate) { - Some(app.selected) - } else { - app.active - }; - let active_group = visible_group_idx.and_then(|idx| { - app.workspaces - .get(idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - }); - - let mut emitted_groups = std::collections::HashSet::<String>::new(); - let mut entries = Vec::new(); - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - let Some(space) = ws - .worktree_space() - .filter(|space| grouped_keys.contains(&space.key)) - else { - entries.push(WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - }); - continue; - }; - - if !emitted_groups.insert(space.key.clone()) { - continue; - } - - let Some(members) = members_by_key.get(&space.key) else { - continue; - }; - let Some(parent_idx) = members.iter().copied().find(|idx| { - app.workspaces - .get(*idx) - .and_then(|member| member.worktree_space()) - .is_some_and(|member_space| !member_space.is_linked_worktree) - }) else { - entries.push(WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - }); - continue; - }; - let collapsed = !force_expanded && app.collapsed_space_keys.contains(&space.key); - entries.push(WorkspaceListEntry::Workspace { - ws_idx: parent_idx, - indented: false, - }); - - if collapsed { - if let Some(active_idx) = visible_group_idx - .filter(|idx| *idx != parent_idx) - .filter(|_| active_group.as_deref() == Some(space.key.as_str())) - { - entries.push(WorkspaceListEntry::Workspace { - ws_idx: active_idx, - indented: true, - }); - } - } else { - for member_idx in members { - if *member_idx == parent_idx { - continue; - } - entries.push(WorkspaceListEntry::Workspace { - ws_idx: *member_idx, - indented: true, - }); - } - } - } + .collect(); + crate::app::agent_view::apply_agent_view(app, &mut entries); entries } -pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect { - let (ws_area, _) = expanded_sidebar_sections(area, split_ratio); - ws_area -} - -pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect { - if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS { - return Rect::default(); - } - - let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS); - let footer_y = area.y + area.height.saturating_sub(1); - let body_height = footer_y.saturating_sub(body_y); - let body_width = area.width.saturating_sub(u16::from(has_scrollbar)); - Rect::new(area.x, body_y, body_width, body_height) -} - -fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize { - let body = workspace_list_body_rect(area, false); - if body.width == 0 || body.height == 0 { - return 0; - } - - let mut used_rows = 0u16; - let mut visible = 0usize; - let entries = workspace_list_entries(app); - for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { - let (row_height, gap) = match entry { - WorkspaceListEntry::Workspace { ws_idx, indented } => { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - ( - workspace_row_height_in_body(app, ws, *indented, body.height), - workspace_entry_gap(app, &entries, entry_idx), - ) - } - }; - if used_rows.saturating_add(row_height) > body.height { - break; - } - used_rows = used_rows.saturating_add(row_height); - visible += 1; - used_rows = used_rows.saturating_add(gap).min(body.height); - } - visible -} - -fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize { - let body = workspace_list_body_rect(area, false); - let entries = workspace_list_entries(app); - let mut used_rows = 0u16; - let mut start = entries.len(); - for (entry_idx, entry) in entries.iter().enumerate().rev() { - let WorkspaceListEntry::Workspace { ws_idx, indented } = entry; - let Some(workspace) = app.workspaces.get(*ws_idx) else { - continue; - }; - let gap = workspace_entry_gap(app, &entries, entry_idx); - let needed = workspace_row_height_in_body(app, workspace, *indented, body.height) - .saturating_add(gap); - if used_rows.saturating_add(needed) > body.height { - break; - } - used_rows = used_rows.saturating_add(needed); - start = entry_idx; - } - start.min(entries.len().saturating_sub(1)) -} - -pub(crate) fn workspace_list_scroll_metrics( - app: &AppState, - area: Rect, -) -> crate::pane::ScrollMetrics { - let max_scroll = workspace_list_bottom_start(app, area); - let scroll = app.workspace_scroll.min(max_scroll); - let viewport_rows = workspace_list_visible_count(app, area, scroll); - - crate::pane::ScrollMetrics { - offset_from_bottom: max_scroll.saturating_sub(scroll), - max_offset_from_bottom: max_scroll, - viewport_rows, - } -} - -pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> { - let metrics = workspace_list_scroll_metrics(app, area); - let body = workspace_list_body_rect(area, true); - (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new( - area.x + area.width.saturating_sub(1), - body.y, - 1, - body.height, - )) -} - -pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect { - if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS { - return Rect::default(); - } - - let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS); - let body_height = (area.y + area.height).saturating_sub(body_y); - let body_width = area.width.saturating_sub(u16::from(has_scrollbar)); - Rect::new(area.x, body_y, body_width, body_height) -} - -fn resolved_agent_rows(app: &AppState, entry: &AgentPanelEntry) -> Vec<Vec<ResolvedToken>> { - let label = entry - .state_labels - .get(agent_panel_status_key(entry.state, entry.seen)) - .map(String::as_str) - .unwrap_or_else(|| state_label(entry.state, entry.seen)); - tokens::agent_rows(&app.sidebar_agents, entry, label) -} - -pub(crate) fn agent_entry_height_in_body( - app: &AppState, - entry: &AgentPanelEntry, - body_height: u16, -) -> u16 { - (resolved_agent_rows(app, entry) - .len() - .max(1) - .min(u16::MAX as usize) as u16) - .min(body_height) -} - -pub(crate) fn agent_entry_gap(app: &AppState, entry_idx: usize, entry_count: usize) -> u16 { - if entry_idx + 1 < entry_count { - app.sidebar_agents.row_gap - } else { - 0 - } -} - -fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> usize { - let body = agent_panel_body_rect(area, false); - if body.width == 0 || body.height == 0 { - return 0; - } - - let mut used_rows = 0u16; - let mut visible = 0usize; - let entries = agent_panel_entries(app); - for (index, entry) in entries.iter().enumerate().skip(scroll) { - let height = agent_entry_height_in_body(app, entry, body.height); - if used_rows.saturating_add(height) > body.height { - break; - } - used_rows = used_rows.saturating_add(height); - visible += 1; - used_rows = used_rows - .saturating_add(agent_entry_gap(app, index, entries.len())) - .min(body.height); - } - visible -} - -fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize { - let body = agent_panel_body_rect(area, false); - let entries = agent_panel_entries(app); - let mut used_rows = 0u16; - let mut start = entries.len(); - for (index, entry) in entries.iter().enumerate().rev() { - let gap = agent_entry_gap(app, index, entries.len()); - let needed = agent_entry_height_in_body(app, entry, body.height).saturating_add(gap); - if used_rows.saturating_add(needed) > body.height { - break; - } - used_rows = used_rows.saturating_add(needed); - start = index; - } - start.min(entries.len().saturating_sub(1)) -} - -pub(crate) fn agent_panel_scroll_for_target( - app: &AppState, - area: Rect, - current_scroll: usize, - target: usize, -) -> usize { - let max_scroll = agent_panel_bottom_start(app, area); - if target < current_scroll { - return target.min(max_scroll); - } - let mut scroll = current_scroll.min(max_scroll); - while scroll < target { - let visible = agent_panel_visible_count_from(app, area, scroll); - if visible > 0 && target < scroll.saturating_add(visible) { - break; - } - scroll += 1; - } - scroll.min(max_scroll) -} - -pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics { - let max_scroll = agent_panel_bottom_start(app, area); - let scroll = app.agent_panel_scroll.min(max_scroll); - let viewport_rows = agent_panel_visible_count_from(app, area, scroll); - - crate::pane::ScrollMetrics { - offset_from_bottom: max_scroll.saturating_sub(scroll), - max_offset_from_bottom: max_scroll, - viewport_rows, - } -} - -pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> { - let metrics = agent_panel_scroll_metrics(app, area); - let body = agent_panel_body_rect(area, true); - (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new( - area.x + area.width.saturating_sub(1), - body.y, - 1, - body.height, - )) -} - -pub(crate) fn compute_workspace_list_areas( - app: &AppState, - area: Rect, -) -> (Vec<crate::app::state::WorkspaceCardArea>, Vec<()>) { - let ws_area = workspace_list_rect(area, app.sidebar_section_split); - if ws_area == Rect::default() { - return (Vec::new(), Vec::new()); - } - - let metrics = workspace_list_scroll_metrics(app, ws_area); - let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics)); - if body.width == 0 || body.height == 0 { - return (Vec::new(), Vec::new()); - } - - let scroll = app.workspace_scroll; - let mut row_y = body.y; - let body_bottom = body.y + body.height; - let mut cards = Vec::new(); - let headers = Vec::new(); - - let entries = workspace_list_entries(app); - for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { - match entry { - WorkspaceListEntry::Workspace { ws_idx, indented } => { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - let row_height = workspace_row_height_in_body(app, ws, *indented, body.height); - let gap = workspace_entry_gap(app, &entries, entry_idx); - if row_y.saturating_add(row_height) > body_bottom { - break; - } - cards.push(crate::app::state::WorkspaceCardArea { - ws_idx: *ws_idx, - rect: Rect::new(body.x, row_y, body.width, row_height), - indented: *indented, - }); - row_y = row_y - .saturating_add(row_height) - .saturating_add(gap) - .min(body_bottom); - } - } - } - - (cards, headers) -} - -pub(crate) fn compute_workspace_card_areas( - app: &AppState, - area: Rect, -) -> Vec<crate::app::state::WorkspaceCardArea> { - compute_workspace_list_areas(app, area).0 -} - -pub(crate) fn workspace_group_chevron_rect(card: &crate::app::state::WorkspaceCardArea) -> Rect { - if card.rect.width == 0 || card.rect.height == 0 { - return Rect::default(); - } - - Rect::new( - card.rect.x + card.rect.width.saturating_sub(1), - card.rect.y, - 1, - 1, - ) -} - -/// Auto-scale sidebar width based on workspace identity + agent summary. -pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) { - let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - if content.width == 0 || content.height == 0 { - return (Rect::default(), None, Rect::default()); - } - - if content.height < 7 { - return (content, None, Rect::default()); - } - - let total_h = content.height as usize; - let ws_h = total_h.div_ceil(2); - let detail_h = total_h.saturating_sub(ws_h + 1); - if ws_h == 0 || detail_h == 0 { - return (content, None, Rect::default()); - } - - let divider_y = content.y + ws_h as u16; - let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); - let detail_area = Rect::new(content.x, divider_y + 1, content.width, detail_h as u16); - (ws_area, Some(divider_y), detail_area) -} - -fn workspace_selection_background(p: &Palette, is_active: bool) -> Color { - if is_active && p.selection_bg == Color::Reset { - p.active_row_bg - } else { - p.selection_bg - } -} - -/// Collapsed sidebar: workspace glance on top, compact agent list below. -pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - - let is_navigating = matches!(app.mode, Mode::Navigate); - - let p = &app.palette; - frame - .buffer_mut() - .set_style(area, Style::default().bg(p.sidebar_bg)); - let sep_style = if is_navigating { - Style::default().fg(p.accent) - } else { - Style::default().fg(p.surface_dim) - }; - let sep_x = area.x + area.width.saturating_sub(1); - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - buf[(sep_x, y)].set_symbol("│"); - buf[(sep_x, y)].set_style(sep_style); - } - - let (ws_area, divider_y, detail_area) = collapsed_sidebar_sections(area); - if ws_area == Rect::default() { - render_sidebar_toggle(app, frame, area, true, p); - return; - } - - for (visible_idx, ws) in app.workspaces.iter().enumerate() { - let y = ws_area.y + visible_idx as u16; - if y >= ws_area.y + ws_area.height { - break; - } - let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); - let (icon, icon_style) = state_icon(agg_state, agg_seen, app.status_indicators, p); - let is_selected = visible_idx == app.selected && is_navigating; - let is_active = Some(visible_idx) == app.active; - let selection_bg = workspace_selection_background(p, is_active); - let row_style = if is_selected { - Style::default().bg(selection_bg) - } else if is_active { - Style::default().bg(p.active_row_bg) - } else { - Style::default() - }; - let num_style = if is_selected { - Style::default().fg(p.overlay1).bg(selection_bg) - } else if is_active { - Style::default().fg(p.text).bg(p.active_row_bg) - } else { - Style::default().fg(p.overlay0) - }; - - if is_selected || is_active { - let buf = frame.buffer_mut(); - for x in ws_area.x..ws_area.x + ws_area.width { - buf[(x, y)].set_style(row_style); - } - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!("{:<2}", visible_idx + 1), num_style), - Span::styled(icon, icon_style), - ])), - Rect::new(ws_area.x, y, ws_area.width, 1), - ); - } - - if let Some(divider_y) = divider_y { - let buf = frame.buffer_mut(); - let divider_color = if app.agent_view_override.is_some() { - p.accent - } else { - p.surface_dim - }; - for x in ws_area.x..ws_area.x + ws_area.width { - buf[(x, divider_y)].set_symbol("─"); - buf[(x, divider_y)].set_style(Style::default().fg(divider_color)); - } - } - - let detail_content_area = Rect::new( - detail_area.x, - detail_area.y, - detail_area.width, - detail_area.height.saturating_sub(1), - ); - if detail_content_area != Rect::default() { - for (detail_idx, detail) in agent_panel_entries(app).iter().enumerate() { - let y = detail_content_area.y + detail_idx as u16; - if y >= detail_content_area.y + detail_content_area.height { - break; - } - let position = detail_idx + 1; - let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id); - let position_style = if is_active { - Style::default().fg(p.text).bg(p.active_row_bg) - } else { - Style::default().fg(p.overlay0) - }; - let (icon, icon_style) = - state_icon(detail.state, detail.seen, app.status_indicators, p); - - if is_active { - let buf = frame.buffer_mut(); - for x in detail_content_area.x..detail_content_area.x + detail_content_area.width { - buf[(x, y)].set_style(Style::default().bg(p.active_row_bg)); - } - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!("{position:<2}"), position_style), - Span::styled(icon, icon_style), - ])), - Rect::new(detail_content_area.x, y, detail_content_area.width, 1), - ); - } - } - - render_sidebar_toggle(app, frame, area, true, p); -} - -pub(crate) fn workspace_drop_slots( - app: &AppState, - cards: &[crate::app::state::WorkspaceCardArea], - area: Rect, -) -> Vec<(crate::app::state::WorkspaceDropTarget, u16)> { - if area.height == 0 || cards.is_empty() { - return Vec::new(); - } - let list_bottom = area.y + area.height.saturating_sub(1); - let entries = workspace_list_entries(app); - let entry_position = |ws_idx| { - entries.iter().position(|entry| { - matches!( - entry, - WorkspaceListEntry::Workspace { - ws_idx: entry_ws_idx, - .. - } if *entry_ws_idx == ws_idx - ) - }) - }; - let block_root_at = |entry_idx: usize| { - entries[..=entry_idx] - .iter() - .rev() - .find_map(|entry| match entry { - WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - } => Some(*ws_idx), - WorkspaceListEntry::Workspace { .. } => None, - }) - }; - - let mut slots = Vec::new(); - let mut previous_root = None; - for card in cards { - let Some(entry_idx) = entry_position(card.ws_idx) else { - continue; - }; - let Some(root_idx) = block_root_at(entry_idx) else { - continue; - }; - if previous_root == Some(root_idx) { - continue; - } - previous_root = Some(root_idx); - if let Some(row) = card.rect.y.checked_sub(1).filter(|row| *row < list_bottom) { - slots.push(( - crate::app::state::WorkspaceDropTarget::Before(root_idx), - row, - )); - } - } - - let Some(last) = cards.last() else { - return slots; - }; - let Some(last_entry_idx) = entry_position(last.ws_idx) else { - return slots; - }; - let next_entry = entries.get(last_entry_idx.saturating_add(1)); - if matches!( - next_entry, - Some(WorkspaceListEntry::Workspace { indented: true, .. }) - ) { - return slots; - } - let target = match next_entry { - Some(WorkspaceListEntry::Workspace { ws_idx, .. }) => { - crate::app::state::WorkspaceDropTarget::Before(*ws_idx) - } - None => crate::app::state::WorkspaceDropTarget::End, - }; - let row = last.rect.y.saturating_add(last.rect.height); - if row < list_bottom - && slots - .last() - .is_none_or(|(last_target, _)| *last_target != target) - { - slots.push((target, row)); - } - slots -} - -pub(crate) fn workspace_drop_indicator_row( - app: &AppState, - cards: &[crate::app::state::WorkspaceCardArea], - area: Rect, - target: crate::app::state::WorkspaceDropTarget, -) -> Option<u16> { - workspace_drop_slots(app, cards, area) - .into_iter() - .find_map(|(candidate, row)| (candidate == target).then_some(row)) -} - -pub(super) fn render_sidebar( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let p = &app.palette; - frame - .buffer_mut() - .set_style(area, Style::default().bg(p.sidebar_bg)); - let is_navigating = matches!(app.mode, Mode::Navigate); - let sep_style = if is_navigating { - Style::default().fg(p.accent) - } else { - Style::default().fg(p.surface_dim) - }; - - let sep_x = area.x + area.width.saturating_sub(1); - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - buf[(sep_x, y)].set_symbol("│"); - buf[(sep_x, y)].set_style(sep_style); - } - - let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - - render_workspace_list(app, terminal_runtimes, frame, ws_area, is_navigating); - render_agent_detail(app, terminal_runtimes, frame, detail_area); - render_sidebar_toggle(app, frame, area, false, p); -} - -fn resolved_token_spans( +pub(crate) fn resolved_token_spans( resolved: &[ResolvedToken], state_icon: (&str, Style), state_text_style: Style, workspace_style: Style, secondary_style: Style, custom_style: Style, - p: &Palette, + palette: &Palette, max_width: usize, ) -> Vec<Span<'static>> { let fixed_widths = resolved @@ -1116,6 +210,7 @@ fn resolved_token_spans( break; } } + let mut spans = Vec::new(); for (position, index) in visible_indices.iter().copied().enumerate() { let token = &resolved[index]; @@ -1123,42 +218,36 @@ fn resolved_token_spans( let previous = &resolved[visible_indices[position - 1]]; spans.push(Span::styled( tokens::separator(previous, token), - Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM), )); } match &token.kind { - ResolvedTokenKind::StateIcon => { - 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), - )); - } - ResolvedTokenKind::Workspace(text) => { - spans.push(Span::styled( - truncate_end(text, budgets[index]), - apply_token_style(workspace_style, token.style), - )); - } + ResolvedTokenKind::StateIcon => 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), + )), + ResolvedTokenKind::Workspace(text) => spans.push(Span::styled( + truncate_end(text, budgets[index]), + apply_token_style(workspace_style, token.style), + )), ResolvedTokenKind::Tab(text) | ResolvedTokenKind::Pane(text) | ResolvedTokenKind::Agent(text) - | ResolvedTokenKind::Branch(text) => { - spans.push(Span::styled( - truncate_end(text, budgets[index]), - apply_token_style(secondary_style, token.style), - )); - } + | ResolvedTokenKind::Branch(text) => spans.push(Span::styled( + truncate_end(text, budgets[index]), + apply_token_style(secondary_style, token.style), + )), ResolvedTokenKind::GitStatus { ahead, behind } => { if *ahead > 0 { spans.push(Span::styled( format!("↑{ahead}"), - apply_token_style(Style::default().fg(p.green), token.style), + apply_token_style(Style::default().fg(palette.green), token.style), )); } if *ahead > 0 && *behind > 0 { @@ -1170,7 +259,7 @@ fn resolved_token_spans( if *behind > 0 { spans.push(Span::styled( format!("↓{behind}"), - apply_token_style(Style::default().fg(p.red), token.style), + apply_token_style(Style::default().fg(palette.red), token.style), )); } } @@ -1186,8 +275,8 @@ fn resolved_token_spans( } fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) -> Style { - if let Some(fg) = patch.fg { - style = style.fg(fg.ratatui()); + if let Some(foreground) = patch.fg { + style = style.fg(foreground.ratatui()); } if let Some(bold) = patch.bold { style = if bold { @@ -1205,1974 +294,3 @@ fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) } style } - -fn render_workspace_list( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, - is_navigating: bool, -) { - let p = &app.palette; - let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) { - Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => { - Some(*source_ws_idx) - } - _ => None, - }; - let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) { - Some(crate::app::state::DragTarget::WorkspaceReorder { - drop_target: Some(drop_target), - .. - }) => workspace_drop_indicator_row(app, &app.view.workspace_card_areas, area, *drop_target), - _ => None, - }; - - let list_bottom = area.y + area.height.saturating_sub(1); - if area.height > 0 { - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " spaces", - Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), - )])), - Rect::new(area.x, area.y, area.width, 1), - ); - } - - let metrics = workspace_list_scroll_metrics(app, area); - let scrollbar_rect = workspace_list_scrollbar_rect(app, area); - let cards = &app.view.workspace_card_areas; - let entries = workspace_list_entries(app); - - for card in cards { - let i = card.ws_idx; - let ws = &app.workspaces[i]; - let row_y = card.rect.y; - let row_height = card.rect.height; - let selected = i == app.selected && is_navigating; - let is_active = Some(i) == app.active; - let is_dragged = dragged_ws_idx == Some(i); - let highlighted = selected || is_active || is_dragged; - let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); - - if highlighted { - let bg = if selected { - workspace_selection_background(p, is_active) - } else if is_dragged { - p.surface1 - } else { - p.active_row_bg - }; - let buf = frame.buffer_mut(); - for y in row_y..row_y + row_height { - if y >= list_bottom { - break; - } - for x in card.rect.x..card.rect.x + card.rect.width { - buf[(x, y)].set_style(Style::default().bg(bg)); - } - } - } - - let name_style = if selected || is_active || is_dragged { - Style::default().fg(p.text).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0) - }; - - let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let display_label = if card.indented { - grouped_child_display_label(&label, ws.branch().as_deref(), ws.custom_name.is_some()) - } else { - label - }; - let parent_group = (!card.indented) - .then(|| workspace_parent_group_state(app, i)) - .flatten(); - let is_last_child = card.indented - && entries - .iter() - .position(|entry| { - matches!( - entry, - WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == i - ) - }) - .is_none_or(|entry_idx| !next_entry_is_indented_workspace(&entries, entry_idx)); - let (display_state, display_seen) = parent_group - .as_ref() - .filter(|(_, collapsed)| *collapsed) - .map(|(key, _)| space_aggregate_state(app, key)) - .unwrap_or((agg_state, agg_seen)); - let state_icon = state_icon(display_state, display_seen, app.status_indicators, p); - let state_text_style = Style::default() - .fg(state_label_color(display_state, display_seen, p)) - .add_modifier(Modifier::DIM); - let branch_style = Style::default().fg(if selected || is_active { - p.mauve - } else { - p.overlay0 - }); - let token_values = ws.metadata_tokens.values(); - let rows = tokens::space_rows( - &app.sidebar_spaces, - SpaceTokenContext { - workspace: &display_label, - branch: ws.branch().as_deref(), - state_text: state_label(display_state, display_seen), - ahead_behind: ws.git_ahead_behind(), - tokens: &token_values, - suppress_git_details: card.indented, - }, - ); - - for (row_index, resolved) in rows.iter().enumerate() { - if row_index as u16 >= row_height || row_y + row_index as u16 >= list_bottom { - break; - } - let mut spans = Vec::new(); - let prefix_width = if card.indented { - spans.push(Span::raw(" ")); - if row_index == 0 { - spans.push(Span::styled( - if is_last_child { "└─ " } else { "├─ " }, - Style::default().fg(p.overlay0), - )); - 6 - } else if is_last_child { - spans.push(Span::raw(" ")); - 8 - } else { - spans.push(Span::styled("│", Style::default().fg(p.overlay0))); - spans.push(Span::raw(" ")); - 8 - } - } else if row_index == 0 { - spans.push(Span::raw(" ")); - 1 - } else { - spans.push(Span::raw(" ")); - 3 - }; - let trailing_width = if row_index == 0 && parent_group.is_some() { - 2 - } else { - 0 - }; - spans.extend(resolved_token_spans( - resolved, - state_icon, - state_text_style, - name_style, - branch_style, - branch_style, - p, - card.rect - .width - .saturating_sub(prefix_width + trailing_width) as usize, - )); - frame.render_widget( - Paragraph::new(Line::from(spans)), - Rect::new(card.rect.x, row_y + row_index as u16, card.rect.width, 1), - ); - } - - if let Some((_, collapsed)) = parent_group { - frame.render_widget( - Paragraph::new(Span::styled( - if collapsed { "▸" } else { "▾" }, - Style::default().fg(p.accent), - )), - workspace_group_chevron_rect(card), - ); - } - } - - if let Some(y) = insertion_row.filter(|y| *y < list_bottom) { - let indicator_right = scrollbar_rect - .map(|rect| rect.x) - .unwrap_or(area.x + area.width); - let buf = frame.buffer_mut(); - for x in area.x..indicator_right { - buf[(x, y)].set_symbol("─"); - buf[(x, y)].set_style(Style::default().fg(p.accent)); - } - } - - if let Some(track) = scrollbar_rect { - render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); - } - - if app.mouse_capture && list_bottom > area.y { - let new_rect = app.sidebar_new_button_rect(); - frame.render_widget( - Paragraph::new(Span::styled(" new", Style::default().fg(p.overlay0))), - new_rect, - ); - - let menu_rect = app.global_launcher_rect(); - let menu_line = if app.global_menu_attention_badge_visible() { - Line::from(vec![ - Span::styled( - "● ", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled("menu", Style::default().fg(p.overlay0)), - ]) - } else { - Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))]) - }; - frame.render_widget( - Paragraph::new(menu_line).alignment(Alignment::Right), - menu_rect, - ); - } -} - -fn render_agent_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let p = &app.palette; - - if area.height < 3 { - return; - } - - let sep_line = "─".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))), - Rect::new(area.x, area.y, area.width, 1), - ); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " agents", - Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), - )])), - Rect::new(area.x, area.y + 1, area.width, 1), - ); - let control_label = active_agent_view_label(app) - .unwrap_or_else(|| agent_panel_sort_label(app.agent_panel_sort)); - let toggle_rect = agent_panel_header_label_rect(area, control_label); - if toggle_rect != Rect::default() { - let color = if app.agent_view_override.is_some() { - p.accent - } else { - p.overlay0 - }; - frame.render_widget( - Paragraph::new(Span::styled( - control_label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - )) - .alignment(Alignment::Right), - toggle_rect, - ); - } - - let details = agent_panel_entries_from(app, terminal_runtimes); - let metrics = agent_panel_scroll_metrics(app, area); - let scrollbar_rect = agent_panel_scrollbar_rect(app, area); - let body = agent_panel_body_rect(area, should_show_scrollbar(metrics)); - if body == Rect::default() { - return; - } - if details.is_empty() && app.agent_view_override.is_some() { - frame.render_widget( - Paragraph::new(" no matching agents") - .style(Style::default().fg(p.overlay0).add_modifier(Modifier::DIM)), - Rect::new(body.x, body.y, body.width, 1), - ); - return; - } - - let scroll = app.agent_panel_scroll.min(metrics.max_offset_from_bottom); - let mut row_y = body.y; - let body_bottom = body.y + body.height; - for (index, detail) in details.iter().enumerate().skip(scroll) { - let label_color = state_label_color(detail.state, detail.seen, p); - let rows = resolved_agent_rows(app, detail); - let height = (rows.len().max(1) as u16).min(body.height); - if row_y.saturating_add(height) > body_bottom { - break; - } - - let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id); - let row_style = if is_active { - Style::default().bg(p.active_row_bg) - } else { - Style::default() - }; - let name_style = if is_active { - Style::default().fg(p.text).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD) - }; - let status_style = if is_active { - Style::default().fg(label_color) - } else { - Style::default().fg(label_color).add_modifier(Modifier::DIM) - }; - let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); - let state_icon = state_icon(detail.state, detail.seen, app.status_indicators, p); - - for (row_index, resolved) in rows.iter().take(height as usize).enumerate() { - let mut spans = vec![Span::raw(if row_index == 0 { " " } else { " " })]; - spans.extend(resolved_token_spans( - resolved, - state_icon, - status_style, - name_style, - agent_style, - agent_style, - p, - body.width - .saturating_sub(if row_index == 0 { 1 } else { 3 }) as usize, - )); - frame.render_widget( - Paragraph::new(Line::from(spans)).style(row_style), - Rect::new(body.x, row_y + row_index as u16, body.width, 1), - ); - } - row_y = row_y - .saturating_add(height) - .saturating_add(agent_entry_gap(app, index, details.len())) - .min(body_bottom); - } - - if let Some(track) = scrollbar_rect { - render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); - } -} - -pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect { - let bottom_y = area.y + area.height.saturating_sub(1); - let content_w = area.width.saturating_sub(1); - if content_w == 0 || area.height == 0 { - return Rect::default(); - } - let x = area.x + content_w / 2; - Rect::new(x, bottom_y, 1, 1) -} - -pub(crate) fn expanded_sidebar_toggle_rect(area: Rect) -> Rect { - if area.width <= 1 || area.height == 0 { - return Rect::default(); - } - Rect::new( - area.x + area.width.saturating_sub(2), - area.y + area.height.saturating_sub(1), - 1, - 1, - ) -} - -fn render_sidebar_toggle( - app: &AppState, - frame: &mut Frame, - area: Rect, - collapsed: bool, - p: &Palette, -) { - let toggle_area = if collapsed { - collapsed_sidebar_toggle_rect(area) - } else { - expanded_sidebar_toggle_rect(area) - }; - if toggle_area == Rect::default() { - return; - } - let icon = if collapsed { "»" } else { "«" }; - let icon_style = if collapsed && app.global_menu_attention_badge_visible() { - Style::default().fg(p.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay0) - }; - frame.render_widget(Paragraph::new(Span::styled(icon, icon_style)), toggle_area); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{detect::Agent, layout::PaneId, workspace::Workspace}; - use ratatui::{backend::TestBackend, layout::Direction, Terminal}; - - fn row_text(buffer: &ratatui::buffer::Buffer, row: u16, width: u16) -> String { - (0..width) - .map(|x| buffer[(x, row)].symbol()) - .collect::<String>() - .trim_end() - .to_string() - } - - fn find_symbol_x(buffer: &ratatui::buffer::Buffer, row: u16, width: u16, symbol: &str) -> u16 { - (0..width) - .find(|x| buffer[(*x, row)].symbol() == symbol) - .unwrap_or_else(|| { - panic!( - "missing symbol {symbol:?} in row {}", - row_text(buffer, row, width) - ) - }) - } - - #[test] - fn expanded_and_collapsed_sidebars_use_custom_background() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces.clear(); - app.active = None; - app.palette.sidebar_bg = ratatui::style::Color::Rgb(12, 34, 56); - let area = Rect::new(0, 0, 26, 20); - - let mut expanded = Terminal::new(TestBackend::new(26, 20)).unwrap(); - expanded - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert!(expanded - .backend() - .buffer() - .content - .iter() - .all(|cell| cell.bg == app.palette.sidebar_bg)); - - let mut collapsed = Terminal::new(TestBackend::new(26, 20)).unwrap(); - collapsed - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert!(collapsed - .backend() - .buffer() - .content - .iter() - .all(|cell| cell.bg == app.palette.sidebar_bg)); - } - - #[test] - fn default_agent_rows_remove_redundant_state_text() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); - terminal_state.detected_agent = Some(Agent::Pi); - terminal_state.state = AgentState::Working; - - let area = Rect::new(0, 0, 26, 20); - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - - let first = row_text(buffer, body.y, 25); - let second = row_text(buffer, body.y + 1, 25); - assert!(first.contains("one")); - assert_eq!(second, " pi"); - assert!(!first.contains("working")); - assert!(!second.contains("working")); - - let workspace_x = find_symbol_x(buffer, body.y, body.width, "o"); - let workspace_style = buffer[(workspace_x, body.y)].style(); - assert_eq!(workspace_style.fg, Some(app.palette.text)); - assert!(workspace_style.add_modifier.contains(Modifier::BOLD)); - assert!(!workspace_style.add_modifier.contains(Modifier::DIM)); - assert_eq!(workspace_style.bg, Some(app.palette.active_row_bg)); - - let agent_x = find_symbol_x(buffer, body.y + 1, body.width, "p"); - let agent_style = buffer[(agent_x, body.y + 1)].style(); - assert_eq!(agent_style.fg, Some(app.palette.overlay0)); - assert!(agent_style.add_modifier.contains(Modifier::DIM)); - assert!(!agent_style.add_modifier.contains(Modifier::BOLD)); - assert_eq!(agent_style.bg, Some(app.palette.active_row_bg)); - } - - #[test] - fn occurrence_false_removes_default_workspace_bold_and_agent_dim() { - let config: crate::config::Config = toml::from_str( - r##" -[ui.sidebar.agents] -rows = [[{ token = "workspace", bold = false }, { token = "agent", dim = false }]] -"##, - ) - .unwrap(); - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_agents = config.ui.sidebar.agents; - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - - let area = Rect::new(0, 0, 26, 20); - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let buffer = terminal.backend().buffer(); - let workspace = buffer[(find_symbol_x(buffer, body.y, body.width, "o"), body.y)].style(); - let agent = buffer[(find_symbol_x(buffer, body.y, body.width, "p"), body.y)].style(); - - assert_eq!(workspace.fg, Some(app.palette.text)); - assert!(!workspace.add_modifier.contains(Modifier::BOLD)); - assert_eq!(agent.fg, Some(app.palette.overlay0)); - assert!(!agent.add_modifier.contains(Modifier::DIM)); - } - - #[test] - fn default_space_workspace_style_tracks_active_state() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.mode = Mode::Terminal; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let first_row = app.view.workspace_card_areas[0].rect.y; - let second_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - let active = buffer[(find_symbol_x(buffer, first_row, 25, "o"), first_row)].style(); - assert_eq!(active.fg, Some(app.palette.text)); - assert!(active.add_modifier.contains(Modifier::BOLD)); - assert!(!active.add_modifier.contains(Modifier::DIM)); - assert_eq!(active.bg, Some(app.palette.active_row_bg)); - - let inactive = buffer[(find_symbol_x(buffer, second_row, 25, "t"), second_row)].style(); - assert_eq!(inactive.fg, Some(app.palette.subtext0)); - assert!(!inactive - .add_modifier - .intersects(Modifier::BOLD | Modifier::DIM)); - assert_eq!(inactive.bg, Some(ratatui::style::Color::Reset)); - } - - #[test] - fn navigate_selection_keeps_its_existing_background_beside_active_workspace() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 1; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let active_row = app.view.workspace_card_areas[0].rect.y; - let selected_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - assert_eq!( - buffer[(0, active_row)].bg, - app.palette.active_row_bg, - "active workspace should keep its dedicated background" - ); - assert_eq!( - buffer[(0, selected_row)].bg, - app.palette.selection_bg, - "navigate selection should use its dedicated cursor background" - ); - } - - #[test] - fn selected_active_workspace_resolves_expanded_background() { - let mut app = crate::app::state::AppState::test_new(); - app.palette = crate::app::state::Palette::terminal(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let active_row = app.view.workspace_card_areas[0].rect.y; - let inactive_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.active_row_bg - ); - - app.selected = 1; - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.active_row_bg - ); - assert_eq!( - terminal.backend().buffer()[(0, inactive_row)].bg, - app.palette.selection_bg - ); - - app.palette = crate::app::state::Palette::catppuccin(); - app.selected = 0; - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.selection_bg - ); - } - - #[test] - fn selected_active_workspace_resolves_collapsed_background() { - let mut app = crate::app::state::AppState::test_new(); - app.palette = crate::app::state::Palette::terminal(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 5, 8); - let mut terminal = Terminal::new(TestBackend::new(5, 8)).unwrap(); - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - - let (workspace_area, _, _) = collapsed_sidebar_sections(area); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.active_row_bg - ); - - app.selected = 1; - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.active_row_bg - ); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y + 1)].bg, - app.palette.selection_bg - ); - - app.palette = crate::app::state::Palette::catppuccin(); - app.selected = 0; - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.selection_bg - ); - } - - #[test] - fn space_occurrence_style_applies_without_styling_separator() { - let config: crate::config::Config = toml::from_str( - r##" -[ui.sidebar.spaces] -rows = [[{ token = "$hype", fg = "#abcdef", bold = true, dim = false }, "workspace"]] -"##, - ) - .unwrap(); - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_spaces = config.ui.sidebar.spaces; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.mode = Mode::Terminal; - app.workspaces[0].metadata_tokens.patch( - std::collections::HashMap::from([("hype".into(), Some("HI".into()))]), - None, - std::time::Instant::now(), - ); - - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let row = app.view.workspace_card_areas[0].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let h = buffer[(find_symbol_x(buffer, row, 25, "H"), row)].style(); - let i = buffer[(find_symbol_x(buffer, row, 25, "I"), row)].style(); - let separator = buffer[(find_symbol_x(buffer, row, 25, "·"), row)].style(); - - for style in [h, i] { - assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(0xab, 0xcd, 0xef))); - assert!(style.add_modifier.contains(Modifier::BOLD)); - assert!(!style.add_modifier.contains(Modifier::DIM)); - assert_eq!(style.bg, Some(app.palette.active_row_bg)); - } - assert_eq!(separator.fg, Some(app.palette.overlay0)); - assert!(separator.add_modifier.contains(Modifier::DIM)); - assert!(!separator.add_modifier.contains(Modifier::BOLD)); - assert_eq!(separator.bg, Some(app.palette.active_row_bg)); - } - - #[test] - fn occurrence_foreground_flattens_composite_git_status_colors() { - let config: crate::config::Config = toml::from_str( - r##"[ui.sidebar.spaces] -rows = [[{ token = "git_status", fg = "#123456" }]] -"##, - ) - .unwrap(); - let spans = resolved_token_spans( - &[ResolvedToken { - kind: ResolvedTokenKind::GitStatus { - ahead: 2, - behind: 1, - }, - style: config.ui.sidebar.spaces.rows[0][0].parts().1, - }], - ("", Style::default()), - Style::default(), - Style::default(), - Style::default(), - Style::default(), - &crate::app::state::AppState::test_new().palette, - 20, - ); - - assert_eq!( - spans - .iter() - .map(|span| span.content.as_ref()) - .collect::<String>(), - "↑2 ↓1" - ); - assert!(spans - .iter() - .all(|span| { span.style.fg == Some(ratatui::style::Color::Rgb(0x12, 0x34, 0x56)) })); - } - - #[test] - fn default_agent_row_gap_packs_rendering_and_scroll_geometry() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.ensure_test_terminals(); - for (workspace, agent) in app.workspaces.iter().zip([Agent::Pi, Agent::Claude]) { - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent); - } - app.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - assert_eq!(app.sidebar_agents.row_gap, 0); - - let area = Rect::new(0, 0, 20, 5); - let metrics = agent_panel_scroll_metrics(&app, area); - let body = agent_panel_body_rect(area, false); - let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); - terminal - .draw(|frame| render_agent_detail(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - assert_eq!(metrics.viewport_rows, 2); - assert_eq!(metrics.max_offset_from_bottom, 0); - assert_eq!(row_text(buffer, body.y, body.width), " pi"); - assert_eq!(row_text(buffer, body.y + 1, body.width), " claude"); - } - - #[test] - fn narrow_agent_rows_preserve_later_tab_tokens() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = Workspace::test_new("very-long-workspace-name"); - let tab_idx = workspace.test_add_tab(Some("logs")); - let pane_id = workspace.tabs[tab_idx].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - - let area = Rect::new(0, 0, 18, 20); - let mut terminal = Terminal::new(TestBackend::new(18, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let first = row_text(buffer, body.y, 17); - - assert!(first.contains("logs"), "rendered row: {first:?}"); - assert!(first.contains('·'), "rendered row: {first:?}"); - } - - #[test] - fn stripped_terminal_title_renders_with_unicode_width_truncation() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.set_terminal_title(Some("⠋ 修复🙂标题很长".into())); - app.sidebar_agents.rows = vec![vec![ - crate::config::AgentSidebarToken::TerminalTitleStripped, - ]]; - - let area = Rect::new(0, 0, 10, 12); - let mut renderer = Terminal::new(TestBackend::new(10, 12)).unwrap(); - renderer - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let rendered = row_text(renderer.backend().buffer(), body.y, 9); - - assert!(!rendered.contains('⠋')); - assert!(rendered.contains('修') && rendered.contains('复')); - - let spans = resolved_token_spans( - &[ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle( - "修复🙂标题很长".into(), - ))], - ("", Style::default()), - Style::default(), - Style::default(), - Style::default(), - Style::default(), - &app.palette, - 8, - ); - let text = spans - .iter() - .map(|span| span.content.as_ref()) - .collect::<String>(); - assert!(display_width(&text) <= 8, "resolved title: {text:?}"); - } - - #[test] - fn variable_agent_heights_pack_the_bottom_and_reveal_targets() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("one"), - Workspace::test_new("two"), - Workspace::test_new("three"), - ]; - app.ensure_test_terminals(); - for workspace in &app.workspaces { - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - } - let first_pane = app.workspaces[0].tabs[0].root_pane; - let first_terminal = app.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.terminals - .get_mut(&first_terminal) - .unwrap() - .metadata_tokens - .patch( - std::collections::HashMap::from([ - ("a".into(), Some("a".into())), - ("b".into(), Some("b".into())), - ]), - None, - std::time::Instant::now(), - ); - app.sidebar_agents.rows = vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Custom("a".into())], - vec![crate::config::AgentSidebarToken::Custom("b".into())], - ]; - let area = Rect::new(0, 0, 20, 6); - - let metrics = agent_panel_scroll_metrics(&app, area); - assert_eq!(metrics.max_offset_from_bottom, 1); - assert_eq!(agent_panel_scroll_for_target(&app, area, 0, 2), 1); - } - - #[test] - fn oversized_space_layout_is_clipped_to_the_section_body() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]; 6]; - let area = Rect::new(0, 0, 20, 10); - let workspace_area = workspace_list_rect(area, app.sidebar_section_split); - let body = workspace_list_body_rect(workspace_area, false); - - let metrics = workspace_list_scroll_metrics(&app, workspace_area); - let (cards, _) = compute_workspace_list_areas(&app, area); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].ws_idx, 0); - assert_eq!(cards[0].rect.height, body.height); - } - - #[test] - fn oversized_agent_override_is_clipped_to_the_panel_body() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - app.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![vec![crate::config::AgentSidebarToken::Agent]; 6], - ); - let panel = Rect::new(0, 0, 20, 5); - - let metrics = agent_panel_scroll_metrics(&app, panel); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(metrics.max_offset_from_bottom, 0); - let entry = agent_panel_entries(&app).pop().unwrap(); - assert_eq!( - agent_entry_height_in_body(&app, &entry, agent_panel_body_rect(panel, false).height), - agent_panel_body_rect(panel, false).height - ); - } - - #[test] - fn render_sidebar_toggle_draws_expanded_collapse_icon() { - let app = crate::app::state::AppState::test_new(); - let area = Rect::new(0, 0, 26, 20); - let mut terminal = - Terminal::new(TestBackend::new(26, 20)).expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_toggle(&app, frame, area, false, &app.palette)) - .expect("sidebar toggle should render"); - - let toggle = expanded_sidebar_toggle_rect(area); - assert_eq!( - terminal.backend().buffer()[(toggle.x, toggle.y)].symbol(), - "«" - ); - } - - #[test] - fn expanded_sidebar_toggle_sits_inside_sidebar_content() { - let area = Rect::new(0, 0, 26, 20); - let toggle = expanded_sidebar_toggle_rect(area); - - assert_eq!(toggle.x, area.x + area.width - 2); - assert_eq!(toggle.y, area.y + area.height - 1); - } - - #[test] - fn agent_panel_tab_label_visibility_tracks_tab_identity() { - let mut app = crate::app::state::AppState::test_new(); - let single_auto = Workspace::test_new("auto"); - let mut single_custom = Workspace::test_new("custom"); - single_custom.tabs[0].set_custom_name("focus".into()); - let mut multi = Workspace::test_new("multi"); - multi.test_add_tab(Some("logs")); - - app.workspaces = vec![single_auto, single_custom, multi]; - app.ensure_test_terminals(); - for (ws_idx, tab_idx, agent) in [ - (0, 0, Agent::Pi), - (1, 0, Agent::Claude), - (2, 0, Agent::Codex), - (2, 1, Agent::Pi), - ] { - let pane_id = app.workspaces[ws_idx].tabs[tab_idx].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent); - } - - let entries = agent_panel_entries(&app); - let labels: Vec<_> = entries - .iter() - .map(|entry| { - ( - entry.primary_label.as_str(), - entry.primary_tab_label.as_deref(), - ) - }) - .collect(); - - assert_eq!( - labels, - [ - ("auto", None), - ("custom", Some("focus")), - ("multi", Some("1")), - ("multi", Some("logs")), - ] - ); - } - - #[test] - fn priority_agent_panel_sort_uses_attention_then_space_order() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("one"), - Workspace::test_new("two"), - Workspace::test_new("three"), - Workspace::test_new("four"), - ]; - app.ensure_test_terminals(); - app.active = Some(0); - app.selected = 0; - app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - - let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, state| { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, AgentState::Working); - set_state(&mut app, 1, AgentState::Idle); - set_state(&mut app, 2, AgentState::Working); - set_state(&mut app, 3, AgentState::Blocked); - - let done_pane = app.workspaces[1].tabs[0].root_pane; - app.workspaces[1].tabs[0] - .panes - .get_mut(&done_pane) - .unwrap() - .seen = false; - - let labels: Vec<String> = agent_panel_entries(&app) - .into_iter() - .map(|entry| entry.primary_label) - .collect(); - - assert_eq!(labels, ["four", "two", "one", "three"]); - } - - #[test] - fn collapsed_sidebar_numbers_grouped_agents_by_list_position() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 12); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2"); - } - - /// Two agent panes in one workspace plus a second workspace, so the - /// assertions can tell pane-level highlighting apart from workspace-level. - fn collapsed_agent_app() -> (crate::app::state::AppState, PaneId, PaneId) { - let mut app = crate::app::state::AppState::test_new(); - let mut first = Workspace::test_new("one"); - let second_pane = first.test_split(Direction::Horizontal); - let first_pane = first.tabs[0].root_pane; - app.workspaces = vec![first, Workspace::test_new("two")]; - app.ensure_test_terminals(); - - let terminal_ids: Vec<_> = app - .workspaces - .iter() - .flat_map(|ws| ws.tabs.iter()) - .flat_map(|tab| tab.panes.values()) - .map(|pane| pane.attached_terminal_id.clone()) - .collect(); - for terminal_id in terminal_ids { - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - (app, first_pane, second_pane) - } - - fn collapsed_agent_row_styles( - app: &crate::app::state::AppState, - area: Rect, - detail_area: Rect, - rows: u16, - ) -> Vec<Vec<ratatui::style::Style>> { - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - terminal - .draw(|frame| render_sidebar_collapsed(app, frame, area)) - .expect("collapsed sidebar should render"); - let buffer = terminal.backend().buffer(); - (0..rows) - .map(|row| { - (detail_area.x..detail_area.x + detail_area.width) - .map(|x| buffer[(x, detail_area.y + row)].style()) - .collect() - }) - .collect() - } - - #[test] - fn collapsed_sidebar_highlights_only_the_focused_agent_pane() { - let (mut app, first_pane, second_pane) = collapsed_agent_app(); - app.active = Some(0); - app.workspaces[0].tabs[0].layout.focus_pane(second_pane); - assert!(app.is_active_pane(0, 0, second_pane)); - assert!(!app.is_active_pane(0, 0, first_pane)); - - let area = Rect::new(0, 0, 4, 14); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let rows = collapsed_agent_row_styles(&app, area, detail_area, 3); - - let highlighted: Vec<_> = rows - .iter() - .filter(|cells| { - cells - .iter() - .all(|style| style.bg == Some(app.palette.active_row_bg)) - }) - .collect(); - assert_eq!( - highlighted.len(), - 1, - "only the focused agent pane should be highlighted, across the whole row" - ); - assert_eq!(highlighted[0][0].fg, Some(app.palette.text)); - - let muted = rows - .iter() - .filter(|cells| cells[0].fg == Some(app.palette.overlay0)) - .count(); - assert_eq!( - muted, 2, - "the sibling pane in the active workspace and the other workspace stay muted" - ); - } - - #[test] - fn collapsed_sidebar_does_not_highlight_agents_without_active_workspace() { - let (mut app, _, _) = collapsed_agent_app(); - app.active = None; - - let area = Rect::new(0, 0, 4, 14); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let rows = collapsed_agent_row_styles(&app, area, detail_area, 3); - - for cells in rows { - assert_eq!(cells[0].fg, Some(app.palette.overlay0)); - for style in cells { - assert_ne!(style.bg, Some(app.palette.active_row_bg)); - } - } - } - - #[test] - fn collapsed_sidebar_keeps_workspace_status_visible_for_two_digit_positions() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = (1..=10) - .map(|idx| Workspace::test_new(&format!("workspace-{idx}"))) - .collect(); - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 25); - let (workspace_area, _, _) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let tenth_row = workspace_area.y + 9; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(workspace_area.x, workspace_area.y)].symbol(), "1"); - assert_eq!( - buffer[(workspace_area.x + 1, workspace_area.y)].symbol(), - " " - ); - assert_eq!( - buffer[(workspace_area.x + 2, workspace_area.y)].symbol(), - "·" - ); - assert_eq!(buffer[(workspace_area.x, tenth_row)].symbol(), "1"); - assert_eq!(buffer[(workspace_area.x + 1, tenth_row)].symbol(), "0"); - assert_eq!(buffer[(workspace_area.x + 2, tenth_row)].symbol(), "·"); - } - - #[test] - fn collapsed_sidebar_keeps_status_visible_for_two_digit_positions() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = (1..=10) - .map(|idx| Workspace::test_new(&format!("workspace-{idx}"))) - .collect(); - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 25); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let tenth_row = detail_area.y + 9; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, tenth_row)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x + 1, tenth_row)].symbol(), "0"); - assert_eq!(buffer[(detail_area.x + 2, tenth_row)].symbol(), "·"); - } - - #[test] - fn collapsed_sidebar_numbers_priority_agents_by_list_position() { - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let mut second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - let urgent_pane = second.test_split(ratatui::layout::Direction::Horizontal); - - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![first, second]; - app.ensure_test_terminals(); - app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - app.status_indicators = crate::config::StatusIndicatorStyle::Symbols; - - let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, pane_id, state| { - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, first_pane, AgentState::Idle); - set_state(&mut app, 1, second_pane, AgentState::Working); - set_state(&mut app, 1, urgent_pane, AgentState::Blocked); - app.workspaces[0].tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .seen = false; - - assert_eq!(app.workspaces[1].public_pane_number(urgent_pane), Some(2)); - assert_eq!(agent_panel_entries(&app)[0].pane_id, urgent_pane); - - let area = Rect::new(0, 0, 4, 16); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 2)].symbol(), "3"); - assert_eq!(buffer[(detail_area.x + 2, detail_area.y)].symbol(), "×"); - assert_eq!( - buffer[(detail_area.x + 2, detail_area.y)].style().fg, - Some(app.palette.red) - ); - assert_eq!(buffer[(detail_area.x + 2, detail_area.y + 1)].symbol(), "✓"); - assert_eq!( - buffer[(detail_area.x + 2, detail_area.y + 1)].style().fg, - Some(app.palette.teal) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn all_workspaces_agent_panel_entries_use_live_root_runtime_cwd_for_workspace_label() { - let unique = format!( - "herdr-agent-panel-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.cwd = stale_cwd; - terminal.detected_agent = Some(Agent::Pi); - app.active = Some(0); - app.selected = 0; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - let entries = agent_panel_entries_from(&app, &runtime_registry); - let primary_label = entries[0].primary_label.clone(); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - assert_eq!(primary_label, "herdr"); - } - - #[test] - fn all_workspaces_agent_panel_entries_prefer_agent_names_for_agent_identity() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("bridge"); - let first_pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let first_terminal_id = app.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - app.terminals - .get_mut(&first_terminal_id) - .unwrap() - .set_agent_name("planner".into()); - app.active = Some(0); - app.selected = 0; - - let entries = agent_panel_entries(&app); - assert_eq!(entries[0].primary_label, "bridge"); - assert_eq!(entries[0].agent_label.as_deref(), Some("planner")); - } - - #[test] - fn expanded_sidebar_sections_handle_tiny_heights() { - let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9); - - assert_eq!(ws_area, Rect::new(0, 0, 19, 3)); - assert_eq!(detail_area, Rect::new(0, 3, 19, 2)); - } - - #[test] - fn sidebar_section_divider_is_hidden_for_tiny_heights() { - let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5); - - assert_eq!(divider, Rect::default()); - } - - #[test] - fn grouped_child_label_keeps_custom_workspace_name() { - assert_eq!( - grouped_child_display_label("renamed issue", Some("worktree/issue-137"), true), - "renamed issue" - ); - } - - #[test] - fn grouped_child_label_uses_short_branch_for_auto_named_workspace() { - assert_eq!( - grouped_child_display_label("herdr-issue", Some("worktree/issue-137"), false), - "issue-137" - ); - } - - #[test] - fn workspace_list_truncates_cjk_branch_without_panic() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("repo"); - ws.cached_git_branch = Some("feature/中文-分支-644".into()); - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.view.workspace_card_areas = vec![crate::app::state::WorkspaceCardArea { - ws_idx: 0, - rect: Rect::new(0, 1, 15, 2), - indented: false, - }]; - - let mut terminal = Terminal::new(TestBackend::new(15, 6)).expect("test terminal"); - let runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - terminal - .draw(|frame| { - render_workspace_list(&app, &runtimes, frame, Rect::new(0, 0, 15, 6), false) - }) - .expect("workspace list should render"); - } - - fn workspace_with_worktree_space( - name: &str, - key: Option<&str>, - checkout_key: &str, - ) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - if let Some(key) = key { - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - checkout_path: std::path::PathBuf::from(checkout_key), - is_linked_worktree: name != "main", - }); - } - ws - } - - fn workspace_with_git_space(name: &str, key: &str) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - ws.cached_git_space = Some(crate::workspace::GitSpaceMetadata { - key: key.into(), - checkout_key: format!("/repo/{name}"), - repo_name: "herdr".into(), - repo_root: std::path::PathBuf::from(format!("/repo/{name}")), - is_linked_worktree: false, - }); - ws - } - - #[test] - fn desktop_worktree_tree_aligns_parents_and_marks_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - Workspace::test_new("notes"), - ]; - app.sidebar_spaces.rows = vec![vec![ - crate::config::SpaceSidebarToken::StateIcon, - crate::config::SpaceSidebarToken::Workspace, - ]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - let buffer = terminal.backend().buffer(); - let cards = &app.view.workspace_card_areas; - let parent_name_x = find_symbol_x(buffer, cards[0].rect.y, cards[0].rect.width, "m"); - let plain_name_x = find_symbol_x(buffer, cards[3].rect.y, cards[3].rect.width, "n"); - assert_eq!(parent_name_x, plain_name_x); - assert_eq!(buffer[(cards[1].rect.x + 3, cards[1].rect.y)].symbol(), "├"); - assert_eq!(buffer[(cards[2].rect.x + 3, cards[2].rect.y)].symbol(), "└"); - assert_eq!( - buffer[(cards[0].rect.x + cards[0].rect.width - 1, cards[0].rect.y)].symbol(), - "▾" - ); - } - - #[test] - fn desktop_worktree_connector_uses_full_list_at_viewport_boundary() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 10); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - assert_eq!(app.view.workspace_card_areas.len(), 2); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - let child = app.view.workspace_card_areas[1]; - assert_eq!( - terminal.backend().buffer()[(child.rect.x + 3, child.rect.y)].symbol(), - "├" - ); - } - - #[test] - fn parent_workspace_row_stays_clickable_when_grouped() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.sidebar_spaces.row_gap = 1; - - let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20)); - - assert!(headers.is_empty()); - assert_eq!(cards[0].ws_idx, 0); - assert!(!cards[0].indented); - assert_eq!(cards[1].ws_idx, 1); - assert!(cards[1].indented); - assert_eq!(cards[1].rect.y, cards[0].rect.y + cards[0].rect.height); - } - - #[test] - fn space_row_gap_preserves_compact_worktree_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - Workspace::test_new("notes"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 2; - - let (spacious, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30)); - assert_eq!( - spacious[1].rect.y, - spacious[0].rect.y + spacious[0].rect.height - ); - assert_eq!( - spacious[2].rect.y, - spacious[1].rect.y + spacious[1].rect.height - ); - assert_eq!( - spacious[3].rect.y, - spacious[2].rect.y + spacious[2].rect.height + 2 - ); - let spacious_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7)); - assert_eq!(spacious_metrics.viewport_rows, 3); - assert_eq!(spacious_metrics.max_offset_from_bottom, 2); - - app.sidebar_spaces.row_gap = 0; - let (packed, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30)); - assert!(packed - .windows(2) - .all(|pair| pair[1].rect.y == pair[0].rect.y + pair[0].rect.height)); - let packed_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7)); - assert_eq!(packed_metrics.viewport_rows, 4); - assert_eq!(packed_metrics.max_offset_from_bottom, 0); - } - - #[test] - fn packed_workspace_drag_indicator_overlays_an_internal_boundary() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - let indicator_row = workspace_drop_indicator_row( - &app, - &app.view.workspace_card_areas, - list_area, - crate::app::state::WorkspaceDropTarget::Before(2), - ) - .unwrap(); - assert_eq!(indicator_row, app.view.workspace_card_areas[1].rect.y); - app.drag = Some(crate::app::state::DragState { - target: crate::app::state::DragTarget::WorkspaceReorder { - source_id: 0, - source_ws_idx: 0, - drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(2)), - }, - }); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(list_area.x, indicator_row)].symbol(), - "─" - ); - } - - #[test] - fn linked_only_worktree_members_do_not_form_parentless_group() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - ]; - - let entries = workspace_list_entries(&app); - - assert_eq!( - entries, - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false - }, - ] - ); - } - - #[test] - fn compact_space_group_scroll_clamps_when_all_entries_fit() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("one", Some("repo-key"), "/repo/herdr-one"), - workspace_with_worktree_space("two", Some("repo-key"), "/repo/herdr-two"), - ]; - let area = Rect::new(0, 0, 30, 20); - app.workspace_scroll = normalized_workspace_scroll(&app, area, 2); - - let (cards, headers) = compute_workspace_list_areas(&app, area); - - assert!(headers.is_empty()); - assert_eq!(app.workspace_scroll, 0); - assert_eq!(cards.len(), 3); - assert_eq!(cards[2].ws_idx, 2); - } - - #[test] - fn workspace_scroll_metrics_count_display_entries_not_raw_workspaces() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - Workspace::test_new("notes"), - ]; - for workspace in &mut app.workspaces { - workspace.cached_git_branch = Some("main".into()); - } - app.collapsed_space_keys.insert("repo-key".into()); - app.active = None; - app.mode = Mode::Terminal; - - let ws_area = Rect::new(0, 0, 30, 6); - let metrics = workspace_list_scroll_metrics(&app, ws_area); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(metrics.max_offset_from_bottom, 1); - assert_eq!(metrics.offset_from_bottom, 1); - } - - #[test] - fn workspace_scroll_offset_applies_to_group_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - Workspace::test_new("notes"), - ]; - app.collapsed_space_keys.insert("repo-key".into()); - app.active = None; - app.mode = Mode::Terminal; - app.workspace_scroll = 1; - - let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 12)); - - assert!(headers.is_empty()); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].ws_idx, 2); - } - - #[test] - fn workspace_list_entries_group_multiple_workspaces_in_same_git_space() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_group_non_contiguous_explicit_members() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_git_space("normal", "other-key"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 2, - indented: true, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_do_not_group_normal_git_workspaces() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_git_space("one", "repo-key"), - workspace_with_git_space("two", "repo-key"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_do_not_auto_attach_normal_git_workspace_to_group() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_git_space("scratch", "repo-key"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 2, - indented: true, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_leave_single_git_and_non_git_workspaces_flat() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_git_space("one", "repo-key"), - workspace_with_worktree_space("notes", None, "/notes"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn collapsed_group_hides_inactive_children_but_keeps_active_visible() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.active = Some(1); - app.mode = Mode::Terminal; - app.collapsed_space_keys.insert("repo-key".into()); - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - - app.active = None; - app.mode = Mode::Terminal; - assert_eq!( - workspace_list_entries(&app), - vec![WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }] - ); - } - - #[test] - fn collapsed_group_keeps_selected_child_visible_in_navigate_mode() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.mode = Mode::Navigate; - app.selected = 1; - app.active = Some(1); - app.collapsed_space_keys.insert("repo-key".into()); - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - } -} diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index c578e13f..eaec154b 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -1,17 +1,16 @@ -use super::AgentPanelEntry; use crate::config::{ AgentSidebarToken, AgentsSidebarConfig, SidebarTokenStyle, SpaceSidebarToken, SpacesSidebarConfig, }; #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct ResolvedToken { +pub(crate) struct ResolvedToken { pub kind: ResolvedTokenKind, pub style: SidebarTokenStyle, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum ResolvedTokenKind { +pub(crate) enum ResolvedTokenKind { StateIcon, StateText(String), Workspace(String), @@ -35,13 +34,24 @@ impl ResolvedToken { } } -pub(super) fn agent_rows( +pub(crate) struct AgentTokenContext<'a> { + pub(crate) workspace: &'a str, + pub(crate) tab: Option<&'a str>, + pub(crate) pane: Option<&'a str>, + pub(crate) agent_label: Option<&'a str>, + pub(crate) terminal_title: Option<&'a str>, + pub(crate) terminal_title_stripped: Option<&'a str>, + pub(crate) canonical_agent: Option<crate::detect::Agent>, + pub(crate) tokens: &'a std::collections::HashMap<String, String>, +} + +pub(crate) fn agent_rows( config: &AgentsSidebarConfig, - entry: &AgentPanelEntry, + context: AgentTokenContext<'_>, state_text: &str, ) -> Vec<Vec<ResolvedToken>> { config - .rows_for_agent(entry.agent) + .rows_for_agent(context.canonical_agent) .iter() .filter_map(|row| { let resolved = row @@ -54,26 +64,24 @@ pub(super) fn agent_rows( Some(ResolvedTokenKind::StateText(state_text.to_string())) } AgentSidebarToken::Workspace => { - Some(ResolvedTokenKind::Workspace(entry.primary_label.clone())) - } - AgentSidebarToken::Tab => { - entry.primary_tab_label.clone().map(ResolvedTokenKind::Tab) - } - AgentSidebarToken::Pane => { - entry.pane_label.clone().map(ResolvedTokenKind::Pane) - } - AgentSidebarToken::Agent => { - entry.agent_label.clone().map(ResolvedTokenKind::Agent) + Some(ResolvedTokenKind::Workspace(context.workspace.to_string())) } - AgentSidebarToken::TerminalTitle => entry + AgentSidebarToken::Tab => context + .tab + .map(|value| ResolvedTokenKind::Tab(value.to_string())), + AgentSidebarToken::Pane => context + .pane + .map(|value| ResolvedTokenKind::Pane(value.to_string())), + AgentSidebarToken::Agent => context + .agent_label + .map(|value| ResolvedTokenKind::Agent(value.to_string())), + AgentSidebarToken::TerminalTitle => context .terminal_title - .clone() - .map(ResolvedTokenKind::TerminalTitle), - AgentSidebarToken::TerminalTitleStripped => entry + .map(|value| ResolvedTokenKind::TerminalTitle(value.to_string())), + AgentSidebarToken::TerminalTitleStripped => context .terminal_title_stripped - .clone() - .map(ResolvedTokenKind::TerminalTitle), - AgentSidebarToken::Custom(name) => entry + .map(|value| ResolvedTokenKind::TerminalTitle(value.to_string())), + AgentSidebarToken::Custom(name) => context .tokens .get(name) .cloned() @@ -88,16 +96,16 @@ pub(super) fn agent_rows( .collect() } -pub(super) struct SpaceTokenContext<'a> { - pub workspace: &'a str, - pub branch: Option<&'a str>, - pub state_text: &'a str, - pub ahead_behind: Option<(usize, usize)>, - pub tokens: &'a std::collections::HashMap<String, String>, - pub suppress_git_details: bool, +pub(crate) struct SpaceTokenContext<'a> { + pub(crate) workspace: &'a str, + pub(crate) branch: Option<&'a str>, + pub(crate) state_text: &'a str, + pub(crate) ahead_behind: Option<(usize, usize)>, + pub(crate) tokens: &'a std::collections::HashMap<String, String>, + pub(crate) suppress_git_details: bool, } -pub(super) fn space_rows( +pub(crate) fn space_rows( config: &SpacesSidebarConfig, context: SpaceTokenContext<'_>, ) -> Vec<Vec<ResolvedToken>> { @@ -141,7 +149,7 @@ pub(super) fn space_rows( .collect() } -pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str { +pub(crate) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str { if matches!(previous.kind, ResolvedTokenKind::StateIcon) || matches!(current.kind, ResolvedTokenKind::GitStatus { .. }) { @@ -155,29 +163,44 @@ pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &' mod tests { use super::*; use crate::config::{AgentSidebarToken, SpaceSidebarToken}; - use crate::detect::AgentState; - fn entry() -> AgentPanelEntry { - AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: crate::layout::PaneId::from_raw(1), - primary_label: "repo".into(), - primary_tab_label: None, - pane_label: None, + struct Entry { + workspace: String, + tab: Option<String>, + pane: Option<String>, + agent_label: Option<String>, + terminal_title: Option<String>, + terminal_title_stripped: Option<String>, + canonical_agent: Option<crate::detect::Agent>, + tokens: std::collections::HashMap<String, String>, + } + + fn entry() -> Entry { + Entry { + workspace: "repo".into(), + tab: None, + pane: None, + agent_label: Some("pi".into()), terminal_title: None, terminal_title_stripped: None, - agent_label: Some("pi".into()), - agent_kind_label: Some("pi".into()), - agent: Some(crate::detect::Agent::Pi), - state: AgentState::Working, - seen: true, - last_agent_state_change_seq: None, - state_labels: std::collections::HashMap::new(), + canonical_agent: Some(crate::detect::Agent::Pi), tokens: std::collections::HashMap::new(), } } + fn context(entry: &Entry) -> AgentTokenContext<'_> { + AgentTokenContext { + workspace: &entry.workspace, + tab: entry.tab.as_deref(), + pane: entry.pane.as_deref(), + agent_label: entry.agent_label.as_deref(), + terminal_title: entry.terminal_title.as_deref(), + terminal_title_stripped: entry.terminal_title_stripped.as_deref(), + canonical_agent: entry.canonical_agent, + tokens: &entry.tokens, + } + } + #[test] fn missing_custom_tokens_elide_rows_and_separators() { let entry = entry(); @@ -193,7 +216,7 @@ mod tests { ..Default::default() }; - let rows = agent_rows(&config, &entry, "working"); + let rows = agent_rows(&config, context(&entry), "working"); assert_eq!(rows.len(), 2); assert_eq!( @@ -223,7 +246,7 @@ mod tests { }; assert_eq!( - agent_rows(&config, &entry, "deep in the mines"), + agent_rows(&config, context(&entry), "deep in the mines"), vec![vec![ ResolvedToken::unstyled(ResolvedTokenKind::StateText("deep in the mines".into())), ResolvedToken::unstyled(ResolvedTokenKind::Custom("reviewing auth".into())), @@ -249,7 +272,7 @@ mod tests { }; assert_eq!( - agent_rows(&config, &entry, "working"), + agent_rows(&config, context(&entry), "working"), vec![vec![ ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("⠋ raw title".into())), ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("raw title".into())), @@ -271,15 +294,15 @@ mod tests { pi.agent_label = Some("renamed pi".into()); assert_eq!( - agent_rows(&config, &pi, "working"), + agent_rows(&config, context(&pi), "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent( "renamed pi".into() ))]] ); - pi.agent = None; + pi.canonical_agent = None; assert_eq!( - agent_rows(&config, &pi, "working"), + agent_rows(&config, context(&pi), "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace( "repo".into() ))]] diff --git a/src/ui/status.rs b/src/ui/status.rs index 62646482..e47e0f90 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -1,17 +1,15 @@ use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, - Frame, + widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -use super::text::display_width_u16; use super::widgets::panel_contrast_fg; use crate::{ - app::state::{CopyFeedback, Palette, ToastKind, ToastNotification}, - config::{StatusIndicatorStyle, ToastClipboardPosition, ToastHerdrPosition}, - detect::AgentState, + app::state::{CopyFeedback, Palette}, + config::ToastClipboardPosition, }; pub(crate) fn copy_feedback_rect( @@ -20,7 +18,7 @@ pub(crate) fn copy_feedback_rect( offset_rows: u16, position: ToastClipboardPosition, ) -> Rect { - if area.width == 0 || area.height == 0 { + if area.is_empty() { return Rect::default(); } @@ -49,129 +47,56 @@ pub(crate) fn copy_feedback_rect( Rect::new(x, y, width, height) } -pub(crate) fn toast_notification_rect( - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - position: ToastHerdrPosition, -) -> Rect { - let content_width = display_width_u16(&toast.title) - .max(display_width_u16(&toast.context)) - .saturating_add(4); - let width = content_width.saturating_add(2).min(area.width); - let content_height = if toast.context.is_empty() { 1 } else { 2 }; - let height = (content_height + 2).min(area.height); - let x = match position { - ToastHerdrPosition::TopLeft | ToastHerdrPosition::BottomLeft => area.x, - ToastHerdrPosition::TopRight | ToastHerdrPosition::BottomRight => { - area.x + area.width.saturating_sub(width) - } - }; - let warning_offset = u16::from(offset_for_warning); - let y = match position { - ToastHerdrPosition::TopLeft | ToastHerdrPosition::TopRight => { - area.y + warning_offset.min(area.height) - } - ToastHerdrPosition::BottomLeft | ToastHerdrPosition::BottomRight => { - area.y + area.height.saturating_sub(height + warning_offset) - } - }; - Rect::new(x, y, width, height) -} - -pub(super) fn render_toast_notification( - frame: &mut Frame, - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - position: ToastHerdrPosition, - p: &Palette, -) { - let dot_color = match toast.kind { - ToastKind::NeedsAttention => p.red, - ToastKind::Finished => p.blue, - ToastKind::UpdateInstalled => p.accent, - }; - let toast_area = toast_notification_rect(area, toast, offset_for_warning, position); - - frame.render_widget(Clear, toast_area); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(p.overlay0)) - .style(Style::default().bg(p.panel_bg)); - let inner = block.inner(toast_area); - frame.render_widget(block, toast_area); - - if inner.height < 1 { - return; - } - - let [title_row, context_row] = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); - - let title = Line::from(vec![ - Span::styled("●", Style::default().fg(dot_color)), - Span::raw(" "), - Span::styled( - &toast.title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - ), - ]); - let context = Line::from(vec![ - Span::styled(" ", Style::default().fg(p.overlay0)), - Span::styled(&toast.context, Style::default().fg(p.overlay0)), - ]); - - frame.render_widget(Paragraph::new(title), title_row); - if !toast.context.is_empty() && inner.height >= 2 { - frame.render_widget(Paragraph::new(context), context_row); - } -} - -pub(super) fn render_copy_feedback( - frame: &mut Frame, +pub(crate) fn render_copy_feedback_buffer( + buffer: &mut Buffer, area: Rect, feedback: &CopyFeedback, offset_rows: u16, position: ToastClipboardPosition, - p: &Palette, + palette: &Palette, ) { let feedback_area = copy_feedback_rect(area, feedback, offset_rows, position); if feedback_area.is_empty() { return; } - frame.render_widget(Clear, feedback_area); + Clear.render(feedback_area, buffer); let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(p.green)) - .style(Style::default().bg(p.panel_bg)); + .border_style(Style::default().fg(palette.green)) + .style(Style::default().bg(palette.panel_bg)); let inner = block.inner(feedback_area); - frame.render_widget(block, feedback_area); + block.render(feedback_area, buffer); if inner.height == 0 { return; } let text = Line::from(vec![ - Span::styled("●", Style::default().fg(p.green).bg(p.panel_bg)), + Span::styled("●", Style::default().fg(palette.green).bg(palette.panel_bg)), Span::raw(" "), Span::styled( &feedback.message, Style::default() - .fg(p.text) - .bg(p.panel_bg) + .fg(palette.text) + .bg(palette.panel_bg) .add_modifier(Modifier::BOLD), ), ]); - frame.render_widget(Paragraph::new(text), inner); + Paragraph::new(text).render(inner, buffer); } -pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { +pub(crate) fn render_config_diagnostic_buffer( + buffer: &mut Buffer, + area: Rect, + message: &str, + palette: &Palette, +) -> u16 { let style = Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.yellow) + .fg(panel_contrast_fg(palette)) + .bg(palette.yellow) .add_modifier(Modifier::BOLD); + let mut rendered_rows = 0u16; for (row, line) in message .lines() @@ -181,176 +106,41 @@ pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: & { let text = format!(" {line} "); let width = (text.len() as u16).min(area.width); - let notif_area = Rect::new( + let diagnostic_area = Rect::new( area.x + area.width.saturating_sub(width), area.y + row as u16, width, 1, ); - frame.render_widget(Clear, notif_area); - frame.render_widget(Paragraph::new(Span::styled(text, style)), notif_area); + Clear.render(diagnostic_area, buffer); + Paragraph::new(Span::styled(text, style)).render(diagnostic_area, buffer); + rendered_rows = rendered_rows.saturating_add(1); } -} -pub(super) fn state_icon_symbol( - state: AgentState, - seen: bool, - indicator_style: StatusIndicatorStyle, -) -> &'static str { - match (indicator_style, state, seen) { - (StatusIndicatorStyle::Dots, AgentState::Blocked, _) => "●", - (StatusIndicatorStyle::Dots, AgentState::Working, _) => "●", - (StatusIndicatorStyle::Dots, AgentState::Idle, false) => "●", - (StatusIndicatorStyle::Dots, AgentState::Idle, true) => "○", - (StatusIndicatorStyle::Dots, AgentState::Unknown, _) => "·", - (StatusIndicatorStyle::Symbols, AgentState::Blocked, _) => "×", - (StatusIndicatorStyle::Symbols, AgentState::Working, _) => "◐", - (StatusIndicatorStyle::Symbols, AgentState::Idle, false) => "✓", - (StatusIndicatorStyle::Symbols, AgentState::Idle, true) => "○", - (StatusIndicatorStyle::Symbols, AgentState::Unknown, _) => "·", - } -} - -pub(super) fn state_icon( - state: AgentState, - seen: bool, - indicator_style: StatusIndicatorStyle, - p: &Palette, -) -> (&'static str, Style) { - ( - state_icon_symbol(state, seen, indicator_style), - Style::default().fg(state_label_color(state, seen, p)), - ) -} - -pub(super) fn state_label(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Blocked, _) => "blocked", - (AgentState::Working, _) => "working", - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Unknown, _) => "idle", - } -} - -pub(super) fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color { - match (state, seen) { - (AgentState::Blocked, _) => p.red, - (AgentState::Working, _) => p.yellow, - (AgentState::Idle, false) => p.teal, - (AgentState::Idle, true) => p.green, - (AgentState::Unknown, _) => p.overlay0, - } + rendered_rows } #[cfg(test)] mod tests { use super::*; - use crate::config::{ToastClipboardPosition, ToastHerdrPosition}; - - fn toast() -> ToastNotification { - ToastNotification { - kind: ToastKind::Finished, - title: "done".to_string(), - context: "workspace".to_string(), - position: None, - target: None, - } - } - - fn feedback() -> CopyFeedback { - CopyFeedback { - message: "copied to clipboard".to_string(), - } - } - - #[test] - fn state_icons_support_dot_and_distinct_symbol_styles() { - let palette = Palette::catppuccin(); - for (indicator_style, expected_symbols) in [ - (StatusIndicatorStyle::Dots, ["●", "●", "●", "○", "·"]), - (StatusIndicatorStyle::Symbols, ["×", "◐", "✓", "○", "·"]), - ] { - for ((state, seen, color), expected_symbol) in [ - (AgentState::Blocked, true, palette.red), - (AgentState::Working, true, palette.yellow), - (AgentState::Idle, false, palette.teal), - (AgentState::Idle, true, palette.green), - (AgentState::Unknown, true, palette.overlay0), - ] - .into_iter() - .zip(expected_symbols) - { - let (actual_symbol, style) = state_icon(state, seen, indicator_style, &palette); - assert_eq!(actual_symbol, expected_symbol); - assert_eq!(display_width_u16(actual_symbol), 1); - assert_eq!(style.fg, Some(color)); - } - } - } - - #[test] - fn toast_rect_uses_configured_corner() { - let area = Rect::new(10, 20, 100, 40); - let toast = toast(); - - let top_left = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopLeft); - assert_eq!(top_left.x, area.x); - assert_eq!(top_left.y, area.y); - - let top_right = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight); - assert_eq!(top_right.x + top_right.width, area.x + area.width); - assert_eq!(top_right.y, area.y); - - let bottom_left = - toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomLeft); - assert_eq!(bottom_left.x, area.x); - assert_eq!(bottom_left.y + bottom_left.height, area.y + area.height); - - let bottom_right = - toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomRight); - assert_eq!(bottom_right.x + bottom_right.width, area.x + area.width); - assert_eq!(bottom_right.y + bottom_right.height, area.y + area.height); - } - - #[test] - fn toast_rect_uses_display_width_for_cjk_labels() { - let area = Rect::new(0, 0, 100, 20); - let toast = ToastNotification { - kind: ToastKind::NeedsAttention, - title: "重构用户认证模块".to_string(), - context: "提交 herdr 的反馈".to_string(), - position: None, - target: None, - }; - - let rect = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight); - - let expected_content_width = - display_width_u16(&toast.title).max(display_width_u16(&toast.context)) + 6; - assert_eq!(rect.width, expected_content_width); - assert_eq!(rect.x + rect.width, area.x + area.width); - } #[test] fn copy_feedback_rect_uses_configured_position() { let area = Rect::new(10, 20, 100, 40); - let feedback = feedback(); + let feedback = CopyFeedback { + message: "copied to clipboard".to_owned(), + }; - let top_center = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter); - assert_eq!(top_center.y, area.y); - assert_eq!( - top_center.x, - area.x + area.width.saturating_sub(top_center.width) / 2 - ); + let top = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter); + assert_eq!(top.y, area.y); + assert_eq!(top.x, area.x + area.width.saturating_sub(top.width) / 2); - let bottom_center = - copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter); - assert_eq!(bottom_center.y + bottom_center.height, area.y + area.height); + let bottom = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter); + assert_eq!(bottom.bottom(), area.bottom()); assert_eq!( - bottom_center.x, - area.x + area.width.saturating_sub(bottom_center.width) / 2 + bottom.x, + area.x + area.width.saturating_sub(bottom.width) / 2 ); } } diff --git a/src/ui/tab_surface.rs b/src/ui/tab_surface.rs index 99ca9727..766c1d83 100644 --- a/src/ui/tab_surface.rs +++ b/src/ui/tab_surface.rs @@ -1,8 +1,7 @@ use ratatui::{layout::Rect, Frame}; use super::panes::{compute_pane_infos, render_panes, resize_tab_panes}; -use crate::app::state::ViewState; -use crate::app::{AppState, Mode}; +use crate::app::AppState; use crate::layout::{PaneInfo, SplitBorder}; use crate::protocol::CursorState; use crate::terminal::TerminalRuntimeRegistry; @@ -18,15 +17,6 @@ pub(crate) struct TabSurfaceView<'a> { pub(crate) split_borders: &'a [SplitBorder], } -impl ViewState { - pub(crate) fn tab_surface(&self) -> TabSurfaceView<'_> { - TabSurfaceView { - pane_infos: &self.pane_infos, - split_borders: &self.split_borders, - } - } -} - pub(crate) fn compute_tab_surface( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -105,10 +95,6 @@ pub(crate) fn tab_surface_cursor( terminal_runtimes: &TerminalRuntimeRegistry, surface: TabSurfaceView<'_>, ) -> Option<CursorState> { - if app.mode != Mode::Terminal { - return None; - } - let ws_idx = app.active?; let info = surface.pane_infos.iter().find(|info| info.is_focused)?; if !app.pane_exposes_host_cursor(ws_idx, info.id) { @@ -189,12 +175,9 @@ mod tests { app.workspaces = vec![workspace]; app.active = Some(0); app.selected = 0; - app.mode = Mode::Terminal; let full_area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app, full_area); - let area = app.view.terminal_area; - assert_eq!(area, Rect::new(26, 1, 80, 19)); + let area = full_area; let surface = compute_tab_surface( &app, &TerminalRuntimeRegistry::new(), @@ -207,7 +190,6 @@ mod tests { app.view.terminal_area = Rect::new(9, 8, 7, 6); app.view.pane_infos.clear(); - app.view.split_borders.clear(); let surface_view = TabSurfaceView { pane_infos: &surface.pane_infos, @@ -238,90 +220,4 @@ mod tests { .any(|(_, symbol, link)| { symbol == "L" && link == uri })); assert!(tab_surface_cursor(&app, &TerminalRuntimeRegistry::new(), surface_view,).is_some()); } - - fn full_app_frame(app: &mut AppState, area: Rect) -> crate::protocol::FrameData { - let (buffer, cursor) = crate::server::render_stream::render_virtual(app, area, true); - let hyperlinks = - crate::server::render_stream::visible_hyperlinks(app, &TerminalRuntimeRegistry::new()); - crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, - cursor, - &hyperlinks, - ) - } - - fn frame_digest(frame: &crate::protocol::FrameData) -> String { - use sha2::{Digest, Sha256}; - - let encoded = bincode::serde::encode_to_vec(frame, bincode::config::standard()).unwrap(); - format!("{:x}", Sha256::digest(encoded)) - } - - fn full_app_characterization_state(uri: &str) -> AppState { - let mut workspace = Workspace::test_new("characterization"); - workspace.identity_cwd = std::path::PathBuf::from("characterization"); - workspace.cached_git_branch = None; - workspace.cached_git_ahead_behind = None; - workspace.cached_git_space = None; - workspace.test_add_tab(Some("logs")); - workspace.switch_tab(0); - let left = workspace.tabs[0].root_pane; - let right = workspace.test_split(Direction::Horizontal); - workspace.insert_test_runtime( - left, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - 40, - 10, - format!("\x1b]8;;{uri}\x1b\\LINK\x1b]8;;\x1b\\").as_bytes(), - ), - ); - workspace.insert_test_runtime( - right, - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 10, b"RIGHT\r\nPANE"), - ); - - let mut app = AppState::test_new(); - app.workspaces = vec![workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app - } - - #[tokio::test] - async fn desktop_full_app_semantic_frame_is_characterized() { - let uri = "https://example.com/full-app"; - let mut app = full_app_characterization_state(uri); - let frame = full_app_frame(&mut app, Rect::new(0, 0, 106, 20)); - - assert_eq!((frame.width, frame.height), (106, 20)); - assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 26, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 80, 1)); - assert_eq!(app.view.terminal_area, Rect::new(26, 1, 80, 19)); - assert_eq!(app.view.pane_infos.len(), 2); - assert!(!app.view.split_borders.is_empty()); - assert!(frame.cursor.is_some()); - assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); - assert_eq!( - frame_digest(&frame), - "a7c21fa42305a41231c7ae254f264f6ef923f46301d8fc4cd35ab6dfdd651b6b" - ); - } - - #[tokio::test] - async fn mobile_full_app_semantic_frame_is_characterized() { - let mut app = full_app_characterization_state("https://example.com/mobile"); - app.mode = Mode::Navigate; - let frame = full_app_frame(&mut app, Rect::new(0, 0, 44, 20)); - - assert_eq!((frame.width, frame.height), (44, 20)); - assert_eq!(app.view.layout, crate::app::state::ViewLayout::Mobile); - assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2)); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18)); - assert_eq!(frame.cursor, None); - assert_eq!( - frame_digest(&frame), - "295608a66067f1e1f066c0adb3cf427e8a2d68bba8f68949fb72d464dcd8baab" - ); - } } diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs deleted file mode 100644 index 603422fb..00000000 --- a/src/ui/tabs.rs +++ /dev/null @@ -1,788 +0,0 @@ -use ratatui::{ - layout::Rect, - style::{Modifier, Style}, - widgets::Paragraph, - Frame, -}; - -use super::text::display_width_u16; -use super::widgets::panel_contrast_fg; -use crate::app::AppState; - -const MIN_TAB_WIDTH: u16 = 8; -const NEW_TAB_WIDTH: u16 = 3; -const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; -const ZOOM_INDICATOR: &str = "ZOOM"; -// The narrowest overflowing tab strip worth keeping interactive: one -// minimum-width tab, both scroll controls, and the new-tab control. -const MIN_TAB_STRIP_WIDTH: u16 = - MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2); - -#[derive(Debug, Clone, Default)] -pub(crate) struct TabBarView { - pub scroll: usize, - pub tab_hit_areas: Vec<Rect>, - pub scroll_left_hit_area: Rect, - pub scroll_right_hit_area: Rect, - pub new_tab_hit_area: Rect, -} - -fn tab_width(ws: &crate::workspace::Workspace, tab_idx: usize) -> u16 { - display_width_u16(&tab_chrome_label(ws, tab_idx)) - .saturating_add(4) - .max(MIN_TAB_WIDTH) -} - -fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String { - let name = ws - .tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()); - if ws.tabs.get(tab_idx).is_some_and(|tab| tab.zoomed) { - format!("{name} Z") - } else { - name - } -} - -#[derive(Clone, Copy)] -struct VisibleStatusSegment<'a> { - text: &'a str, - accent: bool, -} - -fn visible_status_segments(app: &AppState) -> Vec<VisibleStatusSegment<'_>> { - let zoomed = app - .active - .and_then(|index| app.workspaces.get(index)) - .is_some_and(|workspace| workspace.zoomed); - app.tab_bar_right - .iter() - .filter_map(|segment| match segment { - crate::app::state::TabBarStatusSegment::Zoom if zoomed => Some(VisibleStatusSegment { - text: ZOOM_INDICATOR, - accent: true, - }), - crate::app::state::TabBarStatusSegment::Text(Some(text)) - if display_width_u16(text) > 0 => - { - Some(VisibleStatusSegment { - text, - accent: false, - }) - } - crate::app::state::TabBarStatusSegment::Zoom - | crate::app::state::TabBarStatusSegment::Text(_) => None, - }) - .collect() -} - -fn tab_bar_status_width(app: &AppState) -> u16 { - let segments = visible_status_segments(app); - let content_width = segments.iter().fold(0_u16, |width, segment| { - width.saturating_add(display_width_u16(segment.text)) - }); - let separators = u16::try_from(segments.len().saturating_sub(1)).unwrap_or(u16::MAX); - content_width - .saturating_add(display_width_u16(&app.tab_bar_right_separator).saturating_mul(separators)) -} - -fn tab_bar_status_area(app: &AppState, area: Rect) -> Option<Rect> { - let width = tab_bar_status_width(app); - if width == 0 { - return None; - } - let reserved = width.saturating_add(1); - (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH) - .then(|| Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)) -} - -// Tabs win over status decoration on narrow rows. The extra reserved cell is -// the gap between the interactive strip and the right-aligned status entries. -pub(crate) fn tab_bar_content_area(app: &AppState, area: Rect) -> Rect { - let reserved = tab_bar_status_area(app, area) - .map(|status| status.width.saturating_add(1)) - .unwrap_or(0); - Rect { - width: area.width.saturating_sub(reserved), - ..area - } -} - -fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec<Rect> { - let mut rects = vec![Rect::default(); ws.tabs.len()]; - if area.width == 0 || area.height == 0 { - return rects; - } - - let mut x = area.x; - let right = area.x + area.width; - for (idx, rect) in rects.iter_mut().enumerate().skip(scroll) { - if x >= right { - break; - } - let desired = tab_width(ws, idx); - let remaining = right.saturating_sub(x); - let width = desired.min(remaining).max(1); - *rect = Rect::new(x, area.y, width, 1); - x = x.saturating_add(width + 1); - } - rects -} - -fn centered_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize { - let mut best_scroll = ws.active_tab; - let mut best_distance = u16::MAX; - let viewport_center = area.x.saturating_mul(2).saturating_add(area.width); - - for scroll in 0..=ws.active_tab { - let rects = layout_tab_hit_areas(ws, area, scroll); - let Some(active_rect) = rects.get(ws.active_tab).copied() else { - continue; - }; - if active_rect.width == 0 { - continue; - } - - let active_center = active_rect - .x - .saturating_mul(2) - .saturating_add(active_rect.width); - let distance = active_center.abs_diff(viewport_center); - if distance <= best_distance { - best_distance = distance; - best_scroll = scroll; - } - } - - best_scroll -} - -fn trailing_tab_controls_x(tab_hit_areas: &[Rect], fallback_x: u16) -> u16 { - tab_hit_areas - .iter() - .rev() - .find(|rect| rect.width > 0) - .map(|rect| rect.x + rect.width) - .unwrap_or(fallback_x) -} - -fn max_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize { - (0..ws.tabs.len()) - .find(|&scroll| { - layout_tab_hit_areas(ws, area, scroll) - .last() - .is_some_and(|rect| rect.width > 0) - }) - .unwrap_or(0) -} - -pub(crate) fn compute_tab_bar_view( - ws: &crate::workspace::Workspace, - area: Rect, - current_scroll: usize, - follow_active: bool, - mouse_chrome: bool, -) -> TabBarView { - if area.width == 0 || area.height == 0 { - return TabBarView::default(); - } - - if !mouse_chrome { - let max_scroll = max_tab_scroll(ws, area); - let scroll = if follow_active { - centered_tab_scroll(ws, area).min(max_scroll) - } else { - current_scroll.min(max_scroll) - }; - return TabBarView { - scroll, - tab_hit_areas: layout_tab_hit_areas(ws, area, scroll), - scroll_left_hit_area: Rect::default(), - scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), - }; - } - - let area_right = area.x + area.width; - let all_tabs_area = Rect::new( - area.x, - area.y, - area.width.saturating_sub(NEW_TAB_WIDTH), - area.height, - ); - let all_tabs = layout_tab_hit_areas(ws, all_tabs_area, 0); - let overflow = all_tabs.iter().any(|rect| rect.width == 0); - if !overflow { - let new_tab_x = trailing_tab_controls_x(&all_tabs, area.x); - let new_tab_hit_area = Rect::new( - new_tab_x, - area.y, - area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH), - 1, - ); - return TabBarView { - scroll: 0, - tab_hit_areas: all_tabs, - scroll_left_hit_area: Rect::default(), - scroll_right_hit_area: Rect::default(), - new_tab_hit_area, - }; - } - - let left_hit_area = Rect::new(area.x, area.y, TAB_SCROLL_BUTTON_WIDTH.min(area.width), 1); - let tab_area_x = left_hit_area.x + left_hit_area.width; - let reserved_trailing_width = NEW_TAB_WIDTH.saturating_add(TAB_SCROLL_BUTTON_WIDTH); - let tab_area_right = area_right.saturating_sub(reserved_trailing_width); - let tab_area = Rect::new( - tab_area_x, - area.y, - tab_area_right.saturating_sub(tab_area_x), - area.height, - ); - - let max_scroll = max_tab_scroll(ws, tab_area); - let scroll = if follow_active { - centered_tab_scroll(ws, tab_area).min(max_scroll) - } else { - current_scroll.min(max_scroll) - }; - let tab_hit_areas = layout_tab_hit_areas(ws, tab_area, scroll); - let trailing_x = trailing_tab_controls_x(&tab_hit_areas, tab_area_x).min(tab_area_right); - let right_hit_area = Rect::new( - trailing_x, - area.y, - area_right - .saturating_sub(trailing_x) - .min(TAB_SCROLL_BUTTON_WIDTH), - 1, - ); - let new_tab_x = right_hit_area.x + right_hit_area.width; - let new_tab_hit_area = Rect::new( - new_tab_x, - area.y, - area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH), - 1, - ); - - TabBarView { - scroll, - tab_hit_areas, - scroll_left_hit_area: left_hit_area, - scroll_right_hit_area: right_hit_area, - new_tab_hit_area, - } -} - -fn tab_drop_indicator_x( - app: &AppState, - ws: &crate::workspace::Workspace, - insert_idx: usize, -) -> Option<u16> { - let mut visible_tabs = app - .view - .tab_hit_areas - .iter() - .enumerate() - .filter(|(_, rect)| rect.width > 0); - let first_visible = visible_tabs.clone().next()?; - let last_visible = visible_tabs.next_back().unwrap_or(first_visible); - - if insert_idx == 0 { - return Some(if first_visible.0 == 0 { - first_visible.1.x - } else { - app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width - }); - } - - if let Some((_, rect)) = app - .view - .tab_hit_areas - .iter() - .enumerate() - .find(|(idx, rect)| *idx == insert_idx && rect.width > 0) - { - return Some(rect.x.saturating_sub(1)); - } - - if insert_idx >= ws.tabs.len() { - return Some(if last_visible.0 + 1 >= ws.tabs.len() { - last_visible.1.x + last_visible.1.width - } else { - app.view.tab_scroll_right_hit_area.x.saturating_sub(1) - }); - } - - None -} - -pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let Some(active_ws_idx) = app.active else { - return; - }; - let Some(ws) = app.workspaces.get(active_ws_idx) else { - return; - }; - let p = &app.palette; - - frame.render_widget( - Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)), - area, - ); - - let first_visible_idx = app - .view - .tab_hit_areas - .iter() - .enumerate() - .find(|(_, rect)| rect.width > 0) - .map(|(idx, _)| idx); - let last_visible_idx = app - .view - .tab_hit_areas - .iter() - .enumerate() - .rev() - .find(|(_, rect)| rect.width > 0) - .map(|(idx, _)| idx); - let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0; - let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0 - && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()); - - if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { - let style = if can_scroll_left { - Style::default().fg(p.overlay1).bg(p.surface0) - } else { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - }; - frame.render_widget( - Paragraph::new(" < ").style(style), - app.view.tab_scroll_left_hit_area, - ); - } - - if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { - let style = if can_scroll_right { - Style::default().fg(p.overlay1).bg(p.surface0) - } else { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - }; - frame.render_widget( - Paragraph::new(" > ").style(style), - app.view.tab_scroll_right_hit_area, - ); - } - - for (idx, tab) in ws.tabs.iter().enumerate() { - let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else { - break; - }; - if rect.width == 0 { - continue; - } - let active = idx == ws.active_tab; - let style = if active { - let base = Style::default().fg(panel_contrast_fg(p)).bg(p.accent); - if tab.is_auto_named() { - base - } else { - base.add_modifier(Modifier::BOLD) - } - } else if tab.is_auto_named() { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - } else { - Style::default().fg(p.overlay1).bg(p.surface0) - }; - let width = rect.width as usize; - let name = tab_chrome_label(ws, idx); - // Pad by terminal columns, not chars, so wide glyphs stay centered. - let padding = width.saturating_sub(display_width_u16(&name) as usize); - let left = padding / 2; - let text = format!( - "{empty:left$}{name}{empty:right$}", - empty = "", - right = padding - left - ); - frame.render_widget(Paragraph::new(text).style(style), rect); - } - - if let Some(crate::app::state::DragState { - target: - crate::app::state::DragTarget::TabReorder { - ws_idx, - insert_idx: Some(insert_idx), - .. - }, - }) = &app.drag - { - if *ws_idx == active_ws_idx { - if let Some(x) = tab_drop_indicator_x(app, ws, *insert_idx) { - frame.buffer_mut()[(x.min(area.x + area.width.saturating_sub(1)), area.y)] - .set_symbol("│") - .set_style(Style::default().fg(p.accent)); - } - } - } - - if app.mouse_capture && app.view.new_tab_hit_area.width > 0 { - frame.render_widget( - Paragraph::new(" + ").style(Style::default().fg(p.overlay1)), - app.view.new_tab_hit_area, - ); - } - - if first_visible_idx.is_some_and(|idx| idx > 0) { - let x = if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { - app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width - } else { - area.x - }; - if x < area.x + area.width { - frame.buffer_mut()[(x, area.y)] - .set_symbol("…") - .set_style(Style::default().fg(p.overlay0)); - } - } - if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) { - let content = tab_bar_content_area(app, area); - let content_right = content.x + content.width; - let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { - app.view.tab_scroll_right_hit_area.x.saturating_sub(1) - } else { - content_right.saturating_sub(1) - }; - if x >= area.x && x < area.x + area.width { - frame.buffer_mut()[(x, area.y)] - .set_symbol("…") - .set_style(Style::default().fg(p.overlay0)); - } - } - - if let Some(status_area) = tab_bar_status_area(app, area) { - let segments = visible_status_segments(app); - let separator_width = display_width_u16(&app.tab_bar_right_separator); - let mut x = status_area.x; - for (index, segment) in segments.iter().enumerate() { - if index > 0 && separator_width > 0 { - let rect = Rect::new(x, area.y, separator_width, 1); - frame.render_widget( - Paragraph::new(app.tab_bar_right_separator.as_str()) - .style(Style::default().fg(p.overlay0).bg(p.panel_bg)), - rect, - ); - x = x.saturating_add(separator_width); - } - - let width = display_width_u16(segment.text); - let rect = Rect::new(x, area.y, width, 1); - let style = if segment.accent { - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay1).bg(p.panel_bg) - }; - frame.render_widget(Paragraph::new(segment.text).style(style), rect); - x = x.saturating_add(width); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::state::AppState; - use crate::workspace::Workspace; - use ratatui::{backend::TestBackend, Terminal}; - - fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String { - (area.x..area.x + area.width) - .map(|x| buffer[(x, row)].symbol()) - .collect::<String>() - .trim_end() - .to_string() - } - - #[test] - fn tab_bar_marks_zoomed_tabs_without_renaming_them() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].zoomed = true; - let custom_tab = ws.test_add_tab(Some("test")); - ws.tabs[custom_tab].zoomed = true; - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.contains(" 1 Z"), "tab row: {row:?}"); - assert!(row.contains(" test Z"), "tab row: {row:?}"); - assert_eq!(app.workspaces[0].tab_display_name(0).as_deref(), Some("1")); - assert_eq!( - app.workspaces[0].tab_display_name(custom_tab).as_deref(), - Some("test") - ); - } - - #[test] - fn tab_bar_renders_ordered_status_entries_with_separator() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].zoomed = true; - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Zoom, - crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())), - crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())), - ]; - app.tab_bar_right_separator = " · ".into(); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 60, 1); - let content = tab_bar_content_area(&app, app.view.tab_bar_rect); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas.clone(); - - let backend = TestBackend::new(60, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let buffer = terminal.backend().buffer(); - let row = buffer_row_text(buffer, app.view.tab_bar_rect, 0); - assert!( - row.ends_with("ZOOM · wintermute · 14:30"), - "tab row: {row:?}" - ); - let status_x = 60 - display_width_u16("ZOOM · wintermute · 14:30"); - assert_eq!(buffer[(status_x, 0)].style().bg, Some(app.palette.accent)); - for rect in &view.tab_hit_areas { - assert!(rect.x + rect.width <= content.x + content.width); - } - } - - #[test] - fn hidden_status_entries_do_not_leave_dangling_separators() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Zoom, - crate::app::state::TabBarStatusSegment::Text(None), - crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())), - ]; - app.tab_bar_right_separator = " | ".into(); - app.workspaces = vec![Workspace::test_new("test")]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 40, 1); - let content = tab_bar_content_area(&app, app.view.tab_bar_rect); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(40, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.ends_with("wintermute"), "tab row: {row:?}"); - assert!(!row.contains(" | "), "tab row: {row:?}"); - } - - #[test] - fn status_reservation_keeps_a_minimum_width_tab_between_scroll_controls() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![crate::app::state::TabBarStatusSegment::Text(Some( - "x".into(), - ))]; - let mut workspace = Workspace::test_new("test"); - workspace.test_add_tab(None); - workspace.test_add_tab(None); - app.workspaces = vec![workspace]; - app.active = Some(0); - - let too_narrow = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 1, 1); - assert_eq!(tab_bar_content_area(&app, too_narrow), too_narrow); - - let wide_enough = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 2, 1); - let content = tab_bar_content_area(&app, wide_enough); - assert_eq!(content.width, MIN_TAB_STRIP_WIDTH); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, true); - assert!(view.tab_hit_areas[0].width >= MIN_TAB_WIDTH); - } - - #[test] - fn combined_status_entries_yield_to_tab_controls_on_narrow_rows() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Text(Some( - "a-hostname-wider-than-the-whole-bar".into(), - )), - crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())), - ]; - app.workspaces = vec![Workspace::test_new("test")]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - - assert_eq!( - tab_bar_content_area(&app, app.view.tab_bar_rect), - app.view.tab_bar_rect - ); - assert_eq!(tab_bar_status_area(&app, app.view.tab_bar_rect), None); - - let view = compute_tab_bar_view( - &app.workspaces[0], - tab_bar_content_area(&app, app.view.tab_bar_rect), - 0, - true, - true, - ); - assert!(view.tab_hit_areas[0].width > 0); - assert!(view.new_tab_hit_area.width > 0); - } - - #[test] - fn cjk_tab_labels_are_centered_by_display_width() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - // 17 display columns + 4 padding: two columns each side, wide glyphs - // starting right after the left padding. - let rect = app.view.tab_hit_areas[0]; - assert_eq!(rect.width, 21); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(rect.x, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + 1, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + 2, rect.y)].symbol(), "提"); - assert_eq!(buffer[(rect.x + rect.width - 2, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + rect.width - 1, rect.y)].symbol(), " "); - } - - #[test] - fn tab_labels_are_centered_in_their_cells() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("omarchy".into()); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let rect = app.view.tab_hit_areas[0]; - let buffer = terminal.backend().buffer(); - let cell: String = (rect.x..rect.x + rect.width) - .map(|x| buffer[(x, rect.y)].symbol()) - .collect(); - assert_eq!(cell, " omarchy "); - } - - #[test] - fn active_auto_named_tab_keeps_readable_weight() { - let mut app = AppState::test_new(); - let ws = Workspace::test_new("test"); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let tab_rect = app.view.tab_hit_areas[0]; - let style = terminal.backend().buffer()[(tab_rect.x + 1, tab_rect.y)].style(); - - assert_eq!(style.bg, Some(app.palette.accent)); - assert!(!style.add_modifier.contains(Modifier::DIM)); - assert!(!style.add_modifier.contains(Modifier::BOLD)); - } - - #[test] - fn zoom_marker_counts_toward_tab_width() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("abcdefgh".into()); - ws.tabs[0].zoomed = true; - - assert_eq!(tab_width(&ws, 0), 14); - } - - #[test] - fn tab_width_uses_display_width_for_cjk_labels() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - assert_eq!( - tab_width(&ws, 0), - display_width_u16("提交 herdr 的反馈") + 4 - ); - } - - #[test] - fn tab_bar_renders_trailing_cjk_character() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - app.active = Some(0); - app.workspaces = vec![ws]; - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.contains('馈'), "tab row: {row:?}"); - } -} diff --git a/src/ui/text.rs b/src/ui/text.rs index 45f7983b..9b5a7c68 100644 --- a/src/ui/text.rs +++ b/src/ui/text.rs @@ -4,10 +4,6 @@ pub(crate) fn display_width(text: &str) -> usize { UnicodeWidthStr::width(text) } -pub(crate) fn display_width_u16(text: &str) -> u16 { - display_width(text).min(u16::MAX as usize) as u16 -} - pub(crate) fn truncate_end(text: &str, max_width: usize) -> String { if display_width(text) <= max_width { return text.to_string(); @@ -23,22 +19,6 @@ pub(crate) fn truncate_end(text: &str, max_width: usize) -> String { format!("{prefix}…") } -pub(crate) fn middle_elide(text: &str, max_width: usize) -> String { - if display_width(text) <= max_width { - return text.to_string(); - } - if max_width <= 1 { - return "…".to_string(); - } - - let content_width = max_width.saturating_sub(1); - let left_width = content_width / 2; - let right_width = content_width.saturating_sub(left_width); - let prefix = take_prefix_width(text, left_width); - let suffix = take_suffix_width(text, right_width); - format!("{prefix}…{suffix}") -} - fn take_prefix_width(text: &str, max_width: usize) -> String { let mut output = String::new(); let mut width = 0usize; @@ -53,20 +33,6 @@ fn take_prefix_width(text: &str, max_width: usize) -> String { output } -fn take_suffix_width(text: &str, max_width: usize) -> String { - let mut output = Vec::new(); - let mut width = 0usize; - for ch in text.chars().rev() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > max_width { - break; - } - output.push(ch); - width += ch_width; - } - output.into_iter().rev().collect() -} - #[cfg(test)] mod tests { use super::*; @@ -78,12 +44,4 @@ mod tests { assert_eq!(text, "提交 herdr 的反…"); assert!(display_width(&text) <= 16); } - - #[test] - fn middle_elide_uses_display_width() { - let text = middle_elide("重构用户认证模块并迁移到统一登录服务", 12); - - assert!(text.contains('…')); - assert!(display_width(&text) <= 12); - } } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index ef416134..33fcc9a0 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -1,70 +1,30 @@ use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, - Frame, + layout::{Constraint, Layout, Rect}, + style::Color, }; use crate::app::state::Palette; -pub(super) fn render_panel_shell( - frame: &mut Frame, - area: Rect, - border_color: Color, - bg: Color, -) -> Option<Rect> { - if area.width < 2 || area.height < 2 { - return None; - } - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .border_set(ratatui::symbols::border::PLAIN) - .style(Style::default().bg(bg)); - let inner = block.inner(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); - Some(inner) -} - -pub(super) fn panel_contrast_fg(p: &Palette) -> Color { - match p.panel_bg { - Color::Reset => p.surface_dim, +pub(super) fn panel_contrast_fg(palette: &Palette) -> Color { + match palette.panel_bg { + Color::Reset => palette.surface_dim, color => color, } } -pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option<Rect> { - let popup_w = popup_w.min(area.width.saturating_sub(4)); - let popup_h = popup_h.min(area.height.saturating_sub(2)); - if popup_w < 4 || popup_h < 4 { +pub(crate) fn centered_popup_rect(area: Rect, popup_width: u16, popup_height: u16) -> Option<Rect> { + let popup_width = popup_width.min(area.width.saturating_sub(4)); + let popup_height = popup_height.min(area.height.saturating_sub(2)); + if popup_width < 4 || popup_height < 4 { return None; } - let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; - Some(Rect::new(popup_x, popup_y, popup_w, popup_h)) -} - -pub(super) fn render_modal_shell( - frame: &mut Frame, - area: Rect, - popup_w: u16, - popup_h: u16, - p: &Palette, -) -> Option<Rect> { - let popup = centered_popup_rect(area, popup_w, popup_h)?; - render_panel_shell(frame, popup, p.accent, p.panel_bg) -} - -pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - let line = Line::from(vec![Span::styled( - title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - )]); - frame.render_widget(Paragraph::new(line), area); + Some(Rect::new( + area.x + area.width.saturating_sub(popup_width) / 2, + area.y + area.height.saturating_sub(popup_height) / 2, + popup_width, + popup_height, + )) } #[derive(Debug, Clone, Copy)] @@ -110,169 +70,40 @@ pub(crate) fn modal_stack_areas( } let areas = Layout::vertical(constraints).split(inner); - let mut header = Rect::default(); - let mut content = Rect::default(); - let mut footer = None; - let mut actions = None; - + let mut result = ModalStackAreas { + header: Rect::default(), + content: Rect::default(), + footer: None, + actions: None, + }; for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) { match slot { - Slot::Header => header = area, - Slot::Content => content = area, - Slot::Footer => footer = Some(area), - Slot::Actions => actions = Some(area), + Slot::Header => result.header = area, + Slot::Content => result.content = area, + Slot::Footer => result.footer = Some(area), + Slot::Actions => result.actions = Some(area), } } - - ModalStackAreas { - header, - content, - footer, - actions, - } + result } -pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String { +fn action_button_width(hint: Option<&str>, label: &str) -> u16 { match hint { - Some(hint) => format!(" {hint} {label} "), - None => format!(" {label} "), + Some(hint) => format!(" {hint} {label} ").chars().count() as u16, + None => format!(" {label} ").chars().count() as u16, } } -pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 { - action_button_text(hint, label).chars().count() as u16 +pub(crate) fn close_button_rect(area: Rect) -> Rect { + let width = action_button_width(Some("esc"), "close"); + Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) } -pub(crate) struct ActionButtonSpec<'a> { - pub hint: Option<&'a str>, - pub label: &'a str, -} - -pub(crate) fn action_button_row_rects( - area: Rect, - buttons: &[ActionButtonSpec<'_>], - gap: u16, - row_offset: u16, -) -> Vec<Rect> { - let widths: Vec<u16> = buttons - .iter() - .map(|button| action_button_width(button.hint, button.label)) - .collect(); - centered_button_row(area, &widths, gap, row_offset) -} - -pub(super) fn render_action_button( - frame: &mut Frame, - rect: Rect, - hint: Option<&str>, - label: &str, - style: Style, -) { - frame.render_widget( - Paragraph::new(action_button_text(hint, label)) - .style(style) - .alignment(Alignment::Center), - rect, - ); -} - -pub(crate) fn render_modal_description(frame: &mut Frame, area: Rect, text: &str, style: Style) { - frame.render_widget( - Paragraph::new(format!(" {text}")) - .style(style) - .wrap(Wrap { trim: false }), - area, - ); -} - -pub(crate) fn modal_choice_rows(area: Rect, count: usize, row_height: u16) -> Vec<Rect> { - let mut rows = Vec::with_capacity(count); - let mut y = area.y; - for _ in 0..count { - if y >= area.y + area.height { - break; - } - let remaining = area.y + area.height - y; - let height = row_height.min(remaining); - rows.push(Rect::new(area.x, y, area.width, height)); - y = y.saturating_add(row_height); - } - rows -} - -pub(crate) fn render_modal_choice_list<T>( - frame: &mut Frame, - area: Rect, - title: &str, - description: &str, - options: &[(&str, T)], - current_value: T, - selected_idx: usize, - p: &Palette, - row_height: u16, -) where - T: Copy + PartialEq, -{ - let [desc_area, _, list_area] = Layout::vertical([ - Constraint::Length(2), - Constraint::Length(1), - Constraint::Min(2), - ]) - .areas::<3>(area); - - render_modal_description( - frame, - desc_area, - description, - Style::default().fg(p.overlay1), - ); - - let rows = modal_choice_rows(list_area, options.len(), row_height); - for (idx, ((label, value), row)) in options.iter().zip(rows.iter()).enumerate() { - let is_active = *value == current_value; - let is_selected = idx == selected_idx; - let marker = if is_active { " ✓" } else { "" }; - let style = if is_selected { - Style::default() - .bg(p.surface0) - .fg(p.text) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0) - }; - frame.render_widget( - Paragraph::new(format!(" {title}: {label}{marker}")) - .style(style) - .wrap(Wrap { trim: false }), - *row, - ); - } -} - -pub(super) fn centered_button_row( - inner: Rect, - widths: &[u16], - gap: u16, - row_offset: u16, -) -> Vec<Rect> { - let total_w = widths - .iter() - .copied() - .sum::<u16>() - .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16)); - let mut x = inner.x + inner.width.saturating_sub(total_w) / 2; - let y = inner.y + row_offset.min(inner.height.saturating_sub(1)); - widths - .iter() - .map(|w| { - let rect = Rect::new( - x, - y, - (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))), - 1, - ); - x = x.saturating_add(*w).saturating_add(gap); - rect - }) - .collect() +pub(crate) fn continue_button_rect(area: Rect) -> Rect { + Rect::new( + area.x, + area.y, + action_button_width(Some("↵"), "continue"), + 1, + ) } |
