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 /src/app | |
| parent | 6885c8873971d79332376137a14572f8440ef42e (diff) | |
feat: add mobile terminal layout
Diffstat (limited to 'src/app')
| -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 |
7 files changed, 408 insertions, 10 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(), }, |
