tessera-components 0.0.0

Basic components for tessera-ui, using md3e design principles.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
//! Text Rendering Pipeline for UI Components
//!
//! This module implements the GPU pipeline and related utilities for efficient
//! text rendering in Tessera UI components. It leverages the Glyphon engine for
//! font management, shaping, and rasterization, providing high-quality and
//! performant text output. Typical use cases include rendering static labels,
//! paragraphs, and editable text fields within the UI.
//!
//! The pipeline is designed to be reusable and efficient, sharing a static font
//! system across the application to minimize resource usage. It exposes APIs
//! for preparing, measuring, and rendering text, supporting advanced features
//! such as font fallback, shaping, and multi-line layout.
//!
//! This module is intended for integration into custom UI components and
//! rendering flows that require flexible and robust text display.

use std::{num::NonZero, sync::OnceLock};

use glyphon::fontdb;
use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tessera_ui::{
    Color, PxPosition,
    renderer::drawer::pipeline::{DrawContext, DrawablePipeline},
    wgpu,
};

use super::command::{TextCommand, TextConstraint};

/// It costs a lot to create a glyphon font system, so we use a static one
/// to share it every where and avoid creating it multiple times.
static FONT_SYSTEM: OnceLock<RwLock<glyphon::FontSystem>> = OnceLock::new();

/// Create TextData is a heavy operation, so we provide a lru cache to store
/// recently used TextData.
static TEXT_DATA_CACHE: OnceLock<RwLock<lru::LruCache<LruKey, TextData>>> = OnceLock::new();

#[derive(PartialEq)]
struct LruKey {
    text: String,
    color: Color,
    font_size: f32,
    line_height: f32,
    /// The final computed bounds, used as the cache key instead of constraint.
    bounds: [u32; 2],
}

impl Eq for LruKey {}

impl std::hash::Hash for LruKey {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.text.hash(state);
        self.color.r.to_bits().hash(state);
        self.color.g.to_bits().hash(state);
        self.color.b.to_bits().hash(state);
        self.color.a.to_bits().hash(state);
        self.font_size.to_bits().hash(state);
        self.line_height.to_bits().hash(state);
        self.bounds.hash(state);
    }
}

fn write_lru_cache() -> RwLockWriteGuard<'static, lru::LruCache<LruKey, TextData>> {
    TEXT_DATA_CACHE
        .get_or_init(|| {
            RwLock::new(lru::LruCache::new(
                NonZero::new(100).expect("text cache size must be non-zero"),
            ))
        })
        .write()
}

#[cfg(target_os = "android")]
fn init_font_system() -> RwLock<glyphon::FontSystem> {
    let mut font_system = glyphon::FontSystem::new();

    font_system.db_mut().load_fonts_dir("/system/fonts");
    font_system.db_mut().set_sans_serif_family("Roboto");
    font_system.db_mut().set_serif_family("Noto Serif");
    font_system.db_mut().set_monospace_family("Droid Sans Mono");
    font_system.db_mut().set_cursive_family("Dancing Script");
    font_system.db_mut().set_fantasy_family("Dancing Script");

    RwLock::new(font_system)
}

#[cfg(not(target_os = "android"))]
fn init_font_system() -> RwLock<glyphon::FontSystem> {
    RwLock::new(glyphon::FontSystem::new())
}

/// It costs a lot to create a glyphon font system, so we use a static one
/// to share it every where and avoid creating it multiple times.
/// This function returns a read lock of the font system.
pub fn read_font_system() -> RwLockReadGuard<'static, glyphon::FontSystem> {
    FONT_SYSTEM.get_or_init(init_font_system).read()
}

/// It costs a lot to create a glyphon font system, so we use a static one
/// to share it every where and avoid creating it multiple times.
/// This function returns a write lock of the font system.
pub fn write_font_system() -> RwLockWriteGuard<'static, glyphon::FontSystem> {
    FONT_SYSTEM.get_or_init(init_font_system).write()
}

