Skip to main content

agg_gui/
text.rs

1//! Text rendering — font loading, shaping, and glyph rasterization.
2//!
3//! # Pipeline
4//!
5//! ```text
6//! Font bytes (TTF/OTF)
7//!   │  ttf-parser  →  glyph outline curves
8//!   │  rustybuzz   →  shaped glyph positions & advances
9//!   │
10//! GlyphPathBuilder  →  AGG PathStorage (Bézier curves)
11//!   │
12//! rasterize_fill_path  →  Framebuffer pixels
13//! ```
14//!
15//! # Coordinate system
16//!
17//! TrueType fonts use Y-up coordinates (positive Y = above baseline).
18//! This matches GfxCtx's first-quadrant convention exactly — no Y-flip
19//! is needed at the glyph boundary.
20//!
21//! The baseline is placed at the Y coordinate passed to `GfxCtx::fill_text`.
22//! Ascenders go to higher Y values (up), descenders to lower Y values (down),
23//! which is correct for Y-up rendering.
24
25mod bezier_flat;
26pub use bezier_flat::{shape_and_flatten_text, shape_and_flatten_text_via_agg};
27
28use std::sync::Arc;
29
30use agg_rust::basics::{
31    is_end_poly, is_move_to, is_stop, VertexSource, PATH_CMD_LINE_TO, PATH_FLAGS_NONE,
32};
33use agg_rust::conv_contour::ConvContour;
34use agg_rust::conv_curve::ConvCurve;
35use agg_rust::conv_transform::ConvTransform;
36use agg_rust::path_storage::PathStorage;
37use agg_rust::trans_affine::TransAffine;
38
39/// Metrics describing a single line of shaped text.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct TextMetrics {
42    /// Advance width of the text run in pixels.
43    pub width: f64,
44    /// Distance from baseline to top of tallest ascender, in pixels (positive).
45    pub ascent: f64,
46    /// Distance from baseline to bottom of deepest descender, in pixels (positive).
47    pub descent: f64,
48    /// Recommended line height (ascender + descender + line gap), in pixels.
49    pub line_height: f64,
50}
51
52impl TextMetrics {
53    /// Baseline Y that visually centers this text run in a Y-up box.
54    pub fn centered_baseline_y(&self, height: f64) -> f64 {
55        (height - (self.ascent - self.descent)) * 0.5
56    }
57}
58
59/// A loaded font, ready for shaping and rasterization.
60///
61/// Constructed from raw TTF/OTF bytes via [`Font::from_bytes`]. The data is
62/// reference-counted so fonts can be cheaply shared and saved across frames.
63///
64/// An optional fallback font can be chained via [`Font::with_fallback`]; when
65/// a glyph is missing from the primary font (glyph_id == 0 after shaping),
66/// the fallback is consulted for both the glyph outline and advance width.
67pub struct Font {
68    pub(crate) data: Arc<Vec<u8>>,
69    index: u32,
70    /// Cached at construction to avoid repeated parsing.
71    units_per_em: u16,
72    ascender: i16,
73    descender: i16,
74    line_gap: i16,
75    /// Optional fallback used when the primary font lacks a glyph.
76    pub(crate) fallback: Option<Arc<Font>>,
77}
78
79impl Font {
80    /// Parse a font from raw TTF/OTF bytes.
81    ///
82    /// Returns `Err` if the data is not a valid font.
83    pub fn from_bytes(data: Vec<u8>) -> Result<Self, &'static str> {
84        let face = ttf_parser::Face::parse(&data, 0).map_err(|_| "failed to parse font")?;
85        Ok(Self {
86            units_per_em: face.units_per_em(),
87            ascender: face.ascender(),
88            descender: face.descender(),
89            line_gap: face.line_gap(),
90            data: Arc::new(data),
91            index: 0,
92            fallback: None,
93        })
94    }
95
96    /// Parse a font from a borrowed byte slice (data is copied).
97    pub fn from_slice(data: &[u8]) -> Result<Self, &'static str> {
98        Self::from_bytes(data.to_vec())
99    }
100
101    /// Chain a fallback font consulted when this font lacks a glyph.
102    ///
103    /// Returns `self` so it can be used as a builder method:
104    /// ```ignore
105    /// let font = Font::from_slice(MAIN_BYTES)?.with_fallback(Arc::new(emoji_font));
106    /// ```
107    pub fn with_fallback(mut self, fallback: Arc<Font>) -> Self {
108        self.fallback = Some(fallback);
109        self
110    }
111
112    pub fn units_per_em(&self) -> u16 {
113        self.units_per_em
114    }
115
116    /// Ascender height in pixels at the given font size.
117    pub fn ascender_px(&self, size: f64) -> f64 {
118        self.ascender as f64 * size / self.units_per_em as f64
119    }
120
121    /// Descender depth in pixels at the given font size (positive value).
122    pub fn descender_px(&self, size: f64) -> f64 {
123        self.descender.unsigned_abs() as f64 * size / self.units_per_em as f64
124    }
125
126    /// Recommended line height in pixels at the given font size.
127    pub fn line_height_px(&self, size: f64) -> f64 {
128        let total = (self.ascender - self.descender + self.line_gap) as f64;
129        total * size / self.units_per_em as f64
130    }
131
132    /// Actual vertical extent of a single glyph in pixels (Y-up), relative
133    /// to the baseline. Returns `(y_min, y_max)` where `y_min` is how far
134    /// the glyph dips below the baseline (negative for descenders, near
135    /// zero for upright glyphs) and `y_max` is how far it rises above.
136    ///
137    /// Use this for *visually* centring an icon glyph in a button —
138    /// `ascender_px`/`descender_px` describe the FONT's worst-case
139    /// extents and are too generous for most icon fonts (Font Awesome
140    /// glyphs sit in a sub-rectangle of the design space, so centring
141    /// by the font metric leaves them noticeably high). Returns `None`
142    /// when the glyph has no outline (e.g. a space) or isn't in the
143    /// font.
144    pub fn glyph_visual_bounds(&self, glyph: char, size: f64) -> Option<(f64, f64)> {
145        self.with_ttf_face(|face| {
146            let gid = face.glyph_index(glyph)?;
147            let bbox = face.glyph_bounding_box(gid)?;
148            let scale = size / self.units_per_em as f64;
149            // ttf_parser reports y_min / y_max in font units relative to
150            // baseline, Y-up — convert directly.
151            Some((bbox.y_min as f64 * scale, bbox.y_max as f64 * scale))
152        })
153    }
154
155    /// Run `f` with a `rustybuzz::Face` borrowed from the internal data.
156    ///
157    /// The face has the same lifetime as the closure invocation, so it cannot
158    /// outlive this call. Use this for shaping + outline extraction.
159    pub(crate) fn with_rb_face<F, R>(&self, f: F) -> R
160    where
161        F: FnOnce(&rustybuzz::Face<'_>) -> R,
162    {
163        let face = rustybuzz::Face::from_slice(&self.data, self.index)
164            .expect("font was validated at construction");
165        f(&face)
166    }
167
168    /// Run `f` with a `ttf_parser::Face` borrowed from the internal data.
169    ///
170    /// Used for glyph index lookups (fallback resolution) without full shaping.
171    pub(crate) fn with_ttf_face<F, R>(&self, f: F) -> R
172    where
173        F: FnOnce(&ttf_parser::Face<'_>) -> R,
174    {
175        let face = ttf_parser::Face::parse(&self.data, self.index)
176            .expect("font was validated at construction");
177        f(&face)
178    }
179}
180
181// ---------------------------------------------------------------------------
182// Glyph outline → AGG PathStorage
183// ---------------------------------------------------------------------------
184
185/// Converts ttf-parser outline callbacks into an AGG `PathStorage`.
186///
187/// TTF fonts are Y-up; GfxCtx is Y-up — no axis flip is needed. Each glyph
188/// is translated to its screen position `(ox, oy)` and scaled by `scale`.
189///
190/// The builder can optionally apply two of the `font_settings` typography
191/// transforms directly at outline-construction time:
192/// - `width_scale` — horizontal scale applied to every glyph vertex,
193///   leaving advances untouched (matches AGG `truetype_lcd.cpp` "Width").
194/// - `italic_shear` — horizontal shear as a fraction of Y: `x += y *
195///   italic_shear`.  Matches the C++ "Faux Italic" which applies
196///   `TransAffine::new_skewing(faux_italic/3, 0)`; the `/3` convention
197///   keeps the slider range comparable.
198pub(crate) struct GlyphPathBuilder {
199    pub path: PathStorage,
200    ox: f64,
201    oy: f64,
202    scale: f64,
203    /// Horizontal-only outline scale.  Default `1.0`.
204    width_scale: f64,
205    /// Italic shear factor (x += y * italic_shear).  Default `0.0`.
206    italic_shear: f64,
207    pub has_outline: bool,
208}
209
210impl GlyphPathBuilder {
211    pub fn new(ox: f64, oy: f64, scale: f64) -> Self {
212        Self {
213            path: PathStorage::new(),
214            ox,
215            oy,
216            scale,
217            width_scale: 1.0,
218            italic_shear: 0.0,
219            has_outline: false,
220        }
221    }
222
223    /// Enable Width + Faux-Italic transforms for this glyph.  `width`
224    /// multiplies every outline X after font-scaling; `italic` shears
225    /// horizontally proportional to the vertex's Y above the baseline
226    /// (positive italic slants top-right, matching the AGG reference).
227    #[allow(dead_code)]
228    pub fn with_style(mut self, width: f64, italic: f64) -> Self {
229        self.width_scale = width;
230        self.italic_shear = italic;
231        self
232    }
233
234    /// Pixel-space X of a font-unit input vertex.
235    ///
236    /// `italic_shear` uses the **unsheared** Y (distance above baseline)
237    /// so the shear stays consistent whether or not hinting has snapped
238    /// the glyph origin — the shear depends on glyph geometry, not on
239    /// where the baseline landed on screen.
240    #[inline]
241    fn x(&self, v: f32, y_raw: f32) -> f64 {
242        let base_x = self.ox + v as f64 * self.scale * self.width_scale;
243        let shear = y_raw as f64 * self.scale * self.italic_shear;
244        base_x + shear
245    }
246    #[inline]
247    fn y(&self, v: f32) -> f64 {
248        self.oy + v as f64 * self.scale
249    }
250}
251
252impl ttf_parser::OutlineBuilder for GlyphPathBuilder {
253    fn move_to(&mut self, x: f32, y: f32) {
254        self.path.move_to(self.x(x, y), self.y(y));
255        self.has_outline = true;
256    }
257    fn line_to(&mut self, x: f32, y: f32) {
258        self.path.line_to(self.x(x, y), self.y(y));
259    }
260    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
261        self.path
262            .curve3(self.x(x1, y1), self.y(y1), self.x(x, y), self.y(y));
263    }
264    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
265        self.path.curve4(
266            self.x(x1, y1),
267            self.y(y1),
268            self.x(x2, y2),
269            self.y(y2),
270            self.x(x, y),
271            self.y(y),
272        );
273    }
274    fn close(&mut self) {
275        self.path.close_polygon(PATH_FLAGS_NONE);
276    }
277}
278
279// ---------------------------------------------------------------------------
280// Shaping helper — shapes text and returns per-glyph paths
281// ---------------------------------------------------------------------------
282
283/// Shape `text` with `font` at `size` pixels, starting at screen position
284/// `(x, y)` (baseline-left, Y-up). Returns one `PathStorage` per glyph that
285/// has an outline (spaces and control chars yield no path).
286///
287/// Walks the fallback font chain via [`shape_glyphs`], so Font Awesome /
288/// emoji glyphs not present in the primary font are still resolved and
289/// rasterized using the font they live in.
290/// Apply the "faux weight" outline offset to a glyph path.
291///
292/// Port of the AGG C++ `truetype_lcd.cpp` technique:
293/// ```text
294///   curves -> scale(1, 100) -> ConvContour(width=w) -> scale(1, 1/100)
295/// ```
296/// The Y-zoom makes the contour offset act primarily horizontally —
297/// vertical stems pick up the full `w` of extra thickness while
298/// horizontal strokes stay thin, which is what you want for bold-like
299/// weight.  Returns a fresh `PathStorage` containing the offset outline
300/// flattened to straight segments (ConvCurve has already subdivided the
301/// Béziers by the time ConvContour sees them).
302///
303/// `weight_px` is the raw contour width — matches the agg-rust
304/// `contour.set_width(-faux_weight * height / 15.0)` convention; pass
305/// the already-sign-flipped, already-scaled value.
306fn apply_faux_weight(path: PathStorage, weight_px: f64) -> PathStorage {
307    if weight_px.abs() < 1e-4 {
308        return path;
309    }
310    let mut src = path;
311    let mut curves = ConvCurve::new(&mut src);
312    let zoom_in = TransAffine::new_scaling(1.0, 100.0);
313    let mut zoomed_in = ConvTransform::new(&mut curves, zoom_in);
314    let mut contour = ConvContour::new(&mut zoomed_in);
315    contour.set_auto_detect_orientation(false);
316    contour.set_width(weight_px);
317    let zoom_out = TransAffine::new_scaling(1.0, 1.0 / 100.0);
318    let mut out = ConvTransform::new(&mut contour, zoom_out);
319
320    // Flatten the VertexSource chain into a fresh PathStorage.  ConvCurve
321    // has converted all Béziers to line-segments by the time we get here,
322    // so the output is only `move_to` / `line_to` / `end_poly` commands.
323    let mut result = PathStorage::new();
324    out.rewind(0);
325    loop {
326        let (mut vx, mut vy) = (0.0_f64, 0.0_f64);
327        let cmd = out.vertex(&mut vx, &mut vy);
328        if is_stop(cmd) {
329            break;
330        }
331        if is_move_to(cmd) {
332            result.move_to(vx, vy);
333        } else if cmd == PATH_CMD_LINE_TO {
334            result.line_to(vx, vy);
335        } else if is_end_poly(cmd) {
336            result.close_polygon(PATH_FLAGS_NONE);
337        }
338    }
339    result
340}
341
342pub(crate) fn shape_text(
343    font: &Font,
344    text: &str,
345    size: f64,
346    x: f64,
347    y: f64,
348) -> (Vec<PathStorage>, f64) {
349    let shaped = shape_glyphs(font, text, size);
350
351    // Pull the current typography-style globals ONCE per call.  The
352    // text render path consults them here so any widget (including the
353    // LCD Subpixel demo's sliders) that writes through `font_settings`
354    // affects the next paint.
355    //
356    // - `width_scale`  → horizontal outline scale per glyph
357    // - `italic_shear` → faux-italic (0..1 range maps to /3 in the
358    //   outline shear, matching the agg-rust reference)
359    // - `hint_y`       → snap the glyph-origin Y to whole pixels
360    //                    (Y-axis-only hinting, matches `(y+0.5).floor()`)
361    // - `interval_px`  → extra pen advance in pixels per glyph,
362    //                    proportional to em size
363    let width_scale = crate::font_settings::current_width();
364    let italic_shear = crate::font_settings::current_faux_italic() / 3.0;
365    let hint_y = crate::font_settings::hinting_enabled();
366    let interval_em = crate::font_settings::current_interval();
367    let interval_px = interval_em * size;
368    // Faux weight — negative sign matches agg-rust: +faux_weight
369    // thickens (contour width negative expands outward for a CCW
370    // outline), -faux_weight thins.  The `/15.0` denominator reproduces
371    // the reference demo's slider-to-pixels conversion.
372    let faux_weight = crate::font_settings::current_faux_weight();
373    let weight_px = if faux_weight.abs() < 0.05 {
374        0.0 // dead zone near 0, matches reference — avoids zero-width noise
375    } else {
376        -faux_weight * size / 15.0
377    };
378
379    let mut paths = Vec::new();
380    let mut pen_x = x;
381    let mut total_advance = 0.0;
382
383    for g in &shaped {
384        let gx = pen_x + g.x_offset;
385        let gy_unsnapped = y + g.y_offset;
386        // Hinting: snap the glyph origin's Y to the integer pixel
387        // nearest the logical baseline.  Matches the AGG C++
388        // `(y + 0.5).floor()` convention — simple, cheap, preserves
389        // horizontal subpixel positioning.
390        let gy = if hint_y {
391            (gy_unsnapped + 0.5).floor()
392        } else {
393            gy_unsnapped
394        };
395        // glyph_id indexes into whichever font resolved the code point.
396        let render_font = g.fallback_font.as_deref().unwrap_or(font);
397        let scale = size / render_font.units_per_em() as f64;
398
399        let mut builder =
400            GlyphPathBuilder::new(gx, gy, scale).with_style(width_scale, italic_shear);
401        let has_outline = render_font.with_ttf_face(|face| {
402            face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut builder)
403                .is_some()
404        });
405        if has_outline && builder.has_outline {
406            // Apply faux weight (zero-cost pass-through at weight_px == 0).
407            let path = apply_faux_weight(builder.path, weight_px);
408            paths.push(path);
409        }
410
411        // Interval adds a fixed pen-advance delta per glyph, in pixels.
412        // Applied after the font-native advance so kerning (already
413        // baked into x_advance by rustybuzz) is preserved — the extra
414        // spacing just piles on top.
415        let advance = g.x_advance + interval_px;
416        pen_x += advance;
417        total_advance += advance;
418    }
419    (paths, total_advance)
420}
421
422// ---------------------------------------------------------------------------
423// Glyph cache support — shaped glyph info + single-glyph outline extraction
424// ---------------------------------------------------------------------------
425
426/// Position and identity of one shaped glyph, without any rendering.
427///
428/// Returned by [`shape_glyphs`].  All distances are in **pixels** at the
429/// requested font size.
430///
431/// When `fallback_font` is `Some`, the glyph was resolved from the fallback
432/// font rather than the primary.  Callers must use that font for outline
433/// extraction and glyph cache lookups, since `glyph_id` is an index into
434/// the fallback's glyph table, not the primary's.
435#[derive(Clone)]
436pub struct ShapedGlyph {
437    /// Index into the font's glyph table (or fallback's if `fallback_font` is Some).
438    pub glyph_id: u16,
439    /// How far to advance the pen after this glyph.
440    pub x_advance: f64,
441    /// Horizontal offset from the pen position to this glyph's origin.
442    pub x_offset: f64,
443    /// Vertical offset from the baseline to this glyph's origin.
444    pub y_offset: f64,
445    /// Set when this glyph was resolved via the fallback font.
446    /// Use this font instead of the primary for cache lookups and rendering.
447    pub fallback_font: Option<Arc<Font>>,
448}
449
450/// Shape `text` and return per-glyph positioning info, with **no** outline
451/// extraction or tessellation.
452///
453/// Results are cached in a thread-local `HashMap` keyed by
454/// `(font_data_ptr, text, size_bits)`.  The GL `fill_text()` path calls this
455/// on every paint; caching it eliminates the per-frame `rustybuzz::shape()`
456/// cost for static labels and sidebar items.
457///
458/// Use the result together with [`flatten_glyph_at_origin`] and a
459/// [`GlyphCache`] to avoid re-tessellating glyphs every frame.
460pub fn shape_glyphs(font: &Font, text: &str, size: f64) -> Vec<ShapedGlyph> {
461    let font_key = Arc::as_ptr(&font.data) as usize;
462    let size_key = size.to_bits();
463
464    SHAPE_CACHE.with(|cache| {
465        {
466            let c = cache.borrow();
467            if let Some(cached) = c.get(&(font_key, text.to_owned(), size_key)) {
468                return cached.clone();
469            }
470        }
471
472        // Cache miss — shape the text.
473        let scale = size / font.units_per_em() as f64;
474        let glyphs = font.with_rb_face(|face| {
475            let mut buffer = rustybuzz::UnicodeBuffer::new();
476            buffer.push_str(text);
477            let output = rustybuzz::shape(face, &[], buffer);
478            output
479                .glyph_infos()
480                .iter()
481                .zip(output.glyph_positions().iter())
482                .map(|(info, pos)| {
483                    let glyph_id = info.glyph_id as u16;
484                    let x_advance = pos.x_advance as f64 * scale;
485                    let x_offset = pos.x_offset as f64 * scale;
486                    let y_offset = pos.y_offset as f64 * scale;
487
488                    // glyph_id == 0 means the primary font has no glyph for
489                    // this code point.  Walk the fallback chain until a font
490                    // with a matching glyph is found.
491                    if glyph_id == 0 {
492                        let byte_off = info.cluster as usize;
493                        if let Some(ch) = text.get(byte_off..).and_then(|s| s.chars().next()) {
494                            let mut cur_fb = font.fallback.as_ref();
495                            while let Some(fb) = cur_fb {
496                                let fb_id = fb
497                                    .with_ttf_face(|f| f.glyph_index(ch).map(|g| g.0).unwrap_or(0));
498                                if fb_id != 0 {
499                                    let fb_scale = size / fb.units_per_em() as f64;
500                                    let fb_adv = fb.with_ttf_face(|f| {
501                                        f.glyph_hor_advance(ttf_parser::GlyphId(fb_id))
502                                            .map(|a| a as f64 * fb_scale)
503                                            .unwrap_or(0.0)
504                                    });
505                                    return ShapedGlyph {
506                                        glyph_id: fb_id,
507                                        x_advance: fb_adv,
508                                        x_offset,
509                                        y_offset,
510                                        fallback_font: Some(Arc::clone(fb)),
511                                    };
512                                }
513                                cur_fb = fb.fallback.as_ref();
514                            }
515                        }
516                    }
517
518                    ShapedGlyph {
519                        glyph_id,
520                        x_advance,
521                        x_offset,
522                        y_offset,
523                        fallback_font: None,
524                    }
525                })
526                .collect::<Vec<_>>()
527        });
528
529        cache
530            .borrow_mut()
531            .insert((font_key, text.to_owned(), size_key), glyphs.clone());
532        glyphs
533    })
534}
535
536/// Flatten a single glyph's outline using AGG `ConvCurve`, with the glyph
537/// origin at **(0, 0)** in pixel space.
538///
539/// Returns one `Vec<[f32;2]>` per closed contour, ready to pass to
540/// `tessellate_fill`.  Returns `None` for glyphs without an outline (space,
541/// tab, or glyph IDs that reference nothing).
542///
543/// The vertices are in **glyph-local pixels**: the glyph baseline is y=0 and
544/// the leftmost bearing is x=0 (approximately).  To place the glyph on screen
545/// at `(gx, gy)`, translate every vertex by that amount before tessellating or
546/// uploading to the GPU.
547pub fn flatten_glyph_at_origin(
548    font: &Font,
549    glyph_id: u16,
550    size: f64,
551) -> Option<Vec<Vec<[f32; 2]>>> {
552    let scale = size / font.units_per_em() as f64;
553    font.with_rb_face(|face| {
554        let gid = ttf_parser::GlyphId(glyph_id);
555        let mut builder = GlyphPathBuilder::new(0.0, 0.0, scale);
556        let has_outline = face.outline_glyph(gid, &mut builder).is_some();
557        if !has_outline || !builder.has_outline {
558            return None;
559        }
560
561        let mut curves = ConvCurve::new(builder.path);
562        curves.rewind(0);
563
564        let mut contours: Vec<Vec<[f32; 2]>> = Vec::new();
565        let mut current: Vec<[f32; 2]> = Vec::new();
566
567        loop {
568            let (mut cx, mut cy) = (0.0_f64, 0.0_f64);
569            let cmd = curves.vertex(&mut cx, &mut cy);
570            if is_stop(cmd) {
571                break;
572            }
573            if is_move_to(cmd) {
574                if current.len() >= 3 {
575                    contours.push(std::mem::take(&mut current));
576                } else {
577                    current.clear();
578                }
579                current.push([cx as f32, cy as f32]);
580            } else if cmd == PATH_CMD_LINE_TO {
581                current.push([cx as f32, cy as f32]);
582            } else if is_end_poly(cmd) {
583                if current.len() >= 3 {
584                    contours.push(std::mem::take(&mut current));
585                } else {
586                    current.clear();
587                }
588            }
589        }
590        if current.len() >= 3 {
591            contours.push(current);
592        }
593
594        if contours.is_empty() {
595            None
596        } else {
597            Some(contours)
598        }
599    })
600}
601
602/// Measure full text metrics (width, ascent, descent, line_height).
603///
604/// Useful for external rendering backends (e.g. `GlGfxCtx`) that need
605/// text metrics without the `GfxCtx` wrapper.
606pub fn measure_text_metrics(font: &Font, text: &str, size: f64) -> TextMetrics {
607    TextMetrics {
608        width: measure_advance(font, text, size),
609        ascent: font.ascender_px(size),
610        descent: font.descender_px(size),
611        line_height: font.line_height_px(size),
612    }
613}
614
615// ---------------------------------------------------------------------------
616// Global shape/measurement cache — survives across Label instance recreation
617// ---------------------------------------------------------------------------
618//
619// TreeView and other widgets rebuild their Label children every layout() call,
620// so a per-Label cache doesn't help: each new instance starts cold. This
621// thread-local HashMap caches rustybuzz::shape() results for the lifetime of
622// the process, keyed by (font data pointer, text, size bits). The pointer is
623// stable as long as any Arc<Vec<u8>> clone exists (which is always true while
624// the Font is alive).
625
626use std::cell::RefCell;
627use std::collections::HashMap;
628
629thread_local! {
630    /// Caches the full rustybuzz shaping output (per-glyph IDs + advances).
631    /// Used by shape_glyphs() so fill_text() avoids re-shaping every frame.
632    /// Also serves as the measurement cache — measure_advance() reads it too.
633    static SHAPE_CACHE: RefCell<HashMap<(usize, String, u64), Vec<ShapedGlyph>>> =
634        RefCell::new(HashMap::new());
635}
636
637/// Measure text advance width without rasterizing.
638///
639/// Delegates to [`shape_glyphs`] so that fallback-font advances are included
640/// in the measurement.  Results are cached via the shared shape cache.
641///
642/// The measurement matches what `shape_text` will actually pen at paint
643/// time — so `interval` (extra letter-spacing) is added here too.  Width
644/// and italic are ignored: width only affects per-glyph outline scale,
645/// not advances, and italic shears the outline which doesn't change the
646/// horizontal extent of the pen walk.
647pub fn measure_advance(font: &Font, text: &str, size: f64) -> f64 {
648    let shaped = shape_glyphs(font, text, size);
649    let interval_px = crate::font_settings::current_interval() * size;
650    shaped.iter().map(|g| g.x_advance + interval_px).sum()
651}
652
653#[cfg(test)]
654mod tests;