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