From de35fc287286fc43f40d1b12e9ba222504ff6364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Thu, 16 Jul 2026 15:10:45 -0400 Subject: [PATCH] feat(config,ui): add window.separator_px for configurable pane separator width Thread a runtime `sep` width through the layout tree (split_dimension, compute_rects, separators, find_sep_at_pixel) and the public Layout wrappers instead of the private `SEP` const. The App resolves it from config via `Scale::chrome`, making the separator DPI-aware. `.max(1)` guards against a 0 config for hit-testing and rendering. --- CHANGELOG.md | 1 + README.md | 1 + assets/config.toml | 1 + doc/SPEC.md | 1 + src/app_event.rs | 13 ++++-- src/config/mod.rs | 5 +++ src/config/tui_config.rs | 4 ++ src/config/tui_config_test.rs | 1 + src/input/mouse_ops.rs | 5 ++- src/input_ops.rs | 18 ++++++-- src/main.rs | 8 ++++ src/pane_ops.rs | 38 +++++++++++++--- src/pane_ops_test.rs | 2 +- src/renderer/overlays_test.rs | 2 + src/renderer/render_ops.rs | 5 ++- src/renderer/views.rs | 3 +- src/renderer/views_test.rs | 4 +- src/restore.rs | 9 +++- src/ui/layout.rs | 83 +++++++++++++++++++++-------------- src/ui/layout_test.rs | 26 +++++++++-- 20 files changed, 171 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..9585075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- add `window.separator_px` to configure the width of the separator between panes - add `--maximized` and `--fullscreen` flags to start the window in that mode - persist and restore window size, maximized, and fullscreen state per session - REP (`CSI Ps b`): repeat the last printed character `Ps` times diff --git a/README.md b/README.md index c55a473..713ffe4 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ width = 800 height = 600 title = "mmterm" cursor_blink_ms = 500 +separator_px = 1 # width in px of the separator between panes (DPI-scaled) [shell] # program = "/bin/zsh" # defaults to $SHELL diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..afd1b95 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -11,6 +11,7 @@ title = "mmterm" cursor_blink_ms = 500 inactive_dim = 0.55 detect_urls = true +separator_px = 1 [shell] # program = "/bin/zsh" diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..17c53ca 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -221,6 +221,7 @@ Screenshot capture is a two-step flow: region selection followed by a name promp | window | cursor_blink_ms | uint | `500` | | window | inactive_dim | float | `0.55` | | window | detect_urls | bool | `true` | +| window | separator_px | uint | `1` | | terminal | scrollback_lines | uint | `10000` (min 100) | | shell | program | string? | `$SHELL` | | logging | auto_log | bool | `false` | diff --git a/src/app_event.rs b/src/app_event.rs index cf483e7..519121c 100644 --- a/src/app_event.rs +++ b/src/app_event.rs @@ -461,8 +461,14 @@ impl App { fn separator_at_pixel(&self, px: u32, py: u32) -> Option { let tab = &self.state.tabs[self.state.active_tab]; if !tab.zoomed { - tab.layout - .separator_at_pixel_scaled(px, py, 4, self.tab_h(), self.status_h()) + tab.layout.separator_at_pixel_scaled( + px, + py, + 4, + self.tab_h(), + self.status_h(), + self.separator_px(), + ) } else { None } @@ -601,7 +607,8 @@ impl App { let tab_h = self.tab_h(); let status_h = self.status_h(); let pane_padding = self.pane_padding(); - Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, pane_padding); + let sep = self.separator_px(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, sep, pane_padding); let icon = match handle.dir { SplitDir::H => CursorIcon::ColResize, SplitDir::V => CursorIcon::RowResize, diff --git a/src/config/mod.rs b/src/config/mod.rs index 571c12a..609069d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -147,6 +147,9 @@ fn default_inactive_dim() -> f32 { fn default_detect_urls() -> bool { true } +fn default_separator_px() -> u32 { + 1 +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WindowConfig { @@ -158,6 +161,8 @@ pub struct WindowConfig { pub inactive_dim: f32, #[serde(default = "default_detect_urls")] pub detect_urls: bool, + #[serde(default = "default_separator_px")] + pub separator_px: u32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..7254b83 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -90,6 +90,8 @@ pub struct ConfigPanel { /// Section names that are currently collapsed (body fields hidden). pub collapsed: HashSet<&'static str>, pub version: &'static str, + /// Preserved verbatim from the loaded config (not exposed as an editable field). + pub separator_px: u32, } impl ConfigPanel { @@ -313,6 +315,7 @@ impl ConfigPanel { status: None, collapsed, version: env!("MMTERM_VERSION"), + separator_px: cfg.window.separator_px, } } @@ -687,6 +690,7 @@ impl ConfigPanel { cursor_blink_ms: blink_ms, inactive_dim, detect_urls, + separator_px: self.separator_px, }, shell: ShellConfig { program: shell }, terminal: TerminalConfig { scrollback_lines }, diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index 5f8381b..f827206 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -305,6 +305,7 @@ fn distinct_config() -> Config { cursor_blink_ms: 523, inactive_dim: 0.42, detect_urls: true, + separator_px: 7, }, shell: ShellConfig { program: Some("/bin/xyzsh".into()), diff --git a/src/input/mouse_ops.rs b/src/input/mouse_ops.rs index 05ea1fb..8adafee 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -50,7 +50,10 @@ impl App { pub(crate) fn pane_at_pixel(&self, px: f64, py: f64) -> Option { let (tab_h, status_h) = (self.tab_h(), self.status_h()); - let rects = self.tab().layout.rects_scaled(tab_h, status_h); + let rects = self + .tab() + .layout + .rects_scaled(tab_h, status_h, self.separator_px()); geometry::pane_at_pixel(&rects, px, py) } diff --git a/src/input_ops.rs b/src/input_ops.rs index 1c9791a..65b605a 100644 --- a/src/input_ops.rs +++ b/src/input_ops.rs @@ -103,10 +103,11 @@ impl App { let active = self.tab().active; let tab_h = self.tab_h(); let status_h = self.status_h(); + let sep = self.separator_px(); let rect = self .tab() .layout - .rects_scaled(tab_h, status_h) + .rects_scaled(tab_h, status_h, sep) .into_iter() .find(|(id, _)| *id == active) .map(|(_, r)| r) @@ -128,7 +129,8 @@ impl App { let tab_h = self.tab_h(); let status_h = self.status_h(); let pane_padding = self.pane_padding(); - Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, pane_padding); + let sep = self.separator_px(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, sep, pane_padding); if let Some(w) = &self.window { w.request_redraw(); } @@ -140,7 +142,8 @@ impl App { let tab_h = self.tab_h(); let status_h = self.status_h(); let pane_padding = self.pane_padding(); - Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, pane_padding); + let sep = self.separator_px(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, sep, pane_padding); self.request_redraw(); } @@ -233,9 +236,16 @@ impl App { let tab_h = self.tab_h(); let status_h = self.status_h(); let pane_padding = self.pane_padding(); + let sep = self.separator_px(); // Re-grids only the active pane: sibling metrics + rects are unchanged, // so their cols/rows don't change and they are left alone. - Self::sync_pane_sizes_tab(&mut self.state.tabs[idx], tab_h, status_h, pane_padding); + Self::sync_pane_sizes_tab( + &mut self.state.tabs[idx], + tab_h, + status_h, + sep, + pane_padding, + ); } pub(crate) fn should_swallow_key(&mut self, event: &KeyEvent) -> bool { diff --git a/src/main.rs b/src/main.rs index 0135a77..3c4ac9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -208,6 +208,14 @@ impl App { self.scale.chrome(crate::ui::layout::PANE_PADDING) } + /// DPI-scaled pane separator width from config. `.max(1)` keeps hit-testing + /// and rendering sane even when the configured value is 0. + pub(crate) fn separator_px(&self) -> u32 { + self.scale + .chrome(self.state.config.window.separator_px) + .max(1) + } + fn handle_resize(&mut self, w: u32, h: u32) { for tab in &mut self.state.tabs { tab.layout.resize(w, h); diff --git a/src/pane_ops.rs b/src/pane_ops.rs index 7f57df5..67e3c8d 100644 --- a/src/pane_ops.rs +++ b/src/pane_ops.rs @@ -137,9 +137,10 @@ impl App { .and_then(|e| e.pty.cwd()); let tab_h = self.tab_h(); let status_h = self.status_h(); + let sep = self.separator_px(); let layout = Layout::new(0, win_w, win_h); let initial_rect = layout - .rects_scaled(tab_h, status_h) + .rects_scaled(tab_h, status_h, sep) .first() .map(|(_, r)| *r) .unwrap_or([0, tab_h, win_w, win_h]); @@ -244,17 +245,25 @@ impl App { let tab_h = self.tab_h(); let status_h = self.status_h(); let pane_padding = self.pane_padding(); - Self::sync_pane_sizes_tab(&mut self.state.tabs[tab_idx], tab_h, status_h, pane_padding); + let sep = self.separator_px(); + Self::sync_pane_sizes_tab( + &mut self.state.tabs[tab_idx], + tab_h, + status_h, + sep, + pane_padding, + ); } pub(crate) fn sync_pane_sizes_tab( tab: &mut TabState, tab_h: u32, status_h: u32, + sep: u32, pane_padding: u32, ) { // rows*cell_height may be < pane_h by up to (cell_height-1)px — intentional bottom gutter; do not force equality. - let rects = tab.layout.rects_scaled(tab_h, status_h); + let rects = tab.layout.rects_scaled(tab_h, status_h, sep); for (id, rect) in rects { if let Some(entry) = tab.panes.get_mut(&id) { let [_, _, w, h] = rect; @@ -290,8 +299,9 @@ impl App { let tab_h = self.tab_h(); let status_h = self.status_h(); let pane_padding = self.pane_padding(); + let sep = self.separator_px(); for tab in &mut self.state.tabs { - Self::sync_pane_sizes_tab(tab, tab_h, status_h, pane_padding); + Self::sync_pane_sizes_tab(tab, tab_h, status_h, sep, pane_padding); } } @@ -300,10 +310,11 @@ impl App { let active = self.tab().active; let tab_h = self.tab_h(); let status_h = self.status_h(); + let sep = self.separator_px(); let active_rect = self .tab() .layout - .rects_scaled(tab_h, status_h) + .rects_scaled(tab_h, status_h, sep) .into_iter() .find(|(id, _)| *id == active) .map(|(_, r)| r) @@ -330,7 +341,13 @@ impl App { tab.active = new_id; let idx = self.state.active_tab; let pane_padding = self.pane_padding(); - Self::sync_pane_sizes_tab(&mut self.state.tabs[idx], tab_h, status_h, pane_padding); + Self::sync_pane_sizes_tab( + &mut self.state.tabs[idx], + tab_h, + status_h, + sep, + pane_padding, + ); } pub(crate) fn do_close_pane(&mut self, event_loop: &ActiveEventLoop) { @@ -358,7 +375,14 @@ impl App { let tab_h = self.tab_h(); let status_h = self.status_h(); let pane_padding = self.pane_padding(); - Self::sync_pane_sizes_tab(&mut self.state.tabs[idx], tab_h, status_h, pane_padding); + let sep = self.separator_px(); + Self::sync_pane_sizes_tab( + &mut self.state.tabs[idx], + tab_h, + status_h, + sep, + pane_padding, + ); } } diff --git a/src/pane_ops_test.rs b/src/pane_ops_test.rs index fa725fa..bb7051c 100644 --- a/src/pane_ops_test.rs +++ b/src/pane_ops_test.rs @@ -104,7 +104,7 @@ fn sync_uses_per_pane_metrics() { AppState::test_pane_entry(Logical(32.0), metrics(32.0, 16, 32)), ); - App::sync_pane_sizes_tab(&mut tab, 22, 22, 0); + App::sync_pane_sizes_tab(&mut tab, 22, 22, 1, 0); // sync_pane_sizes_tab writes target dimensions to pending_resize; the parser // thread applies them asynchronously. Test the contract that sync_pane_sizes_tab diff --git a/src/renderer/overlays_test.rs b/src/renderer/overlays_test.rs index 1a7782d..12bbda5 100644 --- a/src/renderer/overlays_test.rs +++ b/src/renderer/overlays_test.rs @@ -18,6 +18,7 @@ fn make_panel(value: &str, kind: FieldKind) -> ConfigPanel { status: None, collapsed: HashSet::new(), version: "", + separator_px: 1, } } @@ -331,6 +332,7 @@ fn make_section_panel(collapsed: bool) -> ConfigPanel { status: None, collapsed: c, version: "", + separator_px: 1, } } diff --git a/src/renderer/render_ops.rs b/src/renderer/render_ops.rs index 3ea50ee..1bc1ff7 100644 --- a/src/renderer/render_ops.rs +++ b/src/renderer/render_ops.rs @@ -102,6 +102,7 @@ impl App { // Compute scaled chrome heights before any mutable borrow of self.surface. let tab_h = self.tab_h(); let status_h = self.status_h(); + let sep = self.separator_px(); let Some(surface) = &mut self.surface else { return; @@ -137,7 +138,7 @@ impl App { let (separators, zoomed, active_id) = { let tab = &self.state.tabs[self.state.active_tab]; ( - tab.layout.separators_scaled(tab_h, status_h), + tab.layout.separators_scaled(tab_h, status_h, sep), tab.zoomed, tab.active, ) @@ -205,7 +206,7 @@ impl App { } }) .collect(); - let views = views::collect_pane_views(&self.state, &guards, w, h, tab_h, status_h); + let views = views::collect_pane_views(&self.state, &guards, w, h, tab_h, status_h, sep); let draw_separators: &[[u32; 4]] = if zoomed { &[] } else { &separators }; let right_text = statusbar::resolve( diff --git a/src/renderer/views.rs b/src/renderer/views.rs index 9eac618..d4fb3d1 100644 --- a/src/renderer/views.rs +++ b/src/renderer/views.rs @@ -45,6 +45,7 @@ pub fn collect_pane_views<'a>( h: u32, tab_h: u32, status_h: u32, + sep: u32, ) -> Vec> { if state.tabs.is_empty() { return vec![]; @@ -88,7 +89,7 @@ pub fn collect_pane_views<'a>( metrics: &entry.metrics, }] } else { - let rects = tab.layout.rects_scaled(tab_h, status_h); + let rects = tab.layout.rects_scaled(tab_h, status_h, sep); rects .iter() .filter_map(|(id, rect)| { diff --git a/src/renderer/views_test.rs b/src/renderer/views_test.rs index 493ab20..c8f557e 100644 --- a/src/renderer/views_test.rs +++ b/src/renderer/views_test.rs @@ -24,7 +24,7 @@ fn views(state: &AppState, w: u32, h: u32) -> Vec> { let guards = acquire_grid_guards(state); // We need to keep guards alive for the duration of use. Since we can't // return views that borrow guards in a test helper, call the fn inline. - let _views = collect_pane_views(state, &guards, w, h, TAB_BAR_H, STATUS_BAR_H); + let _views = collect_pane_views(state, &guards, w, h, TAB_BAR_H, STATUS_BAR_H, 1); drop(guards); // Return a simplified view of the data we need to test vec![] @@ -41,7 +41,7 @@ struct ViewSnapshot { fn collect_snapshots(state: &AppState, w: u32, h: u32) -> Vec { let guards = acquire_grid_guards(state); - let views = collect_pane_views(state, &guards, w, h, TAB_BAR_H, STATUS_BAR_H); + let views = collect_pane_views(state, &guards, w, h, TAB_BAR_H, STATUS_BAR_H, 1); views .iter() .map(|v| ViewSnapshot { diff --git a/src/restore.rs b/src/restore.rs index b8c4132..5468a5d 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -110,7 +110,14 @@ impl App { continue; } let pane_padding = self.pane_padding(); - Self::sync_pane_sizes_tab(&mut self.state.tabs[tab_idx], tab_h, status_h, pane_padding); + let sep = self.separator_px(); + Self::sync_pane_sizes_tab( + &mut self.state.tabs[tab_idx], + tab_h, + status_h, + sep, + pane_padding, + ); for (slot, &pane_id) in slot_to_id.iter().enumerate() { let path = session::scrollback_path_for(self.scope.as_deref(), tab_i, slot); let lines = session::load_scrollback(&path); diff --git a/src/ui/layout.rs b/src/ui/layout.rs index aeb8930..1f64fb8 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -7,11 +7,11 @@ pub const NUDGE_STEP: f32 = 0.05; const RATIO_MIN: f32 = 0.1; const RATIO_MAX: f32 = 0.9; -/// Split `full` pixels at `ratio`, reserving 1 px for the separator. +/// Split `full` pixels at `ratio`, reserving `sep` px for the separator. /// Returns `(a_size, b_size)` both clamped to at least 1 px. -fn split_dimension(full: u32, ratio: f32) -> (u32, u32) { - let a = ((full as f32 * ratio) as u32).clamp(1, full.saturating_sub(SEP + 1)); - (a, full.saturating_sub(a + SEP)) +fn split_dimension(full: u32, ratio: f32, sep: u32) -> (u32, u32) { + let a = ((full as f32 * ratio) as u32).clamp(1, full.saturating_sub(sep + 1)); + (a, full.saturating_sub(a + sep)) } #[derive(Clone, Copy, Debug)] @@ -84,38 +84,46 @@ impl Node { } } - fn compute_rects(&self, x: u32, y: u32, w: u32, h: u32, out: &mut Vec<(usize, [u32; 4])>) { + fn compute_rects( + &self, + x: u32, + y: u32, + w: u32, + h: u32, + sep: u32, + out: &mut Vec<(usize, [u32; 4])>, + ) { match self { Node::Leaf(id) => out.push((*id, [x, y, w, h])), Node::Split { dir, ratio, a, b } => match dir { SplitDir::H => { - let (wa, wb) = split_dimension(w, *ratio); - a.compute_rects(x, y, wa, h, out); - b.compute_rects(x + wa + SEP, y, wb, h, out); + let (wa, wb) = split_dimension(w, *ratio, sep); + a.compute_rects(x, y, wa, h, sep, out); + b.compute_rects(x + wa + sep, y, wb, h, sep, out); } SplitDir::V => { - let (ha, hb) = split_dimension(h, *ratio); - a.compute_rects(x, y, w, ha, out); - b.compute_rects(x, y + ha + SEP, w, hb, out); + let (ha, hb) = split_dimension(h, *ratio, sep); + a.compute_rects(x, y, w, ha, sep, out); + b.compute_rects(x, y + ha + sep, w, hb, sep, out); } }, } } - fn separators(&self, x: u32, y: u32, w: u32, h: u32, out: &mut Vec<[u32; 4]>) { + fn separators(&self, x: u32, y: u32, w: u32, h: u32, sep: u32, out: &mut Vec<[u32; 4]>) { if let Node::Split { dir, ratio, a, b } = self { match dir { SplitDir::H => { - let (wa, wb) = split_dimension(w, *ratio); - out.push([x + wa, y, SEP, h]); - a.separators(x, y, wa, h, out); - b.separators(x + wa + SEP, y, wb, h, out); + let (wa, wb) = split_dimension(w, *ratio, sep); + out.push([x + wa, y, sep, h]); + a.separators(x, y, wa, h, sep, out); + b.separators(x + wa + sep, y, wb, h, sep, out); } SplitDir::V => { - let (ha, hb) = split_dimension(h, *ratio); - out.push([x, y + ha, w, SEP]); - a.separators(x, y, w, ha, out); - b.separators(x, y + ha + SEP, w, hb, out); + let (ha, hb) = split_dimension(h, *ratio, sep); + out.push([x, y + ha, w, sep]); + a.separators(x, y, w, ha, sep, out); + b.separators(x, y + ha + sep, w, hb, sep, out); } } } @@ -139,6 +147,7 @@ impl Node { y: u32, w: u32, h: u32, + sep: u32, margin: u32, counter: &mut usize, ) -> Option { @@ -149,7 +158,7 @@ impl Node { *counter += 1; match dir { SplitDir::H => { - let (wa, wb) = split_dimension(w, *ratio); + let (wa, wb) = split_dimension(w, *ratio, sep); if sep_hit(y, h, x + wa, px, py, margin) { return Some(SeparatorHandle { idx, @@ -158,13 +167,13 @@ impl Node { region_size: w, }); } - a.find_sep_at_pixel(px, py, x, y, wa, h, margin, counter) + a.find_sep_at_pixel(px, py, x, y, wa, h, sep, margin, counter) .or_else(|| { - b.find_sep_at_pixel(px, py, x + wa + SEP, y, wb, h, margin, counter) + b.find_sep_at_pixel(px, py, x + wa + sep, y, wb, h, sep, margin, counter) }) } SplitDir::V => { - let (ha, hb) = split_dimension(h, *ratio); + let (ha, hb) = split_dimension(h, *ratio, sep); if sep_hit(x, w, y + ha, py, px, margin) { return Some(SeparatorHandle { idx, @@ -173,9 +182,9 @@ impl Node { region_size: h, }); } - a.find_sep_at_pixel(px, py, x, y, w, ha, margin, counter) + a.find_sep_at_pixel(px, py, x, y, w, ha, sep, margin, counter) .or_else(|| { - b.find_sep_at_pixel(px, py, x, y + ha + SEP, w, hb, margin, counter) + b.find_sep_at_pixel(px, py, x, y + ha + sep, w, hb, sep, margin, counter) }) } } @@ -299,34 +308,37 @@ impl Layout { } pub fn rects(&self) -> Vec<(usize, [u32; 4])> { - self.rects_scaled(TAB_BAR_H, STATUS_BAR_H) + self.rects_scaled(TAB_BAR_H, STATUS_BAR_H, SEP) } - /// Pane rects given PHYSICAL chrome heights (panes start at y = tab_h). - pub fn rects_scaled(&self, tab_h: u32, status_h: u32) -> Vec<(usize, [u32; 4])> { + /// Pane rects given PHYSICAL chrome heights (panes start at y = tab_h) + /// and separator width `sep`. + pub fn rects_scaled(&self, tab_h: u32, status_h: u32, sep: u32) -> Vec<(usize, [u32; 4])> { let mut out = Vec::new(); self.root.compute_rects( 0, tab_h, self.width, self.usable_h_for(tab_h, status_h), + sep, &mut out, ); out } pub fn separators(&self) -> Vec<[u32; 4]> { - self.separators_scaled(TAB_BAR_H, STATUS_BAR_H) + self.separators_scaled(TAB_BAR_H, STATUS_BAR_H, SEP) } - /// Separators given PHYSICAL chrome heights. - pub fn separators_scaled(&self, tab_h: u32, status_h: u32) -> Vec<[u32; 4]> { + /// Separators given PHYSICAL chrome heights and separator width `sep`. + pub fn separators_scaled(&self, tab_h: u32, status_h: u32, sep: u32) -> Vec<[u32; 4]> { let mut out = Vec::new(); self.root.separators( 0, tab_h, self.width, self.usable_h_for(tab_h, status_h), + sep, &mut out, ); out @@ -361,10 +373,11 @@ impl Layout { /// Returns a handle to the separator within `margin` pixels of `(px, py)`, /// or `None` if no separator is that close. pub fn separator_at_pixel(&self, px: u32, py: u32, margin: u32) -> Option { - self.separator_at_pixel_scaled(px, py, margin, TAB_BAR_H, STATUS_BAR_H) + self.separator_at_pixel_scaled(px, py, margin, TAB_BAR_H, STATUS_BAR_H, SEP) } - /// Hit-test a separator given PHYSICAL chrome heights. + /// Hit-test a separator given PHYSICAL chrome heights and separator width `sep`. + #[allow(clippy::too_many_arguments)] pub fn separator_at_pixel_scaled( &self, px: u32, @@ -372,6 +385,7 @@ impl Layout { margin: u32, tab_h: u32, status_h: u32, + sep: u32, ) -> Option { let mut counter = 0usize; self.root.find_sep_at_pixel( @@ -381,6 +395,7 @@ impl Layout { tab_h, self.width, self.usable_h_for(tab_h, status_h), + sep, margin, &mut counter, ) diff --git a/src/ui/layout_test.rs b/src/ui/layout_test.rs index 078037a..a3f72b4 100644 --- a/src/ui/layout_test.rs +++ b/src/ui/layout_test.rs @@ -263,6 +263,26 @@ fn pane_rects_cover_full_usable_area_in_h_split() { assert_eq!(total, W); } +#[test] +fn custom_sep_width_sets_thickness_and_offsets_b_child() { + // With an explicit separator width of 3, the H-split separator rect must be + // 3 px thick and the right (B) child must start 3 px past the left child. + let mut layout = Layout::new(0, W, H); + layout.split(0, 1, SplitDir::H); + let sep = 3; + let seps = layout.separators_scaled(TAB_BAR_H, STATUS_BAR_H, sep); + assert_eq!(seps.len(), 1); + assert_eq!(seps[0][2], sep, "separator rect must be `sep` px thick"); + + let rects = layout.rects_scaled(TAB_BAR_H, STATUS_BAR_H, sep); + let left = rects.iter().find(|(id, _)| *id == 0).unwrap().1; + let right = rects.iter().find(|(id, _)| *id == 1).unwrap().1; + // Right child x-origin is left width + separator width. + assert_eq!(right[0], left[0] + left[2] + sep); + // Widths + separator cover the full window. + assert_eq!(left[2] + right[2] + sep, W); +} + // ── separator_at_pixel ──────────────────────────────────────────────────────── #[test] @@ -517,14 +537,14 @@ fn usable_h_2x() { #[test] fn rects_1x_pane_top_at_22() { let l = Layout::new(0, W, H); - let rects = l.rects_scaled(TAB_BAR_H, STATUS_BAR_H); + let rects = l.rects_scaled(TAB_BAR_H, STATUS_BAR_H, SEP); assert_eq!(rects[0].1[1], 22); } #[test] fn rects_2x_pane_top_at_44() { let l = Layout::new(0, W, H); - let rects = l.rects_scaled(44, 44); + let rects = l.rects_scaled(44, 44, SEP); assert_eq!(rects[0].1[1], 44); } @@ -534,7 +554,7 @@ fn pixel_in_tab_bar_not_in_any_pane_at_2x() { // A physical y=30 is inside the tab bar and must NOT land in any pane rect. // A physical y=50 is below the tab bar and MUST land in a pane rect. let l = Layout::new(0, W, H); - let rects = l.rects_scaled(44, 44); // 2× chrome heights + let rects = l.rects_scaled(44, 44, SEP); // 2× chrome heights // rect tuple: (pane_id, [x, y, w, h]) — [1]=y, [3]=h let hit_at_30 = rects.iter().any(|(_, r)| 30u32 >= r[1] && 30 < r[1] + r[3]); assert!(