forked from NotAShelf/beer
render: draw the grid with rasterized glyphs
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I6350824abb506c2af98884a7374228116a6a6964
This commit is contained in:
parent
a3c41c6ccb
commit
5690e0e883
9 changed files with 768 additions and 24 deletions
82
Cargo.lock
generated
82
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
365
src/font.rs
Normal file
365
src/font.rs
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
24
src/grid.rs
24
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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
238
src/render.rs
Normal file
238
src/render.rs
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue