agg-gui 0.1.0

A Rust GUI framework built on AGG — immediate-mode widgets, Y-up layout, halo-AA rendering via tess2
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
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
//! Text rendering — font loading, shaping, and glyph rasterization.
//!
//! # Pipeline
//!
//! ```text
//! Font bytes (TTF/OTF)
//!   │  ttf-parser  →  glyph outline curves
//!   │  rustybuzz   →  shaped glyph positions & advances
//!//! GlyphPathBuilder  →  AGG PathStorage (Bézier curves)
//!//! rasterize_fill_path  →  Framebuffer pixels
//! ```
//!
//! # Coordinate system
//!
//! TrueType fonts use Y-up coordinates (positive Y = above baseline).
//! This matches GfxCtx's first-quadrant convention exactly — no Y-flip
//! is needed at the glyph boundary.
//!
//! The baseline is placed at the Y coordinate passed to `GfxCtx::fill_text`.
//! Ascenders go to higher Y values (up), descenders to lower Y values (down),
//! which is correct for Y-up rendering.

mod bezier_flat;
pub use bezier_flat::{shape_and_flatten_text, shape_and_flatten_text_via_agg};

use std::sync::Arc;

use agg_rust::basics::{is_end_poly, is_move_to, is_stop, PATH_CMD_LINE_TO, PATH_FLAGS_NONE, VertexSource};
use agg_rust::conv_contour::ConvContour;
use agg_rust::conv_curve::ConvCurve;
use agg_rust::conv_transform::ConvTransform;
use agg_rust::path_storage::PathStorage;
use agg_rust::trans_affine::TransAffine;

/// Metrics describing a single line of shaped text.
#[derive(Debug, Clone, Copy, Default)]
pub struct TextMetrics {
    /// Advance width of the text run in pixels.
    pub width: f64,
    /// Distance from baseline to top of tallest ascender, in pixels (positive).
    pub ascent: f64,
    /// Distance from baseline to bottom of deepest descender, in pixels (positive).
    pub descent: f64,
    /// Recommended line height (ascender + descender + line gap), in pixels.
    pub line_height: f64,
}

/// A loaded font, ready for shaping and rasterization.
///
/// Constructed from raw TTF/OTF bytes via [`Font::from_bytes`]. The data is
/// reference-counted so fonts can be cheaply shared and saved across frames.
///
/// An optional fallback font can be chained via [`Font::with_fallback`]; when
/// a glyph is missing from the primary font (glyph_id == 0 after shaping),
/// the fallback is consulted for both the glyph outline and advance width.
pub struct Font {
    pub(crate) data: Arc<Vec<u8>>,
    index: u32,
    /// Cached at construction to avoid repeated parsing.
    units_per_em: u16,
    ascender: i16,
    descender: i16,
    line_gap: i16,
    /// Optional fallback used when the primary font lacks a glyph.
    pub(crate) fallback: Option<Arc<Font>>,
}

