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::RefCell;
5use std::collections::{hash_map::Entry, HashMap, VecDeque};
6use std::hash::Hash;
7use std::ops::Range;
8use std::rc::Rc;
9
10use super::layout_options::{TextLayoutOptions, TextOverflow};
11use super::paragraph::{Hyphens, LineBreak};
12use super::style::TextStyle;
13
14const ELLIPSIS: &str = "\u{2026}";
15const DEFAULT_FONT_SIZE_SP: f32 = 14.0;
16const WRAP_EPSILON: f32 = 0.5;
17const SCALE_DOWN_SEARCH_STEPS: usize = 14;
18const AUTO_HYPHEN_MIN_SEGMENT_CHARS: usize = 2;
19const AUTO_HYPHEN_MIN_TRAILING_CHARS: usize = 3;
20const AUTO_HYPHEN_PREFERRED_TRAILING_CHARS: usize = 4;
21const TEXT_SERVICE_CACHE_CAPACITY: usize = 256;
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct TextMetrics {
25    pub width: f32,
26    pub height: f32,
27    /// Height of a single line of text
28    pub line_height: f32,
29    /// Number of lines in the text
30    pub line_count: usize,
31}
32
33#[derive(Clone, Debug, PartialEq)]
34pub struct PreparedTextLayout {
35    pub text: crate::text::AnnotatedString,
36    pub visual_style: TextStyle,
37    pub metrics: TextMetrics,
38    pub did_overflow: bool,
39}
40
41pub trait TextMeasurer: 'static {
42    fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics;
43
44    fn measure_for_node(
45        &self,
46        node_id: Option<NodeId>,
47        text: &crate::text::AnnotatedString,
48        style: &TextStyle,
49    ) -> TextMetrics {
50        let _ = node_id;
51        self.measure(text, style)
52    }
53
54    fn measure_subsequence(
55        &self,
56        text: &crate::text::AnnotatedString,
57        range: Range<usize>,
58        style: &TextStyle,
59    ) -> TextMetrics {
60        self.measure(&text.subsequence(range), style)
61    }
62
63    fn measure_subsequence_for_node(
64        &self,
65        node_id: Option<NodeId>,
66        text: &crate::text::AnnotatedString,
67        range: Range<usize>,
68        style: &TextStyle,
69    ) -> TextMetrics {
70        let _ = node_id;
71        self.measure_subsequence(text, range, style)
72    }
73
74    fn get_offset_for_position(
75        &self,
76        text: &crate::text::AnnotatedString,
77        style: &TextStyle,
78        x: f32,
79        y: f32,
80    ) -> usize;
81
82    fn get_cursor_x_for_offset(
83        &self,
84        text: &crate::text::AnnotatedString,
85        style: &TextStyle,
86        offset: usize,
87    ) -> f32;
88
89    fn layout(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult;
90
91    /// Returns an alternate break boundary for `Hyphens::Auto` when a greedy break
92    /// split lands in the middle of a word.
93    ///
94    /// `segment_start_char` and `measured_break_char` are character-boundary indices
95    /// in `line` (not byte offsets). Return `None` to delegate to fallback behavior.
96    fn choose_auto_hyphen_break(
97        &self,
98        _line: &str,
99        _style: &TextStyle,
100        _segment_start_char: usize,
101        _measured_break_char: usize,
102    ) -> Option<usize> {
103        None
104    }
105
106    fn measure_with_options(
107        &self,
108        text: &crate::text::AnnotatedString,
109        style: &TextStyle,
110        options: TextLayoutOptions,
111        max_width: Option<f32>,
112    ) -> TextMetrics {
113        self.prepare_with_options(text, style, options, max_width)
114            .metrics
115    }
116
117    fn measure_with_options_for_node(
118        &self,
119        node_id: Option<NodeId>,
120        text: &crate::text::AnnotatedString,
121        style: &TextStyle,
122        options: TextLayoutOptions,
123        max_width: Option<f32>,
124    ) -> TextMetrics {
125        self.prepare_with_options_for_node(node_id, text, style, options, max_width)
126            .metrics
127    }
128
129    fn prepare_with_options(
130        &self,
131        text: &crate::text::AnnotatedString,
132        style: &TextStyle,
133        options: TextLayoutOptions,
134        max_width: Option<f32>,
135    ) -> PreparedTextLayout {
136        self.prepare_with_options_fallback(text, style, options, max_width)
137    }
138
139    fn prepare_with_options_for_node(
140        &self,
141        node_id: Option<NodeId>,
142        text: &crate::text::AnnotatedString,
143        style: &TextStyle,
144        options: TextLayoutOptions,
145        max_width: Option<f32>,
146    ) -> PreparedTextLayout {
147        prepare_text_layout_with_measurer_for_node(self, node_id, text, style, options, max_width)
148    }
149
150    fn prepare_with_options_fallback(
151        &self,
152        text: &crate::text::AnnotatedString,
153        style: &TextStyle,
154        options: TextLayoutOptions,
155        max_width: Option<f32>,
156    ) -> PreparedTextLayout {
157        prepare_text_layout_fallback(self, text, style, options, max_width)
158    }
159}
160
161#[derive(Default)]
162struct MonospacedTextMeasurer;
163
164impl MonospacedTextMeasurer {
165    const DEFAULT_SIZE: f32 = 14.0;
166    const CHAR_WIDTH_RATIO: f32 = 0.6; // Width is 0.6 of Height
167
168    fn get_metrics(style: &TextStyle) -> (f32, f32) {
169        let font_size = style.resolve_font_size(Self::DEFAULT_SIZE);
170        let line_height = style.resolve_line_height(Self::DEFAULT_SIZE, font_size);
171        let letter_spacing = style.resolve_letter_spacing(Self::DEFAULT_SIZE).max(0.0);
172        (
173            (font_size * Self::CHAR_WIDTH_RATIO) + letter_spacing,
174            line_height,
175        )
176    }
177}
178
179impl TextMeasurer for MonospacedTextMeasurer {
180    fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
181        let (char_width, line_height) = Self::get_metrics(style);
182
183        let lines: Vec<&str> = text.text.split('\n').collect();
184        let line_count = lines.len().max(1);
185
186        let width = lines
187            .iter()
188            .map(|line| line.chars().count() as f32 * char_width)
189            .fold(0.0_f32, f32::max);
190
191        TextMetrics {
192            width,
193            height: line_count as f32 * line_height,
194            line_height,
195            line_count,
196        }
197    }
198
199    fn measure_subsequence(
200        &self,
201        text: &crate::text::AnnotatedString,
202        range: Range<usize>,
203        style: &TextStyle,
204    ) -> TextMetrics {
205        let (char_width, line_height) = Self::get_metrics(style);
206        let slice = &text.text[range];
207        let line_count = slice.split('\n').count().max(1);
208        let width = slice
209            .split('\n')
210            .map(|line| line.chars().count() as f32 * char_width)
211            .fold(0.0_f32, f32::max);
212
213        TextMetrics {
214            width,
215            height: line_count as f32 * line_height,
216            line_height,
217            line_count,
218        }
219    }
220
221    fn get_offset_for_position(
222        &self,
223        text: &crate::text::AnnotatedString,
224        style: &TextStyle,
225        x: f32,
226        y: f32,
227    ) -> usize {
228        let (char_width, line_height) = Self::get_metrics(style);
229
230        if text.text.is_empty() {
231            return 0;
232        }
233
234        let line_index = (y / line_height).floor().max(0.0) as usize;
235        let lines: Vec<&str> = text.text.split('\n').collect();
236        let target_line = line_index.min(lines.len().saturating_sub(1));
237
238        let mut line_start_byte = 0;
239        for line in lines.iter().take(target_line) {
240            line_start_byte += line.len() + 1;
241        }
242
243        let line_text = lines.get(target_line).unwrap_or(&"");
244        let char_index = (x / char_width).round() as usize;
245        let line_char_count = line_text.chars().count();
246        let clamped_index = char_index.min(line_char_count);
247
248        let offset_in_line = line_text
249            .char_indices()
250            .nth(clamped_index)
251            .map(|(i, _)| i)
252            .unwrap_or(line_text.len());
253
254        line_start_byte + offset_in_line
255    }
256
257    fn get_cursor_x_for_offset(
258        &self,
259        text: &crate::text::AnnotatedString,
260        style: &TextStyle,
261        offset: usize,
262    ) -> f32 {
263        let (char_width, _) = Self::get_metrics(style);
264
265        let clamped_offset = offset.min(text.text.len());
266        let char_count = text.text[..clamped_offset].chars().count();
267        char_count as f32 * char_width
268    }
269
270    fn layout(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult {
271        let (char_width, line_height) = Self::get_metrics(style);
272        TextLayoutResult::monospaced(&text.text, char_width, line_height)
273    }
274}
275
276#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
277struct TextBaseCacheKey {
278    node_id: Option<NodeId>,
279    text_hash: u64,
280    style_hash: u64,
281}
282
283#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
284struct TextOptionsCacheKey {
285    base: TextBaseCacheKey,
286    options: TextLayoutOptions,
287    max_width_bits: Option<u32>,
288}
289
290struct BoundedTextCache<K, V> {
291    capacity: usize,
292    entries: HashMap<K, V>,
293    order: VecDeque<K>,
294}
295
296impl<K, V> BoundedTextCache<K, V>
297where
298    K: Clone + Eq + Hash,
299    V: Clone,
300{
301    fn new(capacity: usize) -> Self {
302        Self {
303            capacity,
304            entries: HashMap::new(),
305            order: VecDeque::new(),
306        }
307    }
308
309    fn clear(&mut self) {
310        self.entries.clear();
311        self.order.clear();
312    }
313
314    fn get(&self, key: &K) -> Option<V> {
315        self.entries.get(key).cloned()
316    }
317
318    fn insert(&mut self, key: K, value: V) {
319        match self.entries.entry(key.clone()) {
320            Entry::Occupied(mut entry) => {
321                entry.insert(value);
322                return;
323            }
324            Entry::Vacant(_) => {}
325        }
326        if self.entries.len() == self.capacity {
327            while let Some(evicted) = self.order.pop_front() {
328                if self.entries.remove(&evicted).is_some() {
329                    break;
330                }
331            }
332        }
333        self.order.push_back(key.clone());
334        self.entries.insert(key, value);
335    }
336}
337
338pub(crate) struct TextService {
339    measurer: RefCell<Rc<dyn TextMeasurer>>,
340    metrics_cache: RefCell<BoundedTextCache<TextBaseCacheKey, TextMetrics>>,
341    options_metrics_cache: RefCell<BoundedTextCache<TextOptionsCacheKey, TextMetrics>>,
342    prepared_cache: RefCell<BoundedTextCache<TextOptionsCacheKey, PreparedTextLayout>>,
343    layout_cache: RefCell<BoundedTextCache<TextBaseCacheKey, TextLayoutResult>>,
344}
345
346impl TextService {
347    pub(crate) fn new() -> Self {
348        Self::from_measurer(Rc::new(MonospacedTextMeasurer))
349    }
350
351    pub(crate) fn from_measurer(measurer: Rc<dyn TextMeasurer>) -> Self {
352        Self {
353            measurer: RefCell::new(measurer),
354            metrics_cache: RefCell::new(BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY)),
355            options_metrics_cache: RefCell::new(BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY)),
356            prepared_cache: RefCell::new(BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY)),
357            layout_cache: RefCell::new(BoundedTextCache::new(TEXT_SERVICE_CACHE_CAPACITY)),
358        }
359    }
360
361    pub(crate) fn set_measurer(&self, measurer: Rc<dyn TextMeasurer>) {
362        *self.measurer.borrow_mut() = measurer;
363        self.clear_caches();
364    }
365
366    pub(crate) fn current_measurer(&self) -> Rc<dyn TextMeasurer> {
367        Rc::clone(&self.measurer.borrow())
368    }
369
370    pub(crate) fn with_measurer<R>(&self, f: impl FnOnce(&dyn TextMeasurer) -> R) -> R {
371        let measurer = self.current_measurer();
372        f(&*measurer)
373    }
374
375    pub(crate) fn measure(
376        &self,
377        node_id: Option<NodeId>,
378        text: &crate::text::AnnotatedString,
379        style: &TextStyle,
380    ) -> TextMetrics {
381        let key = text_base_cache_key(node_id, text, style);
382        if let Some(metrics) = self.metrics_cache.borrow().get(&key) {
383            return metrics;
384        }
385        let metrics = self.with_measurer(|m| m.measure_for_node(node_id, text, style));
386        self.metrics_cache.borrow_mut().insert(key, metrics);
387        metrics
388    }
389
390    pub(crate) fn measure_with_options(
391        &self,
392        node_id: Option<NodeId>,
393        text: &crate::text::AnnotatedString,
394        style: &TextStyle,
395        options: TextLayoutOptions,
396        max_width: Option<f32>,
397    ) -> TextMetrics {
398        let key = text_options_cache_key(node_id, text, style, options.normalized(), max_width);
399        if let Some(metrics) = self.options_metrics_cache.borrow().get(&key) {
400            return metrics;
401        }
402        let metrics = self.with_measurer(|m| {
403            m.measure_with_options_for_node(node_id, text, style, options.normalized(), max_width)
404        });
405        self.options_metrics_cache.borrow_mut().insert(key, metrics);
406        metrics
407    }
408
409    pub(crate) fn prepare_with_options(
410        &self,
411        node_id: Option<NodeId>,
412        text: &crate::text::AnnotatedString,
413        style: &TextStyle,
414        options: TextLayoutOptions,
415        max_width: Option<f32>,
416    ) -> PreparedTextLayout {
417        let key = text_options_cache_key(node_id, text, style, options.normalized(), max_width);
418        if let Some(prepared) = self.prepared_cache.borrow().get(&key) {
419            return prepared;
420        }
421        let prepared = self.with_measurer(|m| {
422            m.prepare_with_options_for_node(node_id, text, style, options.normalized(), max_width)
423        });
424        self.prepared_cache
425            .borrow_mut()
426            .insert(key, prepared.clone());
427        self.options_metrics_cache
428            .borrow_mut()
429            .insert(key, prepared.metrics);
430        prepared
431    }
432
433    pub(crate) fn layout(
434        &self,
435        text: &crate::text::AnnotatedString,
436        style: &TextStyle,
437    ) -> TextLayoutResult {
438        let key = text_base_cache_key(None, text, style);
439        if let Some(layout) = self.layout_cache.borrow().get(&key) {
440            return layout;
441        }
442        let layout = self.with_measurer(|m| m.layout(text, style));
443        self.layout_cache.borrow_mut().insert(key, layout.clone());
444        layout
445    }
446
447    fn clear_caches(&self) {
448        self.metrics_cache.borrow_mut().clear();
449        self.options_metrics_cache.borrow_mut().clear();
450        self.prepared_cache.borrow_mut().clear();
451        self.layout_cache.borrow_mut().clear();
452    }
453}
454
455fn text_base_cache_key(
456    node_id: Option<NodeId>,
457    text: &crate::text::AnnotatedString,
458    style: &TextStyle,
459) -> TextBaseCacheKey {
460    TextBaseCacheKey {
461        node_id,
462        text_hash: text.render_hash(),
463        style_hash: style.measurement_hash(),
464    }
465}
466
467fn text_options_cache_key(
468    node_id: Option<NodeId>,
469    text: &crate::text::AnnotatedString,
470    style: &TextStyle,
471    options: TextLayoutOptions,
472    max_width: Option<f32>,
473) -> TextOptionsCacheKey {
474    TextOptionsCacheKey {
475        base: text_base_cache_key(node_id, text, style),
476        options: options.normalized(),
477        max_width_bits: normalize_max_width(max_width).map(f32::to_bits),
478    }
479}
480
481pub fn set_text_measurer<M: TextMeasurer>(measurer: M) {
482    crate::render_state::set_current_text_measurer(Rc::new(measurer));
483}
484
485pub fn measure_text(text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
486    crate::render_state::with_text_service(|service| service.measure(None, text, style))
487}
488
489pub fn measure_text_for_node(
490    node_id: Option<NodeId>,
491    text: &crate::text::AnnotatedString,
492    style: &TextStyle,
493) -> TextMetrics {
494    crate::render_state::with_text_service(|service| service.measure(node_id, text, style))
495}
496
497pub fn measure_text_with_options(
498    text: &crate::text::AnnotatedString,
499    style: &TextStyle,
500    options: TextLayoutOptions,
501    max_width: Option<f32>,
502) -> TextMetrics {
503    crate::render_state::with_text_service(|service| {
504        service.measure_with_options(None, text, style, options.normalized(), max_width)
505    })
506}
507
508pub fn measure_text_with_options_for_node(
509    node_id: Option<NodeId>,
510    text: &crate::text::AnnotatedString,
511    style: &TextStyle,
512    options: TextLayoutOptions,
513    max_width: Option<f32>,
514) -> TextMetrics {
515    crate::render_state::with_text_service(|service| {
516        service.measure_with_options(node_id, text, style, options.normalized(), max_width)
517    })
518}
519
520pub fn prepare_text_layout(
521    text: &crate::text::AnnotatedString,
522    style: &TextStyle,
523    options: TextLayoutOptions,
524    max_width: Option<f32>,
525) -> PreparedTextLayout {
526    crate::render_state::with_text_service(|service| {
527        service.prepare_with_options(None, text, style, options.normalized(), max_width)
528    })
529}
530
531pub fn prepare_text_layout_for_node(
532    node_id: Option<NodeId>,
533    text: &crate::text::AnnotatedString,
534    style: &TextStyle,
535    options: TextLayoutOptions,
536    max_width: Option<f32>,
537) -> PreparedTextLayout {
538    crate::render_state::with_text_service(|service| {
539        service.prepare_with_options(node_id, text, style, options.normalized(), max_width)
540    })
541}
542
543pub fn get_offset_for_position(
544    text: &crate::text::AnnotatedString,
545    style: &TextStyle,
546    x: f32,
547    y: f32,
548) -> usize {
549    crate::render_state::with_text_measurer(|m| m.get_offset_for_position(text, style, x, y))
550}
551
552pub fn get_cursor_x_for_offset(
553    text: &crate::text::AnnotatedString,
554    style: &TextStyle,
555    offset: usize,
556) -> f32 {
557    crate::render_state::with_text_measurer(|m| m.get_cursor_x_for_offset(text, style, offset))
558}
559
560pub fn layout_text(text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult {
561    crate::render_state::with_text_service(|service| service.layout(text, style))
562}
563
564fn prepare_text_layout_fallback<M: TextMeasurer + ?Sized>(
565    measurer: &M,
566    text: &crate::text::AnnotatedString,
567    style: &TextStyle,
568    options: TextLayoutOptions,
569    max_width: Option<f32>,
570) -> PreparedTextLayout {
571    prepare_text_layout_with_measurer_for_node(measurer, None, text, style, options, max_width)
572}
573
574pub fn prepare_text_layout_with_measurer_for_node<M: TextMeasurer + ?Sized>(
575    measurer: &M,
576    node_id: Option<NodeId>,
577    text: &crate::text::AnnotatedString,
578    style: &TextStyle,
579    options: TextLayoutOptions,
580    max_width: Option<f32>,
581) -> PreparedTextLayout {
582    let opts = options.normalized();
583    let max_width = normalize_max_width(max_width);
584    if let Some(min_font_size_sp) = opts.overflow.scale_down_min_font_size_sp() {
585        return prepare_scale_down_text_layout(
586            measurer,
587            node_id,
588            text,
589            style,
590            opts,
591            max_width,
592            min_font_size_sp,
593        );
594    }
595
596    let wrap_width = (opts.soft_wrap && opts.overflow != TextOverflow::Visible)
597        .then_some(max_width)
598        .flatten();
599    let line_break_mode = style
600        .paragraph_style
601        .line_break
602        .take_or_else(|| LineBreak::Simple);
603    let hyphens_mode = style.paragraph_style.hyphens.take_or_else(|| Hyphens::None);
604
605    let mut line_ranges = split_line_ranges(text.text.as_str());
606    if let Some(width_limit) = wrap_width {
607        let mut wrapped_ranges = Vec::with_capacity(line_ranges.len());
608        for line_range in line_ranges.drain(..) {
609            let wrapped_line_ranges = wrap_line_to_width(
610                measurer,
611                text,
612                line_range,
613                style,
614                width_limit,
615                line_break_mode,
616                hyphens_mode,
617            );
618            wrapped_ranges.extend(wrapped_line_ranges);
619        }
620        line_ranges = wrapped_ranges;
621    }
622
623    let mut did_overflow = false;
624    let mut visible_lines: Vec<DisplayLine> = line_ranges
625        .into_iter()
626        .map(DisplayLine::from_source_range)
627        .collect();
628
629    if opts.overflow != TextOverflow::Visible && visible_lines.len() > opts.max_lines {
630        did_overflow = true;
631        visible_lines.truncate(opts.max_lines);
632        if let Some(last_line) = visible_lines.last_mut() {
633            let overflowed = apply_line_overflow(
634                measurer,
635                last_line.display_text(text),
636                style,
637                max_width,
638                opts,
639                true,
640                true,
641            );
642            last_line.apply_display_text(text, overflowed);
643        }
644    }
645
646    if let Some(width_limit) = max_width {
647        let single_line_ellipsis = opts.max_lines == 1 || !opts.soft_wrap;
648        let visible_len = visible_lines.len();
649        for (line_index, line) in visible_lines.iter_mut().enumerate() {
650            let width = line.measure_width(measurer, node_id, text, style);
651            if width > width_limit + WRAP_EPSILON {
652                if opts.overflow == TextOverflow::Visible {
653                    continue;
654                }
655                did_overflow = true;
656                let overflowed = apply_line_overflow(
657                    measurer,
658                    line.display_text(text),
659                    style,
660                    Some(width_limit),
661                    opts,
662                    line_index + 1 == visible_len,
663                    single_line_ellipsis,
664                );
665                line.apply_display_text(text, overflowed);
666            }
667        }
668    }
669
670    let display_annotated = build_display_annotated(text, &visible_lines);
671    debug_assert_eq!(
672        display_annotated.text,
673        join_display_line_text(text, &visible_lines)
674    );
675    let line_height = measurer
676        .measure_for_node(node_id, text, style)
677        .line_height
678        .max(0.0);
679    let display_line_count = visible_lines.len().max(1);
680    let layout_line_count = display_line_count.max(opts.min_lines);
681
682    let measured_width = if visible_lines.is_empty() {
683        0.0
684    } else {
685        visible_lines
686            .iter()
687            .map(|line| line.measure_width(measurer, node_id, text, style))
688            .fold(0.0_f32, f32::max)
689    };
690    let width = if opts.overflow == TextOverflow::Visible {
691        measured_width
692    } else if let Some(width_limit) = max_width {
693        measured_width.min(width_limit)
694    } else {
695        measured_width
696    };
697
698    PreparedTextLayout {
699        text: display_annotated,
700        visual_style: style.clone(),
701        metrics: TextMetrics {
702            width,
703            height: layout_line_count as f32 * line_height,
704            line_height,
705            line_count: layout_line_count,
706        },
707        did_overflow,
708    }
709}
710
711fn prepare_scale_down_text_layout<M: TextMeasurer + ?Sized>(
712    measurer: &M,
713    node_id: Option<NodeId>,
714    text: &crate::text::AnnotatedString,
715    style: &TextStyle,
716    options: TextLayoutOptions,
717    max_width: Option<f32>,
718    min_font_size_sp: f32,
719) -> PreparedTextLayout {
720    let clipped_options = TextLayoutOptions {
721        overflow: TextOverflow::Clip,
722        ..options
723    }
724    .normalized();
725
726    let full_size = prepare_scaled_text_layout(
727        measurer,
728        node_id,
729        text,
730        style,
731        clipped_options,
732        max_width,
733        1.0,
734    );
735    let Some(width_limit) = max_width else {
736        return full_size;
737    };
738    if !full_size.did_overflow {
739        return full_size;
740    }
741
742    let base_font_size = style.resolve_font_size(DEFAULT_FONT_SIZE_SP);
743    if !base_font_size.is_finite() || base_font_size <= 0.0 {
744        return full_size;
745    }
746    let min_scale = (min_font_size_sp.min(base_font_size) / base_font_size).clamp(0.0, 1.0);
747    if min_scale >= 1.0 {
748        return full_size;
749    }
750
751    let min_size = prepare_scaled_text_layout(
752        measurer,
753        node_id,
754        text,
755        style,
756        clipped_options,
757        Some(width_limit),
758        min_scale,
759    );
760    if min_size.did_overflow {
761        return min_size;
762    }
763
764    let mut low = min_scale;
765    let mut high = 1.0;
766    let mut best = min_size;
767    for _ in 0..SCALE_DOWN_SEARCH_STEPS {
768        let mid = (low + high) * 0.5;
769        let candidate = prepare_scaled_text_layout(
770            measurer,
771            node_id,
772            text,
773            style,
774            clipped_options,
775            Some(width_limit),
776            mid,
777        );
778        if candidate.did_overflow {
779            high = mid;
780        } else {
781            low = mid;
782            best = candidate;
783        }
784    }
785
786    best
787}
788
789fn prepare_scaled_text_layout<M: TextMeasurer + ?Sized>(
790    measurer: &M,
791    node_id: Option<NodeId>,
792    text: &crate::text::AnnotatedString,
793    style: &TextStyle,
794    options: TextLayoutOptions,
795    max_width: Option<f32>,
796    font_scale: f32,
797) -> PreparedTextLayout {
798    let visual_style = scale_text_style_font_sizes(style, font_scale);
799    let visual_text = scale_annotated_font_sizes(text, font_scale);
800    prepare_text_layout_with_measurer_for_node(
801        measurer,
802        node_id,
803        visual_text.as_ref(),
804        &visual_style,
805        options,
806        max_width,
807    )
808}
809
810fn scale_annotated_font_sizes(
811    text: &crate::text::AnnotatedString,
812    factor: f32,
813) -> Cow<'_, crate::text::AnnotatedString> {
814    if is_identity_scale(factor) || !annotated_text_needs_scaling(text) {
815        return Cow::Borrowed(text);
816    }
817
818    let mut scaled = text.clone();
819    for span in &mut scaled.span_styles {
820        span.item = scale_span_style_font_sizes(&span.item, factor, None);
821    }
822    Cow::Owned(scaled)
823}
824
825fn scale_text_style_font_sizes(style: &TextStyle, factor: f32) -> TextStyle {
826    if is_identity_scale(factor) {
827        return style.clone();
828    }
829
830    let mut scaled = style.clone();
831    scaled.span_style =
832        scale_span_style_font_sizes(&style.span_style, factor, Some(DEFAULT_FONT_SIZE_SP));
833    scaled.paragraph_style.line_height =
834        scale_text_unit_sp(scaled.paragraph_style.line_height, factor);
835    if let Some(mut indent) = scaled.paragraph_style.text_indent {
836        indent.first_line = scale_text_unit_sp(indent.first_line, factor);
837        indent.rest_line = scale_text_unit_sp(indent.rest_line, factor);
838        scaled.paragraph_style.text_indent = Some(indent);
839    }
840    scaled
841}
842
843fn scale_span_style_font_sizes(
844    style: &crate::text::SpanStyle,
845    factor: f32,
846    default_font_size_sp: Option<f32>,
847) -> crate::text::SpanStyle {
848    let mut scaled = style.clone();
849    scaled.font_size = match (style.font_size, default_font_size_sp) {
850        (crate::text::TextUnit::Unspecified, Some(default_size)) => {
851            crate::text::TextUnit::Sp(default_size * factor)
852        }
853        (unit, Some(_)) => scale_text_unit_sp_and_em(unit, factor),
854        (unit, None) => scale_text_unit_sp(unit, factor),
855    };
856    scaled.letter_spacing = scale_text_unit_sp(scaled.letter_spacing, factor);
857    if let Some(mut shadow) = scaled.shadow {
858        shadow.offset.x = scale_finite_dimension(shadow.offset.x, factor);
859        shadow.offset.y = scale_finite_dimension(shadow.offset.y, factor);
860        shadow.blur_radius = scale_finite_dimension(shadow.blur_radius, factor);
861        scaled.shadow = Some(shadow);
862    }
863    if let Some(crate::text::TextDrawStyle::Stroke { width }) = scaled.draw_style {
864        scaled.draw_style = Some(crate::text::TextDrawStyle::Stroke {
865            width: width * factor,
866        });
867    }
868    scaled
869}
870
871fn annotated_text_needs_scaling(text: &crate::text::AnnotatedString) -> bool {
872    text.span_styles
873        .iter()
874        .any(|span| span_style_needs_scaling(&span.item))
875}
876
877fn span_style_needs_scaling(style: &crate::text::SpanStyle) -> bool {
878    matches!(style.font_size, crate::text::TextUnit::Sp(value) if value.is_finite())
879        || matches!(style.letter_spacing, crate::text::TextUnit::Sp(value) if value.is_finite())
880        || matches!(
881            style.draw_style,
882            Some(crate::text::TextDrawStyle::Stroke { .. })
883        )
884        || style.shadow.is_some()
885}
886
887fn scale_text_unit_sp(unit: crate::text::TextUnit, factor: f32) -> crate::text::TextUnit {
888    match unit {
889        crate::text::TextUnit::Sp(value) if value.is_finite() => {
890            crate::text::TextUnit::Sp(value * factor)
891        }
892        other => other,
893    }
894}
895
896fn scale_text_unit_sp_and_em(unit: crate::text::TextUnit, factor: f32) -> crate::text::TextUnit {
897    match unit {
898        crate::text::TextUnit::Sp(value) if value.is_finite() => {
899            crate::text::TextUnit::Sp(value * factor)
900        }
901        crate::text::TextUnit::Em(value) if value.is_finite() => {
902            crate::text::TextUnit::Em(value * factor)
903        }
904        other => other,
905    }
906}
907
908fn scale_finite_dimension(value: f32, factor: f32) -> f32 {
909    if value.is_finite() {
910        value * factor
911    } else {
912        value
913    }
914}
915
916fn is_identity_scale(factor: f32) -> bool {
917    (factor - 1.0).abs() <= f32::EPSILON
918}
919
920#[derive(Clone, Debug)]
921enum DisplayLineText {
922    Source,
923    Remapped(crate::text::AnnotatedString),
924}
925
926#[derive(Clone, Debug)]
927struct DisplayLine {
928    source_range: Range<usize>,
929    text: DisplayLineText,
930}
931
932impl DisplayLine {
933    fn from_source_range(source_range: Range<usize>) -> Self {
934        Self {
935            source_range,
936            text: DisplayLineText::Source,
937        }
938    }
939
940    fn display_text<'a>(&'a self, source: &'a crate::text::AnnotatedString) -> &'a str {
941        match &self.text {
942            DisplayLineText::Source => &source.text[self.source_range.clone()],
943            DisplayLineText::Remapped(annotated) => annotated.text.as_str(),
944        }
945    }
946
947    fn measure_width<M: TextMeasurer + ?Sized>(
948        &self,
949        measurer: &M,
950        node_id: Option<NodeId>,
951        source: &crate::text::AnnotatedString,
952        style: &TextStyle,
953    ) -> f32 {
954        match &self.text {
955            DisplayLineText::Source => {
956                measurer
957                    .measure_subsequence_for_node(node_id, source, self.source_range.clone(), style)
958                    .width
959            }
960            DisplayLineText::Remapped(annotated) => {
961                measurer.measure_for_node(node_id, annotated, style).width
962            }
963        }
964    }
965
966    fn apply_display_text(&mut self, source: &crate::text::AnnotatedString, display_text: String) {
967        let source_text = &source.text[self.source_range.clone()];
968        self.text = if source_text == display_text {
969            DisplayLineText::Source
970        } else {
971            DisplayLineText::Remapped(remap_annotated_subsequence_for_display(
972                source,
973                self.source_range.clone(),
974                display_text.as_str(),
975            ))
976        };
977    }
978}
979
980fn split_line_ranges(text: &str) -> Vec<Range<usize>> {
981    if text.is_empty() {
982        return single_line_range(0..0);
983    }
984
985    let mut ranges = Vec::new();
986    let mut start = 0usize;
987    for (idx, ch) in text.char_indices() {
988        if ch == '\n' {
989            ranges.push(start..idx);
990            start = idx + ch.len_utf8();
991        }
992    }
993    ranges.push(start..text.len());
994    ranges
995}
996
997fn build_display_annotated(
998    source: &crate::text::AnnotatedString,
999    lines: &[DisplayLine],
1000) -> crate::text::AnnotatedString {
1001    if lines.is_empty() {
1002        return crate::text::AnnotatedString::from("");
1003    }
1004
1005    let mut builder = crate::text::AnnotatedString::builder();
1006    for (idx, line) in lines.iter().enumerate() {
1007        builder = match &line.text {
1008            DisplayLineText::Source => {
1009                builder.append_annotated_subsequence(source, line.source_range.clone())
1010            }
1011            DisplayLineText::Remapped(annotated) => builder.append_annotated(annotated),
1012        };
1013        if idx + 1 < lines.len() {
1014            builder = builder.append("\n");
1015        }
1016    }
1017    builder.to_annotated_string()
1018}
1019
1020fn join_display_line_text(source: &crate::text::AnnotatedString, lines: &[DisplayLine]) -> String {
1021    let mut text = String::new();
1022    for (idx, line) in lines.iter().enumerate() {
1023        text.push_str(line.display_text(source));
1024        if idx + 1 < lines.len() {
1025            text.push('\n');
1026        }
1027    }
1028    text
1029}
1030
1031fn trim_segment_end_whitespace(line: &str, start: usize, mut end: usize) -> usize {
1032    while end > start {
1033        let Some((idx, ch)) = line[start..end].char_indices().next_back() else {
1034            break;
1035        };
1036        if ch.is_whitespace() {
1037            end = start + idx;
1038        } else {
1039            break;
1040        }
1041    }
1042    end
1043}
1044
1045fn remap_annotated_subsequence_for_display(
1046    source: &crate::text::AnnotatedString,
1047    source_range: Range<usize>,
1048    display_text: &str,
1049) -> crate::text::AnnotatedString {
1050    let source_text = &source.text[source_range.clone()];
1051    if source_text == display_text {
1052        return source.subsequence(source_range);
1053    }
1054
1055    let display_chars = map_display_chars_to_source(source_text, display_text);
1056    crate::text::AnnotatedString {
1057        text: display_text.to_string(),
1058        span_styles: remap_subsequence_range_styles(
1059            &source.span_styles,
1060            source_range.clone(),
1061            &display_chars,
1062        ),
1063        paragraph_styles: remap_subsequence_range_styles(
1064            &source.paragraph_styles,
1065            source_range.clone(),
1066            &display_chars,
1067        ),
1068        string_annotations: remap_subsequence_range_styles(
1069            &source.string_annotations,
1070            source_range.clone(),
1071            &display_chars,
1072        ),
1073        link_annotations: remap_subsequence_range_styles(
1074            &source.link_annotations,
1075            source_range,
1076            &display_chars,
1077        ),
1078    }
1079}
1080
1081#[derive(Clone, Copy)]
1082struct DisplayCharMap {
1083    display_start: usize,
1084    display_end: usize,
1085    source_start: Option<usize>,
1086}
1087
1088fn map_display_chars_to_source(source: &str, display: &str) -> Vec<DisplayCharMap> {
1089    let source_chars: Vec<(usize, char)> = source.char_indices().collect();
1090    let mut source_index = 0usize;
1091    let mut maps = Vec::with_capacity(display.chars().count());
1092
1093    for (display_start, display_char) in display.char_indices() {
1094        let display_end = display_start + display_char.len_utf8();
1095        let mut source_start = None;
1096        while source_index < source_chars.len() {
1097            let (candidate_start, candidate_char) = source_chars[source_index];
1098            source_index += 1;
1099            if candidate_char == display_char {
1100                source_start = Some(candidate_start);
1101                break;
1102            }
1103        }
1104        maps.push(DisplayCharMap {
1105            display_start,
1106            display_end,
1107            source_start,
1108        });
1109    }
1110
1111    maps
1112}
1113
1114fn remap_subsequence_range_styles<T: Clone>(
1115    styles: &[crate::text::RangeStyle<T>],
1116    source_range: Range<usize>,
1117    display_chars: &[DisplayCharMap],
1118) -> Vec<crate::text::RangeStyle<T>> {
1119    let mut remapped = Vec::new();
1120
1121    for style in styles {
1122        let overlap_start = style.range.start.max(source_range.start);
1123        let overlap_end = style.range.end.min(source_range.end);
1124        if overlap_start >= overlap_end {
1125            continue;
1126        }
1127        let local_source_range =
1128            (overlap_start - source_range.start)..(overlap_end - source_range.start);
1129        let mut range_start = None;
1130        let mut range_end = 0usize;
1131
1132        for map in display_chars {
1133            let in_range = map.source_start.is_some_and(|source_start| {
1134                source_start >= local_source_range.start && source_start < local_source_range.end
1135            });
1136
1137            if in_range {
1138                if range_start.is_none() {
1139                    range_start = Some(map.display_start);
1140                }
1141                range_end = map.display_end;
1142                continue;
1143            }
1144
1145            if let Some(start) = range_start.take() {
1146                if start < range_end {
1147                    remapped.push(crate::text::RangeStyle {
1148                        item: style.item.clone(),
1149                        range: start..range_end,
1150                    });
1151                }
1152            }
1153        }
1154
1155        if let Some(start) = range_start.take() {
1156            if start < range_end {
1157                remapped.push(crate::text::RangeStyle {
1158                    item: style.item.clone(),
1159                    range: start..range_end,
1160                });
1161            }
1162        }
1163    }
1164
1165    remapped
1166}
1167
1168fn normalize_max_width(max_width: Option<f32>) -> Option<f32> {
1169    match max_width {
1170        Some(width) if width.is_finite() && width > 0.0 => Some(width),
1171        _ => None,
1172    }
1173}
1174
1175fn absolute_range(base: &Range<usize>, relative: Range<usize>) -> Range<usize> {
1176    (base.start + relative.start)..(base.start + relative.end)
1177}
1178
1179fn single_line_range(range: Range<usize>) -> Vec<Range<usize>> {
1180    std::iter::once(range).collect()
1181}
1182
1183fn wrap_line_to_width<M: TextMeasurer + ?Sized>(
1184    measurer: &M,
1185    text: &crate::text::AnnotatedString,
1186    line_range: Range<usize>,
1187    style: &TextStyle,
1188    max_width: f32,
1189    line_break: LineBreak,
1190    hyphens: Hyphens,
1191) -> Vec<Range<usize>> {
1192    let line_text = &text.text[line_range.clone()];
1193    if line_text.is_empty() {
1194        return single_line_range(line_range.start..line_range.start);
1195    }
1196
1197    if matches!(line_break, LineBreak::Heading | LineBreak::Paragraph)
1198        && line_text.chars().any(char::is_whitespace)
1199    {
1200        if let Some(balanced) = wrap_line_with_word_balance(
1201            measurer,
1202            text,
1203            line_range.clone(),
1204            style,
1205            max_width,
1206            line_break,
1207        ) {
1208            return balanced;
1209        }
1210    }
1211
1212    wrap_line_greedy(
1213        measurer, text, line_range, style, max_width, line_break, hyphens,
1214    )
1215}
1216
1217fn wrap_line_greedy<M: TextMeasurer + ?Sized>(
1218    measurer: &M,
1219    text: &crate::text::AnnotatedString,
1220    line_range: Range<usize>,
1221    style: &TextStyle,
1222    max_width: f32,
1223    line_break: LineBreak,
1224    hyphens: Hyphens,
1225) -> Vec<Range<usize>> {
1226    let line_text = &text.text[line_range.clone()];
1227    let boundaries = char_boundaries(line_text);
1228    let mut wrapped = Vec::new();
1229    let mut start_idx = 0usize;
1230
1231    while start_idx < boundaries.len() - 1 {
1232        let mut low = start_idx + 1;
1233        let mut high = boundaries.len() - 1;
1234        let mut best = start_idx + 1;
1235
1236        while low <= high {
1237            let mid = (low + high) / 2;
1238            let segment_range = absolute_range(&line_range, boundaries[start_idx]..boundaries[mid]);
1239            let width = measurer
1240                .measure_subsequence(text, segment_range, style)
1241                .width;
1242            if width <= max_width + WRAP_EPSILON || mid == start_idx + 1 {
1243                best = mid;
1244                low = mid + 1;
1245            } else {
1246                if mid == 0 {
1247                    break;
1248                }
1249                high = mid - 1;
1250            }
1251        }
1252
1253        let wrap_idx = choose_wrap_break(line_text, &boundaries, start_idx, best, line_break);
1254        let mut effective_wrap_idx = wrap_idx;
1255        let can_hyphenate = hyphens == Hyphens::Auto
1256            && wrap_idx == best
1257            && best < boundaries.len() - 1
1258            && is_break_inside_word(line_text, &boundaries, wrap_idx);
1259        if can_hyphenate {
1260            effective_wrap_idx = resolve_auto_hyphen_break(
1261                measurer,
1262                line_text,
1263                style,
1264                &boundaries,
1265                start_idx,
1266                wrap_idx,
1267            );
1268        }
1269
1270        let segment_start = boundaries[start_idx];
1271        let mut segment_end = boundaries[effective_wrap_idx];
1272        if wrap_idx != best {
1273            segment_end = trim_segment_end_whitespace(line_text, segment_start, segment_end);
1274        }
1275        wrapped.push(absolute_range(&line_range, segment_start..segment_end));
1276
1277        start_idx = if wrap_idx != best {
1278            skip_leading_whitespace(line_text, &boundaries, wrap_idx)
1279        } else {
1280            effective_wrap_idx
1281        };
1282    }
1283
1284    if wrapped.is_empty() {
1285        wrapped.push(line_range.start..line_range.start);
1286    }
1287
1288    wrapped
1289}
1290
1291fn wrap_line_with_word_balance<M: TextMeasurer + ?Sized>(
1292    measurer: &M,
1293    text: &crate::text::AnnotatedString,
1294    line_range: Range<usize>,
1295    style: &TextStyle,
1296    max_width: f32,
1297    line_break: LineBreak,
1298) -> Option<Vec<Range<usize>>> {
1299    let line_text = &text.text[line_range.clone()];
1300    let boundaries = char_boundaries(line_text);
1301    let breakpoints = collect_word_breakpoints(line_text, &boundaries);
1302    if breakpoints.len() <= 2 {
1303        return None;
1304    }
1305
1306    let node_count = breakpoints.len();
1307    let mut best_cost = vec![f32::INFINITY; node_count];
1308    let mut next_index = vec![None; node_count];
1309    best_cost[node_count - 1] = 0.0;
1310
1311    for start in (0..node_count - 1).rev() {
1312        for end in start + 1..node_count {
1313            let start_byte = boundaries[breakpoints[start]];
1314            let end_byte = boundaries[breakpoints[end]];
1315            let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
1316            if trimmed_end <= start_byte {
1317                continue;
1318            }
1319            let segment_range = absolute_range(&line_range, start_byte..trimmed_end);
1320            let segment_width = measurer
1321                .measure_subsequence(text, segment_range, style)
1322                .width;
1323            if segment_width > max_width + WRAP_EPSILON {
1324                continue;
1325            }
1326            if !best_cost[end].is_finite() {
1327                continue;
1328            }
1329            let slack = (max_width - segment_width).max(0.0);
1330            let is_last = end == node_count - 1;
1331            let segment_cost = match line_break {
1332                LineBreak::Heading => slack * slack,
1333                LineBreak::Paragraph => {
1334                    if is_last {
1335                        slack * slack * 0.16
1336                    } else {
1337                        slack * slack
1338                    }
1339                }
1340                LineBreak::Simple | LineBreak::Unspecified => slack * slack,
1341            };
1342            let candidate = segment_cost + best_cost[end];
1343            if candidate < best_cost[start] {
1344                best_cost[start] = candidate;
1345                next_index[start] = Some(end);
1346            }
1347        }
1348    }
1349
1350    let mut wrapped = Vec::new();
1351    let mut current = 0usize;
1352    while current < node_count - 1 {
1353        let next = next_index[current]?;
1354        let start_byte = boundaries[breakpoints[current]];
1355        let end_byte = boundaries[breakpoints[next]];
1356        let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
1357        if trimmed_end <= start_byte {
1358            return None;
1359        }
1360        wrapped.push(absolute_range(&line_range, start_byte..trimmed_end));
1361        current = next;
1362    }
1363
1364    if wrapped.is_empty() {
1365        return None;
1366    }
1367
1368    Some(wrapped)
1369}
1370
1371fn collect_word_breakpoints(line: &str, boundaries: &[usize]) -> Vec<usize> {
1372    let mut points = vec![0usize];
1373    for idx in 1..boundaries.len() - 1 {
1374        let prev = &line[boundaries[idx - 1]..boundaries[idx]];
1375        let current = &line[boundaries[idx]..boundaries[idx + 1]];
1376        if prev.chars().all(char::is_whitespace) && !current.chars().all(char::is_whitespace) {
1377            points.push(idx);
1378        }
1379    }
1380    let end = boundaries.len() - 1;
1381    if points.last().copied() != Some(end) {
1382        points.push(end);
1383    }
1384    points
1385}
1386
1387fn choose_wrap_break(
1388    line: &str,
1389    boundaries: &[usize],
1390    start_idx: usize,
1391    best: usize,
1392    _line_break: LineBreak,
1393) -> usize {
1394    if best >= boundaries.len() - 1 {
1395        return best;
1396    }
1397
1398    if best <= start_idx + 1 {
1399        return best;
1400    }
1401
1402    for idx in (start_idx + 1..best).rev() {
1403        let prev = &line[boundaries[idx - 1]..boundaries[idx]];
1404        if prev.chars().all(char::is_whitespace) {
1405            return idx;
1406        }
1407    }
1408    best
1409}
1410
1411fn is_break_inside_word(line: &str, boundaries: &[usize], break_idx: usize) -> bool {
1412    if break_idx == 0 || break_idx >= boundaries.len() - 1 {
1413        return false;
1414    }
1415    let prev = &line[boundaries[break_idx - 1]..boundaries[break_idx]];
1416    let next = &line[boundaries[break_idx]..boundaries[break_idx + 1]];
1417    !prev.chars().all(char::is_whitespace) && !next.chars().all(char::is_whitespace)
1418}
1419
1420fn resolve_auto_hyphen_break<M: TextMeasurer + ?Sized>(
1421    measurer: &M,
1422    line: &str,
1423    style: &TextStyle,
1424    boundaries: &[usize],
1425    start_idx: usize,
1426    break_idx: usize,
1427) -> usize {
1428    if let Some(candidate) = measurer.choose_auto_hyphen_break(line, style, start_idx, break_idx) {
1429        if is_valid_auto_hyphen_break(line, boundaries, start_idx, break_idx, candidate) {
1430            return candidate;
1431        }
1432    }
1433    choose_auto_hyphen_break_fallback(boundaries, start_idx, break_idx)
1434}
1435
1436fn is_valid_auto_hyphen_break(
1437    line: &str,
1438    boundaries: &[usize],
1439    start_idx: usize,
1440    break_idx: usize,
1441    candidate_idx: usize,
1442) -> bool {
1443    let end_idx = boundaries.len().saturating_sub(1);
1444    candidate_idx > start_idx
1445        && candidate_idx < end_idx
1446        && candidate_idx <= break_idx
1447        && candidate_idx >= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS
1448        && is_break_inside_word(line, boundaries, candidate_idx)
1449}
1450
1451fn choose_auto_hyphen_break_fallback(
1452    boundaries: &[usize],
1453    start_idx: usize,
1454    break_idx: usize,
1455) -> usize {
1456    let end_idx = boundaries.len().saturating_sub(1);
1457    if break_idx >= end_idx {
1458        return break_idx;
1459    }
1460    let trailing_len = end_idx.saturating_sub(break_idx);
1461    if trailing_len > 2 || break_idx <= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS {
1462        return break_idx;
1463    }
1464
1465    let min_break = start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS;
1466    let max_break = break_idx.saturating_sub(1);
1467    if min_break > max_break {
1468        return break_idx;
1469    }
1470
1471    let mut best_break = break_idx;
1472    let mut best_penalty = usize::MAX;
1473    for idx in min_break..=max_break {
1474        let candidate_trailing_len = end_idx.saturating_sub(idx);
1475        let candidate_prefix_len = idx.saturating_sub(start_idx);
1476        if candidate_prefix_len < AUTO_HYPHEN_MIN_SEGMENT_CHARS
1477            || candidate_trailing_len < AUTO_HYPHEN_MIN_TRAILING_CHARS
1478        {
1479            continue;
1480        }
1481
1482        let penalty = candidate_trailing_len.abs_diff(AUTO_HYPHEN_PREFERRED_TRAILING_CHARS);
1483        if penalty < best_penalty {
1484            best_penalty = penalty;
1485            best_break = idx;
1486            if penalty == 0 {
1487                break;
1488            }
1489        }
1490    }
1491    best_break
1492}
1493
1494fn skip_leading_whitespace(line: &str, boundaries: &[usize], mut idx: usize) -> usize {
1495    while idx < boundaries.len() - 1 {
1496        let ch = &line[boundaries[idx]..boundaries[idx + 1]];
1497        if !ch.chars().all(char::is_whitespace) {
1498            break;
1499        }
1500        idx += 1;
1501    }
1502    idx
1503}
1504
1505fn apply_line_overflow<M: TextMeasurer + ?Sized>(
1506    measurer: &M,
1507    line: &str,
1508    style: &TextStyle,
1509    max_width: Option<f32>,
1510    options: TextLayoutOptions,
1511    is_last_visible_line: bool,
1512    single_line_ellipsis: bool,
1513) -> String {
1514    if options.overflow == TextOverflow::Clip || !is_last_visible_line {
1515        return line.to_string();
1516    }
1517
1518    let Some(width_limit) = max_width else {
1519        return match options.overflow {
1520            TextOverflow::Ellipsis => format!("{line}{ELLIPSIS}"),
1521            TextOverflow::StartEllipsis => format!("{ELLIPSIS}{line}"),
1522            TextOverflow::MiddleEllipsis => format!("{ELLIPSIS}{line}"),
1523            TextOverflow::Clip | TextOverflow::Visible | TextOverflow::ScaleDown { .. } => {
1524                line.to_string()
1525            }
1526        };
1527    };
1528
1529    match options.overflow {
1530        TextOverflow::Clip | TextOverflow::Visible => line.to_string(),
1531        TextOverflow::Ellipsis => fit_end_ellipsis(measurer, line, style, width_limit),
1532        TextOverflow::StartEllipsis => {
1533            if single_line_ellipsis {
1534                fit_start_ellipsis(measurer, line, style, width_limit)
1535            } else {
1536                line.to_string()
1537            }
1538        }
1539        TextOverflow::MiddleEllipsis => {
1540            if single_line_ellipsis {
1541                fit_middle_ellipsis(measurer, line, style, width_limit)
1542            } else {
1543                line.to_string()
1544            }
1545        }
1546        TextOverflow::ScaleDown { .. } => line.to_string(),
1547    }
1548}
1549
1550fn fit_end_ellipsis<M: TextMeasurer + ?Sized>(
1551    measurer: &M,
1552    line: &str,
1553    style: &TextStyle,
1554    max_width: f32,
1555) -> String {
1556    if measurer
1557        .measure(&crate::text::AnnotatedString::from(line), style)
1558        .width
1559        <= max_width + WRAP_EPSILON
1560    {
1561        return line.to_string();
1562    }
1563
1564    let ellipsis_width = measurer
1565        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
1566        .width;
1567    if ellipsis_width > max_width + WRAP_EPSILON {
1568        return String::new();
1569    }
1570
1571    let boundaries = char_boundaries(line);
1572    let mut low = 0usize;
1573    let mut high = boundaries.len() - 1;
1574    let mut best = 0usize;
1575
1576    while low <= high {
1577        let mid = (low + high) / 2;
1578        let prefix = &line[..boundaries[mid]];
1579        let candidate = format!("{prefix}{ELLIPSIS}");
1580        let width = measurer
1581            .measure(
1582                &crate::text::AnnotatedString::from(candidate.as_str()),
1583                style,
1584            )
1585            .width;
1586        if width <= max_width + WRAP_EPSILON {
1587            best = mid;
1588            low = mid + 1;
1589        } else if mid == 0 {
1590            break;
1591        } else {
1592            high = mid - 1;
1593        }
1594    }
1595
1596    format!("{}{}", &line[..boundaries[best]], ELLIPSIS)
1597}
1598
1599fn fit_start_ellipsis<M: TextMeasurer + ?Sized>(
1600    measurer: &M,
1601    line: &str,
1602    style: &TextStyle,
1603    max_width: f32,
1604) -> String {
1605    if measurer
1606        .measure(&crate::text::AnnotatedString::from(line), style)
1607        .width
1608        <= max_width + WRAP_EPSILON
1609    {
1610        return line.to_string();
1611    }
1612
1613    let ellipsis_width = measurer
1614        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
1615        .width;
1616    if ellipsis_width > max_width + WRAP_EPSILON {
1617        return String::new();
1618    }
1619
1620    let boundaries = char_boundaries(line);
1621    let mut low = 0usize;
1622    let mut high = boundaries.len() - 1;
1623    let mut best = boundaries.len() - 1;
1624
1625    while low <= high {
1626        let mid = (low + high) / 2;
1627        let suffix = &line[boundaries[mid]..];
1628        let candidate = format!("{ELLIPSIS}{suffix}");
1629        let width = measurer
1630            .measure(
1631                &crate::text::AnnotatedString::from(candidate.as_str()),
1632                style,
1633            )
1634            .width;
1635        if width <= max_width + WRAP_EPSILON {
1636            best = mid;
1637            if mid == 0 {
1638                break;
1639            }
1640            high = mid - 1;
1641        } else {
1642            low = mid + 1;
1643        }
1644    }
1645
1646    format!("{ELLIPSIS}{}", &line[boundaries[best]..])
1647}
1648
1649fn fit_middle_ellipsis<M: TextMeasurer + ?Sized>(
1650    measurer: &M,
1651    line: &str,
1652    style: &TextStyle,
1653    max_width: f32,
1654) -> String {
1655    if measurer
1656        .measure(&crate::text::AnnotatedString::from(line), style)
1657        .width
1658        <= max_width + WRAP_EPSILON
1659    {
1660        return line.to_string();
1661    }
1662
1663    let ellipsis_width = measurer
1664        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
1665        .width;
1666    if ellipsis_width > max_width + WRAP_EPSILON {
1667        return String::new();
1668    }
1669
1670    let boundaries = char_boundaries(line);
1671    let total_chars = boundaries.len().saturating_sub(1);
1672    for keep in (0..=total_chars).rev() {
1673        let keep_start = keep.div_ceil(2);
1674        let keep_end = keep / 2;
1675        let start = &line[..boundaries[keep_start]];
1676        let end_start = boundaries[total_chars.saturating_sub(keep_end)];
1677        let end = &line[end_start..];
1678        let candidate = format!("{start}{ELLIPSIS}{end}");
1679        if measurer
1680            .measure(
1681                &crate::text::AnnotatedString::from(candidate.as_str()),
1682                style,
1683            )
1684            .width
1685            <= max_width + WRAP_EPSILON
1686        {
1687            return candidate;
1688        }
1689    }
1690
1691    ELLIPSIS.to_string()
1692}
1693
1694fn char_boundaries(text: &str) -> Vec<usize> {
1695    let mut out = Vec::with_capacity(text.chars().count() + 1);
1696    out.push(0);
1697    for (idx, _) in text.char_indices() {
1698        if idx != 0 {
1699            out.push(idx);
1700        }
1701    }
1702    out.push(text.len());
1703    out
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708    use super::*;
1709    use crate::text::{Hyphens, LineBreak, ParagraphStyle, TextUnit};
1710    use crate::text_layout_result::TextLayoutResult;
1711    use std::cell::Cell;
1712
1713    struct ContractBreakMeasurer {
1714        retreat: usize,
1715    }
1716
1717    impl TextMeasurer for ContractBreakMeasurer {
1718        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
1719            MonospacedTextMeasurer.measure(
1720                &crate::text::AnnotatedString::from(text.text.as_str()),
1721                style,
1722            )
1723        }
1724
1725        fn get_offset_for_position(
1726            &self,
1727            text: &crate::text::AnnotatedString,
1728            style: &TextStyle,
1729            x: f32,
1730            y: f32,
1731        ) -> usize {
1732            MonospacedTextMeasurer.get_offset_for_position(
1733                &crate::text::AnnotatedString::from(text.text.as_str()),
1734                style,
1735                x,
1736                y,
1737            )
1738        }
1739
1740        fn get_cursor_x_for_offset(
1741            &self,
1742            text: &crate::text::AnnotatedString,
1743            style: &TextStyle,
1744            offset: usize,
1745        ) -> f32 {
1746            MonospacedTextMeasurer.get_cursor_x_for_offset(
1747                &crate::text::AnnotatedString::from(text.text.as_str()),
1748                style,
1749                offset,
1750            )
1751        }
1752
1753        fn layout(
1754            &self,
1755            text: &crate::text::AnnotatedString,
1756            style: &TextStyle,
1757        ) -> TextLayoutResult {
1758            MonospacedTextMeasurer.layout(
1759                &crate::text::AnnotatedString::from(text.text.as_str()),
1760                style,
1761            )
1762        }
1763
1764        fn choose_auto_hyphen_break(
1765            &self,
1766            _line: &str,
1767            _style: &TextStyle,
1768            _segment_start_char: usize,
1769            measured_break_char: usize,
1770        ) -> Option<usize> {
1771            measured_break_char.checked_sub(self.retreat)
1772        }
1773    }
1774
1775    struct CountingTextMeasurer {
1776        measure_calls: Rc<Cell<usize>>,
1777        layout_calls: Rc<Cell<usize>>,
1778    }
1779
1780    impl CountingTextMeasurer {
1781        fn new(measure_calls: Rc<Cell<usize>>, layout_calls: Rc<Cell<usize>>) -> Self {
1782            Self {
1783                measure_calls,
1784                layout_calls,
1785            }
1786        }
1787    }
1788
1789    impl TextMeasurer for CountingTextMeasurer {
1790        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
1791            self.measure_calls.set(self.measure_calls.get() + 1);
1792            MonospacedTextMeasurer.measure(text, style)
1793        }
1794
1795        fn get_offset_for_position(
1796            &self,
1797            text: &crate::text::AnnotatedString,
1798            style: &TextStyle,
1799            x: f32,
1800            y: f32,
1801        ) -> usize {
1802            MonospacedTextMeasurer.get_offset_for_position(text, style, x, y)
1803        }
1804
1805        fn get_cursor_x_for_offset(
1806            &self,
1807            text: &crate::text::AnnotatedString,
1808            style: &TextStyle,
1809            offset: usize,
1810        ) -> f32 {
1811            MonospacedTextMeasurer.get_cursor_x_for_offset(text, style, offset)
1812        }
1813
1814        fn layout(
1815            &self,
1816            text: &crate::text::AnnotatedString,
1817            style: &TextStyle,
1818        ) -> TextLayoutResult {
1819            self.layout_calls.set(self.layout_calls.get() + 1);
1820            MonospacedTextMeasurer.layout(text, style)
1821        }
1822    }
1823
1824    #[test]
1825    fn text_service_routes_measurement_through_current_measurer() {
1826        let _app_context = crate::render_state::app_context_test_scope();
1827        let service = TextService::from_measurer(Rc::new(MonospacedTextMeasurer));
1828        let text = crate::text::AnnotatedString::from("abc");
1829        let style = TextStyle::default();
1830
1831        let metrics = service.with_measurer(|measurer| measurer.measure(&text, &style));
1832
1833        assert!(metrics.width > 0.0);
1834        assert!(metrics.height > 0.0);
1835    }
1836
1837    #[test]
1838    fn text_service_caches_metrics_and_layouts_per_context() {
1839        let _app_context = crate::render_state::app_context_test_scope();
1840        let measure_calls = Rc::new(Cell::new(0));
1841        let layout_calls = Rc::new(Cell::new(0));
1842        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
1843            Rc::clone(&measure_calls),
1844            Rc::clone(&layout_calls),
1845        )));
1846        let text = crate::text::AnnotatedString::from("cached text");
1847        let style = TextStyle::default();
1848
1849        let first_metrics = service.measure(Some(7), &text, &style);
1850        let second_metrics = service.measure(Some(7), &text, &style);
1851        let first_layout = service.layout(&text, &style);
1852        let second_layout = service.layout(&text, &style);
1853
1854        assert_eq!(first_metrics, second_metrics);
1855        assert_eq!(first_layout.width, second_layout.width);
1856        assert_eq!(measure_calls.get(), 1);
1857        assert_eq!(layout_calls.get(), 1);
1858    }
1859
1860    #[test]
1861    fn text_service_clears_caches_when_measurer_changes() {
1862        let _app_context = crate::render_state::app_context_test_scope();
1863        let first_measure_calls = Rc::new(Cell::new(0));
1864        let second_measure_calls = Rc::new(Cell::new(0));
1865        let layout_calls = Rc::new(Cell::new(0));
1866        let service = TextService::from_measurer(Rc::new(CountingTextMeasurer::new(
1867            Rc::clone(&first_measure_calls),
1868            Rc::clone(&layout_calls),
1869        )));
1870        let text = crate::text::AnnotatedString::from("cached text");
1871        let style = TextStyle::default();
1872
1873        let _ = service.measure(None, &text, &style);
1874        let _ = service.measure(None, &text, &style);
1875        service.set_measurer(Rc::new(CountingTextMeasurer::new(
1876            Rc::clone(&second_measure_calls),
1877            Rc::clone(&layout_calls),
1878        )));
1879        let _ = service.measure(None, &text, &style);
1880
1881        assert_eq!(first_measure_calls.get(), 1);
1882        assert_eq!(second_measure_calls.get(), 1);
1883    }
1884
1885    fn style_with_line_break(line_break: LineBreak) -> TextStyle {
1886        TextStyle {
1887            span_style: crate::text::SpanStyle {
1888                font_size: TextUnit::Sp(10.0),
1889                ..Default::default()
1890            },
1891            paragraph_style: ParagraphStyle {
1892                line_break,
1893                ..Default::default()
1894            },
1895        }
1896    }
1897
1898    fn style_with_hyphens(hyphens: Hyphens) -> TextStyle {
1899        TextStyle {
1900            span_style: crate::text::SpanStyle {
1901                font_size: TextUnit::Sp(10.0),
1902                ..Default::default()
1903            },
1904            paragraph_style: ParagraphStyle {
1905                hyphens,
1906                ..Default::default()
1907            },
1908        }
1909    }
1910
1911    fn assert_f32_close(actual: f32, expected: f32) {
1912        assert!(
1913            (actual - expected).abs() <= 0.01,
1914            "actual={actual}, expected={expected}"
1915        );
1916    }
1917
1918    #[test]
1919    fn text_layout_options_wraps_and_limits_lines() {
1920        let _app_context = crate::render_state::app_context_test_scope();
1921        let style = TextStyle {
1922            span_style: crate::text::SpanStyle {
1923                font_size: TextUnit::Sp(10.0),
1924                ..Default::default()
1925            },
1926            ..Default::default()
1927        };
1928        let options = TextLayoutOptions {
1929            overflow: TextOverflow::Clip,
1930            soft_wrap: true,
1931            max_lines: 2,
1932            min_lines: 1,
1933        };
1934
1935        let prepared = prepare_text_layout(
1936            &crate::text::AnnotatedString::from("A B C D E F"),
1937            &style,
1938            options,
1939            Some(24.0), // roughly 4 chars in monospaced fallback
1940        );
1941
1942        assert!(prepared.did_overflow);
1943        assert!(prepared.metrics.line_count <= 2);
1944    }
1945
1946    #[test]
1947    fn text_layout_options_end_ellipsis_applies() {
1948        let _app_context = crate::render_state::app_context_test_scope();
1949        let style = TextStyle {
1950            span_style: crate::text::SpanStyle {
1951                font_size: TextUnit::Sp(10.0),
1952                ..Default::default()
1953            },
1954            ..Default::default()
1955        };
1956        let options = TextLayoutOptions {
1957            overflow: TextOverflow::Ellipsis,
1958            soft_wrap: false,
1959            max_lines: 1,
1960            min_lines: 1,
1961        };
1962
1963        let prepared = prepare_text_layout(
1964            &crate::text::AnnotatedString::from("Long long line"),
1965            &style,
1966            options,
1967            Some(20.0),
1968        );
1969        assert!(prepared.did_overflow);
1970        assert!(prepared.text.text.contains(ELLIPSIS));
1971    }
1972
1973    #[test]
1974    fn text_layout_options_visible_keeps_full_text() {
1975        let _app_context = crate::render_state::app_context_test_scope();
1976        let style = TextStyle {
1977            span_style: crate::text::SpanStyle {
1978                font_size: TextUnit::Sp(10.0),
1979                ..Default::default()
1980            },
1981            ..Default::default()
1982        };
1983        let options = TextLayoutOptions {
1984            overflow: TextOverflow::Visible,
1985            soft_wrap: false,
1986            max_lines: 1,
1987            min_lines: 1,
1988        };
1989
1990        let input = "This should remain unchanged";
1991        let prepared = prepare_text_layout(
1992            &crate::text::AnnotatedString::from(input),
1993            &style,
1994            options,
1995            Some(10.0),
1996        );
1997        assert_eq!(prepared.text.text, input);
1998    }
1999
2000    #[test]
2001    fn text_layout_options_respects_min_lines() {
2002        let _app_context = crate::render_state::app_context_test_scope();
2003        let style = TextStyle {
2004            span_style: crate::text::SpanStyle {
2005                font_size: TextUnit::Sp(10.0),
2006                ..Default::default()
2007            },
2008            ..Default::default()
2009        };
2010        let options = TextLayoutOptions {
2011            overflow: TextOverflow::Clip,
2012            soft_wrap: true,
2013            max_lines: 4,
2014            min_lines: 3,
2015        };
2016
2017        let prepared = prepare_text_layout(
2018            &crate::text::AnnotatedString::from("short"),
2019            &style,
2020            options,
2021            Some(100.0),
2022        );
2023        assert_eq!(prepared.metrics.line_count, 3);
2024    }
2025
2026    #[test]
2027    fn text_layout_options_middle_ellipsis_for_single_line() {
2028        let _app_context = crate::render_state::app_context_test_scope();
2029        let style = TextStyle {
2030            span_style: crate::text::SpanStyle {
2031                font_size: TextUnit::Sp(10.0),
2032                ..Default::default()
2033            },
2034            ..Default::default()
2035        };
2036        let options = TextLayoutOptions {
2037            overflow: TextOverflow::MiddleEllipsis,
2038            soft_wrap: false,
2039            max_lines: 1,
2040            min_lines: 1,
2041        };
2042
2043        let prepared = prepare_text_layout(
2044            &crate::text::AnnotatedString::from("abcdefghijk"),
2045            &style,
2046            options,
2047            Some(24.0),
2048        );
2049        assert!(prepared.text.text.contains(ELLIPSIS));
2050        assert!(prepared.did_overflow);
2051    }
2052
2053    #[test]
2054    fn text_layout_options_scale_down_fits_without_rewriting_text() {
2055        let _app_context = crate::render_state::app_context_test_scope();
2056        let style = TextStyle {
2057            span_style: crate::text::SpanStyle {
2058                font_size: TextUnit::Sp(20.0),
2059                ..Default::default()
2060            },
2061            ..Default::default()
2062        };
2063        let options = TextLayoutOptions {
2064            overflow: TextOverflow::ScaleDown {
2065                min_font_size_sp: 10.0,
2066            },
2067            soft_wrap: false,
2068            max_lines: 1,
2069            min_lines: 1,
2070        };
2071
2072        let prepared = prepare_text_layout(
2073            &crate::text::AnnotatedString::from("ABCDE"),
2074            &style,
2075            options,
2076            Some(36.0),
2077        );
2078
2079        assert_eq!(prepared.text.text, "ABCDE");
2080        assert!(prepared.metrics.width <= 36.0 + WRAP_EPSILON);
2081        assert!(!prepared.did_overflow);
2082        let visual_font_size = prepared.visual_style.resolve_font_size(14.0);
2083        assert!(visual_font_size < 20.0);
2084        assert!(visual_font_size >= 10.0);
2085    }
2086
2087    #[test]
2088    fn text_layout_options_scale_down_scales_root_shadow() {
2089        let _app_context = crate::render_state::app_context_test_scope();
2090        let style = TextStyle {
2091            span_style: crate::text::SpanStyle {
2092                font_size: TextUnit::Sp(20.0),
2093                shadow: Some(crate::text::Shadow {
2094                    color: crate::modifier::Color(0.0, 0.0, 0.0, 1.0),
2095                    offset: crate::modifier::Point::new(8.0, 4.0),
2096                    blur_radius: 6.0,
2097                }),
2098                ..Default::default()
2099            },
2100            ..Default::default()
2101        };
2102        let options = TextLayoutOptions {
2103            overflow: TextOverflow::ScaleDown {
2104                min_font_size_sp: 10.0,
2105            },
2106            soft_wrap: false,
2107            max_lines: 1,
2108            min_lines: 1,
2109        };
2110
2111        let prepared = prepare_text_layout(
2112            &crate::text::AnnotatedString::from("ABCDE"),
2113            &style,
2114            options,
2115            Some(36.0),
2116        );
2117
2118        let font_scale = prepared.visual_style.resolve_font_size(14.0) / 20.0;
2119        let shadow = prepared
2120            .visual_style
2121            .span_style
2122            .shadow
2123            .expect("scaled style should retain shadow");
2124        assert_f32_close(shadow.offset.x, 8.0 * font_scale);
2125        assert_f32_close(shadow.offset.y, 4.0 * font_scale);
2126        assert_f32_close(shadow.blur_radius, 6.0 * font_scale);
2127    }
2128
2129    #[test]
2130    fn text_layout_options_scale_down_stops_at_minimum_and_clips() {
2131        let _app_context = crate::render_state::app_context_test_scope();
2132        let style = TextStyle {
2133            span_style: crate::text::SpanStyle {
2134                font_size: TextUnit::Sp(20.0),
2135                ..Default::default()
2136            },
2137            ..Default::default()
2138        };
2139        let options = TextLayoutOptions {
2140            overflow: TextOverflow::ScaleDown {
2141                min_font_size_sp: 10.0,
2142            },
2143            soft_wrap: false,
2144            max_lines: 1,
2145            min_lines: 1,
2146        };
2147
2148        let prepared = prepare_text_layout(
2149            &crate::text::AnnotatedString::from("ABCDEFGHIJ"),
2150            &style,
2151            options,
2152            Some(12.0),
2153        );
2154
2155        assert_eq!(prepared.text.text, "ABCDEFGHIJ");
2156        assert!(prepared.did_overflow);
2157        assert_eq!(prepared.metrics.width, 12.0);
2158        assert_eq!(prepared.visual_style.resolve_font_size(14.0), 10.0);
2159    }
2160
2161    #[test]
2162    fn scale_annotated_font_sizes_borrows_when_spans_need_no_scaling() {
2163        let _app_context = crate::render_state::app_context_test_scope();
2164        let plain = crate::text::AnnotatedString::from("plain");
2165        assert!(matches!(
2166            scale_annotated_font_sizes(&plain, 0.5),
2167            std::borrow::Cow::Borrowed(_)
2168        ));
2169
2170        let colored = crate::text::annotated_string::Builder::new()
2171            .push_style(crate::text::SpanStyle {
2172                color: Some(crate::modifier::Color(1.0, 0.0, 0.0, 1.0)),
2173                ..Default::default()
2174            })
2175            .append("colored")
2176            .pop()
2177            .to_annotated_string();
2178        assert!(matches!(
2179            scale_annotated_font_sizes(&colored, 0.5),
2180            std::borrow::Cow::Borrowed(_)
2181        ));
2182    }
2183
2184    #[test]
2185    fn scale_annotated_font_sizes_scales_span_shadow_geometry() {
2186        let _app_context = crate::render_state::app_context_test_scope();
2187        let text = crate::text::annotated_string::Builder::new()
2188            .push_style(crate::text::SpanStyle {
2189                shadow: Some(crate::text::Shadow {
2190                    color: crate::modifier::Color(0.0, 0.0, 0.0, 1.0),
2191                    offset: crate::modifier::Point::new(6.0, 2.0),
2192                    blur_radius: 4.0,
2193                }),
2194                ..Default::default()
2195            })
2196            .append("shadow")
2197            .pop()
2198            .to_annotated_string();
2199
2200        let scaled = scale_annotated_font_sizes(&text, 0.5);
2201        let std::borrow::Cow::Owned(scaled) = scaled else {
2202            panic!("shadowed span should be scaled into owned text");
2203        };
2204        let shadow = scaled.span_styles[0]
2205            .item
2206            .shadow
2207            .expect("scaled span should retain shadow");
2208        assert_f32_close(shadow.offset.x, 3.0);
2209        assert_f32_close(shadow.offset.y, 1.0);
2210        assert_f32_close(shadow.blur_radius, 2.0);
2211    }
2212
2213    #[test]
2214    fn text_layout_options_does_not_wrap_on_tiny_width_delta() {
2215        let _app_context = crate::render_state::app_context_test_scope();
2216        let style = TextStyle {
2217            span_style: crate::text::SpanStyle {
2218                font_size: TextUnit::Sp(10.0),
2219                ..Default::default()
2220            },
2221            ..Default::default()
2222        };
2223        let options = TextLayoutOptions {
2224            overflow: TextOverflow::Clip,
2225            soft_wrap: true,
2226            max_lines: usize::MAX,
2227            min_lines: 1,
2228        };
2229
2230        let text = "if counter % 2 == 0";
2231        let exact_width = measure_text(&crate::text::AnnotatedString::from(text), &style).width;
2232        let prepared = prepare_text_layout(
2233            &crate::text::AnnotatedString::from(text),
2234            &style,
2235            options,
2236            Some(exact_width - 0.1),
2237        );
2238
2239        assert!(
2240            !prepared.text.text.contains('\n'),
2241            "unexpected line split: {:?}",
2242            prepared.text
2243        );
2244    }
2245
2246    #[test]
2247    fn line_break_mode_changes_wrap_strategy_contract() {
2248        let _app_context = crate::render_state::app_context_test_scope();
2249        let text = "This is an example text";
2250        let options = TextLayoutOptions {
2251            overflow: TextOverflow::Clip,
2252            soft_wrap: true,
2253            max_lines: usize::MAX,
2254            min_lines: 1,
2255        };
2256
2257        let simple = prepare_text_layout(
2258            &crate::text::AnnotatedString::from(text),
2259            &style_with_line_break(LineBreak::Simple),
2260            options,
2261            Some(120.0),
2262        );
2263        let heading = prepare_text_layout(
2264            &crate::text::AnnotatedString::from(text),
2265            &style_with_line_break(LineBreak::Heading),
2266            options,
2267            Some(120.0),
2268        );
2269        let paragraph = prepare_text_layout(
2270            &crate::text::AnnotatedString::from(text),
2271            &style_with_line_break(LineBreak::Paragraph),
2272            options,
2273            Some(50.0),
2274        );
2275
2276        assert_eq!(
2277            simple.text.text.lines().collect::<Vec<_>>(),
2278            vec!["This is an example", "text"]
2279        );
2280        assert_eq!(
2281            heading.text.text.lines().collect::<Vec<_>>(),
2282            vec!["This is an", "example text"]
2283        );
2284        assert_eq!(
2285            paragraph.text.text.lines().collect::<Vec<_>>(),
2286            vec!["This", "is an", "example", "text"]
2287        );
2288    }
2289
2290    #[test]
2291    fn hyphens_mode_changes_wrap_strategy_contract() {
2292        let _app_context = crate::render_state::app_context_test_scope();
2293        let text = "Transformation";
2294        let options = TextLayoutOptions {
2295            overflow: TextOverflow::Clip,
2296            soft_wrap: true,
2297            max_lines: usize::MAX,
2298            min_lines: 1,
2299        };
2300
2301        let auto = prepare_text_layout(
2302            &crate::text::AnnotatedString::from(text),
2303            &style_with_hyphens(Hyphens::Auto),
2304            options,
2305            Some(24.0),
2306        );
2307        let none = prepare_text_layout(
2308            &crate::text::AnnotatedString::from(text),
2309            &style_with_hyphens(Hyphens::None),
2310            options,
2311            Some(24.0),
2312        );
2313
2314        assert_eq!(
2315            auto.text.text.lines().collect::<Vec<_>>(),
2316            vec!["Tran", "sfor", "ma", "tion"]
2317        );
2318        assert_eq!(
2319            none.text.text.lines().collect::<Vec<_>>(),
2320            vec!["Tran", "sfor", "mati", "on"]
2321        );
2322        assert!(
2323            !auto.text.text.contains('-'),
2324            "automatic hyphenation should influence breaks without mutating source text content"
2325        );
2326    }
2327
2328    #[test]
2329    fn hyphens_auto_uses_measurer_hyphen_contract_when_valid() {
2330        let _app_context = crate::render_state::app_context_test_scope();
2331        let text = "Transformation";
2332        let style = style_with_hyphens(Hyphens::Auto);
2333        let options = TextLayoutOptions {
2334            overflow: TextOverflow::Clip,
2335            soft_wrap: true,
2336            max_lines: usize::MAX,
2337            min_lines: 1,
2338        };
2339
2340        let prepared = prepare_text_layout_fallback(
2341            &ContractBreakMeasurer { retreat: 1 },
2342            &crate::text::AnnotatedString::from(text),
2343            &style,
2344            options,
2345            Some(24.0),
2346        );
2347
2348        assert_eq!(
2349            prepared.text.text.lines().collect::<Vec<_>>(),
2350            vec!["Tra", "nsf", "orm", "ati", "on"]
2351        );
2352    }
2353
2354    #[test]
2355    fn hyphens_auto_falls_back_when_measurer_hyphen_contract_is_invalid() {
2356        let _app_context = crate::render_state::app_context_test_scope();
2357        let text = "Transformation";
2358        let style = style_with_hyphens(Hyphens::Auto);
2359        let options = TextLayoutOptions {
2360            overflow: TextOverflow::Clip,
2361            soft_wrap: true,
2362            max_lines: usize::MAX,
2363            min_lines: 1,
2364        };
2365
2366        let prepared = prepare_text_layout_fallback(
2367            &ContractBreakMeasurer { retreat: 10 },
2368            &crate::text::AnnotatedString::from(text),
2369            &style,
2370            options,
2371            Some(24.0),
2372        );
2373
2374        assert_eq!(
2375            prepared.text.text.lines().collect::<Vec<_>>(),
2376            vec!["Tran", "sfor", "ma", "tion"]
2377        );
2378    }
2379
2380    #[test]
2381    fn transformed_text_keeps_span_ranges_within_display_bounds() {
2382        let _app_context = crate::render_state::app_context_test_scope();
2383        let style = TextStyle {
2384            span_style: crate::text::SpanStyle {
2385                font_size: TextUnit::Sp(10.0),
2386                ..Default::default()
2387            },
2388            ..Default::default()
2389        };
2390        let options = TextLayoutOptions {
2391            overflow: TextOverflow::Ellipsis,
2392            soft_wrap: false,
2393            max_lines: 1,
2394            min_lines: 1,
2395        };
2396        let annotated = crate::text::AnnotatedString::builder()
2397            .push_style(crate::text::SpanStyle {
2398                font_weight: Some(crate::text::FontWeight::BOLD),
2399                ..Default::default()
2400            })
2401            .append("Styled overflow text sample")
2402            .pop()
2403            .to_annotated_string();
2404
2405        let prepared = prepare_text_layout(&annotated, &style, options, Some(40.0));
2406        assert!(prepared.did_overflow);
2407        for span in &prepared.text.span_styles {
2408            assert!(span.range.start < span.range.end);
2409            assert!(span.range.end <= prepared.text.text.len());
2410            assert!(prepared.text.text.is_char_boundary(span.range.start));
2411            assert!(prepared.text.text.is_char_boundary(span.range.end));
2412        }
2413    }
2414
2415    #[test]
2416    fn wrapped_text_splits_styles_around_inserted_newlines() {
2417        let _app_context = crate::render_state::app_context_test_scope();
2418        let style = TextStyle {
2419            span_style: crate::text::SpanStyle {
2420                font_size: TextUnit::Sp(10.0),
2421                ..Default::default()
2422            },
2423            ..Default::default()
2424        };
2425        let options = TextLayoutOptions {
2426            overflow: TextOverflow::Clip,
2427            soft_wrap: true,
2428            max_lines: usize::MAX,
2429            min_lines: 1,
2430        };
2431        let annotated = crate::text::AnnotatedString::builder()
2432            .push_style(crate::text::SpanStyle {
2433                text_decoration: Some(crate::text::TextDecoration::UNDERLINE),
2434                ..Default::default()
2435            })
2436            .append("Wrapped style text example")
2437            .pop()
2438            .to_annotated_string();
2439
2440        let prepared = prepare_text_layout(&annotated, &style, options, Some(32.0));
2441        assert!(prepared.text.text.contains('\n'));
2442        assert!(!prepared.text.span_styles.is_empty());
2443        for span in &prepared.text.span_styles {
2444            assert!(span.range.end <= prepared.text.text.len());
2445        }
2446    }
2447
2448    #[test]
2449    fn mixed_font_size_segments_wrap_without_truncation() {
2450        let _app_context = crate::render_state::app_context_test_scope();
2451        let style = TextStyle {
2452            span_style: crate::text::SpanStyle {
2453                font_size: TextUnit::Sp(14.0),
2454                ..Default::default()
2455            },
2456            ..Default::default()
2457        };
2458        let options = TextLayoutOptions {
2459            overflow: TextOverflow::Clip,
2460            soft_wrap: true,
2461            max_lines: usize::MAX,
2462            min_lines: 1,
2463        };
2464        let annotated = crate::text::AnnotatedString::builder()
2465            .append("You can also ")
2466            .push_style(crate::text::SpanStyle {
2467                font_size: TextUnit::Sp(22.0),
2468                ..Default::default()
2469            })
2470            .append("change font size")
2471            .pop()
2472            .append(" dynamically mid-sentence!")
2473            .to_annotated_string();
2474
2475        let prepared = prepare_text_layout(&annotated, &style, options, Some(260.0));
2476        assert!(prepared.text.text.contains('\n'));
2477        assert!(prepared.text.text.contains("mid-sentence!"));
2478        assert!(!prepared.did_overflow);
2479    }
2480}