diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-05 18:32:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-05 18:32:42 +0300 |
| commit | f7b0ee2f758440db3a3ff4cc727f879b420255d7 (patch) | |
| tree | 764149deada51f6196f3eecda46374ec45ff097a | |
| parent | 6885c8873971d79332376137a14572f8440ef42e (diff) | |
feat: add mobile terminal layout
| -rw-r--r-- | src/app/actions.rs | 36 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 335 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 18 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 10 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 4 | ||||
| -rw-r--r-- | src/app/state.rs | 14 | ||||
| -rw-r--r-- | src/ui.rs | 129 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 884 |
9 files changed, 1408 insertions, 23 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 0b187fed..3b904a89 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -8,7 +8,7 @@ use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; use crate::pane::EffectiveStateChange; -use super::state::{AppState, Mode, ToastKind, ToastNotification}; +use super::state::{AppState, Mode, ToastKind, ToastNotification, ViewLayout}; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { matches!(new_state, AgentState::Idle) @@ -135,7 +135,16 @@ impl AppState { } pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) { - if self.sidebar_collapsed || idx >= self.workspaces.len() { + if idx >= self.workspaces.len() { + return; + } + + if self.view.layout == ViewLayout::Mobile && self.mode == Mode::Navigate { + self.ensure_mobile_workspace_visible(idx); + return; + } + + if self.sidebar_collapsed { return; } @@ -164,6 +173,25 @@ impl AppState { } } + fn ensure_mobile_workspace_visible(&mut self, idx: usize) { + let viewport = crate::ui::mobile_switcher_areas(self).viewport; + if viewport.height == 0 { + return; + } + + let row_range = crate::ui::mobile_switcher_workspace_doc_range(idx); + let visible_start = self.mobile_switcher_scroll; + let visible_end = visible_start.saturating_add(viewport.height as usize); + if row_range.start < visible_start { + self.mobile_switcher_scroll = row_range.start; + } else if row_range.end > visible_end { + self.mobile_switcher_scroll = row_range.end.saturating_sub(viewport.height as usize); + } + self.mobile_switcher_scroll = self + .mobile_switcher_scroll + .min(crate::ui::mobile_switcher_max_scroll(self)); + } + pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { let Some(ws) = self.workspaces.get_mut(ws_idx) else { @@ -747,10 +775,10 @@ mod tests { #[test] fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() { let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 26, 14)); + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); state.switch_workspace(7); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 26, 14)); + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); assert!(state .view diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 79237f52..185393f0 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -6,7 +6,7 @@ use tracing::warn; use crate::{ app::state::{ AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, - MenuListState, Mode, TabPressState, WorkspacePressState, + MenuListState, Mode, TabPressState, ViewLayout, WorkspacePressState, }, layout::{PaneInfo, SplitBorder}, selection::Selection, @@ -35,7 +35,8 @@ impl AppState { return self.handle_settings_mouse(mouse); } - let launcher_enabled = !self.sidebar_collapsed + let launcher_enabled = self.view.layout != ViewLayout::Mobile + && !self.sidebar_collapsed && matches!( self.mode, Mode::Terminal @@ -84,6 +85,10 @@ impl AppState { return None; } + if self.view.layout == ViewLayout::Mobile && self.handle_mobile_mouse(mouse) { + return None; + } + let sidebar = self.view.sidebar_rect; let in_sidebar = mouse.column >= sidebar.x && mouse.column < sidebar.x + sidebar.width @@ -676,6 +681,88 @@ impl AppState { None } + fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> bool { + if self.mode == Mode::Navigate { + match mouse.kind { + MouseEventKind::ScrollUp => { + self.scroll_mobile_switcher_at(mouse.column, mouse.row, -1); + return true; + } + MouseEventKind::ScrollDown => { + self.scroll_mobile_switcher_at(mouse.column, mouse.row, 1); + return true; + } + MouseEventKind::Down(MouseButton::Left) => {} + _ => return true, + } + } else if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + return false; + } + + if self.mode != Mode::Navigate { + if !matches!(self.mode, Mode::Terminal | Mode::Resize) { + return false; + } + if rect_contains(self.view.mobile_menu_hit_area, mouse.column, mouse.row) { + self.mobile_switcher_scroll = 0; + self.mode = Mode::Navigate; + return true; + } + return false; + } + + let areas = crate::ui::mobile_switcher_areas(self); + if rect_contains(areas.close, mouse.column, mouse.row) { + self.mode = Mode::Terminal; + return true; + } + + match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) { + Some(crate::ui::MobileSwitcherTarget::NewWorkspace) => { + self.request_new_workspace = true; + } + Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => { + self.switch_workspace(ws_idx); + self.mode = Mode::Terminal; + } + Some(crate::ui::MobileSwitcherTarget::NewTab) => { + open_new_tab_dialog(self); + } + Some(crate::ui::MobileSwitcherTarget::Tab(tab_idx)) => { + self.switch_tab(tab_idx); + self.mode = Mode::Terminal; + } + Some(crate::ui::MobileSwitcherTarget::Agent { + ws_idx, + tab_idx, + pane_id, + }) => { + self.switch_workspace(ws_idx); + self.switch_tab(tab_idx); + self.focus_pane(pane_id); + self.mode = Mode::Terminal; + } + Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => { + let actions = global_menu_actions(self); + if let Some(action) = actions.get(action_idx).copied() { + apply_global_menu_action(self, action); + } + } + None => {} + } + + true + } + + fn scroll_mobile_switcher_at(&mut self, _col: u16, _row: u16, delta: i16) { + let max_scroll = crate::ui::mobile_switcher_max_scroll(self); + apply_scroll( + &mut self.mobile_switcher_scroll, + delta.saturating_mul(2), + max_scroll, + ); + } + pub(super) fn screen_rect(&self) -> Rect { let sidebar = self.view.sidebar_rect; let terminal = self.view.terminal_area; @@ -1090,6 +1177,23 @@ pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRoutin } } +fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { + rect.width > 0 + && rect.height > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height +} + +fn apply_scroll(scroll: &mut usize, delta: i16, max_scroll: usize) { + if delta.is_negative() { + *scroll = scroll.saturating_sub(delta.unsigned_abs() as usize); + } else { + *scroll = scroll.saturating_add(delta as usize).min(max_scroll); + } +} + #[cfg(test)] mod tests { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; @@ -1100,7 +1204,7 @@ mod tests { }; use super::*; use crate::{ - app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode}, + app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout}, workspace::Workspace, }; @@ -1369,6 +1473,231 @@ mod tests { } #[test] + fn mobile_switch_button_opens_switcher_and_workspace_row_switches_workspace() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); + assert_eq!(app.state.view.layout, ViewLayout::Mobile); + + let switch = app.state.view.mobile_menu_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + switch.x + 1, + switch.y + 1, + )); + + assert_eq!(app.state.mode, Mode::Navigate); + + let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + viewport.x + 2, + viewport.y + 4, + )); + + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn mobile_workspace_panel_scroll_reaches_extra_workspaces() { + let mut app = app_for_mouse_test(); + app.state.workspaces = (0..12) + .map(|idx| Workspace::test_new(&format!("ws-{idx}"))) + .collect(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); + let switch = app.state.view.mobile_menu_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + switch.x + 1, + switch.y + 1, + )); + assert_eq!(app.state.mode, Mode::Navigate); + + let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; + app.handle_mouse(mouse( + MouseEventKind::ScrollDown, + viewport.x + 2, + viewport.y, + )); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); + assert_eq!(app.state.mobile_switcher_scroll, 2); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + viewport.x + 2, + viewport.y + 2, + )); + + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn mobile_global_scroll_reaches_tabs_and_switches_tab() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("one"); + ws.test_add_tab(Some("two")); + ws.test_add_tab(Some("three")); + ws.test_add_tab(Some("four")); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 12)); + let switch = app.state.view.mobile_menu_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + switch.x + 1, + switch.y + 1, + )); + + let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; + + app.handle_mouse(mouse( + MouseEventKind::ScrollDown, + viewport.x + 2, + viewport.y, + )); + app.handle_mouse(mouse( + MouseEventKind::ScrollDown, + viewport.x + 2, + viewport.y, + )); + assert_eq!(app.state.mobile_switcher_scroll, 4); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + viewport.x + 2, + viewport.y + 4, + )); + assert_eq!(app.state.workspaces[0].active_tab, 2); + } + + #[test] + fn mobile_switcher_action_rows_create_workspace_and_open_tab_dialog() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("one"); + ws.test_add_tab(Some("logs")); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); + let switch = app.state.view.mobile_menu_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + switch.x + 1, + switch.y + 1, + )); + let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + viewport.x + 2, + viewport.y + 1, + )); + assert!(app.state.request_new_workspace); + + app.state.request_new_workspace = false; + app.state.mode = Mode::Navigate; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + viewport.x + 2, + viewport.y + 5, + )); + assert_eq!(app.state.mode, Mode::RenameTab); + assert!(app.state.creating_new_tab); + } + + #[test] + fn mobile_switcher_swallows_non_left_mouse_events() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); + let switch = app.state.view.mobile_menu_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + switch.x + 1, + switch.y + 1, + )); + assert_eq!(app.state.mode, Mode::Navigate); + + let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + viewport.x + 2, + viewport.y + 2, + )); + + assert_eq!(app.state.mode, Mode::Navigate); + assert!(app.state.context_menu.is_none()); + } + + #[test] + fn mobile_switch_button_does_not_bypass_rename_modal() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::RenameTab; + app.state.creating_new_tab = true; + app.state.name_input = "new tab".into(); + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); + let switch = app.state.view.mobile_menu_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + switch.x + 1, + switch.y + 1, + )); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(!app.state.creating_new_tab); + assert!(!app.state.request_new_tab); + } + + #[test] + fn mobile_switcher_close_returns_to_terminal() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); + let switch = app.state.view.mobile_menu_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + switch.x + 1, + switch.y + 1, + )); + assert_eq!(app.state.mode, Mode::Navigate); + + let close = crate::ui::mobile_switcher_areas(&app.state).close; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + close.x + 1, + close.y, + )); + + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { let input_state = crate::pane::InputState { alternate_screen: true, diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index d55eedea..546228cc 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -650,6 +650,24 @@ mod tests { } #[test] + fn mobile_workspace_keyboard_navigation_keeps_selected_row_visible() { + let mut state = state_with_workspaces(&["a", "b", "c", "d"]); + state.active = Some(0); + state.selected = 0; + state.mode = Mode::Navigate; + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 44, 8)); + assert_eq!(state.mobile_switcher_scroll, 0); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + ); + + assert_eq!(state.selected, 1); + assert_eq!(state.mobile_switcher_scroll, 1); + } + + #[test] fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { let mut state = state_with_workspaces(&["test"]); state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT)); diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index f3ae2b06..fe8f14c1 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -1,6 +1,6 @@ use ratatui::layout::Rect; -use crate::app::state::{AppState, Mode}; +use crate::app::state::{AppState, Mode, ViewLayout}; use super::ScrollbarClickTarget; @@ -170,6 +170,10 @@ impl AppState { } pub(crate) fn global_launcher_rect(&self) -> Rect { + if self.view.layout == ViewLayout::Mobile { + return self.view.mobile_menu_hit_area; + } + let footer = self.sidebar_footer_rect(); let width = if self.update_available.is_some() { 8 @@ -983,7 +987,7 @@ mod tests { app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20)); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); let right = app.state.view.tab_scroll_right_hit_area; assert!(right.width > 0); @@ -1019,7 +1023,7 @@ mod tests { app.state.selected = 0; app.state.tab_scroll = usize::MAX; app.state.tab_scroll_follow_active = false; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20)); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); let last_idx = app.state.workspaces[0].tabs.len() - 1; let target = app.state.view.tab_hit_areas[last_idx]; diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 910a49bd..bbfcd323 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -46,6 +46,7 @@ impl App { } if self.state.is_prefix(&key_event) { + self.state.mobile_switcher_scroll = 0; self.state.mode = Mode::Navigate; return None; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1a287a38..35f502a5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -294,7 +294,9 @@ impl App { agent_panel_scroll: 0, tab_scroll: 0, tab_scroll_follow_active: true, + mobile_switcher_scroll: 0, view: state::ViewState { + layout: state::ViewLayout::Desktop, sidebar_rect: Rect::default(), workspace_card_areas: Vec::new(), tab_bar_rect: Rect::default(), @@ -303,6 +305,8 @@ impl App { tab_scroll_right_hit_area: Rect::default(), new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), + mobile_header_rect: Rect::default(), + mobile_menu_hit_area: Rect::default(), pane_infos: Vec::new(), split_borders: Vec::new(), }, diff --git a/src/app/state.rs b/src/app/state.rs index facb5187..831d8b7d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -329,7 +329,14 @@ pub struct WorkspaceCardArea { /// Computed view geometry — derived from AppState + terminal size. /// Updated before each render, consumed by render and mouse handling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewLayout { + Desktop, + Mobile, +} + pub struct ViewState { + pub layout: ViewLayout, pub sidebar_rect: Rect, pub workspace_card_areas: Vec<WorkspaceCardArea>, pub tab_bar_rect: Rect, @@ -338,6 +345,8 @@ pub struct ViewState { pub tab_scroll_right_hit_area: Rect, pub new_tab_hit_area: Rect, pub terminal_area: Rect, + pub mobile_header_rect: Rect, + pub mobile_menu_hit_area: Rect, pub pane_infos: Vec<PaneInfo>, pub split_borders: Vec<SplitBorder>, } @@ -618,6 +627,7 @@ pub struct AppState { pub agent_panel_scroll: usize, pub tab_scroll: usize, pub tab_scroll_follow_active: bool, + pub mobile_switcher_scroll: usize, // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, pub(crate) drag: Option<DragState>, @@ -746,7 +756,9 @@ impl AppState { agent_panel_scroll: 0, tab_scroll: 0, tab_scroll_follow_active: true, + mobile_switcher_scroll: 0, view: ViewState { + layout: ViewLayout::Desktop, sidebar_rect: Rect::default(), workspace_card_areas: Vec::new(), tab_bar_rect: Rect::default(), @@ -755,6 +767,8 @@ impl AppState { tab_scroll_right_hit_area: Rect::default(), new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), + mobile_header_rect: Rect::default(), + mobile_menu_hit_area: Rect::default(), pane_infos: Vec::new(), split_borders: Vec::new(), }, @@ -8,6 +8,7 @@ use ratatui::{ mod dialogs; mod keybind_help; mod menus; +mod mobile; mod onboarding; mod panes; mod release_notes; @@ -24,6 +25,10 @@ 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, + 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}; @@ -53,9 +58,14 @@ pub(crate) use self::{ }; 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, + }, 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 @@ -87,6 +97,11 @@ pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect } fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { + if is_mobile_width(area) { + compute_mobile_view(app, area, resize_panes); + return; + } + let sidebar_w = if app.sidebar_collapsed { COLLAPSED_WIDTH } else { @@ -146,6 +161,7 @@ fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { let pane_infos = compute_pane_infos(app, terminal_area, resize_panes); app.view = crate::app::ViewState { + layout: ViewLayout::Desktop, sidebar_rect: sidebar_area, workspace_card_areas, tab_bar_rect, @@ -154,6 +170,50 @@ fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { 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(), + pane_infos, + split_borders, + }; +} + +fn compute_mobile_view(app: &mut AppState, area: Rect, resize_panes: bool) { + 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); + let header_hits = compute_mobile_header_hit_areas(app, header_rect); + + 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, pane_infos, split_borders, }; @@ -165,17 +225,24 @@ pub fn render(app: &AppState, frame: &mut Frame) { let tab_bar_area = app.view.tab_bar_rect; let terminal_area = app.view.terminal_area; - if app.sidebar_collapsed { + 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); } - render_tab_bar(app, frame, tab_bar_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::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), @@ -195,13 +262,23 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_config_diagnostic(frame, terminal_area, message, &app.palette); } if let Some(toast) = &app.toast { - render_toast_notification( - frame, - terminal_area, - toast, - has_config_diagnostic, - &app.palette, - ); + 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, + ); + } } } @@ -235,7 +312,11 @@ mod tests { use super::release_notes::{release_notes_lines, release_notes_preview_lines}; use super::scrollbar::scrollbar_thumb; use super::*; - use crate::{app::state::Palette, layout::PaneInfo, workspace::Workspace}; + use crate::{ + app::state::{Palette, ViewLayout}, + layout::PaneInfo, + workspace::Workspace, + }; use ratatui::{backend::TestBackend, Terminal}; use ratatui::{style::Color, text::Line}; @@ -279,6 +360,28 @@ mod tests { } #[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 collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() { let mut app = crate::app::state::AppState::test_new(); app.sidebar_collapsed = true; @@ -423,7 +526,7 @@ mod tests { 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"] { + for name in ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"] { ws.test_add_tab(Some(name)); } @@ -434,7 +537,7 @@ mod tests { app.tab_scroll_follow_active = false; app.tab_scroll = 2; - compute_view(&mut app, Rect::new(0, 0, 44, 20)); + 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); @@ -479,7 +582,7 @@ mod tests { app.tab_scroll_follow_active = false; app.tab_scroll = usize::MAX; - compute_view(&mut app, Rect::new(0, 0, 52, 20)); + 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); diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs new file mode 100644 index 00000000..79f97136 --- /dev/null +++ b/src/ui/mobile.rs @@ -0,0 +1,884 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; + +use super::sidebar::agent_panel_entries; +use super::status::{agent_icon, state_dot}; +use crate::app::state::{Palette, ToastKind, ToastNotification}; +use crate::app::AppState; +use crate::detect::AgentState; +use crate::layout::PaneId; + +pub(crate) const MOBILE_WIDTH_THRESHOLD: u16 = 64; +const SWITCH_BUTTON_WIDTH: u16 = 10; + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct MobileHeaderHitAreas { + pub menu: Rect, +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct MobileSwitcherAreas { + pub close: Rect, + pub viewport: Rect, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MobileSwitcherTarget { + NewWorkspace, + Workspace(usize), + NewTab, + Tab(usize), + Agent { + ws_idx: usize, + tab_idx: usize, + pane_id: PaneId, + }, + Menu(usize), +} + +pub(crate) fn is_mobile_width(area: Rect) -> bool { + area.width > 0 && area.width <= MOBILE_WIDTH_THRESHOLD +} + +pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas { + if area.width == 0 || area.height == 0 { + return MobileHeaderHitAreas::default(); + } + + let width = SWITCH_BUTTON_WIDTH.min(area.width); + let switch = Rect::new( + area.x + area.width.saturating_sub(width), + area.y, + width, + area.height, + ); + + MobileHeaderHitAreas { menu: switch } +} + +pub(crate) fn mobile_switcher_areas(app: &AppState) -> MobileSwitcherAreas { + let screen = mobile_screen_rect(app); + if screen.width == 0 || screen.height <= 2 { + return MobileSwitcherAreas::default(); + } + + let header_h = screen.height.min(2); + let close_w = 10u16.min(screen.width); + let close = Rect::new( + screen.x + screen.width.saturating_sub(close_w), + screen.y, + close_w, + header_h, + ); + let viewport = Rect::new( + screen.x, + screen.y + header_h + 1, + screen.width, + screen.height.saturating_sub(header_h + 1), + ); + + MobileSwitcherAreas { close, viewport } +} + +pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_height: u16) -> usize { + mobile_switcher_content_height(app).saturating_sub(viewport_height as usize) +} + +pub(crate) fn mobile_switcher_workspace_doc_range(idx: usize) -> std::ops::Range<usize> { + let start = 2 + idx * 2; + start..start + 2 +} + +pub(crate) fn mobile_switcher_max_scroll(app: &AppState) -> usize { + mobile_switcher_max_scroll_for_height(app, mobile_switcher_areas(app).viewport.height) +} + +pub(crate) fn mobile_switcher_target_at( + app: &AppState, + col: u16, + row: u16, +) -> Option<MobileSwitcherTarget> { + let areas = mobile_switcher_areas(app); + let content = inset_for_left_scrollbar(areas.viewport); + if !rect_contains(content, col, row) { + return None; + } + + let doc_row = app + .mobile_switcher_scroll + .saturating_add(row.saturating_sub(areas.viewport.y) as usize); + let mut cursor = 0usize; + + cursor += 1; // spaces title + if doc_row == cursor { + return Some(MobileSwitcherTarget::NewWorkspace); + } + cursor += 1; + let spaces_end = cursor + app.workspaces.len() * 2; + if doc_row >= cursor && doc_row < spaces_end { + return Some(MobileSwitcherTarget::Workspace((doc_row - cursor) / 2)); + } + cursor = spaces_end; + + if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { + cursor += 1; // tabs title + if doc_row == cursor { + return Some(MobileSwitcherTarget::NewTab); + } + cursor += 1; + let tabs_end = cursor + ws.tabs.len(); + if doc_row >= cursor && doc_row < tabs_end { + return Some(MobileSwitcherTarget::Tab(doc_row - cursor)); + } + cursor = tabs_end; + } + + cursor += 1; // agents title + let agents = agent_panel_entries(app); + let agents_end = cursor + agents.len() * 2; + if doc_row >= cursor && doc_row < agents_end { + let idx = (doc_row - cursor) / 2; + return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent { + ws_idx: entry.ws_idx, + tab_idx: entry.tab_idx, + pane_id: entry.pane_id, + }); + } + cursor = agents_end; + + cursor += 1; // menu title + let menu_idx = doc_row.checked_sub(cursor)?; + (menu_idx < app.global_menu_labels().len()).then_some(MobileSwitcherTarget::Menu(menu_idx)) +} + +pub(crate) fn render_mobile_header(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + + let p = &app.palette; + fill_rect(frame, area, Style::default().bg(p.panel_bg)); + + let switch = app.view.mobile_menu_hit_area; + let status_w = switch.x.saturating_sub(area.x).saturating_sub(1); + let status = Rect::new(area.x, area.y, status_w, area.height); + + render_header_status(app, frame, status); + render_switch_button(app, frame, switch); +} + +pub(crate) fn render_mobile_toast_banner( + frame: &mut Frame, + area: Rect, + toast: &ToastNotification, + offset_for_warning: bool, + p: &Palette, +) { + if area.width == 0 || area.height == 0 { + return; + } + + let dot_color = match toast.kind { + ToastKind::NeedsAttention => p.red, + ToastKind::Finished => p.blue, + ToastKind::UpdateInstalled => p.accent, + }; + let y = area.y + + area + .height + .saturating_sub(1 + if offset_for_warning { 1 } else { 0 }); + let banner = Rect::new(area.x, y, area.width, 1); + let bg = p.surface0; + + frame.render_widget(Clear, banner); + fill_rect(frame, banner, Style::default().bg(bg)); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled("●", Style::default().fg(dot_color).bg(bg)), + Span::styled(" ", Style::default().bg(bg)), + Span::styled( + mobile_toast_title(toast), + Style::default() + .fg(p.text) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · ", Style::default().fg(p.overlay0).bg(bg)), + Span::styled(&toast.context, Style::default().fg(p.overlay0).bg(bg)), + ])), + banner, + ); +} + +pub(crate) fn render_mobile_panel(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + + let p = &app.palette; + frame.render_widget(Clear, area); + fill_rect(frame, area, Style::default().bg(p.panel_bg)); + + let areas = mobile_switcher_areas(app); + frame.render_widget( + Paragraph::new(" switch").style( + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ), + Rect::new(area.x, area.y, areas.close.x.saturating_sub(area.x), 1), + ); + render_close_button(app, frame, areas.close); + + if area.height > areas.close.height { + draw_horizontal_rule( + frame, + Rect::new(area.x, area.y + areas.close.height, area.width, 1), + p, + ); + } + + render_mobile_switcher_content(app, frame, areas.viewport); +} + +fn render_header_status(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let p = &app.palette; + let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else { + frame.render_widget(Paragraph::new(" no workspace"), area); + return; + }; + + let (state, seen) = ws.aggregate_state(); + let (dot, dot_style) = if matches!(state, AgentState::Working) { + ( + super::spinner_frame(app.spinner_tick), + Style::default().fg(p.yellow), + ) + } else { + state_dot(state, seen, p) + }; + let tab_label = format!("tab {}/{}", ws.active_tab + 1, ws.tabs.len()); + let row1 = Rect::new(area.x, area.y, area.width, 1); + let tab_w = (tab_label.chars().count() as u16 + 1).min(area.width); + let name_w = area.width.saturating_sub(tab_w); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled(dot, dot_style.bg(p.panel_bg)), + Span::raw(" "), + Span::styled( + truncate(&ws.display_name(), name_w.saturating_sub(4) as usize), + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ), + ])), + Rect::new(row1.x, row1.y, name_w, 1), + ); + frame.render_widget( + Paragraph::new(tab_label) + .style(Style::default().fg(p.overlay1).bg(p.panel_bg)) + .alignment(Alignment::Right), + Rect::new(row1.x + name_w, row1.y, tab_w, 1), + ); + + if area.height > 1 { + frame.render_widget( + Paragraph::new(agent_priority_label(app)) + .style(Style::default().fg(p.overlay1).bg(p.panel_bg)), + Rect::new(area.x, area.y + 1, area.width, 1), + ); + } +} + +fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let p = &app.palette; + fill_rect(frame, area, Style::default().bg(p.surface0)); + for y in area.y..area.y + area.height { + frame.buffer_mut()[(area.x, y)] + .set_symbol("│") + .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); + } + let label_y = if area.height > 1 { area.y + 1 } else { area.y }; + frame.render_widget( + Paragraph::new("switch") + .style( + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center), + Rect::new(area.x + 1, label_y, area.width.saturating_sub(1), 1), + ); +} + +fn render_close_button(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let p = &app.palette; + fill_rect(frame, area, Style::default().bg(p.surface0)); + for y in area.y..area.y + area.height { + frame.buffer_mut()[(area.x, y)] + .set_symbol("│") + .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); + } + frame.render_widget( + Paragraph::new("close") + .style( + Style::default() + .fg(p.overlay1) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center), + Rect::new(area.x + 1, area.y, area.width.saturating_sub(1), 1), + ); + if area.height > 1 { + frame.render_widget( + Paragraph::new("×") + .style( + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center), + Rect::new(area.x + 1, area.y + 1, area.width.saturating_sub(1), 1), + ); + } +} + +fn mobile_switcher_content_height(app: &AppState) -> usize { + let spaces_h = 2 + app.workspaces.len() * 2; + let tabs_h = app + .active + .and_then(|idx| app.workspaces.get(idx)) + .map(|ws| 2 + ws.tabs.len()) + .unwrap_or(0); + let agents_h = 1 + agent_panel_entries(app).len() * 2; + let menu_h = 1 + app.global_menu_labels().len(); + spaces_h + tabs_h + agents_h + menu_h +} + +fn render_mobile_switcher_content(app: &AppState, frame: &mut Frame, viewport: Rect) { + if viewport.width == 0 || viewport.height == 0 { + return; + } + + let p = &app.palette; + let total_height = mobile_switcher_content_height(app); + render_left_scrollbar( + frame, + viewport, + total_height, + viewport.height as usize, + app.mobile_switcher_scroll, + p, + ); + let content = inset_for_left_scrollbar(viewport); + if content == Rect::default() { + return; + } + + let mut doc_y = 0usize; + render_section_title_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "spaces", + p, + ); + doc_y += 1; + render_action_row_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "+ new workspace", + p, + ); + doc_y += 1; + for (idx, ws) in app.workspaces.iter().enumerate() { + let active = Some(idx) == app.active; + let selected = idx == app.selected; + let bg = mobile_item_bg(selected, active, p); + let (state, seen) = ws.aggregate_state(); + let (dot, dot_style) = state_dot(state, seen, p); + let title = Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled(dot, dot_style.bg(bg)), + Span::styled(" ", Style::default().bg(bg)), + Span::styled( + truncate(&ws.display_name(), content.width.saturating_sub(5) as usize), + Style::default() + .fg(p.text) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + ]); + let detail = format!( + " {} · tab {}/{}", + ws.branch().unwrap_or_else(|| "shell".into()), + ws.active_tab + 1, + ws.tabs.len() + ); + render_two_line_item( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + bg, + title, + truncate(&detail, content.width as usize), + p.overlay0, + ); + doc_y += 2; + } + + if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { + render_section_title_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "tabs", + p, + ); + doc_y += 1; + render_action_row_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "+ new tab", + p, + ); + doc_y += 1; + for (idx, tab) in ws.tabs.iter().enumerate() { + let active = idx == ws.active_tab; + let bg = mobile_item_bg(false, active, p); + let label = if tab.is_auto_named() { + format!("tab {}", idx + 1) + } else { + format!("{} · {}", idx + 1, tab.display_name()) + }; + let title = Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled( + truncate(&label, content.width.saturating_sub(3) as usize), + Style::default() + .fg(p.text) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + ]); + render_one_line_item( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + bg, + title, + ); + doc_y += 1; + } + } + + let focused_agent = app.active.and_then(|ws_idx| { + let ws = app.workspaces.get(ws_idx)?; + ws.focused_pane_id() + .map(|pane_id| (ws_idx, ws.active_tab, pane_id)) + }); + let entries = agent_panel_entries(app); + render_section_title_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "agents", + p, + ); + doc_y += 1; + for entry in &entries { + let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| { + entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id + }); + let bg = mobile_item_bg(false, active, p); + let (icon, icon_style) = agent_icon(entry.state, entry.seen, app.spinner_tick, p); + let title = Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled(icon, icon_style.bg(bg)), + Span::styled(" ", Style::default().bg(bg)), + Span::styled( + truncate( + &entry.primary_label, + content.width.saturating_sub(5) as usize, + ), + Style::default() + .fg(p.text) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + ]); + let detail = format!( + " {} · {}", + super::status::state_label(entry.state, entry.seen), + entry.agent_label.as_deref().unwrap_or("agent") + ); + render_two_line_item( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + bg, + title, + truncate(&detail, content.width as usize), + p.overlay0, + ); + doc_y += 2; + } + + render_section_title_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "menu", + p, + ); + doc_y += 1; + for label in app.global_menu_labels() { + if let Some(y) = visible_y(viewport, app.mobile_switcher_scroll, doc_y) { + frame.render_widget( + Paragraph::new(format!(" {label}")) + .style(Style::default().fg(p.overlay1).bg(p.panel_bg)), + Rect::new(content.x, y, content.width, 1), + ); + } + doc_y += 1; + } +} + +fn render_section_title_at( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + scroll: usize, + title: &str, + p: &Palette, +) { + let Some(y) = visible_y(viewport, scroll, doc_y) else { + return; + }; + render_section_title( + frame, + Rect::new(content.x, y, content.width.saturating_sub(1), 1), + title, + p, + ); +} + +fn render_action_row_at( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + scroll: usize, + label: &str, + p: &Palette, +) { + let Some(y) = visible_y(viewport, scroll, doc_y) else { + return; + }; + render_action_row(frame, Rect::new(content.x, y, content.width, 1), label, p); +} + +fn render_one_line_item( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + scroll: usize, + bg: ratatui::style::Color, + title: Line<'_>, +) { + fill_visible_doc_rect( + frame, + viewport, + content, + doc_y, + 1, + Style::default().bg(bg), + scroll, + ); + if let Some(y) = visible_y(viewport, scroll, doc_y) { + frame.render_widget( + Paragraph::new(title), + Rect::new(content.x, y, content.width, 1), + ); + } +} + +fn render_two_line_item( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + scroll: usize, + bg: ratatui::style::Color, + title: Line<'_>, + detail: String, + detail_fg: ratatui::style::Color, +) { + fill_visible_doc_rect( + frame, + viewport, + content, + doc_y, + 2, + Style::default().bg(bg), + scroll, + ); + if let Some(y) = visible_y(viewport, scroll, doc_y) { + frame.render_widget( + Paragraph::new(title), + Rect::new(content.x, y, content.width, 1), + ); + } + if let Some(y) = visible_y(viewport, scroll, doc_y + 1) { + frame.render_widget( + Paragraph::new(detail).style(Style::default().fg(detail_fg).bg(bg)), + Rect::new(content.x, y, content.width, 1), + ); + } +} + +fn visible_y(viewport: Rect, scroll: usize, doc_y: usize) -> Option<u16> { + let offset = doc_y.checked_sub(scroll)?; + (offset < viewport.height as usize).then_some(viewport.y + offset as u16) +} + +fn fill_visible_doc_rect( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + height: usize, + style: Style, + scroll: usize, +) { + for offset in 0..height { + if let Some(y) = visible_y(viewport, scroll, doc_y + offset) { + fill_rect(frame, Rect::new(content.x, y, content.width, 1), style); + } + } +} + +fn mobile_item_bg(selected: bool, active: bool, p: &Palette) -> ratatui::style::Color { + if selected { + p.surface0 + } else if active { + p.surface_dim + } else { + p.panel_bg + } +} + +fn inset_for_left_scrollbar(area: Rect) -> Rect { + if area.width <= 1 { + return Rect::default(); + } + Rect::new(area.x + 1, area.y, area.width - 1, area.height) +} + +fn render_left_scrollbar( + frame: &mut Frame, + area: Rect, + total_rows: usize, + visible_rows: usize, + scroll: usize, + p: &Palette, +) { + if area.width == 0 || area.height == 0 || visible_rows == 0 || total_rows <= visible_rows { + return; + } + + let track = Rect::new(area.x, area.y, 1, area.height); + let max_scroll = total_rows.saturating_sub(visible_rows); + let thumb_len = ((track.height as usize * visible_rows).div_ceil(total_rows)) + .max(1) + .min(track.height as usize) as u16; + let travel = track.height.saturating_sub(thumb_len); + let thumb_top = if max_scroll == 0 { + track.y + } else { + track.y + ((travel as usize * scroll.min(max_scroll)) / max_scroll) as u16 + }; + + for y in track.y..track.y + track.height { + let is_thumb = y >= thumb_top && y < thumb_top + thumb_len; + frame.buffer_mut()[(track.x, y)] + .set_symbol(if is_thumb { "▌" } else { "│" }) + .set_style( + Style::default() + .fg(if is_thumb { p.accent } else { p.surface_dim }) + .bg(p.panel_bg), + ); + } +} + +fn render_section_title(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { + frame.render_widget( + Paragraph::new(format!(" {title} ")).style( + Style::default() + .fg(p.overlay1) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ), + Rect::new(area.x, area.y, area.width, 1), + ); +} + +fn render_action_row(frame: &mut Frame, area: Rect, label: &str, p: &Palette) { + if area.width == 0 || area.height == 0 { + return; + } + frame.render_widget( + Paragraph::new(format!(" {label}")).style( + Style::default() + .fg(p.accent) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ), + area, + ); +} + +fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { + rect.width > 0 + && rect.height > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height +} + +fn mobile_screen_rect(app: &AppState) -> Rect { + let header = app.view.mobile_header_rect; + let terminal = app.view.terminal_area; + let x = header.x.min(terminal.x); + let y = header.y.min(terminal.y); + let right = (header.x + header.width).max(terminal.x + terminal.width); + let bottom = (header.y + header.height).max(terminal.y + terminal.height); + Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) +} + +fn agent_priority_label(app: &AppState) -> String { + let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else { + return " no agents".to_string(); + }; + let mut blocked = 0usize; + let mut working = 0usize; + let mut done = 0usize; + for detail in ws.pane_details() { + match (detail.state, detail.seen) { + (AgentState::Blocked, _) => blocked += 1, + (AgentState::Working, _) => working += 1, + (AgentState::Idle, false) => done += 1, + _ => {} + } + } + if blocked > 0 { + format!(" ◉ {blocked} blocked") + } else if working > 0 { + format!(" {working} working") + } else if done > 0 { + format!(" {done} done") + } else { + " all idle".to_string() + } +} + +fn mobile_toast_title(toast: &ToastNotification) -> String { + match toast.kind { + ToastKind::NeedsAttention => toast + .title + .strip_suffix(" needs attention") + .map(|agent| format!("{agent} waiting")) + .unwrap_or_else(|| toast.title.clone()), + ToastKind::Finished => toast + .title + .strip_suffix(" finished") + .map(|agent| format!("{agent} done")) + .unwrap_or_else(|| toast.title.clone()), + ToastKind::UpdateInstalled => "update ready".to_string(), + } +} + +fn fill_rect(frame: &mut Frame, area: Rect, style: Style) { + if area.width == 0 || area.height == 0 { + return; + } + let buf = frame.buffer_mut(); + for y in area.y..area.y + area.height { + for x in area.x..area.x + area.width { + buf[(x, y)].set_symbol(" "); + buf[(x, y)].set_style(style); + } + } +} + +fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) { + if area.width == 0 || area.height == 0 { + return; + } + let buf = frame.buffer_mut(); + for x in area.x..area.x + area.width { + buf[(x, area.y)] + .set_symbol("─") + .set_style(Style::default().fg(p.surface_dim).bg(p.panel_bg)); + } +} + +fn truncate(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}…") +} |