impl Font {
    /// Parse a font from raw TTF/OTF bytes.
    ///
    /// Returns `Err` if the data is not a valid font.
    pub fn from_bytes(data: Vec<u8>) -> Result<Self, &'static str> {
        let face = ttf_parser::Face::parse(&data, 0).map_err(|_| "failed to parse font")?;
        Ok(Self {
            units_per_em: face.units_per_em(),
            ascender: face.ascender(),
            descender: face.descender(),
            line_gap: face.line_gap(),
            data: Arc::new(data),
            index: 0,
            fallback: None,
        })
    }

    /// Parse a font from a borrowed byte slice (data is copied).
    pub fn from_slice(data: &[u8]) -> Result<Self, &'static str> {
        Self::from_bytes(data.to_vec())
    }

    /// Chain a fallback font consulted when this font lacks a glyph.
    ///
    /// Returns `self` so it can be used as a builder method:
    /// ```ignore
    /// let font = Font::from_slice(MAIN_BYTES)?.with_fallback(Arc::new(emoji_font));
    /// ```
    pub fn with_fallback(mut self, fallback: Arc<Font>) -> Self {
        self.fallback = Some(fallback);
        self
    }

    pub fn units_per_em(&self) -> u16 {
        self.units_per_em
    }

    /// Ascender height in pixels at the given font size.
    pub fn ascender_px(&self, size: f64) -> f64 {
        self.ascender as f64 * size / self.units_per_em as f64
    }

    /// Descender depth in pixels at the given font size (positive value).
    pub fn descender_px(&self, size: f64) -> f64 {
        self.descender.unsigned_abs() as f64 * size / self.units_per_em as f64
    }

    /// Recommended line height in pixels at the given font size.
    pub fn line_height_px(&self, size: f64) -> f64 {
        let total = (self.ascender - self.descender + self.line_gap) as f64;
        total * size / self.units_per_em as f64
    }

    /// Run `f` with a `rustybuzz::Face` borrowed from the internal data.
    ///
    /// The face has the same lifetime as the closure invocation, so it cannot
    /// outlive this call. Use this for shaping + outline extraction.
    pub(crate) fn with_rb_face<F, R>(&self, f: F) -> R
    where
        F: FnOnce(&rustybuzz::Face<'_>) -> R,
    {
        let face = rustybuzz::Face::from_slice(&self.data, self.index)
            .expect("font was validated at construction");
        f(&face)
    }

    /// Run `f` with a `ttf_parser::Face` borrowed from the internal data.
    ///
    /// Used for glyph index lookups (fallback resolution) without full shaping.
    pub(crate) fn with_ttf_face<F, R>(&self, f: F) -> R
    where
        F: FnOnce(&ttf_parser::Face<'_>) -> R,
    {
        let face = ttf_parser::Face::parse(&self.data, self.index)
            .expect("font was validated at construction");
        f(&face)
    }
}

// ---------------------------------------------------------------------------
// Glyph outline → AGG PathStorage
// ---------------------------------------------------------------------------

/// Converts ttf-parser outline callbacks into an AGG `PathStorage`.
///
/// TTF fonts are Y-up; GfxCtx is Y-up — no axis flip is needed. Each glyph
/// is translated to its screen position `(ox, oy)` and scaled by `scale`.
///
/// The builder can optionally apply two of the `font_settings` typography
/// transforms directly at outline-construction time:
/// - `width_scale` — horizontal scale applied to every glyph vertex,
///   leaving advances untouched (matches AGG `truetype_lcd.cpp` "Width").
/// - `italic_shear` — horizontal shear as a fraction of Y: `x += y *
///   italic_shear`.  Matches the C++ "Faux Italic" which applies
///   `TransAffine::new_skewing(faux_italic/3, 0)`; the `/3` convention
///   keeps the slider range comparable.
pub(crate) struct GlyphPathBuilder {
    pub path: PathStorage,
    ox: f64,
    oy: f64,
    scale: f64,
    /// Horizontal-only outline scale.  Default `1.0`.
    width_scale: f64,
    /// Italic shear factor (x += y * italic_shear).  Default `0.0`.
    italic_shear: f64,
    pub has_outline: bool,
}

impl GlyphPathBuilder {
    pub fn new(ox: f64, oy: f64, scale: f64) -> Self {
        Self {
            path: PathStorage::new(),
            ox,
            oy,
            scale,
            width_scale: 1.0,
            italic_shear: 0.0,
            has_outline: false,
        }
    }

    /// Enable Width + Faux-Italic transforms for this glyph.  `width`
    /// multiplies every outline X after font-scaling; `italic` shears
    /// horizontally proportional to the vertex's Y above the baseline
    /// (positive italic slants top-right, matching the AGG reference).
    #[allow(dead_code)]
    pub fn with_style(mut self, width: f64, italic: f64) -> Self {
        self.width_scale  = width;
        self.italic_shear = italic;
        self
    }

    /// Pixel-space X of a font-unit input vertex.
    ///
    /// `italic_shear` uses the **unsheared** Y (distance above baseline)
    /// so the shear stays consistent whether or not hinting has snapped
    /// the glyph origin — the shear depends on glyph geometry, not on
    /// where the baseline landed on screen.
    #[inline]
    fn x(&self, v: f32, y_raw: f32) -> f64 {
        let base_x = self.ox + v as f64 * self.scale * self.width_scale;
        let shear  = y_raw as f64 * self.scale * self.italic_shear;
        base_x + shear
    }
    #[inline]
    fn y(&self, v: f32) -> f64 { self.oy + v as f64 * self.scale }
}

impl ttf_parser::OutlineBuilder for GlyphPathBuilder {
    fn move_to(&mut self, x: f32, y: f32) {
        self.path.move_to(self.x(x, y), self.y(y));
        self.has_outline = true;
    }
    fn line_to(&mut self, x: f32, y: f32) {
        self.path.line_to(self.x(x, y), self.y(y));
    }
    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
        self.path.curve3(self.x(x1, y1), self.y(y1), self.x(x, y), self.y(y));
    }
    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
        self.path.curve4(
            self.x(x1, y1), self.y(y1),
            self.x(x2, y2), self.y(y2),
            self.x(x,  y),  self.y(y),
        );
    }
    fn close(&mut self) {
        self.path.close_polygon(PATH_FLAGS_NONE);
    }
}

