From 2d319b7e7366d88340f4aea106d121fff60450f1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 10:53:15 +0300 Subject: [PATCH] render: inner terminal padding (pad-x/pad-y) Signed-off-by: NotAShelf Change-Id: I190f63ca86a8cf976e4d018df73897ab6a6a6964 --- src/config.rs | 5 ++++ src/render.rs | 45 ++++++++++++++++++++++------ src/wayland.rs | 79 ++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 101 insertions(+), 28 deletions(-) diff --git a/src/config.rs b/src/config.rs index cffc726..726c118 100644 --- a/src/config.rs +++ b/src/config.rs @@ -52,6 +52,9 @@ pub struct Main { /// Initial size in character cells. pub initial_cols: u16, pub initial_rows: u16, + /// Inner padding in pixels between the window edge and the cell grid. + pub pad_x: u32, + pub pad_y: u32, /// Characters that break a word for double-click selection. Empty/unset /// keeps the built-in default. pub word_delimiters: Option, @@ -65,6 +68,8 @@ impl Default for Main { term: "beer".to_string(), initial_cols: 80, initial_rows: 24, + pad_x: 2, + pad_y: 2, word_delimiters: None, } } diff --git a/src/render.rs b/src/render.rs index 4f5229e..89e2f77 100644 --- a/src/render.rs +++ b/src/render.rs @@ -99,17 +99,36 @@ pub struct Frame<'a> { #[derive(Debug)] pub struct Renderer { fonts: Fonts, + /// Inner padding `(x, y)` in pixels between the window edge and the grid. + pad: (i32, i32), } impl Renderer { pub fn new(fonts: Fonts) -> Self { - Self { fonts } + Self { fonts, pad: (0, 0) } } pub fn metrics(&self) -> CellMetrics { self.fonts.metrics() } + pub fn set_padding(&mut self, pad_x: u32, pad_y: u32) { + self.pad = (pad_x as i32, pad_y as i32); + } + + /// Fill the whole buffer (including the padding margins) with the background + /// colour. Called once per fresh shm buffer; per-row repaints then leave the + /// margins untouched. + pub fn clear(&self, pixels: &mut [u8], dims: (usize, usize), theme: &Theme) { + let (width, height) = dims; + let mut canvas = Canvas { + pixels, + width, + height, + }; + canvas.fill_rect_a(0, 0, width as u32, height as u32, theme.bg, theme.alpha); + } + /// Repaint a single grid row `y` into `pixels` (BGRA, `width`×`height` px): /// clear the row band, fill backgrounds (and selection), draw glyphs and /// decorations, then the cursor if it sits on this row. `blink_on` is the @@ -132,8 +151,9 @@ impl Renderer { height, }; let m = self.fonts.metrics(); + let (pad_x, pad_y) = self.pad; let cols = grid.cols(); - let row_top = y as i32 * m.height as i32; + let row_top = pad_y + y as i32 * m.height as i32; canvas.fill_rect_a(0, row_top, width as u32, m.height, theme.bg, theme.alpha); // Rows come through the scrollback viewport and may be shorter than @@ -156,7 +176,13 @@ impl Renderer { None => cell_colors(cell, theme).1, }; if bg != theme.bg { - canvas.fill_rect(x as i32 * m.width as i32, row_top, m.width, m.height, bg); + canvas.fill_rect( + pad_x + x as i32 * m.width as i32, + row_top, + m.width, + m.height, + bg, + ); } } @@ -168,7 +194,7 @@ impl Renderer { continue; } let (fg, _) = cell_colors(cell, theme); - let origin_x = x as i32 * m.width as i32; + let origin_x = pad_x + x as i32 * m.width as i32; if cell.c != ' ' { self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg); } @@ -189,6 +215,7 @@ impl Renderer { pixels: &mut [u8], dims: (usize, usize), theme: &Theme, + row: usize, text: &str, ) { let (width, height) = dims; @@ -198,14 +225,14 @@ impl Renderer { height, }; let m = self.fonts.metrics(); - let rows = (height / m.height as usize).max(1); - let row_top = (rows - 1) as i32 * m.height as i32; + let (pad_x, pad_y) = self.pad; + let row_top = pad_y + row as i32 * m.height as i32; canvas.fill_rect(0, row_top, width as u32, m.height, theme.search_bar_bg); let style = Style { bold: false, italic: false, }; - let mut x = 0i32; + let mut x = pad_x; for c in text.chars() { if x as usize + m.width as usize > width { break; @@ -232,8 +259,8 @@ impl Renderer { return; } let (cx, cy) = grid.cursor(); - let x0 = cx as i32 * m.width as i32; - let top = cy as i32 * m.height as i32; + let x0 = self.pad.0 + cx as i32 * m.width as i32; + let top = self.pad.1 + cy as i32 * m.height as i32; // OSC 12 cursor colour wins, then the configured cursor colour, then fg. let color = grid .cursor_color() diff --git a/src/wayland.rs b/src/wayland.rs index e508dd5..f305373 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -196,13 +196,14 @@ pub fn run(config: Config) -> anyhow::Result { window.commit(); let fonts = Fonts::new(&config.main.font, config.main.font_size).context("load font")?; - let renderer = Renderer::new(fonts); + let mut renderer = Renderer::new(fonts); + renderer.set_padding(config.main.pad_x, config.main.pad_y); - // Start at the configured cell geometry; the compositor may override it on - // the first configure. + // Start at the configured cell geometry plus padding; the compositor may + // override it on the first configure. let m = renderer.metrics(); - let width = (u32::from(config.main.initial_cols) * m.width).max(1); - let height = (u32::from(config.main.initial_rows) * m.height).max(1); + let width = (u32::from(config.main.initial_cols) * m.width + 2 * config.main.pad_x).max(1); + let height = (u32::from(config.main.initial_rows) * m.height + 2 * config.main.pad_y).max(1); let pool = SlotPool::new( (width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize, &shm, @@ -285,10 +286,16 @@ pub fn run(config: Config) -> anyhow::Result { Ok(app.exit_code) } -/// Columns and rows that fit a `width`×`height` px window at `metrics`. -fn grid_size(metrics: crate::font::CellMetrics, width: u32, height: u32) -> (u16, u16) { - let cols = (width / metrics.width).max(1); - let rows = (height / metrics.height).max(1); +/// Columns and rows that fit a `width`×`height` px window at `metrics`, after +/// reserving `2 * pad` pixels of inner padding on each axis. +fn grid_size( + metrics: crate::font::CellMetrics, + width: u32, + height: u32, + pad: (u32, u32), +) -> (u16, u16) { + let cols = (width.saturating_sub(2 * pad.0) / metrics.width).max(1); + let rows = (height.saturating_sub(2 * pad.1) / metrics.height).max(1); (cols as u16, rows as u16) } @@ -387,7 +394,12 @@ struct App { impl App { /// Spawn the shell at the current window size and start reading its output. fn spawn_session(&mut self) { - let (cols, rows) = grid_size(self.renderer.metrics(), self.width, self.height); + let (cols, rows) = grid_size( + self.renderer.metrics(), + self.width, + self.height, + (self.config.main.pad_x, self.config.main.pad_y), + ); let pty = match Pty::spawn(cols, rows, &self.config.main.term) { Ok(pty) => pty, Err(err) => { @@ -557,13 +569,24 @@ impl App { self.needs_draw = true; } + /// Inner padding `(x, y)` in pixels. + fn padding(&self) -> (f64, f64) { + ( + f64::from(self.config.main.pad_x), + f64::from(self.config.main.pad_y), + ) + } + /// Map window pixel coordinates to an absolute `(row, col)` grid point. fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> { let session = self.session.as_ref()?; let m = self.renderer.metrics(); + let (pad_x, pad_y) = self.padding(); let grid = session.term.grid(); - let col = (px.max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1)); - let vrow = (py.max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1)); + let col = + ((px - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1)); + let vrow = + ((py - pad_y).max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1)); Some((grid.view_to_abs(vrow), col)) } @@ -683,10 +706,11 @@ impl App { fn report_screen_cell(&self) -> Option<(usize, usize)> { let session = self.session.as_ref()?; let m = self.renderer.metrics(); + let (pad_x, pad_y) = self.padding(); let grid = session.term.grid(); - let col = (self.pointer_pos.0.max(0.0) as usize / m.width as usize) + let col = ((self.pointer_pos.0 - pad_x).max(0.0) as usize / m.width as usize) .min(grid.cols().saturating_sub(1)); - let row = (self.pointer_pos.1.max(0.0) as usize / m.height as usize) + let row = ((self.pointer_pos.1 - pad_y).max(0.0) as usize / m.height as usize) .min(grid.rows().saturating_sub(1)); Some((col, row)) } @@ -968,7 +992,12 @@ impl App { /// Recompute the grid size for the current window and tell the grid and the /// PTY about it if it changed. fn resize_grid(&mut self) { - let (cols, rows) = grid_size(self.renderer.metrics(), self.width, self.height); + let (cols, rows) = grid_size( + self.renderer.metrics(), + self.width, + self.height, + (self.config.main.pad_x, self.config.main.pad_y), + ); let Some(session) = self.session.as_mut() else { return; }; @@ -1122,6 +1151,10 @@ impl App { return; } + // A buffer used for the first time has uninitialized margins; paint the + // whole thing (background + padding) once, then damage it in full below. + let fresh = self.frames[idx].rows.is_empty(); + let pad_y = self.config.main.pad_y as i32; let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else { return; }; @@ -1131,6 +1164,9 @@ impl App { focused, blink_on, }; + if fresh { + self.renderer.clear(canvas, dims, theme); + } for &y in &dirty { self.renderer.render_row(canvas, dims, grid, &frame, y); } @@ -1138,7 +1174,8 @@ impl App { if let Some(text) = &bar_text && dirty.contains(&(rows - 1)) { - self.renderer.render_search_bar(canvas, dims, theme, text); + self.renderer + .render_search_bar(canvas, dims, theme, rows - 1, text); } self.frames[idx].rows = cur; @@ -1147,9 +1184,13 @@ impl App { tracing::error!("attach buffer: {err}"); return; } - for &y in &dirty { - let top = y as i32 * m.height as i32; - surface.damage_buffer(0, top, w as i32, m.height as i32); + if fresh { + surface.damage_buffer(0, 0, w as i32, h as i32); + } else { + for &y in &dirty { + let top = pad_y + y as i32 * m.height as i32; + surface.damage_buffer(0, top, w as i32, m.height as i32); + } } surface.frame(&self.qh, surface.clone()); self.window.commit();