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 measured_width<M: TextMeasurer + ?Sized>(measurer: &M, text: &str, style: &TextStyle) -> f32 {
2089    measurer
2090        .measure(&crate::text::AnnotatedString::from(text), style)
2091        .width
2092}
2093
2094enum EllipsisTruncation {
2095    NotNeeded,
2096    ImpossibleFit,
2097    Truncate(Vec<usize>),
2098}
2099
2100fn ellipsis_truncation<M: TextMeasurer + ?Sized>(
2101    measurer: &M,
2102    line: &str,
2103    style: &TextStyle,
2104    max_width: f32,
2105) -> EllipsisTruncation {
2106    if measured_width(measurer, line, style) <= max_width + WRAP_EPSILON {
2107        return EllipsisTruncation::NotNeeded;
2108    }
2109    if measured_width(measurer, ELLIPSIS, style) > max_width + WRAP_EPSILON {
2110        return EllipsisTruncation::ImpossibleFit;
2111    }
2112    EllipsisTruncation::Truncate(char_boundaries(line))
2113}
2114
2115fn fit_end_ellipsis<M: TextMeasurer + ?Sized>(
2116    measurer: &M,
2117    line: &str,
2118    style: &TextStyle,
2119    max_width: f32,
2120) -> String {
2121    let boundaries = match ellipsis_truncation(measurer, line, style, max_width) {
2122        EllipsisTruncation::NotNeeded => return line.to_string(),
2123        EllipsisTruncation::ImpossibleFit => return String::new(),
2124        EllipsisTruncation::Truncate(boundaries) => boundaries,
2125    };
2126
2127    let mut low = 0usize;
2128    let mut high = boundaries.len() - 1;
2129    let mut best = 0usize;
2130
2131    while low <= high {
2132        let mid = (low + high) / 2;
2133        let prefix = &line[..boundaries[mid]];
2134        let candidate = format!("{prefix}{ELLIPSIS}");
2135        if measured_width(measurer, &candidate, style) <= max_width + WRAP_EPSILON {
2136            best = mid;
2137            low = mid + 1;
2138        } else if mid == 0 {
2139            break;
2140        } else {
2141            high = mid - 1;
2142        }
2143    }
2144
2145    format!("{}{}", &line[..boundaries[best]], ELLIPSIS)
2146}
2147
2148fn fit_start_ellipsis<M: TextMeasurer + ?Sized>(
2149    measurer: &M,
2150    line: &str,
2151    style: &TextStyle,
2152    max_width: f32,
2153) -> String {
2154    let boundaries = match ellipsis_truncation(measurer, line, style, max_width) {
2155        EllipsisTruncation::NotNeeded => return line.to_string(),
2156        EllipsisTruncation::ImpossibleFit => return String::new(),
2157        EllipsisTruncation::Truncate(boundaries) => boundaries,
2158    };
2159
2160    let mut low = 0usize;
2161    let mut high = boundaries.len() - 1;
2162    let mut best = boundaries.len() - 1;
2163
2164    while low <= high {
2165        let mid = (low + high) / 2;
2166        let suffix = &line[boundaries[mid]..];
2167        let candidate = format!("{ELLIPSIS}{suffix}");
2168        if measured_width(measurer, &candidate, style) <= max_width + WRAP_EPSILON {
2169            best = mid;
2170            if mid == 0 {
2171                break;
2172            }
2173            high = mid - 1;
2174        } else {
2175            low = mid + 1;
2176        }
2177    }
2178
2179    format!("{ELLIPSIS}{}", &line[boundaries[best]..])
2180}
2181
2182fn fit_middle_ellipsis<M: TextMeasurer + ?Sized>(
2183    measurer: &M,
2184    line: &str,
2185    style: &TextStyle,
2186    max_width: f32,
2187) -> String {
2188    let boundaries = match ellipsis_truncation(measurer, line, style, max_width) {
2189        EllipsisTruncation::NotNeeded => return line.to_string(),
2190        EllipsisTruncation::ImpossibleFit => return String::new(),
2191        EllipsisTruncation::Truncate(boundaries) => boundaries,
2192    };
2193
2194    let total_chars = boundaries.len().saturating_sub(1);
2195    for keep in (0..=total_chars).rev() {
2196        let keep_start = keep.div_ceil(2);
2197        let keep_end = keep / 2;
2198        let start = &line[..boundaries[keep_start]];
2199        let end_start = boundaries[total_chars.saturating_sub(keep_end)];
2200        let end = &line[end_start..];
2201        let candidate = format!("{start}{ELLIPSIS}{end}");
2202        if measured_width(measurer, &candidate, style) <= max_width + WRAP_EPSILON {
2203            return candidate;
2204        }
2205    }
2206
2207    ELLIPSIS.to_string()
2208}
2209
2210fn char_boundaries(text: &str) -> Vec<usize> {
2211    let mut out = Vec::with_capacity(text.chars().count() + 1);
2212    out.push(0);
2213    for (idx, _) in text.char_indices() {
2214        if idx != 0 {
2215            out.push(idx);
2216        }
2217    }
2218    out.push(text.len());
2219    out
2220}
2221
2222#[cfg(test)]
2223mod tests {
2224    use std::cell::Cell;
2225
2226    use super::*;
2227    use crate::{
2228        text::{Hyphens, LineBreak, ParagraphStyle, TextUnit},
2229        text_layout_result::TextLayoutResult,
2230    };
2231
2232    #[test]
2233    fn text_layout_telemetry_env_flag_is_not_process_cached() {
2234        let source = include_str!("measure.rs");
2235        let once_lock = ["Once", "Lock"].concat();
2236        let cached_init_call = ["get", "_or", "_init"].concat();
2237
2238        assert!(
2239            !source.contains(&once_lock) && !source.contains(&cached_init_call),
2240            "text layout telemetry env flag must be read at the diagnostic boundary"
2241        );
2242    }
2243
2244    #[test]
2245    fn prepared_layout_cache_distinguishes_visual_styles() {
2246        let service = TextService::new();
2247        let text = crate::text::AnnotatedString::from("tinted".to_string());
2248        let options = TextLayoutOptions::default();
2249
2250        let mut style = TextStyle::default();
2251        style.span_style.color = Some(crate::Color(1.0, 0.0, 0.0, 1.0));
2252        let red = service.prepare_with_options(None, &text, &style, options, None);
2253
2254        style.span_style.color = Some(crate::Color(0.0, 0.0, 1.0, 1.0));
2255        let blue = service.prepare_with_options(None, &text, &style, options, None);
2256
2257        assert_eq!(
2258            red.visual_style.span_style.color,
2259            Some(crate::Color(1.0, 0.0, 0.0, 1.0)),
2260        );
2261        assert_eq!(
2262            blue.visual_style.span_style.color,
2263            Some(crate::Color(0.0, 0.0, 1.0, 1.0)),
2264            "a color-only style change must not be served a stale prepared layout \
2265             (measurement hashes ignore visual attributes by design)"
2266        );
2267    }
2268
2269    #[test]
2270    fn system_font_scale_changes_sp_measurement_and_prepared_text() {
2271        let _app_context = crate::render_state::app_context_test_scope();
2272        let text = crate::text::AnnotatedString::from("scale me");
2273        let style = TextStyle {
2274            span_style: crate::text::SpanStyle {
2275                font_size: TextUnit::Sp(10.0),
2276                ..Default::default()
2277            },
2278            ..Default::default()
2279        };
2280
2281        let unscaled = measure_text(&text, &style);
2282        crate::set_font_scale(2.0);
2283        let scaled = measure_text(&text, &style);
2284        let prepared = prepare_text_layout(&text, &style, TextLayoutOptions::default(), None);
2285
2286        assert!((scaled.width - unscaled.width * 2.0).abs() <= f32::EPSILON);
2287        assert!((scaled.height - unscaled.height * 2.0).abs() <= f32::EPSILON);
2288        assert_eq!(
2289            prepared.visual_style.span_style.font_size,
2290            TextUnit::Sp(20.0)
2291        );
2292    }
2293
2294    #[test]
2295    fn a_platform_curve_resolves_an_sp_where_the_platform_does_and_not_where_a_multiplier_would() {
2296        let _app_context = crate::render_state::app_context_test_scope();
2297        let text = crate::text::AnnotatedString::from("SOLID  next at 3 gold");
2298        let style = TextStyle {
2299            span_style: crate::text::SpanStyle {
2300                font_size: TextUnit::Sp(13.0),
2301                letter_spacing: TextUnit::Sp(0.4),
2302                ..Default::default()
2303            },
2304            ..Default::default()
2305        };
2306
2307        crate::set_font_scale_curve(FontScaleCurve::from_samples(
2308            1.24,
2309            &[
2310                (8.0, 9.92),
2311                (10.0, 12.4),
2312                (12.0, 14.88),
2313                (14.0, 17.84),
2314                (16.0, 19.36),
2315                (18.0, 20.88),
2316                (20.0, 22.88),
2317                (24.0, 25.92),
2318                (30.0, 30.0),
2319                (100.0, 100.0),
2320            ],
2321        ));
2322        let prepared = prepare_text_layout(&text, &style, TextLayoutOptions::default(), None);
2323        assert_eq!(
2324            prepared.visual_style.span_style.font_size,
2325            TextUnit::Sp(16.36)
2326        );
2327        assert_eq!(
2328            prepared.visual_style.span_style.letter_spacing,
2329            TextUnit::Sp(0.4 * 1.24)
2330        );
2331        assert_eq!(crate::current_font_scale(), 1.24);
2332
2333        crate::set_font_scale(1.24);
2334        let multiplied = prepare_text_layout(&text, &style, TextLayoutOptions::default(), None);
2335        assert_eq!(
2336            multiplied.visual_style.span_style.font_size,
2337            TextUnit::Sp(13.0 * 1.24)
2338        );
2339    }
2340
2341    #[test]
2342    fn text_service_cache_retains_large_lazy_text_working_set() {
2343        let mut cache = BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY);
2344        let metrics = TextMetrics {
2345            width: 1.0,
2346            height: 1.0,
2347            line_height: 1.0,
2348            line_count: 1,
2349        };
2350
2351        for index in 0..4096u64 {
2352            cache.insert(
2353                TextBaseCacheKey {
2354                    text_hash: index,
2355                    style_hash: 7,
2356                },
2357                metrics,
2358            );
2359        }
2360
2361        for index in 0..4096u64 {
2362            assert!(
2363                cache
2364                    .get(&TextBaseCacheKey {
2365                        text_hash: index,
2366                        style_hash: 7,
2367                    })
2368                    .is_some(),
2369                "large lazy text working-set entry {index} was evicted too early"
2370            );
2371        }
2372    }
2373
2374    struct ContractBreakMeasurer {
2375        retreat: usize,
2376    }
2377
2378    impl TextMeasurer for ContractBreakMeasurer {
2379        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2380            MonospacedTextMeasurer.measure(
2381                &crate::text::AnnotatedString::from(text.text.as_str()),
2382                style,
2383            )
2384        }
2385
2386        fn get_offset_for_position(
2387            &self,
2388            text: &crate::text::AnnotatedString,
2389            style: &TextStyle,
2390            x: f32,
2391            y: f32,
2392        ) -> usize {
2393            MonospacedTextMeasurer.get_offset_for_position(
2394                &crate::text::AnnotatedString::from(text.text.as_str()),
2395                style,
2396                x,
2397                y,
2398            )
2399        }
2400
2401        fn get_cursor_x_for_offset(
2402            &self,
2403            text: &crate::text::AnnotatedString,
2404            style: &TextStyle,
2405            offset: usize,
2406        ) -> f32 {
2407            MonospacedTextMeasurer.get_cursor_x_for_offset(
2408                &crate::text::AnnotatedString::from(text.text.as_str()),
2409                style,
2410                offset,
2411            )
2412        }
2413
2414        fn layout(
2415            &self,
2416            text: &crate::text::AnnotatedString,
2417            style: &TextStyle,
2418        ) -> TextLayoutResult {
2419            MonospacedTextMeasurer.layout(
2420                &crate::text::AnnotatedString::from(text.text.as_str()),
2421                style,
2422            )
2423        }
2424
2425        fn choose_auto_hyphen_break(
2426            &self,
2427            _line: &str,
2428            _style: &TextStyle,
2429            _segment_start_char: usize,
2430            measured_break_char: usize,
2431        ) -> Option<usize> {
2432            measured_break_char.checked_sub(self.retreat)
2433        }
2434    }
2435
2436    fn monospaced_measure(text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2437        MonospacedTextMeasurer.measure(text, style)
2438    }
2439
2440    fn monospaced_offset_for_position(
2441        text: &crate::text::AnnotatedString,
2442        style: &TextStyle,
2443        x: f32,
2444        y: f32,
2445    ) -> usize {
2446        MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
2447    }
2448
2449    fn monospaced_cursor_x_for_offset(
2450        text: &crate::text::AnnotatedString,
2451        style: &TextStyle,
2452        offset: usize,
2453    ) -> f32 {
2454        MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
2455    }
2456
2457    fn monospaced_layout(
2458        text: &crate::text::AnnotatedString,
2459        style: &TextStyle,
2460    ) -> TextLayoutResult {
2461        MonospacedTextMeasurer.layout(text, style)
2462    }
2463
2464    struct CountingTextMeasurer {
2465        measure_calls: Rc<Cell<usize>>,
2466        layout_calls: Rc<Cell<usize>>,
2467    }
2468
2469    impl CountingTextMeasurer {
2470        fn new(measure_calls: Rc<Cell<usize>>, layout_calls: Rc<Cell<usize>>) -> Self {
2471            Self {
2472                measure_calls,
2473                layout_calls,
2474            }
2475        }
2476    }
2477
2478    impl TextMeasurer for CountingTextMeasurer {
2479        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2480            self.measure_calls.set(self.measure_calls.get() + 1);
2481            monospaced_measure(text, style)
2482        }
2483
2484        fn get_offset_for_position(
2485            &self,
2486            text: &crate::text::AnnotatedString,
2487            style: &TextStyle,
2488            x: f32,
2489            y: f32,
2490        ) -> usize {
2491            monospaced_offset_for_position(text, style, x, y)
2492        }
2493
2494        fn get_cursor_x_for_offset(
2495            &self,
2496            text: &crate::text::AnnotatedString,
2497            style: &TextStyle,
2498            offset: usize,
2499        ) -> f32 {
2500            monospaced_cursor_x_for_offset(text, style, offset)
2501        }
2502
2503        fn layout(
2504            &self,
2505            text: &crate::text::AnnotatedString,
2506            style: &TextStyle,
2507        ) -> TextLayoutResult {
2508            self.layout_calls.set(self.layout_calls.get() + 1);
2509            monospaced_layout(text, style)
2510        }
2511    }
2512
2513    struct CountingPreparedTextMeasurer {
2514        prepare_calls: Rc<Cell<usize>>,
2515    }
2516
2517    impl CountingPreparedTextMeasurer {
2518        fn new(prepare_calls: Rc<Cell<usize>>) -> Self {
2519            Self { prepare_calls }
2520        }
2521    }
2522
2523    struct PrefixWidthCountingMeasurer {
2524        prefix_calls: Rc<Cell<usize>>,
2525        subsequence_calls: Rc<Cell<usize>>,
2526    }
2527
2528    impl PrefixWidthCountingMeasurer {
2529        fn new(prefix_calls: Rc<Cell<usize>>, subsequence_calls: Rc<Cell<usize>>) -> Self {
2530            Self {
2531                prefix_calls,
2532                subsequence_calls,
2533            }
2534        }
2535    }
2536
2537    impl TextMeasurer for PrefixWidthCountingMeasurer {
2538        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2539            monospaced_measure(text, style)
2540        }
2541
2542        fn measure_subsequence(
2543            &self,
2544            text: &crate::text::AnnotatedString,
2545            range: Range<usize>,
2546            style: &TextStyle,
2547        ) -> TextMetrics {
2548            self.subsequence_calls.set(self.subsequence_calls.get() + 1);
2549            MonospacedTextMeasurer.measure_subsequence(text, range, style)
2550        }
2551
2552        fn measure_line_prefix_widths(
2553            &self,
2554            text: &crate::text::AnnotatedString,
2555            line_range: Range<usize>,
2556            style: &TextStyle,
2557        ) -> Option<TextLinePrefixWidths> {
2558            self.prefix_calls.set(self.prefix_calls.get() + 1);
2559            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2560        }
2561
2562        fn get_offset_for_position(
2563            &self,
2564            text: &crate::text::AnnotatedString,
2565            style: &TextStyle,
2566            x: f32,
2567            y: f32,
2568        ) -> usize {
2569            monospaced_offset_for_position(text, style, x, y)
2570        }
2571
2572        fn get_cursor_x_for_offset(
2573            &self,
2574            text: &crate::text::AnnotatedString,
2575            style: &TextStyle,
2576            offset: usize,
2577        ) -> f32 {
2578            monospaced_cursor_x_for_offset(text, style, offset)
2579        }
2580
2581        fn layout(
2582            &self,
2583            text: &crate::text::AnnotatedString,
2584            style: &TextStyle,
2585        ) -> TextLayoutResult {
2586            monospaced_layout(text, style)
2587        }
2588    }
2589
2590    struct LineHeightCountingMeasurer {
2591        measure_calls: Rc<Cell<usize>>,
2592        line_height_calls: Rc<Cell<usize>>,
2593    }
2594
2595    struct FitProbeCountingMeasurer {
2596        line_width_calls: Rc<Cell<usize>>,
2597        prefix_calls: Rc<Cell<usize>>,
2598    }
2599
2600    impl FitProbeCountingMeasurer {
2601        fn new(line_width_calls: Rc<Cell<usize>>, prefix_calls: Rc<Cell<usize>>) -> Self {
2602            Self {
2603                line_width_calls,
2604                prefix_calls,
2605            }
2606        }
2607    }
2608
2609    impl LineHeightCountingMeasurer {
2610        fn new(measure_calls: Rc<Cell<usize>>, line_height_calls: Rc<Cell<usize>>) -> Self {
2611            Self {
2612                measure_calls,
2613                line_height_calls,
2614            }
2615        }
2616    }
2617
2618    impl TextMeasurer for LineHeightCountingMeasurer {
2619        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2620            self.measure_calls.set(self.measure_calls.get() + 1);
2621            monospaced_measure(text, style)
2622        }
2623
2624        fn measure_line_prefix_widths(
2625            &self,
2626            text: &crate::text::AnnotatedString,
2627            line_range: Range<usize>,
2628            style: &TextStyle,
2629        ) -> Option<TextLinePrefixWidths> {
2630            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2631        }
2632
2633        fn line_height(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> f32 {
2634            self.line_height_calls.set(self.line_height_calls.get() + 1);
2635            MonospacedTextMeasurer.line_height(text, style)
2636        }
2637
2638        fn get_offset_for_position(
2639            &self,
2640            text: &crate::text::AnnotatedString,
2641            style: &TextStyle,
2642            x: f32,
2643            y: f32,
2644        ) -> usize {
2645            monospaced_offset_for_position(text, style, x, y)
2646        }
2647
2648        fn get_cursor_x_for_offset(
2649            &self,
2650            text: &crate::text::AnnotatedString,
2651            style: &TextStyle,
2652            offset: usize,
2653        ) -> f32 {
2654            monospaced_cursor_x_for_offset(text, style, offset)
2655        }
2656
2657        fn layout(
2658            &self,
2659            text: &crate::text::AnnotatedString,
2660            style: &TextStyle,
2661        ) -> TextLayoutResult {
2662            monospaced_layout(text, style)
2663        }
2664    }
2665
2666    impl TextMeasurer for FitProbeCountingMeasurer {
2667        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2668            monospaced_measure(text, style)
2669        }
2670
2671        fn measure_line_width(
2672            &self,
2673            text: &crate::text::AnnotatedString,
2674            line_range: Range<usize>,
2675            style: &TextStyle,
2676        ) -> Option<f32> {
2677            self.line_width_calls.set(self.line_width_calls.get() + 1);
2678            MonospacedTextMeasurer.measure_line_width(text, line_range, style)
2679        }
2680
2681        fn measure_line_prefix_widths(
2682            &self,
2683            text: &crate::text::AnnotatedString,
2684            line_range: Range<usize>,
2685            style: &TextStyle,
2686        ) -> Option<TextLinePrefixWidths> {
2687            self.prefix_calls.set(self.prefix_calls.get() + 1);
2688            MonospacedTextMeasurer.measure_line_prefix_widths(text, line_range, style)
2689        }
2690
2691        fn get_offset_for_position(
2692            &self,
2693            text: &crate::text::AnnotatedString,
2694            style: &TextStyle,
2695            x: f32,
2696            y: f32,
2697        ) -> usize {
2698            monospaced_offset_for_position(text, style, x, y)
2699        }
2700
2701        fn get_cursor_x_for_offset(
2702            &self,
2703            text: &crate::text::AnnotatedString,
2704            style: &TextStyle,
2705            offset: usize,
2706        ) -> f32 {
2707            monospaced_cursor_x_for_offset(text, style, offset)
2708        }
2709
2710        fn layout(
2711            &self,
2712            text: &crate::text::AnnotatedString,
2713            style: &TextStyle,
2714        ) -> TextLayoutResult {
2715            monospaced_layout(text, style)
2716        }
2717    }
2718
2719    impl TextMeasurer for CountingPreparedTextMeasurer {
2720        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
2721            monospaced_measure(text, style)
2722        }
2723
2724        fn prepare_with_options_for_node(
2725            &self,
2726            _node_id: Option<NodeId>,
2727            text: &crate::text::AnnotatedString,
2728            style: &TextStyle,
2729            options: TextLayoutOptions,
2730            max_width: Option<f32>,
2731        ) -> PreparedTextLayout {
2732            self.prepare_calls.set(self.prepare_calls.get() + 1);
2733            MonospacedTextMeasurer.prepare_with_options(text, style, options, max_width)
2734        }
2735
2736        fn get_offset_for_position(
2737            &self,
2738            text: &crate::text::AnnotatedString,
2739            style: &TextStyle,
2740            x: f32,
2741            y: f32,
2742        ) -> usize {
2743            monospaced_offset_for_position(text, style, x, y)
2744        }
2745
2746        fn get_cursor_x_for_offset(
2747            &self,
2748            text: &crate::text::AnnotatedString,
2749            style: &TextStyle,
2750            offset: usize,
2751        ) -> f32 {
2752            monospaced_cursor_x_for_offset(text, style, offset)
2753        }
2754
2755        fn layout(
2756            &self,
2757            text: &crate::text::AnnotatedString,
2758            style: &TextStyle,
2759        ) -> TextLayoutResult {
2760            monospaced_layout(text, style)
2761        }
2762    }
2763
2764    #[test]
2765    fn text_service_routes_measurement_through_current_measurer() {
2766        let _app_context = crate::render_state::app_context_test_scope();
2767        let service = TextService::from_measurer(Rc::new(MonospacedTextMeasurer));
2768        let text = crate::text::AnnotatedString::from("abc");
2769        let style = TextStyle::default();
2770
2771        let metrics = service.with_measurer(|measurer| measurer.measure(&text, &style));
2772
2773        assert!(metrics.width > 0.0);
2774        assert!(metrics.height > 0.0);
2775    }
2776
2777    #[test]
2778    fn text_service_caches_metrics_and_layouts_per_context() {
2779        let _app_context = crate::render_state::app_context_test_scope();
2780        let measure_calls = Rc::new(Cell::new(0));
2781        let layout_calls = Rc::new(Cell::new(0));
2782        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2783            Rc::clone(&measure_calls),
2784            Rc::clone(&layout_calls),
2785        )));
2786        let text = crate::text::AnnotatedString::from("cached text");
2787        let style = TextStyle::default();
2788
2789        let first_metrics = service.measure(Some(7), &text, &style);
2790        let second_metrics = service.measure(Some(7), &text, &style);
2791        let first_layout = service.layout(&text, &style);
2792        let second_layout = service.layout(&text, &style);
2793
2794        assert_eq!(first_metrics, second_metrics);
2795        assert_eq!(first_layout.width, second_layout.width);
2796        assert_eq!(measure_calls.get(), 1);
2797        assert_eq!(layout_calls.get(), 1);
2798    }
2799
2800    #[test]
2801    fn text_service_reuses_metrics_cache_across_node_ids() {
2802        let _app_context = crate::render_state::app_context_test_scope();
2803        let measure_calls = Rc::new(Cell::new(0));
2804        let layout_calls = Rc::new(Cell::new(0));
2805        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2806            Rc::clone(&measure_calls),
2807            Rc::clone(&layout_calls),
2808        )));
2809        let text = crate::text::AnnotatedString::from("same lazy item text");
2810        let style = TextStyle::default();
2811
2812        let first_metrics = service.measure(Some(7), &text, &style);
2813        let second_metrics = service.measure(Some(8), &text, &style);
2814
2815        assert_eq!(first_metrics, second_metrics);
2816        assert_eq!(measure_calls.get(), 1);
2817    }
2818
2819    #[test]
2820    fn text_service_reuses_prepared_layout_cache_across_node_ids() {
2821        let _app_context = crate::render_state::app_context_test_scope();
2822        let prepare_calls = Rc::new(Cell::new(0));
2823        let service = TextService::from_measurer(Rc::new(CountingPreparedTextMeasurer::new(
2824            Rc::clone(&prepare_calls),
2825        )));
2826        let text = crate::text::AnnotatedString::from("same prepared lazy item text");
2827        let style = TextStyle::default();
2828        let options = TextLayoutOptions::default();
2829
2830        let first = service.prepare_with_options(Some(9), &text, &style, options, Some(120.0));
2831        let second = service.prepare_with_options(Some(10), &text, &style, options, Some(120.0));
2832
2833        assert_eq!(first.metrics, second.metrics);
2834        assert_eq!(prepare_calls.get(), 1);
2835    }
2836
2837    #[test]
2838    fn text_service_clears_caches_when_measurer_changes() {
2839        let _app_context = crate::render_state::app_context_test_scope();
2840        let first_measure_calls = Rc::new(Cell::new(0));
2841        let second_measure_calls = Rc::new(Cell::new(0));
2842        let layout_calls = Rc::new(Cell::new(0));
2843        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
2844            Rc::clone(&first_measure_calls),
2845            Rc::clone(&layout_calls),
2846        )));
2847        let text = crate::text::AnnotatedString::from("cached text");
2848        let style = TextStyle::default();
2849
2850        let _ = service.measure(None, &text, &style);
2851        let _ = service.measure(None, &text, &style);
2852        service.set_measurer(Rc::new(CountingTextMeasurer::new(
2853            Rc::clone(&second_measure_calls),
2854            Rc::clone(&layout_calls),
2855        )));
2856        let _ = service.measure(None, &text, &style);
2857
2858        assert_eq!(first_measure_calls.get(), 1);
2859        assert_eq!(second_measure_calls.get(), 1);
2860    }
2861
2862    #[test]
2863    fn text_wrapping_uses_prefix_widths_without_subsequence_measurement() {
2864        let _app_context = crate::render_state::app_context_test_scope();
2865        let prefix_calls = Rc::new(Cell::new(0));
2866        let subsequence_calls = Rc::new(Cell::new(0));
2867        set_text_measurer(PrefixWidthCountingMeasurer::new(
2868            Rc::clone(&prefix_calls),
2869            Rc::clone(&subsequence_calls),
2870        ));
2871        let style = TextStyle {
2872            span_style: crate::text::SpanStyle {
2873                font_size: TextUnit::Sp(10.0),
2874                ..Default::default()
2875            },
2876            ..Default::default()
2877        };
2878        let options = TextLayoutOptions {
2879            overflow: TextOverflow::Clip,
2880            soft_wrap: true,
2881            max_lines: usize::MAX,
2882            min_lines: 1,
2883        };
2884        let text = crate::text::AnnotatedString::from("word ".repeat(80).as_str());
2885
2886        let prepared = prepare_text_layout(&text, &style, options, Some(80.0));
2887
2888        assert!(prepared.metrics.line_count > 1);
2889        assert!(
2890            prefix_calls.get() > 0,
2891            "wrapping should request a line prefix width plan"
2892        );
2893        assert_eq!(
2894            subsequence_calls.get(),
2895            0,
2896            "prefix-capable wrapping should not probe candidate substrings"
2897        );
2898    }
2899
2900    #[test]
2901    fn text_wrapping_skips_prefix_widths_when_fit_probe_says_line_fits() {
2902        let _app_context = crate::render_state::app_context_test_scope();
2903        let line_width_calls = Rc::new(Cell::new(0));
2904        let prefix_calls = Rc::new(Cell::new(0));
2905        set_text_measurer(FitProbeCountingMeasurer::new(
2906            Rc::clone(&line_width_calls),
2907            Rc::clone(&prefix_calls),
2908        ));
2909        let style = TextStyle {
2910            span_style: crate::text::SpanStyle {
2911                font_size: TextUnit::Sp(10.0),
2912                ..Default::default()
2913            },
2914            ..Default::default()
2915        };
2916        let text = crate::text::AnnotatedString::from("fits without per-glyph prefix widths");
2917
2918        let prepared =
2919            prepare_text_layout(&text, &style, TextLayoutOptions::default(), Some(800.0));
2920
2921        assert_eq!(prepared.metrics.line_count, 1);
2922        assert_eq!(line_width_calls.get(), 1);
2923        assert_eq!(
2924            prefix_calls.get(),
2925            0,
2926            "fitting lines should not allocate prefix-width plans"
2927        );
2928    }
2929
2930    #[test]
2931    fn prepare_text_layout_uses_line_height_without_full_text_measurement() {
2932        let _app_context = crate::render_state::app_context_test_scope();
2933        let measure_calls = Rc::new(Cell::new(0));
2934        let line_height_calls = Rc::new(Cell::new(0));
2935        let measurer = LineHeightCountingMeasurer::new(
2936            Rc::clone(&measure_calls),
2937            Rc::clone(&line_height_calls),
2938        );
2939        let text = crate::text::AnnotatedString::from(
2940            "one two three four five six seven eight nine ten eleven twelve",
2941        );
2942
2943        let prepared = prepare_text_layout_with_measurer_for_node(
2944            &measurer,
2945            Some(7),
2946            &text,
2947            &TextStyle::default(),
2948            TextLayoutOptions::default(),
2949            Some(96.0),
2950        );
2951
2952        assert!(prepared.metrics.height > 0.0);
2953        assert_eq!(line_height_calls.get(), 1);
2954        assert_eq!(
2955            measure_calls.get(),
2956            0,
2957            "line-height lookup must not re-measure the whole paragraph"
2958        );
2959    }
2960
2961    fn style_with_line_break(line_break: LineBreak) -> TextStyle {
2962        TextStyle {
2963            span_style: crate::text::SpanStyle {
2964                font_size: TextUnit::Sp(10.0),
2965                ..Default::default()
2966            },
2967            paragraph_style: ParagraphStyle {
2968                line_break,
2969                ..Default::default()
2970            },
2971        }
2972    }
2973
2974    fn style_with_hyphens(hyphens: Hyphens) -> TextStyle {
2975        TextStyle {
2976            span_style: crate::text::SpanStyle {
2977                font_size: TextUnit::Sp(10.0),
2978                ..Default::default()
2979            },
2980            paragraph_style: ParagraphStyle {
2981                hyphens,
2982                ..Default::default()
2983            },
2984        }
2985    }
2986
2987    fn assert_f32_close(actual: f32, expected: f32) {
2988        assert!(
2989            (actual - expected).abs() <= 0.01,
2990            "actual={actual}, expected={expected}"
2991        );
2992    }
2993
2994    #[test]
2995    fn text_layout_options_wraps_and_limits_lines() {
2996        let _app_context = crate::render_state::app_context_test_scope();
2997        let style = TextStyle {
2998            span_style: crate::text::SpanStyle {
2999                font_size: TextUnit::Sp(10.0),
3000                ..Default::default()
3001            },
3002            ..Default::default()
3003        };
3004        let options = TextLayoutOptions {
3005            overflow: TextOverflow::Clip,
3006            soft_wrap: true,
3007            max_lines: 2,
3008            min_lines: 1,
3009        };
3010
3011        let prepared = prepare_text_layout(
3012            &crate::text::AnnotatedString::from("A B C D E F"),
3013            &style,
3014            options,
3015            Some(24.0),
3016        );
3017
3018        assert!(prepared.did_overflow);
3019        assert!(prepared.metrics.line_count <= 2);
3020    }
3021
3022    #[test]
3023    fn text_layout_options_end_ellipsis_applies() {
3024        let _app_context = crate::render_state::app_context_test_scope();
3025        let style = TextStyle {
3026            span_style: crate::text::SpanStyle {
3027                font_size: TextUnit::Sp(10.0),
3028                ..Default::default()
3029            },
3030            ..Default::default()
3031        };
3032        let options = TextLayoutOptions {
3033            overflow: TextOverflow::Ellipsis,
3034            soft_wrap: false,
3035            max_lines: 1,
3036            min_lines: 1,
3037        };
3038
3039        let prepared = prepare_text_layout(
3040            &crate::text::AnnotatedString::from("Long long line"),
3041            &style,
3042            options,
3043            Some(20.0),
3044        );
3045        assert!(prepared.did_overflow);
3046        assert!(prepared.text.text.contains(ELLIPSIS));
3047    }
3048
3049    #[test]
3050    fn text_layout_options_visible_keeps_full_text() {
3051        let _app_context = crate::render_state::app_context_test_scope();
3052        let style = TextStyle {
3053            span_style: crate::text::SpanStyle {
3054                font_size: TextUnit::Sp(10.0),
3055                ..Default::default()
3056            },
3057            ..Default::default()
3058        };
3059        let options = TextLayoutOptions {
3060            overflow: TextOverflow::Visible,
3061            soft_wrap: false,
3062            max_lines: 1,
3063            min_lines: 1,
3064        };
3065
3066        let input = "This should remain unchanged";
3067        let prepared = prepare_text_layout(
3068            &crate::text::AnnotatedString::from(input),
3069            &style,
3070            options,
3071            Some(10.0),
3072        );
3073        assert_eq!(prepared.text.text, input);
3074    }
3075
3076    #[test]
3077    fn text_layout_options_respects_min_lines() {
3078        let _app_context = crate::render_state::app_context_test_scope();
3079        let style = TextStyle {
3080            span_style: crate::text::SpanStyle {
3081                font_size: TextUnit::Sp(10.0),
3082                ..Default::default()
3083            },
3084            ..Default::default()
3085        };
3086        let options = TextLayoutOptions {
3087            overflow: TextOverflow::Clip,
3088            soft_wrap: true,
3089            max_lines: 4,
3090            min_lines: 3,
3091        };
3092
3093        let prepared = prepare_text_layout(
3094            &crate::text::AnnotatedString::from("short"),
3095            &style,
3096            options,
3097            Some(100.0),
3098        );
3099        assert_eq!(prepared.metrics.line_count, 3);
3100    }
3101
3102    #[test]
3103    fn text_layout_options_middle_ellipsis_for_single_line() {
3104        let _app_context = crate::render_state::app_context_test_scope();
3105        let style = TextStyle {
3106            span_style: crate::text::SpanStyle {
3107                font_size: TextUnit::Sp(10.0),
3108                ..Default::default()
3109            },
3110            ..Default::default()
3111        };
3112        let options = TextLayoutOptions {
3113            overflow: TextOverflow::MiddleEllipsis,
3114            soft_wrap: false,
3115            max_lines: 1,
3116            min_lines: 1,
3117        };
3118
3119        let prepared = prepare_text_layout(
3120            &crate::text::AnnotatedString::from("abcdefghijk"),
3121            &style,
3122            options,
3123            Some(24.0),
3124        );
3125        assert!(prepared.text.text.contains(ELLIPSIS));
3126        assert!(prepared.did_overflow);
3127    }
3128
3129    #[test]
3130    fn text_layout_options_scale_down_fits_without_rewriting_text() {
3131        let _app_context = crate::render_state::app_context_test_scope();
3132        let style = TextStyle {
3133            span_style: crate::text::SpanStyle {
3134                font_size: TextUnit::Sp(20.0),
3135                ..Default::default()
3136            },
3137            ..Default::default()
3138        };
3139        let options = TextLayoutOptions {
3140            overflow: TextOverflow::ScaleDown {
3141                min_font_size_sp: 10.0,
3142            },
3143            soft_wrap: false,
3144            max_lines: 1,
3145            min_lines: 1,
3146        };
3147
3148        let prepared = prepare_text_layout(
3149            &crate::text::AnnotatedString::from("ABCDE"),
3150            &style,
3151            options,
3152            Some(36.0),
3153        );
3154
3155        assert_eq!(prepared.text.text, "ABCDE");
3156        assert!(prepared.metrics.width <= 36.0 + WRAP_EPSILON);
3157        assert!(!prepared.did_overflow);
3158        let visual_font_size = prepared.visual_style.resolve_font_size(14.0);
3159        assert!(visual_font_size < 20.0);
3160        assert!(visual_font_size >= 10.0);
3161    }
3162
3163    #[test]
3164    fn text_layout_options_scale_down_scales_root_shadow() {
3165        let _app_context = crate::render_state::app_context_test_scope();
3166        let style = TextStyle {
3167            span_style: crate::text::SpanStyle {
3168                font_size: TextUnit::Sp(20.0),
3169                shadow: Some(crate::text::Shadow {
3170                    color: crate::modifier::Color(0.0, 0.0, 0.0, 1.0),
3171                    offset: crate::modifier::Point::new(8.0, 4.0),
3172                    blur_radius: 6.0,
3173                }),
3174                ..Default::default()
3175            },
3176            ..Default::default()
3177        };
3178        let options = TextLayoutOptions {
3179            overflow: TextOverflow::ScaleDown {
3180                min_font_size_sp: 10.0,
3181            },
3182            soft_wrap: false,
3183            max_lines: 1,
3184            min_lines: 1,
3185        };
3186
3187        let prepared = prepare_text_layout(
3188            &crate::text::AnnotatedString::from("ABCDE"),
3189            &style,
3190            options,
3191            Some(36.0),
3192        );
3193
3194        let font_scale = prepared.visual_style.resolve_font_size(14.0) / 20.0;
3195        let shadow = prepared
3196            .visual_style
3197            .span_style
3198            .shadow
3199            .expect("scaled style should retain shadow");
3200        assert_f32_close(shadow.offset.x, 8.0 * font_scale);
3201        assert_f32_close(shadow.offset.y, 4.0 * font_scale);
3202        assert_f32_close(shadow.blur_radius, 6.0 * font_scale);
3203    }
3204
3205    #[test]
3206    fn text_layout_options_scale_down_stops_at_minimum_and_clips() {
3207        let _app_context = crate::render_state::app_context_test_scope();
3208        let style = TextStyle {
3209            span_style: crate::text::SpanStyle {
3210                font_size: TextUnit::Sp(20.0),
3211                ..Default::default()
3212            },
3213            ..Default::default()
3214        };
3215        let options = TextLayoutOptions {
3216            overflow: TextOverflow::ScaleDown {
3217                min_font_size_sp: 10.0,
3218            },
3219            soft_wrap: false,
3220            max_lines: 1,
3221            min_lines: 1,
3222        };
3223
3224        let prepared = prepare_text_layout(
3225            &crate::text::AnnotatedString::from("ABCDEFGHIJ"),
3226            &style,
3227            options,
3228            Some(12.0),
3229        );
3230
3231        assert_eq!(prepared.text.text, "ABCDEFGHIJ");
3232        assert!(prepared.did_overflow);
3233        assert_eq!(prepared.metrics.width, 12.0);
3234        assert_eq!(prepared.visual_style.resolve_font_size(14.0), 10.0);
3235    }
3236
3237    #[test]
3238    fn scale_annotated_font_sizes_borrows_when_spans_need_no_scaling() {
3239        let _app_context = crate::render_state::app_context_test_scope();
3240        let plain = crate::text::AnnotatedString::from("plain");
3241        assert!(matches!(
3242            scale_annotated_font_sizes(&plain, FontScaleCurve::linear(0.5)),
3243            std::borrow::Cow::Borrowed(_)
3244        ));
3245
3246        let colored = crate::text::annotated_string::Builder::new()
3247            .push_style(crate::text::SpanStyle {
3248                color: Some(crate::modifier::Color(1.0, 0.0, 0.0, 1.0)),
3249                ..Default::default()
3250            })
3251            .append("colored")
3252            .pop()
3253            .to_annotated_string();
3254        assert!(matches!(
3255            scale_annotated_font_sizes(&colored, FontScaleCurve::linear(0.5)),
3256            std::borrow::Cow::Borrowed(_)
3257        ));
3258    }
3259
3260    #[test]
3261    fn scale_annotated_font_sizes_scales_span_shadow_geometry() {
3262        let _app_context = crate::render_state::app_context_test_scope();
3263        let text = crate::text::annotated_string::Builder::new()
3264            .push_style(crate::text::SpanStyle {
3265                shadow: Some(crate::text::Shadow {
3266                    color: crate::modifier::Color(0.0, 0.0, 0.0, 1.0),
3267                    offset: crate::modifier::Point::new(6.0, 2.0),
3268                    blur_radius: 4.0,
3269                }),
3270                ..Default::default()
3271            })
3272            .append("shadow")
3273            .pop()
3274            .to_annotated_string();
3275
3276        let scaled = scale_annotated_font_sizes(&text, FontScaleCurve::linear(0.5));
3277        let std::borrow::Cow::Owned(scaled) = scaled else {
3278            panic!("shadowed span should be scaled into owned text");
3279        };
3280        let shadow = scaled.span_styles[0]
3281            .item
3282            .shadow
3283            .expect("scaled span should retain shadow");
3284        assert_f32_close(shadow.offset.x, 3.0);
3285        assert_f32_close(shadow.offset.y, 1.0);
3286        assert_f32_close(shadow.blur_radius, 2.0);
3287    }
3288
3289    #[test]
3290    fn text_layout_options_does_not_wrap_on_tiny_width_delta() {
3291        let _app_context = crate::render_state::app_context_test_scope();
3292        let style = TextStyle {
3293            span_style: crate::text::SpanStyle {
3294                font_size: TextUnit::Sp(10.0),
3295                ..Default::default()
3296            },
3297            ..Default::default()
3298        };
3299        let options = TextLayoutOptions {
3300            overflow: TextOverflow::Clip,
3301            soft_wrap: true,
3302            max_lines: usize::MAX,
3303            min_lines: 1,
3304        };
3305
3306        let text = "if counter % 2 == 0";
3307        let exact_width = measure_text(&crate::text::AnnotatedString::from(text), &style).width;
3308        let prepared = prepare_text_layout(
3309            &crate::text::AnnotatedString::from(text),
3310            &style,
3311            options,
3312            Some(exact_width - 0.1),
3313        );
3314
3315        assert!(
3316            !prepared.text.text.contains('\n'),
3317            "unexpected line split: {:?}",
3318            prepared.text
3319        );
3320    }
3321
3322    #[test]
3323    fn line_break_mode_changes_wrap_strategy_contract() {
3324        let _app_context = crate::render_state::app_context_test_scope();
3325        let text = "This is an example text";
3326        let options = TextLayoutOptions {
3327            overflow: TextOverflow::Clip,
3328            soft_wrap: true,
3329            max_lines: usize::MAX,
3330            min_lines: 1,
3331        };
3332
3333        let simple = prepare_text_layout(
3334            &crate::text::AnnotatedString::from(text),
3335            &style_with_line_break(LineBreak::Simple),
3336            options,
3337            Some(120.0),
3338        );
3339        let heading = prepare_text_layout(
3340            &crate::text::AnnotatedString::from(text),
3341            &style_with_line_break(LineBreak::Heading),
3342            options,
3343            Some(120.0),
3344        );
3345        let paragraph = prepare_text_layout(
3346            &crate::text::AnnotatedString::from(text),
3347            &style_with_line_break(LineBreak::Paragraph),
3348            options,
3349            Some(50.0),
3350        );
3351
3352        assert_eq!(
3353            simple.text.text.lines().collect::<Vec<_>>(),
3354            vec!["This is an example", "text"]
3355        );
3356        assert_eq!(
3357            heading.text.text.lines().collect::<Vec<_>>(),
3358            vec!["This is an", "example text"]
3359        );
3360        assert_eq!(
3361            paragraph.text.text.lines().collect::<Vec<_>>(),
3362            vec!["This", "is an", "example", "text"]
3363        );
3364    }
3365
3366    #[test]
3367    fn hyphens_mode_changes_wrap_strategy_contract() {
3368        let _app_context = crate::render_state::app_context_test_scope();
3369        let text = "Transformation";
3370        let options = TextLayoutOptions {
3371            overflow: TextOverflow::Clip,
3372            soft_wrap: true,
3373            max_lines: usize::MAX,
3374            min_lines: 1,
3375        };
3376
3377        let auto = prepare_text_layout(
3378            &crate::text::AnnotatedString::from(text),
3379            &style_with_hyphens(Hyphens::Auto),
3380            options,
3381            Some(24.0),
3382        );
3383        let none = prepare_text_layout(
3384            &crate::text::AnnotatedString::from(text),
3385            &style_with_hyphens(Hyphens::None),
3386            options,
3387            Some(24.0),
3388        );
3389
3390        assert_eq!(
3391            auto.text.text.lines().collect::<Vec<_>>(),
3392            vec!["Tran", "sfor", "ma", "tion"]
3393        );
3394        assert_eq!(
3395            none.text.text.lines().collect::<Vec<_>>(),
3396            vec!["Tran", "sfor", "mati", "on"]
3397        );
3398        assert!(
3399            !auto.text.text.contains('-'),
3400            "automatic hyphenation should influence breaks without mutating source text content"
3401        );
3402    }
3403
3404    #[test]
3405    fn hyphens_auto_uses_measurer_hyphen_contract_when_valid() {
3406        let _app_context = crate::render_state::app_context_test_scope();
3407        let text = "Transformation";
3408        let style = style_with_hyphens(Hyphens::Auto);
3409        let options = TextLayoutOptions {
3410            overflow: TextOverflow::Clip,
3411            soft_wrap: true,
3412            max_lines: usize::MAX,
3413            min_lines: 1,
3414        };
3415
3416        let prepared = prepare_text_layout_fallback(
3417            &ContractBreakMeasurer { retreat: 1 },
3418            &crate::text::AnnotatedString::from(text),
3419            &style,
3420            options,
3421            Some(24.0),
3422        );
3423
3424        assert_eq!(
3425            prepared.text.text.lines().collect::<Vec<_>>(),
3426            vec!["Tra", "nsf", "orm", "ati", "on"]
3427        );
3428    }
3429
3430    #[test]
3431    fn hyphens_auto_falls_back_when_measurer_hyphen_contract_is_invalid() {
3432        let _app_context = crate::render_state::app_context_test_scope();
3433        let text = "Transformation";
3434        let style = style_with_hyphens(Hyphens::Auto);
3435        let options = TextLayoutOptions {
3436            overflow: TextOverflow::Clip,
3437            soft_wrap: true,
3438            max_lines: usize::MAX,
3439            min_lines: 1,
3440        };
3441
3442        let prepared = prepare_text_layout_fallback(
3443            &ContractBreakMeasurer { retreat: 10 },
3444            &crate::text::AnnotatedString::from(text),
3445            &style,
3446            options,
3447            Some(24.0),
3448        );
3449
3450        assert_eq!(
3451            prepared.text.text.lines().collect::<Vec<_>>(),
3452            vec!["Tran", "sfor", "ma", "tion"]
3453        );
3454    }
3455
3456    #[test]
3457    fn transformed_text_keeps_span_ranges_within_display_bounds() {
3458        let _app_context = crate::render_state::app_context_test_scope();
3459        let style = TextStyle {
3460            span_style: crate::text::SpanStyle {
3461                font_size: TextUnit::Sp(10.0),
3462                ..Default::default()
3463            },
3464            ..Default::default()
3465        };
3466        let options = TextLayoutOptions {
3467            overflow: TextOverflow::Ellipsis,
3468            soft_wrap: false,
3469            max_lines: 1,
3470            min_lines: 1,
3471        };
3472        let annotated = crate::text::AnnotatedString::builder()
3473            .push_style(crate::text::SpanStyle {
3474                font_weight: Some(crate::text::FontWeight::BOLD),
3475                ..Default::default()
3476            })
3477            .append("Styled overflow text sample")
3478            .pop()
3479            .to_annotated_string();
3480
3481        let prepared = prepare_text_layout(&annotated, &style, options, Some(40.0));
3482        assert!(prepared.did_overflow);
3483        for span in &prepared.text.span_styles {
3484            assert!(span.range.start < span.range.end);
3485            assert!(span.range.end <= prepared.text.text.len());
3486            assert!(prepared.text.text.is_char_boundary(span.range.start));
3487            assert!(prepared.text.text.is_char_boundary(span.range.end));
3488        }
3489    }
3490
3491    #[test]
3492    fn a_word_that_fits_stays_on_the_line_when_the_space_after_it_fits_too() {
3493        let _app_context = crate::render_state::app_context_test_scope();
3494        let style = TextStyle {
3495            span_style: crate::text::SpanStyle {
3496                font_size: TextUnit::Sp(10.0),
3497                ..Default::default()
3498            },
3499            ..Default::default()
3500        };
3501        let options = TextLayoutOptions {
3502            overflow: TextOverflow::Clip,
3503            soft_wrap: true,
3504            max_lines: usize::MAX,
3505            min_lines: 1,
3506        };
3507        let width_of = |text: &str| {
3508            measure_text_with_options(
3509                &crate::text::AnnotatedString::from(text.to_string()),
3510                &style,
3511                options,
3512                None,
3513            )
3514            .width
3515        };
3516        let fits = width_of("aa bb ");
3517        let overflows = width_of("aa bb c");
3518        assert!(overflows > fits, "the fixture needs a real gap here");
3519        let max_width = (fits + overflows) * 0.5;
3520
3521        let prepared = prepare_text_layout(
3522            &crate::text::AnnotatedString::from("aa bb cc".to_string()),
3523            &style,
3524            options,
3525            Some(max_width),
3526        );
3527        assert_eq!(prepared.text.text, "aa bb\ncc");
3528    }
3529
3530    #[test]
3531    fn wrapped_text_splits_styles_around_inserted_newlines() {
3532        let _app_context = crate::render_state::app_context_test_scope();
3533        let style = TextStyle {
3534            span_style: crate::text::SpanStyle {
3535                font_size: TextUnit::Sp(10.0),
3536                ..Default::default()
3537            },
3538            ..Default::default()
3539        };
3540        let options = TextLayoutOptions {
3541            overflow: TextOverflow::Clip,
3542            soft_wrap: true,
3543            max_lines: usize::MAX,
3544            min_lines: 1,
3545        };
3546        let annotated = crate::text::AnnotatedString::builder()
3547            .push_style(crate::text::SpanStyle {
3548                text_decoration: Some(crate::text::TextDecoration::UNDERLINE),
3549                ..Default::default()
3550            })
3551            .append("Wrapped style text example")
3552            .pop()
3553            .to_annotated_string();
3554
3555        let prepared = prepare_text_layout(&annotated, &style, options, Some(32.0));
3556        assert!(prepared.text.text.contains('\n'));
3557        assert!(!prepared.text.span_styles.is_empty());
3558        for span in &prepared.text.span_styles {
3559            assert!(span.range.end <= prepared.text.text.len());
3560        }
3561    }
3562
3563    #[test]
3564    fn mixed_font_size_segments_wrap_without_truncation() {
3565        let _app_context = crate::render_state::app_context_test_scope();
3566        let style = TextStyle {
3567            span_style: crate::text::SpanStyle {
3568                font_size: TextUnit::Sp(14.0),
3569                ..Default::default()
3570            },
3571            ..Default::default()
3572        };
3573        let options = TextLayoutOptions {
3574            overflow: TextOverflow::Clip,
3575            soft_wrap: true,
3576            max_lines: usize::MAX,
3577            min_lines: 1,
3578        };
3579        let annotated = crate::text::AnnotatedString::builder()
3580            .append("You can also ")
3581            .push_style(crate::text::SpanStyle {
3582                font_size: TextUnit::Sp(22.0),
3583                ..Default::default()
3584            })
3585            .append("change font size")
3586            .pop()
3587            .append(" dynamically mid-sentence!")
3588            .to_annotated_string();
3589
3590        let prepared = prepare_text_layout(&annotated, &style, options, Some(260.0));
3591        assert!(prepared.text.text.contains('\n'));
3592        assert!(prepared.text.text.contains("mid-sentence!"));
3593        assert!(!prepared.did_overflow);
3594    }
3595}