// ---------------------------------------------------------------------------
// Shaping helper — shapes text and returns per-glyph paths
// ---------------------------------------------------------------------------

/// Shape `text` with `font` at `size` pixels, starting at screen position
/// `(x, y)` (baseline-left, Y-up). Returns one `PathStorage` per glyph that
/// has an outline (spaces and control chars yield no path).
///
/// Walks the fallback font chain via [`shape_glyphs`], so Font Awesome /
/// emoji glyphs not present in the primary font are still resolved and
/// rasterized using the font they live in.
/// Apply the "faux weight" outline offset to a glyph path.
///
/// Port of the AGG C++ `truetype_lcd.cpp` technique:
/// ```text
///   curves -> scale(1, 100) -> ConvContour(width=w) -> scale(1, 1/100)
/// ```
/// The Y-zoom makes the contour offset act primarily horizontally —
/// vertical stems pick up the full `w` of extra thickness while
/// horizontal strokes stay thin, which is what you want for bold-like
/// weight.  Returns a fresh `PathStorage` containing the offset outline
/// flattened to straight segments (ConvCurve has already subdivided the
/// Béziers by the time ConvContour sees them).
///
/// `weight_px` is the raw contour width — matches the agg-rust
/// `contour.set_width(-faux_weight * height / 15.0)` convention; pass
/// the already-sign-flipped, already-scaled value.
fn apply_faux_weight(path: PathStorage, weight_px: f64) -> PathStorage {
    if weight_px.abs() < 1e-4 { return path; }
    let mut src = path;
    let mut curves    = ConvCurve::new(&mut src);
    let zoom_in       = TransAffine::new_scaling(1.0, 100.0);
    let mut zoomed_in = ConvTransform::new(&mut curves, zoom_in);
    let mut contour   = ConvContour::new(&mut zoomed_in);
    contour.set_auto_detect_orientation(false);
    contour.set_width(weight_px);
    let zoom_out      = TransAffine::new_scaling(1.0, 1.0 / 100.0);
    let mut out       = ConvTransform::new(&mut contour, zoom_out);

    // Flatten the VertexSource chain into a fresh PathStorage.  ConvCurve
    // has converted all Béziers to line-segments by the time we get here,
    // so the output is only `move_to` / `line_to` / `end_poly` commands.
    let mut result = PathStorage::new();
    out.rewind(0);
    loop {
        let (mut vx, mut vy) = (0.0_f64, 0.0_f64);
        let cmd = out.vertex(&mut vx, &mut vy);
        if is_stop(cmd) { break; }
        if is_move_to(cmd) {
            result.move_to(vx, vy);
        } else if cmd == PATH_CMD_LINE_TO {
            result.line_to(vx, vy);
        } else if is_end_poly(cmd) {
            result.close_polygon(PATH_FLAGS_NONE);
        }
    }
    result
}

