diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-21 14:02:19 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-21 14:03:59 +0300 |
| commit | 4421c0febbb03a1a4264729a70fb7c440a3b9ca8 (patch) | |
| tree | 72d1ade2b453ac1948daa85c50c2b6e3599cc204 /src | |
| parent | 92a10fc044d42b216317696a4e8e6f70d7cc7a7d (diff) | |
fix: add configurable pane gaps
refs #271
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/agent_resume.rs | 26 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 24 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 152 | ||||
| -rw-r--r-- | src/app/input/selection.rs | 2 | ||||
| -rw-r--r-- | src/app/mod.rs | 4 | ||||
| -rw-r--r-- | src/app/runtime.rs | 2 | ||||
| -rw-r--r-- | src/app/state.rs | 5 | ||||
| -rw-r--r-- | src/config/model.rs | 14 | ||||
| -rw-r--r-- | src/layout.rs | 11 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/ui.rs | 19 | ||||
| -rw-r--r-- | src/ui/panes.rs | 647 |
12 files changed, 804 insertions, 108 deletions
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 6a24487f..408f5983 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -1,10 +1,7 @@ use std::time::Instant; use bytes::Bytes; -use ratatui::{ - layout::Rect, - widgets::{Block, Borders}, -}; +use ratatui::layout::Rect; use super::App; @@ -128,7 +125,12 @@ impl App { tab: &crate::workspace::Tab, terminal_area: Rect, ) -> Vec<crate::layout::PaneInfo> { - let mut pane_infos = derived_pending_agent_resume_pane_infos(tab, terminal_area); + let mut pane_infos = derived_pending_agent_resume_pane_infos( + tab, + terminal_area, + self.state.pane_borders, + self.state.pane_gaps, + ); if self.state.active == Some(ws_idx) && self @@ -285,17 +287,13 @@ impl App { fn derived_pending_agent_resume_pane_infos( tab: &crate::workspace::Tab, terminal_area: Rect, + pane_borders: bool, + pane_gaps: bool, ) -> Vec<crate::layout::PaneInfo> { - let multi_pane = tab.layout.pane_count() > 1; - tab.layout - .panes(terminal_area) + crate::ui::apply_pane_chrome(tab.layout.panes(terminal_area), pane_borders, pane_gaps) .into_iter() .map(|mut info| { - let pane_inner = if multi_pane { - Block::default().borders(Borders::ALL).inner(info.rect) - } else { - terminal_area - }; + let pane_inner = crate::ui::pane_inner_rect(info.rect, info.borders); info.inner_rect = stable_terminal_inner_rect(pane_inner); info }) @@ -619,6 +617,7 @@ mod tests { rect: ratatui::layout::Rect::new(0, 0, 100, 30), inner_rect: ratatui::layout::Rect::new(1, 1, 98, 28), scrollbar_rect: None, + borders: ratatui::widgets::Borders::ALL, is_focused: true, }]; app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); @@ -735,6 +734,7 @@ mod tests { rect: ratatui::layout::Rect::new(0, 0, 100, 30), inner_rect: ratatui::layout::Rect::new(1, 1, 98, 28), scrollbar_rect: None, + borders: ratatui::widgets::Borders::ALL, is_focused: true, }]; app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 08a9b677..983db238 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1622,18 +1622,20 @@ impl App { let tab = ws.tabs.get(tab_idx)?; let area = self.state.view.terminal_area; let focused_pane_id = self.public_pane_id(ws_idx, tab.layout.focused())?; - let panes = tab - .layout - .panes(area) - .into_iter() - .filter_map(|pane| { - Some(PaneLayoutPane { - pane_id: self.public_pane_id(ws_idx, pane.id)?, - focused: pane.is_focused, - rect: pane.rect.into(), - }) + let panes = crate::ui::apply_pane_chrome( + tab.layout.panes(area), + self.state.pane_borders, + self.state.pane_gaps, + ) + .into_iter() + .filter_map(|pane| { + Some(PaneLayoutPane { + pane_id: self.public_pane_id(ws_idx, pane.id)?, + focused: pane.is_focused, + rect: pane.rect.into(), }) - .collect(); + }) + .collect(); let splits = tab .layout .splits(area) diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 41bb5267..c8a03674 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -393,11 +393,16 @@ impl AppState { if !in_sidebar { if let Some(border) = self.find_border_at(mouse.column, mouse.row) { + let grab_offset = match border.direction { + Direction::Horizontal => border.pos.saturating_sub(mouse.column), + Direction::Vertical => border.pos.saturating_sub(mouse.row), + }; self.drag = Some(DragState { target: DragTarget::PaneSplit { path: border.path.clone(), direction: border.direction, area: border.area, + grab_offset, }, }); return None; @@ -694,14 +699,23 @@ impl AppState { path, direction, area, + grab_offset, } => { let ratio = match direction { Direction::Horizontal => { - (mouse.column.saturating_sub(area.x)) as f32 + (mouse + .column + .saturating_add(*grab_offset) + .saturating_sub(area.x)) + as f32 / area.width.max(1) as f32 } Direction::Vertical => { - (mouse.row.saturating_sub(area.y)) as f32 + (mouse + .row + .saturating_add(*grab_offset) + .saturating_sub(area.y)) + as f32 / area.height.max(1) as f32 } }; @@ -1257,18 +1271,39 @@ impl AppState { pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> { self.view.split_borders.iter().find(|b| match b.direction { - Direction::Horizontal => { - col >= b.pos.saturating_sub(1) + Direction::Horizontal if self.pane_borders && !self.pane_gaps => { + col == b.pos && row >= b.area.y && row < b.area.y + b.area.height + } + Direction::Horizontal if self.pane_borders && self.pane_gaps => { + row >= b.area.y + && row < b.area.y + b.area.height + && col >= b.pos.saturating_sub(1) && col <= b.pos - && row >= b.area.y + } + Direction::Horizontal if !self.pane_borders && self.pane_gaps => { + row >= b.area.y && row < b.area.y + b.area.height + && b.pos.checked_sub(1).is_some_and(|gap_col| { + col == gap_col && self.pane_frame_at(col, row).is_none() + }) + } + Direction::Vertical if self.pane_borders && !self.pane_gaps => { + row == b.pos && col >= b.area.x && col < b.area.x + b.area.width } - Direction::Vertical => { - row >= b.pos.saturating_sub(1) + Direction::Vertical if self.pane_borders && self.pane_gaps => { + col >= b.area.x + && col < b.area.x + b.area.width + && row >= b.pos.saturating_sub(1) && row <= b.pos - && col >= b.area.x + } + Direction::Vertical if !self.pane_borders && self.pane_gaps => { + col >= b.area.x && col < b.area.x + b.area.width + && b.pos.checked_sub(1).is_some_and(|gap_row| { + row == gap_row && self.pane_frame_at(col, row).is_none() + }) } + _ => false, }) } @@ -2612,6 +2647,7 @@ mod tests { app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; + app.state.pane_gaps = false; app.state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app.state.view.split_borders[0].clone(); @@ -2620,7 +2656,7 @@ mod tests { assert!(app .state .find_border_at(border.pos.saturating_sub(1), row) - .is_some()); + .is_none()); assert!(app.state.find_border_at(border.pos, row).is_some()); assert!(app .state @@ -2634,6 +2670,7 @@ mod tests { app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; + app.state.pane_gaps = false; app.state.workspaces[0].test_split(Direction::Vertical); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app.state.view.split_borders[0].clone(); @@ -2642,7 +2679,7 @@ mod tests { assert!(app .state .find_border_at(col, border.pos.saturating_sub(1)) - .is_some()); + .is_none()); assert!(app.state.find_border_at(col, border.pos).is_some()); assert!(app .state @@ -2651,6 +2688,101 @@ mod tests { } #[test] + fn borderless_no_gap_split_has_no_mouse_hitbox_over_content() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.pane_borders = false; + app.state.workspaces[0].test_split(Direction::Horizontal); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let border = app.state.view.split_borders[0].clone(); + let row = border.area.y.saturating_add(1); + + assert!(app.state.find_border_at(border.pos, row).is_none()); + } + + #[test] + fn bordered_pane_gaps_keep_both_split_borders_draggable() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.pane_gaps = true; + app.state.workspaces[0].test_split(Direction::Horizontal); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let border = app.state.view.split_borders[0].clone(); + let row = border.area.y.saturating_add(1); + + assert!(app + .state + .find_border_at(border.pos.saturating_sub(1), row) + .is_some()); + assert!(app.state.find_border_at(border.pos, row).is_some()); + assert!(app + .state + .find_border_at(border.pos.saturating_add(1), row) + .is_none()); + } + + #[test] + fn borderless_pane_gap_is_not_a_pane_but_remains_split_draggable() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.pane_borders = false; + app.state.pane_gaps = true; + app.state.workspaces[0].test_split(Direction::Horizontal); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let border = app.state.view.split_borders[0].clone(); + let row = border.area.y.saturating_add(1); + let gap_col = border.pos.saturating_sub(1); + + assert!(app.state.pane_at(gap_col, row).is_none()); + assert!(app.state.find_border_at(gap_col, row).is_some()); + assert!(app.state.find_border_at(border.pos, row).is_none()); + } + + #[test] + fn borderless_gap_hitbox_is_empty_when_first_split_side_has_one_cell() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.pane_borders = false; + app.state.pane_gaps = true; + app.state.workspaces[0].test_split(Direction::Horizontal); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 2, 4)); + let border = app.state.view.split_borders[0].clone(); + let row = border.area.y.saturating_add(1); + let candidate_gap_col = border.pos.saturating_sub(1); + + assert!(app.state.pane_frame_at(candidate_gap_col, row).is_some()); + assert!(app.state.find_border_at(candidate_gap_col, row).is_none()); + } + + #[test] + fn borderless_gap_hitbox_is_empty_when_first_split_side_has_zero_width() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.pane_borders = false; + app.state.pane_gaps = true; + app.state.workspaces[0].test_split(Direction::Horizontal); + app.state.workspaces[0].tabs[0] + .layout + .set_ratio_at(&[], 0.1); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 1, 4)); + let border = app.state.view.split_borders[0].clone(); + let row = border.area.y.saturating_add(1); + + assert_eq!(border.pos, 0); + assert!(app.state.find_border_at(0, row).is_none()); + } + + #[test] fn selecting_from_right_pane_first_content_column_starts_selection() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs index 0ccb5506..a66509f0 100644 --- a/src/app/input/selection.rs +++ b/src/app/input/selection.rs @@ -189,6 +189,7 @@ mod autoscroll_tests { rect: Rect::new(0, 0, 80, 24), inner_rect: Rect::new(0, 0, 80, 24), scrollbar_rect: None, + borders: ratatui::widgets::Borders::NONE, is_focused: true, }); (state, pane_id) @@ -207,6 +208,7 @@ mod autoscroll_tests { rect: Rect::new(0, 5, 80, 24), inner_rect: Rect::new(0, 5, 80, 24), scrollbar_rect: None, + borders: ratatui::widgets::Borders::NONE, is_focused: true, }); // Anchor at (5, 10), drag to different cell above pane diff --git a/src/app/mod.rs b/src/app/mod.rs index 0b944cdf..4fd94697 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -598,6 +598,8 @@ impl App { mouse_scroll_lines: config.ui.mouse_scroll_lines(), confirm_close: config.ui.confirm_close, prompt_new_tab_name: config.ui.prompt_new_tab_name, + pane_borders: config.ui.pane_borders, + pane_gaps: config.ui.pane_gaps, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, pane_history_persistence: config.experimental.pane_history, reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, @@ -1327,6 +1329,8 @@ impl App { config.ui.right_click_passthrough_modifiers(); self.state.confirm_close = config.ui.confirm_close; self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; + self.state.pane_borders = config.ui.pane_borders; + self.state.pane_gaps = config.ui.pane_gaps; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; self.state.agent_panel_sort = diff --git a/src/app/runtime.rs b/src/app/runtime.rs index e3d5bc92..b8f8091b 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -697,6 +697,7 @@ mod tests { rect: ratatui::layout::Rect::new(0, 0, 80, 24), inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), scrollbar_rect: None, + borders: ratatui::widgets::Borders::NONE, is_focused: true, }); (app, pane_id) @@ -933,6 +934,7 @@ mod tests { rect: ratatui::layout::Rect::new(0, 0, cols, rows), inner_rect: ratatui::layout::Rect::new(0, 0, cols, rows), scrollbar_rect: None, + borders: ratatui::widgets::Borders::NONE, is_focused: true, }); (app, pane_id) diff --git a/src/app/state.rs b/src/app/state.rs index 55d41358..9e5dbed1 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1019,6 +1019,7 @@ pub(crate) enum DragTarget { path: Vec<bool>, direction: Direction, area: Rect, + grab_offset: u16, }, PaneScrollbar { pane_id: crate::layout::PaneId, @@ -1361,6 +1362,8 @@ pub struct AppState { pub mouse_scroll_lines: usize, pub confirm_close: bool, pub prompt_new_tab_name: bool, + pub pane_borders: bool, + pub pane_gaps: bool, pub show_agent_labels_on_pane_borders: bool, pub pane_history_persistence: bool, /// Expose the focused pane's cursor anchor to the outer terminal even when @@ -1715,6 +1718,8 @@ impl AppState { mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES, confirm_close: true, prompt_new_tab_name: true, + pane_borders: true, + pane_gaps: false, show_agent_labels_on_pane_borders: false, pane_history_persistence: false, reveal_hidden_cursor_for_cjk_ime: false, diff --git a/src/config/model.rs b/src/config/model.rs index d07a425f..5396e26b 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -444,6 +444,10 @@ pub struct UiConfig { pub confirm_close: bool, /// Ask for a tab name before creating a new tab. Default: true. pub prompt_new_tab_name: bool, + /// Draw borders around split panes. Default: true. + pub pane_borders: bool, + /// Keep split panes visually separated instead of sharing divider borders. Default: true. + pub pane_gaps: bool, /// Show agent labels in split pane borders when no manual pane label is set. Default: false. pub show_agent_labels_on_pane_borders: bool, /// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces". @@ -632,6 +636,8 @@ impl Default for UiConfig { mouse_scroll_lines: None, confirm_close: true, prompt_new_tab_name: true, + pane_borders: true, + pane_gaps: true, show_agent_labels_on_pane_borders: false, agent_panel_sort: AgentPanelSortConfig::Spaces, accent: "cyan".into(), @@ -839,15 +845,21 @@ agent_panel_scope = "current" } #[test] - fn pane_border_agent_labels_default_off_and_parse() { + fn pane_appearance_defaults_and_parse() { let default_config = Config::default(); + assert!(default_config.ui.pane_borders); + assert!(default_config.ui.pane_gaps); assert!(!default_config.ui.show_agent_labels_on_pane_borders); let toml = r#" [ui] +pane_borders = false +pane_gaps = true show_agent_labels_on_pane_borders = true "#; let config: Config = toml::from_str(toml).unwrap(); + assert!(!config.ui.pane_borders); + assert!(config.ui.pane_gaps); assert!(config.ui.show_agent_labels_on_pane_borders); } diff --git a/src/layout.rs b/src/layout.rs index 06426f85..cd611d10 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -2,7 +2,10 @@ use std::cmp::Reverse; -use ratatui::layout::{Direction, Rect}; +use ratatui::{ + layout::{Direction, Rect}, + widgets::Borders, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct PaneId(u32); @@ -37,6 +40,8 @@ pub struct PaneInfo { /// Visible scrollbar lane, when scrollback is present. `inner_rect` may still /// exclude a stable hidden gutter when this is `None`. pub scrollbar_rect: Option<Rect>, + /// Borders drawn around this pane after UI chrome is applied. + pub borders: Borders, pub is_focused: bool, } @@ -414,6 +419,7 @@ fn collect_panes(node: &Node, area: Rect, focus: PaneId, result: &mut Vec<PaneIn // inner_rect is set during render when we know if borders are shown inner_rect: area, scrollbar_rect: None, + borders: Borders::NONE, is_focused: *id == focus, }); } @@ -919,6 +925,7 @@ mod tests { rect: Rect::new(10, 10, 10, 10), inner_rect: Rect::new(10, 10, 10, 10), scrollbar_rect: None, + borders: Borders::NONE, is_focused: true, }; let small_overlap_first = PaneInfo { @@ -926,6 +933,7 @@ mod tests { rect: Rect::new(0, 10, 10, 2), inner_rect: Rect::new(0, 10, 10, 2), scrollbar_rect: None, + borders: Borders::NONE, is_focused: false, }; let larger_overlap_second = PaneInfo { @@ -933,6 +941,7 @@ mod tests { rect: Rect::new(0, 10, 10, 8), inner_rect: Rect::new(0, 10, 10, 8), scrollbar_rect: None, + borders: Borders::NONE, is_focused: false, }; let panes = vec![focused.clone(), small_overlap_first, larger_overlap_second]; diff --git a/src/main.rs b/src/main.rs index 193e24d8..ed4d4545 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,6 +272,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Set false to create tabs immediately with generated names. # prompt_new_tab_name = true +# Draw borders around split panes. +# pane_borders = true + +# Keep split panes visually separated instead of sharing divider borders. +# pane_gaps = true + # Show detected/reported agent labels in split pane borders when no manual pane name is set. # show_agent_labels_on_pane_borders = false @@ -83,7 +83,7 @@ pub(crate) use self::{ mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at, mobile_switcher_workspace_doc_range, MobileSwitcherTarget, }, - panes::pane_is_scrolled_back, + panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back}, tabs::compute_tab_bar_view, widgets::{centered_popup_rect, modal_stack_areas}, }; @@ -235,7 +235,13 @@ fn compute_view_internal( let split_borders = app .active .and_then(|i| app.workspaces.get(i)) - .map(|ws| ws.layout.splits(terminal_area)) + .map(|ws| { + if ws.zoomed { + Vec::new() + } else { + ws.layout.splits(terminal_area) + } + }) .unwrap_or_default(); let pane_infos = compute_pane_infos( @@ -310,7 +316,13 @@ fn compute_mobile_view( let split_borders = app .active .and_then(|i| app.workspaces.get(i)) - .map(|ws| ws.layout.splits(terminal_area)) + .map(|ws| { + if ws.zoomed { + Vec::new() + } else { + ws.layout.splits(terminal_area) + } + }) .unwrap_or_default(); let pane_infos = compute_pane_infos( @@ -1001,6 +1013,7 @@ mod tests { rect: Rect::new(0, 0, 12, 8), inner_rect: Rect::new(1, 1, 9, 6), scrollbar_rect: Some(Rect::new(10, 1, 1, 6)), + borders: ratatui::widgets::Borders::ALL, is_focused: true, }; diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 472dd4b1..03174762 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -33,13 +33,18 @@ fn truncate_label(text: &str, max_width: usize) -> String { format!("{prefix}…") } -fn pane_border_title(label: &str, pane_width: u16) -> Option<String> { +fn pane_border_title(label: &str, pane_width: u16, focused: bool) -> Option<String> { let label = label.trim(); if label.is_empty() || pane_width <= 4 { return None; } - let max_label_width = pane_width.saturating_sub(4) as usize; - Some(format!(" {} ", truncate_label(label, max_label_width))) + if focused { + let max_label_width = pane_width.saturating_sub(5) as usize; + Some(format!("▌ {} ", truncate_label(label, max_label_width))) + } else { + let max_label_width = pane_width.saturating_sub(4) as usize; + Some(format!(" {} ", truncate_label(label, max_label_width))) + } } fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect { @@ -55,14 +60,90 @@ fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect { ) } -fn pane_inner_rect(area: Rect, framed: bool) -> Rect { - if framed { - Block::default().borders(Borders::ALL).inner(area) - } else { +pub(crate) fn pane_inner_rect(area: Rect, borders: Borders) -> Rect { + if borders.is_empty() { area + } else { + Block::default().borders(borders).inner(area) } } +fn ranges_overlap(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> bool { + a_start < b_start.saturating_add(b_len) && b_start < a_start.saturating_add(a_len) +} + +fn pane_to_right<'a>(info: &PaneInfo, panes: &'a [PaneInfo]) -> Option<&'a PaneInfo> { + let right = info.rect.x.saturating_add(info.rect.width); + panes.iter().find(|other| { + other.id != info.id + && other.rect.x == right + && ranges_overlap( + info.rect.y, + info.rect.height, + other.rect.y, + other.rect.height, + ) + }) +} + +fn pane_below<'a>(info: &PaneInfo, panes: &'a [PaneInfo]) -> Option<&'a PaneInfo> { + let bottom = info.rect.y.saturating_add(info.rect.height); + panes.iter().find(|other| { + other.id != info.id + && other.rect.y == bottom + && ranges_overlap(info.rect.x, info.rect.width, other.rect.x, other.rect.width) + }) +} + +fn shrink_for_one_cell_gap(size: u16) -> u16 { + if size > 1 { + size - 1 + } else { + size + } +} + +pub(crate) fn apply_pane_chrome( + panes: Vec<PaneInfo>, + pane_borders: bool, + pane_gaps: bool, +) -> Vec<PaneInfo> { + let multi_pane = panes.len() > 1; + panes + .iter() + .cloned() + .map(|mut info| { + 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 right_neighbor.is_some() { + info.rect.width = shrink_for_one_cell_gap(info.rect.width); + } + if below_neighbor.is_some() { + info.rect.height = shrink_for_one_cell_gap(info.rect.height); + } + } + + info.borders = if !multi_pane || !pane_borders { + Borders::NONE + } else { + let mut borders = Borders::ALL; + if !pane_gaps { + if right_neighbor.is_some() { + borders.remove(Borders::RIGHT); + } + if below_neighbor.is_some() { + borders.remove(Borders::BOTTOM); + } + } + borders + }; + info + }) + .collect() +} + fn runtime_for_tab_pane<'a>( terminal_runtimes: &'a TerminalRuntimeRegistry, tab: &'a crate::workspace::Tab, @@ -110,7 +191,12 @@ 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 pane_inner = pane_inner_rect(area, multi_pane); + let borders = if multi_pane && app.pane_borders { + Borders::ALL + } else { + Borders::NONE + }; + let pane_inner = pane_inner_rect(area, borders); let inner_rect = stable_terminal_inner_rect(pane_inner); if !app.direct_attach_resize_locks.contains(terminal_id) { rt.resize( @@ -124,12 +210,8 @@ pub(super) fn resize_tab_panes( return; } - for info in tab.layout.panes(area) { - let pane_inner = if multi_pane { - Block::default().borders(Borders::ALL).inner(info.rect) - } else { - area - }; + for info in apply_pane_chrome(tab.layout.panes(area), app.pane_borders, app.pane_gaps) { + 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) { let inner_rect = stable_terminal_inner_rect(pane_inner); @@ -161,11 +243,15 @@ pub(super) fn compute_pane_infos( }; let multi_pane = ws.layout.pane_count() > 1; - let terminal_active = app.mode == Mode::Terminal; if ws.zoomed { let focused_id = ws.layout.focused(); - let pane_inner = pane_inner_rect(area, multi_pane); + let borders = if multi_pane && app.pane_borders { + Borders::ALL + } else { + Borders::NONE + }; + let pane_inner = pane_inner_rect(area, borders); let mut inner_rect = pane_inner; let mut scrollbar_rect = None; if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, focused_id) { @@ -188,26 +274,15 @@ pub(super) fn compute_pane_infos( rect: area, inner_rect, scrollbar_rect, + borders, is_focused: true, }]; } - let mut pane_infos = ws.layout.panes(area); + let mut pane_infos = apply_pane_chrome(ws.layout.panes(area), app.pane_borders, app.pane_gaps); for info in &mut pane_infos { - let pane_inner = if multi_pane { - let border_set = if info.is_focused && terminal_active { - ratatui::symbols::border::THICK - } else { - ratatui::symbols::border::PLAIN - }; - let block = Block::default() - .borders(Borders::ALL) - .border_set(border_set); - block.inner(info.rect) - } else { - area - }; + let pane_inner = pane_inner_rect(info.rect, info.borders); let mut inner_rect = pane_inner; let mut scrollbar_rect = None; @@ -254,41 +329,6 @@ pub(super) fn render_panes( for info in &app.view.pane_infos { if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { - if multi_pane { - let (border_style, border_set) = if info.is_focused && terminal_active { - ( - Style::default().fg(app.palette.accent), - ratatui::symbols::border::THICK, - ) - } else if info.is_focused { - ( - Style::default().fg(app.palette.accent), - ratatui::symbols::border::PLAIN, - ) - } else { - ( - Style::default().fg(app.palette.overlay0), - ratatui::symbols::border::PLAIN, - ) - }; - - let mut block = Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .border_set(border_set); - if let Some(title) = ws - .pane_state(info.id) - .and_then(|pane| app.terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| { - terminal.border_label(app.show_agent_labels_on_pane_borders) - }) - .and_then(|label| pane_border_title(&label, info.rect.width)) - { - block = block.title(Line::from(Span::styled(title, border_style))); - } - frame.render_widget(block, info.rect); - } - let show_cursor = info.is_focused && terminal_active && !pane_is_scrolled_back(rt) @@ -320,6 +360,259 @@ pub(super) fn render_panes( render_copy_mode_cursor(app, frame, info); } } + + render_pane_borders(app, ws, frame); +} + +#[derive(Clone, Copy, Default)] +struct LineCell { + up: bool, + down: bool, + left: bool, + right: bool, +} + +fn render_pane_borders(app: &AppState, ws: &crate::workspace::Workspace, frame: &mut Frame) { + if !app.pane_borders + || app + .view + .pane_infos + .iter() + .all(|info| info.borders.is_empty()) + { + return; + } + + let mut cells = std::collections::HashMap::<(u16, u16), LineCell>::new(); + for info in &app.view.pane_infos { + add_pane_border_cells(&mut cells, info); + } + add_split_border_cells(app, &mut cells); + + let buf = frame.buffer_mut(); + let area = buf.area; + for ((x, y), line) in cells { + if x < area.x + || x >= area.x.saturating_add(area.width) + || y < area.y + || y >= area.y.saturating_add(area.height) + { + continue; + } + let focused_touching = app + .view + .pane_infos + .iter() + .any(|info| info.is_focused && line_touches_pane(x, y, info, app.pane_gaps)); + let branch_cell = !app.pane_gaps && line_cell_degree(line) >= 3; + let focused = focused_touching && !branch_cell; + let symbol = line_cell_symbol(line); + if symbol.is_empty() { + continue; + } + let cell = &mut buf[(x, y)]; + cell.set_symbol(symbol); + let color = if focused { + app.palette.accent + } else { + app.palette.overlay0 + }; + cell.set_style(Style::default().fg(color)); + } + + render_pane_border_titles(app, ws, frame); +} + +fn add_split_border_cells( + app: &AppState, + cells: &mut std::collections::HashMap<(u16, u16), LineCell>, +) { + if app.pane_gaps { + return; + } + + for split in &app.view.split_borders { + match split.direction { + ratatui::layout::Direction::Horizontal => { + let x = split.pos; + let end = split.area.y.saturating_add(split.area.height); + for y in split.area.y..=end { + if !cells.contains_key(&(x, y)) { + continue; + } + let left = x + .checked_sub(1) + .and_then(|left_x| cells.get(&(left_x, y))) + .is_some_and(|cell| cell.left || cell.right); + let right = cells + .get(&(x.saturating_add(1), y)) + .is_some_and(|cell| cell.left || cell.right); + let cell = cells.entry((x, y)).or_default(); + cell.up |= y > split.area.y; + cell.down |= y + 1 < end; + cell.left |= left; + cell.right |= right; + } + } + ratatui::layout::Direction::Vertical => { + let y = split.pos; + let end = split.area.x.saturating_add(split.area.width); + for x in split.area.x..=end { + if !cells.contains_key(&(x, y)) { + continue; + } + let up = y + .checked_sub(1) + .and_then(|up_y| cells.get(&(x, up_y))) + .is_some_and(|cell| cell.up || cell.down); + let down = cells + .get(&(x, y.saturating_add(1))) + .is_some_and(|cell| cell.up || cell.down); + let cell = cells.entry((x, y)).or_default(); + cell.left |= x > split.area.x; + cell.right |= x + 1 < end; + cell.up |= up; + cell.down |= down; + } + } + } + } +} + +fn add_pane_border_cells( + cells: &mut std::collections::HashMap<(u16, u16), LineCell>, + info: &PaneInfo, +) { + let rect = info.rect; + if rect.width == 0 || rect.height == 0 { + return; + } + let right = rect.x.saturating_add(rect.width).saturating_sub(1); + let bottom = rect.y.saturating_add(rect.height).saturating_sub(1); + + if info.borders.contains(Borders::TOP) { + for x in rect.x..=right { + let cell = cells.entry((x, rect.y)).or_default(); + cell.left |= x > rect.x; + cell.right |= x < right; + } + } + if info.borders.contains(Borders::BOTTOM) { + for x in rect.x..=right { + let cell = cells.entry((x, bottom)).or_default(); + cell.left |= x > rect.x; + cell.right |= x < right; + } + } + if info.borders.contains(Borders::LEFT) { + for y in rect.y..=bottom { + let cell = cells.entry((rect.x, y)).or_default(); + cell.up |= y > rect.y; + cell.down |= y < bottom; + } + } + if info.borders.contains(Borders::RIGHT) { + for y in rect.y..=bottom { + let cell = cells.entry((right, y)).or_default(); + cell.up |= y > rect.y; + cell.down |= y < bottom; + } + } +} + +fn line_touches_pane(x: u16, y: u16, info: &PaneInfo, pane_gaps: bool) -> bool { + let rect = info.rect; + if rect.width == 0 || rect.height == 0 { + return false; + } + let right = rect.x.saturating_add(rect.width).saturating_sub(1); + let bottom = rect.y.saturating_add(rect.height).saturating_sub(1); + let in_rows = y >= rect.y && y <= bottom; + let in_cols = x >= rect.x && x <= right; + let own_border = + (in_rows && (x == rect.x || x == right)) || (in_cols && (y == rect.y || y == bottom)); + + if pane_gaps { + return own_border; + } + + let shared_right = rect.x.saturating_add(rect.width); + let shared_bottom = rect.y.saturating_add(rect.height); + own_border + || (in_rows && x == shared_right) + || (in_cols && y == shared_bottom) + || (x == shared_right && y == shared_bottom) +} + +fn render_pane_border_titles(app: &AppState, ws: &crate::workspace::Workspace, frame: &mut Frame) { + let buf = frame.buffer_mut(); + let area = buf.area; + for info in &app.view.pane_infos { + if !info.borders.contains(Borders::TOP) || info.rect.width <= 4 { + continue; + } + let Some(title) = ws + .pane_state(info.id) + .and_then(|pane| app.terminals.get(&pane.attached_terminal_id)) + .and_then(|terminal| terminal.border_label(app.show_agent_labels_on_pane_borders)) + .and_then(|label| pane_border_title(&label, info.rect.width, info.is_focused)) + else { + continue; + }; + let y = info.rect.y; + if y < area.y || y >= area.y.saturating_add(area.height) { + continue; + } + for (idx, ch) in title.chars().enumerate() { + let x = info.rect.x.saturating_add(1).saturating_add(idx as u16); + if x >= area.x.saturating_add(area.width) + || x >= info + .rect + .x + .saturating_add(info.rect.width) + .saturating_sub(1) + { + break; + } + let color = if info.is_focused { + app.palette.accent + } else { + app.palette.overlay0 + }; + let mut style = Style::default().fg(color); + if info.is_focused { + style = style.add_modifier(Modifier::BOLD); + } + let cell = &mut buf[(x, y)]; + cell.set_symbol(&ch.to_string()); + cell.set_style(style); + } + } +} + +fn line_cell_degree(line: LineCell) -> u8 { + line.up as u8 + line.down as u8 + line.left as u8 + line.right as u8 +} + +fn line_cell_symbol(line: LineCell) -> &'static str { + match (line.up, line.down, line.left, line.right) { + (true, true, true, true) => "┼", + (true, true, true, false) => "┤", + (true, true, false, true) => "├", + (true, false, true, true) => "┴", + (false, true, true, true) => "┬", + (true, true, false, false) | (true, false, false, false) | (false, true, false, false) => { + "│" + } + (false, false, true, true) | (false, false, true, false) | (false, false, false, true) => { + "─" + } + (false, true, false, true) => "┌", + (false, true, true, false) => "┐", + (true, false, false, true) => "└", + (true, false, true, false) => "┘", + _ => "", + } } fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) { @@ -519,12 +812,228 @@ mod tests { #[test] fn pane_border_title_trims_and_truncates() { assert_eq!( - pane_border_title(" claude ", 20).as_deref(), + pane_border_title(" claude ", 20, false).as_deref(), Some(" claude ") ); - assert_eq!(pane_border_title("", 20), None); - assert_eq!(pane_border_title("abcdef", 8).as_deref(), Some(" abc… ")); - assert_eq!(pane_border_title("abcdef", 4), None); + assert_eq!( + pane_border_title(" claude ", 20, true).as_deref(), + Some("▌ claude ") + ); + assert_eq!(pane_border_title("", 20, false), None); + assert_eq!( + pane_border_title("abcdef", 8, false).as_deref(), + Some(" abc… ") + ); + assert_eq!( + pane_border_title("abcdef", 8, true).as_deref(), + Some("▌ ab… ") + ); + assert_eq!(pane_border_title("abcdef", 4, false), None); + } + + #[test] + fn default_horizontal_split_uses_one_shared_divider_column() { + 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, + ); + let left = infos.iter().find(|info| info.id == root).unwrap(); + let right = infos.iter().find(|info| info.id == right).unwrap(); + + assert_eq!(left.rect.x + left.rect.width, right.rect.x); + assert!(!left.borders.contains(Borders::RIGHT)); + assert!(right.borders.contains(Borders::LEFT)); + } + + #[test] + fn default_vertical_split_uses_one_shared_divider_row() { + let mut workspace = Workspace::test_new("test"); + let root = workspace.tabs[0].root_pane; + let bottom = workspace.test_split(ratatui::layout::Direction::Vertical); + 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, + ); + let top = infos.iter().find(|info| info.id == root).unwrap(); + let bottom = infos.iter().find(|info| info.id == bottom).unwrap(); + + assert_eq!(top.rect.y + top.rect.height, bottom.rect.y); + assert!(!top.borders.contains(Borders::BOTTOM)); + assert!(bottom.borders.contains(Borders::TOP)); + } + + #[test] + fn pane_gaps_keep_independent_bordered_panes() { + 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, + true, + ); + let left = infos.iter().find(|info| info.id == root).unwrap(); + let right = infos.iter().find(|info| info.id == right).unwrap(); + + assert_eq!(left.rect.x + left.rect.width, right.rect.x); + assert_eq!(left.borders, Borders::ALL); + assert_eq!(right.borders, Borders::ALL); + } + + #[test] + fn borderless_pane_gaps_add_one_empty_cell_between_panes() { + 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)), + false, + true, + ); + let left = infos.iter().find(|info| info.id == root).unwrap(); + let right = infos.iter().find(|info| info.id == right).unwrap(); + + assert_eq!(left.rect, Rect::new(0, 0, 49, 20)); + assert_eq!(right.rect, Rect::new(50, 0, 50, 20)); + assert!(left.borders.is_empty()); + assert!(right.borders.is_empty()); + } + + #[test] + fn disabled_pane_borders_make_inner_rect_equal_visual_rect() { + let mut workspace = Workspace::test_new("test"); + workspace.test_split(ratatui::layout::Direction::Horizontal); + + let infos = apply_pane_chrome( + workspace.tabs[0].layout.panes(Rect::new(0, 0, 100, 20)), + false, + false, + ); + + for info in infos { + assert!(info.borders.is_empty()); + assert_eq!(pane_inner_rect(info.rect, info.borders), info.rect); + } + } + + #[test] + fn global_pane_border_renderer_composes_junctions_and_focus_style() { + let mut app = AppState::test_new(); + app.mode = Mode::Terminal; + app.view.terminal_area = Rect::new(0, 0, 4, 4); + app.view.pane_infos = vec![ + PaneInfo { + id: PaneId::from_raw(1), + rect: Rect::new(0, 0, 2, 2), + inner_rect: Rect::default(), + scrollbar_rect: None, + borders: Borders::TOP | Borders::LEFT, + is_focused: true, + }, + PaneInfo { + id: PaneId::from_raw(2), + rect: Rect::new(2, 0, 2, 2), + inner_rect: Rect::default(), + scrollbar_rect: None, + borders: Borders::TOP | Borders::LEFT | Borders::RIGHT, + is_focused: false, + }, + PaneInfo { + id: PaneId::from_raw(3), + rect: Rect::new(0, 2, 2, 2), + inner_rect: Rect::default(), + scrollbar_rect: None, + borders: Borders::TOP | Borders::LEFT | Borders::BOTTOM, + is_focused: false, + }, + PaneInfo { + id: PaneId::from_raw(4), + rect: Rect::new(2, 2, 2, 2), + inner_rect: Rect::default(), + scrollbar_rect: None, + borders: Borders::ALL, + is_focused: false, + }, + ]; + app.view.split_borders = vec![ + crate::layout::SplitBorder { + pos: 2, + direction: ratatui::layout::Direction::Horizontal, + ratio: 0.5, + area: Rect::new(0, 0, 4, 4), + path: vec![], + }, + crate::layout::SplitBorder { + pos: 2, + direction: ratatui::layout::Direction::Vertical, + ratio: 0.5, + area: Rect::new(0, 0, 4, 4), + path: vec![false], + }, + ]; + let ws = Workspace::test_new("test"); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 4)).unwrap(); + + terminal + .draw(|frame| render_pane_borders(&app, &ws, frame)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(2, 2)].symbol(), "┼"); + assert_eq!(buffer[(2, 2)].style().fg, Some(app.palette.overlay0)); + assert_eq!(buffer[(2, 1)].symbol(), "│"); + assert_eq!(buffer[(2, 1)].style().fg, Some(app.palette.accent)); + } + + #[test] + fn gapped_pane_focus_does_not_color_neighbor_border() { + let mut app = AppState::test_new(); + app.mode = Mode::Terminal; + app.pane_gaps = true; + app.view.terminal_area = Rect::new(0, 0, 4, 3); + app.view.pane_infos = vec![ + PaneInfo { + id: PaneId::from_raw(1), + rect: Rect::new(0, 0, 2, 3), + inner_rect: Rect::default(), + scrollbar_rect: None, + borders: Borders::ALL, + is_focused: true, + }, + PaneInfo { + id: PaneId::from_raw(2), + rect: Rect::new(2, 0, 2, 3), + inner_rect: Rect::default(), + scrollbar_rect: None, + borders: Borders::ALL, + is_focused: false, + }, + ]; + let ws = Workspace::test_new("test"); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 3)).unwrap(); + + terminal + .draw(|frame| render_pane_borders(&app, &ws, frame)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(1, 1)].style().fg, Some(app.palette.accent)); + assert_eq!(buffer[(2, 1)].style().fg, Some(app.palette.overlay0)); } #[tokio::test] |
