Skip to main content

cranpose_ui/text/
measure.rs

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