pub(crate) fn shape_text(
    font: &Font,
    text: &str,
    size: f64,
    x: f64,
    y: f64,
) -> (Vec<PathStorage>, f64) {
    let shaped = shape_glyphs(font, text, size);

    // Pull the current typography-style globals ONCE per call.  The
    // text render path consults them here so any widget (including the
    // LCD Subpixel demo's sliders) that writes through `font_settings`
    // affects the next paint.
    //
    // - `width_scale`  → horizontal outline scale per glyph
    // - `italic_shear` → faux-italic (0..1 range maps to /3 in the
    //   outline shear, matching the agg-rust reference)
    // - `hint_y`       → snap the glyph-origin Y to whole pixels
    //                    (Y-axis-only hinting, matches `(y+0.5).floor()`)
    // - `interval_px`  → extra pen advance in pixels per glyph,
    //                    proportional to em size
    let width_scale  = crate::font_settings::current_width();
    let italic_shear = crate::font_settings::current_faux_italic() / 3.0;
    let hint_y       = crate::font_settings::hinting_enabled();
    let interval_em  = crate::font_settings::current_interval();
    let interval_px  = interval_em * size;
    // Faux weight — negative sign matches agg-rust: +faux_weight
    // thickens (contour width negative expands outward for a CCW
    // outline), -faux_weight thins.  The `/15.0` denominator reproduces
    // the reference demo's slider-to-pixels conversion.
    let faux_weight  = crate::font_settings::current_faux_weight();
    let weight_px    = if faux_weight.abs() < 0.05 {
        0.0  // dead zone near 0, matches reference — avoids zero-width noise
    } else {
        -faux_weight * size / 15.0
    };

    let mut paths = Vec::new();
    let mut pen_x = x;
    let mut total_advance = 0.0;

    for g in &shaped {
        let gx = pen_x + g.x_offset;
        let gy_unsnapped = y + g.y_offset;
        // Hinting: snap the glyph origin's Y to the integer pixel
        // nearest the logical baseline.  Matches the AGG C++
        // `(y + 0.5).floor()` convention — simple, cheap, preserves
        // horizontal subpixel positioning.
        let gy = if hint_y {
            (gy_unsnapped + 0.5).floor()
        } else {
            gy_unsnapped
        };
        // glyph_id indexes into whichever font resolved the code point.
        let render_font = g.fallback_font.as_deref().unwrap_or(font);
        let scale = size / render_font.units_per_em() as f64;

        let mut builder = GlyphPathBuilder::new(gx, gy, scale)
            .with_style(width_scale, italic_shear);
        let has_outline = render_font.with_ttf_face(|face| {
            face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut builder)
                .is_some()
        });
        if has_outline && builder.has_outline {
            // Apply faux weight (zero-cost pass-through at weight_px == 0).
            let path = apply_faux_weight(builder.path, weight_px);
            paths.push(path);
        }

        // Interval adds a fixed pen-advance delta per glyph, in pixels.
        // Applied after the font-native advance so kerning (already
        // baked into x_advance by rustybuzz) is preserved — the extra
        // spacing just piles on top.
        let advance = g.x_advance + interval_px;
        pen_x += advance;
        total_advance += advance;
    }
    (paths, total_advance)
}

// ---------------------------------------------------------------------------
// Glyph cache support — shaped glyph info + single-glyph outline extraction
// ---------------------------------------------------------------------------

/// Position and identity of one shaped glyph, without any rendering.
///
/// Returned by [`shape_glyphs`].  All distances are in **pixels** at the
/// requested font size.
///
/// When `fallback_font` is `Some`, the glyph was resolved from the fallback
/// font rather than the primary.  Callers must use that font for outline
/// extraction and glyph cache lookups, since `glyph_id` is an index into
/// the fallback's glyph table, not the primary's.
#[derive(Clone)]
pub struct ShapedGlyph {
    /// Index into the font's glyph table (or fallback's if `fallback_font` is Some).
    pub glyph_id: u16,
    /// How far to advance the pen after this glyph.
    pub x_advance: f64,
    /// Horizontal offset from the pen position to this glyph's origin.
    pub x_offset: f64,
    /// Vertical offset from the baseline to this glyph's origin.
    pub y_offset: f64,
    /// Set when this glyph was resolved via the fallback font.
    /// Use this font instead of the primary for cache lookups and rendering.
    pub fallback_font: Option<Arc<Font>>,
}

/// Shape `text` and return per-glyph positioning info, with **no** outline
/// extraction or tessellation.
///
/// Results are cached in a thread-local `HashMap` keyed by
/// `(font_data_ptr, text, size_bits)`.  The GL `fill_text()` path calls this
/// on every paint; caching it eliminates the per-frame `rustybuzz::shape()`
/// cost for static labels and sidebar items.
///
/// Use the result together with [`flatten_glyph_at_origin`] and a
/// [`GlyphCache`] to avoid re-tessellating glyphs every frame.
pub fn shape_glyphs(font: &Font, text: &str, size: f64) -> Vec<ShapedGlyph> {
    let font_key = Arc::as_ptr(&font.data) as usize;
    let size_key = size.to_bits();

    SHAPE_CACHE.with(|cache| {
        {
            let c = cache.borrow();
            if let Some(cached) = c.get(&(font_key, text.to_owned(), size_key)) {
                return cached.clone();
            }
        }

        // Cache miss — shape the text.
        let scale = size / font.units_per_em() as f64;
        let glyphs = font.with_rb_face(|face| {
            let mut buffer = rustybuzz::UnicodeBuffer::new();
            buffer.push_str(text);
            let output = rustybuzz::shape(face, &[], buffer);
            output
                .glyph_infos()
                .iter()
                .zip(output.glyph_positions().iter())
                .map(|(info, pos)| {
                    let glyph_id  = info.glyph_id as u16;
                    let x_advance = pos.x_advance as f64 * scale;
                    let x_offset  = pos.x_offset  as f64 * scale;
                    let y_offset  = pos.y_offset  as f64 * scale;

                    // glyph_id == 0 means the primary font has no glyph for
                    // this code point.  Walk the fallback chain until a font
                    // with a matching glyph is found.
                    if glyph_id == 0 {
                        let byte_off = info.cluster as usize;
                        if let Some(ch) = text.get(byte_off..).and_then(|s| s.chars().next()) {
                            let mut cur_fb = font.fallback.as_ref();
                            while let Some(fb) = cur_fb {
                                let fb_id = fb.with_ttf_face(|f| {
                                    f.glyph_index(ch).map(|g| g.0).unwrap_or(0)
                                });
                                if fb_id != 0 {
                                    let fb_scale = size / fb.units_per_em() as f64;
                                    let fb_adv = fb.with_ttf_face(|f| {
                                        f.glyph_hor_advance(ttf_parser::GlyphId(fb_id))
                                            .map(|a| a as f64 * fb_scale)
                                            .unwrap_or(0.0)
                                    });
                                    return ShapedGlyph {
                                        glyph_id: fb_id,
                                        x_advance: fb_adv,
                                        x_offset,
                                        y_offset,
                                        fallback_font: Some(Arc::clone(fb)),
                                    };
                                }
                                cur_fb = fb.fallback.as_ref();
                            }
                        }
                    }

                    ShapedGlyph { glyph_id, x_advance, x_offset, y_offset,
                                  fallback_font: None }
                })
                .collect::<Vec<_>>()
        });

        cache.borrow_mut().insert((font_key, text.to_owned(), size_key), glyphs.clone());
        glyphs
    })
}

