feat: make outer pane borders configurable (#2535)

This commit is contained in:
David Heinemeier Hansson 2026-08-09 00:37:57 +02:00 committed by GitHub
parent 3a76fea2d8
commit 36a8d36a72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 102 additions and 12 deletions

View File

@ -658,6 +658,12 @@
"default": "true",
"description": "Draw borders around split panes."
},
{
"key": "ui.pane_outer_borders",
"type": "boolean",
"default": "true",
"description": "Draw borders along the outside edge of the pane area. Disable with pane gaps disabled for tmux-style internal splitters without an outside frame."
},
{
"key": "ui.pane_scrollbars",
"type": "boolean",

View File

@ -130,6 +130,7 @@ impl App {
terminal_area,
self.state.pane_borders,
self.state.pane_gaps,
self.state.pane_outer_borders,
);
if self.state.active == Some(ws_idx)
@ -290,15 +291,21 @@ fn derived_pending_agent_resume_pane_infos(
terminal_area: Rect,
pane_borders: bool,
pane_gaps: bool,
pane_outer_borders: bool,
) -> Vec<crate::layout::PaneInfo> {
crate::ui::apply_pane_chrome(tab.layout.panes(terminal_area), pane_borders, pane_gaps)
.into_iter()
.map(|mut info| {
let pane_inner = crate::ui::pane_inner_rect(info.rect, info.borders);
info.inner_rect = stable_terminal_inner_rect(pane_inner);
info
})
.collect()
crate::ui::apply_pane_chrome(
tab.layout.panes(terminal_area),
pane_borders,
pane_gaps,
pane_outer_borders,
)
.into_iter()
.map(|mut info| {
let pane_inner = crate::ui::pane_inner_rect(info.rect, info.borders);
info.inner_rect = stable_terminal_inner_rect(pane_inner);
info
})
.collect()
}
fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect {

View File

@ -1714,6 +1714,7 @@ impl App {
tab.layout.panes(area),
self.state.pane_borders,
self.state.pane_gaps,
self.state.pane_outer_borders,
)
.into_iter()
.filter_map(|pane| {

View File

@ -631,6 +631,7 @@ impl App {
prompt_new_tab_name: config.ui.prompt_new_tab_name,
prompt_new_workspace_name: config.ui.prompt_new_workspace_name,
pane_borders: config.ui.pane_borders,
pane_outer_borders: config.ui.pane_outer_borders,
pane_scrollbars: config.ui.pane_scrollbars,
pane_gaps: config.ui.pane_gaps,
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
@ -1439,6 +1440,7 @@ impl App {
self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name;
self.state.prompt_new_workspace_name = config.ui.prompt_new_workspace_name;
self.state.pane_borders = config.ui.pane_borders;
self.state.pane_outer_borders = config.ui.pane_outer_borders;
self.state.pane_scrollbars = config.ui.pane_scrollbars;
self.state.pane_gaps = config.ui.pane_gaps;
self.state.show_agent_labels_on_pane_borders =

View File

@ -1464,6 +1464,7 @@ pub struct AppState {
pub prompt_new_tab_name: bool,
pub prompt_new_workspace_name: bool,
pub pane_borders: bool,
pub pane_outer_borders: bool,
pub pane_scrollbars: bool,
pub pane_gaps: bool,
pub show_agent_labels_on_pane_borders: bool,
@ -1833,6 +1834,7 @@ impl AppState {
prompt_new_tab_name: true,
prompt_new_workspace_name: false,
pane_borders: true,
pane_outer_borders: true,
pane_scrollbars: true,
pane_gaps: false,
show_agent_labels_on_pane_borders: false,

View File

@ -838,6 +838,8 @@ pub struct UiConfig {
pub prompt_new_workspace_name: bool,
/// Draw borders around split panes. Default: true.
pub pane_borders: bool,
/// 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.
pub pane_scrollbars: bool,
/// Keep split panes visually separated instead of sharing divider borders. Default: true.
@ -1052,6 +1054,7 @@ impl Default for UiConfig {
prompt_new_tab_name: true,
prompt_new_workspace_name: false,
pane_borders: true,
pane_outer_borders: true,
pane_scrollbars: true,
pane_gaps: true,
show_agent_labels_on_pane_borders: false,
@ -1304,6 +1307,7 @@ status_indicators = "symbols"
fn pane_appearance_defaults_and_parse() {
let default_config = Config::default();
assert!(default_config.ui.pane_borders);
assert!(default_config.ui.pane_outer_borders);
assert!(default_config.ui.pane_scrollbars);
assert!(default_config.ui.pane_gaps);
assert!(!default_config.ui.show_agent_labels_on_pane_borders);
@ -1316,6 +1320,7 @@ status_indicators = "symbols"
let toml = r#"
[ui]
pane_borders = false
pane_outer_borders = false
pane_scrollbars = false
pane_gaps = true
show_agent_labels_on_pane_borders = true
@ -1324,6 +1329,7 @@ tab_bar_position = "bottom"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(!config.ui.pane_borders);
assert!(!config.ui.pane_outer_borders);
assert!(!config.ui.pane_scrollbars);
assert!(config.ui.pane_gaps);
assert!(config.ui.show_agent_labels_on_pane_borders);

View File

@ -306,6 +306,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Draw borders around split panes.
# pane_borders = true
# Draw borders along the outside edge of the pane area.
# Disable for tmux-style internal splitters without an outside frame.
# pane_outer_borders = true
# Draw interactive scrollbars beside terminal panes.
# Set false to reclaim the scrollbar column and keep it out of terminal-native selections.
# pane_scrollbars = true

View File

@ -91,8 +91,21 @@ pub(crate) fn apply_pane_chrome(
panes: Vec<PaneInfo>,
pane_borders: bool,
pane_gaps: bool,
pane_outer_borders: bool,
) -> Vec<PaneInfo> {
let multi_pane = panes.len() > 1;
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
.iter()
.map(|info| info.rect.x.saturating_add(info.rect.width))
.max()
.unwrap_or(0);
let outer_bottom = panes
.iter()
.map(|info| info.rect.y.saturating_add(info.rect.height))
.max()
.unwrap_or(0);
panes
.iter()
.cloned()
@ -121,6 +134,20 @@ pub(crate) fn apply_pane_chrome(
borders.remove(Borders::BOTTOM);
}
}
if !pane_outer_borders {
if info.rect.x == outer_left {
borders.remove(Borders::LEFT);
}
if info.rect.y == outer_top {
borders.remove(Borders::TOP);
}
if info.rect.x.saturating_add(info.rect.width) == outer_right {
borders.remove(Borders::RIGHT);
}
if info.rect.y.saturating_add(info.rect.height) == outer_bottom {
borders.remove(Borders::BOTTOM);
}
}
borders
};
info
@ -179,7 +206,7 @@ pub(super) fn resize_tab_panes(
if tab.zoomed {
let focused_id = tab.layout.focused();
if let Some((terminal_id, rt)) = runtime_for_tab_pane(terminal_runtimes, tab, focused_id) {
let borders = if multi_pane && app.pane_borders {
let borders = if multi_pane && app.pane_borders && app.pane_outer_borders {
Borders::ALL
} else {
Borders::NONE
@ -198,7 +225,12 @@ pub(super) fn resize_tab_panes(
return;
}
for info in apply_pane_chrome(tab.layout.panes(area), app.pane_borders, app.pane_gaps) {
for info in apply_pane_chrome(
tab.layout.panes(area),
app.pane_borders,
app.pane_gaps,
app.pane_outer_borders,
) {
let pane_inner = pane_inner_rect(info.rect, info.borders);
if let Some((terminal_id, rt)) = runtime_for_tab_pane(terminal_runtimes, tab, info.id) {
@ -234,7 +266,7 @@ pub(super) fn compute_pane_infos(
if ws.zoomed {
let focused_id = ws.layout.focused();
let borders = if multi_pane && app.pane_borders {
let borders = if multi_pane && app.pane_borders && app.pane_outer_borders {
Borders::ALL
} else {
Borders::NONE
@ -268,7 +300,12 @@ pub(super) fn compute_pane_infos(
}];
}
let mut pane_infos = apply_pane_chrome(ws.layout.panes(area), app.pane_borders, app.pane_gaps);
let mut pane_infos = apply_pane_chrome(
ws.layout.panes(area),
app.pane_borders,
app.pane_gaps,
app.pane_outer_borders,
);
for info in &mut pane_infos {
let pane_inner = pane_inner_rect(info.rect, info.borders);
@ -1054,6 +1091,7 @@ mod tests {
workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)),
true,
false,
true,
);
let left = infos.iter().find(|info| info.id == root).unwrap();
let right = infos.iter().find(|info| info.id == right).unwrap();
@ -1074,6 +1112,7 @@ mod tests {
workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)),
true,
false,
true,
);
let top = infos.iter().find(|info| info.id == root).unwrap();
let bottom = infos.iter().find(|info| info.id == bottom).unwrap();
@ -1083,6 +1122,26 @@ mod tests {
assert!(bottom.borders.contains(Borders::TOP));
}
#[test]
fn disabled_outer_borders_keep_only_shared_pane_dividers() {
let mut workspace = Workspace::test_new("test");
let root = workspace.tabs[0].root_pane;
let right = workspace.test_split(ratatui::layout::Direction::Horizontal);
workspace.tabs[0].layout.focus_pane(root);
let infos = apply_pane_chrome(
workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)),
true,
false,
false,
);
let left = infos.iter().find(|info| info.id == root).unwrap();
let right = infos.iter().find(|info| info.id == right).unwrap();
assert_eq!(left.borders, Borders::NONE);
assert_eq!(right.borders, Borders::LEFT);
}
#[test]
fn pane_gaps_keep_independent_bordered_panes() {
let mut workspace = Workspace::test_new("test");
@ -1094,6 +1153,7 @@ mod tests {
workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)),
true,
true,
true,
);
let left = infos.iter().find(|info| info.id == root).unwrap();
let right = infos.iter().find(|info| info.id == right).unwrap();
@ -1114,6 +1174,7 @@ mod tests {
workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)),
false,
true,
true,
);
let left = infos.iter().find(|info| info.id == root).unwrap();
let right = infos.iter().find(|info| info.id == right).unwrap();
@ -1133,6 +1194,7 @@ mod tests {
workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)),
false,
false,
true,
);
for info in infos {