Skip to main content

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}