/// Flatten a single glyph's outline using AGG `ConvCurve`, with the glyph
/// origin at **(0, 0)** in pixel space.
///
/// Returns one `Vec<[f32;2]>` per closed contour, ready to pass to
/// `tessellate_fill`.  Returns `None` for glyphs without an outline (space,
/// tab, or glyph IDs that reference nothing).
///
/// The vertices are in **glyph-local pixels**: the glyph baseline is y=0 and
/// the leftmost bearing is x=0 (approximately).  To place the glyph on screen
/// at `(gx, gy)`, translate every vertex by that amount before tessellating or
/// uploading to the GPU.
pub fn flatten_glyph_at_origin(font: &Font, glyph_id: u16, size: f64)
    -> Option<Vec<Vec<[f32; 2]>>>
{
    let scale = size / font.units_per_em() as f64;
    font.with_rb_face(|face| {
        let gid = ttf_parser::GlyphId(glyph_id);
        let mut builder = GlyphPathBuilder::new(0.0, 0.0, scale);
        let has_outline = face.outline_glyph(gid, &mut builder).is_some();
        if !has_outline || !builder.has_outline {
            return None;
        }

        let mut curves = ConvCurve::new(builder.path);
        curves.rewind(0);

        let mut contours: Vec<Vec<[f32; 2]>> = Vec::new();
        let mut current: Vec<[f32; 2]>       = Vec::new();

        loop {
            let (mut cx, mut cy) = (0.0_f64, 0.0_f64);
            let cmd = curves.vertex(&mut cx, &mut cy);
            if is_stop(cmd) { break; }
            if is_move_to(cmd) {
                if current.len() >= 3 {
                    contours.push(std::mem::take(&mut current));
                } else {
                    current.clear();
                }
                current.push([cx as f32, cy as f32]);
            } else if cmd == PATH_CMD_LINE_TO {
                current.push([cx as f32, cy as f32]);
            } else if is_end_poly(cmd) {
                if current.len() >= 3 {
                    contours.push(std::mem::take(&mut current));
                } else {
                    current.clear();
                }
            }
        }
        if current.len() >= 3 {
            contours.push(current);
        }

        if contours.is_empty() { None } else { Some(contours) }
    })
}

/// Measure full text metrics (width, ascent, descent, line_height).
///
/// Useful for external rendering backends (e.g. `GlGfxCtx`) that need
/// text metrics without the `GfxCtx` wrapper.
pub fn measure_text_metrics(font: &Font, text: &str, size: f64) -> TextMetrics {
    TextMetrics {
        width:       measure_advance(font, text, size),
        ascent:      font.ascender_px(size),
        descent:     font.descender_px(size),
        line_height: font.line_height_px(size),
    }
}

