aboutsummaryrefslogtreecommitdiff
path: root/src/app
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/app
parent92a10fc044d42b216317696a4e8e6f70d7cc7a7d (diff)
fix: add configurable pane gaps
refs #271
Diffstat (limited to 'src/app')
-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
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,