diff options
| author | Rudolf S. <rud.schmidt@gmail.com> | 2026-09-07 14:04:54 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-07 14:04:54 +0200 |
| commit | 482818130407691154d1635d4029b95d0e730104 (patch) | |
| tree | 5f72ac0992b43a48a670bd3265fc5a2aeae366c8 /src | |
| parent | 4b5e9bda239a0b6903889062d756424578e94691 (diff) | |
feat: make ui.pane_borders a three-state auto/always/off mode (#3234)
* feat: make ui.pane_borders a three-state auto/always/off mode
Legacy booleans keep parsing (true = auto, false = off); "always" frames
a lone pane while pane_outer_borders is enabled.
* fix: align pane border reload test and documentation
---------
Co-authored-by: Rudolf Schmidt <me@rudionrails.com>
Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/agent_resume.rs | 2 | ||||
| -rw-r--r-- | src/app/mod.rs | 10 | ||||
| -rw-r--r-- | src/app/state.rs | 4 | ||||
| -rw-r--r-- | src/config.rs | 4 | ||||
| -rw-r--r-- | src/config/model.rs | 95 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/server/client_shell.rs | 2 | ||||
| -rw-r--r-- | src/ui/panes.rs | 56 |
8 files changed, 146 insertions, 32 deletions
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 4bc12c24..2cbd93ab 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -289,7 +289,7 @@ impl App { fn derived_pending_agent_resume_pane_infos( tab: &crate::workspace::Tab, terminal_area: Rect, - pane_borders: bool, + pane_borders: crate::config::PaneBordersConfig, pane_gaps: bool, pane_outer_borders: bool, ) -> Vec<crate::layout::PaneInfo> { diff --git a/src/app/mod.rs b/src/app/mod.rs index 110be9b7..a0b90023 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1874,13 +1874,9 @@ mod tests { let original_pane_borders = app.state.pane_borders; // Pair the bad bounds with another `[ui]` field change to confirm the // entire section is treated as invalid (not just the bounds). - let target_pane_borders = !original_pane_borders; std::fs::write( &path, - format!( - "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\npane_borders = {}\n", - target_pane_borders - ), + "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\npane_borders = \"always\"\n", ) .unwrap(); @@ -1945,10 +1941,10 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let target_pane_borders = !app.state.pane_borders; + let target_pane_borders = crate::config::PaneBordersConfig::Always; std::fs::write( &path, - format!("[ui]\npane_borders = {target_pane_borders}\nmouse_captur = false\n"), + "[ui]\npane_borders = \"always\"\nmouse_captur = false\n", ) .unwrap(); diff --git a/src/app/state.rs b/src/app/state.rs index 3d3700b2..892ec5e9 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -831,7 +831,7 @@ pub struct AppState { pub sidebar_spaces: crate::config::SpacesSidebarConfig, pub next_agent_state_change_seq: u64, pub confirm_close: bool, - pub pane_borders: bool, + pub pane_borders: crate::config::PaneBordersConfig, pub pane_outer_borders: bool, pub pane_scrollbars: bool, pub pane_gaps: bool, @@ -1058,7 +1058,7 @@ impl AppState { sidebar_spaces: crate::config::SpacesSidebarConfig::default(), next_agent_state_change_seq: 0, confirm_close: true, - pane_borders: true, + pane_borders: crate::config::PaneBordersConfig::Auto, pane_outer_borders: true, pane_scrollbars: true, pane_gaps: false, diff --git a/src/config.rs b/src/config.rs index 4c966453..60a22a1e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -23,8 +23,8 @@ pub use self::{ }, model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, - ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, ShellModeConfig, - SidebarCollapsedModeConfig, StatusIndicatorStyle, TabBarPositionConfig, + ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, PaneBordersConfig, + ShellModeConfig, SidebarCollapsedModeConfig, StatusIndicatorStyle, TabBarPositionConfig, ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, }, diff --git a/src/config/model.rs b/src/config/model.rs index a400382c..604fe9ad 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -844,6 +844,60 @@ pub enum TabBarPositionConfig { Bottom, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PaneBordersConfig { + #[default] + Auto, + Always, + Off, +} + +impl PaneBordersConfig { + pub fn draws_borders(self) -> bool { + !matches!(self, Self::Off) + } + + pub fn shows_borders(self, multi_pane: bool) -> bool { + self.draws_borders() && (multi_pane || matches!(self, Self::Always)) + } +} + +impl<'de> Deserialize<'de> for PaneBordersConfig { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + struct PaneBordersVisitor; + + impl<'de> de::Visitor<'de> for PaneBordersVisitor { + type Value = PaneBordersConfig; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("\"auto\", \"always\", \"off\", or a legacy boolean") + } + + fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> { + Ok(if value { + PaneBordersConfig::Auto + } else { + PaneBordersConfig::Off + }) + } + + fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> { + match value { + "auto" => Ok(PaneBordersConfig::Auto), + "always" => Ok(PaneBordersConfig::Always), + "off" => Ok(PaneBordersConfig::Off), + other => Err(E::invalid_value(de::Unexpected::Str(other), &self)), + } + } + } + + deserializer.deserialize_any(PaneBordersVisitor) + } +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct UiConfig { @@ -876,8 +930,12 @@ pub struct UiConfig { pub prompt_new_tab_name: bool, /// Ask for a workspace name before interactive creation. Default: false. pub prompt_new_workspace_name: bool, - /// Draw borders around split panes. Default: true. - pub pane_borders: bool, + /// Draw borders around split panes. auto draws them only for split panes, + /// always also frames a lone pane (only while pane_outer_borders is + /// enabled, since every edge of a lone pane is an outer edge), off + /// disables them. Legacy booleans map true to auto and false to off. + /// Default: auto. + pub pane_borders: PaneBordersConfig, /// Draw borders along the outside edge of the pane area. Default: true. pub pane_outer_borders: bool, /// Draw interactive scrollbars beside terminal panes. Default: true. @@ -1115,7 +1173,7 @@ impl Default for UiConfig { confirm_close: true, prompt_new_tab_name: true, prompt_new_workspace_name: false, - pane_borders: true, + pane_borders: PaneBordersConfig::Auto, pane_outer_borders: true, pane_scrollbars: true, pane_gaps: true, @@ -1390,9 +1448,34 @@ status_indicators = "symbols" } #[test] + fn pane_borders_legacy_booleans_map_to_modes() { + let enabled: Config = toml::from_str("[ui]\npane_borders = true").unwrap(); + assert_eq!(enabled.ui.pane_borders, PaneBordersConfig::Auto); + + let disabled: Config = toml::from_str("[ui]\npane_borders = false").unwrap(); + assert_eq!(disabled.ui.pane_borders, PaneBordersConfig::Off); + + let auto: Config = toml::from_str("[ui]\npane_borders = \"auto\"").unwrap(); + assert_eq!(auto.ui.pane_borders, PaneBordersConfig::Auto); + + let off: Config = toml::from_str("[ui]\npane_borders = \"off\"").unwrap(); + assert_eq!(off.ui.pane_borders, PaneBordersConfig::Off); + + let unknown = toml::from_str::<Config>("[ui]\npane_borders = \"framed\"") + .unwrap_err() + .to_string(); + assert!(unknown.contains("\"auto\", \"always\", \"off\", or a legacy boolean")); + + let wrong_type = toml::from_str::<Config>("[ui]\npane_borders = 3") + .unwrap_err() + .to_string(); + assert!(wrong_type.contains("\"auto\", \"always\", \"off\", or a legacy boolean")); + } + + #[test] fn pane_appearance_defaults_and_parse() { let default_config = Config::default(); - assert!(default_config.ui.pane_borders); + assert_eq!(default_config.ui.pane_borders, PaneBordersConfig::Auto); assert!(default_config.ui.pane_outer_borders); assert!(default_config.ui.pane_scrollbars); assert!(default_config.ui.pane_gaps); @@ -1407,7 +1490,7 @@ status_indicators = "symbols" let toml = r#" [ui] -pane_borders = false +pane_borders = "always" pane_outer_borders = false pane_scrollbars = false pane_gaps = true @@ -1424,7 +1507,7 @@ tab_bar_right = [ tab_bar_right_separator = " ยท " "#; let config: Config = toml::from_str(toml).unwrap(); - assert!(!config.ui.pane_borders); + assert_eq!(config.ui.pane_borders, PaneBordersConfig::Always); assert!(!config.ui.pane_outer_borders); assert!(!config.ui.pane_scrollbars); assert!(config.ui.pane_gaps); diff --git a/src/main.rs b/src/main.rs index 4d7e2f88..cf7cf5b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -285,7 +285,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # prompt_new_workspace_name = false # Draw borders around split panes. -# pane_borders = true +# "auto" draws them only for split panes, "always" also frames a lone pane +# (only while pane_outer_borders is enabled), "off" disables them. +# Legacy booleans still parse: true = "auto", false = "off". +# pane_borders = "auto" # Draw borders along the outside edge of the pane area. # Disable for tmux-style internal splitters without an outside frame. diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index f511b641..37470693 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -364,7 +364,7 @@ pub(super) fn render_pane_surface( .filter_map(|split| { let hit_rect = split_hit_rect( split, - app.state.pane_borders, + app.state.pane_borders.draws_borders(), app.state.pane_gaps, &pane_frames, )?; diff --git a/src/ui/panes.rs b/src/ui/panes.rs index fbd7b8eb..ab3e352a 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -91,11 +91,12 @@ fn shrink_for_one_cell_gap(size: u16) -> u16 { pub(crate) fn apply_pane_chrome( panes: Vec<PaneInfo>, - pane_borders: bool, + pane_borders: crate::config::PaneBordersConfig, pane_gaps: bool, pane_outer_borders: bool, ) -> Vec<PaneInfo> { let multi_pane = panes.len() > 1; + let bordered = pane_borders.shows_borders(multi_pane); let outer_left = panes.iter().map(|info| info.rect.x).min().unwrap_or(0); let outer_top = panes.iter().map(|info| info.rect.y).min().unwrap_or(0); let outer_right = panes @@ -115,7 +116,7 @@ pub(crate) fn apply_pane_chrome( let right_neighbor = multi_pane.then(|| pane_to_right(&info, &panes)).flatten(); let below_neighbor = multi_pane.then(|| pane_below(&info, &panes)).flatten(); - if multi_pane && pane_gaps && !pane_borders { + if multi_pane && pane_gaps && !pane_borders.draws_borders() { if right_neighbor.is_some() { info.rect.width = shrink_for_one_cell_gap(info.rect.width); } @@ -124,7 +125,7 @@ pub(crate) fn apply_pane_chrome( } } - info.borders = if !multi_pane || !pane_borders { + info.borders = if !bordered { Borders::NONE } else { let mut borders = Borders::ALL; @@ -219,7 +220,7 @@ pub(super) fn resize_tab_panes( if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, terminal_runtimes, workspace_index, tab, focused_id) { - let borders = if multi_pane && app.pane_borders && app.pane_outer_borders { + let borders = if app.pane_borders.shows_borders(multi_pane) && app.pane_outer_borders { Borders::ALL } else { Borders::NONE @@ -284,7 +285,7 @@ pub(super) fn compute_pane_infos_for_tab( if tab.zoomed { let focused_id = tab.layout.focused(); - let borders = if multi_pane && app.pane_borders && app.pane_outer_borders { + let borders = if app.pane_borders.shows_borders(multi_pane) && app.pane_outer_borders { Borders::ALL } else { Borders::NONE @@ -457,7 +458,7 @@ fn render_pane_borders( split_borders: &[crate::layout::SplitBorder], frame: &mut Frame, ) { - if !app.pane_borders || pane_infos.iter().all(|info| info.borders.is_empty()) { + if !app.pane_borders.draws_borders() || pane_infos.iter().all(|info| info.borders.is_empty()) { return; } @@ -830,6 +831,7 @@ fn color_to_rgb(color: Color) -> Option<Rgb> { #[cfg(test)] mod tests { use super::*; + use crate::config::PaneBordersConfig; use crate::layout::PaneId; use crate::selection::Selection; use crate::terminal::TerminalRuntime; @@ -916,7 +918,7 @@ mod tests { let infos = apply_pane_chrome( workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)), - true, + PaneBordersConfig::Auto, false, true, ); @@ -937,7 +939,7 @@ mod tests { let infos = apply_pane_chrome( workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)), - true, + PaneBordersConfig::Auto, false, true, ); @@ -958,7 +960,7 @@ mod tests { let infos = apply_pane_chrome( workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)), - true, + PaneBordersConfig::Auto, false, false, ); @@ -978,7 +980,7 @@ mod tests { let infos = apply_pane_chrome( workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)), - true, + PaneBordersConfig::Auto, true, true, ); @@ -999,7 +1001,7 @@ mod tests { let infos = apply_pane_chrome( workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)), - false, + PaneBordersConfig::Off, true, true, ); @@ -1019,7 +1021,7 @@ mod tests { let infos = apply_pane_chrome( workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)), - false, + PaneBordersConfig::Off, false, true, ); @@ -1031,6 +1033,36 @@ mod tests { } #[test] + fn always_pane_borders_frame_lone_pane() { + let workspace = Workspace::test_new("test"); + let area = Rect::new(0, 0, 100, 20); + + let default_infos = apply_pane_chrome( + workspace.tabs[0].layout.panes(area), + PaneBordersConfig::Auto, + false, + true, + ); + assert_eq!(default_infos[0].borders, Borders::NONE); + + let framed_infos = apply_pane_chrome( + workspace.tabs[0].layout.panes(area), + PaneBordersConfig::Always, + false, + true, + ); + assert_eq!(framed_infos[0].borders, Borders::ALL); + + let no_outer_infos = apply_pane_chrome( + workspace.tabs[0].layout.panes(area), + PaneBordersConfig::Always, + false, + false, + ); + assert_eq!(no_outer_infos[0].borders, Borders::NONE); + } + + #[test] fn global_pane_border_renderer_composes_junctions_and_focus_style() { let mut app = AppState::test_new(); app.view.terminal_area = Rect::new(0, 0, 4, 4); |