/// A text renderer
/// Pipeline for rendering text using the Glyphon engine.
///
/// This struct manages font atlas, cache, viewport, and swash cache for
/// efficient text rendering.
pub struct GlyphonTextRender {
    /// Glyphon font atlas, a heavy-weight, shared resource.
    atlas: glyphon::TextAtlas,
    /// Glyphon cache, a heavy-weight, shared resource.
    #[allow(unused)]
    cache: glyphon::Cache,
    /// Glyphon viewport, holds screen-size related buffers.
    viewport: glyphon::Viewport,
    /// Glyphon swash cache, a CPU-side cache for glyph rasterization.
    swash_cache: glyphon::SwashCache,
    /// Multisample state for anti-aliasing.
    msaa: wgpu::MultisampleState,
    /// Glyphon text renderer, responsible for rendering text.
    renderer: glyphon::TextRenderer,
}

impl GlyphonTextRender {
    /// Creates a new text renderer pipeline.
    ///
    /// # Parameters
    ///
    /// - `gpu`: The wgpu device.
    /// - `queue`: The wgpu queue.
    /// - `config`: Surface configuration.
    /// - `sample_count`: Multisample count for anti-aliasing.
    pub fn new(
        gpu: &wgpu::Device,
        queue: &wgpu::Queue,
        config: &wgpu::SurfaceConfiguration,
        sample_count: u32,
    ) -> Self {
        let cache = glyphon::Cache::new(gpu);
        let mut atlas = glyphon::TextAtlas::new(gpu, queue, &cache, config.format);
        let viewport = glyphon::Viewport::new(gpu, &cache);
        let swash_cache = glyphon::SwashCache::new();
        let msaa = wgpu::MultisampleState {
            count: sample_count,
            mask: !0,
            alpha_to_coverage_enabled: false,
        };
        let renderer = glyphon::TextRenderer::new(&mut atlas, gpu, msaa, None);

        Self {
            atlas,
            cache,
            viewport,
            swash_cache,
            msaa,
            renderer,
        }
    }
}

impl DrawablePipeline<TextCommand> for GlyphonTextRender {
    fn draw(&mut self, context: &mut DrawContext<TextCommand>) {
        if context.commands.is_empty() {
            return;
        }

        self.viewport.update(
            context.queue,
            glyphon::Resolution {
                width: context.config.width,
                height: context.config.height,
            },
        );

        let text_areas = context.commands.iter().map(|(command, _size, start_pos)| {
            let start_pos = PxPosition::new(
                start_pos.x + command.offset.x,
                start_pos.y + command.offset.y,
            );
            command.data.text_area(start_pos)
        });

        self.renderer
            .prepare(
                context.device,
                context.queue,
                &mut write_font_system(),
                &mut self.atlas,
                &self.viewport,
                text_areas,
                &mut self.swash_cache,
            )
            .expect("glyphon prepare failed");

        self.renderer
            .render(&self.atlas, &self.viewport, context.render_pass)
            .expect("glyphon render failed");

        // Re-create the renderer to release borrow on atlas
        let new_renderer =
            glyphon::TextRenderer::new(&mut self.atlas, context.device, self.msaa, None);
        let _ = std::mem::replace(&mut self.renderer, new_renderer);
    }
}

/// Text data for rendering, including buffer and size.
#[derive(Debug, Clone)]
pub struct TextData {
    /// glyphon text buffer
    text_buffer: glyphon::Buffer,
    /// text area size
    pub size: [u32; 2],
    /// Baseline offset of the first visible line, relative to the text origin.
    pub first_baseline: f32,
    /// Baseline offset of the last visible line, relative to the text origin.
    pub last_baseline: f32,
    /// Number of visible layout lines.
    pub line_count: u32,
    base_color: Color,
    current_color: Color,
    text: String,
    font_size: f32,
    line_height: f32,
}

/// Measurement result returned by `TextData::measure()`.
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub struct TextMeasureInfo {
    /// The computed size of the text area.
    pub size: [u32; 2],
    /// Baseline offset of the first visible line.
    pub first_baseline: f32,
    /// Baseline offset of the last visible line.
    pub last_baseline: f32,
    /// Number of visible layout lines.
    pub line_count: u32,
}

impl PartialEq for TextData {
    fn eq(&self, other: &Self) -> bool {
        self.size == other.size
            && self.first_baseline == other.first_baseline
            && self.last_baseline == other.last_baseline
            && self.line_count == other.line_count
            && self.base_color == other.base_color
            && self.current_color == other.current_color
            && self.text == other.text
            && self.font_size == other.font_size
            && self.line_height == other.line_height
    }
}

