diff --git a/Cargo.lock b/Cargo.lock index 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", @@ -128,6 +131,15 @@ version = "1.2.0" 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" @@ -150,6 +162,37 @@ version = "0.1.9" 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" @@ -168,6 +211,28 @@ version = "0.2.186" 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" @@ -499,6 +564,12 @@ version = "0.1.1" 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" @@ -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.toml index 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.rs new file mode 100644 index 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), + /// Pre-multiplied BGRA, four bytes per pixel. + Color(Vec), +} + +/// 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, + /// Index of each style variant, by [`Style::index`]; filled on demand. + styled: [Option; 4], + /// Fallback faces resolved by coverage, deduplicated by file path. + fallbacks: HashMap, + 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 { + 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(®ular, 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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.rs index 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.rs index 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.rs index 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 { self.child.wait() diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 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 { + 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.rs index 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.rs index 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, 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(); } }