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}