//! Software renderer: compose the grid into an ARGB8888 buffer. //! //! The target is a `wl_shm` buffer in `Argb8888`, which on little-endian is //! `[B, G, R, A]` per pixel. Rendering is two passes per frame - backgrounds //! then glyphs - so a wide glyph that overflows its cell is not clipped by the //! neighbouring cell's background fill. use std::num::NonZeroU16; use crate::font::{CellMetrics, Fonts, GlyphData, Style}; use crate::grid::{Cell, CursorShape, Flags, Grid, Underline}; use crate::theme::{Plane, Rgb, Theme}; /// A mutable view over a BGRA pixel buffer. struct Canvas<'a> { pixels: &'a mut [u8], width: usize, height: usize, } impl Canvas<'_> { fn index(&self, x: i32, y: i32) -> Option { if x < 0 || y < 0 || x as usize >= self.width || y as usize >= self.height { return None; } Some((y as usize * self.width + x as usize) * 4) } fn fill_rect(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb) { self.fill_rect_a(x0, y0, w, h, c, 0xff); } /// Fill a rectangle with colour `c` at opacity `alpha`. The shm buffer is /// premultiplied ARGB, so a translucent fill stores `rgb * alpha`. fn fill_rect_a(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb, alpha: u8) { let x_start = x0.max(0) as usize; let x_end = ((x0 + w as i32).max(0) as usize).min(self.width); let y_start = y0.max(0) as usize; let y_end = ((y0 + h as i32).max(0) as usize).min(self.height); if x_start >= x_end { return; } let a = u32::from(alpha); let pm = |v: u8| ((u32::from(v) * a) / 255) as u8; let bytes = [pm(c.2), pm(c.1), pm(c.0), alpha]; for y in y_start..y_end { let row = &mut self.pixels[(y * self.width + x_start) * 4..(y * self.width + x_end) * 4]; for px in row.chunks_exact_mut(4) { px.copy_from_slice(&bytes); } } } /// Alpha-blend `fg` over the existing pixel with coverage `a`. fn blend(&mut self, x: i32, y: i32, fg: Rgb, a: u8) { let Some(i) = self.index(x, y) else { return }; let (a, inv) = (u32::from(a), u32::from(255 - a)); let mix = |src: u8, dst: u8| ((u32::from(src) * a + u32::from(dst) * inv) / 255) as u8; self.pixels[i] = mix(fg.2, self.pixels[i]); self.pixels[i + 1] = mix(fg.1, self.pixels[i + 1]); self.pixels[i + 2] = mix(fg.0, self.pixels[i + 2]); self.pixels[i + 3] = 0xff; } /// Set a single opaque pixel. fn put(&mut self, x: i32, y: i32, c: Rgb) { if let Some(i) = self.index(x, y) { self.pixels[i] = c.2; self.pixels[i + 1] = c.1; self.pixels[i + 2] = c.0; self.pixels[i + 3] = 0xff; } } fn hline(&mut self, x0: i32, y: i32, w: u32, c: Rgb) { self.fill_rect(x0, y, w, 1, c); } /// Composite one pre-multiplied BGRA source pixel over the destination. fn over(&mut self, x: i32, y: i32, src: &[u8]) { let Some(i) = self.index(x, y) else { return }; let inv = u32::from(255 - src[3]); let comp = |s: u8, dst: u8| (u32::from(s) + u32::from(dst) * inv / 255).min(255) as u8; self.pixels[i] = comp(src[0], self.pixels[i]); self.pixels[i + 1] = comp(src[1], self.pixels[i + 1]); self.pixels[i + 2] = comp(src[2], self.pixels[i + 2]); self.pixels[i + 3] = 0xff; } } /// Per-frame constants shared by every row: the colour scheme, focus, and the /// current blink phase. #[derive(Clone, Copy, Debug)] pub struct Frame<'a> { pub theme: &'a Theme, pub focused: bool, pub blink_on: bool, /// Hyperlink currently under the pointer; its cells get a hover underline. pub hovered_link: Option, } #[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, 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); } /// Rebuild the font set at a new size (font-resize bindings). pub fn set_font(&mut self, family: &str, size_px: u32) -> Result<(), crate::font::FontError> { self.fonts = Fonts::new(family, size_px)?; Ok(()) } /// 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 /// current blink phase; blinking cells and a blinking cursor vanish when it /// is `false`. Painting one row at a time is what lets the caller damage /// only the rows that actually changed. pub fn render_row( &mut self, pixels: &mut [u8], dims: (usize, usize), grid: &Grid, frame: &Frame, y: usize, ) { let (theme, focused, blink_on) = (frame.theme, frame.focused, frame.blink_on); let (width, height) = dims; let mut canvas = Canvas { pixels, width, height, }; let m = self.fonts.metrics(); let (pad_x, pad_y) = self.pad; let cols = grid.cols(); 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 // `cols` after a resize, so clamp with `take`. let abs = grid.view_to_abs(y); let cells = grid.view_row(y); let search = grid.search_spans_on(abs); let match_at = |x: usize| -> Option { search .iter() .find(|(lo, hi, _)| x >= *lo && x <= *hi) .map(|(_, _, current)| *current) }; for (x, cell) in cells.iter().take(cols).enumerate() { // Focused match > selection > other match > the cell's own bg. let bg = match match_at(x) { Some(true) => theme.current_match_bg, _ if grid.is_selected(abs, x) => theme.selection_bg, Some(false) => theme.match_bg, None => cell_colors(cell, theme).1, }; if bg != theme.bg { canvas.fill_rect( pad_x + x as i32 * m.width as i32, row_top, m.width, m.height, bg, ); } } for (x, cell) in cells.iter().take(cols).enumerate() { if cell.flags.contains(Flags::WIDE_CONT) { continue; } if cell.flags.contains(Flags::BLINK) && !blink_on { continue; } let (fg, _) = cell_colors(cell, theme); let origin_x = pad_x + x as i32 * m.width as i32; if is_braille(cell.c) { // Drawn directly so the dots are crisp and fill the cell, the // way tools like btop expect, rather than however the fallback // font happens to size its braille glyphs. draw_braille(&mut canvas, cell.c, origin_x, row_top, m, fg); } else if cell.c != ' ' { self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg); } // Stack any combining marks over the base glyph; their own bearings // position them (no shaper, so placement is the font's default). if let Some(marks) = &cell.combining { for mark in marks.chars() { self.draw_glyph(&mut canvas, mark, cell_style(cell), origin_x, row_top, fg); } } draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg); // Underline an OSC 8 hyperlink while the pointer hovers over it. if cell.link.is_some() && cell.link == frame.hovered_link { canvas.hline(origin_x, row_top + m.height as i32 - 2, m.width, fg); } } // The cursor belongs to the live screen; hide it while scrolled back. if grid.view_at_bottom() && grid.cursor().1 == y { self.draw_cursor(&mut canvas, grid, theme, m, focused, blink_on); } } /// Draw the incremental-search prompt across the bottom row, over whatever /// grid content was there. The caller marks the bottom row dirty so this /// repaints whenever the query or match count changes. pub fn render_search_bar( &mut self, pixels: &mut [u8], dims: (usize, usize), theme: &Theme, row: usize, text: &str, ) { let (width, height) = dims; let mut canvas = Canvas { pixels, width, height, }; let m = self.fonts.metrics(); 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 = pad_x; for c in text.chars() { if x as usize + m.width as usize > width { break; } if c != ' ' { self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg); } x += m.width as i32; } } /// Draw a URL hint label (e.g. `a`, `bc`) as a highlighted tag starting at /// viewport cell `(row, col)`, over whatever was there. pub fn render_label( &mut self, pixels: &mut [u8], dims: (usize, usize), theme: &Theme, row: usize, col: usize, text: &str, ) { let (width, height) = dims; let mut canvas = Canvas { pixels, width, height, }; let m = self.fonts.metrics(); let (pad_x, pad_y) = self.pad; let row_top = pad_y + row as i32 * m.height as i32; let style = Style { bold: true, italic: false, }; let mut x = pad_x + col as i32 * m.width as i32; for c in text.chars() { if x as usize + m.width as usize > width { break; } canvas.fill_rect(x, row_top, m.width, m.height, theme.current_match_bg); if c != ' ' { self.draw_glyph(&mut canvas, c, style, x, row_top, theme.bg); } x += m.width as i32; } } /// Draw the IME preedit string inline, starting at grid cell `start_col` of /// row `row`, over whatever was there. The preedit sits on the selection /// background and is underlined so it reads as uncommitted, in-flight text. pub fn render_preedit( &mut self, pixels: &mut [u8], dims: (usize, usize), theme: &Theme, row: usize, start_col: usize, text: &str, ) { let (width, height) = dims; let mut canvas = Canvas { pixels, width, height, }; let m = self.fonts.metrics(); let (pad_x, pad_y) = self.pad; let row_top = pad_y + row as i32 * m.height as i32; let style = Style { bold: false, italic: false, }; let mut x = pad_x + start_col as i32 * m.width as i32; for c in text.chars() { if x < 0 || x as usize + m.width as usize > width { break; } canvas.fill_rect(x, row_top, m.width, m.height, theme.selection_bg); if c != ' ' { self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg); } // Underline the run a row above the cell bottom. canvas.hline(x, row_top + m.height as i32 - 2, m.width, theme.fg); x += m.width as i32; } } /// Draw the cursor: a solid block/underline/beam when focused, a hollow /// outline when not. A blinking cursor shape is only drawn while `blink_on`. fn draw_cursor( &mut self, canvas: &mut Canvas, grid: &Grid, theme: &Theme, m: CellMetrics, focused: bool, blink_on: bool, ) { if !grid.cursor_visible() || (grid.cursor_blink() && !blink_on) { return; } let (cx, cy) = grid.cursor(); 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() .map(|(r, g, b)| Rgb(r, g, b)) .or(theme.cursor) .unwrap_or(theme.fg); if !focused { let right = x0 + m.width as i32 - 1; let bottom = top + m.height as i32 - 1; canvas.hline(x0, top, m.width, color); canvas.hline(x0, bottom, m.width, color); canvas.fill_rect(x0, top, 1, m.height, color); canvas.fill_rect(right, top, 1, m.height, color); return; } match grid.cursor_shape() { CursorShape::Block => { canvas.fill_rect(x0, top, m.width, m.height, color); let cell = grid.cell(cx, cy); if cell.c != ' ' && !cell.flags.contains(Flags::WIDE_CONT) { let (_, bg) = cell_colors(cell, theme); self.draw_glyph(canvas, cell.c, cell_style(cell), x0, top, bg); } } CursorShape::Underline => { canvas.fill_rect(x0, top + m.height as i32 - 2, m.width, 2, color); } CursorShape::Beam => canvas.fill_rect(x0, top, 2, m.height, color), } } fn draw_glyph( &mut self, canvas: &mut Canvas, c: char, style: Style, origin_x: i32, cell_top: i32, fg: Rgb, ) { let m = self.fonts.metrics(); let glyph = match self.fonts.glyph(c, style) { Ok(glyph) => glyph, Err(err) => { tracing::debug!("glyph {c:?}: {err}"); return; } }; let (gw, gh) = (glyph.width as i32, glyph.height as i32); match &glyph.data { GlyphData::Mask(mask) => { let baseline = cell_top + m.ascent as i32; for gy in 0..gh { for gx in 0..gw { let a = mask[(gy * gw + gx) as usize]; if a != 0 { canvas.blend( origin_x + glyph.left + gx, baseline - glyph.top + gy, fg, a, ); } } } } // Colour glyphs (emoji) come from a fixed strike at native size; // scale them to the line height with nearest-neighbour sampling. GlyphData::Color(bgra) if gh > 0 => { let scale = m.height as f32 / gh as f32; let target_w = (gw as f32 * scale) as i32; for ty in 0..m.height as i32 { let sy = ((ty as f32 / scale) as i32).min(gh - 1); for tx in 0..target_w { let sx = ((tx as f32 / scale) as i32).min(gw - 1); let i = ((sy * gw + sx) * 4) as usize; canvas.over(origin_x + tx, cell_top + ty, &bgra[i..i + 4]); } } } GlyphData::Color(_) => {} } } } fn cell_style(cell: &Cell) -> Style { Style { bold: cell.flags.contains(Flags::BOLD), italic: cell.flags.contains(Flags::ITALIC), } } /// Resolve a cell's (foreground, background) RGB, applying reverse video, /// bold-as-bright, dim, and hidden. fn cell_colors(cell: &Cell, theme: &Theme) -> (Rgb, Rgb) { let bold = cell.flags.contains(Flags::BOLD); let mut fg = theme.resolve(cell.fg, Plane::Fg, bold); let mut bg = theme.resolve(cell.bg, Plane::Bg, false); if cell.flags.contains(Flags::REVERSE) { std::mem::swap(&mut fg, &mut bg); } if cell.flags.contains(Flags::DIM) { fg = blend_rgb(fg, bg); } if cell.flags.contains(Flags::HIDDEN) { fg = bg; } (fg, bg) } /// Mix `c` two-thirds of the way from `toward`, used for the dim attribute. fn blend_rgb(c: Rgb, toward: Rgb) -> Rgb { let mix = |a: u8, b: u8| ((u32::from(a) * 2 + u32::from(b)) / 3) as u8; Rgb(mix(c.0, toward.0), mix(c.1, toward.1), mix(c.2, toward.2)) } /// Whether `c` is a Braille Patterns codepoint (U+2800-U+28FF). fn is_braille(c: char) -> bool { ('\u{2800}'..='\u{28ff}').contains(&c) } /// Braille dot geometry for a `width`×`height` cell: the square dot side `w`, /// the two column origins, and the four row origins. Ported verbatim from /// foot's `box-drawing.c` `draw_braille` - base size and spacing from the cell, /// then leftover pixels distributed (dot → margin → spacing → margin → dot) so /// dots land on exact pixels with no rounding drift. fn braille_geometry(width: i32, height: i32) -> (u32, [i32; 2], [i32; 4]) { let mut w = (width / 4).min(height / 8); let mut x_spacing = width / 4; let mut y_spacing = height / 8; let mut x_margin = x_spacing / 2; let mut y_margin = y_spacing / 2; let mut x_left = width - 2 * x_margin - x_spacing - 2 * w; let mut y_left = height - 2 * y_margin - 3 * y_spacing - 4 * w; // First, try hard to ensure a non-zero dot width. if x_left >= 2 && y_left >= 4 && w == 0 { w += 1; x_left -= 2; y_left -= 4; } // Second, prefer a non-zero margin. if x_left >= 2 && x_margin == 0 { x_margin = 1; x_left -= 2; } if y_left >= 2 && y_margin == 0 { y_margin = 1; y_left -= 2; } // Third, increase spacing. if x_left >= 1 { x_spacing += 1; x_left -= 1; } if y_left >= 3 { y_spacing += 1; y_left -= 3; } // Fourth, the side margins. if x_left >= 2 { x_margin += 1; x_left -= 2; } if y_left >= 2 { y_margin += 1; y_left -= 2; } // Last, increase the dot width. if x_left >= 2 && y_left >= 4 { w += 1; } let xs = [x_margin, x_margin + w + x_spacing]; let ys = [ y_margin, y_margin + w + y_spacing, y_margin + 2 * (w + y_spacing), y_margin + 3 * (w + y_spacing), ]; (w.max(0) as u32, xs, ys) } /// Draw a braille pattern as a 2×4 grid of `w`×`w` square dots. Geometry ported /// from foot's `box-drawing.c` `draw_braille`: a dot size and base spacing are /// derived from the cell, then leftover pixels are distributed (dot width → /// margins → spacing → …) so dots land on exact pixels with no rounding drift. /// The low eight bits of the codepoint select dots: bits 0-2 are the left /// column rows 0-2, bits 3-5 the right column rows 0-2, bits 6-7 the bottom row. fn draw_braille(canvas: &mut Canvas, c: char, x0: i32, top: i32, m: CellMetrics, fg: Rgb) { let (w, xs, ys) = braille_geometry(m.width as i32, m.height as i32); let sym = ((c as u32) - 0x2800) as u8; // (bit mask, column index, row index). const DOTS: [(u8, usize, usize); 8] = [ (0x01, 0, 0), (0x02, 0, 1), (0x04, 0, 2), (0x08, 1, 0), (0x10, 1, 1), (0x20, 1, 2), (0x40, 0, 3), (0x80, 1, 3), ]; for (mask, col, row) in DOTS { if sym & mask != 0 { canvas.fill_rect(x0 + xs[col], top + ys[row], w, w, fg); } } } /// Draw underline, strikethrough, and overline for one cell. fn draw_decorations( canvas: &mut Canvas, cell: &Cell, theme: &Theme, x0: i32, top: i32, m: CellMetrics, fg: Rgb, ) { let w = m.width; let baseline = top + m.ascent as i32; let uy = (baseline + 1).min(top + m.height as i32 - 1); // A `Default` underline colour follows the cell's foreground. let uc = match cell.underline_color { crate::grid::Color::Default => fg, other => theme.resolve(other, Plane::Fg, false), }; match cell.underline { Underline::None => {} Underline::Single => canvas.hline(x0, uy, w, uc), Underline::Double => { canvas.hline(x0, uy, w, uc); canvas.hline(x0, (uy - 2).max(top), w, uc); } Underline::Curly => { for dx in 0..w as i32 { let wobble = if (dx / 2) % 2 == 0 { 0 } else { 1 }; canvas.put(x0 + dx, uy - wobble, uc); } } Underline::Dotted => { for dx in (0..w as i32).step_by(2) { canvas.put(x0 + dx, uy, uc); } } Underline::Dashed => { for dx in 0..w as i32 { if (dx / 3) % 2 == 0 { canvas.put(x0 + dx, uy, uc); } } } } if cell.flags.contains(Flags::OVERLINE) { canvas.hline(x0, top, w, fg); } if cell.flags.contains(Flags::STRIKE) { canvas.hline(x0, top + m.ascent as i32 * 2 / 3, w, fg); } } #[cfg(test)] mod tests { use super::braille_geometry; // Pinned to foot box-drawing.c draw_braille output (cross-checked numerically // identical across cell sizes 4..30 x 6..48); guards against drift. #[test] fn braille_geometry_matches_foot() { assert_eq!(braille_geometry(8, 18), (2, [1, 5], [2, 6, 10, 14])); assert_eq!(braille_geometry(10, 20), (2, [1, 6], [1, 6, 11, 16])); assert_eq!(braille_geometry(12, 27), (3, [1, 8], [1, 8, 15, 22])); assert_eq!(braille_geometry(7, 15), (1, [1, 4], [2, 5, 8, 11])); } }