//! Labels. //! //! Building a font system and rasterising the first glyphs costs ~55ms, which is //! almost exactly the window the compositor spends copying window pixels back //! for us. So all of it happens on a worker thread started before the captures //! and joined after them: by the time anything is drawn, every label is shaped //! and its glyphs are already in the cache, and painting one costs ~0.1ms. //! //! Sizes here are physical pixels - the caller scales logical units first, //! because the chrome buffer it paints into is physical too. use std::thread::{self, JoinHandle}; use cosmic_text::{ Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap, fontdb, }; use crate::shm::Painter; use crate::theme::{Argb, Rect}; pub struct Labels { fs: FontSystem, cache: SwashCache, lines: Vec, family: String, } /// Shape `texts` into one centred single line each, at most `box_w` wide. pub fn spawn( texts: Vec, family: String, font_px: f32, line_h: f32, box_w: f32, ) -> JoinHandle { thread::spawn(move || build(texts, family, font_px, line_h, box_w)) } /// The default family: whatever this system calls its monospace font. pub const SYSTEM_MONO: &str = "monospace"; /// Families to try when fontconfig cannot be asked, roughly in order of how /// likely a distribution is to ship one as its default monospace. const MONO_CANDIDATES: &[&str] = &[ "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "Adwaita Mono", "Source Code Pro", "Hack", "Fira Mono", "Courier New", ]; /// Load the smallest font database that can render `family`. /// /// `FontSystem::new()` scans every system font, which costs ~37ms - most of the /// startup budget. A user's own font directories are tiny by comparison, so try /// those first and only pay for the full scan when the family really isn't there /// (which is also what makes an unknown family fall back gracefully). The /// generic default lives among the system fonts, so it skips that shortcut. fn font_db(family: &str) -> FontSystem { let mut db = fontdb::Database::new(); if !is_generic(family) { if let Ok(home) = std::env::var("HOME") { db.load_fonts_dir(format!("{home}/.fonts")); db.load_fonts_dir(format!("{home}/.local/share/fonts")); } if has_family(&db, family) { // The locale only orders CJK fallbacks; labels are ids and titles. return FontSystem::new_with_locale_and_db("en-US".to_string(), db); } } db.load_system_fonts(); FontSystem::new_with_locale_and_db("en-US".to_string(), db) } fn is_generic(family: &str) -> bool { family.eq_ignore_ascii_case(SYSTEM_MONO) } fn has_family(db: &fontdb::Database, family: &str) -> bool { db.faces() .any(|f| f.families.iter().any(|(name, _)| name == family)) } /// Turn the generic default into a real family name. /// /// cosmic-text's own generic resolves through fontdb's built-in preference /// ("FreeMono"), which is usually absent and then lands on an arbitrary face - so /// ask fontconfig instead, since that is what the rest of the desktop uses. A /// named family passes through untouched; if it turns out to be missing, /// cosmic-text falls back on its own. fn resolve_family(db: &fontdb::Database, family: &str) -> String { if !is_generic(family) { return family.to_string(); } fc_match_mono() .filter(|name| has_family(db, name)) .or_else(|| { MONO_CANDIDATES .iter() .find(|name| has_family(db, name)) .map(|name| name.to_string()) }) .unwrap_or_else(|| family.to_string()) } /// What fontconfig says "monospace" means here. A system without the fontconfig /// tools is not an error: the candidate list covers the common defaults. fn fc_match_mono() -> Option { let out = std::process::Command::new("fc-match") .args(["-f", "%{family}", SYSTEM_MONO]) .output() .ok()?; let text = String::from_utf8(out.stdout).ok()?; // fc-match can answer with several comma-separated aliases for one face. let first = text.split(',').next()?.trim().to_string(); (!first.is_empty()).then_some(first) } fn build(texts: Vec, family: String, font_px: f32, line_h: f32, box_w: f32) -> Labels { let mut fs = font_db(&family); let mut cache = SwashCache::new(); let family = resolve_family(fs.db(), &family); let attrs = Attrs::new().family(Family::Name(&family)); let metrics = Metrics::new(font_px, line_h); let mut lines = Vec::with_capacity(texts.len()); for text in &texts { let fitted = ellipsize(&mut fs, &attrs, metrics, text, box_w); let mut buf = Buffer::new(&mut fs, metrics); buf.set_wrap(Wrap::None); buf.set_size(Some(box_w), Some(line_h)); buf.set_text(&fitted, &attrs, Shaping::Advanced, Some(Align::Center)); // Warm the glyph cache here instead of on the first paint. buf.draw(&mut fs, &mut cache, Color::rgb(0, 0, 0), |_, _, _, _, _| {}); lines.push(buf); } Labels { fs, cache, lines, family, } } /// Shorten `text` until it fits in `box_w`, ending with an ellipsis - window /// titles are arbitrarily long, and rofi ellipsised them too. fn ellipsize( fs: &mut FontSystem, attrs: &Attrs, metrics: Metrics, text: &str, box_w: f32, ) -> String { let measure = |fs: &mut FontSystem, s: &str| { let mut b = Buffer::new(fs, metrics); b.set_wrap(Wrap::None); b.set_size(None, Some(metrics.line_height)); b.set_text(s, attrs, Shaping::Advanced, None); b.shape_until_scroll(fs, false); b.layout_runs().map(|r| r.line_w).fold(0.0, f32::max) }; let full = measure(fs, text); if full <= box_w { return text.to_string(); } let chars: Vec = text.chars().collect(); // Proportional first guess, then shrink geometrically. Bounded, because a // pathological title should not cost hundreds of reshapes. let mut keep = ((chars.len() as f32) * box_w / full).floor() as usize; for _ in 0..12 { keep = keep.min(chars.len().saturating_sub(1)); let mut s: String = chars[..keep].iter().collect(); s.push('…'); if keep == 0 || measure(fs, &s) <= box_w { return s; } keep = (keep * 9 / 10).min(keep.saturating_sub(1)); } let mut s: String = chars[..keep.min(chars.len())].iter().collect(); s.push('…'); s } impl Labels { /// The family the labels were actually shaped with, which for the generic /// default depends on what this system has installed. pub fn family(&self) -> &str { &self.family } /// Draw label `i` inside `at` (physical px), clipped to it. pub fn draw(&mut self, p: &mut Painter, i: usize, at: Rect, color: Argb) { let Some(buf) = self.lines.get_mut(i) else { return; }; let rgb = Color::rgb((color >> 16) as u8, (color >> 8) as u8, color as u8); buf.draw(&mut self.fs, &mut self.cache, rgb, |x, y, w, h, c| { p.blend( Rect { x: at.x + x, y: at.y + y, w: w as i32, h: h as i32, }, (c.r(), c.g(), c.b(), c.a()), at, ); }); } } #[cfg(test)] mod tests { use super::*; /// The whole point of a label is pixels on the surface, so assert some. #[test] fn draws_visible_glyphs() { let (w, h) = (400, 34); let mut labels = build( vec!["Hello".to_string()], "monospace".to_string(), 26.0, h as f32, w as f32, ); let mut px = vec![0u8; (w * h * 4) as usize]; let mut p = Painter::new(&mut px, w, h); let at = Rect { x: 0, y: 0, w, h }; labels.draw(&mut p, 0, at, 0x00ffffff); let touched = px .chunks_exact(4) .filter(|c| c[0] != 0 || c[1] != 0 || c[2] != 0) .count(); assert!(touched > 20, "only {touched} pixels were painted"); } #[test] fn the_generic_default_resolves_to_a_real_monospace_family() { let fs = font_db(SYSTEM_MONO); let resolved = resolve_family(fs.db(), SYSTEM_MONO); assert_ne!(resolved, SYSTEM_MONO, "should have named a real family"); assert!( has_family(fs.db(), &resolved), "{resolved:?} is not in the database" ); // A named family passes through, present or not. assert_eq!(resolve_family(fs.db(), "Some Font"), "Some Font"); } #[test] fn ellipsizes_long_titles() { let mut fs = FontSystem::new(); let attrs = Attrs::new(); let metrics = Metrics::new(26.0, 34.0); let long = "a very long window title that certainly does not fit in one narrow cell"; let out = ellipsize(&mut fs, &attrs, metrics, long, 200.0); assert!(out.ends_with('…'), "got {out:?}"); assert!(out.chars().count() < long.chars().count()); // Short text is left alone. assert_eq!(ellipsize(&mut fs, &attrs, metrics, "zsh", 200.0), "zsh"); } }