Skip to main content

damascene_core/text/
metrics.rs

1//! Font-backed text measurement and simple word wrapping.
2//!
3//! The production wgpu path uses [`crate::text::atlas::GlyphAtlas`] for
4//! shaping + rasterization; layout, lint, SVG artifacts, and draw-op IR
5//! all share this core layout artifact for measurement. Text — mono
6//! included — is shaped through `cosmic-text` using bundled UI fonts;
7//! the older TTF-advance path remains only as a no-cosmic fallback.
8
9// Lock in full per-item documentation for this module (issue #73).
10#![warn(missing_docs)]
11
12use crate::tokens;
13use crate::tree::{FontFamily, FontWeight, TextWrap};
14use cosmic_text::{
15    Attrs, Buffer, Cursor, Family, FeatureTag, FontFeatures, FontSystem, Metrics, Shaping, Weight,
16    Wrap, fontdb,
17};
18use lru::LruCache;
19use std::cell::RefCell;
20use std::num::NonZeroUsize;
21
22// JetBrains Mono's fixed advance is 600/1000 upm. Only the no-cosmic
23// fallback path uses this; shaped measurement reads real advances.
24const MONO_CHAR_WIDTH_FACTOR: f32 = 0.60;
25
26/// Family to shape with. Mono runs resolve to the bundled monospace
27/// family — mirroring the render path's `RunStyle::mono_family`
28/// default. (A theme's custom `mono_font_family` is not visible to
29/// measurement; the measurement API carries only a `mono` flag.)
30fn shaping_family(family: FontFamily, mono: bool) -> FontFamily {
31    if mono {
32        FontFamily::JetBrainsMono
33    } else {
34        family
35    }
36}
37
38const BASELINE_MULTIPLIER: f32 = 0.93;
39
40/// One measured visual line of a [`TextLayout`].
41#[derive(Clone, Debug, PartialEq)]
42pub struct TextLine {
43    /// The line's text, trailing whitespace trimmed.
44    pub text: String,
45    /// Measured line width in logical pixels.
46    pub width: f32,
47    /// Top offset from the text layout origin, in logical pixels.
48    pub y: f32,
49    /// Baseline offset from the text layout origin, in logical pixels.
50    pub baseline: f32,
51    /// Paragraph direction as resolved by the shaping engine.
52    pub rtl: bool,
53}
54
55/// Measured multi-line text layout. Coordinates are in logical pixels
56/// (y-down) relative to the layout origin — the top-left of the rect
57/// the layout would be drawn into.
58#[derive(Clone, Debug, PartialEq)]
59pub struct TextLayout {
60    /// Visual lines in top-to-bottom order.
61    pub lines: Vec<TextLine>,
62    /// Widest line's width in logical pixels.
63    pub width: f32,
64    /// Total height in logical pixels — at least one `line_height`
65    /// even for empty text.
66    pub height: f32,
67    /// Line height used for this layout, in logical pixels.
68    pub line_height: f32,
69}
70
71impl TextLayout {
72    /// Number of visual lines, counting empty text as one line.
73    pub fn line_count(&self) -> usize {
74        self.lines.len().max(1)
75    }
76
77    /// Collapse to the size-only [`MeasuredText`] summary.
78    pub fn measured(&self) -> MeasuredText {
79        MeasuredText {
80            width: self.width,
81            height: self.height,
82            line_count: self.line_count(),
83        }
84    }
85}
86
87/// Size-only summary of a [`TextLayout`], the artifact layout sizing
88/// consumes.
89#[derive(Clone, Debug, PartialEq)]
90pub struct MeasuredText {
91    /// Widest line's width in logical pixels.
92    pub width: f32,
93    /// Total height in logical pixels.
94    pub height: f32,
95    /// Number of visual lines (at least 1).
96    pub line_count: usize,
97}
98
99/// Per-frame counters for the layout-side text cache.
100///
101/// This tracks [`layout_text_with_line_height_and_family`], the cache
102/// used by layout / draw-op measurement. It does not include backend
103/// paint-side glyph atlas shaping.
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub struct TextLayoutCacheStats {
106    /// Lookups answered from the cache.
107    pub hits: u64,
108    /// Lookups that had to reshape via cosmic-text.
109    pub misses: u64,
110    /// Entries pushed out of the bounded LRU by inserts.
111    pub evictions: u64,
112    /// Total UTF-8 bytes of text shaped on cache misses.
113    pub shaped_bytes: u64,
114}
115
116/// Shared text geometry context for measurement, hit-testing, caret
117/// positioning, and selection rectangles.
118///
119/// This is intentionally a thin value over the existing cosmic-text-backed
120/// helpers: callers spell the text/style/wrap inputs once, then ask the
121/// same context for the geometry operation they need. Keeping these calls
122/// together matters for widgets like `text_input`, `text_area`, and
123/// selectable text, where measurement, hit-testing, caret placement, and
124/// selection bands must agree on font, wrap width, and line metrics.
125#[derive(Clone, Debug, PartialEq)]
126pub struct TextGeometry<'a> {
127    text: &'a str,
128    size: f32,
129    family: FontFamily,
130    weight: FontWeight,
131    mono: bool,
132    tabular: bool,
133    wrap: TextWrap,
134    available_width: Option<f32>,
135    layout: TextLayout,
136}
137
138impl<'a> TextGeometry<'a> {
139    /// Build a geometry context with the default proportional family,
140    /// laying out `text` once up front. `available_width` is the wrap
141    /// width in logical px, used when `wrap == TextWrap::Wrap`.
142    pub fn new(
143        text: &'a str,
144        size: f32,
145        weight: FontWeight,
146        mono: bool,
147        wrap: TextWrap,
148        available_width: Option<f32>,
149    ) -> Self {
150        Self::new_with_family(
151            text,
152            size,
153            FontFamily::default(),
154            weight,
155            mono,
156            wrap,
157            available_width,
158        )
159    }
160
161    /// [`Self::new`] with an explicit proportional font family.
162    pub fn new_with_family(
163        text: &'a str,
164        size: f32,
165        family: FontFamily,
166        weight: FontWeight,
167        mono: bool,
168        wrap: TextWrap,
169        available_width: Option<f32>,
170    ) -> Self {
171        let layout = layout_text_with_family(
172            text,
173            size,
174            family,
175            weight,
176            mono,
177            false,
178            wrap,
179            available_width,
180        );
181        Self {
182            text,
183            size,
184            family,
185            weight,
186            mono,
187            tabular: false,
188            wrap,
189            available_width,
190            layout,
191        }
192    }
193
194    /// Shape with tabular (fixed-width) numerals — OpenType `tnum`.
195    /// Use when the painted text is also tabular
196    /// ([`crate::tree::El::tabular_numerals`]) so caret placement and
197    /// hit-testing agree with the rendered advances. Re-lays-out.
198    pub fn tabular_numerals(mut self) -> Self {
199        self.tabular = true;
200        self.layout = layout_text_with_family(
201            self.text,
202            self.size,
203            self.family,
204            self.weight,
205            self.mono,
206            true,
207            self.wrap,
208            self.available_width,
209        );
210        self
211    }
212
213    /// The source text this context was built over.
214    pub fn text(&self) -> &'a str {
215        self.text
216    }
217
218    /// The layout computed at construction.
219    pub fn layout(&self) -> &TextLayout {
220        &self.layout
221    }
222
223    /// Size-only summary of the layout.
224    pub fn measured(&self) -> MeasuredText {
225        self.layout.measured()
226    }
227
228    /// Line height of the layout, in logical pixels.
229    pub fn line_height(&self) -> f32 {
230        self.layout.line_height
231    }
232
233    /// Widest line's width in logical pixels.
234    pub fn width(&self) -> f32 {
235        self.layout.width
236    }
237
238    /// Total layout height in logical pixels.
239    pub fn height(&self) -> f32 {
240        self.layout.height
241    }
242
243    /// Hit-test a point in layout-origin coordinates (logical px);
244    /// see [`hit_text`].
245    pub fn hit(&self, x: f32, y: f32) -> Option<TextHit> {
246        hit_text_impl(
247            self.text,
248            self.size,
249            self.family,
250            self.weight,
251            self.tabular,
252            self.wrap,
253            self.available_width,
254            x,
255            y,
256        )
257    }
258
259    /// Hit-test and convert the result to a global byte offset in
260    /// `self.text`. This is the shape most widgets want; cosmic-text's
261    /// cursor reports `(line, byte-in-line)` and hard line breaks need to
262    /// be folded back into the original string.
263    pub fn hit_byte(&self, x: f32, y: f32) -> Option<usize> {
264        let hit = self.hit(x, y)?;
265        Some(self.byte_from_line_position(hit.line, hit.byte_index))
266    }
267
268    /// Caret position for a global byte offset, in layout-origin
269    /// logical px; see [`caret_xy`].
270    pub fn caret_xy(&self, byte_index: usize) -> (f32, f32) {
271        caret_xy_impl(
272            self.text,
273            byte_index,
274            self.size,
275            self.family,
276            self.weight,
277            self.tabular,
278            self.wrap,
279            self.available_width,
280        )
281    }
282
283    /// X position of the caret at `byte_index`. For single-line text this
284    /// replaces ad-hoc substring measurement and preserves shaping/kerning
285    /// decisions made by the text engine.
286    pub fn prefix_width(&self, byte_index: usize) -> f32 {
287        self.caret_xy(byte_index).0
288    }
289
290    /// Per-visual-line selection rects for the global byte range
291    /// `lo..hi`; see [`selection_rects`].
292    pub fn selection_rects(&self, lo: usize, hi: usize) -> Vec<(f32, f32, f32, f32)> {
293        selection_rects_impl(
294            self.text,
295            lo,
296            hi,
297            self.size,
298            self.family,
299            self.weight,
300            self.tabular,
301            self.wrap,
302            self.available_width,
303        )
304    }
305
306    fn byte_from_line_position(&self, line: usize, byte_in_line: usize) -> usize {
307        line_position_to_byte(self.text, line, byte_in_line)
308    }
309}
310
311/// Measure text in logical pixels. `available_width` is only used when
312/// `wrap == TextWrap::Wrap`; `None` means measure explicit newlines only.
313pub fn measure_text(
314    text: &str,
315    size: f32,
316    weight: FontWeight,
317    mono: bool,
318    wrap: TextWrap,
319    available_width: Option<f32>,
320) -> MeasuredText {
321    layout_text(text, size, weight, mono, wrap, available_width).measured()
322}
323
324/// Lay out text into measured lines. Coordinates in [`TextLine`] are
325/// relative to the layout origin; callers place the layout inside a
326/// rectangle and apply alignment/vertical centering as needed.
327pub fn layout_text(
328    text: &str,
329    size: f32,
330    weight: FontWeight,
331    mono: bool,
332    wrap: TextWrap,
333    available_width: Option<f32>,
334) -> TextLayout {
335    layout_text_with_family(
336        text,
337        size,
338        FontFamily::default(),
339        weight,
340        mono,
341        false,
342        wrap,
343        available_width,
344    )
345}
346
347/// Lay out text with an explicit proportional UI font family.
348#[allow(clippy::too_many_arguments)]
349pub fn layout_text_with_family(
350    text: &str,
351    size: f32,
352    family: FontFamily,
353    weight: FontWeight,
354    mono: bool,
355    tabular: bool,
356    wrap: TextWrap,
357    available_width: Option<f32>,
358) -> TextLayout {
359    layout_text_with_line_height_and_family(
360        text,
361        size,
362        line_height(size),
363        family,
364        weight,
365        mono,
366        tabular,
367        0.0,
368        wrap,
369        available_width,
370    )
371}
372
373/// Lay out text with an explicit line-height token. This is the
374/// preferred path for styled elements; [`layout_text`] remains the
375/// fallback for arbitrary measurement callers.
376#[allow(clippy::too_many_arguments)]
377pub fn layout_text_with_line_height(
378    text: &str,
379    size: f32,
380    line_height: f32,
381    weight: FontWeight,
382    mono: bool,
383    wrap: TextWrap,
384    available_width: Option<f32>,
385) -> TextLayout {
386    layout_text_with_line_height_and_family(
387        text,
388        size,
389        line_height,
390        FontFamily::default(),
391        weight,
392        mono,
393        false,
394        0.0,
395        wrap,
396        available_width,
397    )
398}
399
400/// [`layout_text_with_line_height`] with an explicit proportional
401/// font family. This is the fully-parameterized entry point all other
402/// layout/measure helpers funnel into, and the one fronted by the
403/// bounded LRU layout cache (see [`take_shape_cache_stats`]).
404#[allow(clippy::too_many_arguments)]
405pub fn layout_text_with_line_height_and_family(
406    text: &str,
407    size: f32,
408    line_height: f32,
409    family: FontFamily,
410    weight: FontWeight,
411    mono: bool,
412    tabular: bool,
413    letter_spacing: f32,
414    wrap: TextWrap,
415    available_width: Option<f32>,
416) -> TextLayout {
417    // Cache by full shaping inputs. The intrinsic measurement pass
418    // walks subtrees recursively at every flex level, so the same text
419    // node easily gets shaped 2 × tree-depth times per frame.
420    // `ellipsize_text_with_family`'s binary search adds further
421    // repetition by reshaping each candidate prefix. The cache
422    // amortizes all of that to a single hash lookup once the layout
423    // has been computed for the first time. `TextLayout` is fully
424    // owned (no borrows back into `FontSystem`), so the cached values
425    // are safe to clone out across frames. Bounded LRU keeps memory
426    // predictable; eviction is benign — the next call recomputes.
427    // Probe with a reused thread-local key: the text is copied into
428    // the scratch key's existing capacity, so cache hits perform zero
429    // heap allocation. Only a miss materializes an owned key for the
430    // insert (same cost as shaping's era anyway).
431    let cached = SHAPE_KEY_SCRATCH.with_borrow_mut(|key| {
432        key.text.clear();
433        key.text.push_str(text);
434        key.size_bits = size.to_bits();
435        key.line_height_bits = line_height.to_bits();
436        key.family = family;
437        key.weight = weight;
438        key.mono = mono;
439        key.tabular = tabular;
440        key.letter_spacing_bits = letter_spacing.to_bits();
441        key.wrap = wrap;
442        key.available_width_bits = available_width.map(f32::to_bits);
443        SHAPE_CACHE.with_borrow_mut(|c| c.get(key).cloned())
444    });
445    if let Some(cached) = cached {
446        SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
447            stats.hits += 1;
448        });
449        return cached;
450    }
451    SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
452        stats.misses += 1;
453        stats.shaped_bytes += text.len() as u64;
454    });
455    let layout = layout_text_uncached(
456        text,
457        size,
458        line_height,
459        family,
460        weight,
461        mono,
462        tabular,
463        letter_spacing,
464        wrap,
465        available_width,
466    );
467    let key = ShapeKey {
468        text: text.to_owned(),
469        size_bits: size.to_bits(),
470        line_height_bits: line_height.to_bits(),
471        family,
472        weight,
473        mono,
474        tabular,
475        letter_spacing_bits: letter_spacing.to_bits(),
476        wrap,
477        available_width_bits: available_width.map(f32::to_bits),
478    };
479    SHAPE_CACHE.with_borrow_mut(|c| {
480        if c.len() == SHAPE_CACHE_CAPACITY {
481            SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
482                stats.evictions += 1;
483            });
484        }
485        c.put(key, layout.clone());
486    });
487    layout
488}
489
490#[allow(clippy::too_many_arguments)]
491fn layout_text_uncached(
492    text: &str,
493    size: f32,
494    line_height: f32,
495    family: FontFamily,
496    weight: FontWeight,
497    mono: bool,
498    tabular: bool,
499    letter_spacing: f32,
500    wrap: TextWrap,
501    available_width: Option<f32>,
502) -> TextLayout {
503    // Mono shapes through cosmic-text too, with the bundled monospace
504    // family — the same face the render path resolves for mono runs —
505    // so measured advances match rendered advances exactly. The
506    // heuristic below survives only as the no-cosmic fallback.
507    if let Some(layout) = layout_text_cosmic(
508        text,
509        size,
510        line_height,
511        shaping_family(family, mono),
512        weight,
513        tabular,
514        letter_spacing,
515        wrap,
516        available_width,
517    ) {
518        return layout;
519    }
520
521    let raw_lines = match (wrap, available_width) {
522        (TextWrap::Wrap, Some(width)) => {
523            wrap_lines_by_width(text, width, size, family, weight, mono)
524        }
525        _ => text.split('\n').map(str::to_string).collect(),
526    };
527    build_layout(raw_lines, size, line_height, family, weight, mono)
528}
529
530/// Return a single-line string that fits within `available_width`,
531/// appending an ellipsis when truncation is needed.
532pub fn ellipsize_text(
533    text: &str,
534    size: f32,
535    weight: FontWeight,
536    mono: bool,
537    available_width: f32,
538) -> String {
539    ellipsize_text_with_family(
540        text,
541        size,
542        FontFamily::default(),
543        weight,
544        mono,
545        false,
546        available_width,
547    )
548}
549
550/// [`ellipsize_text`] with an explicit proportional font family.
551#[allow(clippy::too_many_arguments)]
552pub fn ellipsize_text_with_family(
553    text: &str,
554    size: f32,
555    family: FontFamily,
556    weight: FontWeight,
557    mono: bool,
558    tabular: bool,
559    available_width: f32,
560) -> String {
561    if available_width <= 0.0 || text.is_empty() {
562        return String::new();
563    }
564    let full = layout_text_with_family(
565        text,
566        size,
567        family,
568        weight,
569        mono,
570        tabular,
571        TextWrap::NoWrap,
572        None,
573    );
574    if full.width <= available_width + 0.5 {
575        return text.to_string();
576    }
577
578    let ellipsis = "…";
579    let ellipsis_w = layout_text_with_family(
580        ellipsis,
581        size,
582        family,
583        weight,
584        mono,
585        tabular,
586        TextWrap::NoWrap,
587        None,
588    )
589    .width;
590    if ellipsis_w > available_width + 0.5 {
591        return ellipsis.to_string();
592    }
593
594    let chars: Vec<char> = text.chars().collect();
595    let mut lo = 0usize;
596    let mut hi = chars.len();
597    while lo < hi {
598        let mid = (lo + hi).div_ceil(2);
599        let candidate: String = chars[..mid].iter().collect();
600        let candidate = format!("{candidate}{ellipsis}");
601        let width = layout_text_with_family(
602            &candidate,
603            size,
604            family,
605            weight,
606            mono,
607            tabular,
608            TextWrap::NoWrap,
609            None,
610        )
611        .width;
612        if width <= available_width + 0.5 {
613            lo = mid;
614        } else {
615            hi = mid - 1;
616        }
617    }
618
619    let prefix: String = chars[..lo].iter().collect();
620    format!("{prefix}{ellipsis}")
621}
622
623/// Return wrapped text capped to `max_lines`, ellipsizing the final
624/// visible line when truncation is needed.
625pub fn clamp_text_to_lines(
626    text: &str,
627    size: f32,
628    weight: FontWeight,
629    mono: bool,
630    available_width: f32,
631    max_lines: usize,
632) -> String {
633    clamp_text_to_lines_with_family(
634        text,
635        size,
636        FontFamily::default(),
637        weight,
638        mono,
639        available_width,
640        max_lines,
641    )
642}
643
644/// [`clamp_text_to_lines`] with an explicit proportional font family.
645pub fn clamp_text_to_lines_with_family(
646    text: &str,
647    size: f32,
648    family: FontFamily,
649    weight: FontWeight,
650    mono: bool,
651    available_width: f32,
652    max_lines: usize,
653) -> String {
654    if text.is_empty() || available_width <= 0.0 || max_lines == 0 {
655        return String::new();
656    }
657
658    let layout = layout_text_with_family(
659        text,
660        size,
661        family,
662        weight,
663        mono,
664        false,
665        TextWrap::Wrap,
666        Some(available_width),
667    );
668    if layout.lines.len() <= max_lines {
669        return text.to_string();
670    }
671
672    let mut lines: Vec<String> = layout
673        .lines
674        .iter()
675        .take(max_lines)
676        .map(|line| line.text.clone())
677        .collect();
678    if let Some(last) = lines.last_mut() {
679        let marked = format!("{last}…");
680        *last =
681            ellipsize_text_with_family(&marked, size, family, weight, mono, false, available_width);
682    }
683    lines.join("\n")
684}
685
686/// Result of a click-to-caret hit-test against a laid-out text run.
687/// Coordinates are in byte units within the source text — convertible
688/// to character indices via `text.char_indices()`.
689#[derive(Clone, Copy, Debug, PartialEq, Eq)]
690pub struct TextHit {
691    /// Logical line within the source text (zero-based). For a
692    /// single-line input always 0; for a wrapped paragraph this is
693    /// the visual line index (line breaks introduced by `\n` or by
694    /// soft wrapping both bump it).
695    pub line: usize,
696    /// Byte offset within that logical line's text. Snaps to the
697    /// nearest grapheme boundary cosmic-text reports.
698    pub byte_index: usize,
699}
700
701/// Hit-test a pixel `(x, y)` against the laid-out form of `text` and
702/// return the cursor position the click would land at. Coordinates
703/// are relative to the layout origin (top-left of the rect that the
704/// layout pass would draw the text into). Returns `None` when the
705/// point is above/left of the first glyph; cosmic-text's clamping
706/// behavior places clicks below the last line at end-of-text.
707///
708/// Used by text-input widgets: clicking inside the rect produces a
709/// caret position by routing the local pointer (pointer minus rect
710/// origin) through this function.
711pub fn hit_text(
712    text: &str,
713    size: f32,
714    weight: FontWeight,
715    wrap: TextWrap,
716    available_width: Option<f32>,
717    x: f32,
718    y: f32,
719) -> Option<TextHit> {
720    hit_text_with_family(
721        text,
722        size,
723        FontFamily::default(),
724        weight,
725        wrap,
726        available_width,
727        x,
728        y,
729    )
730}
731
732/// [`hit_text`] with an explicit proportional font family.
733#[allow(clippy::too_many_arguments)]
734pub fn hit_text_with_family(
735    text: &str,
736    size: f32,
737    family: FontFamily,
738    weight: FontWeight,
739    wrap: TextWrap,
740    available_width: Option<f32>,
741    x: f32,
742    y: f32,
743) -> Option<TextHit> {
744    hit_text_impl(
745        text,
746        size,
747        family,
748        weight,
749        false,
750        wrap,
751        available_width,
752        x,
753        y,
754    )
755}
756
757#[allow(clippy::too_many_arguments)]
758fn hit_text_impl(
759    text: &str,
760    size: f32,
761    family: FontFamily,
762    weight: FontWeight,
763    tabular: bool,
764    wrap: TextWrap,
765    available_width: Option<f32>,
766    x: f32,
767    y: f32,
768) -> Option<TextHit> {
769    with_font_system(|font_system| {
770        let buffer = build_buffer(
771            font_system,
772            text,
773            size,
774            family,
775            weight,
776            tabular,
777            wrap,
778            available_width,
779        );
780        let cursor = buffer.hit(x, y)?;
781        Some(TextHit {
782            line: cursor.line,
783            byte_index: cursor.index,
784        })
785    })
786}
787
788/// Pixel position of the caret at byte offset `byte_index` in the
789/// laid-out form of `text`. Coordinates are relative to the layout
790/// origin (top-left of the rect that the layout pass would draw the
791/// text into); `(0.0, 0.0)` is the start of the first line.
792///
793/// Used by multi-line text widgets: the caret bar's `translate()` is
794/// the result of this call. See [`hit_text`] for the inverse.
795///
796/// `byte_index` is interpreted as a byte offset into the source string
797/// where `\n` separates buffer lines. Out-of-range or non-boundary
798/// indices are clamped to the nearest UTF-8 char boundary.
799pub fn caret_xy(
800    text: &str,
801    byte_index: usize,
802    size: f32,
803    weight: FontWeight,
804    wrap: TextWrap,
805    available_width: Option<f32>,
806) -> (f32, f32) {
807    caret_xy_with_family(
808        text,
809        byte_index,
810        size,
811        FontFamily::default(),
812        weight,
813        wrap,
814        available_width,
815    )
816}
817
818/// [`caret_xy`] with an explicit proportional font family.
819pub fn caret_xy_with_family(
820    text: &str,
821    byte_index: usize,
822    size: f32,
823    family: FontFamily,
824    weight: FontWeight,
825    wrap: TextWrap,
826    available_width: Option<f32>,
827) -> (f32, f32) {
828    caret_xy_impl(
829        text,
830        byte_index,
831        size,
832        family,
833        weight,
834        false,
835        wrap,
836        available_width,
837    )
838}
839
840#[allow(clippy::too_many_arguments)]
841fn caret_xy_impl(
842    text: &str,
843    byte_index: usize,
844    size: f32,
845    family: FontFamily,
846    weight: FontWeight,
847    tabular: bool,
848    wrap: TextWrap,
849    available_width: Option<f32>,
850) -> (f32, f32) {
851    let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
852    with_font_system(|font_system| {
853        let line_h = line_height(size);
854        let buffer = build_buffer(
855            font_system,
856            text,
857            size,
858            family,
859            weight,
860            tabular,
861            wrap,
862            available_width,
863        );
864        let cursor = Cursor::new(target_line, byte_in_line);
865        // cosmic-text's Buffer::cursor_position handles the past-end case
866        // (caret after the last glyph on a line) which highlight() omits
867        // because zero-width segments are filtered out.
868        if let Some((x, y)) = buffer.cursor_position(&cursor) {
869            return (x, y);
870        }
871        // Phantom line beyond the last visible run (e.g. caret right
872        // after a trailing `\n`). Position by line index alone.
873        (0.0, target_line as f32 * line_h)
874    })
875}
876
877/// Per-visual-line highlight rectangles for the byte range `lo..hi`.
878/// Each rect is `(x, y, width, height)` in layout-origin coordinates;
879/// the list is empty when `lo >= hi`.
880///
881/// Used by multi-line text widgets to paint the selection band: a
882/// selection that spans three visual lines yields three rectangles
883/// (partial on the first, full on the middle, partial on the last).
884pub fn selection_rects(
885    text: &str,
886    lo: usize,
887    hi: usize,
888    size: f32,
889    weight: FontWeight,
890    wrap: TextWrap,
891    available_width: Option<f32>,
892) -> Vec<(f32, f32, f32, f32)> {
893    selection_rects_with_family(
894        text,
895        lo,
896        hi,
897        size,
898        FontFamily::default(),
899        weight,
900        wrap,
901        available_width,
902    )
903}
904
905/// [`selection_rects`] with an explicit proportional font family.
906#[allow(clippy::too_many_arguments)]
907pub fn selection_rects_with_family(
908    text: &str,
909    lo: usize,
910    hi: usize,
911    size: f32,
912    family: FontFamily,
913    weight: FontWeight,
914    wrap: TextWrap,
915    available_width: Option<f32>,
916) -> Vec<(f32, f32, f32, f32)> {
917    selection_rects_impl(
918        text,
919        lo,
920        hi,
921        size,
922        family,
923        weight,
924        false,
925        wrap,
926        available_width,
927    )
928}
929
930#[allow(clippy::too_many_arguments)]
931fn selection_rects_impl(
932    text: &str,
933    lo: usize,
934    hi: usize,
935    size: f32,
936    family: FontFamily,
937    weight: FontWeight,
938    tabular: bool,
939    wrap: TextWrap,
940    available_width: Option<f32>,
941) -> Vec<(f32, f32, f32, f32)> {
942    if lo >= hi {
943        return Vec::new();
944    }
945    let (lo_line, lo_in_line) = byte_to_line_position(text, lo);
946    let (hi_line, hi_in_line) = byte_to_line_position(text, hi);
947    with_font_system(|font_system| {
948        let buffer = build_buffer(
949            font_system,
950            text,
951            size,
952            family,
953            weight,
954            tabular,
955            wrap,
956            available_width,
957        );
958        let c_lo = Cursor::new(lo_line, lo_in_line);
959        let c_hi = Cursor::new(hi_line, hi_in_line);
960        let mut rects = Vec::new();
961        for run in buffer.layout_runs() {
962            if run.line_i < lo_line || run.line_i > hi_line {
963                continue;
964            }
965            for (x, w) in run.highlight(c_lo, c_hi) {
966                rects.push((x, run.line_top, w, run.line_height));
967            }
968        }
969        rects
970    })
971}
972
973/// Global byte range (`start..end` offsets into `text`) of the visual
974/// line containing `byte_index`. With `wrap == TextWrap::Wrap` the
975/// range respects soft wrap points, so it can be narrower than the
976/// hard (`\n`-delimited) line; used by Home/End and line-wise cursor
977/// movement in text widgets.
978pub fn visual_line_byte_range(
979    text: &str,
980    byte_index: usize,
981    size: f32,
982    weight: FontWeight,
983    wrap: TextWrap,
984    available_width: Option<f32>,
985) -> (usize, usize) {
986    visual_line_byte_range_with_family(
987        text,
988        byte_index,
989        size,
990        FontFamily::default(),
991        weight,
992        wrap,
993        available_width,
994    )
995}
996
997/// [`visual_line_byte_range`] with an explicit proportional font
998/// family.
999pub fn visual_line_byte_range_with_family(
1000    text: &str,
1001    byte_index: usize,
1002    size: f32,
1003    family: FontFamily,
1004    weight: FontWeight,
1005    wrap: TextWrap,
1006    available_width: Option<f32>,
1007) -> (usize, usize) {
1008    let byte_index = clamp_to_char_boundary(text, byte_index.min(text.len()));
1009    let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
1010    let hard_line_start = line_position_to_byte(text, target_line, 0);
1011    let hard_line_end = line_end_byte(text, hard_line_start);
1012    with_font_system(|font_system| {
1013        let buffer = build_buffer(
1014            font_system,
1015            text,
1016            size,
1017            family,
1018            weight,
1019            false,
1020            wrap,
1021            available_width,
1022        );
1023        let mut last_range = None;
1024        for run in buffer.layout_runs() {
1025            if run.line_i != target_line {
1026                continue;
1027            }
1028            let Some((start, end)) = layout_run_byte_range(&run) else {
1029                continue;
1030            };
1031            last_range = Some((start, end));
1032            if start <= byte_in_line && byte_in_line < end {
1033                return (
1034                    line_position_to_byte(text, target_line, start),
1035                    line_position_to_byte(text, target_line, end),
1036                );
1037            }
1038        }
1039        if let Some((start, end)) = last_range
1040            && byte_index >= line_position_to_byte(text, target_line, start)
1041        {
1042            return (
1043                line_position_to_byte(text, target_line, start),
1044                line_position_to_byte(text, target_line, end),
1045            );
1046        }
1047        (hard_line_start, hard_line_end)
1048    })
1049}
1050
1051/// Convert a global byte offset in `text` to the (BufferLine index,
1052/// byte-in-line) pair that cosmic-text uses for cursors. `\n`
1053/// characters are *not* part of any line — they just bump the line
1054/// counter.
1055fn byte_to_line_position(text: &str, byte_index: usize) -> (usize, usize) {
1056    let byte_index = byte_index.min(text.len());
1057    let mut line = 0;
1058    let mut line_start = 0;
1059    for (i, ch) in text.char_indices() {
1060        if i >= byte_index {
1061            break;
1062        }
1063        if ch == '\n' {
1064            line += 1;
1065            line_start = i + ch.len_utf8();
1066        }
1067    }
1068    (line, byte_index - line_start)
1069}
1070
1071fn line_position_to_byte(text: &str, line: usize, byte_in_line: usize) -> usize {
1072    let mut current_line = 0;
1073    let mut line_start = 0;
1074    for (i, ch) in text.char_indices() {
1075        if current_line == line {
1076            let candidate = line_start + byte_in_line;
1077            return clamp_to_char_boundary(text, candidate.min(text.len()));
1078        }
1079        if ch == '\n' {
1080            current_line += 1;
1081            line_start = i + ch.len_utf8();
1082        }
1083    }
1084    if current_line == line {
1085        clamp_to_char_boundary(text, (line_start + byte_in_line).min(text.len()))
1086    } else {
1087        text.len()
1088    }
1089}
1090
1091fn line_end_byte(text: &str, line_start: usize) -> usize {
1092    text[line_start..]
1093        .find('\n')
1094        .map(|i| line_start + i)
1095        .unwrap_or(text.len())
1096}
1097
1098fn layout_run_byte_range(run: &cosmic_text::LayoutRun<'_>) -> Option<(usize, usize)> {
1099    let start = run.glyphs.iter().map(|glyph| glyph.start).min()?;
1100    let end = run
1101        .glyphs
1102        .iter()
1103        .map(|glyph| glyph.end)
1104        .max()
1105        .unwrap_or(start);
1106    Some((start, end))
1107}
1108
1109fn clamp_to_char_boundary(text: &str, mut byte: usize) -> usize {
1110    byte = byte.min(text.len());
1111    while byte > 0 && !text.is_char_boundary(byte) {
1112        byte -= 1;
1113    }
1114    byte
1115}
1116
1117#[allow(clippy::too_many_arguments)]
1118fn build_buffer(
1119    font_system: &mut FontSystem,
1120    text: &str,
1121    size: f32,
1122    family: FontFamily,
1123    weight: FontWeight,
1124    tabular: bool,
1125    wrap: TextWrap,
1126    available_width: Option<f32>,
1127) -> Buffer {
1128    let line_h = line_height(size);
1129    let mut buffer = Buffer::new(font_system, Metrics::new(size, line_h));
1130    buffer.set_wrap(match wrap {
1131        TextWrap::NoWrap => Wrap::None,
1132        TextWrap::Wrap => Wrap::WordOrGlyph,
1133    });
1134    buffer.set_size(
1135        match wrap {
1136            TextWrap::NoWrap => None,
1137            TextWrap::Wrap => available_width,
1138        },
1139        None,
1140    );
1141    let mut attrs = Attrs::new()
1142        .family(Family::Name(family.family_name()))
1143        .weight(cosmic_weight(weight));
1144    if tabular {
1145        attrs = attrs.font_features(tabular_features());
1146    }
1147    buffer.set_text(text, &attrs, Shaping::Advanced, None);
1148    buffer.shape_until_scroll(font_system, false);
1149    buffer
1150}
1151
1152/// Word-wrap text into lines whose measured width stays within
1153/// `max_width` whenever possible. Explicit newlines always split
1154/// paragraphs. Oversized words are split by character.
1155pub fn wrap_lines(
1156    text: &str,
1157    max_width: f32,
1158    size: f32,
1159    weight: FontWeight,
1160    mono: bool,
1161) -> Vec<String> {
1162    wrap_lines_with_family(text, max_width, size, FontFamily::default(), weight, mono)
1163}
1164
1165/// [`wrap_lines`] with an explicit proportional font family.
1166pub fn wrap_lines_with_family(
1167    text: &str,
1168    max_width: f32,
1169    size: f32,
1170    family: FontFamily,
1171    weight: FontWeight,
1172    mono: bool,
1173) -> Vec<String> {
1174    if let Some(layout) = layout_text_cosmic(
1175        text,
1176        size,
1177        line_height(size),
1178        shaping_family(family, mono),
1179        weight,
1180        false,
1181        0.0,
1182        TextWrap::Wrap,
1183        Some(max_width),
1184    ) {
1185        return layout.lines.into_iter().map(|line| line.text).collect();
1186    }
1187    wrap_lines_by_width(text, max_width, size, family, weight, mono)
1188}
1189
1190fn wrap_lines_by_width(
1191    text: &str,
1192    max_width: f32,
1193    size: f32,
1194    family: FontFamily,
1195    weight: FontWeight,
1196    mono: bool,
1197) -> Vec<String> {
1198    if max_width <= 0.0 {
1199        return vec![String::new()];
1200    }
1201
1202    let ctx = WrapMeasure {
1203        max_width,
1204        size,
1205        family,
1206        weight,
1207        mono,
1208    };
1209    let mut out = Vec::new();
1210    for paragraph in text.split('\n') {
1211        if paragraph.is_empty() {
1212            out.push(String::new());
1213            continue;
1214        }
1215
1216        let mut line = String::new();
1217        for word in paragraph.split_whitespace() {
1218            if line.is_empty() {
1219                push_word_wrapped(&mut out, &mut line, word, ctx);
1220                continue;
1221            }
1222
1223            let candidate = format!("{line} {word}");
1224            if line_width_with_family(&candidate, size, family, weight, mono) <= max_width {
1225                line = candidate;
1226            } else {
1227                out.push(std::mem::take(&mut line));
1228                push_word_wrapped(&mut out, &mut line, word, ctx);
1229            }
1230        }
1231
1232        if !line.is_empty() {
1233            out.push(line);
1234        }
1235    }
1236
1237    if out.is_empty() {
1238        out.push(String::new());
1239    }
1240    out
1241}
1242
1243/// Measure one single-line string. Newline characters are ignored; use
1244/// [`measure_text`] for multi-line text.
1245pub fn line_width(text: &str, size: f32, weight: FontWeight, mono: bool) -> f32 {
1246    line_width_with_family(text, size, FontFamily::default(), weight, mono)
1247}
1248
1249/// [`line_width`] with an explicit proportional font family.
1250pub fn line_width_with_family(
1251    text: &str,
1252    size: f32,
1253    family: FontFamily,
1254    weight: FontWeight,
1255    mono: bool,
1256) -> f32 {
1257    if let Some(layout) = layout_text_cosmic(
1258        text,
1259        size,
1260        line_height(size),
1261        shaping_family(family, mono),
1262        weight,
1263        false,
1264        0.0,
1265        TextWrap::NoWrap,
1266        None,
1267    ) {
1268        return layout.width;
1269    }
1270    line_width_by_ttf(text, size, family, weight, mono)
1271}
1272
1273fn line_width_by_ttf(
1274    text: &str,
1275    size: f32,
1276    family: FontFamily,
1277    weight: FontWeight,
1278    mono: bool,
1279) -> f32 {
1280    if mono {
1281        return text
1282            .chars()
1283            .filter(|c| *c != '\n' && *c != '\r')
1284            .map(|c| if c == '\t' { 4.0 } else { 1.0 })
1285            .sum::<f32>()
1286            * size
1287            * MONO_CHAR_WIDTH_FACTOR;
1288    }
1289
1290    let Ok(face) = ttf_parser::Face::parse(font_bytes(family, weight), 0) else {
1291        return fallback_line_width(text, size, mono);
1292    };
1293    let scale = size / face.units_per_em() as f32;
1294    let fallback_advance = face.units_per_em() as f32 * 0.5;
1295    let mut width = 0.0;
1296    let mut prev = None;
1297
1298    for c in text.chars() {
1299        if c == '\n' || c == '\r' {
1300            continue;
1301        }
1302        if c == '\t' {
1303            width += line_width_with_family("    ", size, family, weight, mono);
1304            prev = None;
1305            continue;
1306        }
1307
1308        let Some(glyph) = glyph_for(&face, c) else {
1309            continue;
1310        };
1311        if let Some(left) = prev {
1312            width += kern(&face, left, glyph) * scale;
1313        }
1314        width += face
1315            .glyph_hor_advance(glyph)
1316            .map(|advance| advance as f32)
1317            .unwrap_or(fallback_advance)
1318            * scale;
1319        prev = Some(glyph);
1320    }
1321    width
1322}
1323
1324/// Default line height (logical px) for a font size (logical px).
1325pub fn line_height(size: f32) -> f32 {
1326    // Styled elements carry an explicit `line_height`; this fallback is
1327    // for raw measurement callers and custom `.font_size(...)` values.
1328    // Known design-token sizes return their paired Tailwind/shadcn line
1329    // height, while arbitrary sizes keep a snapped multiplier.
1330    tokens::line_height_for_size(size)
1331}
1332
1333fn build_layout(
1334    lines: Vec<String>,
1335    size: f32,
1336    line_height: f32,
1337    family: FontFamily,
1338    weight: FontWeight,
1339    mono: bool,
1340) -> TextLayout {
1341    let raw_lines = if lines.is_empty() {
1342        vec![String::new()]
1343    } else {
1344        lines
1345    };
1346    let lines: Vec<TextLine> = raw_lines
1347        .into_iter()
1348        .enumerate()
1349        .map(|(i, text)| {
1350            let y = i as f32 * line_height;
1351            TextLine {
1352                width: line_width_with_family(&text, size, family, weight, mono),
1353                text,
1354                y,
1355                baseline: y + size * BASELINE_MULTIPLIER,
1356                rtl: false,
1357            }
1358        })
1359        .collect();
1360    let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1361    TextLayout {
1362        width,
1363        height: lines.len().max(1) as f32 * line_height,
1364        line_height,
1365        lines,
1366    }
1367}
1368
1369/// The OpenType `tnum` (tabular figures) feature set used when a run
1370/// requests tabular numerals. Honoured by fonts carrying the feature
1371/// (the bundled Inter does); a no-op otherwise.
1372pub(crate) fn tabular_features() -> FontFeatures {
1373    let mut features = FontFeatures::new();
1374    features.set(FeatureTag::new(b"tnum"), 1);
1375    features
1376}
1377
1378#[allow(clippy::too_many_arguments)]
1379fn layout_text_cosmic(
1380    text: &str,
1381    size: f32,
1382    line_height: f32,
1383    family: FontFamily,
1384    weight: FontWeight,
1385    tabular: bool,
1386    letter_spacing: f32,
1387    wrap: TextWrap,
1388    available_width: Option<f32>,
1389) -> Option<TextLayout> {
1390    let options = CosmicLayoutOptions {
1391        size,
1392        line_height,
1393        family,
1394        weight,
1395        tabular,
1396        letter_spacing,
1397        wrap,
1398        available_width,
1399    };
1400    with_font_system(|font_system| layout_text_cosmic_with(font_system, text, options))
1401}
1402
1403#[derive(Copy, Clone)]
1404struct CosmicLayoutOptions {
1405    size: f32,
1406    line_height: f32,
1407    family: FontFamily,
1408    weight: FontWeight,
1409    tabular: bool,
1410    letter_spacing: f32,
1411    wrap: TextWrap,
1412    available_width: Option<f32>,
1413}
1414
1415fn layout_text_cosmic_with(
1416    font_system: &mut FontSystem,
1417    text: &str,
1418    options: CosmicLayoutOptions,
1419) -> Option<TextLayout> {
1420    let CosmicLayoutOptions {
1421        size,
1422        line_height,
1423        family,
1424        weight,
1425        tabular,
1426        letter_spacing,
1427        wrap,
1428        available_width,
1429    } = options;
1430    let mut buffer = Buffer::new(font_system, Metrics::new(size, line_height));
1431    buffer.set_wrap(match wrap {
1432        TextWrap::NoWrap => Wrap::None,
1433        TextWrap::Wrap => Wrap::WordOrGlyph,
1434    });
1435    buffer.set_size(
1436        match wrap {
1437            TextWrap::NoWrap => None,
1438            TextWrap::Wrap => available_width,
1439        },
1440        None,
1441    );
1442    let mut attrs = Attrs::new()
1443        .family(Family::Name(family.family_name()))
1444        .weight(cosmic_weight(weight));
1445    if tabular {
1446        attrs = attrs.font_features(tabular_features());
1447    }
1448    if letter_spacing != 0.0 {
1449        // cosmic-text's LetterSpacing is in EM units (added to the
1450        // em-normalized advance before the font-size multiply); our
1451        // param is CSS-like px.
1452        attrs = attrs.letter_spacing(letter_spacing / size);
1453    }
1454    buffer.set_text(text, &attrs, Shaping::Advanced, None);
1455    buffer.shape_until_scroll(font_system, false);
1456
1457    let mut lines = Vec::new();
1458    let mut height: f32 = 0.0;
1459    for run in buffer.layout_runs() {
1460        height = height.max(run.line_top + run.line_height);
1461        lines.push(TextLine {
1462            text: layout_run_text(&run),
1463            width: run.line_w,
1464            y: run.line_top,
1465            baseline: run.line_y,
1466            rtl: run.rtl,
1467        });
1468    }
1469
1470    if lines.is_empty() {
1471        return None;
1472    }
1473
1474    let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1475    Some(TextLayout {
1476        lines,
1477        width,
1478        height: height.max(line_height),
1479        line_height,
1480    })
1481}
1482
1483// `FontSystem` construction loads the bundled UI faces and builds a
1484// fontdb. Doing it per text-shape call burned ~22ms in the layout pass
1485// on the wasm showcase — basically all of it. Cache once per thread;
1486// cosmic-text's internal shape cache also accumulates across calls now,
1487// which is the side benefit.
1488thread_local! {
1489    static FONT_SYSTEM: RefCell<FontSystem> = RefCell::new(bundled_font_system());
1490    /// How many [`crate::text::registry`] fonts this thread's
1491    /// `FontSystem` has loaded; compared against the registry's count
1492    /// on every shape so host-registered fonts reach measurement too.
1493    static FONT_SYSTEM_REGISTRY_LOADED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1494}
1495
1496/// All measurement-side `FontSystem` access funnels through here: sync
1497/// any newly host-registered fonts into this thread's database first
1498/// (issue #56 — paint and measurement must agree on fallback
1499/// coverage), invalidating the shape cache when coverage changed.
1500fn with_font_system<R>(f: impl FnOnce(&mut FontSystem) -> R) -> R {
1501    FONT_SYSTEM.with_borrow_mut(|font_system| {
1502        FONT_SYSTEM_REGISTRY_LOADED.with(|loaded| {
1503            let mut n = loaded.get();
1504            if crate::text::registry::sync_font_system(font_system, &mut n) {
1505                SHAPE_CACHE.with_borrow_mut(|cache| cache.clear());
1506            }
1507            loaded.set(n);
1508        });
1509        f(font_system)
1510    })
1511}
1512
1513/// Number of faces in this thread's measurement `FontSystem`, after a
1514/// registry sync. Test-only observability for the issue-#56 contract
1515/// that host-registered fonts reach measurement.
1516#[cfg(test)]
1517pub(crate) fn font_system_face_count_for_tests() -> usize {
1518    with_font_system(|font_system| font_system.db().len())
1519}
1520
1521/// Cache key for [`layout_text_with_line_height_and_family`]. Captures
1522/// every input that influences the produced [`TextLayout`]; floats are
1523/// stored as `to_bits` so they participate in `Hash` / `Eq` directly.
1524/// Same shape on every entry — no enum variant hidden behind a `Box`,
1525/// since the lookup happens once per text node per frame and we want
1526/// the fast path to be a single hash.
1527#[derive(Clone, PartialEq, Eq, Hash)]
1528struct ShapeKey {
1529    /// `String` (not `Box<str>`) so the thread-local scratch key can
1530    /// reuse its capacity across lookups — hits allocate nothing.
1531    text: String,
1532    size_bits: u32,
1533    line_height_bits: u32,
1534    family: FontFamily,
1535    weight: FontWeight,
1536    mono: bool,
1537    tabular: bool,
1538    letter_spacing_bits: u32,
1539    wrap: TextWrap,
1540    available_width_bits: Option<u32>,
1541}
1542
1543/// Bounded thread-local LRU of shaped layouts. UI chrome only needs a
1544/// few dozen entries, but markdown-heavy conversation views can lower
1545/// one frame into several thousand distinct text/style/width keys.
1546/// Keep enough room for those views to stay warm across repeated
1547/// layout probes; eviction is benign but expensive because the next
1548/// miss reshapes via cosmic.
1549const SHAPE_CACHE_CAPACITY: usize = 16_384;
1550thread_local! {
1551    static SHAPE_CACHE: RefCell<LruCache<ShapeKey, TextLayout>> =
1552        RefCell::new(LruCache::new(NonZeroUsize::new(SHAPE_CACHE_CAPACITY).unwrap()));
1553    static SHAPE_CACHE_STATS: RefCell<TextLayoutCacheStats> =
1554        RefCell::new(TextLayoutCacheStats::default());
1555    /// Reused probe key for [`SHAPE_CACHE`] lookups — see
1556    /// `layout_text_with_line_height_and_family`.
1557    static SHAPE_KEY_SCRATCH: RefCell<ShapeKey> = RefCell::new(ShapeKey {
1558        text: String::new(),
1559        size_bits: 0,
1560        line_height_bits: 0,
1561        family: FontFamily::default(),
1562        weight: FontWeight::Regular,
1563        mono: false,
1564        tabular: false,
1565        letter_spacing_bits: 0,
1566        wrap: TextWrap::NoWrap,
1567        available_width_bits: None,
1568    });
1569}
1570
1571/// Drain layout-side text cache counters accumulated since the previous
1572/// call. Runners call this once per full prepare so diagnostic overlays
1573/// can distinguish cache churn from paint-side work.
1574pub fn take_shape_cache_stats() -> TextLayoutCacheStats {
1575    SHAPE_CACHE_STATS.with_borrow_mut(std::mem::take)
1576}
1577
1578fn bundled_font_system() -> FontSystem {
1579    let mut db = fontdb::Database::new();
1580    db.set_sans_serif_family(FontFamily::default().family_name());
1581    for bytes in damascene_fonts::DEFAULT_FONTS {
1582        db.load_font_data(bytes.to_vec());
1583    }
1584    FontSystem::new_with_locale_and_db("en-US".to_string(), db)
1585}
1586
1587fn cosmic_weight(weight: FontWeight) -> Weight {
1588    match weight {
1589        FontWeight::Regular => Weight::NORMAL,
1590        FontWeight::Medium => Weight::MEDIUM,
1591        FontWeight::Semibold => Weight::SEMIBOLD,
1592        FontWeight::Bold => Weight::BOLD,
1593    }
1594}
1595
1596fn layout_run_text(run: &cosmic_text::LayoutRun<'_>) -> String {
1597    let Some(start) = run.glyphs.iter().map(|glyph| glyph.start).min() else {
1598        return String::new();
1599    };
1600    let end = run
1601        .glyphs
1602        .iter()
1603        .map(|glyph| glyph.end)
1604        .max()
1605        .unwrap_or(start);
1606    run.text
1607        .get(start..end)
1608        .unwrap_or_default()
1609        .trim_end()
1610        .to_string()
1611}
1612
1613#[derive(Copy, Clone)]
1614struct WrapMeasure {
1615    max_width: f32,
1616    size: f32,
1617    family: FontFamily,
1618    weight: FontWeight,
1619    mono: bool,
1620}
1621
1622fn push_word_wrapped(out: &mut Vec<String>, line: &mut String, word: &str, ctx: WrapMeasure) {
1623    let WrapMeasure {
1624        max_width,
1625        size,
1626        family,
1627        weight,
1628        mono,
1629    } = ctx;
1630    if line_width_with_family(word, size, family, weight, mono) <= max_width {
1631        line.push_str(word);
1632        return;
1633    }
1634
1635    for ch in word.chars() {
1636        let candidate = format!("{line}{ch}");
1637        if !line.is_empty()
1638            && line_width_with_family(&candidate, size, family, weight, mono) > max_width
1639        {
1640            out.push(std::mem::take(line));
1641        }
1642        line.push(ch);
1643    }
1644}
1645
1646fn glyph_for(face: &ttf_parser::Face<'_>, c: char) -> Option<ttf_parser::GlyphId> {
1647    face.glyph_index(c)
1648        .or_else(|| face.glyph_index('\u{FFFD}'))
1649        .or_else(|| face.glyph_index('?'))
1650        .or_else(|| face.glyph_index(' '))
1651}
1652
1653fn kern(face: &ttf_parser::Face<'_>, left: ttf_parser::GlyphId, right: ttf_parser::GlyphId) -> f32 {
1654    let Some(kern) = &face.tables().kern else {
1655        return 0.0;
1656    };
1657    kern.subtables
1658        .into_iter()
1659        .filter(|subtable| subtable.horizontal && !subtable.has_cross_stream)
1660        .find_map(|subtable| subtable.glyphs_kerning(left, right))
1661        .map(|value| value as f32)
1662        .unwrap_or(0.0)
1663}
1664
1665fn font_bytes(family: FontFamily, weight: FontWeight) -> &'static [u8] {
1666    // ttf-parser fallback path (used only when cosmic-text is bypassed
1667    // for monospace measurement, etc.). Sourced from damascene-fonts so we
1668    // share one bundle with the cosmic-text path.
1669    match family {
1670        FontFamily::Inter => {
1671            #[cfg(feature = "inter")]
1672            {
1673                let _ = weight;
1674                damascene_fonts::INTER_VARIABLE
1675            }
1676            #[cfg(not(feature = "inter"))]
1677            {
1678                let _ = weight;
1679                &[]
1680            }
1681        }
1682        FontFamily::Roboto => {
1683            #[cfg(feature = "roboto")]
1684            {
1685                match weight {
1686                    FontWeight::Regular => damascene_fonts::ROBOTO_REGULAR,
1687                    FontWeight::Medium => damascene_fonts::ROBOTO_MEDIUM,
1688                    FontWeight::Semibold | FontWeight::Bold => damascene_fonts::ROBOTO_BOLD,
1689                }
1690            }
1691            #[cfg(not(feature = "roboto"))]
1692            {
1693                let _ = weight;
1694                &[]
1695            }
1696        }
1697        FontFamily::JetBrainsMono => {
1698            #[cfg(feature = "jetbrains-mono")]
1699            {
1700                let _ = weight;
1701                damascene_fonts::JETBRAINS_MONO_VARIABLE
1702            }
1703            #[cfg(not(feature = "jetbrains-mono"))]
1704            {
1705                let _ = weight;
1706                &[]
1707            }
1708        }
1709    }
1710}
1711
1712fn fallback_line_width(text: &str, size: f32, mono: bool) -> f32 {
1713    let char_w = size * if mono { MONO_CHAR_WIDTH_FACTOR } else { 0.60 };
1714    text.chars().count() as f32 * char_w
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719    use super::*;
1720
1721    #[test]
1722    fn proportional_measurement_distinguishes_narrow_and_wide_glyphs() {
1723        let narrow = line_width("iiiiii", 16.0, FontWeight::Regular, false);
1724        let wide = line_width("WWWWWW", 16.0, FontWeight::Regular, false);
1725
1726        assert!(wide > narrow * 2.0, "wide={wide} narrow={narrow}");
1727    }
1728
1729    #[test]
1730    fn mono_measurement_uses_real_font_advances() {
1731        // Mono shapes through cosmic-text with the bundled JetBrains
1732        // Mono face now, not a per-char heuristic: equal char counts
1733        // measure equal, and the advance is the font's true 0.60em
1734        // (the retired heuristic said 0.62em — 1.92px wide here).
1735        let narrow = line_width("iiiiii", 16.0, FontWeight::Regular, true);
1736        let wide = line_width("WWWWWW", 16.0, FontWeight::Regular, true);
1737        assert!(
1738            (narrow - wide).abs() < 0.5,
1739            "monospace: narrow={narrow} wide={wide}"
1740        );
1741
1742        let expected = 6.0 * 16.0 * 0.60;
1743        assert!(
1744            (narrow - expected).abs() < 1.0,
1745            "mono advance must be JetBrains Mono's 0.60em: \
1746             measured={narrow} expected={expected}"
1747        );
1748    }
1749
1750    #[test]
1751    fn letter_spacing_changes_shaped_advances() {
1752        // Negative tracking (shadcn tracking-tight on headings) must
1753        // reach the shaper: the tightened line measures narrower, and
1754        // the delta scales with the glyph count.
1755        let natural = layout_text_with_line_height_and_family(
1756            "Grumpy wizards make toxic brew",
1757            24.0,
1758            32.0,
1759            FontFamily::default(),
1760            FontWeight::Semibold,
1761            false,
1762            false,
1763            0.0,
1764            TextWrap::NoWrap,
1765            None,
1766        );
1767        let tight = layout_text_with_line_height_and_family(
1768            "Grumpy wizards make toxic brew",
1769            24.0,
1770            32.0,
1771            FontFamily::default(),
1772            FontWeight::Semibold,
1773            false,
1774            false,
1775            crate::tokens::TRACKING_TIGHT_EM * 24.0,
1776            TextWrap::NoWrap,
1777            None,
1778        );
1779        // 30 glyphs × 0.6 px ≈ 18 px tighter. A unit mix-up (cosmic's
1780        // LetterSpacing is em-scaled, not px) blows far past this band
1781        // — the original bug crushed the line by hundreds of px.
1782        let delta = natural.width - tight.width;
1783        assert!(
1784            (8.0..30.0).contains(&delta),
1785            "tracking-tight must tighten by ~0.6px/glyph: natural {} vs tight {} (delta {delta})",
1786            natural.width,
1787            tight.width
1788        );
1789    }
1790
1791    #[test]
1792    fn tabular_numerals_equalize_digit_advances() {
1793        let width = |text: &str, tabular: bool| {
1794            layout_text_with_family(
1795                text,
1796                16.0,
1797                FontFamily::Inter,
1798                FontWeight::Regular,
1799                false,
1800                tabular,
1801                TextWrap::NoWrap,
1802                None,
1803            )
1804            .width
1805        };
1806        // With tnum, every digit takes the same advance — the
1807        // narrowest and widest digit strings measure identically.
1808        let one = width("11111111", true);
1809        let eight = width("88888888", true);
1810        assert!(
1811            (one - eight).abs() < 0.01,
1812            "tabular digits must align: 1s={one} 8s={eight}"
1813        );
1814        // The flag participates in the shape cache key: the same
1815        // string measures differently with and without it (Inter's
1816        // default figures are proportional — '1' is narrower).
1817        let one_prop = width("11111111", false);
1818        assert!(
1819            one > one_prop + 0.5,
1820            "tabular '1' should be wider than proportional: tnum={one} default={one_prop}"
1821        );
1822    }
1823
1824    #[cfg(feature = "roboto")]
1825    #[test]
1826    fn font_family_changes_proportional_measurement() {
1827        let roboto = line_width_with_family(
1828            "Save changes",
1829            14.0,
1830            FontFamily::Roboto,
1831            FontWeight::Semibold,
1832            false,
1833        );
1834        let inter = line_width_with_family(
1835            "Save changes",
1836            14.0,
1837            FontFamily::Inter,
1838            FontWeight::Semibold,
1839            false,
1840        );
1841
1842        assert!(
1843            (inter - roboto).abs() > 1.0,
1844            "inter={inter} roboto={roboto}"
1845        );
1846    }
1847
1848    #[test]
1849    fn wrap_lines_respects_measured_widths() {
1850        let lines = wrap_lines(
1851            "wide WWW words stay measured",
1852            120.0,
1853            16.0,
1854            FontWeight::Regular,
1855            false,
1856        );
1857
1858        assert!(lines.len() > 1);
1859        for line in lines {
1860            assert!(
1861                line_width(&line, 16.0, FontWeight::Regular, false) <= 121.0,
1862                "{line:?} overflowed"
1863            );
1864        }
1865    }
1866
1867    #[test]
1868    fn layout_text_carries_line_positions_and_measurement() {
1869        let layout = layout_text(
1870            "alpha beta gamma",
1871            16.0,
1872            FontWeight::Regular,
1873            false,
1874            TextWrap::Wrap,
1875            Some(80.0),
1876        );
1877
1878        assert!(layout.lines.len() > 1);
1879        assert_eq!(layout.measured().line_count, layout.lines.len());
1880        assert_eq!(layout.lines[0].y, 0.0);
1881        assert_eq!(layout.lines[1].y, layout.line_height);
1882        assert!(layout.lines[0].baseline > layout.lines[0].y);
1883        assert!(layout.height >= layout.line_height * 2.0);
1884    }
1885
1886    #[test]
1887    fn tokenized_line_heights_match_shadcn_scale() {
1888        assert_eq!(line_height(12.0), 16.0);
1889        assert_eq!(line_height(14.0), 20.0);
1890        assert_eq!(line_height(16.0), 24.0);
1891        assert_eq!(line_height(24.0), 32.0);
1892        assert_eq!(line_height(30.0), 36.0);
1893    }
1894
1895    #[test]
1896    fn hit_text_at_origin_lands_on_first_byte() {
1897        let hit = hit_text(
1898            "hello world",
1899            16.0,
1900            FontWeight::Regular,
1901            TextWrap::NoWrap,
1902            None,
1903            0.0,
1904            8.0,
1905        )
1906        .expect("hit at origin");
1907        assert_eq!(hit.line, 0);
1908        assert_eq!(hit.byte_index, 0);
1909    }
1910
1911    #[test]
1912    fn hit_text_past_last_glyph_clamps_to_end() {
1913        let text = "hello";
1914        // y=8 lands inside the line; a huge x clamps to end-of-line.
1915        let hit = hit_text(
1916            text,
1917            16.0,
1918            FontWeight::Regular,
1919            TextWrap::NoWrap,
1920            None,
1921            1000.0,
1922            8.0,
1923        )
1924        .expect("hit past end");
1925        assert_eq!(hit.line, 0);
1926        assert_eq!(hit.byte_index, text.len());
1927    }
1928
1929    #[test]
1930    fn hit_text_walks_columns_left_to_right() {
1931        // Successive x positions inside the same line should produce
1932        // monotonically non-decreasing byte indices — the basic contract
1933        // a text input relies on for click-to-caret.
1934        let text = "abcdefghij";
1935        let mut prev = 0usize;
1936        for x in [4.0, 16.0, 32.0, 64.0, 96.0] {
1937            let hit = hit_text(
1938                text,
1939                16.0,
1940                FontWeight::Regular,
1941                TextWrap::NoWrap,
1942                None,
1943                x,
1944                8.0,
1945            );
1946            let Some(hit) = hit else { continue };
1947            assert!(
1948                hit.byte_index >= prev,
1949                "byte_index regressed at x={x}: {} < {prev}",
1950                hit.byte_index
1951            );
1952            prev = hit.byte_index;
1953        }
1954    }
1955
1956    #[test]
1957    fn text_geometry_hit_byte_maps_hard_line_offsets_to_source_bytes() {
1958        let text = "alpha\nbeta";
1959        let geometry = TextGeometry::new(
1960            text,
1961            16.0,
1962            FontWeight::Regular,
1963            false,
1964            TextWrap::NoWrap,
1965            None,
1966        );
1967        let y = geometry.line_height() * 1.5;
1968        let byte = geometry.hit_byte(1000.0, y).expect("hit on second line");
1969        assert_eq!(byte, text.len());
1970    }
1971
1972    #[test]
1973    fn text_geometry_prefix_width_matches_caret_x() {
1974        let text = "hello world";
1975        let geometry = TextGeometry::new(
1976            text,
1977            16.0,
1978            FontWeight::Regular,
1979            false,
1980            TextWrap::NoWrap,
1981            None,
1982        );
1983        let (x, _y) = geometry.caret_xy(5);
1984        assert!((geometry.prefix_width(5) - x).abs() < 0.01);
1985    }
1986
1987    #[test]
1988    fn caret_xy_at_origin_is_zero_zero() {
1989        let (x, y) = caret_xy(
1990            "hello",
1991            0,
1992            16.0,
1993            FontWeight::Regular,
1994            TextWrap::NoWrap,
1995            None,
1996        );
1997        assert!(x.abs() < 0.01, "x={x}");
1998        assert_eq!(y, 0.0);
1999    }
2000
2001    #[test]
2002    fn caret_xy_at_end_of_line_is_at_line_width() {
2003        let text = "hello";
2004        let width = line_width(text, 16.0, FontWeight::Regular, false);
2005        let (x, y) = caret_xy(
2006            text,
2007            text.len(),
2008            16.0,
2009            FontWeight::Regular,
2010            TextWrap::NoWrap,
2011            None,
2012        );
2013        assert!((x - width).abs() < 1.0, "x={x} expected~{width}");
2014        assert_eq!(y, 0.0);
2015    }
2016
2017    #[test]
2018    fn caret_xy_drops_to_next_line_after_newline() {
2019        let text = "foo\nbar";
2020        let line_h = line_height(16.0);
2021        // Right after the \n: should land at start of line 1.
2022        let (x, y) = caret_xy(text, 4, 16.0, FontWeight::Regular, TextWrap::NoWrap, None);
2023        assert!(x.abs() < 0.01, "x={x}");
2024        assert!((y - line_h).abs() < 0.01, "y={y} expected~{line_h}");
2025    }
2026
2027    #[test]
2028    fn caret_xy_on_phantom_trailing_line_falls_below_text() {
2029        let text = "foo\n";
2030        let line_h = line_height(16.0);
2031        let (x, y) = caret_xy(
2032            text,
2033            text.len(),
2034            16.0,
2035            FontWeight::Regular,
2036            TextWrap::NoWrap,
2037            None,
2038        );
2039        assert!(x.abs() < 0.01, "x={x}");
2040        assert!(y >= line_h - 0.01, "y={y} expected ≥ line_h={line_h}");
2041    }
2042
2043    #[test]
2044    fn selection_rects_returns_one_per_visual_line() {
2045        let text = "alpha\nbeta\ngamma";
2046        let rects = selection_rects(
2047            text,
2048            0,
2049            text.len(),
2050            16.0,
2051            FontWeight::Regular,
2052            TextWrap::NoWrap,
2053            None,
2054        );
2055        assert_eq!(
2056            rects.len(),
2057            3,
2058            "expected one rect per BufferLine, got {rects:?}"
2059        );
2060        // Rects are ordered top-down.
2061        assert!(rects[0].1 < rects[1].1);
2062        assert!(rects[1].1 < rects[2].1);
2063        for (_x, _y, w, _h) in &rects {
2064            assert!(*w > 0.0, "empty width: {rects:?}");
2065        }
2066    }
2067
2068    #[test]
2069    fn selection_rects_for_single_line_range_do_not_highlight_other_lines() {
2070        let text = "alpha\nbeta\ngamma";
2071        let lo = text.find("et").unwrap();
2072        let hi = lo + "et".len();
2073        let rects = selection_rects(
2074            text,
2075            lo,
2076            hi,
2077            16.0,
2078            FontWeight::Regular,
2079            TextWrap::NoWrap,
2080            None,
2081        );
2082        assert_eq!(
2083            rects.len(),
2084            1,
2085            "single-line range should only highlight that line: {rects:?}"
2086        );
2087        let line_h = line_height(16.0);
2088        let y = rects[0].1;
2089        assert!(
2090            (y - line_h).abs() < 0.01,
2091            "expected second line y={line_h}, got {y}; rects={rects:?}"
2092        );
2093    }
2094
2095    #[test]
2096    fn visual_line_byte_range_respects_soft_wraps() {
2097        let text = "alpha beta gamma";
2098        let beta = text.find("beta").unwrap();
2099        let width = line_width("alpha", 16.0, FontWeight::Regular, false) + 2.0;
2100        let (lo, hi) = visual_line_byte_range(
2101            text,
2102            beta,
2103            16.0,
2104            FontWeight::Regular,
2105            TextWrap::Wrap,
2106            Some(width),
2107        );
2108        assert!(
2109            lo > 0 && hi < text.len(),
2110            "soft-wrapped visual line should be narrower than the hard line: {lo}..{hi}"
2111        );
2112        assert!(
2113            (lo..hi).contains(&beta),
2114            "range {lo}..{hi} should contain beta byte {beta}"
2115        );
2116    }
2117
2118    #[test]
2119    fn selection_rects_empty_for_collapsed_range() {
2120        let rects = selection_rects(
2121            "alpha",
2122            2,
2123            2,
2124            16.0,
2125            FontWeight::Regular,
2126            TextWrap::NoWrap,
2127            None,
2128        );
2129        assert!(rects.is_empty());
2130    }
2131
2132    #[test]
2133    fn proportional_layout_uses_cosmic_shaping_widths() {
2134        let layout = layout_text(
2135            "Roboto shaping",
2136            18.0,
2137            FontWeight::Medium,
2138            false,
2139            TextWrap::NoWrap,
2140            None,
2141        );
2142
2143        assert_eq!(layout.lines.len(), 1);
2144        assert!((layout.lines[0].width - layout.width).abs() < 0.01);
2145        assert!(layout.lines[0].baseline > layout.lines[0].y);
2146    }
2147
2148    #[test]
2149    fn ellipsize_text_shortens_to_available_width() {
2150        let source = "this is a long branch name";
2151        let available = line_width("this is a…", 14.0, FontWeight::Regular, false);
2152        let clipped = ellipsize_text(source, 14.0, FontWeight::Regular, false, available);
2153        let width = line_width(&clipped, 14.0, FontWeight::Regular, false);
2154
2155        assert!(clipped.ends_with('…'), "clipped={clipped}");
2156        assert!(clipped.len() < source.len());
2157        assert!(
2158            width <= available + 0.5,
2159            "width={width} available={available}"
2160        );
2161    }
2162
2163    #[test]
2164    fn ellipsize_text_keeps_fitting_text_unchanged() {
2165        let source = "short";
2166        let available = line_width(source, 14.0, FontWeight::Regular, false) + 4.0;
2167        assert_eq!(
2168            ellipsize_text(source, 14.0, FontWeight::Regular, false, available),
2169            source
2170        );
2171    }
2172
2173    #[test]
2174    fn clamp_text_to_lines_caps_wrapped_text_with_final_ellipsis() {
2175        let source = "alpha beta gamma delta epsilon zeta";
2176        let available = line_width("alpha beta", 14.0, FontWeight::Regular, false);
2177        let clamped = clamp_text_to_lines(source, 14.0, FontWeight::Regular, false, available, 2);
2178        let layout = layout_text(
2179            &clamped,
2180            14.0,
2181            FontWeight::Regular,
2182            false,
2183            TextWrap::Wrap,
2184            Some(available),
2185        );
2186
2187        assert!(clamped.ends_with('…'), "clamped={clamped}");
2188        assert!(layout.lines.len() <= 2, "layout={layout:?}");
2189    }
2190}