diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-30 03:59:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-30 04:00:33 +0300 |
| commit | f54d8e8c0aee5d3ec9724234d1bcd418bff8ff2f (patch) | |
| tree | 67eb7fd76f668feab4b5a4ef245eea854a04d42d /src | |
| parent | 5e2dc40691e30f90c5fe3b1e60d5074ceb358556 (diff) | |
feat: add hidden collapsed sidebar mode
refs #842
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/sidebar.rs | 14 | ||||
| -rw-r--r-- | src/app/mod.rs | 27 | ||||
| -rw-r--r-- | src/app/state.rs | 2 | ||||
| -rw-r--r-- | src/config.rs | 6 | ||||
| -rw-r--r-- | src/config/model.rs | 30 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/ui.rs | 37 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 4 |
8 files changed, 115 insertions, 8 deletions
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index fd631cd4..39affc83 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -497,6 +497,7 @@ mod tests { use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path}; use crate::{ app::state::{AgentPanelSort, DragTarget, Mode}, + config::SidebarCollapsedModeConfig, detect::Agent, workspace::Workspace, }; @@ -969,6 +970,19 @@ mod tests { } #[test] + fn hidden_collapsed_sidebar_has_no_mouse_expand_hotspot() { + let mut app = app_for_mouse_test(); + app.state.sidebar_collapsed = true; + app.state.sidebar_collapsed_mode = SidebarCollapsedModeConfig::Hidden; + app.state.view.sidebar_rect = Rect::new(0, 0, 0, 20); + app.state.view.terminal_area = Rect::new(0, 0, 80, 20); + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 19)); + + assert!(app.state.sidebar_collapsed); + } + + #[test] fn clicking_expanded_sidebar_toggle_collapses_sidebar() { let mut app = app_for_mouse_test(); app.state.sidebar_collapsed = false; diff --git a/src/app/mod.rs b/src/app/mod.rs index 2860664f..05518134 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -588,6 +588,7 @@ impl App { sidebar_width_source, sidebar_width_auto: false, sidebar_collapsed: false, + sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, sidebar_section_split, agent_panel_sort, next_agent_state_change_seq: 0, @@ -1340,6 +1341,7 @@ impl App { } self.state.sidebar_min_width = config.ui.sidebar_min_width; self.state.sidebar_max_width = config.ui.sidebar_max_width; + self.state.sidebar_collapsed_mode = config.ui.sidebar_collapsed_mode; self.state.mobile_width_threshold = config.ui.mobile_width_threshold; // Re-clamp the live width to the new bounds. No source guard — bounds // always apply, including to widths owned by Persisted or Manual. @@ -2500,6 +2502,31 @@ mod tests { } #[test] + fn reload_config_updates_sidebar_collapsed_mode() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-sidebar-collapsed-mode"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + assert_eq!( + app.state.sidebar_collapsed_mode, + crate::config::SidebarCollapsedModeConfig::Compact + ); + + std::fs::write(&path, "[ui]\nsidebar_collapsed_mode = \"hidden\"\n").unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!( + app.state.sidebar_collapsed_mode, + crate::config::SidebarCollapsedModeConfig::Hidden + ); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] fn reload_config_updates_sidebar_bounds_and_reclamps() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-sidebar-bounds"); diff --git a/src/app/state.rs b/src/app/state.rs index 9e5dbed1..694ff252 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1349,6 +1349,7 @@ pub struct AppState { pub sidebar_width_source: SidebarWidthSource, pub sidebar_width_auto: bool, pub sidebar_collapsed: bool, + pub sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig, /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, @@ -1708,6 +1709,7 @@ impl AppState { sidebar_width_source: SidebarWidthSource::ConfigDefault, sidebar_width_auto: false, sidebar_collapsed: false, + sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, next_agent_state_change_seq: 0, diff --git a/src/config.rs b/src/config.rs index 12e9cf1c..5ddc4c60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,9 +19,9 @@ pub use self::{ }, model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, - ConfigReloadStatus, NewTerminalCwdConfig, ShellModeConfig, ToastClipboardPosition, - ToastConfig, ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, - MAX_TOAST_DELAY_SECONDS, + ConfigReloadStatus, NewTerminalCwdConfig, ShellModeConfig, SidebarCollapsedModeConfig, + ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition, + UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, }, sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, diff --git a/src/config/model.rs b/src/config/model.rs index 92192c26..301b58ca 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -106,6 +106,14 @@ impl AgentPanelSortConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SidebarCollapsedModeConfig { + #[default] + Compact, + Hidden, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct RightClickPassthroughModifierConfig(Option<KeyModifiers>); @@ -763,6 +771,8 @@ pub struct UiConfig { pub sidebar_min_width: u16, /// Maximum sidebar width (columns) when expanded. Default: 36. pub sidebar_max_width: u16, + /// Collapsed sidebar presentation. Default: compact. + pub sidebar_collapsed_mode: SidebarCollapsedModeConfig, /// Terminal width at or below which Herdr uses the mobile single-column layout. Default: 64. pub mobile_width_threshold: u16, /// Capture mouse input for Herdr's mouse UI. Default: true. @@ -963,6 +973,7 @@ impl Default for UiConfig { sidebar_width: 26, sidebar_min_width: 18, sidebar_max_width: 36, + sidebar_collapsed_mode: SidebarCollapsedModeConfig::Compact, mobile_width_threshold: DEFAULT_MOBILE_WIDTH_THRESHOLD, mouse_capture: true, right_click_passthrough_modifier: RightClickPassthroughModifierConfig::default(), @@ -1311,6 +1322,25 @@ mobile_width_threshold = 96 } #[test] + fn sidebar_collapsed_mode_defaults_compact_and_parses_hidden() { + let default_config = Config::default(); + assert_eq!( + default_config.ui.sidebar_collapsed_mode, + SidebarCollapsedModeConfig::Compact + ); + + let toml = r#" +[ui] +sidebar_collapsed_mode = "hidden" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!( + config.ui.sidebar_collapsed_mode, + SidebarCollapsedModeConfig::Hidden + ); + } + + #[test] fn validated_sidebar_bounds_rejects_inverted() { assert_eq!(validated_sidebar_bounds(18, 36), Some((18, 36))); assert_eq!(validated_sidebar_bounds(20, 20), Some((20, 20))); diff --git a/src/main.rs b/src/main.rs index ea46585f..6a44131c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -244,6 +244,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Maximum sidebar width when expanded (columns) # sidebar_max_width = 36 +# Collapsed sidebar presentation: "compact" keeps the narrow status rail, "hidden" uses zero width. +# sidebar_collapsed_mode = "compact" + # Terminal width at or below which Herdr uses the mobile single-column layout. # Increase this for foldables, tablets, or wide phone terminals. # mobile_width_threshold = 64 @@ -182,7 +182,10 @@ fn compute_view_internal( } let sidebar_w = if app.sidebar_collapsed { - COLLAPSED_WIDTH + match app.sidebar_collapsed_mode { + crate::config::SidebarCollapsedModeConfig::Compact => COLLAPSED_WIDTH, + crate::config::SidebarCollapsedModeConfig::Hidden => 0, + } } else { app.sidebar_width .clamp(app.sidebar_min_width, app.sidebar_max_width) @@ -385,10 +388,12 @@ pub fn render_with_runtime_registry( if app.view.layout == ViewLayout::Mobile { render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect); - } else if app.sidebar_collapsed { - render_sidebar_collapsed(app, frame, sidebar_area); - } else { - render_sidebar(app, terminal_runtimes, frame, sidebar_area); + } else if sidebar_area.width > 0 { + if app.sidebar_collapsed { + render_sidebar_collapsed(app, frame, sidebar_area); + } else { + render_sidebar(app, terminal_runtimes, frame, sidebar_area); + } } if app.view.layout != ViewLayout::Mobile { render_tab_bar(app, frame, tab_bar_area); @@ -791,6 +796,28 @@ mod tests { } #[test] + fn hidden_collapsed_sidebar_uses_full_width_terminal_area() { + let mut app = crate::app::state::AppState::test_new(); + app.sidebar_collapsed = true; + app.sidebar_collapsed_mode = crate::config::SidebarCollapsedModeConfig::Hidden; + 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, 80, 20)); + + assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 0, 20)); + assert_eq!(app.view.tab_bar_rect, Rect::new(0, 0, 80, 1)); + assert_eq!(app.view.terminal_area, Rect::new(0, 1, 80, 19)); + assert!(app.view.workspace_card_areas.is_empty()); + + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| render(&app, frame)).unwrap(); + } + + #[test] fn collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() { let mut app = crate::app::state::AppState::test_new(); app.sidebar_collapsed = true; diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index a7b0ca92..72b344fb 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -636,6 +636,10 @@ pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect /// Collapsed sidebar: workspace glance on top, compact agent list below. pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let is_navigating = matches!(app.mode, Mode::Navigate); let p = &app.palette; |
