git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

src/server/text.rs (10.8K)

  1 //! Minimal CPU text rendering, for the desktop-grid square labels.
  2 //!
  3 //! The compositor has no toolkit — clients own their own text (cce-ui does
  4 //! Vulkan + cosmic-text). The one thing the compositor itself has to letter is the
  5 //! desktop grid, so this is deliberately the smallest thing that works:
  6 //! fontdue rasterizes a short ASCII label into an ARGB8888 buffer, which
  7 //! `river_data_buffer_create` wraps as a `wlr_buffer` for a scene node.
  8 //!
  9 //! Labels are short and repeat across frames, so rasterized buffers are cached
 10 //! by (text, size); the cache is swept whenever the label set changes size
 11 //! enough to matter (see `Output::draw_cell_labels`).
 12 
 13 use std::collections::HashMap;
 14 use std::sync::OnceLock;
 15 
 16 use crate::ffi;
 17 
 18 /// Where to look for a font file, in order of preference. The DE's own font
 19 /// wins; the rest are the usual monospace suspects so a machine without it
 20 /// still gets labels. `CCE_GRID_LABEL_FONT` overrides everything.
 21 const FONT_HINTS: &[&str] = &[
 22     "berkeleymono",
 23     "jetbrainsmono",
 24     "dejavusansmono",
 25     "liberationmono",
 26     "notosansmono",
 27     "firacode",
 28     "hack",
 29 ];
 30 
 31 fn font_dirs() -> Vec<std::path::PathBuf> {
 32     let mut dirs = Vec::new();
 33     if let Ok(home) = std::env::var("HOME") {
 34         let data = std::env::var("XDG_DATA_HOME")
 35             .unwrap_or_else(|_| format!("{home}/.local/share"));
 36         dirs.push(std::path::PathBuf::from(format!("{data}/fonts")));
 37         dirs.push(std::path::PathBuf::from(format!("{home}/.fonts")));
 38         // The DE keeps its own fonts in Dropbox on this machine; harmless
 39         // elsewhere since a missing dir is simply skipped.
 40         dirs.push(std::path::PathBuf::from(format!("{home}/Dropbox/Fonts")));
 41     }
 42     dirs.push(std::path::PathBuf::from("/usr/local/share/fonts"));
 43     dirs.push(std::path::PathBuf::from("/usr/share/fonts"));
 44     dirs
 45 }
 46 
 47 /// Recursively collect font files, cheaply bounded so a pathological font tree
 48 /// can't stall startup.
 49 fn collect_fonts(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>, depth: u32) {
 50     if depth > 4 || out.len() > 4000 {
 51         return;
 52     }
 53     let Ok(entries) = std::fs::read_dir(dir) else {
 54         return;
 55     };
 56     for entry in entries.flatten() {
 57         let path = entry.path();
 58         if path.is_dir() {
 59             collect_fonts(&path, out, depth + 1);
 60         } else if matches!(
 61             path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
 62             Some("ttf") | Some("otf")
 63         ) {
 64             out.push(path);
 65         }
 66     }
 67 }
 68 
 69 fn normalized_stem(path: &std::path::Path) -> String {
 70     path.file_stem()
 71         .and_then(|s| s.to_str())
 72         .unwrap_or("")
 73         .chars()
 74         .filter(|c| c.is_ascii_alphanumeric())
 75         .collect::<String>()
 76         .to_ascii_lowercase()
 77 }
 78 
 79 fn load_font() -> Option<fontdue::Font> {
 80     let try_file = |path: &std::path::Path| -> Option<fontdue::Font> {
 81         let bytes = std::fs::read(path).ok()?;
 82         // fontdue rejects fonts it cannot parse; keep looking rather than
 83         // giving up on labels entirely.
 84         fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()).ok()
 85     };
 86 
 87     if let Ok(explicit) = std::env::var("CCE_GRID_LABEL_FONT") {
 88         if let Some(font) = try_file(std::path::Path::new(&explicit)) {
 89             log::info!("grid labels: using font {explicit}");
 90             return Some(font);
 91         }
 92         log::warn!("grid labels: CCE_GRID_LABEL_FONT={explicit} could not be loaded");
 93     }
 94 
 95     let mut candidates = Vec::new();
 96     for dir in font_dirs() {
 97         collect_fonts(&dir, &mut candidates, 0);
 98     }
 99     // Preferred families first, then a regular-weight fallback.
