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 }