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/ui | |
| parent | 92a10fc044d42b216317696a4e8e6f70d7cc7a7d (diff) | |
fix: add configurable pane gaps
refs #271
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/panes.rs | 647 |
1 files changed, 578 insertions, 69 deletions
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] |
