aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRudolf S. <rud.schmidt@gmail.com>2026-09-07 14:04:54 +0200
committerGitHub <noreply@github.com>2026-09-07 14:04:54 +0200
commit482818130407691154d1635d4029b95d0e730104 (patch)
tree5f72ac0992b43a48a670bd3265fc5a2aeae366c8 /src
parent4b5e9bda239a0b6903889062d756424578e94691 (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.rs2
-rw-r--r--src/app/mod.rs10
-rw-r--r--src/app/state.rs4
-rw-r--r--src/config.rs4
-rw-r--r--src/config/model.rs95
-rw-r--r--src/main.rs5
-rw-r--r--src/server/client_shell.rs2
-rw-r--r--src/ui/panes.rs56
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);