100     for hint in FONT_HINTS {
101         for path in &candidates {
102             let stem = normalized_stem(path);
103             if stem.contains(hint) && (stem.contains("regular") || !stem.contains("italic")) {
104                 if let Some(font) = try_file(path) {
105                     log::info!("grid labels: using font {}", path.display());
106                     return Some(font);
107                 }
108             }
109         }
110     }
111     for path in &candidates {
112         if let Some(font) = try_file(path) {
113             log::info!("grid labels: falling back to font {}", path.display());
114             return Some(font);
115         }
116     }
117     log::warn!("grid labels: no usable font found, labels disabled");
118     None
119 }
120 
121 fn font() -> Option<&'static fontdue::Font> {
122     static FONT: OnceLock<Option<fontdue::Font>> = OnceLock::new();
123     FONT.get_or_init(load_font).as_ref()
124 }
125 
126 /// One rasterized label, owning the scene-side buffer.
127 pub struct Label {
128     pub buffer: *mut ffi::wlr_buffer,
129     pub width: i32,
130     pub height: i32,
131 }
132 
133 /// Rasterize `text` at `px` and wrap it in a wlr_buffer. White glyphs with a
134 /// soft dark halo so the label stays legible over both the light grid gaps and
135 /// the dark cells; ARGB8888 premultiplied, as the renderer expects.
136 fn rasterize(text: &str, px: f32) -> Option<Label> {
137     let font = font()?;
138     if text.is_empty() || !(4.0..=200.0).contains(&px) {
139         return None;
140     }
141 
142     // Lay the glyphs out on a common baseline.
143     let mut glyphs = Vec::new();
144     let mut pen_x = 0i32;
145     let (mut top, mut bottom) = (i32::MAX, i32::MIN);
146     for ch in text.chars() {
147         let (metrics, bitmap) = font.rasterize(ch, px);
148         let x = pen_x + metrics.xmin;
149         // fontdue's ymin is the offset of the bitmap's BOTTOM from the
150         // baseline, y-up; the buffer is y-down.
151         let y = -(metrics.height as i32 + metrics.ymin);
152         top = top.min(y);
153         bottom = bottom.max(y + metrics.height as i32);
154         glyphs.push((x, y, metrics.width as i32, metrics.height as i32, bitmap));
155         pen_x += metrics.advance_width.round() as i32;
156     }
157     if glyphs.is_empty() || pen_x <= 0 || top >= bottom {
158         return None;
159     }
160 
161     // One pixel of padding all round so the halo has somewhere to land.
162     const PAD: i32 = 2;
163     let width = pen_x + 2 * PAD;
164     let height = (bottom - top) + 2 * PAD;
165     if width <= 0 || height <= 0 || width > 4096 || height > 4096 {
166         return None;
167     }
168 
169     // Coverage first, then two passes: halo from blurred coverage, glyph on
170     // top. Keeping coverage separate avoids the halo eating the glyph.
171     let (w, h) = (width as usize, height as usize);
172     let mut cov = vec![0u8; w * h];
173     for (gx, gy, gw, gh, bitmap) in &glyphs {
174         for row in 0..*gh {
175             for col in 0..*gw {
176                 let a = bitmap[(row * gw + col) as usize];
177                 if a == 0 {
178                     continue;
179                 }
180                 let px_x = gx + col + PAD;
181                 let px_y = gy - top + row + PAD;
182                 if px_x < 0 || px_y < 0 || px_x >= width || px_y >= height {
183                     continue;
184                 }
185                 let idx = px_y as usize * w + px_x as usize;
186                 cov[idx] = cov[idx].max(a);
187             }
188         }
189     }
190 
191     let mut data = vec![0u8; w * h * 4];
192     for y in 0..h {
193         for x in 0..w {
194             // Halo = max coverage of the 8 neighbours, dimmed.
195             let mut halo = 0u32;
196             for dy in -1i32..=1 {
197                 for dx in -1i32..=1 {
198                     let (nx, ny) = (x as i32 + dx, y as i32 + dy);
199                     if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
200                         continue;
201                     }
202                     halo = halo.max(cov[ny as usize * w + nx as usize] as u32);
203                 }
204             }
205             let glyph = cov[y * w + x] as u32;
206             // Composite: black halo under white glyph, both premultiplied.
207             let halo_a = (halo * 180) / 255;
208             let out_a = (glyph + halo_a * (255 - glyph) / 255).min(255);
209             let out_rgb = glyph; // white premultiplied by its own alpha
210             let idx = (y * w + x) * 4;
211             // ARGB8888 little-endian byte order: B, G, R, A.
212             data[idx] = out_rgb as u8;
213             data[idx + 1] = out_rgb as u8;
214             data[idx + 2] = out_rgb as u8;
215             data[idx + 3] = out_a as u8;
216         }
217     }
218 
219     let stride = w * 4;
220     let buffer = unsafe {
221         ffi::river_data_buffer_create(
222             width,
223             height,
224             stride,
225             data.as_ptr() as *const std::ffi::c_void,
226         )
227     };
228     if buffer.is_null() {
229         return None;
230     }
231     Some(Label { buffer, width, height })
232 }
233 
234 /// Rasterized-label cache. Labels repeat every frame and change only as the
235 /// camera moves, so this keeps the per-frame cost to a hash lookup.
236 #[derive(Default)]
237 pub struct LabelCache {
238     entries: HashMap<(String, u32), Option<Label>>,
239     /// Grid square labels keyed by (col, row, px) so the per-frame overview
240     /// walk over every visible cell is a hash of three integers, with no
241     /// label String built or copied per cell per frame.
242     squares: HashMap<(i32, i32, u32), Option<Label>>,
243 }
244 
245 impl LabelCache {
246     /// Look up (or rasterize) a label. `None` means "cannot draw this" — no
247     /// font, or an unrasterizable string — and is cached too, so a missing
248     /// font costs one lookup per label rather than a filesystem scan.
249     pub fn get(&mut self, text: &str, px: f32) -> Option<&Label> {
250         let key = (text.to_string(), px.round() as u32);
251         self.entries
252             .entry(key)
253             .or_insert_with(|| rasterize(text, px))
254             .as_ref()
255     }
256 
257     /// The grid square at (col, row) — `policy::cells::square_label` — at
258     /// `px`; the name is only formatted on a miss.
259     pub fn get_square(&mut self, col: i32, row: i32, px: f32) -> Option<&Label> {
260         self.squares
261             .entry((col, row, px.round() as u32))
262             .or_insert_with(|| rasterize(&crate::policy::cells::square_label(col, row), px))
263             .as_ref()
264     }
265 
266     /// Drop everything (font size changed, or the cache grew unreasonably).
267     pub fn clear(&mut self) {
268         let named = self.entries.drain().map(|(_, l)| l);
269         let squares = self.squares.drain().map(|(_, l)| l);
270         for label in named.chain(squares).flatten() {
271             unsafe { ffi::wlr_buffer_drop(label.buffer) };
272         }
273     }
274 
275     pub fn len(&self) -> usize {
276         self.entries.len() + self.squares.len()
277     }
278 }
279 
280 impl Drop for LabelCache {
281     fn drop(&mut self) {
282         self.clear();
283     }
284 }
285 
286 #[cfg(test)]
287 mod tests {
288     use super::*;
289 
290     // Rasterization needs a font on the machine; skip rather than fail on a
291     // bare build host.
292     #[test]
293     fn glyph_layout_produces_sane_extents() {
294         let Some(font) = font() else {
295             eprintln!("no font available, skipping");
296             return;
297         };
298         // A label the desktop actually uses.
299         let (metrics, bitmap) = font.rasterize('C', 24.0);
300         assert!(metrics.width > 0 && metrics.height > 0);
301         assert_eq!(bitmap.len(), metrics.width * metrics.height);
302         assert!(bitmap.iter().any(|&a| a > 0), "glyph rasterized blank");
303     }
304 }