clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 5690e0e883a9
tarballunverified9 files changed+768-24
@@ -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",+]@@ -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"@@ -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(®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<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");+ }+}@@ -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] }@@ -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; @@ -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()@@ -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+ }+}@@ -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() }@@ -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(); } }