diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..d388fdc 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.padding_x` and `window.padding_y` to control the inner padding between a pane's border and its grid - 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..def995f 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,8 @@ width = 800 height = 600 title = "mmterm" cursor_blink_ms = 500 +padding_x = 4 # inner padding (px) between a pane's left/right border and its grid +padding_y = 4 # inner padding (px) between a pane's top/bottom border and its grid [shell] # program = "/bin/zsh" # defaults to $SHELL diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..80b9c60 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -11,6 +11,8 @@ title = "mmterm" cursor_blink_ms = 500 inactive_dim = 0.55 detect_urls = true +padding_x = 4 +padding_y = 4 [shell] # program = "/bin/zsh" diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..2346c03 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -103,7 +103,9 @@ vim-style modal input, split panes, and multi-tab sessions. - Baseline alignment per glyph using fontdue `ymin` metric. - SGR overline (`\e[53m` / `\e[55m`): rendered as a 1 px line at the top of the cell; cleared with `\e[55m`. -- 4 px inner padding on all pane edges so text never touches the border. +- Inner padding on all pane edges so text never touches the border; the + horizontal and vertical amounts are configurable via `window.padding_x` and + `window.padding_y` (both default `4` px, DPI-scaled). ### Input - Four modal modes: **Insert** (default), **Normal**, **Visual**, **Search**. @@ -221,6 +223,8 @@ 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 | padding_x | uint | `4` | +| window | padding_y | uint | `4` | | 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..95da851 100644 --- a/src/app_event.rs +++ b/src/app_event.rs @@ -600,8 +600,9 @@ impl App { self.state.tabs[ai].layout.move_separator(handle, new_pos); 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 pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, pad_x, pad_y); 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..5dfda8c 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_padding() -> u32 { + 4 +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WindowConfig { @@ -158,6 +161,10 @@ pub struct WindowConfig { pub inactive_dim: f32, #[serde(default = "default_detect_urls")] pub detect_urls: bool, + #[serde(default = "default_padding")] + pub padding_x: u32, + #[serde(default = "default_padding")] + pub padding_y: u32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..fed54cb 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -18,21 +18,23 @@ const F_WIN_TITLE: usize = 7; const F_BLINK_MS: usize = 8; const F_DIM: usize = 9; const F_DETECT_URLS: usize = 10; -const F_SHELL: usize = 11; -const F_SCROLLBACK: usize = 12; -const F_LOG_AUTO: usize = 13; -const F_LOG_DIR: usize = 14; -const F_THEME_NAME: usize = 15; -const F_COLOR_BG: usize = 16; -const F_COLOR_FG: usize = 17; -const F_COLOR_CUR: usize = 18; -const F_COLOR_SEL: usize = 19; -const F_PALETTE: usize = 20; // F_PALETTE + 0..15 -const F_STATUS_BAR_RIGHT: usize = 36; -const F_AUTO_UPDATE_CHECK: usize = 37; -const F_AUTO_UPDATE_INSTALL: usize = 38; -const F_SHELL_INTEGRATION: usize = 39; -const F_DESKTOP_NOTIFICATIONS: usize = 40; +const F_PADDING_X: usize = 11; +const F_PADDING_Y: usize = 12; +const F_SHELL: usize = 13; +const F_SCROLLBACK: usize = 14; +const F_LOG_AUTO: usize = 15; +const F_LOG_DIR: usize = 16; +const F_THEME_NAME: usize = 17; +const F_COLOR_BG: usize = 18; +const F_COLOR_FG: usize = 19; +const F_COLOR_CUR: usize = 20; +const F_COLOR_SEL: usize = 21; +const F_PALETTE: usize = 22; // F_PALETTE + 0..15 +const F_STATUS_BAR_RIGHT: usize = 38; +const F_AUTO_UPDATE_CHECK: usize = 39; +const F_AUTO_UPDATE_INSTALL: usize = 40; +const F_SHELL_INTEGRATION: usize = 41; +const F_DESKTOP_NOTIFICATIONS: usize = 42; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -176,6 +178,20 @@ impl ConfigPanel { kind: FieldKind::Bool, section: None, }, + Field { + label: "Padding X", + hint: "pixels between a pane's left/right border and its grid", + value: cfg.window.padding_x.to_string(), + kind: FieldKind::UInt, + section: None, + }, + Field { + label: "Padding Y", + hint: "pixels between a pane's top/bottom border and its grid", + value: cfg.window.padding_y.to_string(), + kind: FieldKind::UInt, + section: None, + }, // ── Shell ─────────────────────────────────────────────────────── Field { label: "Shell", @@ -624,6 +640,12 @@ impl ConfigPanel { let detect_urls = get(F_DETECT_URLS) .parse::() .map_err(|_| "Invalid detect_urls — use true or false")?; + let padding_x = get(F_PADDING_X) + .parse::() + .map_err(|_| "Invalid padding_x — use a whole number of pixels")?; + let padding_y = get(F_PADDING_Y) + .parse::() + .map_err(|_| "Invalid padding_y — use a whole number of pixels")?; let shell = { let s = get(F_SHELL); if s.is_empty() { None } else { Some(s) } @@ -687,6 +709,8 @@ impl ConfigPanel { cursor_blink_ms: blink_ms, inactive_dim, detect_urls, + padding_x, + padding_y, }, 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..1a141cd 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -10,8 +10,8 @@ fn make_panel() -> ConfigPanel { #[test] fn from_config_has_correct_field_count() { let panel = make_panel(); - // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify = 41 - assert_eq!(panel.fields.len(), 41); + // 9 base + 2 padding + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify = 43 + assert_eq!(panel.fields.len(), 43); } #[test] @@ -305,6 +305,8 @@ fn distinct_config() -> Config { cursor_blink_ms: 523, inactive_dim: 0.42, detect_urls: true, + padding_x: 6, + padding_y: 9, }, shell: ShellConfig { program: Some("/bin/xyzsh".into()), @@ -361,6 +363,8 @@ fn field_index_sanity() { F_BLINK_MS, F_DIM, F_DETECT_URLS, + F_PADDING_X, + F_PADDING_Y, F_SHELL, F_SCROLLBACK, F_LOG_AUTO, @@ -680,8 +684,8 @@ fn palette_collapsed_by_default() { #[test] fn visible_indices_hides_palette_body() { let panel = make_panel(); - // 41 total - 15 palette body fields = 26 visible - assert_eq!(panel.visible_indices().len(), 26); + // 43 total - 15 palette body fields = 28 visible + assert_eq!(panel.visible_indices().len(), 28); } #[test] @@ -690,7 +694,7 @@ fn toggle_on_palette_header_expands() { panel.selected = F_PALETTE; panel.toggle_collapse(); assert!(!panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 41); + assert_eq!(panel.visible_indices().len(), 43); } #[test] @@ -700,7 +704,7 @@ fn toggle_twice_restores_collapsed() { panel.toggle_collapse(); panel.toggle_collapse(); assert!(panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 26); + assert_eq!(panel.visible_indices().len(), 28); } #[test] diff --git a/src/geometry_test.rs b/src/geometry_test.rs index 9d27799..caf20a6 100644 --- a/src/geometry_test.rs +++ b/src/geometry_test.rs @@ -90,6 +90,56 @@ fn pixel_to_cell_offset_rect() { assert_eq!(result, Some((1, 1))); } +#[test] +fn pixel_to_cell_padded_origin_maps_to_first_cell() { + // App::pixel_to_cell insets the pane rect by (pad_x, pad_y) so the grid is + // hit-tested from the padded origin (matching the padded render origin). A + // click exactly at the padded origin must resolve to cell (0, 0). + let (rx, ry, rw, rh) = (100u32, 50u32, 200u32, 120u32); + let (pad_x, pad_y) = (8u32, 3u32); + let inset = [rx + pad_x, ry + pad_y, rw - pad_x * 2, rh - pad_y * 2]; + // Click at the padded top-left origin → first cell. + assert_eq!( + pixel_to_cell( + inset, + 10, + 12, + 18, + 9, + (rx + pad_x) as f64, + (ry + pad_y) as f64 + ), + Some((0, 0)) + ); + // One cell in on each axis from the padded origin → cell (1, 1). + assert_eq!( + pixel_to_cell( + inset, + 10, + 12, + 18, + 9, + (rx + pad_x + 10) as f64, + (ry + pad_y + 12) as f64 + ), + Some((1, 1)) + ); + // A click inside the left/top padding gutter (before the padded origin) is + // outside the inset rect → None. + assert_eq!( + pixel_to_cell( + inset, + 10, + 12, + 18, + 9, + (rx + pad_x - 1) as f64, + (ry + pad_y) as f64 + ), + None + ); +} + // ── cell_url_at_scroll ──────────────────────────────────────────────────────── use crate::terminal::grid::{Color, Grid, GridColors}; diff --git a/src/input/mouse_ops.rs b/src/input/mouse_ops.rs index 05ea1fb..f540cab 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -55,6 +55,7 @@ impl App { } pub(crate) fn pixel_to_cell(&self, pane_id: usize, px: f64, py: f64) -> Option<(usize, usize)> { + let (pad_x, pad_y) = (self.pane_padding_x(), self.pane_padding_y()); let tab = self.tab(); let entry = tab.panes.get(&pane_id)?; let m = &entry.metrics; @@ -62,8 +63,18 @@ impl App { let g = entry.pane.grid_read()?; (g.cols, g.rows) }; + // The grid is rendered inset by (pad_x, pad_y) from the pane rect origin + // (see renderer `render_row`). Inset the rect the same way so a click at + // the padded origin maps to cell (0, 0); the padding gutters map to None. + let [rx, ry, rw, rh] = entry.pane.rect; + let inset = [ + rx + pad_x, + ry + pad_y, + rw.saturating_sub(pad_x * 2), + rh.saturating_sub(pad_y * 2), + ]; geometry::pixel_to_cell( - entry.pane.rect, + inset, m.cell_width, m.cell_height, grid_cols, diff --git a/src/input_ops.rs b/src/input_ops.rs index 1c9791a..e82e562 100644 --- a/src/input_ops.rs +++ b/src/input_ops.rs @@ -127,8 +127,9 @@ impl App { .nudge_pane(active, split_h, delta); 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 pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, pad_x, pad_y); if let Some(w) = &self.window { w.request_redraw(); } @@ -139,8 +140,9 @@ impl App { self.state.tabs[ai].layout.rotate_leaves(forward); 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 pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[ai], tab_h, status_h, pad_x, pad_y); self.request_redraw(); } @@ -232,10 +234,11 @@ impl App { } let tab_h = self.tab_h(); let status_h = self.status_h(); - let pane_padding = self.pane_padding(); + let pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); // 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, pad_x, pad_y); } pub(crate) fn should_swallow_key(&mut self, event: &KeyEvent) -> bool { diff --git a/src/main.rs b/src/main.rs index 0135a77..39135bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -204,8 +204,12 @@ impl App { self.scale.chrome(crate::ui::layout::STATUS_BAR_H) } - pub(crate) fn pane_padding(&self) -> u32 { - self.scale.chrome(crate::ui::layout::PANE_PADDING) + pub(crate) fn pane_padding_x(&self) -> u32 { + self.scale.chrome(self.state.config.window.padding_x) + } + + pub(crate) fn pane_padding_y(&self) -> u32 { + self.scale.chrome(self.state.config.window.padding_y) } fn handle_resize(&mut self, w: u32, h: u32) { diff --git a/src/pane_ops.rs b/src/pane_ops.rs index 7f57df5..10dc73c 100644 --- a/src/pane_ops.rs +++ b/src/pane_ops.rs @@ -30,8 +30,10 @@ impl App { let [_, _, w, h] = rect; let logical = crate::dpi::Logical(self.state.config.font.size); let metrics = self.renderer.make_metrics(self.scale.px(logical)); - let pad2 = self.scale.chrome(crate::ui::layout::PANE_PADDING) * 2; - let (cols, rows) = metrics.grid_size_for(w.saturating_sub(pad2), h.saturating_sub(pad2)); + let pad_x2 = self.pane_padding_x() * 2; + let pad_y2 = self.pane_padding_y() * 2; + let (cols, rows) = + metrics.grid_size_for(w.saturating_sub(pad_x2), h.saturating_sub(pad_y2)); let t = &self.state.theme; let grid = Arc::new(RwLock::new(Grid::with_colors( cols, @@ -243,25 +245,27 @@ 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 pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[tab_idx], tab_h, status_h, pad_x, pad_y); } pub(crate) fn sync_pane_sizes_tab( tab: &mut TabState, tab_h: u32, status_h: u32, - pane_padding: u32, + pad_x: u32, + pad_y: 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); for (id, rect) in rects { if let Some(entry) = tab.panes.get_mut(&id) { let [_, _, w, h] = rect; - let pad2 = pane_padding * 2; + let (pad_x2, pad_y2) = (pad_x * 2, pad_y * 2); let (cols, rows) = entry .metrics - .grid_size_for(w.saturating_sub(pad2), h.saturating_sub(pad2)); + .grid_size_for(w.saturating_sub(pad_x2), h.saturating_sub(pad_y2)); // Lock hierarchy (see drain.rs): read `grid` in this scoped block // and drop it before taking `pending_resize` below — the two are // never held at once. @@ -289,9 +293,10 @@ impl App { pub(crate) fn sync_all_pane_sizes(&mut self) { let tab_h = self.tab_h(); let status_h = self.status_h(); - let pane_padding = self.pane_padding(); + let pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); 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, pad_x, pad_y); } } @@ -329,8 +334,9 @@ impl App { tab.layout.split(active, new_id, dir); 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); + let pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[idx], tab_h, status_h, pad_x, pad_y); } pub(crate) fn do_close_pane(&mut self, event_loop: &ActiveEventLoop) { @@ -357,8 +363,9 @@ impl App { let idx = self.state.active_tab; 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 pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[idx], tab_h, status_h, pad_x, pad_y); } } diff --git a/src/pane_ops_test.rs b/src/pane_ops_test.rs index fa725fa..6607a84 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, 0, 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/draw_fns.rs b/src/renderer/draw_fns.rs index c24998b..6dbb5c8 100644 --- a/src/renderer/draw_fns.rs +++ b/src/renderer/draw_fns.rs @@ -89,10 +89,11 @@ pub(super) fn cell_out_of_pane_bounds( ry: u32, rw: u32, rh: u32, - padding: u32, + pad_x: u32, + pad_y: u32, ) -> bool { - cell_x + draw_w > rx + rw.saturating_sub(padding) - || cell_y + cell_height > ry + rh.saturating_sub(padding) + cell_x + draw_w > rx + rw.saturating_sub(pad_x) + || cell_y + cell_height > ry + rh.saturating_sub(pad_y) } pub(super) fn should_draw_glyph(cell: &Cell, blink_visible: bool) -> bool { @@ -501,8 +502,10 @@ mod tests { #[test] fn pane_padding_scales() { use crate::dpi::Scale; - assert_eq!(Scale::new(2.0).chrome(crate::ui::layout::PANE_PADDING), 8); - assert_eq!(Scale::new(1.0).chrome(crate::ui::layout::PANE_PADDING), 4); + // Default inner padding (config `window.padding_x`/`padding_y`) is 4 px. + const PANE_PADDING: u32 = 4; + assert_eq!(Scale::new(2.0).chrome(PANE_PADDING), 8); + assert_eq!(Scale::new(1.0).chrome(PANE_PADDING), 4); } // ── Task 23 proxy tests — pure arithmetic, no rendering ────────────────── diff --git a/src/renderer/text.rs b/src/renderer/text.rs index acf5825..7dac071 100644 --- a/src/renderer/text.rs +++ b/src/renderer/text.rs @@ -7,7 +7,7 @@ use crate::terminal::grid::{Cell, CursorShape, ShellState}; use crate::terminal::sixel::SixelImage; use crate::terminal::{Color, Grid}; use crate::theme::ResolvedTheme; -use crate::ui::layout::{PANE_PADDING, STATUS_BAR_H, TAB_BAR_H}; +use crate::ui::layout::{STATUS_BAR_H, TAB_BAR_H}; /// Second logical UI-chrome font; physical = STATUS_FONT_LOGICAL × scale, /// scaled like the terminal font (spec §5.4). @@ -41,6 +41,11 @@ pub struct PaneView<'a> { pub cursor_shape: CursorShape, /// Per-pane cell metrics (font size is per-pane, not per-tab). pub metrics: &'a FontMetrics, + /// Unscaled inner padding (px) between the pane border and the grid on the + /// horizontal axis. The renderer applies DPI scaling via `Scale::chrome`. + pub pad_x: u32, + /// Unscaled inner padding (px) on the vertical axis. + pub pad_y: u32, } /// Cell layout metrics derived from a specific font size. @@ -299,7 +304,8 @@ impl Renderer { pane.rect, &grid.images, m, - self.scale.chrome(PANE_PADDING), + self.scale.chrome(pane.pad_x), + self.scale.chrome(pane.pad_y), ); } } @@ -386,9 +392,10 @@ impl Renderer { .search_matches .partition_point(|&(r, _, _)| r < abs_row); // Precompute row-invariant values used in the tight cell loop. - let pad = self.scale.chrome(PANE_PADDING); - let cell_y = ry + pad + row as u32 * m.cell_height; - let base_x = rx + pad; + let pad_x = self.scale.chrome(pane.pad_x); + let pad_y = self.scale.chrome(pane.pad_y); + let cell_y = ry + pad_y + row as u32 * m.cell_height; + let base_x = rx + pad_x; let cursor_color_u32 = color_u32(grid.cursor_color); let mut col = 0usize; @@ -404,7 +411,18 @@ impl Renderer { let draw_w = cell_cols * m.cell_width; let cell_x = base_x + col as u32 * m.cell_width; - if cell_out_of_pane_bounds(cell_x, cell_y, draw_w, m.cell_height, rx, ry, rw, rh, pad) { + if cell_out_of_pane_bounds( + cell_x, + cell_y, + draw_w, + m.cell_height, + rx, + ry, + rw, + rh, + pad_x, + pad_y, + ) { col += cell_cols as usize; continue; } @@ -1091,12 +1109,13 @@ fn draw_images( rect: [u32; 4], images: &[SixelImage], m: &FontMetrics, - padding: u32, + pad_x: u32, + pad_y: u32, ) { let [rx, ry, ..] = rect; for img in images { - let img_x = rx + padding + img.col as u32 * m.cell_width; - let img_y = ry + padding + img.row as u32 * m.cell_height; + let img_x = rx + pad_x + img.col as u32 * m.cell_width; + let img_y = ry + pad_y + img.row as u32 * m.cell_height; for py in 0..img.height { for px_i in 0..img.width { blit_sixel_pixel( diff --git a/src/renderer/text_test.rs b/src/renderer/text_test.rs index 1d7213a..ed723f7 100644 --- a/src/renderer/text_test.rs +++ b/src/renderer/text_test.rs @@ -211,6 +211,8 @@ fn draw_pane_fills_background_color() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; let mut buf = vec![0u32; 800 * 600]; let theme = default_theme(); @@ -616,6 +618,8 @@ fn make_pane<'a>(grid: &'a Grid, m: &'a crate::renderer::text::FontMetrics) -> P hovered_url: None, cursor_shape: CursorShape::Block, metrics: m, + pad_x: 4, + pad_y: 4, } } @@ -680,6 +684,8 @@ fn draw_inactive_pane_does_not_panic() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &InputMode::Insert); } @@ -705,6 +711,8 @@ fn draw_pane_visual_selection_does_not_panic() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; let mode = InputMode::Visual { start_col: 0, @@ -740,6 +748,8 @@ fn draw_pane_with_search_match_does_not_panic() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &InputMode::Insert); } @@ -908,7 +918,7 @@ fn draw_pane_reverse_video_swaps_background_to_fg_color() { &theme, None, // update_badge: wired in Task 9 ); - // Cell (0,0) background pixel: x = 4 (PANE_PADDING), y = 22+4 (TAB_BAR_H+PANE_PADDING) + // Cell (0,0) background pixel: x = 4 (pad_x), y = 22+4 (TAB_BAR_H+pad_y) let px = 4usize; let py = 26usize; let pixel = buf[py * 800 + px]; @@ -960,6 +970,8 @@ fn draw_pane_scrolled_up_shows_scrollbar_thumb_position() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &InputMode::Insert); } @@ -982,6 +994,8 @@ fn draw_pane_with_cursor_visible_does_not_panic() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &InputMode::Insert); } @@ -1033,6 +1047,8 @@ fn draw_pane_grid_wider_than_rect_clips_overflow_cells() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &InputMode::Insert); } @@ -1077,6 +1093,8 @@ fn draw_pane_non_current_search_match_uses_match_color() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &InputMode::Insert); } @@ -1102,6 +1120,8 @@ fn draw_pane_inactive_with_url_does_not_panic() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &InputMode::Insert); } @@ -1258,6 +1278,8 @@ fn draw_pane_visual_mode_shows_cursor_at_cur_position() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; // Must not panic — actual pixel inspection is left to integration testing. do_draw(&mut r, &[pane], &mode); @@ -1289,6 +1311,8 @@ fn draw_pane_visual_mode_inactive_pane_no_cursor() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &mode); } @@ -1299,7 +1323,8 @@ fn draw_pane_visual_mode_inactive_pane_no_cursor() { fn pane_padding_leaves_top_left_corner_as_background() { // The top-left PANE_PADDING×PANE_PADDING pixels must remain background // color (no glyph pixels written there). - use crate::ui::layout::PANE_PADDING; + // Default inner padding (config `window.padding_x`/`padding_y`). + const PANE_PADDING: u32 = 4; let mut r = make_renderer(); let m = r.make_metrics(Physical(16.0)); let pad2 = PANE_PADDING * 2; @@ -1322,6 +1347,8 @@ fn pane_padding_leaves_top_left_corner_as_background() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; let mut buf = vec![0u32; 800 * 600]; let theme = default_theme(); @@ -1364,7 +1391,8 @@ fn pane_padding_leaves_top_left_corner_as_background() { fn pane_padding_grid_size_accounts_for_both_sides() { // grid_size_for called with 2×PANE_PADDING subtracted must yield fewer // cols/rows than the unpadded call. - use crate::ui::layout::PANE_PADDING; + // Default inner padding (config `window.padding_x`/`padding_y`). + const PANE_PADDING: u32 = 4; let mut r = make_renderer(); let m = r.make_metrics(Physical(16.0)); let pad2 = PANE_PADDING * 2; @@ -1381,6 +1409,97 @@ fn pane_padding_grid_size_accounts_for_both_sides() { ); } +// Renders a grid whose top-left region is filled with 'X' at the given +// (pad_x, pad_y) and returns (buffer, bg). Only the first `rows-1` rows are +// filled so the cursor never triggers a scroll (no scrollback / scrollbar to +// perturb the pixel scan). +fn render_padded(r: &mut Renderer, pad_x: u32, pad_y: u32) -> (Vec, u32) { + let m = r.make_metrics(Physical(16.0)); + let (cols, rows) = m.grid_size_for(800u32.saturating_sub(48), 556u32.saturating_sub(48)); + let mut grid = make_grid(cols, rows); + for _ in 0..cols * rows.saturating_sub(1) { + grid.write_char('X'); + } + let bg = color_u32(grid.default_bg); + let pane = PaneView { + grid: &grid, + rect: [0, 22, 800, 556], + scroll_offset: 0, + is_active: true, + show_cursor: false, + blink_visible: false, + search_matches: &[], + search_current: None, + hovered_url: None, + cursor_shape: CursorShape::Block, + metrics: &m, + pad_x, + pad_y, + }; + let mut buf = vec![0u32; 800 * 600]; + let theme = default_theme(); + r.draw( + &mut buf, + 800, + 600, + &[pane], + &[], + &InputMode::Insert, + false, + &[("t".to_string(), true, false)], + 0, + 0, + None, + None, + 0.55, + None, + false, + false, + ShellState::Unknown, + None, + &theme, + None, + ); + (buf, bg) +} + +#[test] +fn padding_offsets_are_independent_per_axis() { + let mut r = make_renderer(); + + // Leftmost non-bg column and topmost non-bg row over the left region of the + // pane (x < 120 avoids the right-edge scrollbar track). Measuring the + // content block edge — not a single scan line — makes each axis depend only + // on its own padding, independent of sub-glyph sampling. + let content_left = |buf: &[u32], bg: u32| -> Option { + (0..120u32).find(|&x| (22..556u32).any(|y| buf[(y * 800 + x) as usize] != bg)) + }; + let content_top = |buf: &[u32], bg: u32| -> Option { + (22..556u32).find(|&y| (0..120u32).any(|x| buf[(y * 800 + x) as usize] != bg)) + }; + + let (buf_a, bg) = render_padded(&mut r, 4, 4); + let (buf_bx, _) = render_padded(&mut r, 20, 4); // +16 on X only + let (buf_cy, _) = render_padded(&mut r, 4, 20); // +16 on Y only + + let left_a = content_left(&buf_a, bg).expect("content in A"); + let top_a = content_top(&buf_a, bg).expect("content in A"); + let left_bx = content_left(&buf_bx, bg).expect("content in B"); + let top_bx = content_top(&buf_bx, bg).expect("content in B"); + let left_cy = content_left(&buf_cy, bg).expect("content in C"); + let top_cy = content_top(&buf_cy, bg).expect("content in C"); + + // Asymmetric padding must move each axis by its own amount only. + assert_eq!( + left_bx - left_a, + 16, + "pad_x must shift content right by pad_x" + ); + assert_eq!(top_bx, top_a, "pad_x must not move the vertical origin"); + assert_eq!(top_cy - top_a, 16, "pad_y must shift content down by pad_y"); + assert_eq!(left_cy, left_a, "pad_y must not move the horizontal origin"); +} + #[test] fn draw_pane_with_sixel_image_does_not_panic() { use crate::terminal::sixel::SixelImage; @@ -1448,6 +1567,8 @@ fn draw_pane_sixel_image_scrolled_up_not_drawn() { hovered_url: None, cursor_shape: CursorShape::Block, metrics: &m, + pad_x: 4, + pad_y: 4, }; do_draw(&mut r, &[pane], &InputMode::Insert); } diff --git a/src/renderer/views.rs b/src/renderer/views.rs index 9eac618..10fec7a 100644 --- a/src/renderer/views.rs +++ b/src/renderer/views.rs @@ -55,6 +55,8 @@ pub fn collect_pane_views<'a>( let search_matches = &state.search_matches; let search_current_val = state.search_current; let insert_mode = matches!(state.mode(), InputMode::Insert); + let pad_x = state.config.window.padding_x; + let pad_y = state.config.window.padding_y; let guard_for = |id: usize| -> Option<&'a Grid> { guards.iter().find(|(gid, _)| *gid == id).map(|(_, g)| &**g) @@ -86,6 +88,8 @@ pub fn collect_pane_views<'a>( hovered_url: state.hovered_url.as_deref(), cursor_shape: grid.cursor_shape, metrics: &entry.metrics, + pad_x, + pad_y, }] } else { let rects = tab.layout.rects_scaled(tab_h, status_h); @@ -115,6 +119,8 @@ pub fn collect_pane_views<'a>( hovered_url: state.hovered_url.as_deref(), cursor_shape: grid.cursor_shape, metrics: &entry.metrics, + pad_x, + pad_y, }) }) .collect() diff --git a/src/restore.rs b/src/restore.rs index b8c4132..822d27e 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -109,8 +109,9 @@ impl App { // Tab dropped (no pane could be spawned): skip its sizing/scrollback. 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 pad_x = self.pane_padding_x(); + let pad_y = self.pane_padding_y(); + Self::sync_pane_sizes_tab(&mut self.state.tabs[tab_idx], tab_h, status_h, pad_x, pad_y); 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..d94e867 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -1,6 +1,5 @@ pub const STATUS_BAR_H: u32 = 22; pub const TAB_BAR_H: u32 = 22; -pub const PANE_PADDING: u32 = 4; // intentionally 1 physical px at all scales; scale-aware strokes deferred (spec §9) const SEP: u32 = 1; pub const NUDGE_STEP: f32 = 0.05;