use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, text::Span, Frame, }; mod dialogs; mod keybind_help; mod menus; mod mobile; 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::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; use self::onboarding::render_onboarding_overlay; use self::panes::{compute_pane_infos, render_panes, resize_tab_panes}; pub(crate) use self::release_notes::{ product_announcement_display_lines, release_notes_close_button_rect, release_notes_display_lines, release_notes_sections, 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, }; use self::settings::render_settings_overlay; use self::sidebar::{render_sidebar, render_sidebar_collapsed}; use self::status::{render_config_diagnostic, render_toast_notification, toast_notification_rect}; 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, settings_show_primary_action}, 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, mobile::{ mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at, mobile_switcher_workspace_doc_range, MobileSwitcherTarget, }, panes::pane_is_scrolled_back, tabs::compute_tab_bar_view, widgets::{centered_popup_rect, modal_stack_areas}, }; use crate::app::state::ViewLayout; use crate::app::{AppState, Mode}; const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator // Braille spinner frames — smooth rotation const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /// Map spinner_tick (incremented every frame at ~60fps) to a spinner frame. /// We want ~8 updates/sec so divide by 8. pub(super) fn spinner_frame(tick: u32) -> &'static str { SPINNERS[(tick as usize / 8) % SPINNERS.len()] } /// 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) { compute_view_internal( app, area, true, crate::kitty_graphics::HostCellSize::default(), ); } pub fn compute_view_with_cell_size( app: &mut AppState, area: Rect, cell_size: crate::kitty_graphics::HostCellSize, ) { compute_view_internal(app, 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, area: Rect) { compute_view_internal( app, area, false, crate::kitty_graphics::HostCellSize::default(), ); } fn resize_background_tab_panes_to_terminal_area( app: &AppState, 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_panes(app, tab, terminal_area, cell_size); } } } fn compute_view_internal( app: &mut AppState, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) { if is_mobile_width(area) { compute_mobile_view(app, area, resize_panes, cell_size); return; } let sidebar_w = if app.sidebar_collapsed { COLLAPSED_WIDTH } 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 has_tabs = app.active.and_then(|i| app.workspaces.get(i)).is_some(); let (tab_bar_rect, terminal_area) = if has_tabs && main_area.height > 1 { let [tab_bar_rect, terminal_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area); (tab_bar_rect, terminal_area) } else { (Rect::default(), main_area) }; app.workspace_scroll = app .workspace_scroll .min(app.workspaces.len().saturating_sub(1)); if !app.sidebar_collapsed { 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.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(|i| app.workspaces.get(i)) .map(|ws| { compute_tab_bar_view( ws, 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 split_borders = app .active .and_then(|i| app.workspaces.get(i)) .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); let pane_infos = compute_pane_infos(app, terminal_area, resize_panes, cell_size); if resize_panes { resize_background_tab_panes_to_terminal_area(app, terminal_area, cell_size); } let toast_hit_area = app .toast .as_ref() .map(|toast| toast_notification_rect(terminal_area, toast, app.config_diagnostic.is_some())) .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, pane_infos, split_borders, }; } fn compute_mobile_view( app: &mut AppState, 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 split_borders = app .active .and_then(|i| app.workspaces.get(i)) .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); let pane_infos = compute_pane_infos(app, terminal_area, resize_panes, cell_size); if resize_panes { resize_background_tab_panes_to_terminal_area(app, 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, }; } /// Render the UI — reads AppState but does not mutate it. pub fn render(app: &AppState, frame: &mut Frame) { let sidebar_area = app.view.sidebar_rect; let tab_bar_area = app.view.tab_bar_rect; let terminal_area = app.view.terminal_area; if app.view.layout == ViewLayout::Mobile { render_mobile_header(app, frame, app.view.mobile_header_rect); } else if app.sidebar_collapsed { render_sidebar_collapsed(app, frame, sidebar_area); } else { render_sidebar(app, frame, sidebar_area); } if app.view.layout != ViewLayout::Mobile { render_tab_bar(app, frame, tab_bar_area); } render_panes(app, frame, 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, frame, frame.area()) } Mode::Navigate => render_navigate_overlay(app, frame, terminal_area), Mode::Resize => render_resize_overlay(app, frame, terminal_area), Mode::ConfirmClose => render_confirm_close_overlay(app, 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::GlobalMenu => render_global_launcher_menu(app, frame), Mode::KeybindHelp => render_keybind_help_overlay(app, frame), Mode::Terminal => {} } // Notifications (rendered on top of everything) let has_config_diagnostic = app.config_diagnostic.is_some(); if let Some(message) = &app.config_diagnostic { render_config_diagnostic(frame, terminal_area, message, &app.palette); } 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, terminal_area, toast, has_config_diagnostic, &app.palette, ); } } } 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> { 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::release_notes::{release_notes_lines, release_notes_preview_lines}; use super::scrollbar::scrollbar_thumb; use super::*; use crate::{ app::state::{Palette, ViewLayout}, 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() { 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 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 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.surface_dim)); } #[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)), 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 line_text(line: &Line<'_>) -> String { line.spans .iter() .map(|span| span.content.as_ref()) .collect::() } 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::() .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(); let lines = release_notes_lines("- `herdr pane run ...` now works", &palette); assert_eq!(lines.len(), 1); assert_eq!(line_text(&lines[0].1), " • herdr pane run ... now works"); assert_eq!(lines[0].1.spans[1].content.as_ref(), "herdr pane run ..."); assert_eq!(lines[0].1.spans[1].style.fg, Some(palette.accent)); assert_eq!(lines[0].1.spans[1].style.bg, Some(palette.surface0)); } #[test] fn release_notes_preview_lines_show_update_steps() { let palette = Palette::catppuccin(); let lines = release_notes_preview_lines("0.5.0", "herdr update", &palette); assert_eq!(lines.len(), 2); assert_eq!(line_text(&lines[0]), "● update ready"); assert_eq!( line_text(&lines[1]), "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)); } #[test] fn release_notes_fenced_code_blocks_render_as_preformatted_lines() { let palette = Palette::catppuccin(); let lines = release_notes_lines( "### Fixed\n```bash\njust check\n- not a bullet\n```\n- after", &palette, ); assert_eq!(lines.len(), 4); assert_eq!(line_text(&lines[0].1), " FIXED"); assert_eq!(line_text(&lines[1].1), "▏ just check"); assert_eq!(line_text(&lines[2].1), "▏ - not a bullet"); assert_eq!(line_text(&lines[3].1), " • after"); assert_eq!(lines[1].1.spans[0].style.fg, Some(palette.accent)); assert_eq!(lines[1].1.spans[0].style.bg, Some(palette.surface1)); assert_eq!(lines[1].1.spans[1].style.bg, Some(palette.surface1)); assert_eq!(lines[1].1.spans[2].style.bg, Some(palette.surface1)); } #[test] fn release_notes_fenced_code_blocks_preserve_blank_lines() { let palette = Palette::catppuccin(); let lines = release_notes_lines("```\nfirst\n\nsecond\n```", &palette); assert_eq!(lines.len(), 3); assert_eq!(line_text(&lines[0].1), "▏ first"); assert_eq!(line_text(&lines[1].1), "▏ "); assert_eq!(line_text(&lines[2].1), "▏ second"); } #[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.contains(&("unset".to_string(), "previous workspace"))); assert!(workspace_tab.contains(&("unset".to_string(), "next workspace"))); assert!(workspace_tab.contains(&("unset".to_string(), "previous agent"))); assert!(workspace_tab.contains(&("unset".to_string(), "next agent"))); assert!(workspace_tab.contains(&("unset".to_string(), "rename tab"))); assert!(workspace_tab.contains(&("unset".to_string(), "previous tab"))); assert!(workspace_tab.contains(&("unset".to_string(), "next tab"))); assert!(workspace_tab.contains(&("unset".to_string(), "close tab"))); assert!(panes.contains(&("unset".to_string(), "focus pane left"))); assert!(panes.contains(&("unset".to_string(), "focus pane down"))); assert!(panes.contains(&("unset".to_string(), "focus pane up"))); assert!(panes.contains(&("unset".to_string(), "focus pane right"))); } }