aboutsummaryrefslogtreecommitdiff
path: root/src/ui.rs
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/ui.rs
parent6885c8873971d79332376137a14572f8440ef42e (diff)
feat: add mobile terminal layout
Diffstat (limited to 'src/ui.rs')
-rw-r--r--src/ui.rs129
1 files changed, 116 insertions, 13 deletions
diff --git a/src/ui.rs b/src/ui.rs
index 4299019f..4350a6d7 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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);