Skip to main content

cranpose_ui/text/
measure.rs

1use crate::text_layout_result::TextLayoutResult;
2use cranpose_core::NodeId;
3use std::borrow::Cow;
4use std::cell::{Cell, RefCell};
5use std::collections::{hash_map::Entry, HashMap, VecDeque};
6use std::hash::Hash;
7use std::ops::Range;
8use std::rc::Rc;
9use web_time::Instant;
10
11use super::layout_options::{TextLayoutOptions, TextOverflow};
12use super::paragraph::{Hyphens, LineBreak};
13use super::style::TextStyle;
14
15const ELLIPSIS: &str = "\u{2026}";
16const DEFAULT_FONT_SIZE_SP: f32 = 14.0;
17const WRAP_EPSILON: f32 = 0.5;
18const SCALE_DOWN_SEARCH_STEPS: usize = 14;
19const AUTO_HYPHEN_MIN_SEGMENT_CHARS: usize = 2;
20const AUTO_HYPHEN_MIN_TRAILING_CHARS: usize = 3;
21const AUTO_HYPHEN_PREFERRED_TRAILING_CHARS: usize = 4;
22const TEXT_SERVICE_CACHE_CAPACITY: usize = 8192;
23const TEXT_LAYOUT_TELEMETRY_ENV: &str = "CRANPOSE_TEXT_LAYOUT_TELEMETRY";
24
25fn text_layout_telemetry_enabled() -> bool {
26    cranpose_core::env_flag!(TEXT_LAYOUT_TELEMETRY_ENV)
27}
28
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct TextMetrics {
31    pub width: f32,
32    pub height: f32,
33    /// Height of a single line of text
34    pub line_height: f32,
35    /// Number of lines in the text
36    pub line_count: usize,
37}
38
39#[derive(Clone, Debug, PartialEq)]
40pub struct PreparedTextLayout {
41    pub text: crate::text::AnnotatedString,
42    pub visual_style: TextStyle,
43    pub metrics: TextMetrics,
44    pub did_overflow: bool,
45}
46
47#[derive(Clone, Debug, PartialEq)]
48pub struct TextLinePrefixWidths {
49    prefix_widths: Vec<f32>,
50    separator_before: Vec<f32>,
51    non_empty_overhang: f32,
52}
53
54impl TextLinePrefixWidths {
55    pub fn from_parts(
56        prefix_widths: Vec<f32>,
57        separator_before: Vec<f32>,
58        non_empty_overhang: f32,
59    ) -> Option<Self> {
60        if prefix_widths.is_empty() || prefix_widths.len() != separator_before.len() + 1 {
61            return None;
62        }
63        if prefix_widths
64            .iter()
65            .chain(separator_before.iter())
66            .any(|value| !value.is_finite())
67        {
68            return None;
69        }
70        let non_empty_overhang = non_empty_overhang.max(0.0);
71        if !non_empty_overhang.is_finite() {
72            return None;
73        }
74        Some(Self {
75            prefix_widths,
76            separator_before,
77            non_empty_overhang,
78        })
79    }
80
81    pub fn monospaced(char_count: usize, char_width: f32, letter_spacing: f32) -> Option<Self> {
82        if !char_width.is_finite() || !letter_spacing.is_finite() {
83            return None;
84        }
85        let char_width = char_width.max(0.0);
86        let letter_spacing = letter_spacing.max(0.0);
87        let mut prefix_widths = Vec::with_capacity(char_count + 1);
88        let mut separator_before = Vec::with_capacity(char_count);
89        let mut width = 0.0f32;
90        prefix_widths.push(width);
91        for _ in 0..char_count {
92            // One letter space per character rather than per gap, matching
93            // Minikin's half-a-space-each-side rule, and nothing to drop at a
94            // line's leading edge because there is no kerning here.
95            separator_before.push(0.0);
96            width += char_width + letter_spacing;
97            prefix_widths.push(width);
98        }
99        Self::from_parts(prefix_widths, separator_before, 0.0)
100    }
101
102    pub fn char_count(&self) -> usize {
103        self.separator_before.len()
104    }
105
106    pub fn width_for_char_range(&self, start: usize, end: usize) -> Option<f32> {
107        if start > end || end > self.char_count() {
108            return None;
109        }
110        if start == end {
111            return Some(0.0);
112        }
113        let separator = self.separator_before.get(start).copied().unwrap_or(0.0);
114        Some(
115            (self.prefix_widths[end] - self.prefix_widths[start] - separator).max(0.0)
116                + self.non_empty_overhang,
117        )
118    }
119}
120
121pub trait TextMeasurer: 'static {
122    fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics;
123
124    fn measure_for_node(
125        &self,
126        node_id: Option<NodeId>,
127        text: &crate::text::AnnotatedString,
128        style: &TextStyle,
129    ) -> TextMetrics {
130        let _ = node_id;
131        self.measure(text, style)
132    }
133
134    fn measure_subsequence(
135        &self,
136        text: &crate::text::AnnotatedString,
137        range: Range<usize>,
138        style: &TextStyle,
139    ) -> TextMetrics {
140        self.measure(&text.subsequence(range), style)
141    }
142
143    fn measure_subsequence_for_node(
144        &self,
145        node_id: Option<NodeId>,
146        text: &crate::text::AnnotatedString,
147        range: Range<usize>,
148        style: &TextStyle,
149    ) -> TextMetrics {
150        let _ = node_id;
151        self.measure_subsequence(text, range, style)
152    }
153
154    fn measure_line_prefix_widths(
155        &self,
156        text: &crate::text::AnnotatedString,
157        line_range: Range<usize>,
158        style: &TextStyle,
159    ) -> Option<TextLinePrefixWidths> {
160        let _ = text;
161        let _ = line_range;
162        let _ = style;
163        None
164    }
165
166    fn measure_line_width(
167        &self,
168        text: &crate::text::AnnotatedString,
169        line_range: Range<usize>,
170        style: &TextStyle,
171    ) -> Option<f32> {
172        let _ = text;
173        let _ = line_range;
174        let _ = style;
175        None
176    }
177
178    fn line_height(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> f32 {
179        self.measure(text, style).line_height
180    }
181
182    /// The tight glyph box of a text line inside its `line_height` slot:
183    /// `(top_offset, height)` in logical units, where `height` is the font's
184    /// natural ascent+descent extent and `top_offset` positions it within the
185    /// slot (glyph rows are vertically centered). Selection chrome — the
186    /// highlight, the caret and the finger handles — anchors to THIS box,
187    /// not the full slot: with a paragraph line height above the natural one
188    /// the reference shows gaps between highlighted lines and handles riding
189    /// the glyphs. `None` means the box fills the slot.
190    fn glyph_line_box(&self, style: &TextStyle) -> Option<(f32, f32)> {
191        let _ = style;
192        None
193    }
194
195    /// Distance from the top of a line slot down to that line's baseline, in
196    /// logical units — the number the rasterizer places glyph origins at.
197    ///
198    /// Callers that position text by baseline (rather than by its box) need
199    /// this; `None` means the measurer has no font metrics to answer with.
200    fn first_baseline(&self, style: &TextStyle) -> Option<f32> {
201        let _ = style;
202        None
203    }
204
205    fn line_height_for_node(
206        &self,
207        node_id: Option<NodeId>,
208        text: &crate::text::AnnotatedString,
209        style: &TextStyle,
210    ) -> f32 {
211        let _ = node_id;
212        self.line_height(text, style)
213    }
214
215    fn get_offset_for_position(
216        &self,
217        text: &crate::text::AnnotatedString,
218        style: &TextStyle,
219        x: f32,
220        y: f32,
221    ) -> usize;
222
223    fn get_cursor_x_for_offset(
224        &self,
225        text: &crate::text::AnnotatedString,
226        style: &TextStyle,
227        offset: usize,
228    ) -> f32;
229
230    fn layout(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult;
231
232    /// Returns an alternate break boundary for `Hyphens::Auto` when a greedy break
233    /// split lands in the middle of a word.
234    ///
235    /// `segment_start_char` and `measured_break_char` are character-boundary indices
236    /// in `line` (not byte offsets). Return `None` to delegate to fallback behavior.
237    fn choose_auto_hyphen_break(
238        &self,
239        _line: &str,
240        _style: &TextStyle,
241        _segment_start_char: usize,
242        _measured_break_char: usize,
243    ) -> Option<usize> {
244        None
245    }
246
247    fn measure_with_options(
248        &self,
249        text: &crate::text::AnnotatedString,
250        style: &TextStyle,
251        options: TextLayoutOptions,
252        max_width: Option<f32>,
253    ) -> TextMetrics {
254        self.prepare_with_options(text, style, options, max_width)
255            .metrics
256    }
257
258    fn measure_with_options_for_node(
259        &self,
260        node_id: Option<NodeId>,
261        text: &crate::text::AnnotatedString,
262        style: &TextStyle,
263        options: TextLayoutOptions,
264        max_width: Option<f32>,
265    ) -> TextMetrics {
266        self.prepare_with_options_for_node(node_id, text, style, options, max_width)
267            .metrics
268    }
269
270    fn prepare_with_options(
271        &self,
272        text: &crate::text::AnnotatedString,
273        style: &TextStyle,
274        options: TextLayoutOptions,
275        max_width: Option<f32>,
276    ) -> PreparedTextLayout {
277        self.prepare_with_options_fallback(text, style, options, max_width)
278    }
279
280    fn prepare_with_options_for_node(
281        &self,
282        node_id: Option<NodeId>,
283        text: &crate::text::AnnotatedString,
284        style: &TextStyle,
285        options: TextLayoutOptions,
286        max_width: Option<f32>,
287    ) -> PreparedTextLayout {
288        prepare_text_layout_with_measurer_for_node(self, node_id, text, style, options, max_width)
289    }
290
291    fn prepare_with_options_fallback(
292        &self,
293        text: &crate::text::AnnotatedString,
294        style: &TextStyle,
295        options: TextLayoutOptions,
296        max_width: Option<f32>,
297    ) -> PreparedTextLayout {
298        prepare_text_layout_fallback(self, text, style, options, max_width)
299    }
300}
301
302#[derive(Default)]
303struct MonospacedTextMeasurer;
304
305impl MonospacedTextMeasurer {
306    const DEFAULT_SIZE: f32 = 14.0;
307    const CHAR_WIDTH_RATIO: f32 = 0.6; // Width is 0.6 of Height
308
309    fn get_metrics(style: &TextStyle) -> (f32, f32) {
310        let font_size = style.resolve_font_size(Self::DEFAULT_SIZE);
311        let line_height = style.resolve_line_height(Self::DEFAULT_SIZE, font_size);
312        let letter_spacing = style.resolve_letter_spacing(Self::DEFAULT_SIZE).max(0.0);
313        (
314            (font_size * Self::CHAR_WIDTH_RATIO) + letter_spacing,
315            line_height,
316        )
317    }
318}
319
320impl TextMeasurer for MonospacedTextMeasurer {
321    fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
322        let (char_width, line_height) = Self::get_metrics(style);
323
324        let lines: Vec<&str> = text.text.split('\n').collect();
325        let line_count = lines.len().max(1);
326
327        let width = lines
328            .iter()
329            .map(|line| line.chars().count() as f32 * char_width)
330            .fold(0.0_f32, f32::max);
331
332        TextMetrics {
333            width,
334            height: line_count as f32 * line_height,
335            line_height,
336            line_count,
337        }
338    }
339
340    fn measure_subsequence(
341        &self,
342        text: &crate::text::AnnotatedString,
343        range: Range<usize>,
344        style: &TextStyle,
345    ) -> TextMetrics {
346        let (char_width, line_height) = Self::get_metrics(style);
347        let slice = &text.text[range];
348        let line_count = slice.split('\n').count().max(1);
349        let width = slice
350            .split('\n')
351            .map(|line| line.chars().count() as f32 * char_width)
352            .fold(0.0_f32, f32::max);
353
354        TextMetrics {
355            width,
356            height: line_count as f32 * line_height,
357            line_height,
358            line_count,
359        }
360    }
361
362    fn measure_line_prefix_widths(
363        &self,
364        text: &crate::text::AnnotatedString,
365        line_range: Range<usize>,
366        style: &TextStyle,
367    ) -> Option<TextLinePrefixWidths> {
368        // `get_metrics` has already folded the tracking into its per-character
369        // advance, so hand the table the RAW width and let it add the spacing
370        // once. Passing both double-counted it, and the table's old n-1 rule
371        // then disagreed with `measure`'s n by a whole letter space.
372        let font_size = style.resolve_font_size(Self::DEFAULT_SIZE);
373        let letter_spacing = style.resolve_letter_spacing(Self::DEFAULT_SIZE);
374        TextLinePrefixWidths::monospaced(
375            text.text[line_range].chars().count(),
376            font_size * Self::CHAR_WIDTH_RATIO,
377            letter_spacing,
378        )
379    }
380
381    fn measure_line_width(
382        &self,
383        text: &crate::text::AnnotatedString,
384        line_range: Range<usize>,
385        style: &TextStyle,
386    ) -> Option<f32> {
387        Some(self.measure_subsequence(text, line_range, style).width)
388    }
389
390    fn line_height(&self, _text: &crate::text::AnnotatedString, style: &TextStyle) -> f32 {
391        let (_, line_height) = Self::get_metrics(style);
392        line_height
393    }
394
395    fn get_offset_for_position(
396        &self,
397        text: &crate::text::AnnotatedString,
398        style: &TextStyle,
399        x: f32,
400        y: f32,
401    ) -> usize {
402        let (char_width, line_height) = Self::get_metrics(style);
403
404        if text.text.is_empty() {
405            return 0;
406        }
407
408        let line_index = (y / line_height).floor().max(0.0) as usize;
409        let lines: Vec<&str> = text.text.split('\n').collect();
410        let target_line = line_index.min(lines.len().saturating_sub(1));
411
412        let mut line_start_byte = 0;
413        for line in lines.iter().take(target_line) {
414            line_start_byte += line.len() + 1;
415        }
416
417        let line_text = lines.get(target_line).unwrap_or(&"");
418        let char_index = (x / char_width).round() as usize;
419        let line_char_count = line_text.chars().count();
420        let clamped_index = char_index.min(line_char_count);
421
422        let offset_in_line = line_text
423            .char_indices()
424            .nth(clamped_index)
425            .map(|(i, _)| i)
426            .unwrap_or(line_text.len());
427
428        line_start_byte + offset_in_line
429    }
430
431    fn get_cursor_x_for_offset(
432        &self,
433        text: &crate::text::AnnotatedString,
434        style: &TextStyle,
435        offset: usize,
436    ) -> f32 {
437        let (char_width, _) = Self::get_metrics(style);
438
439        let clamped_offset = offset.min(text.text.len());
440        let char_count = text.text[..clamped_offset].chars().count();
441        char_count as f32 * char_width
442    }
443
444    fn layout(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult {
445        let (char_width, line_height) = Self::get_metrics(style);
446        TextLayoutResult::monospaced(&text.text, char_width, line_height)
447    }
448}
449
450#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
451struct TextBaseCacheKey {
452    text_hash: u64,
453    style_hash: u64,
454}
455
456#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
457struct TextOptionsCacheKey {
458    base: TextBaseCacheKey,
459    options: TextLayoutOptions,
460    max_width_bits: Option<u32>,
461}
462
463/// Key for the prepared-layout cache. Prepared layouts carry the full visual
464/// style (color, brush, decoration, shadow), so unlike the metrics caches —
465/// which are keyed on measurement-affecting attributes only — this key must
466/// also distinguish visual attributes or a color-only restyle would be served
467/// a stale layout.
468#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
469struct TextPreparedCacheKey {
470    base: TextOptionsCacheKey,
471    visual_hash: u64,
472}
473
474struct BoundedTextCache<K, V> {
475    capacity: usize,
476    entries: HashMap<K, V>,
477    order: VecDeque<K>,
478}
479
480impl<K, V> BoundedTextCache<K, V>
481where
482    K: Clone + Eq + Hash,
483    V: Clone,
484{
485    fn new(capacity: usize) -> Self {
486        Self {
487            capacity,
488            entries: HashMap::new(),
489            order: VecDeque::new(),
490        }
491    }
492
493    fn clear(&mut self) {
494        self.entries.clear();
495        self.order.clear();
496    }
497
498    fn get(&self, key: &K) -> Option<V> {
499        self.entries.get(key).cloned()
500    }
501
502    fn insert(&mut self, key: K, value: V) {
503        match self.entries.entry(key.clone()) {
504            Entry::Occupied(mut entry) => {
505                entry.insert(value);
506                return;
507            }
508            Entry::Vacant(_) => {}
509        }
510        if self.entries.len() == self.capacity {
511            while let Some(evicted) = self.order.pop_front() {
512                if self.entries.remove(&evicted).is_some() {
513                    break;
514                }
515            }
516        }
517        self.order.push_back(key.clone());
518        self.entries.insert(key, value);
519    }
520}
521
522pub(crate) struct TextService {
523    generation: Cell<u64>,
524    measurer: RefCell<Rc<dyn TextMeasurer>>,
525    metrics_cache: RefCell<BoundedTextCache<TextBaseCacheKey, TextMetrics>>,
526    options_metrics_cache: RefCell<BoundedTextCache<TextOptionsCacheKey, TextMetrics>>,
527    prepared_cache: RefCell<BoundedTextCache<TextPreparedCacheKey, PreparedTextLayout>>,
528    layout_cache: RefCell<BoundedTextCache<TextBaseCacheKey, TextLayoutResult>>,
529}
530
531impl TextService {
532    pub(crate) fn new() -> Self {
533        Self::from_measurer(Rc::new(MonospacedTextMeasurer))
534    }
535
536    pub(crate) fn from_measurer(measurer: Rc<dyn TextMeasurer>) -> Self {
537        Self {
538            generation: Cell::new(1),
539            measurer: RefCell::new(measurer),
540            metrics_cache: RefCell::new(BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY)),
541            options_metrics_cache: RefCell::new(BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY)),
542            prepared_cache: RefCell::new(BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY)),
543            layout_cache: RefCell::new(BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY)),
544        }
545    }
546
547    pub(crate) fn set_measurer(&self, measurer: Rc<dyn TextMeasurer>) {
548        *self.measurer.borrow_mut() = measurer;
549        self.clear_caches();
550    }
551
552    pub(crate) fn generation(&self) -> u64 {
553        self.generation.get()
554    }
555
556    pub(crate) fn current_measurer(&self) -> Rc<dyn TextMeasurer> {
557        Rc::clone(&self.measurer.borrow())
558    }
559
560    pub(crate) fn with_measurer<R>(&self, f: impl FnOnce(&dyn TextMeasurer) -> R) -> R {
561        let measurer = self.current_measurer();
562        f(&*measurer)
563    }
564
565    pub(crate) fn measure(
566        &self,
567        node_id: Option<NodeId>,
568        text: &crate::text::AnnotatedString,
569        style: &TextStyle,
570    ) -> TextMetrics {
571        let key = text_base_cache_key(text, style);
572        if let Some(metrics) = self.metrics_cache.borrow().get(&key) {
573            return metrics;
574        }
575        let metrics = self.with_measurer(|m| m.measure_for_node(node_id, text, style));
576        self.metrics_cache.borrow_mut().insert(key, metrics);
577        metrics
578    }
579
580    pub(crate) fn measure_with_options(
581        &self,
582        node_id: Option<NodeId>,
583        text: &crate::text::AnnotatedString,
584        style: &TextStyle,
585        options: TextLayoutOptions,
586        max_width: Option<f32>,
587    ) -> TextMetrics {
588        let key = text_options_cache_key(text, style, options.normalized(), max_width);
589        if let Some(metrics) = self.options_metrics_cache.borrow().get(&key) {
590            return metrics;
591        }
592        let metrics = self.with_measurer(|m| {
593            m.measure_with_options_for_node(node_id, text, style, options.normalized(), max_width)
594        });
595        self.options_metrics_cache.borrow_mut().insert(key, metrics);
596        metrics
597    }
598
599    pub(crate) fn prepare_with_options(
600        &self,
601        node_id: Option<NodeId>,
602        text: &crate::text::AnnotatedString,
603        style: &TextStyle,
604        options: TextLayoutOptions,
605        max_width: Option<f32>,
606    ) -> PreparedTextLayout {
607        let metrics_key = text_options_cache_key(text, style, options.normalized(), max_width);
608        let key = TextPreparedCacheKey {
609            base: metrics_key,
610            visual_hash: style.render_hash(),
611        };
612        if let Some(prepared) = self.prepared_cache.borrow().get(&key) {
613            return prepared;
614        }
615        let prepared = self.with_measurer(|m| {
616            m.prepare_with_options_for_node(node_id, text, style, options.normalized(), max_width)
617        });
618        self.prepared_cache
619            .borrow_mut()
620            .insert(key, prepared.clone());
621        self.options_metrics_cache
622            .borrow_mut()
623            .insert(metrics_key, prepared.metrics);
624        prepared
625    }
626
627    pub(crate) fn layout(
628        &self,
629        text: &crate::text::AnnotatedString,
630        style: &TextStyle,
631    ) -> TextLayoutResult {
632        let key = text_base_cache_key(text, style);
633        if let Some(layout) = self.layout_cache.borrow().get(&key) {
634            return layout;
635        }
636        let layout = self.with_measurer(|m| m.layout(text, style));
637        self.layout_cache.borrow_mut().insert(key, layout.clone());
638        layout
639    }
640
641    fn clear_caches(&self) {
642        self.generation
643            .set(self.generation.get().wrapping_add(1).max(1));
644        self.metrics_cache.borrow_mut().clear();
645        self.options_metrics_cache.borrow_mut().clear();
646        self.prepared_cache.borrow_mut().clear();
647        self.layout_cache.borrow_mut().clear();
648    }
649}
650
651fn text_base_cache_key(text: &crate::text::AnnotatedString, style: &TextStyle) -> TextBaseCacheKey {
652    TextBaseCacheKey {
653        text_hash: text.render_hash(),
654        style_hash: style.measurement_hash(),
655    }
656}
657
658fn text_options_cache_key(
659    text: &crate::text::AnnotatedString,
660    style: &TextStyle,
661    options: TextLayoutOptions,
662    max_width: Option<f32>,
663) -> TextOptionsCacheKey {
664    TextOptionsCacheKey {
665        base: text_base_cache_key(text, style),
666        options: options.normalized(),
667        max_width_bits: normalize_max_width(max_width).map(f32::to_bits),
668    }
669}
670
671pub fn set_text_measurer<M: TextMeasurer>(measurer: M) {
672    crate::render_state::set_current_text_measurer(Rc::new(measurer));
673}
674
675pub(crate) fn current_text_generation() -> u64 {
676    crate::render_state::with_text_service(TextService::generation)
677}
678
679pub fn measure_text(text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
680    with_system_font_scale(text, style, |text, style| {
681        crate::render_state::with_text_service(|service| service.measure(None, text, style))
682    })
683}
684
685/// Measures `text` at the sizes `style` literally carries — no system font
686/// scale applied.
687///
688/// Use this for a style that is already resolved, which on the drawing side
689/// means every style: a
690/// [`DrawScope`](cranpose_ui_graphics::DrawScope) text run is described by a
691/// flat [`cranpose_ui_graphics::TextStyle`] whose sizes are final, and a scene
692/// lowers that run with `style.resolved_font_size()` and nothing else. Scaling
693/// here would report a box the rasterizer never fills, and every wrap, centring
694/// and hit rect a caller derives from the measurement would be out by the
695/// setting — on the devices where it is not 1.0 and nowhere else.
696///
697/// The `Text` composable is the other case and keeps [`measure_text`]: its
698/// style carries real [`crate::text::TextUnit::Sp`] sizes, and its prepared
699/// layout hands the SCALED style on to the renderer, so both sides move
700/// together there.
701pub(crate) fn measure_resolved_text(
702    text: &crate::text::AnnotatedString,
703    style: &TextStyle,
704) -> TextMetrics {
705    crate::render_state::with_text_service(|service| service.measure(None, text, style))
706}
707
708/// [`first_baseline`] for an already-resolved style — see
709/// [`measure_resolved_text`] for why the drawing side does not scale.
710pub(crate) fn resolved_first_baseline(style: &TextStyle) -> Option<f32> {
711    crate::render_state::with_text_service(|service| {
712        service.with_measurer(|m| m.first_baseline(style))
713    })
714}
715
716/// The tight glyph box `(top_offset, height)` of a `style` text line inside
717/// its line slot (see [`TextMeasurer::glyph_line_box`]). Falls back to the
718/// full slot when the active measurer has no font metrics.
719pub fn glyph_line_box(style: &TextStyle, line_height: f32) -> (f32, f32) {
720    let style = scale_text_style_font_sizes(style, crate::current_font_scale());
721    crate::render_state::with_text_service(|service| {
722        service.with_measurer(|m| m.glyph_line_box(&style))
723    })
724    .map(|(off, h)| (off.min(line_height), h.min(line_height)))
725    .unwrap_or((0.0, line_height))
726}
727
728/// Distance from the top of a `style` line slot down to its baseline (see
729/// [`TextMeasurer::first_baseline`]). `None` when the active measurer carries
730/// no font metrics.
731pub fn first_baseline(style: &TextStyle) -> Option<f32> {
732    let style = scale_text_style_font_sizes(style, crate::current_font_scale());
733    crate::render_state::with_text_service(|service| {
734        service.with_measurer(|m| m.first_baseline(&style))
735    })
736}
737
738pub fn measure_text_for_node(
739    node_id: Option<NodeId>,
740    text: &crate::text::AnnotatedString,
741    style: &TextStyle,
742) -> TextMetrics {
743    with_system_font_scale(text, style, |text, style| {
744        crate::render_state::with_text_service(|service| service.measure(node_id, text, style))
745    })
746}
747
748pub fn measure_text_with_options(
749    text: &crate::text::AnnotatedString,
750    style: &TextStyle,
751    options: TextLayoutOptions,
752    max_width: Option<f32>,
753) -> TextMetrics {
754    with_system_font_scale(text, style, |text, style| {
755        crate::render_state::with_text_service(|service| {
756            service.measure_with_options(None, text, style, options.normalized(), max_width)
757        })
758    })
759}
760
761pub fn measure_text_with_options_for_node(
762    node_id: Option<NodeId>,
763    text: &crate::text::AnnotatedString,
764    style: &TextStyle,
765    options: TextLayoutOptions,
766    max_width: Option<f32>,
767) -> TextMetrics {
768    with_system_font_scale(text, style, |text, style| {
769        crate::render_state::with_text_service(|service| {
770            service.measure_with_options(node_id, text, style, options.normalized(), max_width)
771        })
772    })
773}
774
775pub fn prepare_text_layout(
776    text: &crate::text::AnnotatedString,
777    style: &TextStyle,
778    options: TextLayoutOptions,
779    max_width: Option<f32>,
780) -> PreparedTextLayout {
781    with_system_font_scale(text, style, |text, style| {
782        crate::render_state::with_text_service(|service| {
783            service.prepare_with_options(None, text, style, options.normalized(), max_width)
784        })
785    })
786}
787
788pub fn prepare_text_layout_for_node(
789    node_id: Option<NodeId>,
790    text: &crate::text::AnnotatedString,
791    style: &TextStyle,
792    options: TextLayoutOptions,
793    max_width: Option<f32>,
794) -> PreparedTextLayout {
795    with_system_font_scale(text, style, |text, style| {
796        crate::render_state::with_text_service(|service| {
797            service.prepare_with_options(node_id, text, style, options.normalized(), max_width)
798        })
799    })
800}
801
802pub fn get_offset_for_position(
803    text: &crate::text::AnnotatedString,
804    style: &TextStyle,
805    x: f32,
806    y: f32,
807) -> usize {
808    with_system_font_scale(text, style, |text, style| {
809        crate::render_state::with_text_measurer(|m| m.get_offset_for_position(text, style, x, y))
810    })
811}
812
813/// Byte offset nearest the local content position (`x`, `y`), **wrap-aware** —
814/// the single hit-test every editable-text pointer path uses (tap-to-place,
815/// drag-select, and selection-handle drag).
816///
817/// It is the inverse of the drawn caret and [`wrapped_line_ranges`]: `y` selects
818/// the VISUAL (wrapped) line (`floor(y / line_height)`), then `x` picks the
819/// nearest char boundary WITHIN that line (delegated to the measurer with `y`
820/// forced to 0). `x`/`y` must already be in text space (padding- and
821/// pan-adjusted). The plain [`get_offset_for_position`] maps `y` through the
822/// measurer's logical `\n` layout, so on wrapped text it lands on the wrong line
823/// (an error that grows with each wrapped line above the finger). With
824/// `wrap_width == None` (single-line fields) this reduces to the one logical
825/// line.
826pub fn offset_for_position_wrapped(
827    text: &str,
828    style: &TextStyle,
829    node_id: Option<NodeId>,
830    wrap_width: Option<f32>,
831    line_height: f32,
832    x: f32,
833    y: f32,
834) -> usize {
835    if text.is_empty() {
836        return 0;
837    }
838    let annotated = crate::text::AnnotatedString::from(text);
839    let line_ranges = wrapped_line_ranges(
840        node_id,
841        &annotated,
842        style,
843        TextLayoutOptions::default(),
844        wrap_width,
845    );
846    if line_ranges.is_empty() {
847        return 0;
848    }
849    let line_idx = if line_height > 0.0 {
850        (y / line_height).floor().max(0.0) as usize
851    } else {
852        0
853    }
854    .min(line_ranges.len() - 1);
855    let range = &line_ranges[line_idx];
856    let line = &text[range.start..range.end];
857    let within = get_offset_for_position(&crate::text::AnnotatedString::from(line), style, x, 0.0);
858    range.start + within.min(line.len())
859}
860
861pub fn get_cursor_x_for_offset(
862    text: &crate::text::AnnotatedString,
863    style: &TextStyle,
864    offset: usize,
865) -> f32 {
866    with_system_font_scale(text, style, |text, style| {
867        crate::render_state::with_text_measurer(|m| m.get_cursor_x_for_offset(text, style, offset))
868    })
869}
870
871pub fn layout_text(text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult {
872    with_system_font_scale(text, style, |text, style| {
873        crate::render_state::with_text_service(|service| service.layout(text, style))
874    })
875}
876
877/// Returns the source-text byte range covered by each **visual** (wrapped) line
878/// when `text` is laid out at `max_width` with `options`, matching the wrapping
879/// the renderer performs. Each range excludes the trailing `\n`. With
880/// `max_width == None` (or soft-wrap disabled) this is just the logical
881/// `\n`-delimited lines.
882///
883/// The text field uses this to place its caret and selection handles on the
884/// correct visual line for wrapped text: the in-content caret otherwise counts
885/// only logical `\n` lines, so a caret on a wrapped line's second visual line is
886/// drawn on the first (and its x, being the whole logical-line prefix width,
887/// runs off the right edge and is clipped), while typing and the magnifier land
888/// on the correct spot.
889pub fn wrapped_line_ranges(
890    node_id: Option<NodeId>,
891    text: &crate::text::AnnotatedString,
892    style: &TextStyle,
893    options: TextLayoutOptions,
894    max_width: Option<f32>,
895) -> Vec<Range<usize>> {
896    with_system_font_scale(text, style, |text, style| {
897        crate::render_state::with_text_measurer(|m| {
898            wrapped_line_ranges_with_measurer(m, node_id, text, style, options, max_width)
899        })
900    })
901}
902
903fn wrapped_line_ranges_with_measurer<M: TextMeasurer + ?Sized>(
904    measurer: &M,
905    _node_id: Option<NodeId>,
906    text: &crate::text::AnnotatedString,
907    style: &TextStyle,
908    options: TextLayoutOptions,
909    max_width: Option<f32>,
910) -> Vec<Range<usize>> {
911    let opts = options.normalized();
912    let max_width = normalize_max_width(max_width);
913    // Mirror the wrap decision in `prepare_text_layout_with_measurer_for_node`.
914    let wrap_width = (opts.soft_wrap && opts.overflow != TextOverflow::Visible)
915        .then_some(max_width)
916        .flatten();
917    let line_break_mode = style
918        .paragraph_style
919        .line_break
920        .take_or_else(|| LineBreak::Simple);
921    let hyphens_mode = style.paragraph_style.hyphens.take_or_else(|| Hyphens::None);
922
923    let line_ranges = split_line_ranges(text.text.as_str());
924    let Some(width_limit) = wrap_width else {
925        return line_ranges;
926    };
927    let mut ranges = Vec::with_capacity(line_ranges.len());
928    for line_range in line_ranges {
929        for display_line in wrap_line_to_width(
930            measurer,
931            text,
932            line_range,
933            style,
934            width_limit,
935            line_break_mode,
936            hyphens_mode,
937        ) {
938            ranges.push(display_line.source_range.clone());
939        }
940    }
941    ranges
942}
943
944fn prepare_text_layout_fallback<M: TextMeasurer + ?Sized>(
945    measurer: &M,
946    text: &crate::text::AnnotatedString,
947    style: &TextStyle,
948    options: TextLayoutOptions,
949    max_width: Option<f32>,
950) -> PreparedTextLayout {
951    prepare_text_layout_with_measurer_for_node(measurer, None, text, style, options, max_width)
952}
953
954pub fn prepare_text_layout_with_measurer_for_node<M: TextMeasurer + ?Sized>(
955    measurer: &M,
956    node_id: Option<NodeId>,
957    text: &crate::text::AnnotatedString,
958    style: &TextStyle,
959    options: TextLayoutOptions,
960    max_width: Option<f32>,
961) -> PreparedTextLayout {
962    let telemetry = text_layout_telemetry_enabled();
963    let total_start = telemetry.then(Instant::now);
964    let opts = options.normalized();
965    let max_width = normalize_max_width(max_width);
966    if let Some(min_font_size_sp) = opts.overflow.scale_down_min_font_size_sp() {
967        return prepare_scale_down_text_layout(
968            measurer,
969            node_id,
970            text,
971            style,
972            opts,
973            max_width,
974            min_font_size_sp,
975        );
976    }
977
978    let wrap_width = (opts.soft_wrap && opts.overflow != TextOverflow::Visible)
979        .then_some(max_width)
980        .flatten();
981    let line_break_mode = style
982        .paragraph_style
983        .line_break
984        .take_or_else(|| LineBreak::Simple);
985    let hyphens_mode = style.paragraph_style.hyphens.take_or_else(|| Hyphens::None);
986
987    let wrap_start = telemetry.then(Instant::now);
988    let line_ranges = split_line_ranges(text.text.as_str());
989    let source_line_count = line_ranges.len();
990    let mut visible_lines: Vec<DisplayLine>;
991    if let Some(width_limit) = wrap_width {
992        visible_lines = Vec::with_capacity(line_ranges.len());
993        for line_range in line_ranges {
994            let wrapped_lines = wrap_line_to_width(
995                measurer,
996                text,
997                line_range,
998                style,
999                width_limit,
1000                line_break_mode,
1001                hyphens_mode,
1002            );
1003            visible_lines.extend(wrapped_lines);
1004        }
1005    } else {
1006        visible_lines = line_ranges
1007            .into_iter()
1008            .map(DisplayLine::from_source_range)
1009            .collect();
1010    }
1011    let wrap_ms = wrap_start.map(|start| start.elapsed().as_secs_f64() * 1000.0);
1012
1013    let overflow_start = telemetry.then(Instant::now);
1014    let mut did_overflow = false;
1015    if opts.overflow != TextOverflow::Visible && visible_lines.len() > opts.max_lines {
1016        did_overflow = true;
1017        visible_lines.truncate(opts.max_lines);
1018        if let Some(last_line) = visible_lines.last_mut() {
1019            let overflowed = apply_line_overflow(
1020                measurer,
1021                last_line.display_text(text),
1022                style,
1023                max_width,
1024                opts,
1025                true,
1026                true,
1027            );
1028            last_line.apply_display_text(text, overflowed);
1029        }
1030    }
1031
1032    if let Some(width_limit) = max_width {
1033        let single_line_ellipsis = opts.max_lines == 1 || !opts.soft_wrap;
1034        let visible_len = visible_lines.len();
1035        for (line_index, line) in visible_lines.iter_mut().enumerate() {
1036            let width = line.measure_width(measurer, node_id, text, style);
1037            if width > width_limit + WRAP_EPSILON {
1038                if opts.overflow == TextOverflow::Visible {
1039                    continue;
1040                }
1041                did_overflow = true;
1042                let overflowed = apply_line_overflow(
1043                    measurer,
1044                    line.display_text(text),
1045                    style,
1046                    Some(width_limit),
1047                    opts,
1048                    line_index + 1 == visible_len,
1049                    single_line_ellipsis,
1050                );
1051                line.apply_display_text(text, overflowed);
1052            }
1053        }
1054    }
1055    let overflow_ms = overflow_start.map(|start| start.elapsed().as_secs_f64() * 1000.0);
1056
1057    let build_start = telemetry.then(Instant::now);
1058    let display_annotated = build_display_annotated(text, &visible_lines);
1059    debug_assert_eq!(
1060        display_annotated.text,
1061        join_display_line_text(text, &visible_lines)
1062    );
1063    let build_ms = build_start.map(|start| start.elapsed().as_secs_f64() * 1000.0);
1064
1065    let metrics_start = telemetry.then(Instant::now);
1066    let line_height = measurer.line_height_for_node(node_id, text, style).max(0.0);
1067    let display_line_count = visible_lines.len().max(1);
1068    let layout_line_count = display_line_count.max(opts.min_lines);
1069
1070    let measured_width = if visible_lines.is_empty() {
1071        0.0
1072    } else {
1073        visible_lines
1074            .iter()
1075            .map(|line| line.measure_width(measurer, node_id, text, style))
1076            .fold(0.0_f32, f32::max)
1077    };
1078    let metrics_ms = metrics_start.map(|start| start.elapsed().as_secs_f64() * 1000.0);
1079    let width = if opts.overflow == TextOverflow::Visible {
1080        measured_width
1081    } else if let Some(width_limit) = max_width {
1082        measured_width.min(width_limit)
1083    } else {
1084        measured_width
1085    };
1086
1087    let prepared = PreparedTextLayout {
1088        text: display_annotated,
1089        visual_style: style.clone(),
1090        metrics: TextMetrics {
1091            width,
1092            height: layout_line_count as f32 * line_height,
1093            line_height,
1094            line_count: layout_line_count,
1095        },
1096        did_overflow,
1097    };
1098
1099    if let Some(start) = total_start {
1100        eprintln!(
1101            "[text-layout-telemetry] bytes={} spans={} source_lines={} display_lines={} wrap={} max_width={:?} wrap_ms={:.2} overflow_ms={:.2} build_ms={:.2} metrics_ms={:.2} total_ms={:.2}",
1102            text.text.len(),
1103            text.span_styles.len(),
1104            source_line_count,
1105            display_line_count,
1106            wrap_width.is_some(),
1107            max_width,
1108            wrap_ms.unwrap_or(0.0),
1109            overflow_ms.unwrap_or(0.0),
1110            build_ms.unwrap_or(0.0),
1111            metrics_ms.unwrap_or(0.0),
1112            start.elapsed().as_secs_f64() * 1000.0,
1113        );
1114    }
1115
1116    prepared
1117}
1118
1119fn prepare_scale_down_text_layout<M: TextMeasurer + ?Sized>(
1120    measurer: &M,
1121    node_id: Option<NodeId>,
1122    text: &crate::text::AnnotatedString,
1123    style: &TextStyle,
1124    options: TextLayoutOptions,
1125    max_width: Option<f32>,
1126    min_font_size_sp: f32,
1127) -> PreparedTextLayout {
1128    let clipped_options = TextLayoutOptions {
1129        overflow: TextOverflow::Clip,
1130        ..options
1131    }
1132    .normalized();
1133
1134    let full_size = prepare_scaled_text_layout(
1135        measurer,
1136        node_id,
1137        text,
1138        style,
1139        clipped_options,
1140        max_width,
1141        1.0,
1142    );
1143    let Some(width_limit) = max_width else {
1144        return full_size;
1145    };
1146    if !full_size.did_overflow {
1147        return full_size;
1148    }
1149
1150    let base_font_size = style.resolve_font_size(DEFAULT_FONT_SIZE_SP);
1151    if !base_font_size.is_finite() || base_font_size <= 0.0 {
1152        return full_size;
1153    }
1154    let min_scale = (min_font_size_sp.min(base_font_size) / base_font_size).clamp(0.0, 1.0);
1155    if min_scale >= 1.0 {
1156        return full_size;
1157    }
1158
1159    let min_size = prepare_scaled_text_layout(
1160        measurer,
1161        node_id,
1162        text,
1163        style,
1164        clipped_options,
1165        Some(width_limit),
1166        min_scale,
1167    );
1168    if min_size.did_overflow {
1169        return min_size;
1170    }
1171
1172    let mut low = min_scale;
1173    let mut high = 1.0;
1174    let mut best = min_size;
1175    for _ in 0..SCALE_DOWN_SEARCH_STEPS {
1176        let mid = (low + high) * 0.5;
1177        let candidate = prepare_scaled_text_layout(
1178            measurer,
1179            node_id,
1180            text,
1181            style,
1182            clipped_options,
1183            Some(width_limit),
1184            mid,
1185        );
1186        if candidate.did_overflow {
1187            high = mid;
1188        } else {
1189            low = mid;
1190            best = candidate;
1191        }
1192    }
1193
1194    best
1195}
1196
1197fn prepare_scaled_text_layout<M: TextMeasurer + ?Sized>(
1198    measurer: &M,
1199    node_id: Option<NodeId>,
1200    text: &crate::text::AnnotatedString,
1201    style: &TextStyle,
1202    options: TextLayoutOptions,
1203    max_width: Option<f32>,
1204    font_scale: f32,
1205) -> PreparedTextLayout {
1206    let visual_style = scale_text_style_font_sizes(style, font_scale);
1207    let visual_text = scale_annotated_font_sizes(text, font_scale);
1208    prepare_text_layout_with_measurer_for_node(
1209        measurer,
1210        node_id,
1211        visual_text.as_ref(),
1212        &visual_style,
1213        options,
1214        max_width,
1215    )
1216}
1217
1218fn scale_annotated_font_sizes(
1219    text: &crate::text::AnnotatedString,
1220    factor: f32,
1221) -> Cow<'_, crate::text::AnnotatedString> {
1222    if is_identity_scale(factor) || !annotated_text_needs_scaling(text) {
1223        return Cow::Borrowed(text);
1224    }
1225
1226    let mut scaled = text.clone();
1227    for span in &mut scaled.span_styles {
1228        span.item = scale_span_style_font_sizes(&span.item, factor, None);
1229    }
1230    Cow::Owned(scaled)
1231}
1232
1233fn scale_text_style_font_sizes(style: &TextStyle, factor: f32) -> TextStyle {
1234    if is_identity_scale(factor) {
1235        return style.clone();
1236    }
1237
1238    let mut scaled = style.clone();
1239    scaled.span_style =
1240        scale_span_style_font_sizes(&style.span_style, factor, Some(DEFAULT_FONT_SIZE_SP));
1241    scaled.paragraph_style.line_height =
1242        scale_text_unit_sp(scaled.paragraph_style.line_height, factor);
1243    if let Some(mut indent) = scaled.paragraph_style.text_indent {
1244        indent.first_line = scale_text_unit_sp(indent.first_line, factor);
1245        indent.rest_line = scale_text_unit_sp(indent.rest_line, factor);
1246        scaled.paragraph_style.text_indent = Some(indent);
1247    }
1248    scaled
1249}
1250
1251fn with_system_font_scale<R>(
1252    text: &crate::text::AnnotatedString,
1253    style: &TextStyle,
1254    block: impl FnOnce(&crate::text::AnnotatedString, &TextStyle) -> R,
1255) -> R {
1256    let factor = crate::current_font_scale();
1257    let visual_style = scale_text_style_font_sizes(style, factor);
1258    let visual_text = scale_annotated_font_sizes(text, factor);
1259    block(visual_text.as_ref(), &visual_style)
1260}
1261
1262fn scale_span_style_font_sizes(
1263    style: &crate::text::SpanStyle,
1264    factor: f32,
1265    default_font_size_sp: Option<f32>,
1266) -> crate::text::SpanStyle {
1267    let mut scaled = style.clone();
1268    scaled.font_size = match (style.font_size, default_font_size_sp) {
1269        (crate::text::TextUnit::Unspecified, Some(default_size)) => {
1270            crate::text::TextUnit::Sp(default_size * factor)
1271        }
1272        (unit, Some(_)) => scale_text_unit_sp_and_em(unit, factor),
1273        (unit, None) => scale_text_unit_sp(unit, factor),
1274    };
1275    scaled.letter_spacing = scale_text_unit_sp(scaled.letter_spacing, factor);
1276    if let Some(mut shadow) = scaled.shadow {
1277        shadow.offset.x = scale_finite_dimension(shadow.offset.x, factor);
1278        shadow.offset.y = scale_finite_dimension(shadow.offset.y, factor);
1279        shadow.blur_radius = scale_finite_dimension(shadow.blur_radius, factor);
1280        scaled.shadow = Some(shadow);
1281    }
1282    if let Some(crate::text::TextDrawStyle::Stroke { width }) = scaled.draw_style {
1283        scaled.draw_style = Some(crate::text::TextDrawStyle::Stroke {
1284            width: width * factor,
1285        });
1286    }
1287    scaled
1288}
1289
1290fn annotated_text_needs_scaling(text: &crate::text::AnnotatedString) -> bool {
1291    text.span_styles
1292        .iter()
1293        .any(|span| span_style_needs_scaling(&span.item))
1294}
1295
1296fn span_style_needs_scaling(style: &crate::text::SpanStyle) -> bool {
1297    matches!(style.font_size, crate::text::TextUnit::Sp(value) if value.is_finite())
1298        || matches!(style.letter_spacing, crate::text::TextUnit::Sp(value) if value.is_finite())
1299        || matches!(
1300            style.draw_style,
1301            Some(crate::text::TextDrawStyle::Stroke { .. })
1302        )
1303        || style.shadow.is_some()
1304}
1305
1306fn scale_text_unit_sp(unit: crate::text::TextUnit, factor: f32) -> crate::text::TextUnit {
1307    match unit {
1308        crate::text::TextUnit::Sp(value) if value.is_finite() => {
1309            crate::text::TextUnit::Sp(value * factor)
1310        }
1311        other => other,
1312    }
1313}
1314
1315fn scale_text_unit_sp_and_em(unit: crate::text::TextUnit, factor: f32) -> crate::text::TextUnit {
1316    match unit {
1317        crate::text::TextUnit::Sp(value) if value.is_finite() => {
1318            crate::text::TextUnit::Sp(value * factor)
1319        }
1320        crate::text::TextUnit::Em(value) if value.is_finite() => {
1321            crate::text::TextUnit::Em(value * factor)
1322        }
1323        other => other,
1324    }
1325}
1326
1327fn scale_finite_dimension(value: f32, factor: f32) -> f32 {
1328    if value.is_finite() {
1329        value * factor
1330    } else {
1331        value
1332    }
1333}
1334
1335fn is_identity_scale(factor: f32) -> bool {
1336    (factor - 1.0).abs() <= f32::EPSILON
1337}
1338
1339#[derive(Clone, Debug)]
1340enum DisplayLineText {
1341    Source,
1342    Remapped(crate::text::AnnotatedString),
1343}
1344
1345#[derive(Clone, Debug)]
1346struct DisplayLine {
1347    source_range: Range<usize>,
1348    text: DisplayLineText,
1349    measured_width: Option<f32>,
1350}
1351
1352impl DisplayLine {
1353    fn from_source_range(source_range: Range<usize>) -> Self {
1354        Self {
1355            source_range,
1356            text: DisplayLineText::Source,
1357            measured_width: None,
1358        }
1359    }
1360
1361    fn from_measured_source_range(source_range: Range<usize>, measured_width: f32) -> Self {
1362        Self {
1363            source_range,
1364            text: DisplayLineText::Source,
1365            measured_width: measured_width
1366                .is_finite()
1367                .then_some(measured_width.max(0.0)),
1368        }
1369    }
1370
1371    fn display_text<'a>(&'a self, source: &'a crate::text::AnnotatedString) -> &'a str {
1372        match &self.text {
1373            DisplayLineText::Source => &source.text[self.source_range.clone()],
1374            DisplayLineText::Remapped(annotated) => annotated.text.as_str(),
1375        }
1376    }
1377
1378    fn measure_width<M: TextMeasurer + ?Sized>(
1379        &self,
1380        measurer: &M,
1381        node_id: Option<NodeId>,
1382        source: &crate::text::AnnotatedString,
1383        style: &TextStyle,
1384    ) -> f32 {
1385        match &self.text {
1386            DisplayLineText::Source => self.measured_width.unwrap_or_else(|| {
1387                measurer
1388                    .measure_subsequence_for_node(node_id, source, self.source_range.clone(), style)
1389                    .width
1390            }),
1391            DisplayLineText::Remapped(annotated) => {
1392                measurer.measure_for_node(node_id, annotated, style).width
1393            }
1394        }
1395    }
1396
1397    fn apply_display_text(&mut self, source: &crate::text::AnnotatedString, display_text: String) {
1398        let source_text = &source.text[self.source_range.clone()];
1399        self.measured_width = None;
1400        self.text = if source_text == display_text {
1401            DisplayLineText::Source
1402        } else {
1403            DisplayLineText::Remapped(remap_annotated_subsequence_for_display(
1404                source,
1405                self.source_range.clone(),
1406                display_text.as_str(),
1407            ))
1408        };
1409    }
1410}
1411
1412fn split_line_ranges(text: &str) -> Vec<Range<usize>> {
1413    if text.is_empty() {
1414        return single_line_range(0..0);
1415    }
1416
1417    let mut ranges = Vec::new();
1418    let mut start = 0usize;
1419    for (idx, ch) in text.char_indices() {
1420        if ch == '\n' {
1421            ranges.push(start..idx);
1422            start = idx + ch.len_utf8();
1423        }
1424    }
1425    ranges.push(start..text.len());
1426    ranges
1427}
1428
1429fn build_display_annotated(
1430    source: &crate::text::AnnotatedString,
1431    lines: &[DisplayLine],
1432) -> crate::text::AnnotatedString {
1433    if lines.is_empty() {
1434        return crate::text::AnnotatedString::from("");
1435    }
1436
1437    let mut builder = crate::text::AnnotatedString::builder();
1438    for (idx, line) in lines.iter().enumerate() {
1439        builder = match &line.text {
1440            DisplayLineText::Source => {
1441                builder.append_annotated_subsequence(source, line.source_range.clone())
1442            }
1443            DisplayLineText::Remapped(annotated) => builder.append_annotated(annotated),
1444        };
1445        if idx + 1 < lines.len() {
1446            builder = builder.append("\n");
1447        }
1448    }
1449    builder.to_annotated_string()
1450}
1451
1452fn join_display_line_text(source: &crate::text::AnnotatedString, lines: &[DisplayLine]) -> String {
1453    let mut text = String::new();
1454    for (idx, line) in lines.iter().enumerate() {
1455        text.push_str(line.display_text(source));
1456        if idx + 1 < lines.len() {
1457            text.push('\n');
1458        }
1459    }
1460    text
1461}
1462
1463fn trim_segment_end_whitespace(line: &str, start: usize, mut end: usize) -> usize {
1464    while end > start {
1465        let Some((idx, ch)) = line[start..end].char_indices().next_back() else {
1466            break;
1467        };
1468        if ch.is_whitespace() {
1469            end = start + idx;
1470        } else {
1471            break;
1472        }
1473    }
1474    end
1475}
1476
1477fn remap_annotated_subsequence_for_display(
1478    source: &crate::text::AnnotatedString,
1479    source_range: Range<usize>,
1480    display_text: &str,
1481) -> crate::text::AnnotatedString {
1482    let source_text = &source.text[source_range.clone()];
1483    if source_text == display_text {
1484        return source.subsequence(source_range);
1485    }
1486
1487    let display_chars = map_display_chars_to_source(source_text, display_text);
1488    crate::text::AnnotatedString {
1489        text: display_text.to_string(),
1490        span_styles: remap_subsequence_range_styles(
1491            &source.span_styles,
1492            source_range.clone(),
1493            &display_chars,
1494        ),
1495        paragraph_styles: remap_subsequence_range_styles(
1496            &source.paragraph_styles,
1497            source_range.clone(),
1498            &display_chars,
1499        ),
1500        string_annotations: remap_subsequence_range_styles(
1501            &source.string_annotations,
1502            source_range.clone(),
1503            &display_chars,
1504        ),
1505        link_annotations: remap_subsequence_range_styles(
1506            &source.link_annotations,
1507            source_range,
1508            &display_chars,
1509        ),
1510    }
1511}
1512
1513#[derive(Clone, Copy)]
1514struct DisplayCharMap {
1515    display_start: usize,
1516    display_end: usize,
1517    source_start: Option<usize>,
1518}
1519
1520fn map_display_chars_to_source(source: &str, display: &str) -> Vec<DisplayCharMap> {
1521    let source_chars: Vec<(usize, char)> = source.char_indices().collect();
1522    let mut source_index = 0usize;
1523    let mut maps = Vec::with_capacity(display.chars().count());
1524
1525    for (display_start, display_char) in display.char_indices() {
1526        let display_end = display_start + display_char.len_utf8();
1527        let mut source_start = None;
1528        while source_index < source_chars.len() {
1529            let (candidate_start, candidate_char) = source_chars[source_index];
1530            source_index += 1;
1531            if candidate_char == display_char {
1532                source_start = Some(candidate_start);
1533                break;
1534            }
1535        }
1536        maps.push(DisplayCharMap {
1537            display_start,
1538            display_end,
1539            source_start,
1540        });
1541    }
1542
1543    maps
1544}
1545
1546fn remap_subsequence_range_styles<T: Clone>(
1547    styles: &[crate::text::RangeStyle<T>],
1548    source_range: Range<usize>,
1549    display_chars: &[DisplayCharMap],
1550) -> Vec<crate::text::RangeStyle<T>> {
1551    let mut remapped = Vec::new();
1552
1553    for style in styles {
1554        let overlap_start = style.range.start.max(source_range.start);
1555        let overlap_end = style.range.end.min(source_range.end);
1556        if overlap_start >= overlap_end {
1557            continue;
1558        }
1559        let local_source_range =
1560            (overlap_start - source_range.start)..(overlap_end - source_range.start);
1561        let mut range_start = None;
1562        let mut range_end = 0usize;
1563
1564        for map in display_chars {
1565            let in_range = map.source_start.is_some_and(|source_start| {
1566                source_start >= local_source_range.start && source_start < local_source_range.end
1567            });
1568
1569            if in_range {
1570                if range_start.is_none() {
1571                    range_start = Some(map.display_start);
1572                }
1573                range_end = map.display_end;
1574                continue;
1575            }
1576
1577            if let Some(start) = range_start.take() {
1578                if start < range_end {
1579                    remapped.push(crate::text::RangeStyle {
1580                        item: style.item.clone(),
1581                        range: start..range_end,
1582                    });
1583                }
1584            }
1585        }
1586
1587        if let Some(start) = range_start.take() {
1588            if start < range_end {
1589                remapped.push(crate::text::RangeStyle {
1590                    item: style.item.clone(),
1591                    range: start..range_end,
1592                });
1593            }
1594        }
1595    }
1596
1597    remapped
1598}
1599
1600fn normalize_max_width(max_width: Option<f32>) -> Option<f32> {
1601    match max_width {
1602        Some(width) if width.is_finite() && width > 0.0 => Some(width),
1603        _ => None,
1604    }
1605}
1606
1607fn absolute_range_from_start(base_start: usize, relative: Range<usize>) -> Range<usize> {
1608    (base_start + relative.start)..(base_start + relative.end)
1609}
1610
1611fn boundary_index_for_byte(boundaries: &[usize], byte_offset: usize) -> usize {
1612    boundaries
1613        .binary_search(&byte_offset)
1614        .unwrap_or_else(|index| index.min(boundaries.len().saturating_sub(1)))
1615}
1616
1617fn single_line_range(range: Range<usize>) -> Vec<Range<usize>> {
1618    std::iter::once(range).collect()
1619}
1620
1621struct LineMeasureContext<'a, M: TextMeasurer + ?Sized> {
1622    measurer: &'a M,
1623    text: &'a crate::text::AnnotatedString,
1624    style: &'a TextStyle,
1625    line_start: usize,
1626    prefix_widths: Option<TextLinePrefixWidths>,
1627}
1628
1629impl<'a, M: TextMeasurer + ?Sized> LineMeasureContext<'a, M> {
1630    fn new(
1631        measurer: &'a M,
1632        text: &'a crate::text::AnnotatedString,
1633        line_range: &Range<usize>,
1634        style: &'a TextStyle,
1635        boundary_count: usize,
1636    ) -> Self {
1637        let expected_chars = boundary_count.saturating_sub(1);
1638        let prefix_widths = measurer
1639            .measure_line_prefix_widths(text, line_range.clone(), style)
1640            .filter(|widths| widths.char_count() == expected_chars);
1641        Self {
1642            measurer,
1643            text,
1644            style,
1645            line_start: line_range.start,
1646            prefix_widths,
1647        }
1648    }
1649
1650    fn measure_char_range(&self, boundaries: &[usize], start_idx: usize, end_idx: usize) -> f32 {
1651        if let Some(width) = self.prefix_width_for_char_range(start_idx, end_idx) {
1652            return width;
1653        }
1654        let segment_range =
1655            absolute_range_from_start(self.line_start, boundaries[start_idx]..boundaries[end_idx]);
1656        self.measurer
1657            .measure_subsequence(self.text, segment_range, self.style)
1658            .width
1659    }
1660
1661    fn prefix_width_for_char_range(&self, start_idx: usize, end_idx: usize) -> Option<f32> {
1662        if let Some(prefix_widths) = &self.prefix_widths {
1663            if let Some(width) = prefix_widths.width_for_char_range(start_idx, end_idx) {
1664                return Some(width);
1665            }
1666        }
1667        None
1668    }
1669
1670    fn display_line_for_char_range(
1671        &self,
1672        boundaries: &[usize],
1673        start_idx: usize,
1674        end_idx: usize,
1675    ) -> DisplayLine {
1676        let source_range =
1677            absolute_range_from_start(self.line_start, boundaries[start_idx]..boundaries[end_idx]);
1678        let measured_width = self.measure_char_range(boundaries, start_idx, end_idx);
1679        DisplayLine::from_measured_source_range(source_range, measured_width)
1680    }
1681}
1682
1683fn wrap_line_to_width<M: TextMeasurer + ?Sized>(
1684    measurer: &M,
1685    text: &crate::text::AnnotatedString,
1686    line_range: Range<usize>,
1687    style: &TextStyle,
1688    max_width: f32,
1689    line_break: LineBreak,
1690    hyphens: Hyphens,
1691) -> Vec<DisplayLine> {
1692    let line_text = &text.text[line_range.clone()];
1693    if line_text.is_empty() {
1694        return vec![DisplayLine::from_source_range(
1695            line_range.start..line_range.start,
1696        )];
1697    }
1698
1699    if let Some(measured_width) = measurer.measure_line_width(text, line_range.clone(), style) {
1700        if measured_width <= max_width + WRAP_EPSILON {
1701            return vec![DisplayLine::from_measured_source_range(
1702                line_range,
1703                measured_width,
1704            )];
1705        }
1706    }
1707
1708    if matches!(line_break, LineBreak::Heading | LineBreak::Paragraph)
1709        && line_text.chars().any(char::is_whitespace)
1710    {
1711        if let Some(balanced) = wrap_line_with_word_balance(
1712            measurer,
1713            text,
1714            line_range.clone(),
1715            style,
1716            max_width,
1717            line_break,
1718        ) {
1719            return balanced;
1720        }
1721    }
1722
1723    wrap_line_greedy(
1724        measurer, text, line_range, style, max_width, line_break, hyphens,
1725    )
1726}
1727
1728fn wrap_line_greedy<M: TextMeasurer + ?Sized>(
1729    measurer: &M,
1730    text: &crate::text::AnnotatedString,
1731    line_range: Range<usize>,
1732    style: &TextStyle,
1733    max_width: f32,
1734    line_break: LineBreak,
1735    hyphens: Hyphens,
1736) -> Vec<DisplayLine> {
1737    let line_text = &text.text[line_range.clone()];
1738    let boundaries = char_boundaries(line_text);
1739    let measure_context =
1740        LineMeasureContext::new(measurer, text, &line_range, style, boundaries.len());
1741    if let Some(measured_width) =
1742        measure_context.prefix_width_for_char_range(0, boundaries.len() - 1)
1743    {
1744        if measured_width <= max_width + WRAP_EPSILON {
1745            return vec![DisplayLine::from_measured_source_range(
1746                line_range,
1747                measured_width,
1748            )];
1749        }
1750    }
1751    let mut wrapped = Vec::new();
1752    let mut start_idx = 0usize;
1753
1754    while start_idx < boundaries.len() - 1 {
1755        let mut low = start_idx + 1;
1756        let mut high = boundaries.len() - 1;
1757        let mut best = start_idx + 1;
1758
1759        while low <= high {
1760            let mid = (low + high) / 2;
1761            let width = measure_context.measure_char_range(&boundaries, start_idx, mid);
1762            if width <= max_width + WRAP_EPSILON || mid == start_idx + 1 {
1763                best = mid;
1764                low = mid + 1;
1765            } else {
1766                if mid == 0 {
1767                    break;
1768                }
1769                high = mid - 1;
1770            }
1771        }
1772
1773        let wrap_idx = choose_wrap_break(line_text, &boundaries, start_idx, best, line_break);
1774        let mut effective_wrap_idx = wrap_idx;
1775        let can_hyphenate = hyphens == Hyphens::Auto
1776            && wrap_idx == best
1777            && best < boundaries.len() - 1
1778            && is_break_inside_word(line_text, &boundaries, wrap_idx);
1779        if can_hyphenate {
1780            effective_wrap_idx = resolve_auto_hyphen_break(
1781                measurer,
1782                line_text,
1783                style,
1784                &boundaries,
1785                start_idx,
1786                wrap_idx,
1787            );
1788        }
1789
1790        // A break chosen at a word boundary leaves the space on the line it
1791        // broke after, whether or not that boundary happened to be `best`. The
1792        // space is not drawn and Compose does not count it towards the line's
1793        // width, so leaving it in shifts a centred line half a space left.
1794        let broke_at_word_boundary = effective_wrap_idx > start_idx
1795            && line_text[boundaries[effective_wrap_idx - 1]..boundaries[effective_wrap_idx]]
1796                .chars()
1797                .all(char::is_whitespace);
1798        let segment_start = boundaries[start_idx];
1799        let mut segment_end = boundaries[effective_wrap_idx];
1800        if wrap_idx != best || broke_at_word_boundary {
1801            segment_end = trim_segment_end_whitespace(line_text, segment_start, segment_end);
1802        }
1803        let segment_end_idx = boundary_index_for_byte(&boundaries, segment_end);
1804        wrapped.push(measure_context.display_line_for_char_range(
1805            &boundaries,
1806            start_idx,
1807            segment_end_idx,
1808        ));
1809
1810        start_idx = if wrap_idx != best || broke_at_word_boundary {
1811            skip_leading_whitespace(line_text, &boundaries, wrap_idx)
1812        } else {
1813            effective_wrap_idx
1814        };
1815    }
1816
1817    if wrapped.is_empty() {
1818        wrapped.push(DisplayLine::from_source_range(
1819            line_range.start..line_range.start,
1820        ));
1821    }
1822
1823    wrapped
1824}
1825
1826fn wrap_line_with_word_balance<M: TextMeasurer + ?Sized>(
1827    measurer: &M,
1828    text: &crate::text::AnnotatedString,
1829    line_range: Range<usize>,
1830    style: &TextStyle,
1831    max_width: f32,
1832    line_break: LineBreak,
1833) -> Option<Vec<DisplayLine>> {
1834    let line_text = &text.text[line_range.clone()];
1835    let boundaries = char_boundaries(line_text);
1836    let measure_context =
1837        LineMeasureContext::new(measurer, text, &line_range, style, boundaries.len());
1838    if let Some(measured_width) =
1839        measure_context.prefix_width_for_char_range(0, boundaries.len() - 1)
1840    {
1841        if measured_width <= max_width + WRAP_EPSILON {
1842            return Some(vec![DisplayLine::from_measured_source_range(
1843                line_range,
1844                measured_width,
1845            )]);
1846        }
1847    }
1848    let breakpoints = collect_word_breakpoints(line_text, &boundaries);
1849    if breakpoints.len() <= 2 {
1850        return None;
1851    }
1852
1853    let node_count = breakpoints.len();
1854    let mut best_cost = vec![f32::INFINITY; node_count];
1855    let mut next_index = vec![None; node_count];
1856    best_cost[node_count - 1] = 0.0;
1857
1858    for start in (0..node_count - 1).rev() {
1859        for end in start + 1..node_count {
1860            let start_byte = boundaries[breakpoints[start]];
1861            let end_byte = boundaries[breakpoints[end]];
1862            let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
1863            if trimmed_end <= start_byte {
1864                continue;
1865            }
1866            let segment_start_idx = breakpoints[start];
1867            let segment_end_idx = boundary_index_for_byte(&boundaries, trimmed_end);
1868            let segment_width =
1869                measure_context.measure_char_range(&boundaries, segment_start_idx, segment_end_idx);
1870            if segment_width > max_width + WRAP_EPSILON {
1871                continue;
1872            }
1873            if !best_cost[end].is_finite() {
1874                continue;
1875            }
1876            let slack = (max_width - segment_width).max(0.0);
1877            let is_last = end == node_count - 1;
1878            let segment_cost = match line_break {
1879                LineBreak::Heading => slack * slack,
1880                LineBreak::Paragraph => {
1881                    if is_last {
1882                        slack * slack * 0.16
1883                    } else {
1884                        slack * slack
1885                    }
1886                }
1887                LineBreak::Simple | LineBreak::Unspecified => slack * slack,
1888            };
1889            let candidate = segment_cost + best_cost[end];
1890            if candidate < best_cost[start] {
1891                best_cost[start] = candidate;
1892                next_index[start] = Some(end);
1893            }
1894        }
1895    }
1896
1897    let mut wrapped = Vec::new();
1898    let mut current = 0usize;
1899    while current < node_count - 1 {
1900        let next = next_index[current]?;
1901        let start_byte = boundaries[breakpoints[current]];
1902        let end_byte = boundaries[breakpoints[next]];
1903        let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
1904        if trimmed_end <= start_byte {
1905            return None;
1906        }
1907        let segment_start_idx = breakpoints[current];
1908        let segment_end_idx = boundary_index_for_byte(&boundaries, trimmed_end);
1909        wrapped.push(measure_context.display_line_for_char_range(
1910            &boundaries,
1911            segment_start_idx,
1912            segment_end_idx,
1913        ));
1914        current = next;
1915    }
1916
1917    if wrapped.is_empty() {
1918        return None;
1919    }
1920
1921    Some(wrapped)
1922}
1923
1924fn collect_word_breakpoints(line: &str, boundaries: &[usize]) -> Vec<usize> {
1925    let mut points = vec![0usize];
1926    for idx in 1..boundaries.len() - 1 {
1927        let prev = &line[boundaries[idx - 1]..boundaries[idx]];
1928        let current = &line[boundaries[idx]..boundaries[idx + 1]];
1929        if prev.chars().all(char::is_whitespace) && !current.chars().all(char::is_whitespace) {
1930            points.push(idx);
1931        }
1932    }
1933    let end = boundaries.len() - 1;
1934    if points.last().copied() != Some(end) {
1935        points.push(end);
1936    }
1937    points
1938}
1939
1940fn choose_wrap_break(
1941    line: &str,
1942    boundaries: &[usize],
1943    start_idx: usize,
1944    best: usize,
1945    _line_break: LineBreak,
1946) -> usize {
1947    if best >= boundaries.len() - 1 {
1948        return best;
1949    }
1950
1951    if best <= start_idx + 1 {
1952        return best;
1953    }
1954
1955    // `..=best`, not `..best`. `best` is the widest prefix that still fits, and
1956    // when the character before it is a space that prefix ends on a word
1957    // boundary: it is already the greedy break, and the right one. Excluding it
1958    // sent the search back to the PREVIOUS space and dropped a word that fitted
1959    // — "Designed and built for Wear / OS." came out "Designed and built for /
1960    // Wear OS.". It bites whenever the trailing space fits and the next word's
1961    // first glyph does not, which on a narrow watch column is most lines.
1962    for idx in (start_idx + 1..=best).rev() {
1963        let prev = &line[boundaries[idx - 1]..boundaries[idx]];
1964        if prev.chars().all(char::is_whitespace) {
1965            return idx;
1966        }
1967    }
1968    best
1969}
1970
1971fn is_break_inside_word(line: &str, boundaries: &[usize], break_idx: usize) -> bool {
1972    if break_idx == 0 || break_idx >= boundaries.len() - 1 {
1973        return false;
1974    }
1975    let prev = &line[boundaries[break_idx - 1]..boundaries[break_idx]];
1976    let next = &line[boundaries[break_idx]..boundaries[break_idx + 1]];
1977    !prev.chars().all(char::is_whitespace) && !next.chars().all(char::is_whitespace)
1978}
1979
1980fn resolve_auto_hyphen_break<M: TextMeasurer + ?Sized>(
1981    measurer: &M,
1982    line: &str,
1983    style: &TextStyle,
1984    boundaries: &[usize],
1985    start_idx: usize,
1986    break_idx: usize,
1987) -> usize {
1988    if let Some(candidate) = measurer.choose_auto_hyphen_break(line, style, start_idx, break_idx) {
1989        if is_valid_auto_hyphen_break(line, boundaries, start_idx, break_idx, candidate) {
1990            return candidate;
1991        }
1992    }
1993    choose_auto_hyphen_break_fallback(boundaries, start_idx, break_idx)
1994}
1995
1996fn is_valid_auto_hyphen_break(
1997    line: &str,
1998    boundaries: &[usize],
1999    start_idx: usize,
2000    break_idx: usize,
2001    candidate_idx: usize,
2002) -> bool {
2003    let end_idx = boundaries.len().saturating_sub(1);
2004    candidate_idx > start_idx
2005        && candidate_idx < end_idx
2006        && candidate_idx <= break_idx
2007        && candidate_idx >= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS
2008        && is_break_inside_word(line, boundaries, candidate_idx)
2009}
2010
2011fn choose_auto_hyphen_break_fallback(
2012    boundaries: &[usize],
2013    start_idx: usize,
2014    break_idx: usize,
2015) -> usize {
2016    let end_idx = boundaries.len().saturating_sub(1);
2017    if break_idx >= end_idx {
2018        return break_idx;
2019    }
2020    let trailing_len = end_idx.saturating_sub(break_idx);
2021    if trailing_len > 2 || break_idx <= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS {
2022        return break_idx;
2023    }
2024
2025    let min_break = start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS;
2026    let max_break = break_idx.saturating_sub(1);
2027    if min_break > max_break {
2028        return break_idx;
2029    }
2030
2031    let mut best_break = break_idx;
2032    let mut best_penalty = usize::MAX;
2033    for idx in min_break..=max_break {
2034        let candidate_trailing_len = end_idx.saturating_sub(idx);
2035        let candidate_prefix_len = idx.saturating_sub(start_idx);
2036        if candidate_prefix_len < AUTO_HYPHEN_MIN_SEGMENT_CHARS
2037            || candidate_trailing_len < AUTO_HYPHEN_MIN_TRAILING_CHARS
2038        {
2039            continue;
2040        }
2041
2042        let penalty = candidate_trailing_len.abs_diff(AUTO_HYPHEN_PREFERRED_TRAILING_CHARS);
2043        if penalty < best_penalty {
2044            best_penalty = penalty;
2045            best_break = idx;
2046            if penalty == 0 {
2047                break;
2048            }
2049        }
2050    }
2051    best_break
2052}
2053
2054fn skip_leading_whitespace(line: &str, boundaries: &[usize], mut idx: usize) -> usize {
2055    while idx < boundaries.len() - 1 {
2056        let ch = &line[boundaries[idx]..boundaries[idx + 1]];
2057        if !ch.chars().all(char::is_whitespace) {
2058            break;
2059        }
2060        idx += 1;
2061    }
2062    idx
2063}
2064
2065fn apply_line_overflow<M: TextMeasurer + ?Sized>(
2066    measurer: &M,
2067    line: &str,
2068    style: &TextStyle,
2069    max_width: Option<f32>,
2070    options: TextLayoutOptions,
2071    is_last_visible_line: bool,
2072    single_line_ellipsis: bool,
2073) -> String {
2074    if options.overflow == TextOverflow::Clip || !is_last_visible_line {
2075        return line.to_string();
2076    }
2077
2078    let Some(width_limit) = max_width else {
2079        return match options.overflow {
2080            TextOverflow::Ellipsis => format!("{line}{ELLIPSIS}"),
2081            TextOverflow::StartEllipsis => format!("{ELLIPSIS}{line}"),
2082            TextOverflow::MiddleEllipsis => format!("{ELLIPSIS}{line}"),
2083            TextOverflow::Clip | TextOverflow::Visible | TextOverflow::ScaleDown { .. } => {
2084                line.to_string()
2085            }
2086        };
2087    };
2088
2089    match options.overflow {
2090        TextOverflow::Clip | TextOverflow::Visible => line.to_string(),
2091        TextOverflow::Ellipsis => fit_end_ellipsis(measurer, line, style, width_limit),
2092        TextOverflow::StartEllipsis => {
2093            if single_line_ellipsis {
2094                fit_start_ellipsis(measurer, line, style, width_limit)
2095            } else {
2096                line.to_string()
2097            }
2098        }
2099        TextOverflow::MiddleEllipsis => {
2100            if single_line_ellipsis {
2101                fit_middle_ellipsis(measurer, line, style, width_limit)
2102            } else {
2103                line.to_string()
2104            }
2105        }
2106        TextOverflow::ScaleDown { .. } => line.to_string(),
2107    }
2108}
2109
2110fn fit_end_ellipsis<M: TextMeasurer + ?Sized>(
2111    measurer: &M,
2112    line: &str,
2113    style: &TextStyle,
2114    max_width: f32,
2115) -> String {
2116    if measurer
2117        .measure(&crate::text::AnnotatedString::from(line), style)
2118        .width
2119        <= max_width + WRAP_EPSILON
2120    {
2121        return line.to_string();
2122    }
2123
2124    let ellipsis_width = measurer
2125        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
2126        .width;
2127    if ellipsis_width > max_width + WRAP_EPSILON {
2128        return String::new();
2129    }
2130
2131    let boundaries = char_boundaries(line);
2132    let mut low = 0usize;
2133    let mut high = boundaries.len() - 1;
2134    let mut best = 0usize;
2135
2136    while low <= high {
2137        let mid = (low + high) / 2;
2138        let prefix = &line[..boundaries[mid]];
2139        let candidate = format!("{prefix}{ELLIPSIS}");
2140        let width = measurer
2141            .measure(
2142                &crate::text::AnnotatedString::from(candidate.as_str()),
2143                style,
2144            )
2145            .width;
2146        if width <= max_width + WRAP_EPSILON {
2147            best = mid;
2148            low = mid + 1;
2149        } else if mid == 0 {
2150            break;
2151        } else {
2152            high = mid - 1;
2153        }
2154    }
2155
2156    format!("{}{}", &line[..boundaries[best]], ELLIPSIS)
2157}
2158
2159fn fit_start_ellipsis<M: TextMeasurer + ?Sized>(
2160    measurer: &M,
2161    line: &str,
2162    style: &TextStyle,
2163    max_width: f32,
2164) -> String {
2165    if measurer
2166        .measure(&crate::text::AnnotatedString::from(line), style)
2167        .width
2168        <= max_width + WRAP_EPSILON
2169    {
2170        return line.to_string();
2171    }
2172
2173    let ellipsis_width = measurer
2174        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
2175        .width;
2176    if ellipsis_width > max_width + WRAP_EPSILON {
2177        return String::new();
2178    }
2179
2180    let boundaries = char_boundaries(line);
2181    let mut low = 0usize;
2182    let mut high = boundaries.len() - 1;
2183    let mut best = boundaries.len() - 1;
2184
2185    while low <= high {
2186        let mid = (low + high) / 2;
2187        let suffix = &line[boundaries[mid]..];
2188        let candidate = format!("{ELLIPSIS}{suffix}");
2189        let width = measurer
2190            .measure(
2191                &crate::text::AnnotatedString::from(candidate.as_str()),
2192                style,
2193            )
2194            .width;
2195        if width <= max_width + WRAP_EPSILON {
2196            best = mid;
2197            if mid == 0 {
2198                break;
2199            }
2200            high = mid - 1;
2201        } else {
2202            low = mid + 1;
2203        }
2204    }
2205
2206    format!("{ELLIPSIS}{}", &line[boundaries[best]..])
2207}
2208
2209fn fit_middle_ellipsis<M: TextMeasurer + ?Sized>(
2210    measurer: &M,
2211    line: &str,
2212    style: &TextStyle,
2213    max_width: f32,
2214) -> String {
2215    if measurer
2216        .measure(&crate::text::AnnotatedString::from(line), style)
2217        .width
2218        <= max_width + WRAP_EPSILON
2219    {
2220        return line.to_string();
2221    }
2222
2223    let ellipsis_width = measurer
2224        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
2225        .width;
2226    if ellipsis_width > max_width + WRAP_EPSILON {
2227        return String::new();
2228    }
2229
2230    let boundaries = char_boundaries(line);
2231    let total_chars = boundaries.len().saturating_sub(1);
2232    for keep in (0..=total_chars).rev() {
2233        let keep_start = keep.div_ceil(2);
2234        let keep_end = keep / 2;
2235        let start = &line[..boundaries[keep_start]];
2236        let end_start = boundaries[total_chars.saturating_sub(keep_end)];
2237        let end = &line[end_start..];
2238        let candidate = format!("{start}{ELLIPSIS}{end}");
2239        if measurer
2240            .measure(
2241                &crate::text::AnnotatedString::from(candidate.as_str()),
2242                style,
2243            )
2244            .width
2245            <= max_width + WRAP_EPSILON
2246        {
2247            return candidate;
2248        }
2249    }
2250
2251    ELLIPSIS.to_string()
2252}
2253
2254fn char_boundaries(text: &str) -> Vec<usize> {
2255    let mut out = Vec::with_capacity(text.chars().count() + 1);
2256    out.push(0);
2257    for (idx, _) in text.char_indices() {
2258        if idx != 0 {
2259            out.push(idx);
2260        }
2261    }
2262    out.push(text.len());
2263    out
2264}
2265
2266#[cfg(test)]
2267mod tests {
2268    use super::*;
2269    use crate::text::{Hyphens, LineBreak, ParagraphStyle, TextUnit};
2270    use crate::text_layout_result::TextLayoutResult;
2271    use std::cell::Cell;
2272
2273    #[test]
2274    fn text_layout_telemetry_env_flag_is_not_process_cached() {
2275        let source = include_str!("measure.rs");
2276        let once_lock = ["Once", "Lock"].concat();
2277        let cached_init_call = ["get", "_or", "_init"].concat();
2278
2279        assert!(
2280            !source.contains(&once_lock) && !source.contains(&cached_init_call),
2281            "text layout telemetry env flag must be read at the diagnostic boundary"
2282        );
2283    }
2284
2285    #[test]
2286    fn prepared_layout_cache_distinguishes_visual_styles() {
2287        let service = TextService::new();
2288        let text = crate::text::AnnotatedString::from("tinted".to_string());
2289        let options = TextLayoutOptions::default();
2290
2291        let mut style = TextStyle::default();
2292        style.span_style.color = Some(crate::Color(1.0, 0.0, 0.0, 1.0));
2293        let red = service.prepare_with_options(None, &text, &style, options, None);
2294
2295        style.span_style.color = Some(crate::Color(0.0, 0.0, 1.0, 1.0));
2296        let blue = service.prepare_with_options(None, &text, &style, options, None);
2297
2298        assert_eq!(
2299            red.visual_style.span_style.color,
2300            Some(crate::Color(1.0, 0.0, 0.0, 1.0)),
2301        );
2302        assert_eq!(
2303            blue.visual_style.span_style.color,
2304            Some(crate::Color(0.0, 0.0, 1.0, 1.0)),
2305            "a color-only style change must not be served a stale prepared layout \
2306             (measurement hashes ignore visual attributes by design)"
2307        );
2308    }
2309
2310    #[test]
2311    fn system_font_scale_changes_sp_measurement_and_prepared_text() {
2312        let _app_context = crate::render_state::app_context_test_scope();
2313        let text = crate::text::AnnotatedString::from("scale me");
2314        let style = TextStyle {
2315            span_style: crate::text::SpanStyle {
2316                font_size: TextUnit::Sp(10.0),
2317                ..Default::default()
2318            },
2319            ..Default::default()
2320        };
2321
2322        let unscaled = measure_text(&text, &style);
2323        crate::set_font_scale(2.0);
2324        let scaled = measure_text(&text, &style);
2325        let prepared = prepare_text_layout(&text, &style, TextLayoutOptions::default(), None);
2326
2327        assert!((scaled.width - unscaled.width * 2.0).abs() <= f32::EPSILON);
2328        assert!((scaled.height - unscaled.height * 2.0).abs() <= f32::EPSILON);
2329        assert_eq!(
2330            prepared.visual_style.span_style.font_size,
2331            TextUnit::Sp(20.0)
2332        );
2333    }
2334
2335    #[test]
2336    fn text_service_cache_retains_large_lazy_text_working_set() {
2337        let mut cache = BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY);
2338        let metrics = TextMetrics {
2339            width: 1.0,
2340            height: 1.0,
2341            line_height: 1.0,
2342            line_count: 1,
2343        };
2344
2345        for index in 0..4096u64 {
2346            cache.insert(
2347                TextBaseCacheKey {
2348                    text_hash: index,
2349                    style_hash: 7,
2350                },
2351                metrics,
2352            );
2353        }
2354
2355        for index in 0..4096u64 {
2356            assert!(
2357                cache
2358                    .get(&TextBaseCacheKey {
2359                        text_hash: index,
2360                        style_hash: 7,
2361                    })
2362                    .is_some(),
2363                "large lazy text working-set entry {index} was evicted too early"
2364            );
2365        }
2366    }
2367
2368    struct ContractBreakMeasurer {
2369        retreat: usize,
2370    }
2371
2372    impl TextMeasurer for ContractBreakMeasurer {
2373        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2374            MonospacedTextMeasurer.measure(
2375                &crate::text::AnnotatedString::from(text.text.as_str()),
2376                style,
2377            )
2378        }
2379
2380        fn get_offset_for_position(
2381            &self,
2382            text: &crate::text::AnnotatedString,
2383            style: &TextStyle,
2384            x: f32,
2385            y: f32,
2386        ) -> usize {
2387            MonospacedTextMeasurer.get_offset_for_position(
2388                &crate::text::AnnotatedString::from(text.text.as_str()),
2389                style,
2390                x,
2391                y,
2392            )
2393        }
2394
2395        fn get_cursor_x_for_offset(
2396            &self,
2397            text: &crate::text::AnnotatedString,
2398            style: &TextStyle,
2399            offset: usize,
2400        ) -> f32 {
2401            MonospacedTextMeasurer.get_cursor_x_for_offset(
2402                &crate::text::AnnotatedString::from(text.text.as_str()),
2403                style,
2404                offset,
2405            )
2406        }
2407
2408        fn layout(
2409            &self,
2410            text: &crate::text::AnnotatedString,
2411            style: &TextStyle,
2412        ) -> TextLayoutResult {
2413            MonospacedTextMeasurer.layout(
2414                &crate::text::AnnotatedString::from(text.text.as_str()),
2415                style,
2416            )
2417        }
2418
2419        fn choose_auto_hyphen_break(
2420            &self,
2421            _line: &str,
2422            _style: &TextStyle,
2423            _segment_start_char: usize,
2424            measured_break_char: usize,
2425        ) -> Option<usize> {
2426            measured_break_char.checked_sub(self.retreat)
2427        }
2428    }
2429
2430    struct CountingTextMeasurer {
2431        measure_calls: Rc<Cell<usize>>,
2432        layout_calls: Rc<Cell<usize>>,
2433    }
2434
2435    impl CountingTextMeasurer {
2436        fn new(measure_calls: Rc<Cell<usize>>, layout_calls: Rc<Cell<usize>>) -> Self {
2437            Self {
2438                measure_calls,
2439                layout_calls,
2440            }
2441        }
2442    }
2443
2444    impl TextMeasurer for CountingTextMeasurer {
2445        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2446            self.measure_calls.set(self.measure_calls.get() + 1);
2447            MonospacedTextMeasurer.measure(text, style)
2448        }
2449
2450        fn get_offset_for_position(
2451            &self,
2452            text: &crate::text::AnnotatedString,
2453            style: &TextStyle,
2454            x: f32,
2455            y: f32,
2456        ) -> usize {
2457            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2458        }
2459
2460        fn get_cursor_x_for_offset(
2461            &self,
2462            text: &crate::text::AnnotatedString,
2463            style: &TextStyle,
2464            offset: usize,
2465        ) -> f32 {
2466            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2467        }
2468
2469        fn layout(
2470            &self,
2471            text: &crate::text::AnnotatedString,
2472            style: &TextStyle,
2473        ) -> TextLayoutResult {
2474            self.layout_calls.set(self.layout_calls.get() + 1);
2475            MonospacedTextMeasurer.layout(text, style)
2476        }
2477    }
2478
2479    struct CountingPreparedTextMeasurer {
2480        prepare_calls: Rc<Cell<usize>>,
2481    }
2482
2483    impl CountingPreparedTextMeasurer {
2484        fn new(prepare_calls: Rc<Cell<usize>>) -> Self {
2485            Self { prepare_calls }
2486        }
2487    }
2488
2489    struct PrefixWidthCountingMeasurer {
2490        prefix_calls: Rc<Cell<usize>>,
2491        subsequence_calls: Rc<Cell<usize>>,
2492    }
2493
2494    impl PrefixWidthCountingMeasurer {
2495        fn new(prefix_calls: Rc<Cell<usize>>, subsequence_calls: Rc<Cell<usize>>) -> Self {
2496            Self {
2497                prefix_calls,
2498                subsequence_calls,
2499            }
2500        }
2501    }
2502
2503    impl TextMeasurer for PrefixWidthCountingMeasurer {
2504        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2505            MonospacedTextMeasurer.measure(text, style)
2506        }
2507
2508        fn measure_subsequence(
2509            &self,
2510            text: &crate::text::AnnotatedString,
2511            range: Range<usize>,
2512            style: &TextStyle,
2513        ) -> TextMetrics {
2514            self.subsequence_calls.set(self.subsequence_calls.get() + 1);
2515            MonospacedTextMeasurer.measure_subsequence(text, range, style)
2516        }
2517
2518        fn measure_line_prefix_widths(
2519            &self,
2520            text: &crate::text::AnnotatedString,
2521            line_range: Range<usize>,
2522            style: &TextStyle,
2523        ) -> Option<TextLinePrefixWidths> {
2524            self.prefix_calls.set(self.prefix_calls.get() + 1);
2525            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2526        }
2527
2528        fn get_offset_for_position(
2529            &self,
2530            text: &crate::text::AnnotatedString,
2531            style: &TextStyle,
2532            x: f32,
2533            y: f32,
2534        ) -> usize {
2535            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2536        }
2537
2538        fn get_cursor_x_for_offset(
2539            &self,
2540            text: &crate::text::AnnotatedString,
2541            style: &TextStyle,
2542            offset: usize,
2543        ) -> f32 {
2544            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2545        }
2546
2547        fn layout(
2548            &self,
2549            text: &crate::text::AnnotatedString,
2550            style: &TextStyle,
2551        ) -> TextLayoutResult {
2552            MonospacedTextMeasurer.layout(text, style)
2553        }
2554    }
2555
2556    struct LineHeightCountingMeasurer {
2557        measure_calls: Rc<Cell<usize>>,
2558        line_height_calls: Rc<Cell<usize>>,
2559    }
2560
2561    struct FitProbeCountingMeasurer {
2562        line_width_calls: Rc<Cell<usize>>,
2563        prefix_calls: Rc<Cell<usize>>,
2564    }
2565
2566    impl FitProbeCountingMeasurer {
2567        fn new(line_width_calls: Rc<Cell<usize>>, prefix_calls: Rc<Cell<usize>>) -> Self {
2568            Self {
2569                line_width_calls,
2570                prefix_calls,
2571            }
2572        }
2573    }
2574
2575    impl LineHeightCountingMeasurer {
2576        fn new(measure_calls: Rc<Cell<usize>>, line_height_calls: Rc<Cell<usize>>) -> Self {
2577            Self {
2578                measure_calls,
2579                line_height_calls,
2580            }
2581        }
2582    }
2583
2584    impl TextMeasurer for LineHeightCountingMeasurer {
2585        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2586            self.measure_calls.set(self.measure_calls.get() + 1);
2587            MonospacedTextMeasurer.measure(text, style)
2588        }
2589
2590        fn measure_line_prefix_widths(
2591            &self,
2592            text: &crate::text::AnnotatedString,
2593            line_range: Range<usize>,
2594            style: &TextStyle,
2595        ) -> Option<TextLinePrefixWidths> {
2596            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2597        }
2598
2599        fn line_height(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> f32 {
2600            self.line_height_calls.set(self.line_height_calls.get() + 1);
2601            MonospacedTextMeasurer.line_height(text, style)
2602        }
2603
2604        fn get_offset_for_position(
2605            &self,
2606            text: &crate::text::AnnotatedString,
2607            style: &TextStyle,
2608            x: f32,
2609            y: f32,
2610        ) -> usize {
2611            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2612        }
2613
2614        fn get_cursor_x_for_offset(
2615            &self,
2616            text: &crate::text::AnnotatedString,
2617            style: &TextStyle,
2618            offset: usize,
2619        ) -> f32 {
2620            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2621        }
2622
2623        fn layout(
2624            &self,
2625            text: &crate::text::AnnotatedString,
2626            style: &TextStyle,
2627        ) -> TextLayoutResult {
2628            MonospacedTextMeasurer.layout(text, style)
2629        }
2630    }
2631
2632    impl TextMeasurer for FitProbeCountingMeasurer {
2633        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2634            MonospacedTextMeasurer.measure(text, style)
2635        }
2636
2637        fn measure_line_width(
2638            &self,
2639            text: &crate::text::AnnotatedString,
2640            line_range: Range<usize>,
2641            style: &TextStyle,
2642        ) -> Option<f32> {
2643            self.line_width_calls.set(self.line_width_calls.get() + 1);
2644            MonospacedTextMeasurer.measure_line_width(text, line_range, style)
2645        }
2646
2647        fn measure_line_prefix_widths(
2648            &self,
2649            text: &crate::text::AnnotatedString,
2650            line_range: Range<usize>,
2651            style: &TextStyle,
2652        ) -> Option<TextLinePrefixWidths> {
2653            self.prefix_calls.set(self.prefix_calls.get() + 1);
2654            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2655        }
2656
2657        fn get_offset_for_position(
2658            &self,
2659            text: &crate::text::AnnotatedString,
2660            style: &TextStyle,
2661            x: f32,
2662            y: f32,
2663        ) -> usize {
2664            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2665        }
2666
2667        fn get_cursor_x_for_offset(
2668            &self,
2669            text: &crate::text::AnnotatedString,
2670            style: &TextStyle,
2671            offset: usize,
2672        ) -> f32 {
2673            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2674        }
2675
2676        fn layout(
2677            &self,
2678            text: &crate::text::AnnotatedString,
2679            style: &TextStyle,
2680        ) -> TextLayoutResult {
2681            MonospacedTextMeasurer.layout(text, style)
2682        }
2683    }
2684
2685    impl TextMeasurer for CountingPreparedTextMeasurer {
2686        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2687            MonospacedTextMeasurer.measure(text, style)
2688        }
2689
2690        fn prepare_with_options_for_node(
2691            &self,
2692            _node_id: Option<NodeId>,
2693            text: &crate::text::AnnotatedString,
2694            style: &TextStyle,
2695            options: TextLayoutOptions,
2696            max_width: Option<f32>,
2697        ) -> PreparedTextLayout {
2698            self.prepare_calls.set(self.prepare_calls.get() + 1);
2699            MonospacedTextMeasurer.prepare_with_options(text, style, options, max_width)
2700        }
2701
2702        fn get_offset_for_position(
2703            &self,
2704            text: &crate::text::AnnotatedString,
2705            style: &TextStyle,
2706            x: f32,
2707            y: f32,
2708        ) -> usize {
2709            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2710        }
2711
2712        fn get_cursor_x_for_offset(
2713            &self,
2714            text: &crate::text::AnnotatedString,
2715            style: &TextStyle,
2716            offset: usize,
2717        ) -> f32 {
2718            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2719        }
2720
2721        fn layout(
2722            &self,
2723            text: &crate::text::AnnotatedString,
2724            style: &TextStyle,
2725        ) -> TextLayoutResult {
2726            MonospacedTextMeasurer.layout(text, style)
2727        }
2728    }
2729
2730    #[test]
2731    fn text_service_routes_measurement_through_current_measurer() {
2732        let _app_context = crate::render_state::app_context_test_scope();
2733        let service = TextService::from_measurer(Rc::new(MonospacedTextMeasurer));
2734        let text = crate::text::AnnotatedString::from("abc");
2735        let style = TextStyle::default();
2736
2737        let metrics = service.with_measurer(|measurer| measurer.measure(&text, &style));
2738
2739        assert!(metrics.width > 0.0);
2740        assert!(metrics.height > 0.0);
2741    }
2742
2743    #[test]
2744    fn text_service_caches_metrics_and_layouts_per_context() {
2745        let _app_context = crate::render_state::app_context_test_scope();
2746        let measure_calls = Rc::new(Cell::new(0));
2747        let layout_calls = Rc::new(Cell::new(0));
2748        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2749            Rc::clone(&measure_calls),
2750            Rc::clone(&layout_calls),
2751        )));
2752        let text = crate::text::AnnotatedString::from("cached text");
2753        let style = TextStyle::default();
2754
2755        let first_metrics = service.measure(Some(7), &text, &style);
2756        let second_metrics = service.measure(Some(7), &text, &style);
2757        let first_layout = service.layout(&text, &style);
2758        let second_layout = service.layout(&text, &style);
2759
2760        assert_eq!(first_metrics, second_metrics);
2761        assert_eq!(first_layout.width, second_layout.width);
2762        assert_eq!(measure_calls.get(), 1);
2763        assert_eq!(layout_calls.get(), 1);
2764    }
2765
2766    #[test]
2767    fn text_service_reuses_metrics_cache_across_node_ids() {
2768        let _app_context = crate::render_state::app_context_test_scope();
2769        let measure_calls = Rc::new(Cell::new(0));
2770        let layout_calls = Rc::new(Cell::new(0));
2771        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2772            Rc::clone(&measure_calls),
2773            Rc::clone(&layout_calls),
2774        )));
2775        let text = crate::text::AnnotatedString::from("same lazy item text");
2776        let style = TextStyle::default();
2777
2778        let first_metrics = service.measure(Some(7), &text, &style);
2779        let second_metrics = service.measure(Some(8), &text, &style);
2780
2781        assert_eq!(first_metrics, second_metrics);
2782        assert_eq!(measure_calls.get(), 1);
2783    }
2784
2785    #[test]
2786    fn text_service_reuses_prepared_layout_cache_across_node_ids() {
2787        let _app_context = crate::render_state::app_context_test_scope();
2788        let prepare_calls = Rc::new(Cell::new(0));
2789        let service = TextService::from_measurer(Rc::new(CountingPreparedTextMeasurer::new(
2790            Rc::clone(&prepare_calls),
2791        )));
2792        let text = crate::text::AnnotatedString::from("same prepared lazy item text");
2793        let style = TextStyle::default();
2794        let options = TextLayoutOptions::default();
2795
2796        let first = service.prepare_with_options(Some(9), &text, &style, options, Some(120.0));
2797        let second = service.prepare_with_options(Some(10), &text, &style, options, Some(120.0));
2798
2799        assert_eq!(first.metrics, second.metrics);
2800        assert_eq!(prepare_calls.get(), 1);
2801    }
2802
2803    #[test]
2804    fn text_service_clears_caches_when_measurer_changes() {
2805        let _app_context = crate::render_state::app_context_test_scope();
2806        let first_measure_calls = Rc::new(Cell::new(0));
2807        let second_measure_calls = Rc::new(Cell::new(0));
2808        let layout_calls = Rc::new(Cell::new(0));
2809        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2810            Rc::clone(&first_measure_calls),
2811            Rc::clone(&layout_calls),
2812        )));
2813        let text = crate::text::AnnotatedString::from("cached text");
2814        let style = TextStyle::default();
2815
2816        let _ = service.measure(None, &text, &style);
2817        let _ = service.measure(None, &text, &style);
2818        service.set_measurer(Rc::new(CountingTextMeasurer::new(
2819            Rc::clone(&second_measure_calls),
2820            Rc::clone(&layout_calls),
2821        )));
2822        let _ = service.measure(None, &text, &style);
2823
2824        assert_eq!(first_measure_calls.get(), 1);
2825        assert_eq!(second_measure_calls.get(), 1);
2826    }
2827
2828    #[test]
2829    fn text_wrapping_uses_prefix_widths_without_subsequence_measurement() {
2830        let _app_context = crate::render_state::app_context_test_scope();
2831        let prefix_calls = Rc::new(Cell::new(0));
2832        let subsequence_calls = Rc::new(Cell::new(0));
2833        set_text_measurer(PrefixWidthCountingMeasurer::new(
2834            Rc::clone(&prefix_calls),
2835            Rc::clone(&subsequence_calls),
2836        ));
2837        let style = TextStyle {
2838            span_style: crate::text::SpanStyle {
2839                font_size: TextUnit::Sp(10.0),
2840                ..Default::default()
2841            },
2842            ..Default::default()
2843        };
2844        let options = TextLayoutOptions {
2845            overflow: TextOverflow::Clip,
2846            soft_wrap: true,
2847            max_lines: usize::MAX,
2848            min_lines: 1,
2849        };
2850        let text = crate::text::AnnotatedString::from("word ".repeat(80).as_str());
2851
2852        let prepared = prepare_text_layout(&text, &style, options, Some(80.0));
2853
2854        assert!(prepared.metrics.line_count > 1);
2855        assert!(
2856            prefix_calls.get() > 0,
2857            "wrapping should request a line prefix width plan"
2858        );
2859        assert_eq!(
2860            subsequence_calls.get(),
2861            0,
2862            "prefix-capable wrapping should not probe candidate substrings"
2863        );
2864    }
2865
2866    #[test]
2867    fn text_wrapping_skips_prefix_widths_when_fit_probe_says_line_fits() {
2868        let _app_context = crate::render_state::app_context_test_scope();
2869        let line_width_calls = Rc::new(Cell::new(0));
2870        let prefix_calls = Rc::new(Cell::new(0));
2871        set_text_measurer(FitProbeCountingMeasurer::new(
2872            Rc::clone(&line_width_calls),
2873            Rc::clone(&prefix_calls),
2874        ));
2875        let style = TextStyle {
2876            span_style: crate::text::SpanStyle {
2877                font_size: TextUnit::Sp(10.0),
2878                ..Default::default()
2879            },
2880            ..Default::default()
2881        };
2882        let text = crate::text::AnnotatedString::from("fits without per-glyph prefix widths");
2883
2884        let prepared =
2885            prepare_text_layout(&text, &style, TextLayoutOptions::default(), Some(800.0));
2886
2887        assert_eq!(prepared.metrics.line_count, 1);
2888        assert_eq!(line_width_calls.get(), 1);
2889        assert_eq!(
2890            prefix_calls.get(),
2891            0,
2892            "fitting lines should not allocate prefix-width plans"
2893        );
2894    }
2895
2896    #[test]
2897    fn prepare_text_layout_uses_line_height_without_full_text_measurement() {
2898        let _app_context = crate::render_state::app_context_test_scope();
2899        let measure_calls = Rc::new(Cell::new(0));
2900        let line_height_calls = Rc::new(Cell::new(0));
2901        let measurer = LineHeightCountingMeasurer::new(
2902            Rc::clone(&measure_calls),
2903            Rc::clone(&line_height_calls),
2904        );
2905        let text = crate::text::AnnotatedString::from(
2906            "one two three four five six seven eight nine ten eleven twelve",
2907        );
2908
2909        let prepared = prepare_text_layout_with_measurer_for_node(
2910            &measurer,
2911            Some(7),
2912            &text,
2913            &TextStyle::default(),
2914            TextLayoutOptions::default(),
2915            Some(96.0),
2916        );
2917
2918        assert!(prepared.metrics.height > 0.0);
2919        assert_eq!(line_height_calls.get(), 1);
2920        assert_eq!(
2921            measure_calls.get(),
2922            0,
2923            "line-height lookup must not re-measure the whole paragraph"
2924        );
2925    }
2926
2927    fn style_with_line_break(line_break: LineBreak) -> TextStyle {
2928        TextStyle {
2929            span_style: crate::text::SpanStyle {
2930                font_size: TextUnit::Sp(10.0),
2931                ..Default::default()
2932            },
2933            paragraph_style: ParagraphStyle {
2934                line_break,
2935                ..Default::default()
2936            },
2937        }
2938    }
2939
2940    fn style_with_hyphens(hyphens: Hyphens) -> TextStyle {
2941        TextStyle {
2942            span_style: crate::text::SpanStyle {
2943                font_size: TextUnit::Sp(10.0),
2944                ..Default::default()
2945            },
2946            paragraph_style: ParagraphStyle {
2947                hyphens,
2948                ..Default::default()
2949            },
2950        }
2951    }
2952
2953    fn assert_f32_close(actual: f32, expected: f32) {
2954        assert!(
2955            (actual - expected).abs() <= 0.01,
2956            "actual={actual}, expected={expected}"
2957        );
2958    }
2959
2960    #[test]
2961    fn text_layout_options_wraps_and_limits_lines() {
2962        let _app_context = crate::render_state::app_context_test_scope();
2963        let style = TextStyle {
2964            span_style: crate::text::SpanStyle {
2965                font_size: TextUnit::Sp(10.0),
2966                ..Default::default()
2967            },
2968            ..Default::default()
2969        };
2970        let options = TextLayoutOptions {
2971            overflow: TextOverflow::Clip,
2972            soft_wrap: true,
2973            max_lines: 2,
2974            min_lines: 1,
2975        };
2976
2977        let prepared = prepare_text_layout(
2978            &crate::text::AnnotatedString::from("A B C D E F"),
2979            &style,
2980            options,
2981            Some(24.0), // roughly 4 chars in monospaced fallback
2982        );
2983
2984        assert!(prepared.did_overflow);
2985        assert!(prepared.metrics.line_count <= 2);
2986    }
2987
2988    #[test]
2989    fn text_layout_options_end_ellipsis_applies() {
2990        let _app_context = crate::render_state::app_context_test_scope();
2991        let style = TextStyle {
2992            span_style: crate::text::SpanStyle {
2993                font_size: TextUnit::Sp(10.0),
2994                ..Default::default()
2995            },
2996            ..Default::default()
2997        };
2998        let options = TextLayoutOptions {
2999            overflow: TextOverflow::Ellipsis,
3000            soft_wrap: false,
3001            max_lines: 1,
3002            min_lines: 1,
3003        };
3004
3005        let prepared = prepare_text_layout(
3006            &crate::text::AnnotatedString::from("Long long line"),
3007            &style,
3008            options,
3009            Some(20.0),
3010        );
3011        assert!(prepared.did_overflow);
3012        assert!(prepared.text.text.contains(ELLIPSIS));
3013    }
3014
3015    #[test]
3016    fn text_layout_options_visible_keeps_full_text() {
3017        let _app_context = crate::render_state::app_context_test_scope();
3018        let style = TextStyle {
3019            span_style: crate::text::SpanStyle {
3020                font_size: TextUnit::Sp(10.0),
3021                ..Default::default()
3022            },
3023            ..Default::default()
3024        };
3025        let options = TextLayoutOptions {
3026            overflow: TextOverflow::Visible,
3027            soft_wrap: false,
3028            max_lines: 1,
3029            min_lines: 1,
3030        };
3031
3032        let input = "This should remain unchanged";
3033        let prepared = prepare_text_layout(
3034            &crate::text::AnnotatedString::from(input),
3035            &style,
3036            options,
3037            Some(10.0),
3038        );
3039        assert_eq!(prepared.text.text, input);
3040    }
3041
3042    #[test]
3043    fn text_layout_options_respects_min_lines() {
3044        let _app_context = crate::render_state::app_context_test_scope();
3045        let style = TextStyle {
3046            span_style: crate::text::SpanStyle {
3047                font_size: TextUnit::Sp(10.0),
3048                ..Default::default()
3049            },
3050            ..Default::default()
3051        };
3052        let options = TextLayoutOptions {
3053            overflow: TextOverflow::Clip,
3054            soft_wrap: true,
3055            max_lines: 4,
3056            min_lines: 3,
3057        };
3058
3059        let prepared = prepare_text_layout(
3060            &crate::text::AnnotatedString::from("short"),
3061            &style,
3062            options,
3063            Some(100.0),
3064        );
3065        assert_eq!(prepared.metrics.line_count, 3);
3066    }
3067
3068    #[test]
3069    fn text_layout_options_middle_ellipsis_for_single_line() {
3070        let _app_context = crate::render_state::app_context_test_scope();
3071        let style = TextStyle {
3072            span_style: crate::text::SpanStyle {
3073                font_size: TextUnit::Sp(10.0),
3074                ..Default::default()
3075            },
3076            ..Default::default()
3077        };
3078        let options = TextLayoutOptions {
3079            overflow: TextOverflow::MiddleEllipsis,
3080            soft_wrap: false,
3081            max_lines: 1,
3082            min_lines: 1,
3083        };
3084
3085        let prepared = prepare_text_layout(
3086            &crate::text::AnnotatedString::from("abcdefghijk"),
3087            &style,
3088            options,
3089            Some(24.0),
3090        );
3091        assert!(prepared.text.text.contains(ELLIPSIS));
3092        assert!(prepared.did_overflow);
3093    }
3094
3095    #[test]
3096    fn text_layout_options_scale_down_fits_without_rewriting_text() {
3097        let _app_context = crate::render_state::app_context_test_scope();
3098        let style = TextStyle {
3099            span_style: crate::text::SpanStyle {
3100                font_size: TextUnit::Sp(20.0),
3101                ..Default::default()
3102            },
3103            ..Default::default()
3104        };
3105        let options = TextLayoutOptions {
3106            overflow: TextOverflow::ScaleDown {
3107                min_font_size_sp: 10.0,
3108            },
3109            soft_wrap: false,
3110            max_lines: 1,
3111            min_lines: 1,
3112        };
3113
3114        let prepared = prepare_text_layout(
3115            &crate::text::AnnotatedString::from("ABCDE"),
3116            &style,
3117            options,
3118            Some(36.0),
3119        );
3120
3121        assert_eq!(prepared.text.text, "ABCDE");
3122        assert!(prepared.metrics.width <= 36.0 + WRAP_EPSILON);
3123        assert!(!prepared.did_overflow);
3124        let visual_font_size = prepared.visual_style.resolve_font_size(14.0);
3125        assert!(visual_font_size < 20.0);
3126        assert!(visual_font_size >= 10.0);
3127    }
3128
3129    #[test]
3130    fn text_layout_options_scale_down_scales_root_shadow() {
3131        let _app_context = crate::render_state::app_context_test_scope();
3132        let style = TextStyle {
3133            span_style: crate::text::SpanStyle {
3134                font_size: TextUnit::Sp(20.0),
3135                shadow: Some(crate::text::Shadow {
3136                    color: crate::modifier::Color(0.0, 0.0, 0.0, 1.0),
3137                    offset: crate::modifier::Point::new(8.0, 4.0),
3138                    blur_radius: 6.0,
3139                }),
3140                ..Default::default()
3141            },
3142            ..Default::default()
3143        };
3144        let options = TextLayoutOptions {
3145            overflow: TextOverflow::ScaleDown {
3146                min_font_size_sp: 10.0,
3147            },
3148            soft_wrap: false,
3149            max_lines: 1,
3150            min_lines: 1,
3151        };
3152
3153        let prepared = prepare_text_layout(
3154            &crate::text::AnnotatedString::from("ABCDE"),
3155            &style,
3156            options,
3157            Some(36.0),
3158        );
3159
3160        let font_scale = prepared.visual_style.resolve_font_size(14.0) / 20.0;
3161        let shadow = prepared
3162            .visual_style
3163            .span_style
3164            .shadow
3165            .expect("scaled style should retain shadow");
3166        assert_f32_close(shadow.offset.x, 8.0 * font_scale);
3167        assert_f32_close(shadow.offset.y, 4.0 * font_scale);
3168        assert_f32_close(shadow.blur_radius, 6.0 * font_scale);
3169    }
3170
3171    #[test]
3172    fn text_layout_options_scale_down_stops_at_minimum_and_clips() {
3173        let _app_context = crate::render_state::app_context_test_scope();
3174        let style = TextStyle {
3175            span_style: crate::text::SpanStyle {
3176                font_size: TextUnit::Sp(20.0),
3177                ..Default::default()
3178            },
3179            ..Default::default()
3180        };
3181        let options = TextLayoutOptions {
3182            overflow: TextOverflow::ScaleDown {
3183                min_font_size_sp: 10.0,
3184            },
3185            soft_wrap: false,
3186            max_lines: 1,
3187            min_lines: 1,
3188        };
3189
3190        let prepared = prepare_text_layout(
3191            &crate::text::AnnotatedString::from("ABCDEFGHIJ"),
3192            &style,
3193            options,
3194            Some(12.0),
3195        );
3196
3197        assert_eq!(prepared.text.text, "ABCDEFGHIJ");
3198        assert!(prepared.did_overflow);
3199        assert_eq!(prepared.metrics.width, 12.0);
3200        assert_eq!(prepared.visual_style.resolve_font_size(14.0), 10.0);
3201    }
3202
3203    #[test]
3204    fn scale_annotated_font_sizes_borrows_when_spans_need_no_scaling() {
3205        let _app_context = crate::render_state::app_context_test_scope();
3206        let plain = crate::text::AnnotatedString::from("plain");
3207        assert!(matches!(
3208            scale_annotated_font_sizes(&plain, 0.5),
3209            std::borrow::Cow::Borrowed(_)
3210        ));
3211
3212        let colored = crate::text::annotated_string::Builder::new()
3213            .push_style(crate::text::SpanStyle {
3214                color: Some(crate::modifier::Color(1.0, 0.0, 0.0, 1.0)),
3215                ..Default::default()
3216            })
3217            .append("colored")
3218            .pop()
3219            .to_annotated_string();
3220        assert!(matches!(
3221            scale_annotated_font_sizes(&colored, 0.5),
3222            std::borrow::Cow::Borrowed(_)
3223        ));
3224    }
3225
3226    #[test]
3227    fn scale_annotated_font_sizes_scales_span_shadow_geometry() {
3228        let _app_context = crate::render_state::app_context_test_scope();
3229        let text = crate::text::annotated_string::Builder::new()
3230            .push_style(crate::text::SpanStyle {
3231                shadow: Some(crate::text::Shadow {
3232                    color: crate::modifier::Color(0.0, 0.0, 0.0, 1.0),
3233                    offset: crate::modifier::Point::new(6.0, 2.0),
3234                    blur_radius: 4.0,
3235                }),
3236                ..Default::default()
3237            })
3238            .append("shadow")
3239            .pop()
3240            .to_annotated_string();
3241
3242        let scaled = scale_annotated_font_sizes(&text, 0.5);
3243        let std::borrow::Cow::Owned(scaled) = scaled else {
3244            panic!("shadowed span should be scaled into owned text");
3245        };
3246        let shadow = scaled.span_styles[0]
3247            .item
3248            .shadow
3249            .expect("scaled span should retain shadow");
3250        assert_f32_close(shadow.offset.x, 3.0);
3251        assert_f32_close(shadow.offset.y, 1.0);
3252        assert_f32_close(shadow.blur_radius, 2.0);
3253    }
3254
3255    #[test]
3256    fn text_layout_options_does_not_wrap_on_tiny_width_delta() {
3257        let _app_context = crate::render_state::app_context_test_scope();
3258        let style = TextStyle {
3259            span_style: crate::text::SpanStyle {
3260                font_size: TextUnit::Sp(10.0),
3261                ..Default::default()
3262            },
3263            ..Default::default()
3264        };
3265        let options = TextLayoutOptions {
3266            overflow: TextOverflow::Clip,
3267            soft_wrap: true,
3268            max_lines: usize::MAX,
3269            min_lines: 1,
3270        };
3271
3272        let text = "if counter % 2 == 0";
3273        let exact_width = measure_text(&crate::text::AnnotatedString::from(text), &style).width;
3274        let prepared = prepare_text_layout(
3275            &crate::text::AnnotatedString::from(text),
3276            &style,
3277            options,
3278            Some(exact_width - 0.1),
3279        );
3280
3281        assert!(
3282            !prepared.text.text.contains('\n'),
3283            "unexpected line split: {:?}",
3284            prepared.text
3285        );
3286    }
3287
3288    #[test]
3289    fn line_break_mode_changes_wrap_strategy_contract() {
3290        let _app_context = crate::render_state::app_context_test_scope();
3291        let text = "This is an example text";
3292        let options = TextLayoutOptions {
3293            overflow: TextOverflow::Clip,
3294            soft_wrap: true,
3295            max_lines: usize::MAX,
3296            min_lines: 1,
3297        };
3298
3299        let simple = prepare_text_layout(
3300            &crate::text::AnnotatedString::from(text),
3301            &style_with_line_break(LineBreak::Simple),
3302            options,
3303            Some(120.0),
3304        );
3305        let heading = prepare_text_layout(
3306            &crate::text::AnnotatedString::from(text),
3307            &style_with_line_break(LineBreak::Heading),
3308            options,
3309            Some(120.0),
3310        );
3311        let paragraph = prepare_text_layout(
3312            &crate::text::AnnotatedString::from(text),
3313            &style_with_line_break(LineBreak::Paragraph),
3314            options,
3315            Some(50.0),
3316        );
3317
3318        assert_eq!(
3319            simple.text.text.lines().collect::<Vec<_>>(),
3320            vec!["This is an example", "text"]
3321        );
3322        assert_eq!(
3323            heading.text.text.lines().collect::<Vec<_>>(),
3324            vec!["This is an", "example text"]
3325        );
3326        assert_eq!(
3327            paragraph.text.text.lines().collect::<Vec<_>>(),
3328            vec!["This", "is an", "example", "text"]
3329        );
3330    }
3331
3332    #[test]
3333    fn hyphens_mode_changes_wrap_strategy_contract() {
3334        let _app_context = crate::render_state::app_context_test_scope();
3335        let text = "Transformation";
3336        let options = TextLayoutOptions {
3337            overflow: TextOverflow::Clip,
3338            soft_wrap: true,
3339            max_lines: usize::MAX,
3340            min_lines: 1,
3341        };
3342
3343        let auto = prepare_text_layout(
3344            &crate::text::AnnotatedString::from(text),
3345            &style_with_hyphens(Hyphens::Auto),
3346            options,
3347            Some(24.0),
3348        );
3349        let none = prepare_text_layout(
3350            &crate::text::AnnotatedString::from(text),
3351            &style_with_hyphens(Hyphens::None),
3352            options,
3353            Some(24.0),
3354        );
3355
3356        assert_eq!(
3357            auto.text.text.lines().collect::<Vec<_>>(),
3358            vec!["Tran", "sfor", "ma", "tion"]
3359        );
3360        assert_eq!(
3361            none.text.text.lines().collect::<Vec<_>>(),
3362            vec!["Tran", "sfor", "mati", "on"]
3363        );
3364        assert!(
3365            !auto.text.text.contains('-'),
3366            "automatic hyphenation should influence breaks without mutating source text content"
3367        );
3368    }
3369
3370    #[test]
3371    fn hyphens_auto_uses_measurer_hyphen_contract_when_valid() {
3372        let _app_context = crate::render_state::app_context_test_scope();
3373        let text = "Transformation";
3374        let style = style_with_hyphens(Hyphens::Auto);
3375        let options = TextLayoutOptions {
3376            overflow: TextOverflow::Clip,
3377            soft_wrap: true,
3378            max_lines: usize::MAX,
3379            min_lines: 1,
3380        };
3381
3382        let prepared = prepare_text_layout_fallback(
3383            &ContractBreakMeasurer { retreat: 1 },
3384            &crate::text::AnnotatedString::from(text),
3385            &style,
3386            options,
3387            Some(24.0),
3388        );
3389
3390        assert_eq!(
3391            prepared.text.text.lines().collect::<Vec<_>>(),
3392            vec!["Tra", "nsf", "orm", "ati", "on"]
3393        );
3394    }
3395
3396    #[test]
3397    fn hyphens_auto_falls_back_when_measurer_hyphen_contract_is_invalid() {
3398        let _app_context = crate::render_state::app_context_test_scope();
3399        let text = "Transformation";
3400        let style = style_with_hyphens(Hyphens::Auto);
3401        let options = TextLayoutOptions {
3402            overflow: TextOverflow::Clip,
3403            soft_wrap: true,
3404            max_lines: usize::MAX,
3405            min_lines: 1,
3406        };
3407
3408        let prepared = prepare_text_layout_fallback(
3409            &ContractBreakMeasurer { retreat: 10 },
3410            &crate::text::AnnotatedString::from(text),
3411            &style,
3412            options,
3413            Some(24.0),
3414        );
3415
3416        assert_eq!(
3417            prepared.text.text.lines().collect::<Vec<_>>(),
3418            vec!["Tran", "sfor", "ma", "tion"]
3419        );
3420    }
3421
3422    #[test]
3423    fn transformed_text_keeps_span_ranges_within_display_bounds() {
3424        let _app_context = crate::render_state::app_context_test_scope();
3425        let style = TextStyle {
3426            span_style: crate::text::SpanStyle {
3427                font_size: TextUnit::Sp(10.0),
3428                ..Default::default()
3429            },
3430            ..Default::default()
3431        };
3432        let options = TextLayoutOptions {
3433            overflow: TextOverflow::Ellipsis,
3434            soft_wrap: false,
3435            max_lines: 1,
3436            min_lines: 1,
3437        };
3438        let annotated = crate::text::AnnotatedString::builder()
3439            .push_style(crate::text::SpanStyle {
3440                font_weight: Some(crate::text::FontWeight::BOLD),
3441                ..Default::default()
3442            })
3443            .append("Styled overflow text sample")
3444            .pop()
3445            .to_annotated_string();
3446
3447        let prepared = prepare_text_layout(&annotated, &style, options, Some(40.0));
3448        assert!(prepared.did_overflow);
3449        for span in &prepared.text.span_styles {
3450            assert!(span.range.start < span.range.end);
3451            assert!(span.range.end <= prepared.text.text.len());
3452            assert!(prepared.text.text.is_char_boundary(span.range.start));
3453            assert!(prepared.text.text.is_char_boundary(span.range.end));
3454        }
3455    }
3456
3457    #[test]
3458    fn a_word_that_fits_stays_on_the_line_when_the_space_after_it_fits_too() {
3459        // The greedy break has to consider the widest prefix that fits as a
3460        // break candidate in its own right. When that prefix ends on a space —
3461        // the whole word fitted, and so did the space after it, and only the
3462        // NEXT word's first glyph did not — breaking there is the greedy
3463        // answer. Searching strictly before it threw the last word onto the
3464        // next line: on the Wear Credits screen "Designed and built for Wear /
3465        // OS." came out "Designed and built for / Wear OS.".
3466        let _app_context = crate::render_state::app_context_test_scope();
3467        let style = TextStyle {
3468            span_style: crate::text::SpanStyle {
3469                font_size: TextUnit::Sp(10.0),
3470                ..Default::default()
3471            },
3472            ..Default::default()
3473        };
3474        let options = TextLayoutOptions {
3475            overflow: TextOverflow::Clip,
3476            soft_wrap: true,
3477            max_lines: usize::MAX,
3478            min_lines: 1,
3479        };
3480        let width_of = |text: &str| {
3481            measure_text_with_options(
3482                &crate::text::AnnotatedString::from(text.to_string()),
3483                &style,
3484                options,
3485                None,
3486            )
3487            .width
3488        };
3489        // Wide enough for "aa bb " and not for "aa bb c" — the exact case.
3490        let fits = width_of("aa bb ");
3491        let overflows = width_of("aa bb c");
3492        assert!(overflows > fits, "the fixture needs a real gap here");
3493        let max_width = (fits + overflows) * 0.5;
3494
3495        let prepared = prepare_text_layout(
3496            &crate::text::AnnotatedString::from("aa bb cc".to_string()),
3497            &style,
3498            options,
3499            Some(max_width),
3500        );
3501        assert_eq!(prepared.text.text, "aa bb\ncc");
3502    }
3503
3504    #[test]
3505    fn wrapped_text_splits_styles_around_inserted_newlines() {
3506        let _app_context = crate::render_state::app_context_test_scope();
3507        let style = TextStyle {
3508            span_style: crate::text::SpanStyle {
3509                font_size: TextUnit::Sp(10.0),
3510                ..Default::default()
3511            },
3512            ..Default::default()
3513        };
3514        let options = TextLayoutOptions {
3515            overflow: TextOverflow::Clip,
3516            soft_wrap: true,
3517            max_lines: usize::MAX,
3518            min_lines: 1,
3519        };
3520        let annotated = crate::text::AnnotatedString::builder()
3521            .push_style(crate::text::SpanStyle {
3522                text_decoration: Some(crate::text::TextDecoration::UNDERLINE),
3523                ..Default::default()
3524            })
3525            .append("Wrapped style text example")
3526            .pop()
3527            .to_annotated_string();
3528
3529        let prepared = prepare_text_layout(&annotated, &style, options, Some(32.0));
3530        assert!(prepared.text.text.contains('\n'));
3531        assert!(!prepared.text.span_styles.is_empty());
3532        for span in &prepared.text.span_styles {
3533            assert!(span.range.end <= prepared.text.text.len());
3534        }
3535    }
3536
3537    #[test]
3538    fn mixed_font_size_segments_wrap_without_truncation() {
3539        let _app_context = crate::render_state::app_context_test_scope();
3540        let style = TextStyle {
3541            span_style: crate::text::SpanStyle {
3542                font_size: TextUnit::Sp(14.0),
3543                ..Default::default()
3544            },
3545            ..Default::default()
3546        };
3547        let options = TextLayoutOptions {
3548            overflow: TextOverflow::Clip,
3549            soft_wrap: true,
3550            max_lines: usize::MAX,
3551            min_lines: 1,
3552        };
3553        let annotated = crate::text::AnnotatedString::builder()
3554            .append("You can also ")
3555            .push_style(crate::text::SpanStyle {
3556                font_size: TextUnit::Sp(22.0),
3557                ..Default::default()
3558            })
3559            .append("change font size")
3560            .pop()
3561            .append(" dynamically mid-sentence!")
3562            .to_annotated_string();
3563
3564        let prepared = prepare_text_layout(&annotated, &style, options, Some(260.0));
3565        assert!(prepared.text.text.contains('\n'));
3566        assert!(prepared.text.text.contains("mid-sentence!"));
3567        assert!(!prepared.did_overflow);
3568    }
3569}