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