// ---------------------------------------------------------------------------
// Global shape/measurement cache — survives across Label instance recreation
// ---------------------------------------------------------------------------
//
// TreeView and other widgets rebuild their Label children every layout() call,
// so a per-Label cache doesn't help: each new instance starts cold. This
// thread-local HashMap caches rustybuzz::shape() results for the lifetime of
// the process, keyed by (font data pointer, text, size bits). The pointer is
// stable as long as any Arc<Vec<u8>> clone exists (which is always true while
// the Font is alive).

use std::cell::RefCell;
use std::collections::HashMap;

thread_local! {
    /// Caches the full rustybuzz shaping output (per-glyph IDs + advances).
    /// Used by shape_glyphs() so fill_text() avoids re-shaping every frame.
    /// Also serves as the measurement cache — measure_advance() reads it too.
    static SHAPE_CACHE: RefCell<HashMap<(usize, String, u64), Vec<ShapedGlyph>>> =
        RefCell::new(HashMap::new());
}

/// Measure text advance width without rasterizing.
///
/// Delegates to [`shape_glyphs`] so that fallback-font advances are included
/// in the measurement.  Results are cached via the shared shape cache.
///
/// The measurement matches what `shape_text` will actually pen at paint
/// time — so `interval` (extra letter-spacing) is added here too.  Width
/// and italic are ignored: width only affects per-glyph outline scale,
/// not advances, and italic shears the outline which doesn't change the
/// horizontal extent of the pen walk.
pub fn measure_advance(font: &Font, text: &str, size: f64) -> f64 {
    let shaped = shape_glyphs(font, text, size);
    let interval_px = crate::font_settings::current_interval() * size;
    shaped.iter().map(|g| g.x_advance + interval_px).sum()
}

#[cfg(test)]
mod tests {
    use super::*;

    const FONT_BYTES: &[u8] =
        include_bytes!("../../demo/assets/CascadiaCode.ttf");
    const FA_BYTES: &[u8] =
        include_bytes!("../../demo/assets/fa.ttf");

    fn test_font() -> Arc<Font> {
        Arc::new(Font::from_slice(FONT_BYTES).expect("font ok"))
    }

    /// Font-Awesome codepoint U+F109 ("fa-laptop") — used by the demo's
    /// backend-panel button label.  The primary font (CascadiaCode) does not
    /// cover the FA range, so the fallback chain must carry it.
    const FA_LAPTOP: &str = "\u{F109}";

    /// A `shape_text` call for a codepoint absent from the primary font must
    /// walk the fallback chain and produce the real glyph outline — not the
    /// primary font's `.notdef` (the tofu box the top screenshot shows).
    #[test]
    fn test_shape_text_renders_fa_icon_via_fallback() {
        let fa = Font::from_slice(FA_BYTES).expect("parse fa.ttf");
        let font = Arc::new(
            Font::from_slice(FONT_BYTES).expect("cc")
                .with_fallback(Arc::new(fa)),
        );

        // shape_glyphs must agree the glyph was resolved via fallback.
        let shaped = shape_glyphs(&font, FA_LAPTOP, 16.0);
        assert_eq!(shaped.len(), 1);
        assert!(
            shaped[0].fallback_font.is_some(),
            "FA codepoint must resolve via fallback font"
        );

        // shape_text must return a non-empty path for that glyph.
        let (paths, _adv) = shape_text(&font, FA_LAPTOP, 16.0, 0.0, 0.0);
        assert_eq!(
            paths.len(),
            1,
            "fallback outline must yield exactly one PathStorage for FA_LAPTOP"
        );
    }

    /// The outline returned by `shape_text` for a codepoint missing from the
    /// primary font must match the fallback font's outline — not the primary
    /// font's `.notdef`.  Compare flattened bounding boxes.
    #[test]
    fn test_shape_text_fa_outline_matches_fallback_font() {
        use agg_rust::conv_curve::ConvCurve;
        use agg_rust::basics::{is_stop, VertexSource};

        let fa_arc = Arc::new(Font::from_slice(FA_BYTES).expect("fa"));
        let font = Arc::new(
            Font::from_slice(FONT_BYTES).expect("cc")
                .with_fallback(Arc::clone(&fa_arc)),
        );

        // Outline via the fallback-aware shape_text.
        let (mut paths, _) = shape_text(&font, FA_LAPTOP, 48.0, 0.0, 0.0);
        assert_eq!(paths.len(), 1);
        let mut curves = ConvCurve::new(&mut paths[0]);
        curves.rewind(0);

        let (mut xmin, mut xmax) = (f64::INFINITY, f64::NEG_INFINITY);
        loop {
            let (mut cx, mut cy) = (0.0, 0.0);
            let cmd = curves.vertex(&mut cx, &mut cy);
            if is_stop(cmd) { break; }
            if cx < xmin { xmin = cx; }
            if cx > xmax { xmax = cx; }
            let _ = cy;
        }
        let width = xmax - xmin;

        // FA's "laptop" glyph is full-width at 48 px; the CascadiaCode .notdef
        // (tofu) is closer to advance-width (~24 px).  A width over 32 px at
        // size 48 proves we took the fallback outline, not .notdef.
        assert!(
            width > 32.0,
            "FA glyph outline width at 48 px was {width:.1} — too narrow, \
             likely still rendering CascadiaCode .notdef instead of FA fallback"
        );
    }

