diff options
Diffstat (limited to 'src/ui.rs')
| -rw-r--r-- | src/ui.rs | 3616 |
1 files changed, 167 insertions, 3449 deletions
@@ -1,15 +1,64 @@ use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap}, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::Span, Frame, }; -use crate::app::state::{AgentPanelScope, ToastKind, ToastNotification}; +mod dialogs; +mod keybind_help; +mod menus; +mod onboarding; +mod panes; +mod release_notes; +mod scrollbar; +mod settings; +mod sidebar; +mod status; +mod tabs; +mod widgets; + +use self::dialogs::{render_confirm_close_overlay, render_rename_overlay}; +use self::keybind_help::render_keybind_help_overlay; +use self::menus::{ + render_context_menu, render_global_launcher_menu, render_navigate_overlay, + render_resize_overlay, +}; +use self::onboarding::render_onboarding_overlay; +pub(crate) use self::onboarding::{ + onboarding_notification_button_rects, onboarding_welcome_continue_rect, +}; +use self::panes::{compute_pane_infos, render_panes}; +use self::release_notes::render_release_notes_overlay; +pub(crate) use self::release_notes::{ + release_notes_close_button_rect, release_notes_display_lines, release_notes_sections, + RELEASE_NOTES_MODAL_SIZE, +}; +pub(crate) use self::scrollbar::{ + pane_scrollbar_rect, release_notes_scrollbar_rect, scrollbar_offset_from_drag_row, + scrollbar_offset_from_row, scrollbar_thumb_grab_offset, should_show_scrollbar, +}; +use self::settings::render_settings_overlay; +use self::sidebar::{render_sidebar, render_sidebar_collapsed}; +use self::status::{render_config_diagnostic, render_toast_notification}; +use self::tabs::render_tab_bar; +pub(crate) use self::{ + dialogs::{confirm_close_button_rects, confirm_close_popup_rect, rename_button_rects}, + settings::settings_button_rects, + sidebar::{ + agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics, + agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections, + collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections, + sidebar_section_divider_rect, workspace_drop_indicator_row, workspace_list_rect, + workspace_list_scroll_metrics, workspace_list_scrollbar_rect, + }, +}; +pub(crate) use self::{ + keybind_help::keybind_help_lines, + tabs::compute_tab_bar_view, + widgets::{centered_popup_rect, modal_stack_areas}, +}; use crate::app::{AppState, Mode}; -use crate::detect::AgentState; -use crate::layout::PaneInfo; const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator pub(crate) const MIN_SIDEBAR_WIDTH: u16 = 18; @@ -20,211 +69,26 @@ const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "â /// Map spinner_tick (incremented every frame at ~60fps) to a spinner frame. /// We want ~8 updates/sec so divide by 8. -fn spinner_frame(tick: u32) -> &'static str { +pub(super) fn spinner_frame(tick: u32) -> &'static str { SPINNERS[(tick as usize / 8) % SPINNERS.len()] } -use crate::app::state::Palette; - -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 agent_label: Option<String>, - pub state: AgentState, - pub seen: bool, -} - -fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) { - if total_h == 0 { - return (0, 0); - } - - if total_h < 6 { - let ws_h = (total_h + 1) / 2; - return (ws_h, total_h.saturating_sub(ws_h)); - } - - 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) -} - -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 { - 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) -} - -pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect { - let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - if content.width == 0 || content.height < 6 { - 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_current_workspace_idx(app: &AppState) -> Option<usize> { - if matches!( - app.mode, - Mode::Navigate - | Mode::RenameWorkspace - | Mode::Resize - | Mode::ConfirmClose - | Mode::ContextMenu - | Mode::Settings - | Mode::GlobalMenu - | Mode::KeybindHelp - ) { - Some(app.selected) - } else { - app.active - } -} - -fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str { - match scope { - AgentPanelScope::CurrentWorkspace => "current", - AgentPanelScope::AllWorkspaces => "all", - } -} - -pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect { - if area.width == 0 || area.height < 2 { - return Rect::default(); - } - - let label = agent_panel_toggle_label(scope); - let width = label.chars().count() as u16; - Rect::new( - area.x + area.width.saturating_sub(width), - area.y + 1, - width, - 1, - ) -} - -pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { - match app.agent_panel_scope { - AgentPanelScope::CurrentWorkspace => { - let Some(ws_idx) = agent_panel_current_workspace_idx(app) else { - return Vec::new(); - }; - let Some(ws) = app.workspaces.get(ws_idx) else { - return Vec::new(); - }; - ws.pane_details() - .into_iter() - .map(|detail| AgentPanelEntry { - ws_idx, - tab_idx: detail.tab_idx, - pane_id: detail.pane_id, - primary_label: detail.label, - primary_tab_label: None, - agent_label: None, - state: detail.state, - seen: detail.seen, - }) - .collect() - } - AgentPanelScope::AllWorkspaces => app - .workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - let multi_tab = ws.tabs.len() > 1; - let workspace_label = ws.display_name(); - ws.pane_details() - .into_iter() - .map(move |detail| AgentPanelEntry { - ws_idx, - tab_idx: detail.tab_idx, - pane_id: detail.pane_id, - primary_label: workspace_label.clone(), - primary_tab_label: multi_tab.then_some(detail.tab_label), - agent_label: Some(detail.agent_label), - state: detail.state, - seen: detail.seen, - }) - }) - .collect(), - } -} - -fn truncate_text(text: &str, max_width: usize) -> String { - let len = text.chars().count(); - if len <= max_width { - return text.to_string(); - } - if max_width == 0 { - return String::new(); - } - if max_width == 1 { - return "…".to_string(); - } - let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect(); - format!("{prefix}…") +/// Compute view geometry and reconcile pane sizes. +/// Called before render to separate mutation from drawing. +pub fn compute_view(app: &mut AppState, area: Rect) { + compute_view_internal(app, area, true); } -fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String { - let Some(tab_label) = entry.primary_tab_label.as_deref() else { - return truncate_text(&entry.primary_label, max_width); - }; - - let separator = " · "; - let separator_width = separator.chars().count(); - if max_width <= separator_width + 2 { - return truncate_text( - &format!("{}{}{}", entry.primary_label, separator, tab_label), - max_width, - ); - } - - let available = max_width.saturating_sub(separator_width); - let min_tab = 4.min(available.saturating_sub(1)).max(1); - let preferred_workspace = ((available * 2) / 3).max(1); - let mut workspace_budget = preferred_workspace - .min(available.saturating_sub(min_tab)) - .max(1); - let mut tab_budget = available.saturating_sub(workspace_budget); - - let workspace_len = entry.primary_label.chars().count(); - let tab_len = tab_label.chars().count(); - - if workspace_len < workspace_budget { - let spare = workspace_budget - workspace_len; - workspace_budget = workspace_len; - tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget)); - } - if tab_len < tab_budget { - let spare = tab_budget - tab_len; - tab_budget = tab_len; - workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget)); - } - - format!( - "{}{}{}", - truncate_text(&entry.primary_label, workspace_budget), - separator, - truncate_text(tab_label, tab_budget) - ) +/// Compute view geometry for a client-sized render without resizing pane runtimes. +/// +/// This is used by the headless server when a non-foreground client needs its +/// own frame size while the shared pane runtimes stay pinned to the foreground +/// client. +pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect) { + compute_view_internal(app, area, false); } -/// Compute view geometry and reconcile pane sizes. -/// Called before render to separate mutation from drawing. -pub fn compute_view(app: &mut AppState, area: Rect) { +fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { let sidebar_w = if app.sidebar_collapsed { COLLAPSED_WIDTH } else { @@ -281,7 +145,7 @@ pub fn compute_view(app: &mut AppState, area: Rect) { .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); - let pane_infos = compute_pane_infos(app, terminal_area); + let pane_infos = compute_pane_infos(app, terminal_area, resize_panes); app.view = crate::app::ViewState { sidebar_rect: sidebar_area, @@ -343,1454 +207,6 @@ pub fn render(app: &AppState, frame: &mut Frame) { } } -const MIN_TAB_WIDTH: u16 = 8; -const NEW_TAB_WIDTH: u16 = 3; -const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; -const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; -const AGENT_PANEL_HEADER_ROWS: u16 = 3; - -#[derive(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 workspace_row_height(ws: &crate::workspace::Workspace) -> u16 { - if ws.branch().is_some() { - 2 - } else { - 1 - } -} - -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; - for ws in app.workspaces.iter().skip(scroll) { - let needed = workspace_row_height(ws).saturating_add(1); - if used_rows.saturating_add(needed) > body.height { - break; - } - used_rows = used_rows.saturating_add(needed); - visible += 1; - } - visible -} - -pub(crate) fn workspace_list_scroll_metrics( - app: &AppState, - area: Rect, -) -> crate::pane::ScrollMetrics { - let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll); - let total_rows = app.workspaces.len(); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - let offset_from_bottom = total_rows - .saturating_sub(app.workspace_scroll) - .saturating_sub(viewport_rows); - - crate::pane::ScrollMetrics { - offset_from_bottom, - max_offset_from_bottom, - 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 agent_panel_visible_count(area: Rect) -> usize { - let body = agent_panel_body_rect(area, false); - if body.width == 0 || body.height < 2 { - return 0; - } - - let mut used_rows = 0u16; - let mut visible = 0usize; - while used_rows.saturating_add(2) <= body.height { - used_rows = used_rows.saturating_add(2); - visible += 1; - if used_rows < body.height { - used_rows = used_rows.saturating_add(1); - } - } - visible -} - -pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics { - let viewport_rows = agent_panel_visible_count(area); - let total_rows = agent_panel_entries(app).len(); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - let offset_from_bottom = total_rows - .saturating_sub(app.agent_panel_scroll) - .saturating_sub(viewport_rows); - - crate::pane::ScrollMetrics { - offset_from_bottom, - max_offset_from_bottom, - 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_card_areas( - app: &AppState, - area: Rect, -) -> Vec<crate::app::state::WorkspaceCardArea> { - let ws_area = workspace_list_rect(area, app.sidebar_section_split); - if ws_area == Rect::default() { - return 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(); - } - - let mut row_y = body.y; - let body_bottom = body.y + body.height; - let mut cards = Vec::new(); - - for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) { - let row_height = workspace_row_height(ws); - if row_y.saturating_add(row_height).saturating_add(1) > body_bottom { - break; - } - cards.push(crate::app::state::WorkspaceCardArea { - ws_idx, - rect: Rect::new(body.x, row_y, body.width, row_height), - }); - row_y = row_y.saturating_add(row_height + 1); - } - - cards -} - -fn tab_width(tab: &crate::workspace::Tab) -> u16 { - (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH) -} - -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 in scroll..ws.tabs.len() { - if x >= right { - break; - } - let desired = tab_width(&ws.tabs[idx]); - let remaining = right.saturating_sub(x); - let width = desired.min(remaining).max(1); - rects[idx] = 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, -) -> TabBarView { - if area.width == 0 || area.height == 0 { - return TabBarView::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 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.last().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 -} - -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.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.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.add_modifier(Modifier::DIM) - } 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.display_name(); - let text = format!(" {:width$}", name, width = width.saturating_sub(1)); - 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.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 = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width; - 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 x = app.view.tab_scroll_right_hit_area.x.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)); - } - } -} - -/// Compute pane layout info and resize pane runtimes to match. -fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> { - let Some(ws_idx) = app.active else { - return Vec::new(); - }; - let Some(ws) = app.workspaces.get(ws_idx) else { - return Vec::new(); - }; - - let multi_pane = ws.layout.pane_count() > 1; - let terminal_active = app.mode == Mode::Terminal; - - if ws.zoomed { - let focused_id = ws.layout.focused(); - let mut inner_rect = area; - let mut scrollbar_rect = None; - if let Some(rt) = ws.runtimes.get(&focused_id) { - if rt - .scroll_metrics() - .is_some_and(|metrics| should_show_scrollbar(metrics) && area.width > 1) - { - inner_rect.width = inner_rect.width.saturating_sub(1); - scrollbar_rect = Some(Rect::new( - area.x + area.width.saturating_sub(1), - area.y, - 1, - area.height, - )); - } - rt.resize(inner_rect.height, inner_rect.width); - } - return vec![PaneInfo { - id: focused_id, - rect: area, - inner_rect, - scrollbar_rect, - is_focused: true, - }]; - } - - let mut pane_infos = ws.layout.panes(area); - - for info in &mut pane_infos { - let pane_inner = if multi_pane { - let border_set = if info.is_focused && terminal_active { - ratatui::symbols::border::THICK - } else { - ratatui::symbols::border::PLAIN - }; - let block = Block::default() - .borders(Borders::ALL) - .border_set(border_set); - block.inner(info.rect) - } else { - area - }; - - let mut inner_rect = pane_inner; - let mut scrollbar_rect = None; - if let Some(rt) = ws.runtimes.get(&info.id) { - if rt - .scroll_metrics() - .is_some_and(|metrics| should_show_scrollbar(metrics) && pane_inner.width > 1) - { - inner_rect.width = inner_rect.width.saturating_sub(1); - scrollbar_rect = Some(Rect::new( - pane_inner.x + pane_inner.width.saturating_sub(1), - pane_inner.y, - 1, - pane_inner.height, - )); - } - rt.resize(inner_rect.height, inner_rect.width); - } - - info.inner_rect = inner_rect; - info.scrollbar_rect = scrollbar_rect; - } - - pane_infos -} - -/// 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 + 1) / 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) -} - -/// Collapsed sidebar: workspace glance on top, compact agent list below. -fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { - let is_navigating = matches!(app.mode, Mode::Navigate); - - let p = &app.palette; - 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(); - let (icon, icon_style) = state_dot(agg_state, agg_seen, p); - let is_selected = visible_idx == app.selected && is_navigating; - let is_active = Some(visible_idx) == app.active; - let row_style = if is_selected { - Style::default().bg(p.surface0) - } else if is_active { - Style::default().bg(p.surface_dim) - } else { - Style::default() - }; - let num_style = if is_selected { - Style::default().fg(p.overlay1).bg(p.surface0) - } else if is_active { - Style::default().fg(p.text).bg(p.surface_dim) - } 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!("{}", visible_idx + 1), num_style), - Span::styled(" ", row_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(); - 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(p.surface_dim)); - } - } - - let detail_ws_idx = if is_navigating { - Some(app.selected) - } else { - app.active - }; - 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() { - if let Some(ws_idx) = detail_ws_idx { - if let Some(ws) = app.workspaces.get(ws_idx) { - for (detail_idx, detail) in ws.pane_details().iter().enumerate() { - let y = detail_content_area.y + detail_idx as u16; - if y >= detail_content_area.y + detail_content_area.height { - break; - } - let pane_num = ws - .public_pane_number(detail.pane_id) - .unwrap_or(detail_idx + 1); - let pane_style = Style::default().fg(p.overlay0); - let (icon, icon_style) = - agent_icon(detail.state, detail.seen, app.spinner_tick, p); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!("{pane_num}"), pane_style), - Span::styled(" ", pane_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_indicator_row( - cards: &[crate::app::state::WorkspaceCardArea], - area: Rect, - insert_idx: usize, -) -> Option<u16> { - if area.height == 0 { - return None; - } - let list_bottom = area.y + area.height.saturating_sub(1); - - let first = cards.first()?; - if insert_idx == first.ws_idx { - return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom); - } - - if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) { - return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom); - } - - cards - .last() - .filter(|card| insert_idx == card.ws_idx.saturating_add(1)) - .map(|card| card.rect.y.saturating_add(card.rect.height)) - .filter(|y| *y < list_bottom) -} - -fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - 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) - }; - - // Right border - 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); - - // --- Top section: Workspaces --- - render_workspace_list(app, frame, ws_area, is_navigating); - - // --- Bottom section: Agent detail --- - render_agent_detail(app, frame, detail_area); - - render_sidebar_toggle(app, frame, area, false, p); -} - -/// Render the workspace list in the top section of the sidebar. -fn render_workspace_list(app: &AppState, 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 { - insert_idx: Some(insert_idx), - .. - }) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx), - _ => None, - }; - - // Section header + reserve last row for "new" button - 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; - - 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(); - - if highlighted { - let bg = if selected { - p.surface0 - } else if is_dragged { - p.surface1 - } else { - p.surface_dim - }; - 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)); - } - } - } - - if is_active { - let buf = frame.buffer_mut(); - for y in row_y..row_y + row_height { - if y >= list_bottom { - break; - } - buf[(card.rect.x, y)].set_symbol("▌"); - buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent)); - } - } - - 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 num_style = if selected || is_active { - Style::default().fg(p.overlay1) - } else { - Style::default().fg(p.overlay0) - }; - - let mut line1 = vec![ - Span::styled(" ", Style::default()), - Span::styled(format!("{} ", i + 1), num_style), - Span::styled(ws.display_name(), name_style), - Span::styled(" ", Style::default()), - ]; - let (icon, icon_style) = state_dot(agg_state, agg_seen, p); - line1.push(Span::styled(icon, icon_style)); - - frame.render_widget( - Paragraph::new(Line::from(line1)), - Rect::new(card.rect.x, row_y, card.rect.width, 1), - ); - - if row_height > 1 && row_y + 1 < list_bottom { - if let Some(branch) = ws.branch() { - let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| { - let mut parts = Vec::new(); - if ahead > 0 { - parts.push((format!("↑{}", ahead), p.green)); - } - if behind > 0 { - parts.push((format!("↓{}", behind), p.red)); - } - (!parts.is_empty()).then_some(parts) - }); - let reserved = upstream_label - .as_ref() - .map(|parts| { - parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len() - }) - .unwrap_or(0); - let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved); - let branch_display = if branch.len() > max_branch_len { - format!("{}…", &branch[..max_branch_len.saturating_sub(1)]) - } else { - branch - }; - let branch_color = if selected || is_active { - p.mauve - } else { - p.overlay0 - }; - let mut spans = vec![ - Span::styled(" ", Style::default()), - Span::styled(branch_display, Style::default().fg(branch_color)), - ]; - if let Some(parts) = upstream_label { - spans.push(Span::styled(" ", Style::default())); - for (idx, (label, color)) in parts.into_iter().enumerate() { - if idx > 0 { - spans.push(Span::styled(" ", Style::default())); - } - spans.push(Span::styled(label, Style::default().fg(color))); - } - } - frame.render_widget( - Paragraph::new(Line::from(spans)), - Rect::new(card.rect.x, row_y + 1, card.rect.width, 1), - ); - } - } - } - - 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, "▕"); - } - - // Footer actions for workspace section - if 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.update_available.is_some() { - Line::from(vec![ - Span::styled("menu", Style::default().fg(p.overlay0)), - Span::styled( - " ●", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - ]) - } else { - Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))]) - }; - frame.render_widget( - Paragraph::new(menu_line).alignment(Alignment::Right), - menu_rect, - ); - } -} - -/// Render the agent detail panel in the bottom section of the sidebar. -fn render_agent_detail(app: &AppState, 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 toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope); - if toggle_rect != Rect::default() { - frame.render_widget( - Paragraph::new(Span::styled( - agent_panel_toggle_label(app.agent_panel_scope), - Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), - )) - .alignment(Alignment::Right), - toggle_rect, - ); - } - - let details = agent_panel_entries(app); - 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; - } - - let mut row_y = body.y; - let body_bottom = body.y + body.height; - for detail in details.iter().skip(app.agent_panel_scroll) { - if row_y.saturating_add(1) >= body_bottom { - break; - } - - let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p); - let label_color = state_label_color(detail.state, detail.seen, p); - let label = state_label(detail.state, detail.seen); - - let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD); - let status_style = Style::default().fg(label_color).add_modifier(Modifier::DIM); - let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); - - let primary_label = - format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize); - let name_line = Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(icon, icon_style), - Span::styled(" ", Style::default()), - Span::styled(primary_label, name_style), - ]); - frame.render_widget( - Paragraph::new(name_line), - Rect::new(body.x, row_y, body.width, 1), - ); - row_y += 1; - - let mut status_spans = vec![ - Span::styled(" ", Style::default()), - Span::styled(label, status_style), - ]; - if let Some(agent_label) = &detail.agent_label { - status_spans.push(Span::styled(" · ", agent_style)); - status_spans.push(Span::styled(agent_label, agent_style)); - } - frame.render_widget( - Paragraph::new(Line::from(status_spans)), - Rect::new(body.x, row_y, body.width, 1), - ); - row_y += 1; - - if row_y < body_bottom { - row_y += 1; - } - } - - 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) -} - -fn render_sidebar_toggle( - app: &AppState, - frame: &mut Frame, - area: Rect, - collapsed: bool, - p: &Palette, -) { - // Toggle button not needed when sidebar has content — skip for now - // to avoid conflicting with the agent detail panel at the bottom. - if !collapsed { - return; - } - let toggle_area = collapsed_sidebar_toggle_rect(area); - if toggle_area == Rect::default() { - return; - } - let icon_style = if app.update_available.is_some() { - Style::default().fg(p.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay0) - }; - frame.render_widget(Paragraph::new(Span::styled("»", icon_style)), toggle_area); -} - -fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(ws_idx) = app.active else { - render_empty(app, frame, area); - return; - }; - let Some(ws) = app.workspaces.get(ws_idx) else { - render_empty(app, frame, area); - return; - }; - - let multi_pane = ws.layout.pane_count() > 1; - let terminal_active = app.mode == Mode::Terminal; - - for info in &app.view.pane_infos { - if let Some(rt) = ws.runtimes.get(&info.id) { - // Draw borders for multi-pane layouts - if multi_pane { - let (border_style, border_set) = if info.is_focused && terminal_active { - ( - Style::default().fg(app.palette.accent), - ratatui::symbols::border::THICK, - ) - } else if info.is_focused { - ( - Style::default().fg(app.palette.accent), - ratatui::symbols::border::PLAIN, - ) - } else { - ( - Style::default().fg(app.palette.overlay0), - ratatui::symbols::border::PLAIN, - ) - }; - - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .border_set(border_set); - frame.render_widget(block, info.rect); - } - - // Draw terminal content. Only the focused pane should own the cursor. - rt.render(frame, info.inner_rect, info.is_focused && terminal_active); - render_pane_scrollbar(app, frame, info, rt); - - // Dim unfocused panes only in navigate mode - 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)]; - let style = cell.style(); - let fg = style.fg.unwrap_or(Color::White); - let dimmed_fg = dim_color(fg); - cell.set_style(style.fg(dimmed_fg)); - } - } - } - - // Selection highlight - render_selection_highlight( - &app.selection, - frame, - info.id, - info.inner_rect, - rt.scroll_metrics(), - &app.palette, - ); - } - } -} - -/// Render selection highlight for a pane by inverting fg/bg colors. -/// Reduce a color's brightness by blending it toward black. -fn dim_color(color: Color) -> Color { - match color { - Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3), - Color::White => Color::DarkGray, - Color::Gray => Color::DarkGray, - Color::DarkGray => Color::Rgb(30, 30, 30), - Color::Red => Color::Rgb(60, 0, 0), - Color::Green => Color::Rgb(0, 60, 0), - Color::Yellow => Color::Rgb(60, 60, 0), - Color::Blue => Color::Rgb(0, 0, 60), - Color::Magenta => Color::Rgb(60, 0, 60), - Color::Cyan => Color::Rgb(0, 60, 60), - Color::LightRed => Color::Rgb(80, 30, 30), - Color::LightGreen => Color::Rgb(30, 80, 30), - Color::LightYellow => Color::Rgb(80, 80, 30), - Color::LightBlue => Color::Rgb(30, 30, 80), - Color::LightMagenta => Color::Rgb(80, 30, 80), - Color::LightCyan => Color::Rgb(30, 80, 80), - // Indexed colors and others: just use DIM modifier as fallback - _ => Color::DarkGray, - } -} - -pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> { - info.scrollbar_rect -} - -pub(crate) fn release_notes_scrollbar_rect( - body: Rect, - metrics: crate::pane::ScrollMetrics, -) -> Option<Rect> { - (should_show_scrollbar(metrics) && body.width > 1).then_some(Rect::new( - body.x + body.width - 1, - body.y, - 1, - body.height, - )) -} - -pub(crate) fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool { - metrics.max_offset_from_bottom > 0 -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ScrollbarThumb { - pub top: u16, - pub len: u16, -} - -pub(crate) fn scrollbar_thumb( - metrics: crate::pane::ScrollMetrics, - track: Rect, -) -> Option<ScrollbarThumb> { - if metrics.max_offset_from_bottom == 0 || track.height == 0 { - return None; - } - - let track_height = track.height as usize; - let total_rows = metrics.max_offset_from_bottom + metrics.viewport_rows; - if total_rows == 0 { - return None; - } - - let thumb_len = ((metrics.viewport_rows * track_height) as f32 / total_rows as f32) - .round() - .max(1.0) - .min(track_height as f32) as usize; - let max_thumb_top = track_height.saturating_sub(thumb_len); - let scrolled_from_top = metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom); - let thumb_top = if max_thumb_top == 0 || metrics.max_offset_from_bottom == 0 { - 0 - } else { - ((scrolled_from_top * max_thumb_top) as f32 / metrics.max_offset_from_bottom as f32) - .round() - .clamp(0.0, max_thumb_top as f32) as usize - }; - - Some(ScrollbarThumb { - top: track.y + thumb_top as u16, - len: thumb_len as u16, - }) -} - -pub(crate) fn scrollbar_thumb_grab_offset( - metrics: crate::pane::ScrollMetrics, - track: Rect, - row: u16, -) -> Option<u16> { - let thumb = scrollbar_thumb(metrics, track)?; - (row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top) -} - -fn scrollbar_offset_from_thumb_top( - metrics: crate::pane::ScrollMetrics, - track: Rect, - thumb_top: usize, -) -> usize { - if metrics.max_offset_from_bottom == 0 { - return 0; - } - - let thumb_len = scrollbar_thumb(metrics, track) - .map(|thumb| thumb.len as usize) - .unwrap_or(1); - let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize); - if max_thumb_top == 0 { - return 0; - } - - let desired_top = thumb_top.min(max_thumb_top); - let scrolled_from_top = ((desired_top * metrics.max_offset_from_bottom) as f32 - / max_thumb_top as f32) - .round() as usize; - metrics - .max_offset_from_bottom - .saturating_sub(scrolled_from_top) -} - -pub(crate) fn scrollbar_offset_from_row( - metrics: crate::pane::ScrollMetrics, - track: Rect, - row: u16, -) -> usize { - let thumb = match scrollbar_thumb(metrics, track) { - Some(thumb) => thumb, - None => return 0, - }; - let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1)); - let row_offset = clamped_row.saturating_sub(track.y) as usize; - let thumb_center = (thumb.len as usize) / 2; - let desired_top = row_offset.saturating_sub(thumb_center); - scrollbar_offset_from_thumb_top(metrics, track, desired_top) -} - -pub(crate) fn scrollbar_offset_from_drag_row( - metrics: crate::pane::ScrollMetrics, - track: Rect, - row: u16, - grab_row_offset: u16, -) -> usize { - let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1)); - let row_offset = clamped_row.saturating_sub(track.y) as usize; - let desired_top = row_offset.saturating_sub(grab_row_offset as usize); - scrollbar_offset_from_thumb_top(metrics, track, desired_top) -} - -fn render_scrollbar( - frame: &mut Frame, - metrics: crate::pane::ScrollMetrics, - track: Rect, - track_color: Color, - thumb_color: Color, - thumb_symbol: &str, -) { - if metrics.max_offset_from_bottom == 0 { - return; - } - - let Some(thumb) = scrollbar_thumb(metrics, track) else { - return; - }; - - let buf = frame.buffer_mut(); - for y in track.y..track.y + track.height { - let cell = &mut buf[(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)]; - cell.set_symbol(thumb_symbol); - cell.set_style(Style::default().fg(thumb_color)); - } -} - -fn render_pane_scrollbar( - app: &AppState, - frame: &mut Frame, - info: &PaneInfo, - rt: &crate::pane::PaneRuntime, -) { - let Some(metrics) = rt.scroll_metrics() else { - return; - }; - 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, - metrics, - track, - track_color, - thumb_color, - thumb_symbol, - ); -} - -fn render_selection_highlight( - selection: &Option<crate::selection::Selection>, - frame: &mut Frame, - pane_id: crate::layout::PaneId, - inner: Rect, - scroll_metrics: Option<crate::pane::ScrollMetrics>, - p: &Palette, -) { - if let Some(sel) = selection { - if sel.is_visible() && sel.pane_id == pane_id { - let buf = frame.buffer_mut(); - 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::default().fg(panel_contrast_fg(p)).bg(p.blue)); - } - } - } - } - } -} - -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( - format!("{}", app.keybinds.new_workspace_label), - 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, - ); -} - -const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; - fn dim_background(frame: &mut Frame, area: Rect) { let buf = frame.buffer_mut(); for y in area.y..area.y + area.height { @@ -1801,1730 +217,7 @@ fn dim_background(frame: &mut Frame, area: Rect) { } } -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) -} - -fn panel_contrast_fg(p: &Palette) -> Color { - match p.panel_bg { - Color::Reset => p.surface_dim, - color => color, - } -} - -pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24); - -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 { - 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)) -} - -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) -} - -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), - ), - Span::raw(" "), - Span::styled("⬆", Style::default().fg(p.accent)), - ]); - frame.render_widget(Paragraph::new(line), area); -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct ModalStackAreas { - pub header: Rect, - pub content: Rect, - pub footer: Option<Rect>, - pub actions: Option<Rect>, -} - -pub(crate) fn modal_stack_areas( - inner: Rect, - header_height: u16, - footer_height: u16, - actions_height: u16, - gap: u16, -) -> ModalStackAreas { - #[derive(Clone, Copy)] - enum Slot { - Header, - Content, - Footer, - Actions, - } - - let mut constraints = Vec::new(); - let mut slots = Vec::new(); - let mut push = |slot: Slot, constraint: Constraint| { - if !slots.is_empty() { - constraints.push(Constraint::Length(gap)); - } - constraints.push(constraint); - slots.push(slot); - }; - - push(Slot::Header, Constraint::Length(header_height)); - push(Slot::Content, Constraint::Min(0)); - if footer_height > 0 { - push(Slot::Footer, Constraint::Length(footer_height)); - } - if actions_height > 0 { - push(Slot::Actions, Constraint::Length(actions_height)); - } - - 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; - - 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), - } - } - - ModalStackAreas { - header, - content, - footer, - actions, - } -} - -fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - dim_background(frame, area); - - match app.onboarding_step { - 0 => render_onboarding_welcome(app, frame, area), - _ => render_onboarding_notifications(app, frame, area), - } -} - -fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(notes) = &app.release_notes else { - return; - }; - - 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 { - "preview before updating" - } 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 sections = release_notes_sections(stack.content, notes.preview); - 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: sections.notes_body.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(sections.notes_body, metrics); - let notes_text_area = track - .map(|_| { - Rect::new( - sections.notes_body.x, - sections.notes_body.y, - sections.notes_body.width.saturating_sub(1), - sections.notes_body.height, - ) - }) - .unwrap_or(sections.notes_body); - - if let Some(instructions_area) = sections.instructions { - render_release_notes_preview_panel(frame, instructions_area, ¬es.version, &app.palette); - } - - let body = Paragraph::new( - release_notes_lines(notes.body.as_str(), &app.palette) - .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(), - ); -} - -fn release_notes_inline_spans<'a>( - text: &str, - base_style: Style, - code_style: Style, -) -> (usize, Vec<Span<'a>>) { - let mut spans = Vec::new(); - let mut width = 0; - let mut remaining = text; - - while let Some(start) = remaining.find('`') { - let (before, after_start) = remaining.split_at(start); - if !before.is_empty() { - width += before.chars().count(); - spans.push(Span::styled(before.to_string(), base_style)); - } - - let after_start = &after_start[1..]; - let Some(end) = after_start.find('`') else { - let literal = format!("`{after_start}"); - width += literal.chars().count(); - spans.push(Span::styled(literal, base_style)); - remaining = ""; - break; - }; - - let (code, after_end) = after_start.split_at(end); - width += code.chars().count(); - if !code.is_empty() { - spans.push(Span::styled(code.to_string(), code_style)); - } - remaining = &after_end[1..]; - } - - if !remaining.is_empty() { - width += remaining.chars().count(); - spans.push(Span::styled(remaining.to_string(), base_style)); - } - - if spans.is_empty() { - spans.push(Span::styled(String::new(), base_style)); - } - - (width, spans) -} - -pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, Line<'a>)> { - let mut lines = Vec::new(); - let mut in_fenced_code_block = false; - let text_style = Style::default().fg(p.text); - let inline_code_style = Style::default() - .fg(p.accent) - .bg(p.surface0) - .add_modifier(Modifier::BOLD); - - for raw in body.lines() { - let trimmed = raw.trim_end(); - if trimmed.trim_start().starts_with("```") { - in_fenced_code_block = !in_fenced_code_block; - continue; - } - - if in_fenced_code_block { - let code_bg = p.surface1; - let gutter_style = Style::default().fg(p.accent).bg(code_bg); - let code_style = Style::default().fg(p.text).bg(code_bg); - let width = 2 + trimmed.chars().count(); - let mut spans = vec![ - Span::styled("▏", gutter_style), - Span::styled(" ", code_style), - ]; - if !trimmed.is_empty() { - spans.push(Span::styled(trimmed.to_string(), code_style)); - } - lines.push((width, Line::from(spans))); - continue; - } - - if trimmed.is_empty() { - lines.push((0, Line::raw(""))); - continue; - } - - if let Some(rest) = trimmed.strip_prefix("### ") { - let text = rest.trim().to_string(); - if text.is_empty() { - lines.push((0, Line::raw(""))); - continue; - } - let width = 1 + text.chars().count(); - lines.push(( - width, - Line::from(vec![ - Span::raw(" "), - Span::styled( - text.to_uppercase(), - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - ]), - )); - continue; - } - - if let Some(rest) = trimmed.strip_prefix("- ") { - let (text_width, mut spans) = - release_notes_inline_spans(rest, text_style, inline_code_style); - let width = 3 + text_width; - let mut line_spans = vec![Span::styled(" • ", Style::default().fg(p.accent))]; - line_spans.append(&mut spans); - lines.push((width, Line::from(line_spans))); - continue; - } - - let (text_width, mut spans) = - release_notes_inline_spans(trimmed, text_style, inline_code_style); - let width = 1 + text_width; - let mut line_spans = vec![Span::raw(" ")]; - line_spans.append(&mut spans); - lines.push((width, Line::from(line_spans))); - } - - lines -} - -pub(crate) struct ReleaseNotesSections { - pub instructions: Option<Rect>, - pub notes_body: Rect, -} - -pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesSections { - if preview && area.height >= 6 { - let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).areas::<2>(area); - ReleaseNotesSections { - instructions: Some(rows[0]), - notes_body: rows[1], - } - } else { - ReleaseNotesSections { - instructions: None, - notes_body: area, - } - } -} - -fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Vec<Line<'a>> { - let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD); - let text_style = Style::default().fg(p.text); - let code_style = Style::default() - .fg(p.accent) - .bg(p.surface0) - .add_modifier(Modifier::BOLD); - - vec![ - Line::from(vec![ - Span::styled( - "●", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled(" update available", title_style), - ]), - Line::from(vec![ - Span::styled("quit herdr and run ", text_style), - Span::styled("herdr update", code_style), - Span::styled(" to install", text_style), - ]), - ] -} - -fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &str, p: &Palette) { - let rows = Layout::vertical([ - Constraint::Length(2), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<4>(area); - - let text_area = Rect::new( - rows[0].x + 1, - rows[0].y, - rows[0].width.saturating_sub(2), - rows[0].height, - ); - frame.render_widget( - Paragraph::new(release_notes_preview_lines(_version, p)).wrap(Wrap { trim: false }), - text_area, - ); - - let divider_area = Rect::new(rows[2].x + 1, rows[2].y, rows[2].width.saturating_sub(2), 1); - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "─".repeat(divider_area.width as usize), - Style::default().fg(p.surface1), - )])), - divider_area, - ); -} - -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) -} - -pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { - Rect::new( - area.x, - area.y, - action_button_width(Some("↵"), "continue"), - 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(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - area, - &[ - ActionButtonSpec { - hint: Some("esc"), - label: "back", - }, - ActionButtonSpec { - hint: Some("↵"), - label: "start", - }, - ], - 2, - 0, - ); - (rects[0], rects[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 navigate 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]); - - 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), - ); -} - -fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 56, 14, &app.palette) else { - return; - }; - - if inner.height < 11 { - return; - } - - let stack = modal_stack_areas(inner, 3, 0, 1, 1); - let header_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<3>(stack.header); - let option_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<5>(stack.content); - - render_modal_header(frame, header_rows[0], "notification style", &app.palette); - frame.render_widget( - Paragraph::new(" herdr watches background panes and can alert you") - .style(Style::default().fg(app.palette.overlay1)), - header_rows[1], - ); - frame.render_widget( - Paragraph::new(" when agents finish or need attention.") - .style(Style::default().fg(app.palette.overlay1)), - header_rows[2], - ); - - let options = [ - "quiet no interruptions", - "visual only top-right toasts", - "sound only sound alerts", - "both sound and toasts", - ]; - - for (idx, option) in options.iter().enumerate() { - let selected = idx == app.onboarding_list.selected; - let prefix = if selected { "›" } else { " " }; - let style = if selected { - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - } else { - Style::default().fg(app.palette.text) - }; - frame.render_widget( - Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style), - option_rows[idx], - ); - } - - let (back_rect, save_rect) = - onboarding_notification_button_rects(stack.actions.unwrap_or_default()); - render_action_button( - frame, - back_rect, - Some("esc"), - "back", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - save_rect, - Some("↵"), - "start", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); -} - /// Floating overlay for navigate mode — appears at bottom of terminal area. -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 line = Line::from(vec![ - Span::styled(" NAVIGATE ", mode_style), - Span::raw(" "), - Span::styled("esc", key), - Span::styled(" back ", dim), - Span::styled("↑↓", key), - Span::styled(" ws ", dim), - Span::styled("⇥", key), - Span::styled(" pane ", dim), - Span::styled(kb.new_tab_label.as_str(), key), - Span::styled(" new tab ", dim), - Span::styled(kb.split_vertical_label.as_str(), key), - Span::styled(" split│ ", dim), - Span::styled(kb.split_horizontal_label.as_str(), key), - Span::styled(" split─ ", dim), - Span::styled(kb.close_pane_label.as_str(), key), - Span::styled(" close ", dim), - Span::styled(kb.fullscreen_label.as_str(), key), - Span::styled(" full ", dim), - Span::styled(kb.resize_mode_label.as_str(), key), - Span::styled(" resize ", dim), - Span::styled("?", key), - Span::styled(" keybinds ", dim), - Span::styled("s", key), - Span::styled(" settings ", dim), - Span::styled("q", key), - Span::styled(" quit", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - - frame.render_widget(Clear, overlay_area); - let bg = Style::default().bg(app.palette.panel_bg); - let buf = frame.buffer_mut(); - for x in overlay_area.x..overlay_area.x + overlay_area.width { - buf[(x, overlay_y)].set_style(bg); - } - frame.render_widget(Paragraph::new(line), overlay_area); - - if app.update_available.is_some() { - let status = Line::from(vec![Span::styled( - " update available", - 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, - ); - } -} - -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); - - if *item == "update available" { - let line = if selected { - Line::from(vec![ - Span::styled( - " update available ", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "● ", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - ]) - } else { - Line::from(vec![ - Span::styled(" update available ", Style::default().fg(app.palette.text)), - Span::styled( - "● ", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - ]) - }; - frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect); - continue; - } - - let style = if selected { - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.palette.text) - }; - frame.render_widget( - Paragraph::new(format!(" {item} ")) - .style(style) - .alignment(Alignment::Left), - rect, - ); - } -} - -fn optional_keybind_label(label: &Option<String>) -> String { - label.clone().unwrap_or_else(|| "unset".to_string()) -} - -fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'static str)>)> { - let kb = &app.keybinds; - let mut groups = Vec::new(); - - groups.push(( - "global", - vec![ - ( - crate::config::format_key_combo((app.prefix_code, app.prefix_mods)), - "navigate mode", - ), - ("prefix + ?".to_string(), "keybinds"), - ], - )); - - groups.push(( - "navigation", - vec![ - ("esc".to_string(), "back"), - ("↑ / ↓".to_string(), "workspace list"), - ("h j k l / arrows".to_string(), "move focus"), - ("tab / shift+tab".to_string(), "cycle pane"), - ("enter".to_string(), "open workspace"), - ("s".to_string(), "settings"), - ("q".to_string(), "quit"), - ], - )); - - let workspace_tab = vec![ - (kb.new_workspace_label.clone(), "new workspace"), - (kb.rename_workspace_label.clone(), "rename workspace"), - (kb.close_workspace_label.clone(), "close workspace"), - ( - optional_keybind_label(&kb.previous_workspace_label), - "previous workspace", - ), - ( - optional_keybind_label(&kb.next_workspace_label), - "next workspace", - ), - (kb.new_tab_label.clone(), "new tab"), - (optional_keybind_label(&kb.rename_tab_label), "rename tab"), - ( - optional_keybind_label(&kb.previous_tab_label), - "previous tab", - ), - (optional_keybind_label(&kb.next_tab_label), "next tab"), - (optional_keybind_label(&kb.close_tab_label), "close tab"), - ]; - groups.push(("workspaces / tabs", workspace_tab)); - - let panes = vec![ - (kb.split_vertical_label.clone(), "split vertical"), - (kb.split_horizontal_label.clone(), "split horizontal"), - (kb.close_pane_label.clone(), "close pane"), - (kb.fullscreen_label.clone(), "fullscreen"), - (kb.resize_mode_label.clone(), "resize mode"), - (kb.toggle_sidebar_label.clone(), "toggle sidebar"), - ( - optional_keybind_label(&kb.focus_pane_left_label), - "focus pane left", - ), - ( - optional_keybind_label(&kb.focus_pane_down_label), - "focus pane down", - ), - ( - optional_keybind_label(&kb.focus_pane_up_label), - "focus pane up", - ), - ( - optional_keybind_label(&kb.focus_pane_right_label), - "focus pane right", - ), - ]; - groups.push(("panes", panes)); - - if !kb.custom_commands.is_empty() { - groups.push(( - "custom", - kb.custom_commands - .iter() - .map(|binding| (binding.label.clone(), "custom command")) - .collect(), - )); - } - - groups -} - -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 = keybind_help_groups(app); - let key_width = groups - .iter() - .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) - .max() - .unwrap_or(8); - - let mut lines = Vec::new(); - - 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.to_string(), label_style), - ]), - )); - } - lines.push((0, Line::raw(""))); - } - - lines -} - -fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) { - 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"), - "close", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - frame.render_widget( - Paragraph::new(" available commands and configured shortcuts") - .style(Style::default().fg(app.palette.overlay1)), - 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, - "▐", - ); - } - - 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("jump", 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("close", Style::default().fg(app.palette.overlay0)), - Span::styled(" esc / enter ", Style::default().fg(app.palette.text)), - ])), - stack.footer.unwrap_or_default(), - ); -} - -/// Floating overlay for resize mode. -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); - - frame.render_widget(Clear, overlay_area); - let bg = Style::default().bg(app.palette.panel_bg); - let buf = frame.buffer_mut(); - for x in overlay_area.x..overlay_area.x + overlay_area.width { - buf[(x, overlay_y)].set_style(bg); - } - frame.render_widget(Paragraph::new(line), overlay_area); -} - -/// Centered popup confirmation dialog with dimmed background. -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]) -} - -fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - dim_background(frame, area); - - let title = match app.mode { - Mode::RenameWorkspace => "rename workspace", - Mode::RenameTab if app.creating_new_tab => "new tab", - Mode::RenameTab => "rename tab", - _ => 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); - frame.render_widget(Clear, input_rect); - frame.render_widget( - Paragraph::new(format!(" {}█", app.name_input)).style( - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0), - ), - 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), - ); -} - -fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let ws_name = app - .workspaces - .get(app.selected) - .map(|ws| ws.display_name()) - .unwrap_or_else(|| "?".to_string()); - let pane_count = 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") - }; - - // Dim the entire background - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - for x in area.x..area.x + area.width { - let cell = &mut buf[(x, y)]; - cell.set_style(cell.style().add_modifier(Modifier::DIM)); - } - } - - // Centered popup - 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(" Close workspace?", warn)]); - - let detail_line = Line::from(vec![ - Span::styled( - format!(" {ws_name}"), - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - Span::styled(format!(" — {pane_text}"), 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), - ]) - .areas::<3>(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 action_button_text(hint: Option<&str>, label: &str) -> String { - match hint { - Some(hint) => format!(" {hint} {label} "), - None => format!(" {label} "), - } -} - -pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 { - action_button_text(hint, label).chars().count() as u16 -} - -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, - ); -} - -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 confirm_close_popup_rect(area: Rect) -> Option<Rect> { - centered_popup_rect(area, 44, 5) -} - -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, - 2, - ); - (rects[0], rects[1]) -} - -pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "apply", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "close", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -/// Right-click context menu popup anchored near the click position. -// --------------------------------------------------------------------------- -// Settings overlay -// --------------------------------------------------------------------------- - -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, 56, 20) else { - return; - }; - - // Dim everything behind the modal - 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); - - // Title - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " settings", - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - )])), - header_rows[0], - ); - - // Tab bar - let tabs = Tabs::new(SettingsSection::ALL.iter().map(|s| s.label())) - .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]); - - // Separator - let sep = "─".repeat(inner.width as usize); - frame.render_widget( - Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))), - header_rows[2], - ); - - // Section content - let content_area = stack.content; - - match app.settings.section { - SettingsSection::Theme => { - render_settings_theme(app, frame, content_area); - } - 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_settings_toggle( - frame, - content_area, - p, - "visual toasts", - "show top-right notifications for background events", - app.toast_config.enabled, - app.settings.list.selected, - ); - } - } - - // Footer buttons + hints - if let Some(footer_area) = stack.footer { - let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]) - .areas::<2>(footer_area); - let (apply_rect, close_rect) = settings_button_rects(inner); - render_action_button( - frame, - apply_rect, - Some("↵"), - "apply", - 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], - ); - } -} - -/// Render the theme picker list inside the settings panel. -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); -} - -/// Reusable toggle widget for boolean settings (sound, toast). -fn render_settings_toggle( - frame: &mut Frame, - area: Rect, - p: &crate::app::state::Palette, - title: &str, - description: &str, - current_value: bool, - selected_idx: usize, -) { - let [desc_area, _, list_area] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(2), - ]) - .areas::<3>(area); - - let max_desc_len = (desc_area.width as usize).saturating_sub(2); - let desc_text = if description.len() > max_desc_len { - format!(" {}…", &description[..max_desc_len.saturating_sub(2)]) - } else { - format!(" {description}") - }; - frame.render_widget( - Paragraph::new(Span::styled(desc_text, Style::default().fg(p.overlay1))), - desc_area, - ); - - let items: Vec<ListItem> = ["on", "off"] - .into_iter() - .map(|label| { - let is_active = (label == "on") == current_value; - let marker = if is_active { " ✓" } else { "" }; - ListItem::new(Line::from(vec![ - Span::styled(format!("{title}: {label}"), 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(" ▸ "); - let mut state = ListState::default().with_selected(Some(selected_idx.min(1))); - frame.render_stateful_widget(list, list_area, &mut state); -} - -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); -} - -fn render_toast_notification( - frame: &mut Frame, - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - p: &Palette, -) { - let dot_color = match toast.kind { - ToastKind::NeedsAttention => p.red, - ToastKind::Finished => p.blue, - ToastKind::UpdateInstalled => p.accent, - }; - let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4; - let width = content_width.saturating_add(2).min(area.width); - let height = 4u16.min(area.height); - let x = area.x + area.width.saturating_sub(width); - let y = area.y - + area - .height - .saturating_sub(height + if offset_for_warning { 1 } else { 0 }); - let toast_area = Rect::new(x, y, width, height); - - 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 < 2 { - 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); - frame.render_widget(Paragraph::new(context), context_row); -} - -/// Visual badge for a pane's state + seen flag. -/// -/// | State | Icon | Color | -/// |--------------------|------|--------| -/// | Blocked | ● | Red | -/// | Working | ● | Yellow | -/// | Done (idle+unseen) | ● | Blue | -/// | Idle (seen) | ○ | Green | -/// | Unknown | · | Gray | -/// -/// Filled dot = needs attention (blocked/working, or finished unseen). -/// Hollow dot = nothing to do here. -fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { - let text = format!(" config warning: {message} "); - let width = text.len() as u16 + 2; - let notif_area = Rect::new( - area.x + area.width.saturating_sub(width.min(area.width)), - area.y, - width.min(area.width), - 1, - ); - - frame.render_widget(Clear, notif_area); - frame.render_widget( - Paragraph::new(Span::styled( - text, - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.yellow) - .add_modifier(Modifier::BOLD), - )), - notif_area, - ); -} - -/// Compact dot icon for workspace-level aggregate state (top section). -fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) { - match (state, seen) { - (AgentState::Blocked, _) => ("●", Style::default().fg(p.red)), - (AgentState::Working, _) => ("●", Style::default().fg(p.yellow)), - (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)), - (AgentState::Idle, true) => ("○", Style::default().fg(p.green)), - (AgentState::Unknown, _) => ("·", Style::default().fg(p.overlay0)), - } -} - -/// Rich icon for per-pane agent detail (bottom section). -/// Uses animated spinner for the working state. -fn agent_icon(state: AgentState, seen: bool, tick: u32, p: &Palette) -> (&'static str, Style) { - match (state, seen) { - (AgentState::Blocked, _) => ("◉", Style::default().fg(p.red)), - (AgentState::Working, _) => (spinner_frame(tick), Style::default().fg(p.yellow)), - (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)), - (AgentState::Idle, true) => ("✓", Style::default().fg(p.green)), - (AgentState::Unknown, _) => ("○", Style::default().fg(p.overlay0)), - } -} - -/// State label for the agent detail panel. -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", - } -} - -/// Color for the state label text. -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, - } -} - fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec<Span<'static>> { let mut spans = Vec::new(); spans.push(Span::raw(" ")); @@ -3540,9 +233,13 @@ fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> V #[cfg(test)] mod tests { + use super::keybind_help::keybind_help_groups; + use super::release_notes::{release_notes_lines, release_notes_preview_lines}; + use super::scrollbar::scrollbar_thumb; use super::*; - use crate::{detect::Agent, workspace::Workspace}; + use crate::{app::state::Palette, layout::PaneInfo, workspace::Workspace}; use ratatui::{backend::TestBackend, Terminal}; + use ratatui::{style::Color, text::Line}; #[tokio::test] async fn focused_pane_cursor_wins_during_terminal_render() { @@ -3607,6 +304,37 @@ mod tests { } #[test] + fn expanded_sidebar_workspace_rows_show_state_before_name_without_numbers() { + let mut app = crate::app::state::AppState::test_new(); + let mut ws = Workspace::test_new("one"); + let repo = temp_git_repo("main"); + ws.identity_cwd = repo.clone(); + let root_pane = ws.tabs[0].root_pane; + ws.tabs[0].pane_cwds.insert(root_pane, repo.clone()); + + app.workspaces = vec![ws]; + app.selected = 0; + app.mode = Mode::Navigate; + + compute_view(&mut app, Rect::new(0, 0, 80, 20)); + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| render(&app, frame)).unwrap(); + let buffer = terminal.backend().buffer(); + + let card = app.view.workspace_card_areas[0].rect; + let line1 = buffer_row_text(buffer, card, card.y); + let line2 = buffer_row_text(buffer, card, card.y + 1); + + assert!(line1.starts_with(" · one")); + assert!(!line1.contains("1 one")); + assert_eq!(line2, " main"); + + std::fs::remove_dir_all(repo).ok(); + } + + #[test] fn tab_bar_dims_auto_named_tabs_and_emphasizes_custom_tabs() { let mut app = crate::app::state::AppState::test_new(); let mut ws = Workspace::test_new("test"); @@ -3765,78 +493,11 @@ mod tests { } #[test] - fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() { - let mut app = crate::app::state::AppState::test_new(); - let mut first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - first.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - - let mut second = Workspace::test_new("two"); - let second_tab = second.test_add_tab(Some("logs")); - let second_pane = second.tabs[second_tab].root_pane; - second.tabs[second_tab] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - - app.workspaces = vec![first, second]; - app.active = Some(0); - app.selected = 0; - app.agent_panel_scope = AgentPanelScope::AllWorkspaces; - - let entries = agent_panel_entries(&app); - assert_eq!(entries[0].primary_label, "one"); - assert!(entries[0].primary_tab_label.is_none()); - assert_eq!(entries[0].agent_label.as_deref(), Some("pi")); - assert_eq!(entries[1].primary_label, "two"); - assert_eq!(entries[1].primary_tab_label.as_deref(), Some("logs")); - assert_eq!(entries[1].agent_label.as_deref(), Some("claude")); - } - - #[test] - fn all_workspaces_primary_label_truncates_workspace_and_tab() { - let entry = AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: crate::layout::PaneId::from_raw(1), - primary_label: "agent-browser".into(), - primary_tab_label: Some("test-escalation".into()), - agent_label: Some("claude".into()), - state: AgentState::Idle, - seen: true, - }; - - let label = format_agent_panel_primary_label(&entry, 18); - - assert_eq!(label, "agent-bro… · test…"); - } - - #[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 pane_scrollbar_rect_uses_rightmost_inner_column() { + fn pane_scrollbar_rect_overlays_rightmost_inner_column() { let info = PaneInfo { id: crate::layout::PaneId::from_raw(1), rect: Rect::new(0, 0, 12, 8), - inner_rect: Rect::new(1, 1, 9, 6), + inner_rect: Rect::new(1, 1, 10, 6), scrollbar_rect: Some(Rect::new(10, 1, 1, 6)), is_focused: true, }; @@ -3844,6 +505,40 @@ mod tests { assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6))); } + #[tokio::test] + async fn compute_view_keeps_terminal_width_when_pane_scrollbar_is_visible() { + let mut app = crate::app::state::AppState::test_new(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + 12, + 4, + 4096, + b"000000000000\r\n111111111111\r\n222222222222\r\n333333333333\r\n444444444444\r\n", + ), + ); + + app.workspaces = vec![ws]; + app.active = Some(0); + app.selected = 0; + + compute_view(&mut app, Rect::new(0, 0, 40, 12)); + + let info = app.view.pane_infos.first().expect("pane info"); + assert_eq!(info.inner_rect.width, app.view.terminal_area.width); + assert_eq!( + info.scrollbar_rect, + Some(Rect::new( + info.inner_rect.x + info.inner_rect.width.saturating_sub(1), + info.inner_rect.y, + 1, + info.inner_rect.height, + )) + ); + } + #[test] fn scrollbar_stays_hidden_without_scrollback() { let metrics = crate::pane::ScrollMetrics { @@ -3915,6 +610,29 @@ mod tests { .collect::<String>() } + 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() + } + + fn temp_git_repo(branch: &str) -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("unix time") + .as_nanos(); + let root = std::env::temp_dir().join(format!("herdr-ui-test-{unique}")); + std::fs::create_dir_all(root.join(".git")).expect("create .git dir"); + std::fs::write( + root.join(".git/HEAD"), + format!("ref: refs/heads/{branch}\n"), + ) + .expect("write HEAD"); + root + } + #[test] fn release_notes_inline_code_spans_are_styled_without_backticks() { let palette = Palette::catppuccin(); @@ -3933,10 +651,10 @@ mod tests { let lines = release_notes_preview_lines("0.5.0", &palette); assert_eq!(lines.len(), 2); - assert_eq!(line_text(&lines[0]), "● update available"); + assert_eq!(line_text(&lines[0]), "● update ready"); assert_eq!( line_text(&lines[1]), - "quit herdr and run herdr update to install" + "detach from this session, then run herdr update in your shell" ); assert_eq!(lines[0].spans[0].style.fg, Some(palette.accent)); assert_eq!(lines[0].spans[1].style.fg, Some(palette.text)); |
