diff options
| author | CHIH,GODLEE <61595521+ChihGodlee@users.noreply.github.com> | 2026-05-18 20:27:43 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-18 15:27:43 +0300 |
| commit | 3189ede2b9225b1b930cc43368248c6b4f3a4daf (patch) | |
| tree | 24252debe6f388edfe0e621462f1bf38d5b46b20 /src | |
| parent | c9eb7520e66c2a941c020e96e8242de66847f29d (diff) | |
feat: configure sidebar min/max width via [ui] (#135)
* feat: configure sidebar min/max width via [ui]
Expose the previously hardcoded MIN_SIDEBAR_WIDTH / MAX_SIDEBAR_WIDTH
clamps as [ui] sidebar_min_width / sidebar_max_width. Defaults remain
18 / 36 so existing configs are unchanged. Hot-reload is wired through
the existing [ui] block; reload re-clamps the live width to the new
bounds so tightening takes effect immediately.
Reject inverted bounds (min > max) at every entry point so a bad config
can never reach u16::clamp(min, max), which panics: App::new falls back
to defaults, and apply_live_config treats the whole [ui] section as
invalid and returns a partial reload report.
refs #132
* test: keep sidebar reload width within bounds
refs #132
---------
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/sidebar.rs | 25 | ||||
| -rw-r--r-- | src/app/mod.rs | 209 | ||||
| -rw-r--r-- | src/app/state.rs | 4 | ||||
| -rw-r--r-- | src/config.rs | 4 | ||||
| -rw-r--r-- | src/config/model.rs | 44 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/ui.rs | 34 |
7 files changed, 296 insertions, 30 deletions
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index e8e1e094..94feaf93 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -244,8 +244,7 @@ impl AppState { pub(super) 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); + self.sidebar_width = width.clamp(self.sidebar_min_width, self.sidebar_max_width); self.sidebar_width_source = crate::app::state::SidebarWidthSource::Manual; self.mark_session_dirty(); } @@ -1240,6 +1239,28 @@ mod tests { } #[test] + fn dragging_past_max_clamps_to_configured_max() { + let mut app = app_for_mouse_test(); + app.state.sidebar_max_width = 30; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 50, 5)); + + assert_eq!(app.state.sidebar_width, 30); + } + + #[test] + fn dragging_below_min_clamps_to_configured_min() { + let mut app = app_for_mouse_test(); + app.state.sidebar_min_width = 22; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 5, 5)); + + assert_eq!(app.state.sidebar_width, 22); + } + + #[test] fn dragging_sidebar_section_divider_sets_split_ratio() { let mut app = app_for_mouse_test(); let divider = crate::ui::sidebar_section_divider_rect( diff --git a/src/app/mod.rs b/src/app/mod.rs index 13b8ecc0..125fabed 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -288,6 +288,22 @@ impl App { let agent_panel_scope = agent_panel_scope_from_config(config.ui.agent_panel_scope); + // Validate sidebar bounds before they reach any `u16::clamp(min, max)` + // call: `clamp` panics when `min > max`. On bad config, fall back to + // the built-in defaults rather than crashing on the first render. + let (sidebar_min_width, sidebar_max_width) = crate::config::validated_sidebar_bounds( + config.ui.sidebar_min_width, + config.ui.sidebar_max_width, + ) + .unwrap_or_else(|| { + tracing::warn!( + min = config.ui.sidebar_min_width, + max = config.ui.sidebar_max_width, + "ui.sidebar_min_width is greater than sidebar_max_width; falling back to default bounds (18, 36)" + ); + (18, 36) + }); + info!( pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes, "using pane scrollback configuration" @@ -378,6 +394,8 @@ impl App { prefix_mods, default_sidebar_width: config.ui.sidebar_width, sidebar_width, + sidebar_min_width, + sidebar_max_width, sidebar_width_source, sidebar_width_auto: false, sidebar_collapsed: false, @@ -730,26 +748,50 @@ impl App { } if !invalid_section("ui") { - diagnostics.extend(config.ui.sound.diagnostics()); + // Validate sidebar bounds before they reach any `u16::clamp` call. + // On `min > max`, treat the entire `[ui]` section as invalid: keep + // the previous settings and skip the section so the re-clamp below + // — and every subsequent render/drag — can never panic. + if crate::config::validated_sidebar_bounds( + config.ui.sidebar_min_width, + config.ui.sidebar_max_width, + ) + .is_none() + { + diagnostics.push(format!( + "ui.sidebar_min_width ({}) is greater than sidebar_max_width ({}); keeping previous [ui] settings", + config.ui.sidebar_min_width, config.ui.sidebar_max_width, + )); + } else { + diagnostics.extend(config.ui.sound.diagnostics()); - self.state.default_sidebar_width = config.ui.sidebar_width; - if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { - self.state.sidebar_width = config.ui.sidebar_width; - } - self.state.mouse_capture = config.ui.mouse_capture; - self.state.confirm_close = config.ui.confirm_close; - self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; - self.state.show_agent_labels_on_pane_borders = - config.ui.show_agent_labels_on_pane_borders; - self.state.agent_panel_scope = - agent_panel_scope_from_config(config.ui.agent_panel_scope); - self.state.agent_panel_scroll = 0; - self.state.accent = crate::config::parse_color(&config.ui.accent); - if !self.state.local_sound_playback && self.state.sound != config.ui.sound { - self.state.request_client_sound_config_reload = true; + self.state.default_sidebar_width = config.ui.sidebar_width; + if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { + self.state.sidebar_width = config.ui.sidebar_width; + } + self.state.sidebar_min_width = config.ui.sidebar_min_width; + self.state.sidebar_max_width = config.ui.sidebar_max_width; + // Re-clamp the live width to the new bounds. No source guard — bounds + // always apply, including to widths owned by Persisted or Manual. + self.state.sidebar_width = self + .state + .sidebar_width + .clamp(self.state.sidebar_min_width, self.state.sidebar_max_width); + self.state.mouse_capture = config.ui.mouse_capture; + self.state.confirm_close = config.ui.confirm_close; + self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; + self.state.show_agent_labels_on_pane_borders = + config.ui.show_agent_labels_on_pane_borders; + self.state.agent_panel_scope = + agent_panel_scope_from_config(config.ui.agent_panel_scope); + self.state.agent_panel_scroll = 0; + self.state.accent = crate::config::parse_color(&config.ui.accent); + if !self.state.local_sound_playback && self.state.sound != config.ui.sound { + self.state.request_client_sound_config_reload = true; + } + self.state.sound = config.ui.sound.clone(); + self.state.toast_config = config.ui.toast.clone(); } - self.state.sound = config.ui.sound.clone(); - self.state.toast_config = config.ui.toast.clone(); } if !invalid_section("experimental") { @@ -1170,18 +1212,18 @@ mod tests { state::SidebarWidthSource::ConfigDefault ); - std::fs::write(&path, "[ui]\nsidebar_width = 42\n").unwrap(); + std::fs::write(&path, "[ui]\nsidebar_width = 34\n").unwrap(); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 42); - assert_eq!(app.state.sidebar_width, 42); + assert_eq!(app.state.default_sidebar_width, 34); + assert_eq!(app.state.sidebar_width, 34); app.state.sidebar_width = 31; app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - std::fs::write(&path, "[ui]\nsidebar_width = 44\n").unwrap(); + std::fs::write(&path, "[ui]\nsidebar_width = 35\n").unwrap(); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 44); + assert_eq!(app.state.default_sidebar_width, 35); assert_eq!(app.state.sidebar_width, 31); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -1189,6 +1231,127 @@ mod tests { } #[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"); + 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(); + // Default bounds. + assert_eq!(app.state.sidebar_min_width, 18); + assert_eq!(app.state.sidebar_max_width, 36); + + // Manually set a width and flip the source so the existing + // sidebar_width-only-when-config-owned guard does NOT update it. + app.state.sidebar_width = 30; + app.state.sidebar_width_source = state::SidebarWidthSource::Manual; + + // Tightening max below the current width must re-clamp the live width + // even when source is Manual — bounds always apply. + std::fs::write(&path, "[ui]\nsidebar_max_width = 24\n").unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.sidebar_max_width, 24); + assert_eq!( + app.state.sidebar_width, 24, + "manual width must re-clamp to new max" + ); + + // Loosening max leaves the live width alone (it's already within bounds). + app.state.sidebar_width = 24; + std::fs::write(&path, "[ui]\nsidebar_max_width = 60\n").unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.sidebar_max_width, 60); + assert_eq!(app.state.sidebar_width, 24); + + // Raising min above the current width re-clamps upward. + std::fs::write(&path, "[ui]\nsidebar_min_width = 30\n").unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.sidebar_min_width, 30); + assert_eq!( + app.state.sidebar_width, 30, + "manual width must re-clamp up to new min" + ); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn app_new_falls_back_to_default_bounds_on_inverted_config() { + let mut config = Config::default(); + config.ui.sidebar_min_width = 50; + config.ui.sidebar_max_width = 30; + + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let app = App::new( + &config, + true, + None, + None, + api_rx, + crate::api::EventHub::default(), + ); + + assert_eq!( + app.state.sidebar_min_width, 18, + "App::new must fall back to default min when bounds are inverted" + ); + assert_eq!( + app.state.sidebar_max_width, 36, + "App::new must fall back to default max when bounds are inverted" + ); + } + + #[test] + fn reload_config_invalid_sidebar_bounds_keeps_previous_ui_and_returns_partial() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-invalid-sidebar-bounds"); + 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(); + let original_min = app.state.sidebar_min_width; + let original_max = app.state.sidebar_max_width; + let original_mouse_capture = app.state.mouse_capture; + // Pair the bad bounds with another `[ui]` field change to confirm the + // entire section is treated as invalid (not just the bounds). + let target_mouse_capture = !original_mouse_capture; + std::fs::write( + &path, + format!( + "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\nmouse_capture = {}\n", + target_mouse_capture + ), + ) + .unwrap(); + + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert_eq!(app.state.sidebar_min_width, original_min); + assert_eq!(app.state.sidebar_max_width, original_max); + assert_eq!( + app.state.mouse_capture, original_mouse_capture, + "[ui] is treated as invalid on bad bounds; mouse_capture must not apply" + ); + assert!(app + .state + .config_diagnostic + .as_deref() + .is_some_and(|message| { + message.contains("sidebar_min_width") + && message.contains("sidebar_max_width") + && message.contains("greater") + })); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] fn reload_config_keeps_current_keybinds_on_invalid_binding_but_applies_other_sections() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-invalid-keybind"); diff --git a/src/app/state.rs b/src/app/state.rs index 72630749..c5734ec2 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -928,6 +928,8 @@ pub struct AppState { pub prefix_mods: KeyModifiers, pub default_sidebar_width: u16, pub sidebar_width: u16, + pub sidebar_min_width: u16, + pub sidebar_max_width: u16, pub sidebar_width_source: SidebarWidthSource, pub sidebar_width_auto: bool, pub sidebar_collapsed: bool, @@ -1170,6 +1172,8 @@ impl AppState { prefix_mods: KeyModifiers::CONTROL, default_sidebar_width: 26, sidebar_width: 26, + sidebar_min_width: 18, + sidebar_max_width: 36, sidebar_width_source: SidebarWidthSource::ConfigDefault, sidebar_width_auto: false, sidebar_collapsed: false, diff --git a/src/config.rs b/src/config.rs index 9343321e..cf85e6cc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,8 +16,8 @@ pub use self::{ Keybinds, LiveKeybindConfig, }, model::{ - AgentPanelScopeConfig, Config, ConfigReloadReport, ConfigReloadStatus, ToastConfig, - ToastDelivery, + validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport, + ConfigReloadStatus, ToastConfig, ToastDelivery, }, sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, diff --git a/src/config/model.rs b/src/config/model.rs index 60a8859c..673eec02 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -48,6 +48,19 @@ pub struct ConfigReloadReport { pub diagnostics: Vec<String>, } +/// Validate `[ui]` sidebar bound configuration. +/// +/// Returns `Some((min, max))` when `min <= max`, `None` otherwise. The two +/// values are funneled through this helper before they reach any +/// `u16::clamp(min, max)` call site (`u16::clamp` panics when `min > max`). +pub fn validated_sidebar_bounds(min: u16, max: u16) -> Option<(u16, u16)> { + if min <= max { + Some((min, max)) + } else { + None + } +} + #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct Config { @@ -147,6 +160,10 @@ pub struct IndexedKeysConfig { #[serde(default)] pub struct UiConfig { pub sidebar_width: u16, + /// Minimum sidebar width (columns) when expanded. Default: 18. + pub sidebar_min_width: u16, + /// Maximum sidebar width (columns) when expanded. Default: 36. + pub sidebar_max_width: u16, /// Capture mouse input for Herdr's mouse UI. Default: true. pub mouse_capture: bool, /// Ask for confirmation before closing a workspace. Default: true. @@ -224,6 +241,8 @@ impl Default for UiConfig { fn default() -> Self { Self { sidebar_width: 26, + sidebar_min_width: 18, + sidebar_max_width: 36, mouse_capture: true, confirm_close: true, prompt_new_tab_name: true, @@ -315,6 +334,31 @@ prompt_new_tab_name = false } #[test] + fn sidebar_bounds_default_and_parse() { + let default_config = Config::default(); + assert_eq!(default_config.ui.sidebar_min_width, 18); + assert_eq!(default_config.ui.sidebar_max_width, 36); + + let toml = r#" +[ui] +sidebar_min_width = 12 +sidebar_max_width = 80 +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.sidebar_min_width, 12); + assert_eq!(config.ui.sidebar_max_width, 80); + } + + #[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))); + assert_eq!(validated_sidebar_bounds(0, u16::MAX), Some((0, u16::MAX))); + assert_eq!(validated_sidebar_bounds(50, 30), None); + assert_eq!(validated_sidebar_bounds(u16::MAX, 0), None); + } + + #[test] fn mouse_capture_default_on_and_parse() { let default_config = Config::default(); assert!(default_config.ui.mouse_capture); diff --git a/src/main.rs b/src/main.rs index c1b0acd3..2c17e704 100644 --- a/src/main.rs +++ b/src/main.rs @@ -130,6 +130,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Sidebar width (auto-scaled based on workspace names, this sets the default) # sidebar_width = 26 +# Minimum sidebar width when expanded (columns) +# sidebar_min_width = 18 + +# Maximum sidebar width when expanded (columns) +# sidebar_max_width = 36 + # Capture mouse input for Herdr's mouse UI. # Set false to let the terminal handle normal clicks, such as Cmd-clicking URLs. # Pane apps like lazygit and btop can still receive mouse when they request it. @@ -71,8 +71,6 @@ use crate::app::state::ViewLayout; use crate::app::{AppState, Mode}; const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator -pub(crate) const MIN_SIDEBAR_WIDTH: u16 = 18; -pub(crate) const MAX_SIDEBAR_WIDTH: u16 = 36; // Braille spinner frames — smooth rotation const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -147,7 +145,7 @@ fn compute_view_internal( COLLAPSED_WIDTH } else { app.sidebar_width - .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) + .clamp(app.sidebar_min_width, app.sidebar_max_width) }; let [sidebar_area, main_area] = @@ -451,6 +449,36 @@ mod tests { } #[test] + fn compute_view_clamps_sidebar_width_to_configured_max() { + 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; + app.sidebar_max_width = 30; + app.sidebar_width = 999; + + compute_view(&mut app, Rect::new(0, 0, 100, 20)); + + assert_eq!(app.view.sidebar_rect.width, 30); + } + + #[test] + fn compute_view_clamps_sidebar_width_to_configured_min() { + 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; + app.sidebar_min_width = 22; + app.sidebar_width = 5; + + compute_view(&mut app, Rect::new(0, 0, 100, 20)); + + assert_eq!(app.view.sidebar_rect.width, 22); + } + + #[test] fn collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() { let mut app = crate::app::state::AppState::test_new(); app.sidebar_collapsed = true; |
