render: inner terminal padding (pad-x/pad-y)

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I190f63ca86a8cf976e4d018df73897ab6a6a6964
This commit is contained in:
raf 2026-06-25 10:53:15 +03:00
commit 2d319b7e73
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
3 changed files with 101 additions and 28 deletions

View file

@ -52,6 +52,9 @@ pub struct Main {
/// Initial size in character cells. /// Initial size in character cells.
pub initial_cols: u16, pub initial_cols: u16,
pub initial_rows: 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 /// Characters that break a word for double-click selection. Empty/unset
/// keeps the built-in default. /// keeps the built-in default.
pub word_delimiters: Option<String>, pub word_delimiters: Option<String>,
@ -65,6 +68,8 @@ impl Default for Main {
term: "beer".to_string(), term: "beer".to_string(),
initial_cols: 80, initial_cols: 80,
initial_rows: 24, initial_rows: 24,
pad_x: 2,
pad_y: 2,
word_delimiters: None, word_delimiters: None,
} }
} }

View file

@ -99,17 +99,36 @@ pub struct Frame<'a> {
#[derive(Debug)] #[derive(Debug)]
pub struct Renderer { pub struct Renderer {
fonts: Fonts, fonts: Fonts,
/// Inner padding `(x, y)` in pixels between the window edge and the grid.
pad: (i32, i32),
} }
impl Renderer { impl Renderer {
pub fn new(fonts: Fonts) -> Self { pub fn new(fonts: Fonts) -> Self {
Self { fonts } Self { fonts, pad: (0, 0) }
} }
pub fn metrics(&self) -> CellMetrics { pub fn metrics(&self) -> CellMetrics {
self.fonts.metrics() 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): /// Repaint a single grid row `y` into `pixels` (BGRA, `width`×`height` px):
/// clear the row band, fill backgrounds (and selection), draw glyphs and /// 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 /// decorations, then the cursor if it sits on this row. `blink_on` is the
@ -132,8 +151,9 @@ impl Renderer {
height, height,
}; };
let m = self.fonts.metrics(); let m = self.fonts.metrics();
let (pad_x, pad_y) = self.pad;
let cols = grid.cols(); 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); 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 // Rows come through the scrollback viewport and may be shorter than
@ -156,7 +176,13 @@ impl Renderer {
None => cell_colors(cell, theme).1, None => cell_colors(cell, theme).1,
}; };
if bg != theme.bg { 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; continue;
} }
let (fg, _) = cell_colors(cell, theme); 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 != ' ' { if cell.c != ' ' {
self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg); self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg);
} }
@ -189,6 +215,7 @@ impl Renderer {
pixels: &mut [u8], pixels: &mut [u8],
dims: (usize, usize), dims: (usize, usize),
theme: &Theme, theme: &Theme,
row: usize,
text: &str, text: &str,
) { ) {
let (width, height) = dims; let (width, height) = dims;
@ -198,14 +225,14 @@ impl Renderer {
height, height,
}; };
let m = self.fonts.metrics(); let m = self.fonts.metrics();
let rows = (height / m.height as usize).max(1); let (pad_x, pad_y) = self.pad;
let row_top = (rows - 1) as i32 * m.height as i32; 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); canvas.fill_rect(0, row_top, width as u32, m.height, theme.search_bar_bg);
let style = Style { let style = Style {
bold: false, bold: false,
italic: false, italic: false,
}; };
let mut x = 0i32; let mut x = pad_x;
for c in text.chars() { for c in text.chars() {
if x as usize + m.width as usize > width { if x as usize + m.width as usize > width {
break; break;
@ -232,8 +259,8 @@ impl Renderer {
return; return;
} }
let (cx, cy) = grid.cursor(); let (cx, cy) = grid.cursor();
let x0 = cx as i32 * m.width as i32; let x0 = self.pad.0 + cx as i32 * m.width as i32;
let top = cy as i32 * m.height 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. // OSC 12 cursor colour wins, then the configured cursor colour, then fg.
let color = grid let color = grid
.cursor_color() .cursor_color()

View file

@ -196,13 +196,14 @@ pub fn run(config: Config) -> anyhow::Result<ExitCode> {
window.commit(); window.commit();
let fonts = Fonts::new(&config.main.font, config.main.font_size).context("load font")?; 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 // Start at the configured cell geometry plus padding; the compositor may
// the first configure. // override it on the first configure.
let m = renderer.metrics(); let m = renderer.metrics();
let width = (u32::from(config.main.initial_cols) * m.width).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).max(1); let height = (u32::from(config.main.initial_rows) * m.height + 2 * config.main.pad_y).max(1);
let pool = SlotPool::new( let pool = SlotPool::new(
(width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize, (width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize,
&shm, &shm,
@ -285,10 +286,16 @@ pub fn run(config: Config) -> anyhow::Result<ExitCode> {
Ok(app.exit_code) Ok(app.exit_code)
} }
/// Columns and rows that fit a `width`×`height` px window at `metrics`. /// Columns and rows that fit a `width`×`height` px window at `metrics`, after
fn grid_size(metrics: crate::font::CellMetrics, width: u32, height: u32) -> (u16, u16) { /// reserving `2 * pad` pixels of inner padding on each axis.
let cols = (width / metrics.width).max(1); fn grid_size(
let rows = (height / metrics.height).max(1); 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) (cols as u16, rows as u16)
} }
@ -387,7 +394,12 @@ struct App {
impl App { impl App {
/// Spawn the shell at the current window size and start reading its output. /// Spawn the shell at the current window size and start reading its output.
fn spawn_session(&mut self) { 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) { let pty = match Pty::spawn(cols, rows, &self.config.main.term) {
Ok(pty) => pty, Ok(pty) => pty,
Err(err) => { Err(err) => {
@ -557,13 +569,24 @@ impl App {
self.needs_draw = true; 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. /// Map window pixel coordinates to an absolute `(row, col)` grid point.
fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> { fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {
let session = self.session.as_ref()?; let session = self.session.as_ref()?;
let m = self.renderer.metrics(); let m = self.renderer.metrics();
let (pad_x, pad_y) = self.padding();
let grid = session.term.grid(); let grid = session.term.grid();
let col = (px.max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1)); let col =
let vrow = (py.max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1)); ((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)) Some((grid.view_to_abs(vrow), col))
} }
@ -683,10 +706,11 @@ impl App {
fn report_screen_cell(&self) -> Option<(usize, usize)> { fn report_screen_cell(&self) -> Option<(usize, usize)> {
let session = self.session.as_ref()?; let session = self.session.as_ref()?;
let m = self.renderer.metrics(); let m = self.renderer.metrics();
let (pad_x, pad_y) = self.padding();
let grid = session.term.grid(); 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)); .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)); .min(grid.rows().saturating_sub(1));
Some((col, row)) Some((col, row))
} }
@ -968,7 +992,12 @@ impl App {
/// Recompute the grid size for the current window and tell the grid and the /// Recompute the grid size for the current window and tell the grid and the
/// PTY about it if it changed. /// PTY about it if it changed.
fn resize_grid(&mut self) { 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 { let Some(session) = self.session.as_mut() else {
return; return;
}; };
@ -1122,6 +1151,10 @@ impl App {
return; 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 { let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {
return; return;
}; };
@ -1131,6 +1164,9 @@ impl App {
focused, focused,
blink_on, blink_on,
}; };
if fresh {
self.renderer.clear(canvas, dims, theme);
}
for &y in &dirty { for &y in &dirty {
self.renderer.render_row(canvas, dims, grid, &frame, y); self.renderer.render_row(canvas, dims, grid, &frame, y);
} }
@ -1138,7 +1174,8 @@ impl App {
if let Some(text) = &bar_text if let Some(text) = &bar_text
&& dirty.contains(&(rows - 1)) && 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; self.frames[idx].rows = cur;
@ -1147,9 +1184,13 @@ impl App {
tracing::error!("attach buffer: {err}"); tracing::error!("attach buffer: {err}");
return; return;
} }
for &y in &dirty { if fresh {
let top = y as i32 * m.height as i32; surface.damage_buffer(0, 0, w as i32, h as i32);
surface.damage_buffer(0, top, w as i32, m.height 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()); surface.frame(&self.qh, surface.clone());
self.window.commit(); self.window.commit();