diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-30 01:05:55 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-30 01:05:55 +0300 |
| commit | aee654578408c7fbdb1640d2dca8eaea4ec1754e (patch) | |
| tree | 908ba777c917a6f1c06e9c9ebd4409e1a440b95e /src | |
| parent | 263b8f32654588bc0ca229361f7b7be30d72aed2 (diff) | |
feat: add manual sidebar resizing
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 145 | ||||
| -rw-r--r-- | src/app/mod.rs | 4 | ||||
| -rw-r--r-- | src/app/state.rs | 17 | ||||
| -rw-r--r-- | src/ui.rs | 33 |
4 files changed, 168 insertions, 31 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index a45b8e42..0c901be5 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -8,7 +8,9 @@ use tracing::warn; use crate::layout::{NavDirection, PaneInfo, SplitBorder}; use crate::selection::Selection; -use super::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, Mode}; +use super::state::{ + key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, Mode, +}; use super::App; // --------------------------------------------------------------------------- @@ -95,6 +97,22 @@ impl App { } pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && self.state.on_sidebar_divider(mouse.column, mouse.row) + { + let now = std::time::Instant::now(); + let is_double_click = self + .last_sidebar_divider_click + .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW); + self.last_sidebar_divider_click = Some(now); + + if is_double_click { + self.state.sidebar_width_auto = true; + self.state.drag = None; + return; + } + } + if let Some(action) = self.state.handle_mouse(mouse) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), @@ -857,12 +875,23 @@ impl AppState { return None; } + if self.on_sidebar_divider(mouse.column, mouse.row) { + self.drag = Some(DragState { + target: DragTarget::SidebarDivider, + }); + self.sidebar_width_auto = false; + self.set_manual_sidebar_width(mouse.column); + return None; + } + if !in_sidebar { if let Some(border) = self.find_border_at(mouse.column, mouse.row) { self.drag = Some(DragState { - path: border.path.clone(), - direction: border.direction, - area: border.area, + target: DragTarget::PaneSplit { + path: border.path.clone(), + direction: border.direction, + area: border.area, + }, }); return None; } @@ -927,20 +956,32 @@ impl AppState { MouseEventKind::Drag(MouseButton::Left) => { if let Some(drag) = &self.drag { - let ratio = match drag.direction { - Direction::Horizontal => { - (mouse.column.saturating_sub(drag.area.x)) as f32 - / drag.area.width.max(1) as f32 + match &drag.target { + DragTarget::PaneSplit { + path, + direction, + area, + } => { + let ratio = match direction { + Direction::Horizontal => { + (mouse.column.saturating_sub(area.x)) as f32 + / area.width.max(1) as f32 + } + Direction::Vertical => { + (mouse.row.saturating_sub(area.y)) as f32 + / area.height.max(1) as f32 + } + }; + let ratio = ratio.clamp(0.1, 0.9); + let path = path.clone(); + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.layout.set_ratio_at(&path, ratio); + } } - Direction::Vertical => { - (mouse.row.saturating_sub(drag.area.y)) as f32 - / drag.area.height.max(1) as f32 + DragTarget::SidebarDivider => { + self.sidebar_width_auto = false; + self.set_manual_sidebar_width(mouse.column); } - }; - let ratio = ratio.clamp(0.1, 0.9); - let path = drag.path.clone(); - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - ws.layout.set_ratio_at(&path, ratio); } } else if let Some(sel) = &mut self.selection { sel.drag(mouse.column, mouse.row); @@ -1034,6 +1075,24 @@ impl AppState { None } + fn on_sidebar_divider(&self, col: u16, row: u16) -> bool { + if self.sidebar_collapsed { + return false; + } + let sidebar = self.view.sidebar_rect; + sidebar.width > 0 + && col == sidebar.x + sidebar.width.saturating_sub(1) + && row >= sidebar.y + && row < sidebar.y + sidebar.height + } + + fn set_manual_sidebar_width(&mut self, divider_col: u16) { + let sidebar = self.view.sidebar_rect; + let width = divider_col.saturating_sub(sidebar.x).saturating_add(1); + self.sidebar_width = + width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH); + } + /// Find which workspace index a sidebar row belongs to (two-section layout). fn workspace_at_row(&self, row: u16) -> Option<usize> { let sidebar = self.view.sidebar_rect; @@ -1197,8 +1256,9 @@ impl AppState { #[cfg(test)] mod tests { use super::*; - use crate::workspace::Workspace; - use crossterm::event::KeyModifiers; + use crate::{config::Config, workspace::Workspace}; + use crossterm::event::{KeyModifiers, MouseEvent}; + use ratatui::layout::Rect; fn state_with_workspaces(names: &[&str]) -> AppState { let mut state = AppState::test_new(); @@ -1211,6 +1271,30 @@ mod tests { state } + fn app_for_mouse_test() -> App { + let (_api_tx, api_rx) = std::sync::mpsc::channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.mode = Mode::Terminal; + app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20); + app.state.view.terminal_area = Rect::new(26, 0, 80, 20); + app + } + + fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column: col, + row, + modifiers: KeyModifiers::empty(), + } + } + #[test] fn custom_rename_key_enters_rename_mode() { let mut state = state_with_workspaces(&["test"]); @@ -1365,4 +1449,29 @@ mod tests { assert!(state.sound.enabled); assert_eq!(state.mode, Mode::Settings); } + + #[test] + fn dragging_sidebar_divider_sets_manual_width() { + let mut app = app_for_mouse_test(); + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5)); + + assert!(!app.state.sidebar_width_auto); + assert_eq!(app.state.sidebar_width, 31); + } + + #[test] + fn double_clicking_sidebar_divider_resets_auto_width() { + let mut app = app_for_mouse_test(); + app.state.sidebar_width_auto = false; + app.state.sidebar_width = 30; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5)); + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); + + assert!(app.state.sidebar_width_auto); + assert!(app.state.drag.is_none()); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index f352f97c..15ae2b9b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,7 @@ use std::io; use std::time::{Duration, Instant}; const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); +const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); use crossterm::event::{self, Event, KeyEventKind}; use ratatui::layout::Rect; @@ -37,6 +38,7 @@ pub struct App { config_diagnostic_deadline: Option<Instant>, toast_deadline: Option<Instant>, last_git_remote_status_refresh: Instant, + last_sidebar_divider_click: Option<Instant>, } /// Resolve the palette from config: base theme + optional custom overrides. @@ -135,6 +137,7 @@ impl App { prefix_code, prefix_mods, sidebar_width: config.ui.sidebar_width, + sidebar_width_auto: true, sidebar_collapsed: false, confirm_close: config.ui.confirm_close, confirm_close_selected_confirm: true, @@ -184,6 +187,7 @@ impl App { event_tx, event_rx, last_git_remote_status_refresh: Instant::now(), + last_sidebar_divider_click: None, api_rx, event_hub, last_focus, diff --git a/src/app/state.rs b/src/app/state.rs index fcde6bff..b1350e48 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -389,11 +389,18 @@ pub struct SettingsState { pub original_theme: Option<String>, } -/// Active mouse drag on a split border. +pub(crate) enum DragTarget { + PaneSplit { + path: Vec<bool>, + direction: Direction, + area: Rect, + }, + SidebarDivider, +} + +/// Active mouse drag on a split border or sidebar divider. pub(crate) struct DragState { - pub path: Vec<bool>, - pub direction: Direction, - pub area: Rect, + pub target: DragTarget, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -464,6 +471,7 @@ pub struct AppState { pub prefix_code: KeyCode, pub prefix_mods: KeyModifiers, pub sidebar_width: u16, + pub sidebar_width_auto: bool, pub sidebar_collapsed: bool, pub confirm_close: bool, pub confirm_close_selected_confirm: bool, @@ -554,6 +562,7 @@ impl AppState { prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, sidebar_width: 26, + sidebar_width_auto: true, sidebar_collapsed: false, confirm_close: true, confirm_close_selected_confirm: true, @@ -13,8 +13,8 @@ use crate::detect::AgentState; use crate::layout::PaneInfo; const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator -const MIN_SIDEBAR_WIDTH: u16 = 18; -const MAX_SIDEBAR_WIDTH: u16 = 36; +pub(crate) const MIN_SIDEBAR_WIDTH: u16 = 18; +pub(crate) const MAX_SIDEBAR_WIDTH: u16 = 36; // Braille spinner frames — smooth rotation const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -32,8 +32,11 @@ use crate::app::state::Palette; pub fn compute_view(app: &mut AppState, area: Rect) { let sidebar_w = if app.sidebar_collapsed { COLLAPSED_WIDTH - } else { + } else if app.sidebar_width_auto { compute_sidebar_width(app) + } else { + app.sidebar_width + .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) }; let [sidebar_area, main_area] = @@ -159,7 +162,9 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> { /// Auto-scale sidebar width based on workspace identity + agent summary. fn compute_sidebar_width(app: &AppState) -> u16 { if app.workspaces.is_empty() { - return app.sidebar_width; + return app + .sidebar_width + .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); } let max_line = app .workspaces @@ -570,11 +575,11 @@ fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool, p: &Pal fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { let Some(ws_idx) = app.active else { - render_empty(frame, area, &app.palette); + render_empty(app, frame, area); return; }; let Some(ws) = app.workspaces.get(ws_idx) else { - render_empty(frame, area, &app.palette); + render_empty(app, frame, area); return; }; @@ -690,19 +695,29 @@ fn render_selection_highlight( } } -fn render_empty(frame: &mut Frame, area: Rect, p: &Palette) { +fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { + let p = &app.palette; let lines = vec