impl TextData {
    /// Measures text layout and caches the result.
    ///
    /// This method performs the expensive text shaping and layout computation,
    /// stores the result in the LRU cache, and returns measurement information.
    /// The cached result can later be retrieved using [`TextData::get()`].
    ///
    /// # Parameters
    /// - `text`: The text string.
    /// - `color`: The text color.
    /// - `font_size`: Font size.
    /// - `line_height`: Line height.
    /// - `constraint`: Text constraint for layout.
    pub fn measure(
        text: String,
        color: Color,
        font_size: f32,
        line_height: f32,
        constraint: TextConstraint,
    ) -> TextMeasureInfo {
        let (text_buffer, bounds, first_baseline, last_baseline, line_count) =
            Self::build_buffer(&text, color, font_size, line_height, &constraint);

        // Build cache key using bounds (not constraint)
        let key = LruKey {
            text: text.clone(),
            color,
            font_size,
            line_height,
            bounds,
        };

        // Store in cache
        let data = Self {
            text_buffer,
            size: bounds,
            first_baseline,
            last_baseline,
            line_count,
            base_color: color,
            current_color: color,
            text,
            font_size,
            line_height,
        };
        write_lru_cache().put(key, data);

        TextMeasureInfo {
            size: bounds,
            first_baseline,
            last_baseline,
            line_count,
        }
    }

    /// Retrieves cached text data using the computed bounds.
    ///
    /// This method attempts to retrieve cached data from a prior
    /// [`TextData::measure()`] call. If the cache entry was evicted (due to LRU
    /// limits), it will automatically recompute the text layout using the
    /// bounds as the constraint.
    ///
    /// # Parameters
    /// - `text`: The text string.
    /// - `color`: The text color.
    /// - `font_size`: Font size.
    /// - `line_height`: Line height.
    /// - `bounds`: The computed bounds from measurement (width, height).
    pub fn get(
        text: String,
        color: Color,
        font_size: f32,
        line_height: f32,
        bounds: [u32; 2],
    ) -> Self {
        let key = LruKey {
            text: text.clone(),
            color,
            font_size,
            line_height,
            bounds,
        };

        // Try to get from cache first
        if let Some(cached) = write_lru_cache().get(&key).cloned() {
            return cached;
        }

        // Cache miss (possibly evicted by LRU), recompute using bounds as constraint
        let constraint = TextConstraint {
            max_width: Some(bounds[0] as f32),
            max_height: Some(bounds[1] as f32),
        };
        let (text_buffer, computed_bounds, first_baseline, last_baseline, line_count) =
            Self::build_buffer(&text, color, font_size, line_height, &constraint);

        let data = Self {
            text_buffer,
            size: computed_bounds,
            first_baseline,
            last_baseline,
            line_count,
            base_color: color,
            current_color: color,
            text: text.clone(),
            font_size,
            line_height,
        };

        // Store back in cache
        write_lru_cache().put(key, data.clone());
        data
    }

    /// Prepares text data for rendering (legacy API, combines measure + get).
    ///
    /// # Parameters
    /// Builds [`TextData`] directly from a pre-shaped glyphon buffer.
    pub fn from_buffer(text_buffer: glyphon::Buffer) -> Self {
        // Calculate total height including descender for the last line
        let metrics = text_buffer.metrics();
        // Calculate text bounds
        let mut run_width: f32 = 0.0;
        let mut first_baseline = 0.0;
        let mut last_baseline = 0.0;
        let mut line_count: u32 = 0;
        for run in text_buffer.layout_runs() {
            // Take the max. width of all lines.
            run_width = run_width.max(run.line_w);
            if line_count == 0 {
                first_baseline = run.line_y;
            }
            last_baseline = run.line_y;
            line_count += 1;
        }
        let descent_amount = (metrics.line_height - metrics.font_size).max(0.0);
        let total_height = line_count as f32 * metrics.line_height + descent_amount;
        // build text data
        Self {
            text_buffer,
            size: [run_width as u32, total_height.ceil() as u32],
            first_baseline,
            last_baseline,
            line_count,
            base_color: Color::WHITE,
            current_color: Color::WHITE,
            text: String::new(),
            font_size: metrics.font_size,
            line_height: metrics.line_height,
        }
    }

