Skip to main content

cranpose_ui/text/
measure.rs

1use std::{
2    borrow::Cow,
3    cell::{Cell, RefCell},
4    collections::{hash_map::Entry, HashMap, VecDeque},
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                if start < range_end {
1617                    remapped.push(crate::text::RangeStyle {
1618                        item: style.item.clone(),
1619                        range: start..range_end,
1620                    });
1621                }
1622            }
1623        }
1624
1625        if let Some(start) = range_start.take() {
1626            if start < range_end {
1627                remapped.push(crate::text::RangeStyle {
1628                    item: style.item.clone(),
1629                    range: start..range_end,
1630                });
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            if let Some(width) = prefix_widths.width_for_char_range(start_idx, end_idx) {
1702                return Some(width);
1703            }
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        if measured_width <= max_width + WRAP_EPSILON {
1739            return vec![DisplayLine::from_measured_source_range(
1740                line_range,
1741                measured_width,
1742            )];
1743        }
1744    }
1745
1746    if matches!(line_break, LineBreak::Heading | LineBreak::Paragraph)
1747        && line_text.chars().any(char::is_whitespace)
1748    {
1749        if let Some(balanced) = wrap_line_with_word_balance(
1750            measurer,
1751            text,
1752            line_range.clone(),
1753            style,
1754            max_width,
1755            line_break,
1756        ) {
1757            return balanced;
1758        }
1759    }
1760
1761    wrap_line_greedy(
1762        measurer, text, line_range, style, max_width, line_break, hyphens,
1763    )
1764}
1765
1766fn wrap_line_greedy<M: TextMeasurer + ?Sized>(
1767    measurer: &M,
1768    text: &crate::text::AnnotatedString,
1769    line_range: Range<usize>,
1770    style: &TextStyle,
1771    max_width: f32,
1772    line_break: LineBreak,
1773    hyphens: Hyphens,
1774) -> Vec<DisplayLine> {
1775    let line_text = &text.text[line_range.clone()];
1776    let boundaries = char_boundaries(line_text);
1777    let measure_context =
1778        LineMeasureContext::new(measurer, text, &line_range, style, boundaries.len());
1779    if let Some(measured_width) =
1780        measure_context.prefix_width_for_char_range(0, boundaries.len() - 1)
1781    {
1782        if measured_width <= max_width + WRAP_EPSILON {
1783            return vec![DisplayLine::from_measured_source_range(
1784                line_range,
1785                measured_width,
1786            )];
1787        }
1788    }
1789    let mut wrapped = Vec::new();
1790    let mut start_idx = 0usize;
1791
1792    while start_idx < boundaries.len() - 1 {
1793        let mut low = start_idx + 1;
1794        let mut high = boundaries.len() - 1;
1795        let mut best = start_idx + 1;
1796
1797        while low <= high {
1798            let mid = (low + high) / 2;
1799            let width = measure_context.measure_char_range(&boundaries, start_idx, mid);
1800            if width <= max_width + WRAP_EPSILON || mid == start_idx + 1 {
1801                best = mid;
1802                low = mid + 1;
1803            } else {
1804                if mid == 0 {
1805                    break;
1806                }
1807                high = mid - 1;
1808            }
1809        }
1810
1811        let wrap_idx = choose_wrap_break(line_text, &boundaries, start_idx, best, line_break);
1812        let mut effective_wrap_idx = wrap_idx;
1813        let can_hyphenate = hyphens == Hyphens::Auto
1814            && wrap_idx == best
1815            && best < boundaries.len() - 1
1816            && is_break_inside_word(line_text, &boundaries, wrap_idx);
1817        if can_hyphenate {
1818            effective_wrap_idx = resolve_auto_hyphen_break(
1819                measurer,
1820                line_text,
1821                style,
1822                &boundaries,
1823                start_idx,
1824                wrap_idx,
1825            );
1826        }
1827
1828        // A break chosen at a word boundary leaves the space on the line it
1829        // broke after, whether or not that boundary happened to be `best`. The
1830        // space is not drawn and Compose does not count it towards the line's
1831        // width, so leaving it in shifts a centred line half a space left.
1832        let broke_at_word_boundary = effective_wrap_idx > start_idx
1833            && line_text[boundaries[effective_wrap_idx - 1]..boundaries[effective_wrap_idx]]
1834                .chars()
1835                .all(char::is_whitespace);
1836        let segment_start = boundaries[start_idx];
1837        let mut segment_end = boundaries[effective_wrap_idx];
1838        if wrap_idx != best || broke_at_word_boundary {
1839            segment_end = trim_segment_end_whitespace(line_text, segment_start, segment_end);
1840        }
1841        let segment_end_idx = boundary_index_for_byte(&boundaries, segment_end);
1842        wrapped.push(measure_context.display_line_for_char_range(
1843            &boundaries,
1844            start_idx,
1845            segment_end_idx,
1846        ));
1847
1848        start_idx = if wrap_idx != best || broke_at_word_boundary {
1849            skip_leading_whitespace(line_text, &boundaries, wrap_idx)
1850        } else {
1851            effective_wrap_idx
1852        };
1853    }
1854
1855    if wrapped.is_empty() {
1856        wrapped.push(DisplayLine::from_source_range(
1857            line_range.start..line_range.start,
1858        ));
1859    }
1860
1861    wrapped
1862}
1863
1864fn wrap_line_with_word_balance<M: TextMeasurer + ?Sized>(
1865    measurer: &M,
1866    text: &crate::text::AnnotatedString,
1867    line_range: Range<usize>,
1868    style: &TextStyle,
1869    max_width: f32,
1870    line_break: LineBreak,
1871) -> Option<Vec<DisplayLine>> {
1872    let line_text = &text.text[line_range.clone()];
1873    let boundaries = char_boundaries(line_text);
1874    let measure_context =
1875        LineMeasureContext::new(measurer, text, &line_range, style, boundaries.len());
1876    if let Some(measured_width) =
1877        measure_context.prefix_width_for_char_range(0, boundaries.len() - 1)
1878    {
1879        if measured_width <= max_width + WRAP_EPSILON {
1880            return Some(vec![DisplayLine::from_measured_source_range(
1881                line_range,
1882                measured_width,
1883            )]);
1884        }
1885    }
1886    let breakpoints = collect_word_breakpoints(line_text, &boundaries);
1887    if breakpoints.len() <= 2 {
1888        return None;
1889    }
1890
1891    let node_count = breakpoints.len();
1892    let mut best_cost = vec![f32::INFINITY; node_count];
1893    let mut next_index = vec![None; node_count];
1894    best_cost[node_count - 1] = 0.0;
1895
1896    for start in (0..node_count - 1).rev() {
1897        for end in start + 1..node_count {
1898            let start_byte = boundaries[breakpoints[start]];
1899            let end_byte = boundaries[breakpoints[end]];
1900            let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
1901            if trimmed_end <= start_byte {
1902                continue;
1903            }
1904            let segment_start_idx = breakpoints[start];
1905            let segment_end_idx = boundary_index_for_byte(&boundaries, trimmed_end);
1906            let segment_width =
1907                measure_context.measure_char_range(&boundaries, segment_start_idx, segment_end_idx);
1908            if segment_width > max_width + WRAP_EPSILON {
1909                continue;
1910            }
1911            if !best_cost[end].is_finite() {
1912                continue;
1913            }
1914            let slack = (max_width - segment_width).max(0.0);
1915            let is_last = end == node_count - 1;
1916            let segment_cost = match line_break {
1917                LineBreak::Heading => slack * slack,
1918                LineBreak::Paragraph => {
1919                    if is_last {
1920                        slack * slack * 0.16
1921                    } else {
1922                        slack * slack
1923                    }
1924                }
1925                LineBreak::Simple | LineBreak::Unspecified => slack * slack,
1926            };
1927            let candidate = segment_cost + best_cost[end];
1928            if candidate < best_cost[start] {
1929                best_cost[start] = candidate;
1930                next_index[start] = Some(end);
1931            }
1932        }
1933    }
1934
1935    let mut wrapped = Vec::new();
1936    let mut current = 0usize;
1937    while current < node_count - 1 {
1938        let next = next_index[current]?;
1939        let start_byte = boundaries[breakpoints[current]];
1940        let end_byte = boundaries[breakpoints[next]];
1941        let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
1942        if trimmed_end <= start_byte {
1943            return None;
1944        }
1945        let segment_start_idx = breakpoints[current];
1946        let segment_end_idx = boundary_index_for_byte(&boundaries, trimmed_end);
1947        wrapped.push(measure_context.display_line_for_char_range(
1948            &boundaries,
1949            segment_start_idx,
1950            segment_end_idx,
1951        ));
1952        current = next;
1953    }
1954
1955    if wrapped.is_empty() {
1956        return None;
1957    }
1958
1959    Some(wrapped)
1960}
1961
1962fn collect_word_breakpoints(line: &str, boundaries: &[usize]) -> Vec<usize> {
1963    let mut points = vec![0usize];
1964    for idx in 1..boundaries.len() - 1 {
1965        let prev = &line[boundaries[idx - 1]..boundaries[idx]];
1966        let current = &line[boundaries[idx]..boundaries[idx + 1]];
1967        if prev.chars().all(char::is_whitespace) && !current.chars().all(char::is_whitespace) {
1968            points.push(idx);
1969        }
1970    }
1971    let end = boundaries.len() - 1;
1972    if points.last().copied() != Some(end) {
1973        points.push(end);
1974    }
1975    points
1976}
1977
1978fn choose_wrap_break(
1979    line: &str,
1980    boundaries: &[usize],
1981    start_idx: usize,
1982    best: usize,
1983    _line_break: LineBreak,
1984) -> usize {
1985    if best >= boundaries.len() - 1 {
1986        return best;
1987    }
1988
1989    if best <= start_idx + 1 {
1990        return best;
1991    }
1992
1993    // `..=best`, not `..best`. `best` is the widest prefix that still fits, and
1994    // when the character before it is a space that prefix ends on a word
1995    // boundary: it is already the greedy break, and the right one. Excluding it
1996    // sent the search back to the PREVIOUS space and dropped a word that fitted
1997    // — "Designed and built for Wear / OS." came out "Designed and built for /
1998    // Wear OS.". It bites whenever the trailing space fits and the next word's
1999    // first glyph does not, which on a narrow watch column is most lines.
2000    for idx in (start_idx + 1..=best).rev() {
2001        let prev = &line[boundaries[idx - 1]..boundaries[idx]];
2002        if prev.chars().all(char::is_whitespace) {
2003            return idx;
2004        }
2005    }
2006    best
2007}
2008
2009fn is_break_inside_word(line: &str, boundaries: &[usize], break_idx: usize) -> bool {
2010    if break_idx == 0 || break_idx >= boundaries.len() - 1 {
2011        return false;
2012    }
2013    let prev = &line[boundaries[break_idx - 1]..boundaries[break_idx]];
2014    let next = &line[boundaries[break_idx]..boundaries[break_idx + 1]];
2015    !prev.chars().all(char::is_whitespace) && !next.chars().all(char::is_whitespace)
2016}
2017
2018fn resolve_auto_hyphen_break<M: TextMeasurer + ?Sized>(
2019    measurer: &M,
2020    line: &str,
2021    style: &TextStyle,
2022    boundaries: &[usize],
2023    start_idx: usize,
2024    break_idx: usize,
2025) -> usize {
2026    if let Some(candidate) = measurer.choose_auto_hyphen_break(line, style, start_idx, break_idx) {
2027        if is_valid_auto_hyphen_break(line, boundaries, start_idx, break_idx, candidate) {
2028            return candidate;
2029        }
2030    }
2031    choose_auto_hyphen_break_fallback(boundaries, start_idx, break_idx)
2032}
2033
2034fn is_valid_auto_hyphen_break(
2035    line: &str,
2036    boundaries: &[usize],
2037    start_idx: usize,
2038    break_idx: usize,
2039    candidate_idx: usize,
2040) -> bool {
2041    let end_idx = boundaries.len().saturating_sub(1);
2042    candidate_idx > start_idx
2043        && candidate_idx < end_idx
2044        && candidate_idx <= break_idx
2045        && candidate_idx >= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS
2046        && is_break_inside_word(line, boundaries, candidate_idx)
2047}
2048
2049fn choose_auto_hyphen_break_fallback(
2050    boundaries: &[usize],
2051    start_idx: usize,
2052    break_idx: usize,
2053) -> usize {
2054    let end_idx = boundaries.len().saturating_sub(1);
2055    if break_idx >= end_idx {
2056        return break_idx;
2057    }
2058    let trailing_len = end_idx.saturating_sub(break_idx);
2059    if trailing_len > 2 || break_idx <= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS {
2060        return break_idx;
2061    }
2062
2063    let min_break = start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS;
2064    let max_break = break_idx.saturating_sub(1);
2065    if min_break > max_break {
2066        return break_idx;
2067    }
2068
2069    let mut best_break = break_idx;
2070    let mut best_penalty = usize::MAX;
2071    for idx in min_break..=max_break {
2072        let candidate_trailing_len = end_idx.saturating_sub(idx);
2073        let candidate_prefix_len = idx.saturating_sub(start_idx);
2074        if candidate_prefix_len < AUTO_HYPHEN_MIN_SEGMENT_CHARS
2075            || candidate_trailing_len < AUTO_HYPHEN_MIN_TRAILING_CHARS
2076        {
2077            continue;
2078        }
2079
2080        let penalty = candidate_trailing_len.abs_diff(AUTO_HYPHEN_PREFERRED_TRAILING_CHARS);
2081        if penalty < best_penalty {
2082            best_penalty = penalty;
2083            best_break = idx;
2084            if penalty == 0 {
2085                break;
2086            }
2087        }
2088    }
2089    best_break
2090}
2091
2092fn skip_leading_whitespace(line: &str, boundaries: &[usize], mut idx: usize) -> usize {
2093    while idx < boundaries.len() - 1 {
2094        let ch = &line[boundaries[idx]..boundaries[idx + 1]];
2095        if !ch.chars().all(char::is_whitespace) {
2096            break;
2097        }
2098        idx += 1;
2099    }
2100    idx
2101}
2102
2103fn apply_line_overflow<M: TextMeasurer + ?Sized>(
2104    measurer: &M,
2105    line: &str,
2106    style: &TextStyle,
2107    max_width: Option<f32>,
2108    options: TextLayoutOptions,
2109    is_last_visible_line: bool,
2110    single_line_ellipsis: bool,
2111) -> String {
2112    if options.overflow == TextOverflow::Clip || !is_last_visible_line {
2113        return line.to_string();
2114    }
2115
2116    let Some(width_limit) = max_width else {
2117        return match options.overflow {
2118            TextOverflow::Ellipsis => format!("{line}{ELLIPSIS}"),
2119            TextOverflow::StartEllipsis => format!("{ELLIPSIS}{line}"),
2120            TextOverflow::MiddleEllipsis => format!("{ELLIPSIS}{line}"),
2121            TextOverflow::Clip | TextOverflow::Visible | TextOverflow::ScaleDown { .. } => {
2122                line.to_string()
2123            }
2124        };
2125    };
2126
2127    match options.overflow {
2128        TextOverflow::Clip | TextOverflow::Visible => line.to_string(),
2129        TextOverflow::Ellipsis => fit_end_ellipsis(measurer, line, style, width_limit),
2130        TextOverflow::StartEllipsis => {
2131            if single_line_ellipsis {
2132                fit_start_ellipsis(measurer, line, style, width_limit)
2133            } else {
2134                line.to_string()
2135            }
2136        }
2137        TextOverflow::MiddleEllipsis => {
2138            if single_line_ellipsis {
2139                fit_middle_ellipsis(measurer, line, style, width_limit)
2140            } else {
2141                line.to_string()
2142            }
2143        }
2144        TextOverflow::ScaleDown { .. } => line.to_string(),
2145    }
2146}
2147
2148fn fit_end_ellipsis<M: TextMeasurer + ?Sized>(
2149    measurer: &M,
2150    line: &str,
2151    style: &TextStyle,
2152    max_width: f32,
2153) -> String {
2154    if measurer
2155        .measure(&crate::text::AnnotatedString::from(line), style)
2156        .width
2157        <= max_width + WRAP_EPSILON
2158    {
2159        return line.to_string();
2160    }
2161
2162    let ellipsis_width = measurer
2163        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
2164        .width;
2165    if ellipsis_width > max_width + WRAP_EPSILON {
2166        return String::new();
2167    }
2168
2169    let boundaries = char_boundaries(line);
2170    let mut low = 0usize;
2171    let mut high = boundaries.len() - 1;
2172    let mut best = 0usize;
2173
2174    while low <= high {
2175        let mid = (low + high) / 2;
2176        let prefix = &line[..boundaries[mid]];
2177        let candidate = format!("{prefix}{ELLIPSIS}");
2178        let width = measurer
2179            .measure(
2180                &crate::text::AnnotatedString::from(candidate.as_str()),
2181                style,
2182            )
2183            .width;
2184        if width <= max_width + WRAP_EPSILON {
2185            best = mid;
2186            low = mid + 1;
2187        } else if mid == 0 {
2188            break;
2189        } else {
2190            high = mid - 1;
2191        }
2192    }
2193
2194    format!("{}{}", &line[..boundaries[best]], ELLIPSIS)
2195}
2196
2197fn fit_start_ellipsis<M: TextMeasurer + ?Sized>(
2198    measurer: &M,
2199    line: &str,
2200    style: &TextStyle,
2201    max_width: f32,
2202) -> String {
2203    if measurer
2204        .measure(&crate::text::AnnotatedString::from(line), style)
2205        .width
2206        <= max_width + WRAP_EPSILON
2207    {
2208        return line.to_string();
2209    }
2210
2211    let ellipsis_width = measurer
2212        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
2213        .width;
2214    if ellipsis_width > max_width + WRAP_EPSILON {
2215        return String::new();
2216    }
2217
2218    let boundaries = char_boundaries(line);
2219    let mut low = 0usize;
2220    let mut high = boundaries.len() - 1;
2221    let mut best = boundaries.len() - 1;
2222
2223    while low <= high {
2224        let mid = (low + high) / 2;
2225        let suffix = &line[boundaries[mid]..];
2226        let candidate = format!("{ELLIPSIS}{suffix}");
2227        let width = measurer
2228            .measure(
2229                &crate::text::AnnotatedString::from(candidate.as_str()),
2230                style,
2231            )
2232            .width;
2233        if width <= max_width + WRAP_EPSILON {
2234            best = mid;
2235            if mid == 0 {
2236                break;
2237            }
2238            high = mid - 1;
2239        } else {
2240            low = mid + 1;
2241        }
2242    }
2243
2244    format!("{ELLIPSIS}{}", &line[boundaries[best]..])
2245}
2246
2247fn fit_middle_ellipsis<M: TextMeasurer + ?Sized>(
2248    measurer: &M,
2249    line: &str,
2250    style: &TextStyle,
2251    max_width: f32,
2252) -> String {
2253    if measurer
2254        .measure(&crate::text::AnnotatedString::from(line), style)
2255        .width
2256        <= max_width + WRAP_EPSILON
2257    {
2258        return line.to_string();
2259    }
2260
2261    let ellipsis_width = measurer
2262        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
2263        .width;
2264    if ellipsis_width > max_width + WRAP_EPSILON {
2265        return String::new();
2266    }
2267
2268    let boundaries = char_boundaries(line);
2269    let total_chars = boundaries.len().saturating_sub(1);
2270    for keep in (0..=total_chars).rev() {
2271        let keep_start = keep.div_ceil(2);
2272        let keep_end = keep / 2;
2273        let start = &line[..boundaries[keep_start]];
2274        let end_start = boundaries[total_chars.saturating_sub(keep_end)];
2275        let end = &line[end_start..];
2276        let candidate = format!("{start}{ELLIPSIS}{end}");
2277        if measurer
2278            .measure(
2279                &crate::text::AnnotatedString::from(candidate.as_str()),
2280                style,
2281            )
2282            .width
2283            <= max_width + WRAP_EPSILON
2284        {
2285            return candidate;
2286        }
2287    }
2288
2289    ELLIPSIS.to_string()
2290}
2291
2292fn char_boundaries(text: &str) -> Vec<usize> {
2293    let mut out = Vec::with_capacity(text.chars().count() + 1);
2294    out.push(0);
2295    for (idx, _) in text.char_indices() {
2296        if idx != 0 {
2297            out.push(idx);
2298        }
2299    }
2300    out.push(text.len());
2301    out
2302}
2303
2304#[cfg(test)]
2305mod tests {
2306    use std::cell::Cell;
2307
2308    use super::*;
2309    use crate::{
2310        text::{Hyphens, LineBreak, ParagraphStyle, TextUnit},
2311        text_layout_result::TextLayoutResult,
2312    };
2313
2314    #[test]
2315    fn text_layout_telemetry_env_flag_is_not_process_cached() {
2316        let source = include_str!("measure.rs");
2317        let once_lock = ["Once", "Lock"].concat();
2318        let cached_init_call = ["get", "_or", "_init"].concat();
2319
2320        assert!(
2321            !source.contains(&once_lock) && !source.contains(&cached_init_call),
2322            "text layout telemetry env flag must be read at the diagnostic boundary"
2323        );
2324    }
2325
2326    #[test]
2327    fn prepared_layout_cache_distinguishes_visual_styles() {
2328        let service = TextService::new();
2329        let text = crate::text::AnnotatedString::from("tinted".to_string());
2330        let options = TextLayoutOptions::default();
2331
2332        let mut style = TextStyle::default();
2333        style.span_style.color = Some(crate::Color(1.0, 0.0, 0.0, 1.0));
2334        let red = service.prepare_with_options(None, &text, &style, options, None);
2335
2336        style.span_style.color = Some(crate::Color(0.0, 0.0, 1.0, 1.0));
2337        let blue = service.prepare_with_options(None, &text, &style, options, None);
2338
2339        assert_eq!(
2340            red.visual_style.span_style.color,
2341            Some(crate::Color(1.0, 0.0, 0.0, 1.0)),
2342        );
2343        assert_eq!(
2344            blue.visual_style.span_style.color,
2345            Some(crate::Color(0.0, 0.0, 1.0, 1.0)),
2346            "a color-only style change must not be served a stale prepared layout \
2347             (measurement hashes ignore visual attributes by design)"
2348        );
2349    }
2350
2351    #[test]
2352    fn system_font_scale_changes_sp_measurement_and_prepared_text() {
2353        let _app_context = crate::render_state::app_context_test_scope();
2354        let text = crate::text::AnnotatedString::from("scale me");
2355        let style = TextStyle {
2356            span_style: crate::text::SpanStyle {
2357                font_size: TextUnit::Sp(10.0),
2358                ..Default::default()
2359            },
2360            ..Default::default()
2361        };
2362
2363        let unscaled = measure_text(&text, &style);
2364        crate::set_font_scale(2.0);
2365        let scaled = measure_text(&text, &style);
2366        let prepared = prepare_text_layout(&text, &style, TextLayoutOptions::default(), None);
2367
2368        assert!((scaled.width - unscaled.width * 2.0).abs() <= f32::EPSILON);
2369        assert!((scaled.height - unscaled.height * 2.0).abs() <= f32::EPSILON);
2370        assert_eq!(
2371            prepared.visual_style.span_style.font_size,
2372            TextUnit::Sp(20.0)
2373        );
2374    }
2375
2376    #[test]
2377    fn a_platform_curve_resolves_an_sp_where_the_platform_does_and_not_where_a_multiplier_would() {
2378        // The defect this exists for: Wear Material 3 sets a Settings chip's
2379        // secondary line in `labelSmall`, which is 13sp. On Android 14 at the
2380        // font scale Wear calls large the platform resolves that to 16.36dp,
2381        // not to 13 * 1.24 = 16.12 — measured on a Wear OS 5 emulator through
2382        // both `TypedValue.applyDimension(COMPLEX_UNIT_SP, ..)` and
2383        // `androidx.compose.ui.unit.Density(context)`, which agree exactly.
2384        // 1.5% is small and it is the difference between the string
2385        // `SOLID  next at 3 gold` taking one line and taking two.
2386        let _app_context = crate::render_state::app_context_test_scope();
2387        let text = crate::text::AnnotatedString::from("SOLID  next at 3 gold");
2388        let style = TextStyle {
2389            span_style: crate::text::SpanStyle {
2390                font_size: TextUnit::Sp(13.0),
2391                letter_spacing: TextUnit::Sp(0.4),
2392                ..Default::default()
2393            },
2394            ..Default::default()
2395        };
2396
2397        crate::set_font_scale_curve(FontScaleCurve::from_samples(
2398            1.24,
2399            &[
2400                (8.0, 9.92),
2401                (10.0, 12.4),
2402                (12.0, 14.88),
2403                (14.0, 17.84),
2404                (16.0, 19.36),
2405                (18.0, 20.88),
2406                (20.0, 22.88),
2407                (24.0, 25.92),
2408                (30.0, 30.0),
2409                (100.0, 100.0),
2410            ],
2411        ));
2412        let prepared = prepare_text_layout(&text, &style, TextLayoutOptions::default(), None);
2413        assert_eq!(
2414            prepared.visual_style.span_style.font_size,
2415            TextUnit::Sp(16.36)
2416        );
2417        // The tracking is below the first knot, where the platform's curve is
2418        // the multiplication again, so it must NOT move off it.
2419        assert_eq!(
2420            prepared.visual_style.span_style.letter_spacing,
2421            TextUnit::Sp(0.4 * 1.24)
2422        );
2423        // And the setting itself is still the setting, whatever the table then
2424        // does with an individual size.
2425        assert_eq!(crate::current_font_scale(), 1.24);
2426
2427        crate::set_font_scale(1.24);
2428        let multiplied = prepare_text_layout(&text, &style, TextLayoutOptions::default(), None);
2429        assert_eq!(
2430            multiplied.visual_style.span_style.font_size,
2431            TextUnit::Sp(13.0 * 1.24)
2432        );
2433    }
2434
2435    #[test]
2436    fn text_service_cache_retains_large_lazy_text_working_set() {
2437        let mut cache = BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY);
2438        let metrics = TextMetrics {
2439            width: 1.0,
2440            height: 1.0,
2441            line_height: 1.0,
2442            line_count: 1,
2443        };
2444
2445        for index in 0..4096u64 {
2446            cache.insert(
2447                TextBaseCacheKey {
2448                    text_hash: index,
2449                    style_hash: 7,
2450                },
2451                metrics,
2452            );
2453        }
2454
2455        for index in 0..4096u64 {
2456            assert!(
2457                cache
2458                    .get(&TextBaseCacheKey {
2459                        text_hash: index,
2460                        style_hash: 7,
2461                    })
2462                    .is_some(),
2463                "large lazy text working-set entry {index} was evicted too early"
2464            );
2465        }
2466    }
2467
2468    struct ContractBreakMeasurer {
2469        retreat: usize,
2470    }
2471
2472    impl TextMeasurer for ContractBreakMeasurer {
2473        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2474            MonospacedTextMeasurer.measure(
2475                &crate::text::AnnotatedString::from(text.text.as_str()),
2476                style,
2477            )
2478        }
2479
2480        fn get_offset_for_position(
2481            &self,
2482            text: &crate::text::AnnotatedString,
2483            style: &TextStyle,
2484            x: f32,
2485            y: f32,
2486        ) -> usize {
2487            MonospacedTextMeasurer.get_offset_for_position(
2488                &crate::text::AnnotatedString::from(text.text.as_str()),
2489                style,
2490                x,
2491                y,
2492            )
2493        }
2494
2495        fn get_cursor_x_for_offset(
2496            &self,
2497            text: &crate::text::AnnotatedString,
2498            style: &TextStyle,
2499            offset: usize,
2500        ) -> f32 {
2501            MonospacedTextMeasurer.get_cursor_x_for_offset(
2502                &crate::text::AnnotatedString::from(text.text.as_str()),
2503                style,
2504                offset,
2505            )
2506        }
2507
2508        fn layout(
2509            &self,
2510            text: &crate::text::AnnotatedString,
2511            style: &TextStyle,
2512        ) -> TextLayoutResult {
2513            MonospacedTextMeasurer.layout(
2514                &crate::text::AnnotatedString::from(text.text.as_str()),
2515                style,
2516            )
2517        }
2518
2519        fn choose_auto_hyphen_break(
2520            &self,
2521            _line: &str,
2522            _style: &TextStyle,
2523            _segment_start_char: usize,
2524            measured_break_char: usize,
2525        ) -> Option<usize> {
2526            measured_break_char.checked_sub(self.retreat)
2527        }
2528    }
2529
2530    struct CountingTextMeasurer {
2531        measure_calls: Rc<Cell<usize>>,
2532        layout_calls: Rc<Cell<usize>>,
2533    }
2534
2535    impl CountingTextMeasurer {
2536        fn new(measure_calls: Rc<Cell<usize>>, layout_calls: Rc<Cell<usize>>) -> Self {
2537            Self {
2538                measure_calls,
2539                layout_calls,
2540            }
2541        }
2542    }
2543
2544    impl TextMeasurer for CountingTextMeasurer {
2545        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2546            self.measure_calls.set(self.measure_calls.get() + 1);
2547            MonospacedTextMeasurer.measure(text, style)
2548        }
2549
2550        fn get_offset_for_position(
2551            &self,
2552            text: &crate::text::AnnotatedString,
2553            style: &TextStyle,
2554            x: f32,
2555            y: f32,
2556        ) -> usize {
2557            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2558        }
2559
2560        fn get_cursor_x_for_offset(
2561            &self,
2562            text: &crate::text::AnnotatedString,
2563            style: &TextStyle,
2564            offset: usize,
2565        ) -> f32 {
2566            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2567        }
2568
2569        fn layout(
2570            &self,
2571            text: &crate::text::AnnotatedString,
2572            style: &TextStyle,
2573        ) -> TextLayoutResult {
2574            self.layout_calls.set(self.layout_calls.get() + 1);
2575            MonospacedTextMeasurer.layout(text, style)
2576        }
2577    }
2578
2579    struct CountingPreparedTextMeasurer {
2580        prepare_calls: Rc<Cell<usize>>,
2581    }
2582
2583    impl CountingPreparedTextMeasurer {
2584        fn new(prepare_calls: Rc<Cell<usize>>) -> Self {
2585            Self { prepare_calls }
2586        }
2587    }
2588
2589    struct PrefixWidthCountingMeasurer {
2590        prefix_calls: Rc<Cell<usize>>,
2591        subsequence_calls: Rc<Cell<usize>>,
2592    }
2593
2594    impl PrefixWidthCountingMeasurer {
2595        fn new(prefix_calls: Rc<Cell<usize>>, subsequence_calls: Rc<Cell<usize>>) -> Self {
2596            Self {
2597                prefix_calls,
2598                subsequence_calls,
2599            }
2600        }
2601    }
2602
2603    impl TextMeasurer for PrefixWidthCountingMeasurer {
2604        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2605            MonospacedTextMeasurer.measure(text, style)
2606        }
2607
2608        fn measure_subsequence(
2609            &self,
2610            text: &crate::text::AnnotatedString,
2611            range: Range<usize>,
2612            style: &TextStyle,
2613        ) -> TextMetrics {
2614            self.subsequence_calls.set(self.subsequence_calls.get() + 1);
2615            MonospacedTextMeasurer.measure_subsequence(text, range, style)
2616        }
2617
2618        fn measure_line_prefix_widths(
2619            &self,
2620            text: &crate::text::AnnotatedString,
2621            line_range: Range<usize>,
2622            style: &TextStyle,
2623        ) -> Option<TextLinePrefixWidths> {
2624            self.prefix_calls.set(self.prefix_calls.get() + 1);
2625            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2626        }
2627
2628        fn get_offset_for_position(
2629            &self,
2630            text: &crate::text::AnnotatedString,
2631            style: &TextStyle,
2632            x: f32,
2633            y: f32,
2634        ) -> usize {
2635            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2636        }
2637
2638        fn get_cursor_x_for_offset(
2639            &self,
2640            text: &crate::text::AnnotatedString,
2641            style: &TextStyle,
2642            offset: usize,
2643        ) -> f32 {
2644            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2645        }
2646
2647        fn layout(
2648            &self,
2649            text: &crate::text::AnnotatedString,
2650            style: &TextStyle,
2651        ) -> TextLayoutResult {
2652            MonospacedTextMeasurer.layout(text, style)
2653        }
2654    }
2655
2656    struct LineHeightCountingMeasurer {
2657        measure_calls: Rc<Cell<usize>>,
2658        line_height_calls: Rc<Cell<usize>>,
2659    }
2660
2661    struct FitProbeCountingMeasurer {
2662        line_width_calls: Rc<Cell<usize>>,
2663        prefix_calls: Rc<Cell<usize>>,
2664    }
2665
2666    impl FitProbeCountingMeasurer {
2667        fn new(line_width_calls: Rc<Cell<usize>>, prefix_calls: Rc<Cell<usize>>) -> Self {
2668            Self {
2669                line_width_calls,
2670                prefix_calls,
2671            }
2672        }
2673    }
2674
2675    impl LineHeightCountingMeasurer {
2676        fn new(measure_calls: Rc<Cell<usize>>, line_height_calls: Rc<Cell<usize>>) -> Self {
2677            Self {
2678                measure_calls,
2679                line_height_calls,
2680            }
2681        }
2682    }
2683
2684    impl TextMeasurer for LineHeightCountingMeasurer {
2685        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2686            self.measure_calls.set(self.measure_calls.get() + 1);
2687            MonospacedTextMeasurer.measure(text, style)
2688        }
2689
2690        fn measure_line_prefix_widths(
2691            &self,
2692            text: &crate::text::AnnotatedString,
2693            line_range: Range<usize>,
2694            style: &TextStyle,
2695        ) -> Option<TextLinePrefixWidths> {
2696            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2697        }
2698
2699        fn line_height(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> f32 {
2700            self.line_height_calls.set(self.line_height_calls.get() + 1);
2701            MonospacedTextMeasurer.line_height(text, style)
2702        }
2703
2704        fn get_offset_for_position(
2705            &self,
2706            text: &crate::text::AnnotatedString,
2707            style: &TextStyle,
2708            x: f32,
2709            y: f32,
2710        ) -> usize {
2711            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2712        }
2713
2714        fn get_cursor_x_for_offset(
2715            &self,
2716            text: &crate::text::AnnotatedString,
2717            style: &TextStyle,
2718            offset: usize,
2719        ) -> f32 {
2720            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2721        }
2722
2723        fn layout(
2724            &self,
2725            text: &crate::text::AnnotatedString,
2726            style: &TextStyle,
2727        ) -> TextLayoutResult {
2728            MonospacedTextMeasurer.layout(text, style)
2729        }
2730    }
2731
2732    impl TextMeasurer for FitProbeCountingMeasurer {
2733        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2734            MonospacedTextMeasurer.measure(text, style)
2735        }
2736
2737        fn measure_line_width(
2738            &self,
2739            text: &crate::text::AnnotatedString,
2740            line_range: Range<usize>,
2741            style: &TextStyle,
2742        ) -> Option<f32> {
2743            self.line_width_calls.set(self.line_width_calls.get() + 1);
2744            MonospacedTextMeasurer.measure_line_width(text, line_range, style)
2745        }
2746
2747        fn measure_line_prefix_widths(
2748            &self,
2749            text: &crate::text::AnnotatedString,
2750            line_range: Range<usize>,
2751            style: &TextStyle,
2752        ) -> Option<TextLinePrefixWidths> {
2753            self.prefix_calls.set(self.prefix_calls.get() + 1);
2754            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2755        }
2756
2757        fn get_offset_for_position(
2758            &self,
2759            text: &crate::text::AnnotatedString,
2760            style: &TextStyle,
2761            x: f32,
2762            y: f32,
2763        ) -> usize {
2764            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2765        }
2766
2767        fn get_cursor_x_for_offset(
2768            &self,
2769            text: &crate::text::AnnotatedString,
2770            style: &TextStyle,
2771            offset: usize,
2772        ) -> f32 {
2773            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2774        }
2775
2776        fn layout(
2777            &self,
2778            text: &crate::text::AnnotatedString,
2779            style: &TextStyle,
2780        ) -> TextLayoutResult {
2781            MonospacedTextMeasurer.layout(text, style)
2782        }
2783    }
2784
2785    impl TextMeasurer for CountingPreparedTextMeasurer {
2786        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2787            MonospacedTextMeasurer.measure(text, style)
2788        }
2789
2790        fn prepare_with_options_for_node(
2791            &self,
2792            _node_id: Option<NodeId>,
2793            text: &crate::text::AnnotatedString,
2794            style: &TextStyle,
2795            options: TextLayoutOptions,
2796            max_width: Option<f32>,
2797        ) -> PreparedTextLayout {
2798            self.prepare_calls.set(self.prepare_calls.get() + 1);
2799            MonospacedTextMeasurer.prepare_with_options(text, style, options, max_width)
2800        }
2801
2802        fn get_offset_for_position(
2803            &self,
2804            text: &crate::text::AnnotatedString,
2805            style: &TextStyle,
2806            x: f32,
2807            y: f32,
2808        ) -> usize {
2809            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2810        }
2811
2812        fn get_cursor_x_for_offset(
2813            &self,
2814            text: &crate::text::AnnotatedString,
2815            style: &TextStyle,
2816            offset: usize,
2817        ) -> f32 {
2818            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2819        }
2820
2821        fn layout(
2822            &self,
2823            text: &crate::text::AnnotatedString,
2824            style: &TextStyle,
2825        ) -> TextLayoutResult {
2826            MonospacedTextMeasurer.layout(text, style)
2827        }
2828    }
2829
2830    #[test]
2831    fn text_service_routes_measurement_through_current_measurer() {
2832        let _app_context = crate::render_state::app_context_test_scope();
2833        let service = TextService::from_measurer(Rc::new(MonospacedTextMeasurer));
2834        let text = crate::text::AnnotatedString::from("abc");
2835        let style = TextStyle::default();
2836
2837        let metrics = service.with_measurer(|measurer| measurer.measure(&text, &style));
2838
2839        assert!(metrics.width > 0.0);
2840        assert!(metrics.height > 0.0);
2841    }
2842
2843    #[test]
2844    fn text_service_caches_metrics_and_layouts_per_context() {
2845        let _app_context = crate::render_state::app_context_test_scope();
2846        let measure_calls = Rc::new(Cell::new(0));
2847        let layout_calls = Rc::new(Cell::new(0));
2848        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2849            Rc::clone(&measure_calls),
2850            Rc::clone(&layout_calls),
2851        )));
2852        let text = crate::text::AnnotatedString::from("cached text");
2853        let style = TextStyle::default();
2854
2855        let first_metrics = service.measure(Some(7), &text, &style);
2856        let second_metrics = service.measure(Some(7), &text, &style);
2857        let first_layout = service.layout(&text, &style);
2858        let second_layout = service.layout(&text, &style);
2859
2860        assert_eq!(first_metrics, second_metrics);
2861        assert_eq!(first_layout.width, second_layout.width);
2862        assert_eq!(measure_calls.get(), 1);
2863        assert_eq!(layout_calls.get(), 1);
2864    }
2865
2866    #[test]
2867    fn text_service_reuses_metrics_cache_across_node_ids() {
2868        let _app_context = crate::render_state::app_context_test_scope();
2869        let measure_calls = Rc::new(Cell::new(0));
2870        let layout_calls = Rc::new(Cell::new(0));
2871        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2872            Rc::clone(&measure_calls),
2873            Rc::clone(&layout_calls),
2874        )));
2875        let text = crate::text::AnnotatedString::from("same lazy item text");
2876        let style = TextStyle::default();
2877
2878        let first_metrics = service.measure(Some(7), &text, &style);
2879        let second_metrics = service.measure(Some(8), &text, &style);
2880
2881        assert_eq!(first_metrics, second_metrics);
2882        assert_eq!(measure_calls.get(), 1);
2883    }
2884
2885    #[test]
2886    fn text_service_reuses_prepared_layout_cache_across_node_ids() {
2887        let _app_context = crate::render_state::app_context_test_scope();
2888        let prepare_calls = Rc::new(Cell::new(0));
2889        let service = TextService::from_measurer(Rc::new(CountingPreparedTextMeasurer::new(
2890            Rc::clone(&prepare_calls),
2891        )));
2892        let text = crate::text::AnnotatedString::from("same prepared lazy item text");
2893        let style = TextStyle::default();
2894        let options = TextLayoutOptions::default();
2895
2896        let first = service.prepare_with_options(Some(9), &text, &style, options, Some(120.0));
2897        let second = service.prepare_with_options(Some(10), &text, &style, options, Some(120.0));
2898
2899        assert_eq!(first.metrics, second.metrics);
2900        assert_eq!(prepare_calls.get(), 1);
2901    }
2902
2903    #[test]
2904    fn text_service_clears_caches_when_measurer_changes() {
2905        let _app_context = crate::render_state::app_context_test_scope();
2906        let first_measure_calls = Rc::new(Cell::new(0));
2907        let second_measure_calls = Rc::new(Cell::new(0));
2908        let layout_calls = Rc::new(Cell::new(0));
2909        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2910            Rc::clone(&first_measure_calls),
2911            Rc::clone(&layout_calls),
2912        )));
2913        let text = crate::text::AnnotatedString::from("cached text");
2914        let style = TextStyle::default();
2915
2916        let _ = service.measure(None, &text, &style);
2917        let _ = service.measure(None, &text, &style);
2918        service.set_measurer(Rc::new(CountingTextMeasurer::new(
2919            Rc::clone(&second_measure_calls),
2920            Rc::clone(&layout_calls),
2921        )));
2922        let _ = service.measure(None, &text, &style);
2923
2924        assert_eq!(first_measure_calls.get(), 1);
2925        assert_eq!(second_measure_calls.get(), 1);
2926    }
2927
2928    #[test]
2929    fn text_wrapping_uses_prefix_widths_without_subsequence_measurement() {
2930        let _app_context = crate::render_state::app_context_test_scope();
2931        let prefix_calls = Rc::new(Cell::new(0));
2932        let subsequence_calls = Rc::new(Cell::new(0));
2933        set_text_measurer(PrefixWidthCountingMeasurer::new(
2934            Rc::clone(&prefix_calls),
2935            Rc::clone(&subsequence_calls),
2936        ));
2937        let style = TextStyle {
2938            span_style: crate::text::SpanStyle {
2939                font_size: TextUnit::Sp(10.0),
2940                ..Default::default()
2941            },
2942            ..Default::default()
2943        };
2944        let options = TextLayoutOptions {
2945            overflow: TextOverflow::Clip,
2946            soft_wrap: true,
2947            max_lines: usize::MAX,
2948            min_lines: 1,
2949        };
2950        let text = crate::text::AnnotatedString::from("word ".repeat(80).as_str());
2951
2952        let prepared = prepare_text_layout(&text, &style, options, Some(80.0));
2953
2954        assert!(prepared.metrics.line_count > 1);
2955        assert!(
2956            prefix_calls.get() > 0,
2957            "wrapping should request a line prefix width plan"
2958        );
2959        assert_eq!(
2960            subsequence_calls.get(),
2961            0,
2962            "prefix-capable wrapping should not probe candidate substrings"
2963        );
2964    }
2965
2966    #[test]
2967    fn text_wrapping_skips_prefix_widths_when_fit_probe_says_line_fits() {
2968        let _app_context = crate::render_state::app_context_test_scope();
2969        let line_width_calls = Rc::new(Cell::new(0));
2970        let prefix_calls = Rc::new(Cell::new(0));
2971        set_text_measurer(FitProbeCountingMeasurer::new(
2972            Rc::clone(&line_width_calls),
2973            Rc::clone(&prefix_calls),
2974        ));
2975        let style = TextStyle {
2976            span_style: crate::text::SpanStyle {
2977                font_size: TextUnit::Sp(10.0),
2978                ..Default::default()
2979            },
2980            ..Default::default()
2981        };
2982        let text = crate::text::AnnotatedString::from("fits without per-glyph prefix widths");
2983
2984        let prepared =
2985            prepare_text_layout(&text, &style, TextLayoutOptions::default(), Some(800.0));
2986
2987        assert_eq!(prepared.metrics.line_count, 1);
2988        assert_eq!(line_width_calls.get(), 1);
2989        assert_eq!(
2990            prefix_calls.get(),
2991            0,
2992            "fitting lines should not allocate prefix-width plans"
2993        );
2994    }
2995
2996    #[test]
2997    fn prepare_text_layout_uses_line_height_without_full_text_measurement() {
2998        let _app_context = crate::render_state::app_context_test_scope();
2999        let measure_calls = Rc::new(Cell::new(0));
3000        let line_height_calls = Rc::new(Cell::new(0));
3001        let measurer = LineHeightCountingMeasurer::new(
3002            Rc::clone(&measure_calls),
3003            Rc::clone(&line_height_calls),
3004        );
3005        let text = crate::text::AnnotatedString::from(
3006            "one two three four five six seven eight nine ten eleven twelve",
3007        );
3008
3009        let prepared = prepare_text_layout_with_measurer_for_node(
3010            &measurer,
3011            Some(7),
3012            &text,
3013            &TextStyle::default(),
3014            TextLayoutOptions::default(),
3015            Some(96.0),
3016        );
3017
3018        assert!(prepared.metrics.height > 0.0);
3019        assert_eq!(line_height_calls.get(), 1);
3020        assert_eq!(
3021            measure_calls.get(),
3022            0,
3023            "line-height lookup must not re-measure the whole paragraph"
3024        );
3025    }
3026
3027    fn style_with_line_break(line_break: LineBreak) -> TextStyle {
3028        TextStyle {
3029            span_style: crate::text::SpanStyle {
3030                font_size: TextUnit::Sp(10.0),
3031                ..Default::default()
3032            },
3033            paragraph_style: ParagraphStyle {
3034                line_break,
3035                ..Default::default()
3036            },
3037        }
3038    }
3039
3040    fn style_with_hyphens(hyphens: Hyphens) -> TextStyle {
3041        TextStyle {
3042            span_style: crate::text::SpanStyle {
3043                font_size: TextUnit::Sp(10.0),
3044                ..Default::default()
3045            },
3046            paragraph_style: ParagraphStyle {
3047                hyphens,
3048                ..Default::default()
3049            },
3050        }
3051    }
3052
3053    fn assert_f32_close(actual: f32, expected: f32) {
3054        assert!(
3055            (actual - expected).abs() <= 0.01,
3056            "actual={actual}, expected={expected}"
3057        );
3058    }
3059
3060    #[test]
3061    fn text_layout_options_wraps_and_limits_lines() {
3062        let _app_context = crate::render_state::app_context_test_scope();
3063        let style = TextStyle {
3064            span_style: crate::text::SpanStyle {
3065                font_size: TextUnit::Sp(10.0),
3066                ..Default::default()
3067            },
3068            ..Default::default()
3069        };
3070        let options = TextLayoutOptions {
3071            overflow: TextOverflow::Clip,
3072            soft_wrap: true,
3073            max_lines: 2,
3074            min_lines: 1,
3075        };
3076
3077        let prepared = prepare_text_layout(
3078            &crate::text::AnnotatedString::from("A B C D E F"),
3079            &style,
3080            options,
3081            Some(24.0), // roughly 4 chars in monospaced fallback
3082        );
3083
3084        assert!(prepared.did_overflow);
3085        assert!(prepared.metrics.line_count <= 2);
3086    }
3087
3088    #[test]
3089    fn text_layout_options_end_ellipsis_applies() {
3090        let _app_context = crate::render_state::app_context_test_scope();
3091        let style = TextStyle {
3092            span_style: crate::text::SpanStyle {
3093                font_size: TextUnit::Sp(10.0),
3094                ..Default::default()
3095            },
3096            ..Default::default()
3097        };
3098        let options = TextLayoutOptions {
3099            overflow: TextOverflow::Ellipsis,
3100            soft_wrap: false,
3101            max_lines: 1,
3102            min_lines: 1,
3103        };
3104
3105        let prepared = prepare_text_layout(
3106            &crate::text::AnnotatedString::from("Long long line"),
3107            &style,
3108            options,
3109            Some(20.0),
3110        );
3111        assert!(prepared.did_overflow);
3112        assert!(prepared.text.text.contains(ELLIPSIS));
3113    }
3114
3115    #[test]
3116    fn text_layout_options_visible_keeps_full_text() {
3117        let _app_context = crate::render_state::app_context_test_scope();
3118        let style = TextStyle {
3119            span_style: crate::text::SpanStyle {
3120                font_size: TextUnit::Sp(10.0),
3121                ..Default::default()
3122            },
3123            ..Default::default()
3124        };
3125        let options = TextLayoutOptions {
3126            overflow: TextOverflow::Visible,
3127            soft_wrap: false,
3128            max_lines: 1,
3129            min_lines: 1,
3130        };
3131
3132        let input = "This should remain unchanged";
3133        let prepared = prepare_text_layout(
3134            &crate::text::AnnotatedString::from(input),
3135            &style,
3136            options,
3137            Some(10.0),
3138        );
3139        assert_eq!(prepared.text.text, input);
3140    }
3141
3142    #[test]
3143    fn text_layout_options_respects_min_lines() {
3144        let _app_context = crate::render_state::app_context_test_scope();
3145        let style = TextStyle {
3146            span_style: crate::text::SpanStyle {
3147                font_size: TextUnit::Sp(10.0),
3148                ..Default::default()
3149            },
3150            ..Default::default()
3151        };
3152        let options = TextLayoutOptions {
3153            overflow: TextOverflow::Clip,
3154            soft_wrap: true,
3155            max_lines: 4,
3156            min_lines: 3,
3157        };
3158
3159        let prepared = prepare_text_layout(
3160            &crate::text::AnnotatedString::from("short"),
3161            &style,
3162            options,
3163            Some(100.0),
3164        );
3165        assert_eq!(prepared.metrics.line_count, 3);
3166    }
3167
3168    #[test]
3169    fn text_layout_options_middle_ellipsis_for_single_line() {
3170        let _app_context = crate::render_state::app_context_test_scope();
3171        let style = TextStyle {
3172            span_style: crate::text::SpanStyle {
3173                font_size: TextUnit::Sp(10.0),
3174                ..Default::default()
3175            },
3176            ..Default::default()
3177        };
3178        let options = TextLayoutOptions {
3179            overflow: TextOverflow::MiddleEllipsis,
3180            soft_wrap: false,
3181            max_lines: 1,
3182            min_lines: 1,
3183        };
3184
3185        let prepared = prepare_text_layout(
3186            &crate::text::AnnotatedString::from("abcdefghijk"),
3187            &style,
3188            options,
3189            Some(24.0),
3190        );
3191        assert!(prepared.text.text.contains(ELLIPSIS));
3192        assert!(prepared.did_overflow);
3193    }
3194
3195    #[test]
3196    fn text_layout_options_scale_down_fits_without_rewriting_text() {
3197        let _app_context = crate::render_state::app_context_test_scope();
3198        let style = TextStyle {
3199            span_style: crate::text::SpanStyle {
3200                font_size: TextUnit::Sp(20.0),
3201                ..Default::default()
3202            },
3203            ..Default::default()
3204        };
3205        let options = TextLayoutOptions {
3206            overflow: TextOverflow::ScaleDown {
3207                min_font_size_sp: 10.0,
3208            },
3209            soft_wrap: false,
3210            max_lines: 1,
3211            min_lines: 1,
3212        };
3213
3214        let prepared = prepare_text_layout(
3215            &crate::text::AnnotatedString::from("ABCDE"),
3216            &style,
3217            options,
3218            Some(36.0),
3219        );
3220
3221        assert_eq!(prepared.text.text, "ABCDE");
3222        assert!(prepared.metrics.width <= 36.0 + WRAP_EPSILON);
3223        assert!(!prepared.did_overflow);
3224        let visual_font_size = prepared.visual_style.resolve_font_size(14.0);
3225        assert!(visual_font_size < 20.0);
3226        assert!(visual_font_size >= 10.0);
3227    }
3228
3229    #[test]
3230    fn text_layout_options_scale_down_scales_root_shadow() {
3231        let _app_context = crate::render_state::app_context_test_scope();
3232        let style = TextStyle {
3233            span_style: crate::text::SpanStyle {
3234                font_size: TextUnit::Sp(20.0),
3235                shadow: Some(crate::text::Shadow {
3236                    color: crate::modifier::Color(0.0, 0.0, 0.0, 1.0),
3237                    offset: crate::modifier::Point::new(8.0, 4.0),
3238                    blur_radius: 6.0,
3239                }),
3240                ..Default::default()
3241            },
3242            ..Default::default()
3243        };
3244        let options = TextLayoutOptions {
3245            overflow: TextOverflow::ScaleDown {
3246                min_font_size_sp: 10.0,
3247            },
3248            soft_wrap: false,
3249            max_lines: 1,
3250            min_lines: 1,
3251        };
3252
3253        let prepared = prepare_text_layout(
3254            &crate::text::AnnotatedString::from("ABCDE"),
3255            &style,
3256            options,
3257            Some(36.0),
3258        );
3259
3260        let font_scale = prepared.visual_style.resolve_font_size(14.0) / 20.0;
3261        let shadow = prepared
3262            .visual_style
3263            .span_style
3264            .shadow
3265            .expect("scaled style should retain shadow");
3266        assert_f32_close(shadow.offset.x, 8.0 * font_scale);
3267        assert_f32_close(shadow.offset.y, 4.0 * font_scale);
3268        assert_f32_close(shadow.blur_radius, 6.0 * font_scale);
3269    }
3270
3271    #[test]
3272    fn text_layout_options_scale_down_stops_at_minimum_and_clips() {
3273        let _app_context = crate::render_state::app_context_test_scope();
3274        let style = TextStyle {
3275            span_style: crate::text::SpanStyle {
3276                font_size: TextUnit::Sp(20.0),
3277                ..Default::default()
3278            },
3279            ..Default::default()
3280        };
3281        let options = TextLayoutOptions {
3282            overflow: TextOverflow::ScaleDown {
3283                min_font_size_sp: 10.0,
3284            },
3285            soft_wrap: false,
3286            max_lines: 1,
3287            min_lines: 1,
3288        };
3289
3290        let prepared = prepare_text_layout(
3291            &crate::text::AnnotatedString::from("ABCDEFGHIJ"),
3292            &style,
3293            options,
3294            Some(12.0),
3295        );
3296
3297        assert_eq!(prepared.text.text, "ABCDEFGHIJ");
3298        assert!(prepared.did_overflow);
3299        assert_eq!(prepared.metrics.width, 12.0);
3300        assert_eq!(prepared.visual_style.resolve_font_size(14.0), 10.0);
3301    }
3302
3303    #[test]
3304    fn scale_annotated_font_sizes_borrows_when_spans_need_no_scaling() {
3305        let _app_context = crate::render_state::app_context_test_scope();
3306        let plain = crate::text::AnnotatedString::from("plain");
3307        assert!(matches!(
3308            scale_annotated_font_sizes(&plain, FontScaleCurve::linear(0.5)),
3309            std::borrow::Cow::Borrowed(_)
3310        ));
3311
3312        let colored = crate::text::annotated_string::Builder::new()
3313            .push_style(crate::text::SpanStyle {
3314                color: Some(crate::modifier::Color(1.0, 0.0, 0.0, 1.0)),
3315                ..Default::default()
3316            })
3317            .append("colored")
3318            .pop()
3319            .to_annotated_string();
3320        assert!(matches!(
3321            scale_annotated_font_sizes(&colored, FontScaleCurve::linear(0.5)),
3322            std::borrow::Cow::Borrowed(_)
3323        ));
3324    }
3325
3326    #[test]
3327    fn scale_annotated_font_sizes_scales_span_shadow_geometry() {
3328        let _app_context = crate::render_state::app_context_test_scope();
3329        let text = crate::text::annotated_string::Builder::new()
3330            .push_style(crate::text::SpanStyle {
3331                shadow: Some(crate::text::Shadow {
3332                    color: crate::modifier::Color(0.0, 0.0, 0.0, 1.0),
3333                    offset: crate::modifier::Point::new(6.0, 2.0),
3334                    blur_radius: 4.0,
3335                }),
3336                ..Default::default()
3337            })
3338            .append("shadow")
3339            .pop()
3340            .to_annotated_string();
3341
3342        let scaled = scale_annotated_font_sizes(&text, FontScaleCurve::linear(0.5));
3343        let std::borrow::Cow::Owned(scaled) = scaled else {
3344            panic!("shadowed span should be scaled into owned text");
3345        };
3346        let shadow = scaled.span_styles[0]
3347            .item
3348            .shadow
3349            .expect("scaled span should retain shadow");
3350        assert_f32_close(shadow.offset.x, 3.0);
3351        assert_f32_close(shadow.offset.y, 1.0);
3352        assert_f32_close(shadow.blur_radius, 2.0);
3353    }
3354
3355    #[test]
3356    fn text_layout_options_does_not_wrap_on_tiny_width_delta() {
3357        let _app_context = crate::render_state::app_context_test_scope();
3358        let style = TextStyle {
3359            span_style: crate::text::SpanStyle {
3360                font_size: TextUnit::Sp(10.0),
3361                ..Default::default()
3362            },
3363            ..Default::default()
3364        };
3365        let options = TextLayoutOptions {
3366            overflow: TextOverflow::Clip,
3367            soft_wrap: true,
3368            max_lines: usize::MAX,
3369            min_lines: 1,
3370        };
3371
3372        let text = "if counter % 2 == 0";
3373        let exact_width = measure_text(&crate::text::AnnotatedString::from(text), &style).width;
3374        let prepared = prepare_text_layout(
3375            &crate::text::AnnotatedString::from(text),
3376            &style,
3377            options,
3378            Some(exact_width - 0.1),
3379        );
3380
3381        assert!(
3382            !prepared.text.text.contains('\n'),
3383            "unexpected line split: {:?}",
3384            prepared.text
3385        );
3386    }
3387
3388    #[test]
3389    fn line_break_mode_changes_wrap_strategy_contract() {
3390        let _app_context = crate::render_state::app_context_test_scope();
3391        let text = "This is an example text";
3392        let options = TextLayoutOptions {
3393            overflow: TextOverflow::Clip,
3394            soft_wrap: true,
3395            max_lines: usize::MAX,
3396            min_lines: 1,
3397        };
3398
3399        let simple = prepare_text_layout(
3400            &crate::text::AnnotatedString::from(text),
3401            &style_with_line_break(LineBreak::Simple),
3402            options,
3403            Some(120.0),
3404        );
3405        let heading = prepare_text_layout(
3406            &crate::text::AnnotatedString::from(text),
3407            &style_with_line_break(LineBreak::Heading),
3408            options,
3409            Some(120.0),
3410        );
3411        let paragraph = prepare_text_layout(
3412            &crate::text::AnnotatedString::from(text),
3413            &style_with_line_break(LineBreak::Paragraph),
3414            options,
3415            Some(50.0),
3416        );
3417
3418        assert_eq!(
3419            simple.text.text.lines().collect::<Vec<_>>(),
3420            vec!["This is an example", "text"]
3421        );
3422        assert_eq!(
3423            heading.text.text.lines().collect::<Vec<_>>(),
3424            vec!["This is an", "example text"]
3425        );
3426        assert_eq!(
3427            paragraph.text.text.lines().collect::<Vec<_>>(),
3428            vec!["This", "is an", "example", "text"]
3429        );
3430    }
3431
3432    #[test]
3433    fn hyphens_mode_changes_wrap_strategy_contract() {
3434        let _app_context = crate::render_state::app_context_test_scope();
3435        let text = "Transformation";
3436        let options = TextLayoutOptions {
3437            overflow: TextOverflow::Clip,
3438            soft_wrap: true,
3439            max_lines: usize::MAX,
3440            min_lines: 1,
3441        };
3442
3443        let auto = prepare_text_layout(
3444            &crate::text::AnnotatedString::from(text),
3445            &style_with_hyphens(Hyphens::Auto),
3446            options,
3447            Some(24.0),
3448        );
3449        let none = prepare_text_layout(
3450            &crate::text::AnnotatedString::from(text),
3451            &style_with_hyphens(Hyphens::None),
3452            options,
3453            Some(24.0),
3454        );
3455
3456        assert_eq!(
3457            auto.text.text.lines().collect::<Vec<_>>(),
3458            vec!["Tran", "sfor", "ma", "tion"]
3459        );
3460        assert_eq!(
3461            none.text.text.lines().collect::<Vec<_>>(),
3462            vec!["Tran", "sfor", "mati", "on"]
3463        );
3464        assert!(
3465            !auto.text.text.contains('-'),
3466            "automatic hyphenation should influence breaks without mutating source text content"
3467        );
3468    }
3469
3470    #[test]
3471    fn hyphens_auto_uses_measurer_hyphen_contract_when_valid() {
3472        let _app_context = crate::render_state::app_context_test_scope();
3473        let text = "Transformation";
3474        let style = style_with_hyphens(Hyphens::Auto);
3475        let options = TextLayoutOptions {
3476            overflow: TextOverflow::Clip,
3477            soft_wrap: true,
3478            max_lines: usize::MAX,
3479            min_lines: 1,
3480        };
3481
3482        let prepared = prepare_text_layout_fallback(
3483            &ContractBreakMeasurer { retreat: 1 },
3484            &crate::text::AnnotatedString::from(text),
3485            &style,
3486            options,
3487            Some(24.0),
3488        );
3489
3490        assert_eq!(
3491            prepared.text.text.lines().collect::<Vec<_>>(),
3492            vec!["Tra", "nsf", "orm", "ati", "on"]
3493        );
3494    }
3495
3496    #[test]
3497    fn hyphens_auto_falls_back_when_measurer_hyphen_contract_is_invalid() {
3498        let _app_context = crate::render_state::app_context_test_scope();
3499        let text = "Transformation";
3500        let style = style_with_hyphens(Hyphens::Auto);
3501        let options = TextLayoutOptions {
3502            overflow: TextOverflow::Clip,
3503            soft_wrap: true,
3504            max_lines: usize::MAX,
3505            min_lines: 1,
3506        };
3507
3508        let prepared = prepare_text_layout_fallback(
3509            &ContractBreakMeasurer { retreat: 10 },
3510            &crate::text::AnnotatedString::from(text),
3511            &style,
3512            options,
3513            Some(24.0),
3514        );
3515
3516        assert_eq!(
3517            prepared.text.text.lines().collect::<Vec<_>>(),
3518            vec!["Tran", "sfor", "ma", "tion"]
3519        );
3520    }
3521
3522    #[test]
3523    fn transformed_text_keeps_span_ranges_within_display_bounds() {
3524        let _app_context = crate::render_state::app_context_test_scope();
3525        let style = TextStyle {
3526            span_style: crate::text::SpanStyle {
3527                font_size: TextUnit::Sp(10.0),
3528                ..Default::default()
3529            },
3530            ..Default::default()
3531        };
3532        let options = TextLayoutOptions {
3533            overflow: TextOverflow::Ellipsis,
3534            soft_wrap: false,
3535            max_lines: 1,
3536            min_lines: 1,
3537        };
3538        let annotated = crate::text::AnnotatedString::builder()
3539            .push_style(crate::text::SpanStyle {
3540                font_weight: Some(crate::text::FontWeight::BOLD),
3541                ..Default::default()
3542            })
3543            .append("Styled overflow text sample")
3544            .pop()
3545            .to_annotated_string();
3546
3547        let prepared = prepare_text_layout(&annotated, &style, options, Some(40.0));
3548        assert!(prepared.did_overflow);
3549        for span in &prepared.text.span_styles {
3550            assert!(span.range.start < span.range.end);
3551            assert!(span.range.end <= prepared.text.text.len());
3552            assert!(prepared.text.text.is_char_boundary(span.range.start));
3553            assert!(prepared.text.text.is_char_boundary(span.range.end));
3554        }
3555    }
3556
3557    #[test]
3558    fn a_word_that_fits_stays_on_the_line_when_the_space_after_it_fits_too() {
3559        // The greedy break has to consider the widest prefix that fits as a
3560        // break candidate in its own right. When that prefix ends on a space —
3561        // the whole word fitted, and so did the space after it, and only the
3562        // NEXT word's first glyph did not — breaking there is the greedy
3563        // answer. Searching strictly before it threw the last word onto the
3564        // next line: on the Wear Credits screen "Designed and built for Wear /
3565        // OS." came out "Designed and built for / Wear OS.".
3566        let _app_context = crate::render_state::app_context_test_scope();
3567        let style = TextStyle {
3568            span_style: crate::text::SpanStyle {
3569                font_size: TextUnit::Sp(10.0),
3570                ..Default::default()
3571            },
3572            ..Default::default()
3573        };
3574        let options = TextLayoutOptions {
3575            overflow: TextOverflow::Clip,
3576            soft_wrap: true,
3577            max_lines: usize::MAX,
3578            min_lines: 1,
3579        };
3580        let width_of = |text: &str| {
3581            measure_text_with_options(
3582                &crate::text::AnnotatedString::from(text.to_string()),
3583                &style,
3584                options,
3585                None,
3586            )
3587            .width
3588        };
3589        // Wide enough for "aa bb " and not for "aa bb c" — the exact case.
3590        let fits = width_of("aa bb ");
3591        let overflows = width_of("aa bb c");
3592        assert!(overflows > fits, "the fixture needs a real gap here");
3593        let max_width = (fits + overflows) * 0.5;
3594
3595        let prepared = prepare_text_layout(
3596            &crate::text::AnnotatedString::from("aa bb cc".to_string()),
3597            &style,
3598            options,
3599            Some(max_width),
3600        );
3601        assert_eq!(prepared.text.text, "aa bb\ncc");
3602    }
3603
3604    #[test]
3605    fn wrapped_text_splits_styles_around_inserted_newlines() {
3606        let _app_context = crate::render_state::app_context_test_scope();
3607        let style = TextStyle {
3608            span_style: crate::text::SpanStyle {
3609                font_size: TextUnit::Sp(10.0),
3610                ..Default::default()
3611            },
3612            ..Default::default()
3613        };
3614        let options = TextLayoutOptions {
3615            overflow: TextOverflow::Clip,
3616            soft_wrap: true,
3617            max_lines: usize::MAX,
3618            min_lines: 1,
3619        };
3620        let annotated = crate::text::AnnotatedString::builder()
3621            .push_style(crate::text::SpanStyle {
3622                text_decoration: Some(crate::text::TextDecoration::UNDERLINE),
3623                ..Default::default()
3624            })
3625            .append("Wrapped style text example")
3626            .pop()
3627            .to_annotated_string();
3628
3629        let prepared = prepare_text_layout(&annotated, &style, options, Some(32.0));
3630        assert!(prepared.text.text.contains('\n'));
3631        assert!(!prepared.text.span_styles.is_empty());
3632        for span in &prepared.text.span_styles {
3633            assert!(span.range.end <= prepared.text.text.len());
3634        }
3635    }
3636
3637    #[test]
3638    fn mixed_font_size_segments_wrap_without_truncation() {
3639        let _app_context = crate::render_state::app_context_test_scope();
3640        let style = TextStyle {
3641            span_style: crate::text::SpanStyle {
3642                font_size: TextUnit::Sp(14.0),
3643                ..Default::default()
3644            },
3645            ..Default::default()
3646        };
3647        let options = TextLayoutOptions {
3648            overflow: TextOverflow::Clip,
3649            soft_wrap: true,
3650            max_lines: usize::MAX,
3651            min_lines: 1,
3652        };
3653        let annotated = crate::text::AnnotatedString::builder()
3654            .append("You can also ")
3655            .push_style(crate::text::SpanStyle {
3656                font_size: TextUnit::Sp(22.0),
3657                ..Default::default()
3658            })
3659            .append("change font size")
3660            .pop()
3661            .append(" dynamically mid-sentence!")
3662            .to_annotated_string();
3663
3664        let prepared = prepare_text_layout(&annotated, &style, options, Some(260.0));
3665        assert!(prepared.text.text.contains('\n'));
3666        assert!(prepared.text.text.contains("mid-sentence!"));
3667        assert!(!prepared.did_overflow);
3668    }
3669}