aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-21 14:02:19 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-21 14:03:59 +0300
commit4421c0febbb03a1a4264729a70fb7c440a3b9ca8 (patch)
tree72d1ade2b453ac1948daa85c50c2b6e3599cc204 /src
parent92a10fc044d42b216317696a4e8e6f70d7cc7a7d (diff)
fix: add configurable pane gaps
refs #271
Diffstat (limited to 'src')
-rw-r--r--src/app/agent_resume.rs26
-rw-r--r--src/app/api/panes.rs24
-rw-r--r--src/app/input/mouse.rs152
-rw-r--r--src/app/input/selection.rs2
-rw-r--r--src/app/mod.rs4
-rw-r--r--src/app/runtime.rs2
-rw-r--r--src/app/state.rs5
-rw-r--r--src/config/model.rs14
-rw-r--r--src/layout.rs11
-rw-r--r--src/main.rs6
-rw-r--r--src/ui.rs19
-rw-r--r--src/ui/panes.rs647
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
diff --git a/src/ui.rs b/src/ui.rs
index 9c6d24d8..42e132d1 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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]