brewery
notashelf /
5690e0e883a9bfb1879e8fd1bdd3df1cf796a997

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 5690e0e883a9

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-24 12:36 UTC

unverified9 files changed+768-24
render: draw the grid with rasterized glyphs

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6350824abb506c2af98884a7374228116a6a6964
diff --git a/Cargo.lock b/Cargo.lockindex b14bb70..f51ab08 100644--- a/Cargo.lock+++ b/Cargo.lock@@ -30,9 +30,12 @@ dependencies = [  "anyhow",  "calloop",  "calloop-wayland-source",+ "fontconfig",+ "freetype-rs",  "pound",  "rustix",  "smithay-client-toolkit",+ "thiserror",  "tracing",  "tracing-subscriber",  "unicode-width",@@ -129,6 +132,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"  [[package]]+name = "dlib"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"+dependencies = [+ "libloading",+]++[[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -151,6 +163,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"  [[package]]+name = "fontconfig"+version = "0.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0d0e1eb4148faaf675053b7299bc5b4c041c8c0c724cf77844d6be8dd0ac5b9d"+dependencies = [+ "yeslogic-fontconfig-sys",+]++[[package]]+name = "freetype-rs"+version = "0.38.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6d228d6de56c90dd7585341f341849441b3490180c62d27133e525eb726809b4"+dependencies = [+ "bitflags",+ "freetype-sys",+ "libc",+]++[[package]]+name = "freetype-sys"+version = "0.23.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "eab537ce43cab850c64b4cdc390ce7e4f47f877485ddc323208e268280c308ae"+dependencies = [+ "cc",+ "libz-sys",+ "pkg-config",+]++[[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -169,6 +212,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"  [[package]]+name = "libloading"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"+dependencies = [+ "cfg-if",+ "windows-link",+]++[[package]]+name = "libz-sys"+version = "1.1.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"+dependencies = [+ "cc",+ "libc",+ "pkg-config",+ "vcpkg",+]++[[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -500,6 +565,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"  [[package]]+name = "vcpkg"+version = "0.2.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"++[[package]] name = "venial" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -677,3 +748,14 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" dependencies = [  "bytemuck", ]++[[package]]+name = "yeslogic-fontconfig-sys"+version = "6.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"+dependencies = [+ "dlib",+ "once_cell",+ "pkg-config",+]diff --git a/Cargo.toml b/Cargo.tomlindex e1e4768..2496395 100644--- a/Cargo.toml+++ b/Cargo.toml@@ -11,6 +11,8 @@ readme = true anyhow = "1.0.102" calloop = "0.14.4" calloop-wayland-source = "0.4.1"+fontconfig = "0.11.0"+freetype-rs = "0.38.0" pound = "0.1.6" rustix = { version = "1.1.4", features = [   "pty",@@ -20,6 +22,7 @@ rustix = { version = "1.1.4", features = [   "fs", ] } smithay-client-toolkit = "0.20.0"+thiserror = "2.0.18" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } unicode-width = "0.2.2"diff --git a/src/font.rs b/src/font.rsnew file mode 100644index 0000000..16587be--- /dev/null+++ b/src/font.rs@@ -0,0 +1,365 @@+//! Font discovery, rasterization, and glyph caching.+//!+//! fontconfig resolves family names and performs per-codepoint fallback;+//! FreeType rasterizes each glyph to an 8-bit coverage mask or, for colour+//! fonts, a pre-multiplied BGRA bitmap. Layout is fixed-cell, so a glyph's own+//! advance is never consulted - only the [`CellMetrics`] taken from the primary+//! face. All C interop is handled by the `freetype` and `fontconfig` safe+//! wrappers, so this module contains no `unsafe`.++use std::collections::HashMap;+use std::fmt;+use std::path::PathBuf;++use fontconfig::{CharSet, Fontconfig, Pattern};+use freetype::bitmap::PixelMode;+use freetype::face::LoadFlag;+use freetype::{Face, Library};+use thiserror::Error;++#[derive(Debug, Error)]+pub enum FontError {+    #[error("FreeType: {0}")]+    FreeType(#[from] freetype::Error),+    #[error("could not initialize fontconfig")]+    FontconfigInit,+    #[error("fontconfig: {0}")]+    Fontconfig(#[from] fontconfig::FontconfigError),+    #[error("no font matched family {0:?}")]+    NoFamily(String),+    #[error("font {0:?} reports no size metrics")]+    NoMetrics(String),+}++/// Bold/italic selection, used both to pick a face and to key the glyph cache.+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]+pub struct Style {+    pub bold: bool,+    pub italic: bool,+}++impl Style {+    /// Dense index in `0..4` for array storage.+    fn index(self) -> usize {+        usize::from(self.bold) | (usize::from(self.italic) << 1)+    }++    fn fontconfig_style(self) -> &'static str {+        match (self.bold, self.italic) {+            (false, false) => "Regular",+            (true, false) => "Bold",+            (false, true) => "Italic",+            (true, true) => "Bold Italic",+        }+    }+}++/// Fixed cell geometry in pixels, derived from the primary face.+#[derive(Clone, Copy, Debug)]+pub struct CellMetrics {+    pub width: u32,+    pub height: u32,+    /// Baseline offset from the top of the cell.+    pub ascent: u32,+}++/// A rasterized glyph: its bitmap plus the offsets to place it on the baseline.+#[derive(Clone, Debug)]+pub struct Glyph {+    /// Horizontal offset from the pen position to the bitmap's left edge.+    pub left: i32,+    /// Vertical offset from the baseline up to the bitmap's top edge.+    pub top: i32,+    pub width: u32,+    pub height: u32,+    pub data: GlyphData,+}++/// Glyph pixel data. A `Mask` is tinted with the cell's foreground colour; a+/// `Color` bitmap (emoji) is composited directly.+#[derive(Clone, Debug)]+pub enum GlyphData {+    /// One coverage byte per pixel.+    Mask(Vec<u8>),+    /// Pre-multiplied BGRA, four bytes per pixel.+    Color(Vec<u8>),+}++/// The font set for one terminal: a primary family with lazily-loaded+/// bold/italic variants and per-codepoint fallback faces, plus a glyph cache.+pub struct Fonts {+    library: Library,+    fontconfig: Fontconfig,+    family: String,+    size_px: u32,+    metrics: CellMetrics,+    /// All loaded faces; indices into this vector are stable.+    faces: Vec<Face>,+    /// Index of each style variant, by [`Style::index`]; filled on demand.+    styled: [Option<usize>; 4],+    /// Fallback faces resolved by coverage, deduplicated by file path.+    fallbacks: HashMap<PathBuf, usize>,+    cache: HashMap<(char, usize), Glyph>,+}++impl fmt::Debug for Fonts {+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+        f.debug_struct("Fonts")+            .field("family", &self.family)+            .field("size_px", &self.size_px)+            .field("metrics", &self.metrics)+            .field("faces", &self.faces.len())+            .field("cached", &self.cache.len())+            .finish()+    }+}++impl Fonts {+    /// Resolve `family` at `size_px` and compute the cell metrics.+    pub fn new(family: &str, size_px: u32) -> Result<Self, FontError> {+        let library = Library::init()?;+        let fontconfig = Fontconfig::new().ok_or(FontError::FontconfigInit)?;++        let regular = resolve_face(&library, &fontconfig, family, Style::default(), size_px)?;+        let metrics = cell_metrics(&regular, family)?;++        Ok(Self {+            library,+            fontconfig,+            family: family.to_owned(),+            size_px,+            metrics,+            faces: vec![regular],+            styled: [Some(0), None, None, None],+            fallbacks: HashMap::new(),+            cache: HashMap::new(),+        })+    }++    pub fn metrics(&self) -> CellMetrics {+        self.metrics+    }++    /// Return the rasterized glyph for `c` in `style`, rasterizing and caching+    /// it on first use.+    pub fn glyph(&mut self, c: char, style: Style) -> Result<&Glyph, FontError> {+        let key = (c, style.index());+        if !self.cache.contains_key(&key) {+            let face = self.face_for(c, style)?;+            let glyph = rasterize(&self.faces[face], c)?;+            self.cache.insert(key, glyph);+        }+        Ok(&self.cache[&key])+    }++    /// Pick the face that should render `c`: the requested style if it has the+    /// glyph, then regular, then known fallbacks, then a fresh fontconfig+    /// coverage match. Falls back to the styled face (rendering `.notdef`).+    fn face_for(&mut self, c: char, style: Style) -> Result<usize, FontError> {+        let styled = self.styled_face(style)?;+        if face_has_glyph(&self.faces[styled], c) {+            return Ok(styled);+        }+        if let Some(regular) = self.styled[0]+            && regular != styled+            && face_has_glyph(&self.faces[regular], c)+        {+            return Ok(regular);+        }+        for &idx in self.fallbacks.values() {+            if face_has_glyph(&self.faces[idx], c) {+                return Ok(idx);+            }+        }+        Ok(self.load_fallback(c)?.unwrap_or(styled))+    }++    /// Lazily load the face for `style`, caching regular's index if the variant+    /// cannot be resolved so the lookup is not retried per glyph.+    fn styled_face(&mut self, style: Style) -> Result<usize, FontError> {+        if let Some(idx) = self.styled[style.index()] {+            return Ok(idx);+        }+        let regular = self.styled[0].expect("regular face is loaded at construction");+        let idx = match resolve_face(+            &self.library,+            &self.fontconfig,+            &self.family,+            style,+            self.size_px,+        ) {+            Ok(face) => {+                self.faces.push(face);+                self.faces.len() - 1+            }+            Err(_) => regular,+        };+        self.styled[style.index()] = Some(idx);+        Ok(idx)+    }++    /// Ask fontconfig for a font covering `c`, load it, and remember it.+    fn load_fallback(&mut self, c: char) -> Result<Option<usize>, FontError> {+        let mut charset = CharSet::new(&self.fontconfig)?;+        charset.add_char(c)?;+        let mut pattern = Pattern::new(&self.fontconfig)?;+        pattern.add_string(c"family", c"monospace")?;+        pattern.add_charset(charset)?;+        let matched = pattern.font_match()?;++        let path = PathBuf::from(matched.filename()?);+        if let Some(&idx) = self.fallbacks.get(&path) {+            return Ok(Some(idx));+        }+        let index = matched.face_index().unwrap_or(0) as isize;+        let face = self.library.new_face(&path, index)?;+        // Bitmap-strike-only fonts reject an arbitrary pixel size; skip them+        // rather than guess a strike.+        if face.set_pixel_sizes(0, self.size_px).is_err() {+            return Ok(None);+        }+        self.faces.push(face);+        let idx = self.faces.len() - 1;+        self.fallbacks.insert(path, idx);+        Ok(Some(idx))+    }+}++fn face_has_glyph(face: &Face, c: char) -> bool {+    face.get_char_index(c as usize).is_some_and(|g| g != 0)+}++fn resolve_face(+    library: &Library,+    fontconfig: &Fontconfig,+    family: &str,+    style: Style,+    size_px: u32,+) -> Result<Face, FontError> {+    let font = fontconfig+        .find(family, Some(style.fontconfig_style()))+        .map_err(|_| FontError::NoFamily(family.to_owned()))?;+    let face = library.new_face(&font.path, font.index.unwrap_or(0) as isize)?;+    face.set_pixel_sizes(0, size_px)?;+    Ok(face)+}++fn cell_metrics(face: &Face, family: &str) -> Result<CellMetrics, FontError> {+    let metrics = face+        .size_metrics()+        .ok_or_else(|| FontError::NoMetrics(family.to_owned()))?;+    // FreeType reports these in 26.6 fixed point.+    let ascent = (metrics.ascender >> 6).max(1) as u32;+    let height = (metrics.height >> 6).max(1) as u32;++    // For a monospace face every advance is equal; measure one ASCII glyph.+    face.load_char('M' as usize, LoadFlag::DEFAULT)?;+    let width = (face.glyph().advance().x >> 6).max(1) as u32;++    Ok(CellMetrics {+        width,+        height,+        ascent,+    })+}++fn rasterize(face: &Face, c: char) -> Result<Glyph, FontError> {+    face.load_char(c as usize, LoadFlag::RENDER | LoadFlag::COLOR)?;+    let slot = face.glyph();+    let bitmap = slot.bitmap();+    let width = bitmap.width().max(0) as usize;+    let height = bitmap.rows().max(0) as usize;+    let pitch = bitmap.pitch();+    let src = bitmap.buffer();++    let data = match bitmap.pixel_mode()? {+        PixelMode::Gray => GlyphData::Mask(pack_rows(src, width, pitch, height)),+        PixelMode::Bgra => GlyphData::Color(pack_rows(src, width * 4, pitch, height)),+        PixelMode::Mono => GlyphData::Mask(expand_mono(src, width, pitch, height)),+        _ => GlyphData::Mask(vec![0; width * height]),+    };++    Ok(Glyph {+        left: slot.bitmap_left(),+        top: slot.bitmap_top(),+        width: width as u32,+        height: height as u32,+        data,+    })+}++/// Copy `height` rows of `row_bytes` each out of FreeType's padded buffer,+/// honouring pitch sign (positive = top-down).+fn pack_rows(src: &[u8], row_bytes: usize, pitch: i32, height: usize) -> Vec<u8> {+    let stride = pitch.unsigned_abs() as usize;+    let take = row_bytes.min(stride);+    let mut out = vec![0u8; row_bytes * height];+    for row in 0..height {+        let src_row = if pitch >= 0 { row } else { height - 1 - row };+        let start = src_row * stride;+        if start + take <= src.len() {+            out[row * row_bytes..row * row_bytes + take].copy_from_slice(&src[start..start + take]);+        }+    }+    out+}++/// Expand a 1-bit-per-pixel mono bitmap to one coverage byte per pixel.+fn expand_mono(src: &[u8], width: usize, pitch: i32, height: usize) -> Vec<u8> {+    let stride = pitch.unsigned_abs() as usize;+    let mut out = vec![0u8; width * height];+    for row in 0..height {+        let src_row = if pitch >= 0 { row } else { height - 1 - row };+        let base = src_row * stride;+        for x in 0..width {+            let byte = base + x / 8;+            if byte < src.len() && src[byte] & (0x80 >> (x % 8)) != 0 {+                out[row * width + x] = 0xff;+            }+        }+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    fn fonts() -> Fonts {+        Fonts::new("monospace", 16).expect("system has a monospace font")+    }++    #[test]+    fn cell_metrics_are_sane() {+        let m = fonts().metrics();+        assert!(m.width >= 1 && m.height >= 1);+        assert!(m.ascent >= 1 && m.ascent <= m.height);+    }++    #[test]+    fn ascii_glyph_has_ink() {+        let mut f = fonts();+        let glyph = f.glyph('M', Style::default()).expect("rasterize M");+        assert!(glyph.width > 0 && glyph.height > 0);+        match &glyph.data {+            GlyphData::Mask(px) => assert!(px.iter().any(|&p| p > 0), "M should have coverage"),+            GlyphData::Color(_) => {}+        }+    }++    #[test]+    fn space_is_blank_but_ok() {+        let mut f = fonts();+        // Space resolves without error; it simply carries no ink.+        f.glyph(' ', Style::default()).expect("rasterize space");+    }++    #[test]+    fn glyphs_are_cached() {+        let mut f = fonts();+        f.glyph('a', Style::default()).unwrap();+        let before = f.cache.len();+        f.glyph('a', Style::default()).unwrap();+        assert_eq!(f.cache.len(), before, "second lookup must hit the cache");+    }+}diff --git a/src/grid.rs b/src/grid.rsindex c7fecd6..51933a3 100644--- a/src/grid.rs+++ b/src/grid.rs@@ -124,10 +124,32 @@ impl Grid {         }     } +    pub fn cols(&self) -> usize {+        self.cols+    }+     pub fn rows(&self) -> usize {         self.rows     } +    /// Resize the screen, clipping content (reflow comes later).+    pub fn resize(&mut self, cols: usize, rows: usize) {+        let cols = cols.max(1);+        let rows = rows.max(1);+        for line in &mut self.lines {+            line.resize(cols, Cell::default());+        }+        self.lines.resize(rows, vec![Cell::default(); cols]);+        self.cols = cols;+        self.rows = rows;+        self.top = 0;+        self.bottom = rows - 1;+        self.tabs = default_tabs(cols);+        self.cursor.x = self.cursor.x.min(cols - 1);+        self.cursor.y = self.cursor.y.min(rows - 1);+        self.wrap_pending = false;+    }+     pub fn cursor(&self) -> (usize, usize) {         (self.cursor.x, self.cursor.y)     }@@ -526,6 +548,7 @@ impl Grid {     // --- inspection (logging + tests) ---      /// The visible text of one row, trailing blanks trimmed.+    #[cfg(test)]     pub fn row_text(&self, y: usize) -> String {         self.lines[y]             .iter()@@ -536,7 +559,6 @@ impl Grid {             .to_string()     } -    #[cfg(test)]     pub fn cell(&self, x: usize, y: usize) -> &Cell {         &self.lines[y][x]     }diff --git a/src/main.rs b/src/main.rsindex 80d2128..5981dc1 100644--- a/src/main.rs+++ b/src/main.rs@@ -1,7 +1,9 @@ //! beer, a fast, software-rendered, Wayland-native terminal emulator. +mod font; mod grid; mod pty;+mod render; mod vt; mod wayland; diff --git a/src/pty.rs b/src/pty.rsindex 9047984..122caf0 100644--- a/src/pty.rs+++ b/src/pty.rs@@ -73,6 +73,11 @@ impl Pty {         &self.master     } +    /// Inform the kernel (and thus the child) of a new terminal size.+    pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> {+        set_winsize(&self.master, cols, rows)+    }+     /// Reap the child if it has exited.     pub fn wait(&mut self) -> io::Result<ExitStatus> {         self.child.wait()diff --git a/src/render.rs b/src/render.rsnew file mode 100644index 0000000..7419a71--- /dev/null+++ b/src/render.rs@@ -0,0 +1,238 @@+//! 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 crate::font::{CellMetrics, Fonts, GlyphData, Style};+use crate::grid::{Color, Flags, Grid};++/// Foreground/background used for `Color::Default`.+const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);+const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);++#[derive(Clone, Copy)]+struct Rgb(u8, u8, u8);++/// 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<usize> {+        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) {+        for dy in 0..h as i32 {+            for dx in 0..w as i32 {+                if let Some(i) = self.index(x0 + dx, y0 + dy) {+                    self.pixels[i] = c.2;+                    self.pixels[i + 1] = c.1;+                    self.pixels[i + 2] = c.0;+                    self.pixels[i + 3] = 0xff;+                }+            }+        }+    }++    /// 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;+    }++    /// 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;+    }+}++#[derive(Debug)]+pub struct Renderer {+    fonts: Fonts,+}++impl Renderer {+    pub fn new(fonts: Fonts) -> Self {+        Self { fonts }+    }++    pub fn metrics(&self) -> CellMetrics {+        self.fonts.metrics()+    }++    /// Compose `grid` into `pixels` (BGRA, `width`×`height` px). The cursor cell+    /// is drawn reversed.+    pub fn render(&mut self, grid: &Grid, pixels: &mut [u8], width: usize, height: usize) {+        let mut canvas = Canvas {+            pixels,+            width,+            height,+        };+        canvas.fill_rect(0, 0, width as u32, height as u32, DEFAULT_BG);++        let m = self.fonts.metrics();+        let cursor = grid.cursor();++        for y in 0..grid.rows() {+            for x in 0..grid.cols() {+                let (_, bg) = cell_colors(grid.cell(x, y), (x, y) == cursor);+                let (px, py) = (x as i32 * m.width as i32, y as i32 * m.height as i32);+                canvas.fill_rect(px, py, m.width, m.height, bg);+            }+        }++        for y in 0..grid.rows() {+            for x in 0..grid.cols() {+                let cell = grid.cell(x, y);+                if cell.flags.contains(Flags::WIDE_CONT) || cell.c == ' ' {+                    continue;+                }+                let (fg, _) = cell_colors(cell, (x, y) == cursor);+                let style = Style {+                    bold: cell.flags.contains(Flags::BOLD),+                    italic: cell.flags.contains(Flags::ITALIC),+                };+                let origin_x = x as i32 * m.width as i32;+                let baseline = y as i32 * m.height as i32 + m.ascent as i32;+                self.draw_glyph(&mut canvas, cell.c, style, origin_x, baseline, fg);+            }+        }+    }++    fn draw_glyph(+        &mut self,+        canvas: &mut Canvas,+        c: char,+        style: Style,+        origin_x: i32,+        baseline: i32,+        fg: Rgb,+    ) {+        let glyph = match self.fonts.glyph(c, style) {+            Ok(glyph) => glyph,+            Err(err) => {+                tracing::debug!("glyph {c:?}: {err}");+                return;+            }+        };+        let (left, top, w, h) = (+            glyph.left,+            glyph.top,+            glyph.width as i32,+            glyph.height as i32,+        );+        match &glyph.data {+            GlyphData::Mask(mask) => {+                for gy in 0..h {+                    for gx in 0..w {+                        let a = mask[(gy * w + gx) as usize];+                        if a != 0 {+                            canvas.blend(origin_x + left + gx, baseline - top + gy, fg, a);+                        }+                    }+                }+            }+            GlyphData::Color(bgra) => {+                for gy in 0..h {+                    for gx in 0..w {+                        let i = ((gy * w + gx) * 4) as usize;+                        canvas.over(origin_x + left + gx, baseline - top + gy, &bgra[i..i + 4]);+                    }+                }+            }+        }+    }+}++/// Resolve a cell's (foreground, background) RGB, applying reverse video,+/// bold-as-bright for the foreground, and hidden.+fn cell_colors(cell: &crate::grid::Cell, cursor: bool) -> (Rgb, Rgb) {+    let bold = cell.flags.contains(Flags::BOLD);+    let mut fg = resolve(cell.fg, DEFAULT_FG, bold);+    let mut bg = resolve(cell.bg, DEFAULT_BG, false);+    if cell.flags.contains(Flags::REVERSE) ^ cursor {+        std::mem::swap(&mut fg, &mut bg);+    }+    if cell.flags.contains(Flags::HIDDEN) {+        fg = bg;+    }+    (fg, bg)+}++fn resolve(color: Color, default: Rgb, bold: bool) -> Rgb {+    match color {+        Color::Default => default,+        Color::Indexed(i) => ansi256(if bold && i < 8 { i + 8 } else { i }),+        Color::Rgb(r, g, b) => Rgb(r, g, b),+    }+}++/// The xterm 256-colour palette: 16 base, a 6×6×6 cube, then 24 greys.+fn ansi256(i: u8) -> Rgb {+    const BASE: [Rgb; 16] = [+        Rgb(0x00, 0x00, 0x00),+        Rgb(0xcd, 0x00, 0x00),+        Rgb(0x00, 0xcd, 0x00),+        Rgb(0xcd, 0xcd, 0x00),+        Rgb(0x00, 0x00, 0xee),+        Rgb(0xcd, 0x00, 0xcd),+        Rgb(0x00, 0xcd, 0xcd),+        Rgb(0xe5, 0xe5, 0xe5),+        Rgb(0x7f, 0x7f, 0x7f),+        Rgb(0xff, 0x00, 0x00),+        Rgb(0x00, 0xff, 0x00),+        Rgb(0xff, 0xff, 0x00),+        Rgb(0x5c, 0x5c, 0xff),+        Rgb(0xff, 0x00, 0xff),+        Rgb(0x00, 0xff, 0xff),+        Rgb(0xff, 0xff, 0xff),+    ];+    match i {+        0..=15 => BASE[i as usize],+        16..=231 => {+            let i = i - 16;+            Rgb(cube(i / 36), cube((i / 6) % 6), cube(i % 6))+        }+        _ => {+            let v = 8 + 10 * (i - 232);+            Rgb(v, v, v)+        }+    }+}++fn cube(step: u8) -> u8 {+    if step == 0 { 0 } else { 55 + 40 * step }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn palette_cube_and_grey() {+        assert_eq!(ansi256(16).0, 0); // cube origin is black+        let white = ansi256(231);+        assert_eq!((white.0, white.1, white.2), (255, 255, 255));+        assert_eq!(ansi256(232).0, 8); // first grey step+    }+}diff --git a/src/vt.rs b/src/vt.rsindex ab6bc9f..fb9d7e0 100644--- a/src/vt.rs+++ b/src/vt.rs@@ -40,6 +40,10 @@ impl Term {         &self.grid     } +    pub fn resize(&mut self, cols: usize, rows: usize) {+        self.grid.resize(cols, rows);+    }+     pub fn title(&self) -> Option<&str> {         self.title.as_deref()     }diff --git a/src/wayland.rs b/src/wayland.rsindex 7bae825..ec570c2 100644--- a/src/wayland.rs+++ b/src/wayland.rs@@ -11,7 +11,9 @@ use calloop::generic::Generic; use calloop::{EventLoop, Interest, Mode, PostAction}; use calloop_wayland_source::WaylandSource; +use crate::font::Fonts; use crate::pty::Pty;+use crate::render::Renderer; use crate::vt::Term; use smithay_client_toolkit::{     compositor::{CompositorHandler, CompositorState},@@ -39,11 +41,9 @@ use wayland_client::{ /// Default window size in pixels before the compositor suggests one. const DEFAULT_W: u32 = 800; const DEFAULT_H: u32 = 600;-/// Background fill, 0xAARRGGBB. Foot-ish dark grey.-const BG: u32 = 0xFF18_1818;-/// Terminal size handed to the shell.-const COLS: u16 = 80;-const ROWS: u16 = 24;+/// Primary font family and pixel size, resolved via fontconfig.+const FONT_FAMILY: &str = "monospace";+const FONT_SIZE_PX: u32 = 16;  /// Run a single window until it is closed. pub fn run() -> anyhow::Result<()> {@@ -73,8 +73,12 @@ pub fn run() -> anyhow::Result<()> {     let pool = SlotPool::new(DEFAULT_W as usize * DEFAULT_H as usize * 4, &shm)         .context("create shm slot pool")?; -    let pty = Pty::spawn(COLS, ROWS).context("spawn shell on pty")?;-    let term = Term::new(COLS as usize, ROWS as usize);+    let fonts = Fonts::new(FONT_FAMILY, FONT_SIZE_PX).context("load font")?;+    let renderer = Renderer::new(fonts);+    let (cols, rows) = grid_size(renderer.metrics(), DEFAULT_W, DEFAULT_H);++    let pty = Pty::spawn(cols, rows).context("spawn shell on pty")?;+    let term = Term::new(cols as usize, rows as usize);     let mut parser = vte::Parser::new();      // Read child output off a clone of the master; the original stays in `pty`@@ -117,10 +121,11 @@ pub fn run() -> anyhow::Result<()> {         window,         pty,         term,+        renderer,         title: None,         width: DEFAULT_W,         height: DEFAULT_H,-        configured: false,+        dirty: false,         exit: false,     }; @@ -128,10 +133,21 @@ pub fn run() -> anyhow::Result<()> {         event_loop             .dispatch(Duration::from_millis(16), &mut app)             .context("dispatch event loop")?;+        if app.dirty {+            app.draw();+            app.dirty = false;+        }     }     Ok(()) } +/// 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);+    (cols as u16, rows as u16)+}+ /// Write every byte to `fd`, retrying short writes and interrupts. fn write_all(fd: &OwnedFd, mut buf: &[u8]) -> rustix::io::Result<()> {     while !buf.is_empty() {@@ -156,16 +172,18 @@ struct App {     window: Window,     pty: Pty,     term: Term,+    renderer: Renderer,     /// Last title applied to the toplevel, to avoid redundant requests.     title: Option<String>,     width: u32,     height: u32,-    configured: bool,+    /// The grid changed and the window needs repainting.+    dirty: bool,     exit: bool, }  impl App {-    /// After parsing child output: send any replies and sync the title.+    /// After parsing child output: send any replies, sync the title, repaint.     fn after_feed(&mut self) {         let reply = self.term.take_response();         if !reply.is_empty()@@ -174,18 +192,25 @@ impl App {             tracing::warn!("write to pty: {err}");         } -        if tracing::enabled!(tracing::Level::TRACE) {-            let grid = self.term.grid();-            for y in 0..grid.rows() {-                tracing::trace!("{y:2}|{}", grid.row_text(y));-            }-        }-         if self.term.title() != self.title.as_deref() {             self.title = self.term.title().map(str::to_owned);             self.window                 .set_title(self.title.clone().unwrap_or_default());         }+        self.dirty = true;+    }++    /// 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);+        if (cols as usize, rows as usize) == (self.term.grid().cols(), self.term.grid().rows()) {+            return;+        }+        self.term.resize(cols as usize, rows as usize);+        if let Err(err) = self.pty.resize(cols, rows) {+            tracing::warn!("resize pty: {err}");+        }     }      /// The child shell has gone away; reap it and tear the window down.@@ -197,7 +222,7 @@ impl App {         self.exit = true;     } -    /// Fill the surface with the background colour and present it.+    /// Render the grid into a fresh buffer and present it.     fn draw(&mut self) {         let (w, h) = (self.width, self.height);         let stride = w as i32 * 4;@@ -214,10 +239,8 @@ impl App {                 }             }; -        let bytes = BG.to_le_bytes();-        for px in canvas.chunks_exact_mut(4) {-            px.copy_from_slice(&bytes);-        }+        self.renderer+            .render(self.term.grid(), canvas, w as usize, h as usize);          let surface = self.window.wl_surface();         if let Err(err) = buffer.attach_to(surface) {@@ -286,7 +309,7 @@ impl WindowHandler for App {             self.width = w.get();             self.height = h.get();         }-        self.configured = true;+        self.resize_grid();         self.draw();     } }