Skip to main content

cranpose_ui/text/
measure.rs

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