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/app | |
| parent | 92a10fc044d42b216317696a4e8e6f70d7cc7a7d (diff) | |
fix: add configurable pane gaps
refs #271
Diffstat (limited to 'src/app')
| -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 |
7 files changed, 181 insertions, 34 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, |
