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 glyphon::{
23    Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
24    TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
25};
26
27/// A single positioned run of text the frontend queues for the current frame.
28/// Coordinates are top-left device pixels (matching the diagnostics overlay);
29/// `color` is linear RGBA in `0.0..=1.0`. The public seam the overlay and a
30/// later HUD both fill.
31pub struct TextRun<'a> {
32    /// The text to draw (a single line; no wrapping is applied).
33    pub text: &'a str,
34    /// Left edge, device pixels from the surface's top-left.
35    pub x: f32,
36    /// Top edge, device pixels from the surface's top-left.
37    pub y: f32,
38    /// Font size in device pixels.
39    pub size: f32,
40    /// Linear RGBA in `0.0..=1.0`.
41    pub color: [f32; 4],
42}
43
44/// An owned copy of a queued run, held from [`TextLayer::queue`] until the flush
45/// in `render()` — the caller's borrowed `&str` need not outlive its own frame.
46struct OwnedRun {
47    text: String,
48    x: f32,
49    y: f32,
50    size: f32,
51    color: [f32; 4],
52}
53
54/// Line height as a multiple of the font size. Runs are single-line, so this
55/// only sets vertical extent, never wrapping.
56const LINE_HEIGHT_RATIO: f32 = 1.25;
57
58/// Owns glyphon's font/atlas/renderer state plus a reusable buffer pool, and the
59/// per-frame queue of runs. One instance per [`super::Renderer`].
60pub struct TextLayer {
61    font_system: FontSystem,
62    swash_cache: SwashCache,
63    viewport: Viewport,
64    atlas: TextAtlas,
65    renderer: TextRenderer,
66    /// Runs queued for the current frame (cleared each frame at `end_frame`).
67    runs: Vec<OwnedRun>,
68    /// One reusable cosmic-text buffer per run, grown on demand and reshaped in
69    /// place each frame — no per-frame `Buffer` allocation in steady state.
70    buffers: Vec<Buffer>,
71    /// Whether the last `prepare` produced drawable content for `render`.
72    ready: bool,
73}
74
75impl TextLayer {
76    /// Build the text layer on `device`, targeting `format` (the surface format).
77    /// Loads the system font set once here, not per frame.
78    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
79        let font_system = FontSystem::new();
80        let swash_cache = SwashCache::new();
81        let cache = Cache::new(device);
82        let viewport = Viewport::new(device, &cache);
83        let mut atlas = TextAtlas::new(device, queue, &cache, format);
84        let renderer =
85            TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None);
86        Self {
87            font_system,
88            swash_cache,
89            viewport,
90            atlas,
91            renderer,
92            runs: Vec::new(),
93            buffers: Vec::new(),
94            ready: false,
95        }
96    }
97
98    /// Replace this frame's queued runs with owning copies of `runs`.
99    pub fn queue(&mut self, runs: &[TextRun<'_>]) {
100        self.runs.clear();
101        self.runs.extend(runs.iter().map(|r| OwnedRun {
102            text: r.text.to_owned(),
103            x: r.x,
104            y: r.y,
105            size: r.size,
106            color: r.color,
107        }));
108    }
109
110    /// Append one run to this frame's queue, leaving what is already there
111    /// alone. The core's own furniture — the now-playing banner (ADR-0110) —
112    /// goes in through this rather than [`queue`](Self::queue), which replaces:
113    /// a frontend that queues nothing this frame must not erase it.
114    pub fn push(&mut self, run: TextRun<'_>) {
115        self.runs.push(OwnedRun {
116            text: run.text.to_owned(),
117            x: run.x,
118            y: run.y,
119            size: run.size,
120            color: run.color,
121        });
122    }
123
124    /// Shape the queued runs and upload their glyphs to the atlas. Returns
125    /// whether there is anything to draw; an atlas-full or shaping failure
126    /// degrades to "nothing drawn" rather than panicking on the render path.
127    pub fn prepare(
128        &mut self,
129        device: &wgpu::Device,
130        queue: &wgpu::Queue,
131        width: u32,
132        height: u32,
133    ) -> bool {
134        self.ready = false;
135        if self.runs.is_empty() {
136            return false;
137        }
138
139        // Split-borrow the fields so the buffer pool and the font system can be
140        // mutated disjointly (glyphon's shaping needs both).
141        let Self {
142            font_system,
143            swash_cache,
144            viewport,
145            atlas,
146            renderer,
147            runs,
148            buffers,
149            ready,
150        } = self;
151
152        // Grow the reusable pool to cover this frame's run count.
153        while buffers.len() < runs.len() {
154            buffers.push(Buffer::new(
155                font_system,
156                Metrics::new(16.0, 16.0 * LINE_HEIGHT_RATIO),
157            ));
158        }
159
160        // Reshape one buffer per run with its size and text (single line).
161        for (buf, run) in buffers.iter_mut().zip(runs.iter()) {
162            buf.set_metrics(Metrics::new(run.size, run.size * LINE_HEIGHT_RATIO));
163            buf.set_size(None, None); // no wrap; TextArea bounds clip to screen
164            buf.set_text(
165                run.text.as_str(),
166                &Attrs::new().family(Family::SansSerif),
167                Shaping::Advanced,
168                None,
169            );
170            buf.shape_until_scroll(font_system, false);
171        }
172
173        viewport.update(
174            queue,
175            Resolution {
176                width: width.max(1),
177                height: height.max(1),
178            },
179        );
180
181        let clip_w = width.max(1) as i32;
182        let clip_h = height.max(1) as i32;
183        let areas = buffers.iter().zip(runs.iter()).map(|(buf, run)| TextArea {
184            buffer: buf,
185            left: run.x,
186            top: run.y,
187            scale: 1.0,
188            bounds: TextBounds {
189                left: 0,
190                top: 0,
191                right: clip_w,
192                bottom: clip_h,
193            },
194            default_color: color_of(run.color),
195            custom_glyphs: &[],
196        });
197
198        if renderer
199            .prepare(
200                device,
201                queue,
202                font_system,
203                atlas,
204                viewport,
205                areas,
206                swash_cache,
207            )
208            .is_err()
209        {
210            return false; // atlas full / shaping error — skip text this frame
211        }
212        *ready = true;
213        true
214    }
215
216    /// Draw the prepared runs into `pass` (a load pass over the scene). No-op if
217    /// `prepare` produced nothing drawable.
218    pub fn render<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>) {
219        if !self.ready {
220            return;
221        }
222        // Best-effort: a render error can't recover mid-frame, so drop it rather
223        // than panic on the hot path (the text simply won't appear).
224        let _ = self.renderer.render(&self.atlas, &self.viewport, pass);
225    }
226
227    /// End-of-frame housekeeping: free atlas space unused this frame and clear
228    /// the queue for the next one.
229    pub fn end_frame(&mut self) {
230        self.atlas.trim();
231        self.runs.clear();
232        self.ready = false;
233    }
234}
235
236/// Map a linear `[r, g, b, a]` in `0.0..=1.0` to glyphon's 8-bit color.
237fn color_of([r, g, b, a]: [f32; 4]) -> Color {
238    let to_u8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
239    Color::rgba(to_u8(r), to_u8(g), to_u8(b), to_u8(a))
240}