    /// Get the glyphon text area from the text data
    fn text_area(&'_ self, start_pos: PxPosition) -> glyphon::TextArea<'_> {
        let bounds = glyphon::TextBounds {
            left: start_pos.x.raw(),
            top: start_pos.y.raw(),
            right: start_pos.x.raw() + self.size[0] as i32,
            bottom: start_pos.y.raw() + self.size[1] as i32,
        };
        glyphon::TextArea {
            buffer: &self.text_buffer,
            left: start_pos.x.to_f32(),
            top: start_pos.y.to_f32(),
            scale: 1.0,
            bounds,
            default_color: glyphon::Color::rgba(
                (self.current_color.r * 255.0) as u8,
                (self.current_color.g * 255.0) as u8,
                (self.current_color.b * 255.0) as u8,
                (self.current_color.a * 255.0) as u8,
            ),
            custom_glyphs: &[],
        }
    }

    fn build_buffer(
        text: &str,
        color: Color,
        size: f32,
        line_height: f32,
        constraint: &TextConstraint,
    ) -> (glyphon::Buffer, [u32; 2], f32, f32, u32) {
        // Create text buffer
        let mut text_buffer = glyphon::Buffer::new(
            &mut write_font_system(),
            glyphon::Metrics::new(size, line_height),
        );
        let color = glyphon::Color::rgba(
            (color.r * 255.0) as u8,
            (color.g * 255.0) as u8,
            (color.b * 255.0) as u8,
            (color.a * 255.0) as u8,
        );
        text_buffer.set_wrap(&mut write_font_system(), glyphon::Wrap::Glyph);
        text_buffer.set_size(
            &mut write_font_system(),
            constraint.max_width,
            constraint.max_height,
        );
        text_buffer.set_text(
            &mut write_font_system(),
            text,
            &glyphon::Attrs::new()
                .family(fontdb::Family::SansSerif)
                .color(color),
            glyphon::Shaping::Advanced,
            None,
        );
        text_buffer.shape_until_scroll(&mut write_font_system(), false);
        // Calculate text bounds and baselines.
        let mut run_width: f32 = 0.0;
        let metrics = text_buffer.metrics();
        let mut first_baseline = 0.0;
        let mut last_baseline = 0.0;
        let mut line_count: u32 = 0;
        for run in text_buffer.layout_runs() {
            run_width = run_width.max(run.line_w);
            if line_count == 0 {
                first_baseline = run.line_y;
            }
            last_baseline = run.line_y;
            line_count += 1;
        }
        let descent_amount = (metrics.line_height - metrics.font_size).max(0.0);
        let total_height = line_count as f32 * metrics.line_height + descent_amount;
        (
            text_buffer,
            [run_width as u32, total_height.ceil() as u32],
            first_baseline,
            last_baseline,
            line_count,
        )
    }

    pub(crate) fn apply_opacity(&mut self, opacity: f32) {
        let target_alpha = (self.base_color.a * opacity).clamp(0.0, 1.0);
        let target_color = self.base_color.with_alpha(target_alpha);
        if (target_color.a - self.current_color.a).abs() <= f32::EPSILON
            && (target_color.r - self.current_color.r).abs() <= f32::EPSILON
            && (target_color.g - self.current_color.g).abs() <= f32::EPSILON
            && (target_color.b - self.current_color.b).abs() <= f32::EPSILON
        {
            return;
        }

        // Use the current size as constraint for rebuilding with new color
        let constraint = TextConstraint {
            max_width: Some(self.size[0] as f32),
            max_height: Some(self.size[1] as f32),
        };
        let (buffer, bounds, first_baseline, last_baseline, line_count) = Self::build_buffer(
            &self.text,
            target_color,
            self.font_size,
            self.line_height,
            &constraint,
        );
        self.text_buffer = buffer;
        self.size = bounds;
        self.first_baseline = first_baseline;
        self.last_baseline = last_baseline;
        self.line_count = line_count;
        self.current_color = target_color;
    }
}