Skip to main content

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