1use std::cell::RefCell;
18use std::collections::{HashMap, VecDeque};
19use std::path::Path;
20use std::sync::Arc;
21
22use crate::application::Application;
23use crate::text::flexible::FlexibleTextEngine;
24use crate::text::flexible_renderer::FlexibleTextRenderer;
25use crate::text::{WispFontStyle, WispText, WispTextAlign, WispTextLayout};
26use crate::texture::render_texture::RenderTexture;
27use glam::Vec2;
28
29pub const MAX_ENTRIES: usize = 64;
34
35#[derive(Hash, Eq, PartialEq, Clone, Debug)]
43pub struct TextTextureKey {
44 content: String,
45 family: Option<String>,
46 size_bits: u32,
47 color_bits: [u32; 4],
48 line_height_bits: u32,
49 letter_spacing_bits: u32,
50 weight: u16,
51 italic: bool,
52 align: u8,
53 wrap_width_bits: Option<u32>,
54 width_px: u32,
55 height_px: u32,
56}
57
58impl TextTextureKey {
59 #[must_use]
62 pub fn new(text: &WispText, width_px: u32, height_px: u32) -> Self {
63 let s = text.style;
64 Self {
65 content: text.content.clone(),
66 family: text.font_family.clone(),
67 size_bits: s.size_ndc.to_bits(),
68 color_bits: [
69 s.color.r.to_bits(),
70 s.color.g.to_bits(),
71 s.color.b.to_bits(),
72 s.color.a.to_bits(),
73 ],
74 line_height_bits: s.line_height.to_bits(),
75 letter_spacing_bits: s.letter_spacing_ndc.to_bits(),
76 weight: s.weight.value(),
77 italic: matches!(s.style, WispFontStyle::Italic),
78 align: match s.align {
79 WispTextAlign::Left => 0,
80 WispTextAlign::Center => 1,
81 WispTextAlign::Right => 2,
82 },
83 wrap_width_bits: text.max_width_ndc.map(f32::to_bits),
84 width_px,
85 height_px,
86 }
87 }
88}
89
90pub struct TextTextureCache {
92 map: HashMap<TextTextureKey, Arc<RenderTexture>>,
93 order: VecDeque<TextTextureKey>,
94 hits: u64,
95 misses: u64,
96}
97
98impl std::fmt::Debug for TextTextureCache {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.debug_struct("TextTextureCache")
101 .field("entries", &self.map.len())
102 .field("hits", &self.hits)
103 .field("misses", &self.misses)
104 .finish_non_exhaustive()
105 }
106}
107
108impl Default for TextTextureCache {
109 fn default() -> Self {
110 Self::new()
111 }
112}
113
114impl TextTextureCache {
115 #[must_use]
117 pub fn new() -> Self {
118 Self {
119 map: HashMap::new(),
120 order: VecDeque::new(),
121 hits: 0,
122 misses: 0,
123 }
124 }
125
126 pub fn get_or_insert<F>(&mut self, key: TextTextureKey, generate: F) -> Arc<RenderTexture>
129 where
130 F: FnOnce() -> RenderTexture,
131 {
132 if let Some(existing) = self.map.get(&key) {
133 self.hits += 1;
134 return Arc::clone(existing);
135 }
136 self.misses += 1;
137
138 let rt = Arc::new(generate());
139 if self.map.len() >= MAX_ENTRIES
140 && let Some(oldest) = self.order.pop_front()
141 {
142 self.map.remove(&oldest);
143 }
144 self.map.insert(key.clone(), Arc::clone(&rt));
145 self.order.push_back(key);
146 rt
147 }
148
149 #[must_use]
151 pub fn stats(&self) -> (u64, u64) {
152 (self.hits, self.misses)
153 }
154
155 #[must_use]
157 pub fn len(&self) -> usize {
158 self.map.len()
159 }
160
161 #[must_use]
163 pub fn is_empty(&self) -> bool {
164 self.map.is_empty()
165 }
166
167 pub fn clear(&mut self) {
169 self.map.clear();
170 self.order.clear();
171 }
173}
174
175pub struct TextTexturePipeline {
185 engine: FlexibleTextEngine,
186 renderer: RefCell<FlexibleTextRenderer>,
187 cache: RefCell<TextTextureCache>,
188 format: wgpu::TextureFormat,
189}
190
191impl std::fmt::Debug for TextTexturePipeline {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 f.debug_struct("TextTexturePipeline")
194 .field("format", &self.format)
195 .field("cache", &self.cache.borrow())
196 .finish_non_exhaustive()
197 }
198}
199
200impl TextTexturePipeline {
201 #[must_use]
203 pub fn new(app: &Application, format: wgpu::TextureFormat) -> Self {
204 let engine = FlexibleTextEngine::new();
205 let renderer = FlexibleTextRenderer::new(app, format, engine.font_system_handle());
206 Self {
207 engine,
208 renderer: RefCell::new(renderer),
209 cache: RefCell::new(TextTextureCache::new()),
210 format,
211 }
212 }
213
214 pub fn from_font_paths<P: AsRef<Path>>(
221 app: &Application,
222 format: wgpu::TextureFormat,
223 paths: impl IntoIterator<Item = P>,
224 ) -> std::io::Result<Self> {
225 let engine = FlexibleTextEngine::from_font_paths(paths)?;
226 let renderer = FlexibleTextRenderer::new(app, format, engine.font_system_handle());
227 Ok(Self {
228 engine,
229 renderer: RefCell::new(renderer),
230 cache: RefCell::new(TextTextureCache::new()),
231 format,
232 })
233 }
234
235 #[must_use]
240 pub fn from_font_bytes(
241 app: &Application,
242 format: wgpu::TextureFormat,
243 bytes: impl IntoIterator<Item = Vec<u8>>,
244 ) -> Self {
245 let engine = FlexibleTextEngine::from_font_bytes(bytes);
246 let renderer = FlexibleTextRenderer::new(app, format, engine.font_system_handle());
247 Self {
248 engine,
249 renderer: RefCell::new(renderer),
250 cache: RefCell::new(TextTextureCache::new()),
251 format,
252 }
253 }
254
255 #[must_use]
258 pub fn format(&self) -> wgpu::TextureFormat {
259 self.format
260 }
261
262 #[must_use]
265 pub fn stats(&self) -> (u64, u64) {
266 self.cache.borrow().stats()
267 }
268
269 #[must_use]
271 pub fn cache_len(&self) -> usize {
272 self.cache.borrow().len()
273 }
274
275 pub fn clear_cache(&self) {
277 self.cache.borrow_mut().clear();
278 }
279
280 #[must_use]
282 pub fn engine(&self) -> &FlexibleTextEngine {
283 &self.engine
284 }
285
286 pub fn render(
295 &self,
296 app: &Application,
297 text: &WispText,
298 width_px: u32,
299 height_px: u32,
300 ) -> Arc<RenderTexture> {
301 let key = TextTextureKey::new(text, width_px, height_px);
302 self.cache.borrow_mut().get_or_insert(key, || {
303 let rt = RenderTexture::with_format(app, width_px, height_px, self.format);
304 let layout = self.engine.layout_concrete(text);
305 if layout.metrics().line_count == 0 {
309 return rt;
310 }
311 let mut r = self.renderer.borrow_mut();
312 r.set_resolution(width_px, height_px);
313 r.draw(
314 rt.view(),
315 &[(&layout, Vec2::new(-1.0, 1.0), text.style.color)],
316 true,
317 );
318 rt
319 })
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::application::{AppConfig, Application};
327 use crate::color::Color;
328
329 fn boot() -> Application {
330 pollster::block_on(Application::new(AppConfig::default())).expect("init")
331 }
332
333 fn pipeline(app: &Application) -> TextTexturePipeline {
334 TextTexturePipeline::new(app, wgpu::TextureFormat::Rgba8Unorm)
335 }
336
337 #[test]
338 fn first_render_records_a_miss_and_a_cache_entry() {
339 let app = boot();
340 let p = pipeline(&app);
341 let _ = p.render(&app, &WispText::new("hello"), 64, 32);
342 assert_eq!(p.stats(), (0, 1));
343 assert_eq!(p.cache_len(), 1);
344 }
345
346 #[test]
347 fn second_render_with_same_inputs_is_a_cache_hit() {
348 let app = boot();
349 let p = pipeline(&app);
350 let a = p.render(&app, &WispText::new("hello"), 64, 32);
351 let b = p.render(&app, &WispText::new("hello"), 64, 32);
352 assert_eq!(p.stats(), (1, 1));
353 assert!(Arc::ptr_eq(&a, &b));
355 }
356
357 #[test]
358 fn changing_content_invalidates_cache() {
359 let app = boot();
360 let p = pipeline(&app);
361 let a = p.render(&app, &WispText::new("hello"), 64, 32);
362 let b = p.render(&app, &WispText::new("world"), 64, 32);
363 assert_eq!(p.stats(), (0, 2));
364 assert!(!Arc::ptr_eq(&a, &b));
365 assert_eq!(p.cache_len(), 2);
366 }
367
368 #[test]
369 fn changing_style_invalidates_cache() {
370 let app = boot();
371 let p = pipeline(&app);
372 let plain = WispText::new("hello");
373 let bold = WispText::new("hello")
374 .with_style(plain.style.with_weight(crate::text::WispFontWeight::Bold));
375 let a = p.render(&app, &plain, 64, 32);
376 let b = p.render(&app, &bold, 64, 32);
377 assert_eq!(p.stats(), (0, 2));
378 assert!(!Arc::ptr_eq(&a, &b));
379 }
380
381 #[test]
382 fn changing_color_invalidates_cache() {
383 let app = boot();
384 let p = pipeline(&app);
385 let red = WispText::new("hello").with_style(
386 crate::text::WispTextStyle::default().with_color(Color::rgba(1.0, 0.0, 0.0, 1.0)),
387 );
388 let green = WispText::new("hello").with_style(
389 crate::text::WispTextStyle::default().with_color(Color::rgba(0.0, 1.0, 0.0, 1.0)),
390 );
391 let a = p.render(&app, &red, 64, 32);
392 let b = p.render(&app, &green, 64, 32);
393 assert!(!Arc::ptr_eq(&a, &b));
394 }
395
396 #[test]
397 fn changing_wrap_width_invalidates_cache() {
398 let app = boot();
399 let p = pipeline(&app);
400 let unwrapped = WispText::new("a long line of text");
401 let wrapped = WispText::new("a long line of text").with_wrap(0.5);
402 let a = p.render(&app, &unwrapped, 64, 32);
403 let b = p.render(&app, &wrapped, 64, 32);
404 assert!(!Arc::ptr_eq(&a, &b));
405 }
406
407 #[test]
408 fn changing_dimensions_invalidates_cache() {
409 let app = boot();
410 let p = pipeline(&app);
411 let a = p.render(&app, &WispText::new("hello"), 64, 32);
412 let b = p.render(&app, &WispText::new("hello"), 128, 32);
413 assert!(!Arc::ptr_eq(&a, &b));
414 assert_eq!(p.cache_len(), 2);
415 }
416
417 #[test]
418 fn changing_font_family_invalidates_cache() {
419 let app = boot();
420 let p = pipeline(&app);
421 let a = p.render(&app, &WispText::new("hello"), 64, 32);
422 let b = p.render(
423 &app,
424 &WispText::new("hello").with_font_family("Inter"),
425 64,
426 32,
427 );
428 assert!(!Arc::ptr_eq(&a, &b));
429 }
430
431 #[test]
432 fn cache_evicts_at_capacity() {
433 let app = boot();
434 let p = pipeline(&app);
435 for i in 0..=MAX_ENTRIES {
437 let _ = p.render(&app, &WispText::new(format!("entry-{i}")), 32, 32);
439 }
440 assert_eq!(p.cache_len(), MAX_ENTRIES);
442 let (hits, misses) = p.stats();
443 assert_eq!(hits, 0);
444 let expected_misses = u64::try_from(MAX_ENTRIES + 1).expect("fits");
445 assert_eq!(misses, expected_misses);
446 }
447
448 #[test]
449 fn clear_cache_drops_entries_and_refills_on_next_render() {
450 let app = boot();
451 let p = pipeline(&app);
452 let _ = p.render(&app, &WispText::new("hello"), 64, 32);
453 assert_eq!(p.cache_len(), 1);
454 p.clear_cache();
455 assert_eq!(p.cache_len(), 0);
456 let _ = p.render(&app, &WispText::new("hello"), 64, 32);
457 assert_eq!(p.stats(), (0, 2));
459 }
460
461 #[test]
462 fn rendered_texture_has_non_zero_glyph_pixels() {
463 let app = boot();
464 let p = pipeline(&app);
465 let rt = p.render(&app, &WispText::new("hi"), 128, 64);
466 let bytes = rt.read_pixels(&app);
467 let non_zero = bytes.chunks_exact(4).filter(|p| p[3] > 0).count();
468 assert!(
469 non_zero > 0,
470 "expected the text pipeline to paint glyph pixels"
471 );
472 }
473}