From 5e3d474aa968ba13edd3b223990eb6dd877931cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=96R=C3=96K=20Attila?= Date: Thu, 7 May 2026 12:41:42 +0200 Subject: [PATCH 1/4] Add skrifa + vello_cpu font rendering backend Add a `skrifa` feature flag as an alternative font rendering backend using skrifa for font parsing/metrics/outlines and vello_cpu for rasterization. Priority order when multiple features are enabled: crossfont > skrifa > ab_glyph > dumb (no text) Usage: --features skrifa --- Cargo.toml | 4 + src/title.rs | 26 ++-- src/title/skrifa_renderer.rs | 253 +++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 src/title/skrifa_renderer.rs diff --git a/Cargo.toml b/Cargo.toml index 9bd02cc..e783b03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ smithay-client-toolkit = { version = "0.20.0", default-features = false } crossfont = { version = "0.9.0", optional = true } # Draw title text using ab_glyph `--features ab_glyph` ab_glyph = { version = "0.2.17", optional = true } +# Draw title text using skrifa + vello_cpu `--features skrifa` +skrifa = { version = "0.40", optional = true } +vello_cpu = { version = "0.0.6", optional = true } [dev-dependencies] tiny-skia = { version = "0.12", default-features = false, features = [ @@ -38,3 +41,4 @@ tiny-skia = { version = "0.12", default-features = false, features = [ default = ["ab_glyph"] crossfont = ["dep:crossfont"] ab_glyph = ["dep:ab_glyph", "memmap2"] +skrifa = ["dep:skrifa", "dep:vello_cpu", "memmap2"] diff --git a/src/title.rs b/src/title.rs index 1067f65..015cf94 100644 --- a/src/title.rs +++ b/src/title.rs @@ -1,26 +1,31 @@ use tiny_skia::{Color, Pixmap}; -#[cfg(any(feature = "crossfont", feature = "ab_glyph"))] +#[cfg(any(feature = "crossfont", feature = "skrifa", feature = "ab_glyph"))] mod config; -#[cfg(any(feature = "crossfont", feature = "ab_glyph"))] +#[cfg(any(feature = "crossfont", feature = "skrifa", feature = "ab_glyph"))] mod font_preference; #[cfg(feature = "crossfont")] mod crossfont_renderer; -#[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))] +#[cfg(all(not(feature = "crossfont"), feature = "skrifa"))] +mod skrifa_renderer; + +#[cfg(all(not(feature = "crossfont"), not(feature = "skrifa"), feature = "ab_glyph"))] mod ab_glyph_renderer; -#[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))] +#[cfg(all(not(feature = "crossfont"), not(feature = "skrifa"), not(feature = "ab_glyph")))] mod dumb; #[derive(Debug)] pub struct TitleText { #[cfg(feature = "crossfont")] imp: crossfont_renderer::CrossfontTitleText, - #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))] + #[cfg(all(not(feature = "crossfont"), feature = "skrifa"))] + imp: skrifa_renderer::SkrifaTitleText, + #[cfg(all(not(feature = "crossfont"), not(feature = "skrifa"), feature = "ab_glyph"))] imp: ab_glyph_renderer::AbGlyphTitleText, - #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))] + #[cfg(all(not(feature = "crossfont"), not(feature = "skrifa"), not(feature = "ab_glyph")))] imp: dumb::DumbTitleText, } @@ -31,12 +36,17 @@ impl TitleText { .ok() .map(|imp| Self { imp }); - #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))] + #[cfg(all(not(feature = "crossfont"), feature = "skrifa"))] + return Some(Self { + imp: skrifa_renderer::SkrifaTitleText::new(color), + }); + + #[cfg(all(not(feature = "crossfont"), not(feature = "skrifa"), feature = "ab_glyph"))] return Some(Self { imp: ab_glyph_renderer::AbGlyphTitleText::new(color), }); - #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))] + #[cfg(all(not(feature = "crossfont"), not(feature = "skrifa"), not(feature = "ab_glyph")))] { let _ = color; return None; diff --git a/src/title/skrifa_renderer.rs b/src/title/skrifa_renderer.rs new file mode 100644 index 0000000..ee7367a --- /dev/null +++ b/src/title/skrifa_renderer.rs @@ -0,0 +1,253 @@ +//! Title renderer using skrifa + vello_cpu. +//! +//! Requires no dynamically linked dependencies. +//! +//! Can fallback to an embedded Cantarell-Regular.ttf font (SIL Open Font Licence v1.1) +//! if the system font doesn't work. +use crate::title::{config, font_preference::FontPreference}; +use skrifa::{ + instance::{LocationRef, Size}, + outline::{DrawSettings, OutlinePen}, + MetadataProvider, +}; +use std::{fs::File, process::Command}; +use tiny_skia::{Color, Pixmap, PremultipliedColorU8}; +use vello_cpu::kurbo::{Affine, BezPath}; + +const CANTARELL: &[u8] = include_bytes!("Cantarell-Regular.ttf"); + +#[derive(Debug)] +pub struct SkrifaTitleText { + title: String, + font: Option<(memmap2::Mmap, FontPreference)>, + original_px_size: f32, + px_size: f32, + color: Color, + pixmap: Option, +} + +impl SkrifaTitleText { + pub fn new(color: Color) -> Self { + let font_pref = config::titlebar_font().unwrap_or_default(); + let font_pref_pt_size = font_pref.pt_size; + let font = font_file_matching(&font_pref) + .and_then(|f| mmap(&f)) + .map(|mmap| (mmap, font_pref)); + + let px_size = pt_to_px(&font, font_pref_pt_size); + + Self { + title: <_>::default(), + font, + original_px_size: px_size, + px_size, + color, + pixmap: None, + } + } + + pub fn update_scale(&mut self, scale: u32) { + let new_size = self.original_px_size * scale as f32; + if (self.px_size - new_size).abs() > f32::EPSILON { + self.px_size = new_size; + self.pixmap = self.render(); + } + } + + pub fn update_title(&mut self, title: impl Into) { + let new_title = title.into(); + if new_title != self.title { + self.title = new_title; + self.pixmap = self.render(); + } + } + + pub fn update_color(&mut self, color: Color) { + if color != self.color { + self.color = color; + self.pixmap = self.render(); + } + } + + pub fn pixmap(&self) -> Option<&Pixmap> { + self.pixmap.as_ref() + } + + /// Render returning the new `Pixmap`. + fn render(&self) -> Option { + if self.title.is_empty() { + return None; + } + + let font = parse_font(&self.font); + let size = Size::new(self.px_size); + let location = LocationRef::default(); + + let metrics = font.metrics(size, location); + let glyph_metrics = font.glyph_metrics(size, location); + let charmap = font.charmap(); + let outlines = font.outline_glyphs(); + + // Layout glyphs and collect paths + let mut paths: Vec<(BezPath, f64, f64)> = Vec::new(); + let mut caret: f64 = 0.0; + let ascent = metrics.ascent as f64; + + let mut last_glyph_id = None; + for c in self.title.chars() { + if c.is_control() { + continue; + } + + let glyph_id = charmap.map(c).unwrap_or_default(); + + // Kerning (if available) + // Note: skrifa doesn't expose a simple kern method on glyph_metrics, + // so we skip kerning for simplicity. The visual difference is minimal + // for title bar text. + let _ = last_glyph_id; + + let mut path = BezPath::new(); + let mut pen = PathPen(&mut path); + + if let Some(outline) = outlines.get(glyph_id) { + let settings = DrawSettings::unhinted(size, location); + let _ = outline.draw(settings, &mut pen); + } + + if !path.is_empty() { + paths.push((path, caret, ascent)); + } + + caret += glyph_metrics.advance_width(glyph_id).unwrap_or(0.0) as f64; + last_glyph_id = Some(glyph_id); + } + + if paths.is_empty() { + return None; + } + + // Calculate bounding box + let total_width = caret.ceil() as u16; + let total_height = (metrics.ascent - metrics.descent).ceil() as u16; + + if total_width == 0 || total_height == 0 { + return None; + } + + // Render using vello_cpu + let mut ctx = vello_cpu::RenderContext::new(total_width, total_height); + + let r = self.color.red(); + let g = self.color.green(); + let b = self.color.blue(); + let a = self.color.alpha(); + ctx.set_paint(vello_cpu::color::AlphaColor::::new([r, g, b, a])); + + for (path, x, y) in &paths { + ctx.set_transform(Affine::translate((*x, *y))); + ctx.fill_path(path); + } + + ctx.flush(); + + let mut vello_pixmap = vello_cpu::Pixmap::new(total_width, total_height); + ctx.render_to_pixmap(&mut vello_pixmap); + + // Convert vello_cpu::Pixmap (premultiplied RGBA u8) to tiny_skia::Pixmap + let data = vello_pixmap.data(); + let mut tiny_pixmap = Pixmap::new(total_width as u32, total_height as u32)?; + let pixels = tiny_pixmap.pixels_mut(); + + for (pixel, src) in pixels.iter_mut().zip(data.iter()) { + if let Some(px) = + PremultipliedColorU8::from_rgba(src.r, src.g, src.b, src.a) + { + *pixel = px; + } + } + + Some(tiny_pixmap) + } +} + +/// Convert point size to pixel size using font's units_per_em. +fn pt_to_px(font_data: &Option<(memmap2::Mmap, FontPreference)>, pt_size: f32) -> f32 { + let font = parse_font(font_data); + let metrics = font.metrics(Size::unscaled(), LocationRef::default()); + let units_per_em = metrics.units_per_em as f32; + if units_per_em > 0.0 { + // Standard conversion: 1pt = 1.333px at 96dpi (96/72) + pt_size * (96.0 / 72.0) + } else { + // Fallback + pt_size * 1.333 + } +} + +/// Parse the memmapped system font or fallback to built-in Cantarell. +fn parse_font<'a>(sys_font: &'a Option<(memmap2::Mmap, FontPreference)>) -> skrifa::FontRef<'a> { + match sys_font { + Some((mmap, _font_pref)) => { + skrifa::FontRef::from_index(mmap, 0).unwrap_or_else(|_| { + #[allow(clippy::unwrap_used)] + skrifa::FontRef::from_index(CANTARELL, 0).unwrap() + }) + } + #[allow(clippy::unwrap_used)] + _ => skrifa::FontRef::from_index(CANTARELL, 0).unwrap(), + } +} + +/// Font-config without dynamically linked dependencies +fn font_file_matching(pref: &FontPreference) -> Option { + let mut pattern = pref.name.clone(); + if let Some(style) = &pref.style { + pattern.push(':'); + pattern.push_str(style); + } + Command::new("fc-match") + .arg("-f") + .arg("%{file}") + .arg(&pattern) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .and_then(|path| File::open(path.trim()).ok()) +} + +fn mmap(file: &File) -> Option { + // Safety: System font files are not expected to be mutated during use + unsafe { memmap2::Mmap::map(file).ok() } +} + +/// A pen that draws skrifa glyph outlines into a `kurbo::BezPath`. +struct PathPen<'a>(&'a mut BezPath); + +impl OutlinePen for PathPen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((x as f64, -(y as f64))); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((x as f64, -(y as f64))); + } + + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0 + .quad_to((cx0 as f64, -(cy0 as f64)), (x as f64, -(y as f64))); + } + + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.curve_to( + (cx0 as f64, -(cy0 as f64)), + (cx1 as f64, -(cy1 as f64)), + (x as f64, -(y as f64)), + ); + } + + fn close(&mut self) { + self.0.close_path(); + } +} + From 6b9f4e22f464d158cca78cf1a080accbda07480a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=96R=C3=96K=20Attila?= Date: Thu, 7 May 2026 12:42:02 +0200 Subject: [PATCH 2/4] Add tests for the skrifa font rendering backend Test the rendering pipeline using the bundled Cantarell font: - renders_non_empty_pixmap: verifies output has non-zero dimensions - renders_pixels_with_coverage: confirms glyphs produce visible pixels - empty_title_returns_none: no pixmap for empty string - update_scale_changes_size: scaling produces wider output - update_color_rerenders: color change produces different pixels - parse_bundled_font_succeeds: Cantarell parses with valid metrics Run with: cargo test --no-default-features --features skrifa --- src/title/skrifa_renderer.rs | 70 ++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/title/skrifa_renderer.rs b/src/title/skrifa_renderer.rs index ee7367a..2b012c5 100644 --- a/src/title/skrifa_renderer.rs +++ b/src/title/skrifa_renderer.rs @@ -251,3 +251,73 @@ impl OutlinePen for PathPen<'_> { } } +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + /// Helper to create a renderer using only the bundled Cantarell font. + fn new_renderer() -> SkrifaTitleText { + let mut renderer = SkrifaTitleText { + title: String::new(), + font: None, // forces fallback to bundled Cantarell + original_px_size: 13.3, + px_size: 13.3, + color: Color::BLACK, + pixmap: None, + }; + renderer.update_title("Hello"); + renderer + } + + #[test] + fn renders_non_empty_pixmap() { + let renderer = new_renderer(); + let pixmap = renderer.pixmap().expect("should produce a pixmap"); + assert!(pixmap.width() > 0); + assert!(pixmap.height() > 0); + } + + #[test] + fn renders_pixels_with_coverage() { + let renderer = new_renderer(); + let pixmap = renderer.pixmap().expect("should produce a pixmap"); + // At least some pixels should have non-zero alpha (i.e. glyphs were drawn) + let has_visible = pixmap.pixels().iter().any(|px| px.alpha() > 0); + assert!(has_visible, "rendered text should have visible pixels"); + } + + #[test] + fn empty_title_returns_none() { + let mut renderer = new_renderer(); + renderer.update_title(""); + assert!(renderer.pixmap().is_none()); + } + + #[test] + fn update_scale_changes_size() { + let mut renderer = new_renderer(); + let w1 = renderer.pixmap().expect("pixmap").width(); + renderer.update_scale(2); + let w2 = renderer.pixmap().expect("pixmap after scale").width(); + assert!(w2 > w1, "scaled text should be wider"); + } + + #[test] + fn update_color_rerenders() { + let mut renderer = new_renderer(); + let old_pixels: Vec<_> = renderer.pixmap().unwrap().pixels().to_vec(); + renderer.update_color(Color::from_rgba8(255, 0, 0, 255)); + let new_pixels: Vec<_> = renderer.pixmap().unwrap().pixels().to_vec(); + assert_ne!(old_pixels, new_pixels, "color change should produce different pixels"); + } + + #[test] + fn parse_bundled_font_succeeds() { + let font = parse_font(&None); + let metrics = font.metrics(Size::new(16.0), LocationRef::default()); + assert!(metrics.units_per_em > 0); + assert!(metrics.ascent > 0.0); + } +} From 15f6413a029d65b4d07dbd61ab930485ef38ddc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=96R=C3=96K=20Attila?= Date: Thu, 7 May 2026 12:48:01 +0200 Subject: [PATCH 3/4] skrifa: enable font hinting for improved glyph quality Use HintingInstance with default options (Engine::AutoFallback, SmoothMode::Normal) to apply hinting when drawing glyph outlines. Falls back gracefully to unhinted rendering if the font doesn't support it. --- src/title/skrifa_renderer.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/title/skrifa_renderer.rs b/src/title/skrifa_renderer.rs index 2b012c5..9878297 100644 --- a/src/title/skrifa_renderer.rs +++ b/src/title/skrifa_renderer.rs @@ -7,7 +7,7 @@ use crate::title::{config, font_preference::FontPreference}; use skrifa::{ instance::{LocationRef, Size}, - outline::{DrawSettings, OutlinePen}, + outline::{DrawSettings, HintingInstance, HintingOptions, OutlinePen}, MetadataProvider, }; use std::{fs::File, process::Command}; @@ -88,6 +88,16 @@ impl SkrifaTitleText { let charmap = font.charmap(); let outlines = font.outline_glyphs(); + // Create a hinting instance for better glyph quality at small sizes. + // Falls back to unhinted rendering if the font doesn't support hinting. + let hinting = HintingInstance::new( + &outlines, + size, + location, + HintingOptions::default(), + ) + .ok(); + // Layout glyphs and collect paths let mut paths: Vec<(BezPath, f64, f64)> = Vec::new(); let mut caret: f64 = 0.0; @@ -111,7 +121,10 @@ impl SkrifaTitleText { let mut pen = PathPen(&mut path); if let Some(outline) = outlines.get(glyph_id) { - let settings = DrawSettings::unhinted(size, location); + let settings = match &hinting { + Some(h) => DrawSettings::from(h), + None => DrawSettings::unhinted(size, location), + }; let _ = outline.draw(settings, &mut pen); } From c2d9cff5872a07d11f92b9dad862cd55af39948b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=96R=C3=96K=20Attila?= Date: Thu, 7 May 2026 12:51:19 +0200 Subject: [PATCH 4/4] docs: document skrifa feature in README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index ea4f8aa..aea6fd0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,13 @@ ## Title text: ab_glyph By default title text is drawn with _ab_glyph_ crate. This can be disabled by disabling default features. +## Title text: skrifa + vello_cpu +Title text can be drawn with _skrifa_ (font parsing/hinting) and _vello_cpu_ (CPU rasterization). No dynamically linked dependencies required. + +```toml +sctk-adwaita = { default-features = false, features = ["skrifa"] } +``` + ## Title text: crossfont Alternatively title text may be drawn with _crossfont_ crate. This adds a requirement on _freetype_.