rlx_core/render/text.rs
1//! On-canvas text via glyphon (ADR-0009), behind the non-default `text` feature.
2//!
3//! A small, reusable seam: the frontend queues a list of positioned [`TextRun`]s
4//! each frame; [`TextLayer`] shapes them and draws them in a second render pass
5//! that loads (does not clear) the scene, so text composites over the visual in
6//! the same frame. It lives in `core` — not the standalone — because that is
7//! where the wgpu device/queue/surface live (ADR-0001: the frontend never sees a
8//! backend); the `text` **feature**, not a crate boundary, keeps it out of the
9//! plugin/default build. First consumer is Plan 0008's browse overlay; Plan
10//! 0009's HUD reuses the same seam rather than a throwaway.
11
12// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set). Runs
13// every displayed frame while text is queued; a panic here is a visible crash.
14#![deny(
15 clippy::unwrap_used,
16 clippy::expect_used,
17 clippy::indexing_slicing,
18 clippy::panic,
19 clippy::unreachable
20)]
21
22use std::borrow::Cow;
23use std::collections::HashMap;
24
25use glyphon::{
26 Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
27 TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
28};
29
30use super::panel::{Panel, PanelPass};
31
32/// A single positioned run of text the frontend queues for the current frame.
33/// Coordinates are top-left device pixels (matching the diagnostics overlay);
34/// `color` is linear RGBA in `0.0..=1.0`. The public seam the overlay and a
35/// later HUD both fill.
36pub struct TextRun<'a> {
37 /// The text to draw (a single line; no wrapping is applied).
38 pub text: &'a str,
39 /// Left edge, device pixels from the surface's top-left.
40 pub x: f32,
41 /// Top edge, device pixels from the surface's top-left.
42 pub y: f32,
43 /// Font size in device pixels.
44 pub size: f32,
45 /// Linear RGBA in `0.0..=1.0`.
46 pub color: [f32; 4],
47}
48
49/// An owned copy of a queued run, held from [`TextLayer::queue`] until the flush
50/// in `render()` — the caller's borrowed `&str` need not outlive its own frame.
51struct OwnedRun {
52 text: String,
53 x: f32,
54 y: f32,
55 size: f32,
56 color: [f32; 4],
57}
58
59/// Line height as a multiple of the font size. Runs are single-line, so this
60/// only sets vertical extent, never wrapping.
61pub const LINE_HEIGHT_RATIO: f32 = 1.25;
62
63/// Measured widths kept before the cache is emptied and starts again. A frame
64/// measures a few dozen strings, so this is many frames of browsing.
65const MEASURE_CACHE_CAP: usize = 4096;
66
67/// The width a run of text lays out to, shaped exactly as [`TextLayer`] draws
68/// it: the system sans-serif family, advanced shaping, one line.
69///
70/// GPU-free — it owns a font system and nothing else — so layout that depends
71/// on a measurement can be tested without a device. The fonts are the
72/// machine's, so a width is only ever compared against another width from the
73/// same measurer, never against a written-down number.
74pub struct TextMeasure {
75 font_system: FontSystem,
76 buffer: Buffer,
77 /// `(text, size bits) -> width`, so a list redrawn every frame shapes each
78 /// name once rather than once per frame.
79 cache: HashMap<(String, u32), f32>,
80}
81
82impl Default for TextMeasure {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl TextMeasure {
89 /// A measurer over the system's fonts. Loads the font set, which is slow;
90 /// build one and keep it.
91 pub fn new() -> Self {
92 let mut font_system = FontSystem::new();
93 let buffer = Buffer::new(
94 &mut font_system,
95 Metrics::new(16.0, 16.0 * LINE_HEIGHT_RATIO),
96 );
97 Self {
98 font_system,
99 buffer,
100 cache: HashMap::new(),
101 }
102 }
103
104 /// The laid-out width of `text` at `size` device pixels. `0.0` for an empty
105 /// string or a size that is not positive and finite.
106 pub fn width(&mut self, text: &str, size: f32) -> f32 {
107 if text.is_empty() || !size.is_finite() || size <= 0.0 {
108 return 0.0;
109 }
110 let key = (text.to_owned(), size.to_bits());
111 if let Some(&w) = self.cache.get(&key) {
112 return w;
113 }
114 let Self {
115 font_system,
116 buffer,
117 cache,
118 } = self;
119 buffer.set_metrics(Metrics::new(size, size * LINE_HEIGHT_RATIO));
120 buffer.set_size(None, None);
121 buffer.set_text(
122 text,
123 &Attrs::new().family(Family::SansSerif),
124 Shaping::Advanced,
125 None,
126 );
127 buffer.shape_until_scroll(font_system, false);
128 let w = buffer
129 .layout_runs()
130 .map(|run| run.line_w)
131 .fold(0.0_f32, f32::max);
132 if cache.len() >= MEASURE_CACHE_CAP {
133 cache.clear();
134 }
135 cache.insert(key, w);
136 w
137 }
138
139 /// `text` shortened to fit `max_width` at `size`, with an ASCII ellipsis —
140 /// [`fit_width`] over this measurer.
141 pub fn fit<'a>(&mut self, text: &'a str, size: f32, max_width: f32) -> Cow<'a, str> {
142 fit_width(text, max_width, |s| self.width(s, size))
143 }
144}
145
146/// `text` shortened to fit `max_width` as `width` measures it, ending in `...`
147/// when it had to cut.
148///
149/// **The property this holds, whatever `width` is:** the result's measured
150/// width is at most `max_width`, and a `text` that already fits comes back
151/// unchanged and borrowed. When not even `...` fits, the result is empty.
152///
153/// Cut on character boundaries, so a multi-byte name is never split inside a
154/// code point. The longest fitting prefix is found by bisection and then
155/// checked, stepping down while it does not fit, so a measurer that is not
156/// monotone in the prefix length (kerning can make it so) still cannot return
157/// a result wider than asked.
158pub fn fit_width<'a>(
159 text: &'a str,
160 max_width: f32,
161 mut width: impl FnMut(&str) -> f32,
162) -> Cow<'a, str> {
163 if width(text) <= max_width {
164 return Cow::Borrowed(text);
165 }
166 let bounds: Vec<usize> = text.char_indices().map(|(i, _)| i).collect();
167 let cut = |keep: usize| -> String {
168 let end = bounds.get(keep).copied().unwrap_or(text.len());
169 let mut s = String::with_capacity(end + 3);
170 s.push_str(text.get(..end).unwrap_or(""));
171 s.push_str("...");
172 s
173 };
174 // Bisect on the number of characters kept: `lo` always fits (or is zero).
175 let (mut lo, mut hi) = (0usize, bounds.len());
176 while lo < hi {
177 let mid = (lo + hi).div_ceil(2);
178 if width(&cut(mid)) <= max_width {
179 lo = mid;
180 } else {
181 hi = mid - 1;
182 }
183 }
184 loop {
185 let candidate = cut(lo);
186 if width(&candidate) <= max_width {
187 return Cow::Owned(candidate);
188 }
189 if lo == 0 {
190 return Cow::Owned(String::new());
191 }
192 lo -= 1;
193 }
194}
195
196/// Owns glyphon's font/atlas/renderer state plus a reusable buffer pool, and the
197/// per-frame queue of runs and panels. One instance per [`super::Renderer`], and
198/// one per secondary surface.
199pub struct TextLayer {
200 /// The measurer, whose font system is also the one every run is shaped
201 /// with — so a measured width is the drawn width.
202 measure: TextMeasure,
203 swash_cache: SwashCache,
204 viewport: Viewport,
205 atlas: TextAtlas,
206 renderer: TextRenderer,
207 /// Runs queued for the current frame (cleared each frame at `end_frame`).
208 runs: Vec<OwnedRun>,
209 /// Panels queued for the current frame, drawn under every run.
210 panels: Vec<Panel>,
211 panel_pass: PanelPass,
212 /// One reusable cosmic-text buffer per run, grown on demand and reshaped in
213 /// place each frame — no per-frame `Buffer` allocation in steady state.
214 buffers: Vec<Buffer>,
215 /// Whether the last `prepare` produced text for `render`.
216 ready: bool,
217 /// Whether the last `prepare` produced panels for `render`.
218 panels_ready: bool,
219}
220
221impl TextLayer {
222 /// Build the text layer on `device`, targeting `format` (the surface format).
223 /// Loads the system font set once here, not per frame.
224 pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
225 let measure = TextMeasure::new();
226 let swash_cache = SwashCache::new();
227 let cache = Cache::new(device);
228 let viewport = Viewport::new(device, &cache);
229 let mut atlas = TextAtlas::new(device, queue, &cache, format);
230 let renderer =
231 TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None);
232 Self {
233 measure,
234 swash_cache,
235 viewport,
236 atlas,
237 renderer,
238 runs: Vec::new(),
239 panels: Vec::new(),
240 panel_pass: PanelPass::new(device, format),
241 buffers: Vec::new(),
242 ready: false,
243 panels_ready: false,
244 }
245 }
246
247 /// The laid-out width of `text` at `size` device pixels, as this layer
248 /// would draw it.
249 pub fn measure(&mut self, text: &str, size: f32) -> f32 {
250 self.measure.width(text, size)
251 }
252
253 /// Replace this frame's queued panels with `panels`.
254 pub fn queue_panels(&mut self, panels: &[Panel]) {
255 self.panels.clear();
256 self.panels.extend_from_slice(panels);
257 }
258
259 /// Append one panel to this frame's queue — the core's own furniture, for
260 /// the reason [`push`](Self::push) exists.
261 pub fn push_panel(&mut self, panel: Panel) {
262 self.panels.push(panel);
263 }
264
265 /// Replace this frame's queued runs with owning copies of `runs`.
266 pub fn queue(&mut self, runs: &[TextRun<'_>]) {
267 self.runs.clear();
268 self.runs.extend(runs.iter().map(|r| OwnedRun {
269 text: r.text.to_owned(),
270 x: r.x,
271 y: r.y,
272 size: r.size,
273 color: r.color,
274 }));
275 }
276
277 /// Append one run to this frame's queue, leaving what is already there
278 /// alone. The core's own furniture — the now-playing banner (ADR-0110) —
279 /// goes in through this rather than [`queue`](Self::queue), which replaces:
280 /// a frontend that queues nothing this frame must not erase it.
281 pub fn push(&mut self, run: TextRun<'_>) {
282 self.runs.push(OwnedRun {
283 text: run.text.to_owned(),
284 x: run.x,
285 y: run.y,
286 size: run.size,
287 color: run.color,
288 });
289 }
290
291 /// Write the queued panels, shape the queued runs and upload their glyphs to
292 /// the atlas. Returns whether there is anything to draw; an atlas-full or
293 /// shaping failure degrades to "no text drawn" rather than panicking on the
294 /// render path.
295 pub fn prepare(
296 &mut self,
297 device: &wgpu::Device,
298 queue: &wgpu::Queue,
299 width: u32,
300 height: u32,
301 ) -> bool {
302 self.ready = false;
303 self.panels_ready = self.panel_pass.prepare(queue, &self.panels, width, height);
304 if self.runs.is_empty() {
305 return self.panels_ready;
306 }
307
308 // Split-borrow the fields so the buffer pool and the font system can be
309 // mutated disjointly (glyphon's shaping needs both).
310 let Self {
311 measure,
312 swash_cache,
313 viewport,
314 atlas,
315 renderer,
316 runs,
317 buffers,
318 ready,
319 panels_ready,
320 ..
321 } = self;
322 let font_system = &mut measure.font_system;
323
324 // Grow the reusable pool to cover this frame's run count.
325 while buffers.len() < runs.len() {
326 buffers.push(Buffer::new(
327 font_system,
328 Metrics::new(16.0, 16.0 * LINE_HEIGHT_RATIO),
329 ));
330 }
331
332 // Reshape one buffer per run with its size and text (single line).
333 for (buf, run) in buffers.iter_mut().zip(runs.iter()) {
334 buf.set_metrics(Metrics::new(run.size, run.size * LINE_HEIGHT_RATIO));
335 buf.set_size(None, None); // no wrap; TextArea bounds clip to screen
336 buf.set_text(
337 run.text.as_str(),
338 &Attrs::new().family(Family::SansSerif),
339 Shaping::Advanced,
340 None,
341 );
342 buf.shape_until_scroll(font_system, false);
343 }
344
345 viewport.update(
346 queue,
347 Resolution {
348 width: width.max(1),
349 height: height.max(1),
350 },
351 );
352
353 let clip_w = width.max(1) as i32;
354 let clip_h = height.max(1) as i32;
355 let areas = buffers.iter().zip(runs.iter()).map(|(buf, run)| TextArea {
356 buffer: buf,
357 left: run.x,
358 top: run.y,
359 scale: 1.0,
360 bounds: TextBounds {
361 left: 0,
362 top: 0,
363 right: clip_w,
364 bottom: clip_h,
365 },
366 default_color: color_of(run.color),
367 custom_glyphs: &[],
368 });
369
370 if renderer
371 .prepare(
372 device,
373 queue,
374 font_system,
375 atlas,
376 viewport,
377 areas,
378 swash_cache,
379 )
380 .is_err()
381 {
382 // Atlas full / shaping error — skip text this frame, keep the panels.
383 return *panels_ready;
384 }
385 *ready = true;
386 true
387 }
388
389 /// Draw the prepared panels into `pass`, as one draw. Separate from
390 /// [`render`](Self::render) so a caller can draw between the two — the
391 /// shell's picture sits on its panel and under its caption. Call this first.
392 pub fn render_panels(&self, pass: &mut wgpu::RenderPass<'_>) {
393 if self.panels_ready {
394 self.panel_pass.render(pass);
395 }
396 }
397
398 /// Whether the last [`prepare`](Self::prepare) left panels to draw.
399 pub fn has_panels(&self) -> bool {
400 self.panels_ready
401 }
402
403 /// Draw the prepared runs into `pass` (a load pass over the scene). No-op if
404 /// `prepare` produced no text. Panels are [`render_panels`](Self::render_panels)'.
405 pub fn render<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>) {
406 if !self.ready {
407 return;
408 }
409 // Best-effort: a render error can't recover mid-frame, so drop it rather
410 // than panic on the hot path (the text simply won't appear).
411 let _ = self.renderer.render(&self.atlas, &self.viewport, pass);
412 }
413
414 /// End-of-frame housekeeping: free atlas space unused this frame and clear
415 /// the queue for the next one.
416 pub fn end_frame(&mut self) {
417 self.atlas.trim();
418 self.runs.clear();
419 self.panels.clear();
420 self.panel_pass.end_frame();
421 self.ready = false;
422 self.panels_ready = false;
423 }
424}
425
426/// Map a linear `[r, g, b, a]` in `0.0..=1.0` to glyphon's 8-bit color.
427fn color_of([r, g, b, a]: [f32; 4]) -> Color {
428 let to_u8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
429 Color::rgba(to_u8(r), to_u8(g), to_u8(b), to_u8(a))
430}
431
432#[cfg(test)]
433mod tests {
434 #![allow(
435 clippy::unwrap_used,
436 clippy::expect_used,
437 clippy::panic,
438 clippy::indexing_slicing
439 )]
440
441 use super::*;
442
443 /// A deterministic spread of strings: ASCII names, wide and multi-byte
444 /// characters, spaces, and the empty string.
445 fn corpus() -> Vec<String> {
446 let alphabet: Vec<char> = "aBcW iIl.MmQ-éßЖ漢字".chars().collect();
447 let mut seed = 0x9e37_79b9_u32;
448 let mut out = vec![
449 String::new(),
450 "Star Mandala Bordered".to_owned(),
451 "Iris Bloom Kaleidoscope".to_owned(),
452 ];
453 for len in 1..40 {
454 let mut s = String::new();
455 for _ in 0..len {
456 seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
457 let i = (seed >> 16) as usize % alphabet.len();
458 s.push(alphabet[i]);
459 }
460 out.push(s);
461 }
462 out
463 }
464
465 fn assert_holds(measure: &mut dyn FnMut(&str) -> f32, label: &str) {
466 for text in corpus() {
467 let full = measure(&text);
468 for max_width in [0.0, 1.0, 12.0, 40.0, 97.5, 180.0, 333.0, 1000.0] {
469 let fitted = fit_width(&text, max_width, &mut *measure);
470 let w = measure(&fitted);
471 assert!(
472 w <= max_width,
473 "{label}: {text:?} fitted to {max_width} measures {w}: {fitted:?}"
474 );
475 if full <= max_width {
476 assert!(
477 matches!(fitted, Cow::Borrowed(s) if s == text),
478 "{label}: {text:?} fits {max_width} and must come back unchanged"
479 );
480 } else {
481 assert!(
482 fitted.is_empty() || fitted.ends_with("..."),
483 "{label}: a cut string must say so: {fitted:?}"
484 );
485 }
486 }
487 }
488 }
489
490 /// **The truncation property, held on the machine's own font.** Whatever the
491 /// system sans-serif is, the measured width of a fitted string never exceeds
492 /// the width asked for, and a string that fits is untouched. A property of
493 /// the call, not of a font, so it holds wherever this runs — a machine with
494 /// no fonts at all measures everything as zero, and still holds it.
495 #[test]
496 fn a_fitted_string_never_measures_wider_than_asked_on_the_system_font() {
497 let mut m = TextMeasure::new();
498 assert_holds(&mut |s| m.width(s, 22.0), "system font");
499 }
500
501 /// The same property against a measurer that is not monotone in prefix
502 /// length — a wide glyph that shrinks when followed by another, the way a
503 /// kerning pair can — which the bisection alone would get wrong.
504 #[test]
505 fn a_fitted_string_holds_the_property_under_a_non_monotone_measure() {
506 let mut odd = |s: &str| {
507 let n = s.chars().count() as f32;
508 let penalty = if s.chars().count() % 2 == 1 { 9.0 } else { 0.0 };
509 n * 7.0 + penalty
510 };
511 assert_holds(&mut odd, "non-monotone");
512 }
513
514 #[test]
515 fn a_long_name_keeps_its_longest_fitting_prefix() {
516 // Ten px per character: 100 px holds seven characters and the ellipsis.
517 let fitted = fit_width("abcdefghijklmnop", 100.0, |s| {
518 s.chars().count() as f32 * 10.0
519 });
520 assert_eq!(fitted, "abcdefg...");
521 }
522
523 #[test]
524 fn the_measurer_caches_rather_than_reshaping() {
525 let mut m = TextMeasure::new();
526 let a = m.width("Spectrum Corona", 22.0);
527 assert_eq!(m.width("Spectrum Corona", 22.0), a);
528 assert_eq!(m.cache.len(), 1);
529 assert_eq!(m.width("", 22.0), 0.0);
530 assert_eq!(m.width("x", f32::NAN), 0.0);
531 }
532}