    /// Verify that shape_and_flatten_text produces a sane number of
    /// contour points at typical UI font sizes.
    ///
    /// Before the fix, subdivide_quad tested flatness in font units
    /// (~2048 upm), producing ~1000 sub-divisions per Bézier segment
    /// instead of ~4 — this test would time-out or produce millions of
    /// points under the broken implementation.
    #[test]
    fn test_flatten_point_count_is_sane() {
        let font = test_font();
        let sizes: &[f64] = &[10.0, 13.0, 14.0, 24.0, 34.0];
        let texts: &[&str] = &[
            "Hello",
            "The quick brown fox",
            "Caption — 10px  The quick brown fox",
            "agg-gui",
            "Aa",
        ];

        for &size in sizes {
            for &text in texts {
                let contours =
                    shape_and_flatten_text(&font, text, size, 0.0, 0.0, 0.5);

                let total_pts: usize = contours.iter().map(|c| c.len()).sum();
                let char_count = text.chars().count().max(1);
                let pts_per_char = total_pts / char_count;

                // A well-formed glyph at any typical size should produce
                // between 4 and 300 points per character.  Anything above
                // ~500 means over-subdivision is happening again.
                assert!(
                    pts_per_char <= 500,
                    "size={size} text={text:?}: {pts_per_char} pts/char \
                     (total {total_pts}) — too many, subdivision loop likely"
                );
                assert!(
                    total_pts > 0 || text.trim().is_empty(),
                    "size={size} text={text:?}: zero points produced"
                );
            }
        }
    }

    /// Print raw contour coordinates for a single character.
    #[test]
    fn test_dump_single_char_coords() {
        use crate::gl_renderer::tessellate_fill;
        let font = test_font();
        for ch in ['W', 'i', 'd', 'g', 'e', 't', 's'] {
            let s = ch.to_string();
            let contours = shape_and_flatten_text(&font, &s, 13.0, 10.0, 50.0, 0.5);
            let total: usize = contours.iter().map(|c| c.len()).sum();
            eprintln!("{:?}: {} contours, {} pts", ch, contours.len(), total);
            // Print bounding box of each contour
            for (ci, c) in contours.iter().enumerate() {
                if c.is_empty() { continue; }
                let xs: Vec<f32> = c.iter().map(|p| p[0]).collect();
                let ys: Vec<f32> = c.iter().map(|p| p[1]).collect();
                let xmin = xs.iter().cloned().fold(f32::INFINITY, f32::min);
                let xmax = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
                let ymin = ys.iter().cloned().fold(f32::INFINITY, f32::min);
                let ymax = ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
                eprintln!("  contour {ci}: {}/{} pts  x:[{xmin:.1},{xmax:.1}] y:[{ymin:.1},{ymax:.1}]",
                    c.len(), c.len());
            }
            let result = tessellate_fill(&contours);
            eprintln!("  tess: {:?}", result.as_ref().map(|(v,i)| (v.len()/2, i.len()/3)));
        }
    }

