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