Skip to main content

cranpose_ui/text/
measure.rs

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