aboutsummaryrefslogtreecommitdiff
path: root/src/ui.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui.rs')
-rw-r--r--src/ui.rs1510
1 files changed, 47 insertions, 1463 deletions
diff --git a/src/ui.rs b/src/ui.rs
index 8adc31ef..7a488374 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,118 +1,53 @@
-use ratatui::{
- layout::{Constraint, Layout, Rect},
- style::{Modifier, Style},
- text::Span,
- Frame,
-};
+use ratatui::layout::Rect;
-mod dialogs;
-mod keybind_help;
-mod menus;
-mod mobile;
-mod navigator;
mod onboarding;
mod panes;
mod release_notes;
mod scrollbar;
-mod settings;
mod sidebar;
mod status;
mod tab_surface;
-mod tabs;
mod text;
mod widgets;
-use self::dialogs::{
- render_confirm_close_overlay, render_new_linked_worktree_overlay,
- render_open_existing_worktree_overlay, render_remove_worktree_overlay, render_rename_overlay,
-};
-use self::keybind_help::render_keybind_help_overlay;
-use self::menus::{
- render_context_menu, render_copy_mode_overlay, render_global_launcher_menu,
- render_navigate_overlay, render_prefix_overlay, render_resize_overlay,
-};
-use self::mobile::{
- compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height,
- mobile_toast_banner_rect, render_mobile_header, render_mobile_panel,
- render_mobile_toast_banner,
+pub(crate) use self::onboarding::{
+ onboarding_welcome_continue_rect, ONBOARDING_DESCRIPTION, ONBOARDING_HELP_LABEL,
+ ONBOARDING_HELP_SUFFIX, ONBOARDING_NEXT, ONBOARDING_PREFIX_LABEL, ONBOARDING_PREFIX_SUFFIX,
+ ONBOARDING_SUBTITLE, ONBOARDING_TITLE,
};
-use self::navigator::render_navigator_overlay;
-pub(crate) use self::onboarding::onboarding_welcome_continue_rect;
-use self::onboarding::render_onboarding_overlay;
+#[cfg(all(test, unix))]
pub(crate) use self::panes::popup_pane_rects;
-use self::panes::{render_empty, render_popup_pane, resize_popup_pane};
+use self::panes::resize_popup_pane;
+pub(crate) use self::panes::{
+ apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back, render_selection_highlight,
+};
pub(crate) use self::release_notes::{
- product_announcement_display_lines, release_notes_close_button_rect,
- release_notes_display_lines, release_notes_wrapped_line_count, PRODUCT_ANNOUNCEMENT_MODAL_SIZE,
- RELEASE_NOTES_MODAL_SIZE,
+ product_announcement_display_lines, product_announcement_scroll_metrics,
+ release_notes_close_button_rect, release_notes_display_lines, release_notes_scroll_metrics,
+ PRODUCT_ANNOUNCEMENT_MODAL_SIZE, RELEASE_NOTES_MODAL_SIZE,
};
-use self::release_notes::{render_product_announcement_overlay, render_release_notes_overlay};
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,
+ release_notes_scrollbar_rect, render_pane_scrollbar_buffer, render_scrollbar_buffer,
+ scrollbar_offset_from_drag_row, scrollbar_offset_from_row, scrollbar_thumb,
+ scrollbar_thumb_grab_offset,
};
-use self::settings::render_settings_overlay;
-#[cfg(test)]
-pub(crate) use self::sidebar::workspace_drop_indicator_row;
-use self::sidebar::{render_sidebar, render_sidebar_collapsed};
-use self::status::{
- copy_feedback_rect, render_config_diagnostic, render_copy_feedback, render_toast_notification,
- toast_notification_rect,
+pub(crate) use self::sidebar::{
+ agent_panel_entries_from, expanded_sidebar_sections, resolved_token_spans, sidebar_agent_rows,
+ sidebar_section_divider_rect, sidebar_space_rows, AgentPanelEntry, AgentTokenContext,
+ ResolvedToken, ResolvedTokenKind, SpaceTokenContext,
};
+use self::status::copy_feedback_rect;
+pub(crate) use self::status::{render_config_diagnostic_buffer, render_copy_feedback_buffer};
pub(crate) use self::tab_surface::{
- compute_tab_surface, render_tab_surface, resize_tab_surface, TabSurfaceLayout,
-};
-use self::tabs::render_tab_bar;
-pub(crate) use self::{
- dialogs::{
- confirm_close_button_rects, confirm_close_popup_rect, new_linked_worktree_button_rects,
- new_linked_worktree_inner_rect, open_existing_worktree_button_rects,
- open_existing_worktree_inner_rect, open_existing_worktree_max_visible_rows,
- open_existing_worktree_visible_start, remove_worktree_button_rects,
- remove_worktree_popup_rect, rename_button_rects,
- },
- settings::{
- settings_button_rects, settings_popup_height, settings_show_primary_action,
- SETTINGS_POPUP_WIDTH,
- },
- sidebar::{
- agent_entry_gap, agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries,
- agent_panel_scroll_for_target, agent_panel_scroll_metrics, agent_panel_scrollbar_rect,
- agent_panel_toggle_rect, all_agent_panel_entries, collapsed_sidebar_sections,
- collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections,
- expanded_sidebar_toggle_rect, normalized_workspace_scroll, sidebar_section_divider_rect,
- workspace_drop_slots, workspace_group_chevron_rect, workspace_list_entries,
- workspace_list_entries_expanded, workspace_list_rect, workspace_list_scroll_metrics,
- workspace_list_scrollbar_rect, workspace_parent_group_state, AgentPanelEntry,
- WorkspaceListEntry,
- },
+ compute_tab_surface, render_tab_surface, resize_tab_surface, tab_surface_cursor,
+ tab_surface_hyperlinks, TabSurfaceLayout, TabSurfaceView,
};
+pub(crate) use self::text::truncate_end;
+pub(crate) use self::widgets::{centered_popup_rect, modal_stack_areas};
-pub(crate) use self::{
- keybind_help::keybind_help_lines,
- mobile::{
- mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at,
- mobile_switcher_workspace_doc_range, MobileSwitcherTarget,
- },
- panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back},
- tab_surface::{tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceView},
- tabs::{compute_tab_bar_view, tab_bar_content_area},
- widgets::{centered_popup_rect, modal_stack_areas},
-};
-use crate::app::state::ViewLayout;
-use crate::app::{AppState, Mode};
+use crate::app::AppState;
use crate::terminal::TerminalRuntimeRegistry;
-const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator
-
-/// Compute view geometry and reconcile pane sizes.
-/// Called before render to separate mutation from drawing.
-#[cfg_attr(not(test), allow(dead_code))]
-pub fn compute_view(app: &mut AppState, area: Rect) {
- let terminal_runtimes = TerminalRuntimeRegistry::new();
- compute_view_with_runtime_registry(app, &terminal_runtimes, area);
-}
-
pub fn compute_view_with_runtime_registry(
app: &mut AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -136,11 +71,6 @@ pub fn compute_view_with_cell_size(
compute_view_internal(app, terminal_runtimes, area, true, cell_size);
}
-/// 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,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -155,63 +85,6 @@ pub(crate) fn compute_view_without_resizing_panes(
);
}
-fn resize_background_tab_panes_to_area(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- terminal_area: Rect,
- cell_size: crate::kitty_graphics::HostCellSize,
-) {
- for (ws_idx, ws) in app.workspaces.iter().enumerate() {
- for (tab_idx, tab) in ws.tabs.iter().enumerate() {
- if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() {
- continue;
- }
- resize_tab_surface(app, terminal_runtimes, tab, terminal_area, cell_size);
- }
- }
-}
-
-fn resize_background_tab_panes_for_desktop(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- main_area: Rect,
- cell_size: crate::kitty_graphics::HostCellSize,
-) {
- for (ws_idx, ws) in app.workspaces.iter().enumerate() {
- let (_, terminal_area) = desktop_tab_bar_and_terminal_area(app, ws, main_area);
- for (tab_idx, tab) in ws.tabs.iter().enumerate() {
- if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() {
- continue;
- }
- resize_tab_surface(app, terminal_runtimes, tab, terminal_area, cell_size);
- }
- }
-}
-
-fn desktop_tab_bar_and_terminal_area(
- app: &AppState,
- ws: &crate::workspace::Workspace,
- main_area: Rect,
-) -> (Rect, Rect) {
- let hide_single_tab_bar = app.hide_tab_bar_when_single_tab && ws.tabs.len() == 1;
- if !hide_single_tab_bar && main_area.height > 1 {
- match app.tab_bar_position {
- crate::config::TabBarPositionConfig::Top => {
- let [tab_bar_rect, terminal_area] =
- Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
- (tab_bar_rect, terminal_area)
- }
- crate::config::TabBarPositionConfig::Bottom => {
- let [terminal_area, tab_bar_rect] =
- Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(main_area);
- (tab_bar_rect, terminal_area)
- }
- }
- } else {
- (Rect::default(), main_area)
- }
-}
-
fn compute_view_internal(
app: &mut AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -219,335 +92,37 @@ fn compute_view_internal(
resize_panes: bool,
cell_size: crate::kitty_graphics::HostCellSize,
) {
- if is_mobile_width(area, app.mobile_width_threshold) {
- compute_mobile_view(app, terminal_runtimes, area, resize_panes, cell_size);
- return;
- }
+ let TabSurfaceLayout { pane_infos, .. } =
+ compute_tab_surface(app, terminal_runtimes, area, resize_panes, cell_size);
- let sidebar_w = if app.sidebar_collapsed {
- match app.sidebar_collapsed_mode {
- crate::config::SidebarCollapsedModeConfig::Compact => COLLAPSED_WIDTH,
- crate::config::SidebarCollapsedModeConfig::Hidden => 0,
- }
- } else {
- app.sidebar_width
- .clamp(app.sidebar_min_width, app.sidebar_max_width)
- };
-
- let [sidebar_area, main_area] =
- Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area);
-
- let (tab_bar_rect, terminal_area) = app
- .active
- .and_then(|i| app.workspaces.get(i))
- .map(|ws| desktop_tab_bar_and_terminal_area(app, ws, main_area))
- .unwrap_or((Rect::default(), main_area));
-
- if !app.sidebar_collapsed {
- app.workspace_scroll = normalized_workspace_scroll(app, sidebar_area, app.workspace_scroll);
- let (_, detail_area) = expanded_sidebar_sections(sidebar_area, app.sidebar_section_split);
- let max_agent_scroll = agent_panel_scroll_metrics(app, detail_area).max_offset_from_bottom;
- app.agent_panel_scroll = app.agent_panel_scroll.min(max_agent_scroll);
- } else {
- app.workspace_scroll = app
- .workspace_scroll
- .min(app.workspaces.len().saturating_sub(1));
- app.agent_panel_scroll = 0;
- }
-
- let workspace_card_areas = if app.sidebar_collapsed {
- Vec::new()
- } else {
- compute_workspace_card_areas(app, sidebar_area)
- };
-
- let tab_bar_view = app
- .active
- .and_then(|ws_idx| app.workspaces.get(ws_idx))
- .map(|ws| {
- compute_tab_bar_view(
- ws,
- tab_bar_content_area(app, tab_bar_rect),
- app.tab_scroll,
- app.tab_scroll_follow_active,
- app.mouse_capture,
- )
- })
- .unwrap_or_default();
- app.tab_scroll = tab_bar_view.scroll;
-
- let TabSurfaceLayout {
- pane_infos,
- split_borders,
- } = compute_tab_surface(
- app,
- terminal_runtimes,
- terminal_area,
- resize_panes,
- cell_size,
- );
if resize_panes {
- resize_background_tab_panes_for_desktop(app, terminal_runtimes, main_area, cell_size);
- resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size);
+ resize_background_tab_panes(app, terminal_runtimes, area, cell_size);
+ resize_popup_pane(app, terminal_runtimes, area, cell_size);
}
- let toast_hit_area = app
- .toast
- .as_ref()
- .map(|toast| {
- toast_notification_rect(
- area,
- toast,
- app.config_diagnostic.is_some(),
- toast.position.unwrap_or(app.toast_config.herdr.position),
- )
- })
- .unwrap_or_default();
-
app.view = crate::app::ViewState {
- layout: ViewLayout::Desktop,
- sidebar_rect: sidebar_area,
- workspace_card_areas,
- tab_bar_rect,
- tab_hit_areas: tab_bar_view.tab_hit_areas,
- tab_scroll_left_hit_area: tab_bar_view.scroll_left_hit_area,
- tab_scroll_right_hit_area: tab_bar_view.scroll_right_hit_area,
- new_tab_hit_area: tab_bar_view.new_tab_hit_area,
- terminal_area,
- mobile_header_rect: Rect::default(),
- mobile_menu_hit_area: Rect::default(),
- toast_hit_area,
+ terminal_area: area,
pane_infos,
- split_borders,
};
- app.sync_copy_mode_search_geometry();
}
-fn compute_mobile_view(
- app: &mut AppState,
+fn resize_background_tab_panes(
+ app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
area: Rect,
- resize_panes: bool,
cell_size: crate::kitty_graphics::HostCellSize,
) {
- let header_h = area.height.min(2);
- let (header_rect, terminal_area) = if area.height > header_h {
- let [header_rect, terminal_area] =
- Layout::vertical([Constraint::Length(header_h), Constraint::Min(1)]).areas(area);
- (header_rect, terminal_area)
- } else {
- (area, Rect::default())
- };
-
- if app.mode == Mode::Navigate {
- let switcher_viewport_h = area.height.saturating_sub(header_h + 1);
- let max_scroll = mobile_switcher_max_scroll_for_height(app, switcher_viewport_h);
- app.mobile_switcher_scroll = app.mobile_switcher_scroll.min(max_scroll);
- }
-
- let TabSurfaceLayout {
- pane_infos,
- split_borders,
- } = compute_tab_surface(
- app,
- terminal_runtimes,
- terminal_area,
- resize_panes,
- cell_size,
- );
- if resize_panes {
- resize_background_tab_panes_to_area(app, terminal_runtimes, terminal_area, cell_size);
- resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size);
- }
- let header_hits = compute_mobile_header_hit_areas(app, header_rect);
-
- let toast_hit_area = app
- .toast
- .as_ref()
- .map(|_| mobile_toast_banner_rect(area, app.config_diagnostic.is_some()))
- .unwrap_or_default();
-
- app.view = crate::app::ViewState {
- layout: ViewLayout::Mobile,
- sidebar_rect: Rect::default(),
- workspace_card_areas: Vec::new(),
- tab_bar_rect: Rect::default(),
- tab_hit_areas: Vec::new(),
- tab_scroll_left_hit_area: Rect::default(),
- tab_scroll_right_hit_area: Rect::default(),
- new_tab_hit_area: Rect::default(),
- terminal_area,
- mobile_header_rect: header_rect,
- mobile_menu_hit_area: header_hits.menu,
- toast_hit_area,
- pane_infos,
- split_borders,
- };
- app.sync_copy_mode_search_geometry();
-}
-
-/// Render the UI — reads AppState but does not mutate it.
-#[cfg_attr(not(test), allow(dead_code))]
-pub fn render(app: &AppState, frame: &mut Frame) {
- let terminal_runtimes = TerminalRuntimeRegistry::new();
- render_with_runtime_registry(app, &terminal_runtimes, frame);
-}
-
-pub fn render_with_runtime_registry(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
-) {
- let tab_bar_area = app.view.tab_bar_rect;
- let terminal_area = app.view.terminal_area;
-
- render_navigation_chrome(app, terminal_runtimes, frame);
- if app.view.layout != ViewLayout::Mobile {
- render_tab_bar(app, frame, tab_bar_area);
- }
- if app
- .active
- .and_then(|ws_idx| app.workspaces.get(ws_idx))
- .is_some()
- {
- render_tab_surface(app, terminal_runtimes, app.view.tab_surface(), frame);
- } else {
- render_empty(app, frame, terminal_area);
- }
-
- // Ambient notifications sit above panes, but below interactive overlays.
- render_notifications(app, frame, terminal_area);
- render_popup_pane(app, terminal_runtimes, frame, terminal_area);
-
- let mode_bar_area = if app.view.layout == ViewLayout::Desktop
- && app.tab_bar_position == crate::config::TabBarPositionConfig::Bottom
- && tab_bar_area.height > 0
- {
- tab_bar_area
- } else {
- terminal_area
- };
-
- match app.mode {
- Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
- Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()),
- Mode::ProductAnnouncement => render_product_announcement_overlay(app, frame, frame.area()),
- Mode::Navigate if app.view.layout == ViewLayout::Mobile => {
- render_mobile_panel(app, terminal_runtimes, frame, frame.area())
- }
- Mode::Navigate => render_navigate_overlay(app, frame, mode_bar_area),
- Mode::Prefix => render_prefix_overlay(app, frame, mode_bar_area),
- Mode::Copy => render_copy_mode_overlay(app, frame, mode_bar_area),
- Mode::Resize => render_resize_overlay(app, frame, mode_bar_area),
- Mode::ConfirmClose => {
- render_confirm_close_overlay(app, terminal_runtimes, frame, terminal_area)
- }
- Mode::ContextMenu => {
- render_context_menu(app, frame);
- }
- Mode::Settings => render_settings_overlay(app, frame, frame.area()),
- Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
- render_rename_overlay(app, frame, frame.area())
- }
- Mode::NewLinkedWorktree => render_new_linked_worktree_overlay(app, frame, frame.area()),
- Mode::OpenExistingWorktree => {
- render_open_existing_worktree_overlay(app, frame, frame.area())
- }
- Mode::ConfirmRemoveWorktree => render_remove_worktree_overlay(app, frame, frame.area()),
- Mode::GlobalMenu => render_global_launcher_menu(app, frame),
- Mode::KeybindHelp => render_keybind_help_overlay(app, frame),
- Mode::Navigator => render_navigator_overlay(app, terminal_runtimes, frame),
- Mode::Terminal => {}
- }
-}
-
-fn render_navigation_chrome(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
-) {
- if app.view.layout == ViewLayout::Mobile {
- render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect);
- } else if app.view.sidebar_rect.width > 0 {
- if app.sidebar_collapsed {
- render_sidebar_collapsed(app, frame, app.view.sidebar_rect);
- } else {
- render_sidebar(app, terminal_runtimes, frame, app.view.sidebar_rect);
- }
- }
-}
-
-fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) {
- let has_config_diagnostic = app.config_diagnostic.is_some();
- if let Some(message) = &app.config_diagnostic {
- let diagnostic_area = if app.view.layout == ViewLayout::Mobile {
- terminal_area
- } else {
- frame.area()
- };
- render_config_diagnostic(frame, diagnostic_area, message, &app.palette);
- }
- let mut copy_feedback_offset = u16::from(has_config_diagnostic);
- let mut toast_rect = None;
- if let Some(toast) = &app.toast {
- if app.view.layout == ViewLayout::Mobile {
- render_mobile_toast_banner(
- frame,
- frame.area(),
- toast,
- has_config_diagnostic,
- &app.palette,
- );
- } else {
- render_toast_notification(
- frame,
- frame.area(),
- toast,
- has_config_diagnostic,
- toast.position.unwrap_or(app.toast_config.herdr.position),
- &app.palette,
- );
- toast_rect = Some(toast_notification_rect(
- frame.area(),
- toast,
- has_config_diagnostic,
- toast.position.unwrap_or(app.toast_config.herdr.position),
- ));
- }
- if app.view.layout == ViewLayout::Mobile {
- toast_rect = Some(mobile_toast_banner_rect(
- frame.area(),
- has_config_diagnostic,
- ));
- }
- }
- if let Some(feedback) = &app.copy_feedback {
- let area = if app.view.layout == ViewLayout::Mobile {
- frame.area()
- } else {
- terminal_area
- };
- if let Some(toast_rect) = toast_rect {
- copy_feedback_offset = copy_feedback_offset_for_toast(
- area,
- feedback,
- copy_feedback_offset,
- app.toast_config.clipboard.position,
- toast_rect,
- );
+ for (workspace_index, workspace) in app.workspaces.iter().enumerate() {
+ for (tab_index, tab) in workspace.tabs.iter().enumerate() {
+ if app.active == Some(workspace_index) && tab_index == workspace.active_tab_index() {
+ continue;
+ }
+ resize_tab_surface(app, terminal_runtimes, tab, area, cell_size);
}
- render_copy_feedback(
- frame,
- area,
- feedback,
- copy_feedback_offset,
- app.toast_config.clipboard.position,
- &app.palette,
- );
}
}
-fn copy_feedback_offset_for_toast(
+pub(crate) fn copy_feedback_offset_for_toast(
area: Rect,
feedback: &crate::app::state::CopyFeedback,
base_offset: u16,
@@ -555,1007 +130,16 @@ fn copy_feedback_offset_for_toast(
toast_rect: Rect,
) -> u16 {
let feedback_rect = copy_feedback_rect(area, feedback, base_offset, position);
- if rects_overlap(feedback_rect, toast_rect) {
+ if rectangles_overlap(feedback_rect, toast_rect) {
base_offset.saturating_add(toast_rect.height)
} else {
base_offset
}
}
-fn rects_overlap(a: Rect, b: Rect) -> bool {
- a.x < b.x.saturating_add(b.width)
- && b.x < a.x.saturating_add(a.width)
- && a.y < b.y.saturating_add(b.height)
- && b.y < a.y.saturating_add(a.height)
-}
-
-fn dim_background(frame: &mut Frame, area: Rect) {
- 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));
- }
- }
-}
-
-/// Floating overlay for navigate mode — appears at bottom of terminal area.
-fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec<Span<'static>> {
- let mut spans = Vec::new();
- spans.push(Span::raw(" "));
- for (i, (k, desc)) in items.iter().enumerate() {
- if i > 0 {
- spans.push(Span::styled(" ", dim_style));
- }
- spans.push(Span::styled(k.to_string(), key_style));
- spans.push(Span::styled(format!(" {desc}"), dim_style));
- }
- spans
-}
-
-#[cfg(test)]
-mod tests {
- use super::keybind_help::keybind_help_groups;
- use super::scrollbar::scrollbar_thumb;
- use super::*;
- use crate::{app::state::ViewLayout, layout::PaneInfo, workspace::Workspace};
- use ratatui::style::Color;
- use ratatui::{backend::TestBackend, Terminal};
-
- #[test]
- fn copy_feedback_offset_only_increases_when_toast_rect_overlaps() {
- let area = Rect::new(0, 0, 80, 24);
- let feedback = crate::app::state::CopyFeedback {
- message: "copied to clipboard".into(),
- };
- let toast = crate::app::state::ToastNotification {
- kind: crate::app::state::ToastKind::Finished,
- title: "pi finished".into(),
- context: "workspace · 1".into(),
- position: None,
- target: None,
- };
-
- let bottom_right_toast = toast_notification_rect(
- area,
- &toast,
- false,
- crate::config::ToastHerdrPosition::BottomRight,
- );
- assert_eq!(
- copy_feedback_offset_for_toast(
- area,
- &feedback,
- 0,
- crate::config::ToastClipboardPosition::TopCenter,
- bottom_right_toast,
- ),
- 0
- );
-
- let bottom_center_toast = Rect::new(28, 21, 24, 3);
- assert_eq!(
- copy_feedback_offset_for_toast(
- area,
- &feedback,
- 0,
- crate::config::ToastClipboardPosition::BottomCenter,
- bottom_center_toast,
- ),
- bottom_center_toast.height
- );
- }
-
- #[test]
- fn workspace_creation_dialog_renders_new_workspace_title() {
- let mut app = crate::app::state::AppState::test_new();
- app.mode = Mode::RenameWorkspace;
- app.pending_workspace_create_cwd = Some("/tmp/project".into());
- app.name_input = "project".into();
-
- let area = Rect::new(0, 0, 80, 20);
- compute_view(&mut app, area);
- let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
- terminal.draw(|frame| render(&app, frame)).unwrap();
- let screen = (0..area.height)
- .map(|row| buffer_row_text(terminal.backend().buffer(), area, row))
- .collect::<Vec<_>>()
- .join("\n");
-
- assert!(screen.contains("new workspace"), "{screen}");
- assert!(screen.contains("project"), "{screen}");
- }
-
- #[tokio::test]
- async fn focused_pane_cursor_wins_during_terminal_render() {
- let mut app = crate::app::state::AppState::test_new();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal);
-
- ws.insert_test_runtime(
- first_pane,
- crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"),
- );
- ws.insert_test_runtime(
- second_pane,
- crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"r\r\nb"),
- );
- ws.tabs[0].layout.focus_pane(first_pane);
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
- let focused = app
- .view
- .pane_infos
- .iter()
- .find(|info| info.id == first_pane)
- .expect("focused pane info");
-
- let backend = TestBackend::new(80, 20);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal.draw(|frame| render(&app, frame)).unwrap();
-
- terminal
- .backend_mut()
- .assert_cursor_position((focused.inner_rect.x + 4, focused.inner_rect.y));
- }
-
- #[test]
- fn mobile_width_uses_header_and_full_width_terminal() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- compute_view(&mut app, Rect::new(0, 0, 44, 20));
-
- assert_eq!(app.view.layout, ViewLayout::Mobile);
- assert_eq!(app.view.sidebar_rect, Rect::default());
- assert_eq!(app.view.tab_bar_rect, Rect::default());
- assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2));
- assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18));
- assert_eq!(app.view.mobile_menu_hit_area.height, 2);
- assert_eq!(
- app.view.mobile_menu_hit_area.x + app.view.mobile_menu_hit_area.width,
- 44
- );
- }
-
- #[test]
- fn mobile_config_diagnostic_keeps_command_visible() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app.config_diagnostic = Some("config.toml:100:10; herdr config check".into());
-
- let area = Rect::new(0, 0, 44, 20);
- compute_view(&mut app, area);
- let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
- terminal.draw(|frame| render(&app, frame)).unwrap();
- let row = buffer_row_text(terminal.backend().buffer(), area, app.view.terminal_area.y);
-
- assert!(row.contains("config.toml:100:10"), "{row}");
- assert!(row.contains("herdr config check"), "{row}");
- }
-
- #[test]
- fn desktop_toast_hit_area_uses_full_frame_not_terminal_area() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app.toast_config.herdr.position = crate::config::ToastHerdrPosition::TopLeft;
- app.toast = Some(crate::app::state::ToastNotification {
- kind: crate::app::state::ToastKind::Finished,
- title: "pi finished".into(),
- context: "one".into(),
- position: None,
- target: None,
- });
-
- compute_view(&mut app, Rect::new(0, 0, 100, 20));
-
- assert_eq!(app.view.layout, ViewLayout::Desktop);
- assert!(app.view.terminal_area.x > 0);
- assert_eq!(app.view.toast_hit_area.x, 0);
- assert_eq!(app.view.toast_hit_area.y, 0);
- }
-
- #[test]
- fn desktop_toast_hit_area_still_offsets_for_config_diagnostic() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app.config_diagnostic = Some("config warning".into());
- app.toast_config.herdr.position = crate::config::ToastHerdrPosition::TopLeft;
- app.toast = Some(crate::app::state::ToastNotification {
- kind: crate::app::state::ToastKind::Finished,
- title: "pi finished".into(),
- context: "one".into(),
- position: None,
- target: None,
- });
-
- compute_view(&mut app, Rect::new(0, 0, 100, 20));
-
- assert_eq!(app.view.toast_hit_area.x, 0);
- assert_eq!(app.view.toast_hit_area.y, 1);
- }
-
- #[test]
- fn configured_mobile_width_threshold_controls_layout_switch() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
- assert_eq!(app.view.layout, ViewLayout::Desktop);
-
- app.mobile_width_threshold = 90;
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
- assert_eq!(app.view.layout, ViewLayout::Mobile);
- assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 80, 2));
- assert_eq!(app.view.terminal_area, Rect::new(0, 2, 80, 18));
- }
-
- #[test]
- fn desktop_tab_bar_position_controls_geometry_and_mode_bar_placement() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Prefix;
-
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
- assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1));
- assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19));
-
- app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom;
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
- assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 19));
- assert_eq!(app.view.tab_bar_rect, Rect::new(26, 19, 54, 1));
- assert!(app.view.tab_hit_areas.iter().all(|rect| rect.y == 19));
- assert_eq!(app.view.new_tab_hit_area.y, 19);
-
- let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
- terminal.draw(|frame| render(&app, frame)).unwrap();
- let mode_row = buffer_row_text(
- terminal.backend().buffer(),
- app.view.tab_bar_rect,
- app.view.tab_bar_rect.y,
- );
- assert!(mode_row.contains("PREFIX"), "{mode_row}");
- }
-
- #[test]
- fn hide_tab_bar_when_single_tab_toggles_geometry_with_tab_count() {
- let mut app = crate::app::state::AppState::test_new();
- app.hide_tab_bar_when_single_tab = true;
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
- let single_tab_terminal_area = app.view.terminal_area;
- assert_eq!(app.view.tab_bar_rect, Rect::default());
- assert_eq!(single_tab_terminal_area, Rect::new(26, 0, 54, 20));
- assert!(app.view.tab_hit_areas.is_empty());
- assert_eq!(app.view.new_tab_hit_area, Rect::default());
-
- app.workspaces[0].test_add_tab(Some("logs"));
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
-
- assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1));
- assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19));
- assert_eq!(app.view.tab_hit_areas.len(), 2);
- assert!(app.view.tab_hit_areas.iter().all(|rect| rect.width > 0));
- assert!(app.view.new_tab_hit_area.width > 0);
-
- assert!(app.workspaces[0].close_tab(1));
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
-
- assert_eq!(app.view.terminal_area, single_tab_terminal_area);
- assert_eq!(app.view.tab_bar_rect, Rect::default());
- assert!(app.view.tab_hit_areas.is_empty());
- assert_eq!(app.view.new_tab_hit_area, Rect::default());
- }
-
- #[test]
- fn bottom_tab_bar_still_hides_when_single_tab() {
- let mut app = crate::app::state::AppState::test_new();
- app.hide_tab_bar_when_single_tab = true;
- app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom;
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Prefix;
-
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
- assert_eq!(app.view.tab_bar_rect, Rect::default());
- assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 20));
-
- let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
- terminal.draw(|frame| render(&app, frame)).unwrap();
- let mode_row = buffer_row_text(
- terminal.backend().buffer(),
- app.view.terminal_area,
- app.view.terminal_area.y + app.view.terminal_area.height - 1,
- );
- assert!(mode_row.contains("PREFIX"), "{mode_row}");
- }
-
- #[tokio::test]
- async fn hide_tab_bar_when_single_tab_resizes_background_tabs_per_workspace() {
- let mut app = crate::app::state::AppState::test_new();
- app.hide_tab_bar_when_single_tab = true;
-
- let mut one_tab_workspace = Workspace::test_new("one");
- let one_tab_pane = one_tab_workspace.tabs[0].root_pane;
- let one_tab_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b"");
- one_tab_workspace.tabs[0]
- .runtimes
- .insert(one_tab_pane, one_tab_runtime);
-
- let mut two_tab_workspace = Workspace::test_new("two");
- let background_tab = two_tab_workspace.test_add_tab(Some("logs"));
- let two_tab_pane = two_tab_workspace.tabs[background_tab].root_pane;
- let two_tab_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b"");
- two_tab_workspace.tabs[background_tab]
- .runtimes
- .insert(two_tab_pane, two_tab_runtime);
-
- app.workspaces = vec![one_tab_workspace, two_tab_workspace];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
-
- let one_tab_size = app.workspaces[0].tabs[0].runtimes[&one_tab_pane].current_size();
- let two_tab_size =
- app.workspaces[1].tabs[background_tab].runtimes[&two_tab_pane].current_size();
- assert_eq!(one_tab_size, (20, 53));
- assert_eq!(two_tab_size, (19, 53));
- }
-
- #[tokio::test]
- async fn mobile_background_tabs_use_mobile_terminal_area() {
- let mut app = crate::app::state::AppState::test_new();
-
- let mut workspace = Workspace::test_new("mobile");
- let background_tab = workspace.test_add_tab(Some("logs"));
- let background_pane = workspace.tabs[background_tab].root_pane;
- let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b"");
- workspace.tabs[background_tab]
- .runtimes
- .insert(background_pane, runtime);
-
- app.workspaces = vec![workspace];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- compute_view(&mut app, Rect::new(0, 0, 44, 20));
-
- assert_eq!(app.view.layout, ViewLayout::Mobile);
- assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18));
- assert_eq!(
- app.workspaces[0].tabs[background_tab].runtimes[&background_pane].current_size(),
- (18, 43)
- );
- }
-
- #[test]
- fn product_announcement_renders_above_config_diagnostic() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::ProductAnnouncement;
- app.product_announcement = Some(crate::app::state::ProductAnnouncementState {
- version: "0.6.0".into(),
- id: "keybinding-v2".into(),
- title: "Keybinding syntax changed".into(),
- body: "### Update\n- Body".into(),
- scroll: 0,
- preview: false,
- });
- app.config_diagnostic = Some(
- "unsafe direct keybinding: keys.new_workspace = \"n\"\nunsafe direct keybinding: keys.new_tab = \"c\""
- .into(),
- );
-
- let area = Rect::new(0, 0, 44, 20);
- compute_view(&mut app, area);
-
- let backend = TestBackend::new(area.width, area.height);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal.draw(|frame| render(&app, frame)).unwrap();
- let buffer = terminal.backend().buffer();
-
- let popup = centered_popup_rect(
- area,
- PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0,
- PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1,
- )
- .expect("announcement popup");
- let title_row = popup.y + 1;
- let row = buffer_row_text(buffer, Rect::new(0, title_row, area.width, 1), title_row);
-
- assert!(row.contains("Keybinding syntax changed"));
- assert!(!row.contains("config warning"));
- }
-
- #[test]
- fn compute_view_clamps_sidebar_width_to_configured_max() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app.sidebar_max_width = 30;
- app.sidebar_width = 999;
-
- compute_view(&mut app, Rect::new(0, 0, 100, 20));
-
- assert_eq!(app.view.sidebar_rect.width, 30);
- }
-
- #[test]
- fn compute_view_clamps_sidebar_width_to_configured_min() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app.sidebar_min_width = 22;
- app.sidebar_width = 5;
-
- compute_view(&mut app, Rect::new(0, 0, 100, 20));
-
- assert_eq!(app.view.sidebar_rect.width, 22);
- }
-
- #[test]
- fn hidden_collapsed_sidebar_uses_full_width_terminal_area() {
- let mut app = crate::app::state::AppState::test_new();
- app.sidebar_collapsed = true;
- app.sidebar_collapsed_mode = crate::config::SidebarCollapsedModeConfig::Hidden;
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
-
- assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 0, 20));
- assert_eq!(app.view.tab_bar_rect, Rect::new(0, 0, 80, 1));
- assert_eq!(app.view.terminal_area, Rect::new(0, 1, 80, 19));
- assert!(app.view.workspace_card_areas.is_empty());
-
- let backend = TestBackend::new(80, 20);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal.draw(|frame| render(&app, frame)).unwrap();
- }
-
- #[test]
- fn collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() {
- let mut app = crate::app::state::AppState::test_new();
- app.sidebar_collapsed = true;
- app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
- app.active = Some(1);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- 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 (ws_area, _, _) = collapsed_sidebar_sections(app.view.sidebar_rect);
- let active_row = ws_area.y + 1;
- let active_style = buffer[(ws_area.x, active_row)].style();
-
- assert_eq!(active_style.bg, Some(app.palette.active_row_bg));
- }
-
- #[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.refresh_git_ahead_behind();
-
- app.workspaces = vec![ws];
- app.ensure_test_terminals();
- let root_terminal_id = app.workspaces[0].tabs[0].panes[&root_pane]
- .attached_terminal_id
- .clone();
- app.terminals.get_mut(&root_terminal_id).unwrap().cwd = repo.clone();
- 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");
- let custom_tab = ws.test_add_tab(Some("logs"));
- ws.switch_tab(custom_tab);
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- 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 auto_rect = app.view.tab_hit_areas[0];
- let custom_rect = app.view.tab_hit_areas[1];
- let auto_style = buffer[(auto_rect.x + 1, auto_rect.y)].style();
- let custom_style = buffer[(custom_rect.x + 1, custom_rect.y)].style();
-
- assert_eq!(auto_style.fg, Some(app.palette.overlay0));
- assert!(auto_style.add_modifier.contains(Modifier::DIM));
- assert_eq!(custom_style.fg, Some(app.palette.panel_bg));
- assert!(custom_style.add_modifier.contains(Modifier::BOLD));
- }
-
- #[test]
- fn tab_bar_uses_surface_dim_when_panel_background_resets() {
- let mut app = crate::app::state::AppState::test_new();
- let mut ws = Workspace::test_new("test");
- let custom_tab = ws.test_add_tab(Some("logs"));
- ws.switch_tab(custom_tab);
-
- app.palette.panel_bg = Color::Reset;
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- 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 custom_rect = app.view.tab_hit_areas[1];
- let custom_style = buffer[(custom_rect.x + 1, custom_rect.y)].style();
-
- assert_eq!(custom_style.bg, Some(app.palette.accent));
- assert_eq!(custom_style.fg, Some(app.palette.surface_dim));
- assert!(custom_style.add_modifier.contains(Modifier::BOLD));
- }
-
- #[test]
- fn new_tab_button_tracks_rightmost_tab_when_tabs_fit() {
- let mut app = crate::app::state::AppState::test_new();
- let mut ws = Workspace::test_new("test");
- ws.test_add_tab(Some("logs"));
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
-
- compute_view(&mut app, Rect::new(0, 0, 80, 20));
-
- let last_visible = app
- .view
- .tab_hit_areas
- .iter()
- .rev()
- .find(|rect| rect.width > 0)
- .copied()
- .expect("last visible tab");
-
- assert_eq!(
- app.view.new_tab_hit_area.x,
- last_visible.x + last_visible.width
- );
- }
-
- #[test]
- fn tab_bar_shows_scroll_controls_when_tabs_overflow() {
- let mut app = crate::app::state::AppState::test_new();
- let mut ws = Workspace::test_new("test");
- for name in ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"] {
- ws.test_add_tab(Some(name));
- }
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app.tab_scroll_follow_active = false;
- app.tab_scroll = 2;
-
- compute_view(&mut app, Rect::new(0, 0, 65, 20));
-
- assert!(app.view.tab_scroll_left_hit_area.width > 0);
- assert!(app.view.tab_scroll_right_hit_area.width > 0);
- assert_eq!(app.view.tab_hit_areas[0].width, 0);
- assert_eq!(app.view.tab_hit_areas[1].width, 0);
- assert!(app.view.tab_hit_areas[2].width > 0);
- assert!(app.view.new_tab_hit_area.width > 0);
-
- let last_visible = app
- .view
- .tab_hit_areas
- .iter()
- .rev()
- .find(|rect| rect.width > 0)
- .copied()
- .expect("last visible tab");
-
- assert_eq!(
- app.view.tab_scroll_right_hit_area.x,
- last_visible.x + last_visible.width
- );
- assert_eq!(
- app.view.new_tab_hit_area.x,
- app.view.tab_scroll_right_hit_area.x + app.view.tab_scroll_right_hit_area.width
- );
- }
-
- #[test]
- fn tab_bar_clamps_manual_scroll_at_last_visible_tab() {
- let mut app = crate::app::state::AppState::test_new();
- let mut ws = Workspace::test_new("test");
- for name in [
- "one", "two", "three", "four", "five", "six", "seven", "eight",
- ] {
- ws.test_add_tab(Some(name));
- }
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app.tab_scroll_follow_active = false;
- app.tab_scroll = usize::MAX;
-
- compute_view(&mut app, Rect::new(0, 0, 65, 20));
-
- let last_idx = app.workspaces[0].tabs.len() - 1;
- assert!(app.view.tab_hit_areas[last_idx].width > 0);
- let clamped_scroll = app.tab_scroll;
-
- app.scroll_tabs_right();
-
- assert_eq!(app.tab_scroll, clamped_scroll);
- assert!(app.view.tab_hit_areas[last_idx].width > 0);
- }
-
- #[test]
- fn pane_scrollbar_rect_uses_reserved_rightmost_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),
- scrollbar_rect: Some(Rect::new(10, 1, 1, 6)),
- borders: ratatui::widgets::Borders::ALL,
- is_focused: true,
- };
-
- assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6)));
- }
-
- #[tokio::test]
- async fn compute_view_reserves_terminal_column_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.insert_test_runtime(
- pane_id,
- crate::terminal::TerminalRuntime::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 + 1, app.view.terminal_area.width);
- assert_eq!(
- info.scrollbar_rect,
- Some(Rect::new(
- info.inner_rect.x + info.inner_rect.width,
- info.inner_rect.y,
- 1,
- info.inner_rect.height,
- ))
- );
- }
-
- #[test]
- fn scrollbar_stays_hidden_without_scrollback() {
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: 0,
- max_offset_from_bottom: 0,
- viewport_rows: 5,
- };
-
- assert!(!should_show_scrollbar(metrics));
- }
-
- #[test]
- fn scrollbar_shows_with_scrollback() {
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: 0,
- max_offset_from_bottom: 20,
- viewport_rows: 5,
- };
-
- assert!(should_show_scrollbar(metrics));
- }
-
- #[test]
- fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() {
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: 0,
- max_offset_from_bottom: 20,
- viewport_rows: 5,
- };
- let track = Rect::new(9, 4, 1, 5);
-
- let thumb = scrollbar_thumb(metrics, track).expect("thumb");
- assert_eq!(thumb.top + thumb.len, track.y + track.height);
- }
-
- #[test]
- fn scrollbar_offset_mapping_hits_top_middle_and_bottom() {
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: 0,
- max_offset_from_bottom: 20,
- viewport_rows: 5,
- };
- let track = Rect::new(9, 4, 1, 5);
-
- assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 20);
- assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10);
- assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0);
- }
-
- #[test]
- fn dragging_from_current_thumb_row_preserves_offset() {
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: 7,
- max_offset_from_bottom: 20,
- viewport_rows: 5,
- };
- let track = Rect::new(9, 4, 1, 8);
- let thumb = scrollbar_thumb(metrics, track).expect("thumb");
- let row = thumb.top + thumb.len / 2;
- let grab = scrollbar_thumb_grab_offset(metrics, track, row).expect("grab");
-
- assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7);
- }
-
- 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 prefix_mode_renders_prefix_indicator() {
- let mut app = crate::app::state::AppState::test_new();
- app.mode = Mode::Prefix;
- app.view.terminal_area = ratatui::layout::Rect::new(0, 0, 60, 4);
- let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 4))
- .expect("test terminal");
-
- terminal
- .draw(|frame| render_prefix_overlay(&app, frame, app.view.terminal_area))
- .expect("draw prefix overlay");
-
- let rendered = terminal
- .backend()
- .buffer()
- .content()
- .iter()
- .map(|cell| cell.symbol())
- .collect::<String>();
- assert!(rendered.contains("PREFIX"));
- }
-
- #[test]
- fn keybind_help_shows_unset_for_optional_actions() {
- let app = crate::app::state::AppState::test_new();
- let groups = keybind_help_groups(&app);
-
- let workspace_tab = groups
- .iter()
- .find(|(name, _)| *name == "workspaces / tabs")
- .expect("workspace tab group")
- .1
- .clone();
- let panes = groups
- .iter()
- .find(|(name, _)| *name == "panes")
- .expect("panes group")
- .1
- .clone();
-
- assert!(workspace_tab
- .iter()
- .any(|(key, label)| key == "unset" && label.as_ref() == "previous workspace"));
- assert!(workspace_tab
- .iter()
- .any(|(key, label)| key == "unset" && label.as_ref() == "next workspace"));
- assert!(workspace_tab
- .iter()
- .any(|(key, label)| key == "unset" && label.as_ref() == "previous agent"));
- assert!(workspace_tab
- .iter()
- .any(|(key, label)| key == "unset" && label.as_ref() == "next agent"));
- assert!(workspace_tab
- .iter()
- .any(|(key, label)| key == "unset" && label.as_ref() == "focus agent 1-9"));
- assert!(workspace_tab
- .iter()
- .any(|(key, label)| key == "unset" && label.as_ref() == "switch workspace 1-9"));
- assert!(panes
- .iter()
- .any(|(key, label)| key == "prefix+h" && label.as_ref() == "focus pane left"));
- assert!(panes
- .iter()
- .any(|(key, label)| key == "prefix+j" && label.as_ref() == "focus pane down"));
- assert!(panes
- .iter()
- .any(|(key, label)| key == "prefix+k" && label.as_ref() == "focus pane up"));
- assert!(panes
- .iter()
- .any(|(key, label)| key == "prefix+l" && label.as_ref() == "focus pane right"));
- }
-
- #[test]
- fn keybind_help_shows_custom_command_descriptions() {
- let mut app = crate::app::state::AppState::test_new();
- app.keybinds.custom_commands = vec![
- crate::config::CustomCommandKeybind {
- bindings: crate::config::ActionKeybinds::prefix("alt+g"),
- label: "prefix+alt+g".to_string(),
- command: "lazygit".to_string(),
- action: crate::config::CustomCommandAction::Pane,
- description: Some("open lazygit".to_string()),
- width: None,
- height: None,
- },
- crate::config::CustomCommandKeybind {
- bindings: crate::config::ActionKeybinds::prefix("alt+h"),
- label: "prefix+alt+h".to_string(),
- command: "echo hello".to_string(),
- action: crate::config::CustomCommandAction::Shell,
- description: None,
- width: None,
- height: None,
- },
- ];
-
- let groups = keybind_help_groups(&app);
- let custom = groups
- .iter()
- .find(|(name, _)| *name == "custom")
- .expect("custom group")
- .1
- .clone();
- assert!(custom
- .iter()
- .any(|(key, label)| key == "prefix+alt+g" && label.as_ref() == "open lazygit"));
- assert!(custom
- .iter()
- .any(|(key, label)| key == "prefix+alt+h" && label.as_ref() == "custom command"));
-
- let rendered_help = keybind_help_lines(&app)
- .into_iter()
- .flat_map(|(_, line)| line.spans)
- .map(|span| span.content.into_owned())
- .collect::<Vec<_>>()
- .join("");
- assert!(rendered_help.contains("open lazygit"));
- assert!(rendered_help.contains("custom command"));
- }
-
- #[test]
- fn keybind_help_compacts_multiple_indexed_ranges() {
- let config: crate::config::Config = toml::from_str(
- r#"
-[keys]
-switch_tab = ["prefix+1..9", "alt+1..9"]
-switch_workspace = "ctrl+1..9"
-"#,
- )
- .expect("config parses");
-
- let mut app = crate::app::state::AppState::test_new();
- app.keybinds = config.keybinds();
-
- let workspace_tab = keybind_help_groups(&app)
- .into_iter()
- .find(|(name, _)| *name == "workspaces / tabs")
- .expect("workspace tab group")
- .1;
-
- let switch_tab_key = workspace_tab
- .iter()
- .find(|(_, label)| label.as_ref() == "switch tab 1-9")
- .map(|(key, _)| key.as_str())
- .expect("switch tab help entry");
- let switch_workspace_key = workspace_tab
- .iter()
- .find(|(_, label)| label.as_ref() == "switch workspace 1-9")
- .map(|(key, _)| key.as_str())
- .expect("switch workspace help entry");
-
- assert_eq!(switch_tab_key, "prefix+1..9 / alt+1..9");
- assert_eq!(switch_workspace_key, "ctrl+1..9");
- }
+fn rectangles_overlap(left: Rect, right: Rect) -> bool {
+ left.x < right.right()
+ && right.x < left.right()
+ && left.y < right.bottom()
+ && right.y < left.bottom()
}