aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-05 18:32:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-05 18:32:42 +0300
commitf7b0ee2f758440db3a3ff4cc727f879b420255d7 (patch)
tree764149deada51f6196f3eecda46374ec45ff097a /src/app
parent6885c8873971d79332376137a14572f8440ef42e (diff)
feat: add mobile terminal layout
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs36
-rw-r--r--src/app/input/mouse.rs335
-rw-r--r--src/app/input/navigate.rs18
-rw-r--r--src/app/input/sidebar.rs10
-rw-r--r--src/app/input/terminal.rs1
-rw-r--r--src/app/mod.rs4
-rw-r--r--src/app/state.rs14
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(),
},