    /// Simulate the text draw calls that happen on the very first WASM
    /// render frame (Basics tab + window visible) and assert the full
    /// pipeline (shape → flatten → tessellate) completes in < 200 ms.
    ///
    /// This test catches both infinite-subdivision loops and algorithmic
    /// slowness that would cause a tab-kill dialog in the browser.
    /// WASM is ~5× slower than native, so 200 ms native ≈ 1 s WASM — fine.
    #[test]
    fn test_first_frame_text_pipeline_is_fast() {
        use crate::gl_renderer::tessellate_fill;
        use std::time::Instant;

        let font = test_font();
        let t0 = Instant::now();

        // All fill_text calls expected on the first rendered frame:
        //   tab bar (TabView), window title + label (Window),
        //   button labels (Button), text field placeholders (TextField).
        let calls: &[(&str, f64)] = &[
            // tab bar labels (13 pt)
            ("Basics",   13.0),
            ("Widgets",  13.0),
            ("Text",     13.0),
            ("Layout",   13.0),
            ("Tree",     13.0),
            // floating window
            ("3D Demo",                  16.0),
            ("WebGL2 — rotating cube",   11.0),
            // Basics tab buttons
            ("Primary Action",  14.0),
            ("Secondary",       14.0),
            ("Destructive",     14.0),
            // text field placeholders
            ("Type something\u{2026}",  14.0),
            ("Another field",           14.0),
        ];

        let mut total_pts  = 0usize;
        let mut total_tris = 0usize;

        for &(text, size) in calls {
            let contours = shape_and_flatten_text(&font, text, size, 10.0, 50.0, 0.5);
            total_pts += contours.iter().map(|c| c.len()).sum::<usize>();

            if let Some((verts, idx)) = tessellate_fill(&contours) {
                total_tris += idx.len() / 3;
                let _ = verts;
            }
        }

        let elapsed = t0.elapsed();

        // Sanity: we should have produced some geometry.
        assert!(total_pts  > 0,  "no contour points produced");
        assert!(total_tris > 0,  "no triangles tessellated");

        // Performance gate: must finish in under 200 ms natively.
        assert!(
            elapsed.as_millis() < 200,
            "first-frame text pipeline took {}ms (pts={total_pts} tris={total_tris}) — \
             too slow, would hang browser (WASM is ~5× slower)",
            elapsed.as_millis()
        );

        eprintln!(
            "first-frame text: {total_pts} pts, {total_tris} tris in {}ms",
            elapsed.as_millis()
        );
    }

    /// Verify shape_glyphs returns the right number of glyphs with positive advances.
    #[test]
    fn test_shape_glyphs_basic() {
        let font = test_font();
        let glyphs = shape_glyphs(&font, "Hi", 14.0);
        assert_eq!(glyphs.len(), 2, "two glyphs for 'Hi'");
        assert!(glyphs[0].x_advance > 0.0, "H has positive advance");
        assert!(glyphs[1].x_advance > 0.0, "i has positive advance");
    }

    /// flatten_glyph_at_origin must produce coords in glyph-local pixel space
    /// (roughly 0..size range), not in font units (hundreds–thousands).
    #[test]
    fn test_flatten_glyph_at_origin_local_coords() {
        let font = test_font();
        let size  = 16.0_f64;
        let glyphs = shape_glyphs(&font, "H", size);
        assert!(!glyphs.is_empty());
        let gid = glyphs[0].glyph_id;

        let contours = flatten_glyph_at_origin(&font, gid, size)
            .expect("'H' must have an outline");
        assert!(!contours.is_empty(), "should produce at least one contour");

        for contour in &contours {
            for &[x, y] in contour {
                assert!(
                    x >= -2.0 && x <= size as f32 + 4.0,
                    "x={x} should be in glyph-local pixels for size={size}"
                );
                assert!(
                    y >= -size as f32 * 0.3 && y <= size as f32 * 1.2,
                    "y={y} should be in glyph-local pixels for size={size}"
                );
            }
        }
    }

    /// Space has no outline; flatten_glyph_at_origin should return None.
    #[test]
    fn test_flatten_glyph_at_origin_space_returns_none() {
        let font   = test_font();
        let glyphs = shape_glyphs(&font, " ", 14.0);
        assert_eq!(glyphs.len(), 1);
        let result = flatten_glyph_at_origin(&font, glyphs[0].glyph_id, 14.0);
        assert!(
            result.is_none(),
            "space glyph should have no outline, got {:?}",
            result.as_ref().map(|c| c.len())
        );
    }

    /// Verify that all contour points are in screen-pixel range for the
    /// given font size (not left in raw font units).
    #[test]
    fn test_flatten_output_is_in_screen_space() {
        let font = test_font();
        // Place text at (100, 200) at size 16.
        let contours =
            shape_and_flatten_text(&font, "Hello", 16.0, 100.0, 200.0, 0.5);

        assert!(!contours.is_empty(), "should produce contours for 'Hello'");

        for (ci, contour) in contours.iter().enumerate() {
            for &[x, y] in contour {
                // Screen-space points should be near (100±50, 200±30) at 16pt.
                // Font-unit coordinates would be in the hundreds–thousands.
                assert!(
                    x > 50.0 && x < 300.0,
                    "contour {ci}: x={x} looks like font units, not screen px"
                );
                assert!(
                    y > 150.0 && y < 280.0,
                    "contour {ci}: y={y} looks like font units, not screen px"
                );
            }
        }
    }
}