Skip to main content

cranpose_ui/text/
measure.rs

1use crate::text_layout_result::TextLayoutResult;
2use cranpose_core::NodeId;
3use std::cell::RefCell;
4use std::ops::Range;
5
6use super::layout_options::{TextLayoutOptions, TextOverflow};
7use super::paragraph::{Hyphens, LineBreak};
8use super::style::TextStyle;
9
10const ELLIPSIS: &str = "\u{2026}";
11const WRAP_EPSILON: f32 = 0.5;
12const AUTO_HYPHEN_MIN_SEGMENT_CHARS: usize = 2;
13const AUTO_HYPHEN_MIN_TRAILING_CHARS: usize = 3;
14const AUTO_HYPHEN_PREFERRED_TRAILING_CHARS: usize = 4;
15
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct TextMetrics {
18    pub width: f32,
19    pub height: f32,
20    /// Height of a single line of text
21    pub line_height: f32,
22    /// Number of lines in the text
23    pub line_count: usize,
24}
25
26#[derive(Clone, Debug, PartialEq)]
27pub struct PreparedTextLayout {
28    pub text: crate::text::AnnotatedString,
29    pub metrics: TextMetrics,
30    pub did_overflow: bool,
31}
32
33pub trait TextMeasurer: 'static {
34    fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics;
35
36    fn measure_for_node(
37        &self,
38        node_id: Option<NodeId>,
39        text: &crate::text::AnnotatedString,
40        style: &TextStyle,
41    ) -> TextMetrics {
42        let _ = node_id;
43        self.measure(text, style)
44    }
45
46    fn measure_subsequence(
47        &self,
48        text: &crate::text::AnnotatedString,
49        range: Range<usize>,
50        style: &TextStyle,
51    ) -> TextMetrics {
52        self.measure(&text.subsequence(range), style)
53    }
54
55    fn measure_subsequence_for_node(
56        &self,
57        node_id: Option<NodeId>,
58        text: &crate::text::AnnotatedString,
59        range: Range<usize>,
60        style: &TextStyle,
61    ) -> TextMetrics {
62        let _ = node_id;
63        self.measure_subsequence(text, range, style)
64    }
65
66    fn get_offset_for_position(
67        &self,
68        text: &crate::text::AnnotatedString,
69        style: &TextStyle,
70        x: f32,
71        y: f32,
72    ) -> usize;
73
74    fn get_cursor_x_for_offset(
75        &self,
76        text: &crate::text::AnnotatedString,
77        style: &TextStyle,
78        offset: usize,
79    ) -> f32;
80
81    fn layout(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult;
82
83    /// Returns an alternate break boundary for `Hyphens::Auto` when a greedy break
84    /// split lands in the middle of a word.
85    ///
86    /// `segment_start_char` and `measured_break_char` are character-boundary indices
87    /// in `line` (not byte offsets). Return `None` to delegate to fallback behavior.
88    fn choose_auto_hyphen_break(
89        &self,
90        _line: &str,
91        _style: &TextStyle,
92        _segment_start_char: usize,
93        _measured_break_char: usize,
94    ) -> Option<usize> {
95        None
96    }
97
98    fn measure_with_options(
99        &self,
100        text: &crate::text::AnnotatedString,
101        style: &TextStyle,
102        options: TextLayoutOptions,
103        max_width: Option<f32>,
104    ) -> TextMetrics {
105        self.prepare_with_options(text, style, options, max_width)
106            .metrics
107    }
108
109    fn measure_with_options_for_node(
110        &self,
111        node_id: Option<NodeId>,
112        text: &crate::text::AnnotatedString,
113        style: &TextStyle,
114        options: TextLayoutOptions,
115        max_width: Option<f32>,
116    ) -> TextMetrics {
117        self.prepare_with_options_for_node(node_id, text, style, options, max_width)
118            .metrics
119    }
120
121    fn prepare_with_options(
122        &self,
123        text: &crate::text::AnnotatedString,
124        style: &TextStyle,
125        options: TextLayoutOptions,
126        max_width: Option<f32>,
127    ) -> PreparedTextLayout {
128        self.prepare_with_options_fallback(text, style, options, max_width)
129    }
130
131    fn prepare_with_options_for_node(
132        &self,
133        node_id: Option<NodeId>,
134        text: &crate::text::AnnotatedString,
135        style: &TextStyle,
136        options: TextLayoutOptions,
137        max_width: Option<f32>,
138    ) -> PreparedTextLayout {
139        prepare_text_layout_with_measurer_for_node(self, node_id, text, style, options, max_width)
140    }
141
142    fn prepare_with_options_fallback(
143        &self,
144        text: &crate::text::AnnotatedString,
145        style: &TextStyle,
146        options: TextLayoutOptions,
147        max_width: Option<f32>,
148    ) -> PreparedTextLayout {
149        prepare_text_layout_fallback(self, text, style, options, max_width)
150    }
151}
152
153#[derive(Default)]
154struct MonospacedTextMeasurer;
155
156impl MonospacedTextMeasurer {
157    const DEFAULT_SIZE: f32 = 14.0;
158    const CHAR_WIDTH_RATIO: f32 = 0.6; // Width is 0.6 of Height
159
160    fn get_metrics(style: &TextStyle) -> (f32, f32) {
161        let font_size = style.resolve_font_size(Self::DEFAULT_SIZE);
162        let line_height = style.resolve_line_height(Self::DEFAULT_SIZE, font_size);
163        let letter_spacing = style.resolve_letter_spacing(Self::DEFAULT_SIZE).max(0.0);
164        (
165            (font_size * Self::CHAR_WIDTH_RATIO) + letter_spacing,
166            line_height,
167        )
168    }
169}
170
171impl TextMeasurer for MonospacedTextMeasurer {
172    fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
173        let (char_width, line_height) = Self::get_metrics(style);
174
175        let lines: Vec<&str> = text.text.split('\n').collect();
176        let line_count = lines.len().max(1);
177
178        let width = lines
179            .iter()
180            .map(|line| line.chars().count() as f32 * char_width)
181            .fold(0.0_f32, f32::max);
182
183        TextMetrics {
184            width,
185            height: line_count as f32 * line_height,
186            line_height,
187            line_count,
188        }
189    }
190
191    fn measure_subsequence(
192        &self,
193        text: &crate::text::AnnotatedString,
194        range: Range<usize>,
195        style: &TextStyle,
196    ) -> TextMetrics {
197        let (char_width, line_height) = Self::get_metrics(style);
198        let slice = &text.text[range];
199        let line_count = slice.split('\n').count().max(1);
200        let width = slice
201            .split('\n')
202            .map(|line| line.chars().count() as f32 * char_width)
203            .fold(0.0_f32, f32::max);
204
205        TextMetrics {
206            width,
207            height: line_count as f32 * line_height,
208            line_height,
209            line_count,
210        }
211    }
212
213    fn get_offset_for_position(
214        &self,
215        text: &crate::text::AnnotatedString,
216        style: &TextStyle,
217        x: f32,
218        y: f32,
219    ) -> usize {
220        let (char_width, line_height) = Self::get_metrics(style);
221
222        if text.text.is_empty() {
223            return 0;
224        }
225
226        let line_index = (y / line_height).floor().max(0.0) as usize;
227        let lines: Vec<&str> = text.text.split('\n').collect();
228        let target_line = line_index.min(lines.len().saturating_sub(1));
229
230        let mut line_start_byte = 0;
231        for line in lines.iter().take(target_line) {
232            line_start_byte += line.len() + 1;
233        }
234
235        let line_text = lines.get(target_line).unwrap_or(&"");
236        let char_index = (x / char_width).round() as usize;
237        let line_char_count = line_text.chars().count();
238        let clamped_index = char_index.min(line_char_count);
239
240        let offset_in_line = line_text
241            .char_indices()
242            .nth(clamped_index)
243            .map(|(i, _)| i)
244            .unwrap_or(line_text.len());
245
246        line_start_byte + offset_in_line
247    }
248
249    fn get_cursor_x_for_offset(
250        &self,
251        text: &crate::text::AnnotatedString,
252        style: &TextStyle,
253        offset: usize,
254    ) -> f32 {
255        let (char_width, _) = Self::get_metrics(style);
256
257        let clamped_offset = offset.min(text.text.len());
258        let char_count = text.text[..clamped_offset].chars().count();
259        char_count as f32 * char_width
260    }
261
262    fn layout(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult {
263        let (char_width, line_height) = Self::get_metrics(style);
264        TextLayoutResult::monospaced(&text.text, char_width, line_height)
265    }
266}
267
268thread_local! {
269    static TEXT_MEASURER: RefCell<Box<dyn TextMeasurer>> = RefCell::new(Box::new(MonospacedTextMeasurer));
270}
271
272pub fn set_text_measurer<M: TextMeasurer>(measurer: M) {
273    TEXT_MEASURER.with(|m| {
274        *m.borrow_mut() = Box::new(measurer);
275    });
276}
277
278pub fn measure_text(text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
279    TEXT_MEASURER.with(|m| m.borrow().measure(text, style))
280}
281
282pub fn measure_text_for_node(
283    node_id: Option<NodeId>,
284    text: &crate::text::AnnotatedString,
285    style: &TextStyle,
286) -> TextMetrics {
287    TEXT_MEASURER.with(|m| m.borrow().measure_for_node(node_id, text, style))
288}
289
290pub fn measure_text_with_options(
291    text: &crate::text::AnnotatedString,
292    style: &TextStyle,
293    options: TextLayoutOptions,
294    max_width: Option<f32>,
295) -> TextMetrics {
296    TEXT_MEASURER.with(|m| {
297        m.borrow()
298            .measure_with_options(text, style, options.normalized(), max_width)
299    })
300}
301
302pub fn measure_text_with_options_for_node(
303    node_id: Option<NodeId>,
304    text: &crate::text::AnnotatedString,
305    style: &TextStyle,
306    options: TextLayoutOptions,
307    max_width: Option<f32>,
308) -> TextMetrics {
309    TEXT_MEASURER.with(|m| {
310        m.borrow().measure_with_options_for_node(
311            node_id,
312            text,
313            style,
314            options.normalized(),
315            max_width,
316        )
317    })
318}
319
320pub fn prepare_text_layout(
321    text: &crate::text::AnnotatedString,
322    style: &TextStyle,
323    options: TextLayoutOptions,
324    max_width: Option<f32>,
325) -> PreparedTextLayout {
326    TEXT_MEASURER.with(|m| {
327        m.borrow()
328            .prepare_with_options(text, style, options.normalized(), max_width)
329    })
330}
331
332pub fn prepare_text_layout_for_node(
333    node_id: Option<NodeId>,
334    text: &crate::text::AnnotatedString,
335    style: &TextStyle,
336    options: TextLayoutOptions,
337    max_width: Option<f32>,
338) -> PreparedTextLayout {
339    TEXT_MEASURER.with(|m| {
340        m.borrow().prepare_with_options_for_node(
341            node_id,
342            text,
343            style,
344            options.normalized(),
345            max_width,
346        )
347    })
348}
349
350pub fn get_offset_for_position(
351    text: &crate::text::AnnotatedString,
352    style: &TextStyle,
353    x: f32,
354    y: f32,
355) -> usize {
356    TEXT_MEASURER.with(|m| m.borrow().get_offset_for_position(text, style, x, y))
357}
358
359pub fn get_cursor_x_for_offset(
360    text: &crate::text::AnnotatedString,
361    style: &TextStyle,
362    offset: usize,
363) -> f32 {
364    TEXT_MEASURER.with(|m| m.borrow().get_cursor_x_for_offset(text, style, offset))
365}
366
367pub fn layout_text(text: &crate::text::AnnotatedString, style: &TextStyle) -> TextLayoutResult {
368    TEXT_MEASURER.with(|m| m.borrow().layout(text, style))
369}
370
371fn prepare_text_layout_fallback<M: TextMeasurer + ?Sized>(
372    measurer: &M,
373    text: &crate::text::AnnotatedString,
374    style: &TextStyle,
375    options: TextLayoutOptions,
376    max_width: Option<f32>,
377) -> PreparedTextLayout {
378    prepare_text_layout_with_measurer_for_node(measurer, None, text, style, options, max_width)
379}
380
381pub fn prepare_text_layout_with_measurer_for_node<M: TextMeasurer + ?Sized>(
382    measurer: &M,
383    node_id: Option<NodeId>,
384    text: &crate::text::AnnotatedString,
385    style: &TextStyle,
386    options: TextLayoutOptions,
387    max_width: Option<f32>,
388) -> PreparedTextLayout {
389    let opts = options.normalized();
390    let max_width = normalize_max_width(max_width);
391    let wrap_width = (opts.soft_wrap && opts.overflow != TextOverflow::Visible)
392        .then_some(max_width)
393        .flatten();
394    let line_break_mode = style
395        .paragraph_style
396        .line_break
397        .take_or_else(|| LineBreak::Simple);
398    let hyphens_mode = style.paragraph_style.hyphens.take_or_else(|| Hyphens::None);
399
400    let mut line_ranges = split_line_ranges(text.text.as_str());
401    if let Some(width_limit) = wrap_width {
402        let mut wrapped_ranges = Vec::with_capacity(line_ranges.len());
403        for line_range in line_ranges.drain(..) {
404            let wrapped_line_ranges = wrap_line_to_width(
405                measurer,
406                text,
407                line_range,
408                style,
409                width_limit,
410                line_break_mode,
411                hyphens_mode,
412            );
413            wrapped_ranges.extend(wrapped_line_ranges);
414        }
415        line_ranges = wrapped_ranges;
416    }
417
418    let mut did_overflow = false;
419    let mut visible_lines: Vec<DisplayLine> = line_ranges
420        .into_iter()
421        .map(DisplayLine::from_source_range)
422        .collect();
423
424    if opts.overflow != TextOverflow::Visible && visible_lines.len() > opts.max_lines {
425        did_overflow = true;
426        visible_lines.truncate(opts.max_lines);
427        if let Some(last_line) = visible_lines.last_mut() {
428            let overflowed = apply_line_overflow(
429                measurer,
430                last_line.display_text(text),
431                style,
432                max_width,
433                opts,
434                true,
435                true,
436            );
437            last_line.apply_display_text(text, overflowed);
438        }
439    }
440
441    if let Some(width_limit) = max_width {
442        let single_line_ellipsis = opts.max_lines == 1 || !opts.soft_wrap;
443        let visible_len = visible_lines.len();
444        for (line_index, line) in visible_lines.iter_mut().enumerate() {
445            let width = line.measure_width(measurer, node_id, text, style);
446            if width > width_limit + WRAP_EPSILON {
447                if opts.overflow == TextOverflow::Visible {
448                    continue;
449                }
450                did_overflow = true;
451                let overflowed = apply_line_overflow(
452                    measurer,
453                    line.display_text(text),
454                    style,
455                    Some(width_limit),
456                    opts,
457                    line_index + 1 == visible_len,
458                    single_line_ellipsis,
459                );
460                line.apply_display_text(text, overflowed);
461            }
462        }
463    }
464
465    let display_annotated = build_display_annotated(text, &visible_lines);
466    debug_assert_eq!(
467        display_annotated.text,
468        join_display_line_text(text, &visible_lines)
469    );
470    let line_height = measurer
471        .measure_for_node(node_id, text, style)
472        .line_height
473        .max(0.0);
474    let display_line_count = visible_lines.len().max(1);
475    let layout_line_count = display_line_count.max(opts.min_lines);
476
477    let measured_width = if visible_lines.is_empty() {
478        0.0
479    } else {
480        visible_lines
481            .iter()
482            .map(|line| line.measure_width(measurer, node_id, text, style))
483            .fold(0.0_f32, f32::max)
484    };
485    let width = if opts.overflow == TextOverflow::Visible {
486        measured_width
487    } else if let Some(width_limit) = max_width {
488        measured_width.min(width_limit)
489    } else {
490        measured_width
491    };
492
493    PreparedTextLayout {
494        text: display_annotated,
495        metrics: TextMetrics {
496            width,
497            height: layout_line_count as f32 * line_height,
498            line_height,
499            line_count: layout_line_count,
500        },
501        did_overflow,
502    }
503}
504
505#[derive(Clone, Debug)]
506enum DisplayLineText {
507    Source,
508    Remapped(crate::text::AnnotatedString),
509}
510
511#[derive(Clone, Debug)]
512struct DisplayLine {
513    source_range: Range<usize>,
514    text: DisplayLineText,
515}
516
517impl DisplayLine {
518    fn from_source_range(source_range: Range<usize>) -> Self {
519        Self {
520            source_range,
521            text: DisplayLineText::Source,
522        }
523    }
524
525    fn display_text<'a>(&'a self, source: &'a crate::text::AnnotatedString) -> &'a str {
526        match &self.text {
527            DisplayLineText::Source => &source.text[self.source_range.clone()],
528            DisplayLineText::Remapped(annotated) => annotated.text.as_str(),
529        }
530    }
531
532    fn measure_width<M: TextMeasurer + ?Sized>(
533        &self,
534        measurer: &M,
535        node_id: Option<NodeId>,
536        source: &crate::text::AnnotatedString,
537        style: &TextStyle,
538    ) -> f32 {
539        match &self.text {
540            DisplayLineText::Source => {
541                measurer
542                    .measure_subsequence_for_node(node_id, source, self.source_range.clone(), style)
543                    .width
544            }
545            DisplayLineText::Remapped(annotated) => {
546                measurer.measure_for_node(node_id, annotated, style).width
547            }
548        }
549    }
550
551    fn apply_display_text(&mut self, source: &crate::text::AnnotatedString, display_text: String) {
552        let source_text = &source.text[self.source_range.clone()];
553        self.text = if source_text == display_text {
554            DisplayLineText::Source
555        } else {
556            DisplayLineText::Remapped(remap_annotated_subsequence_for_display(
557                source,
558                self.source_range.clone(),
559                display_text.as_str(),
560            ))
561        };
562    }
563}
564
565fn split_line_ranges(text: &str) -> Vec<Range<usize>> {
566    if text.is_empty() {
567        return single_line_range(0..0);
568    }
569
570    let mut ranges = Vec::new();
571    let mut start = 0usize;
572    for (idx, ch) in text.char_indices() {
573        if ch == '\n' {
574            ranges.push(start..idx);
575            start = idx + ch.len_utf8();
576        }
577    }
578    ranges.push(start..text.len());
579    ranges
580}
581
582fn build_display_annotated(
583    source: &crate::text::AnnotatedString,
584    lines: &[DisplayLine],
585) -> crate::text::AnnotatedString {
586    if lines.is_empty() {
587        return crate::text::AnnotatedString::from("");
588    }
589
590    let mut builder = crate::text::AnnotatedString::builder();
591    for (idx, line) in lines.iter().enumerate() {
592        builder = match &line.text {
593            DisplayLineText::Source => {
594                builder.append_annotated_subsequence(source, line.source_range.clone())
595            }
596            DisplayLineText::Remapped(annotated) => builder.append_annotated(annotated),
597        };
598        if idx + 1 < lines.len() {
599            builder = builder.append("\n");
600        }
601    }
602    builder.to_annotated_string()
603}
604
605fn join_display_line_text(source: &crate::text::AnnotatedString, lines: &[DisplayLine]) -> String {
606    let mut text = String::new();
607    for (idx, line) in lines.iter().enumerate() {
608        text.push_str(line.display_text(source));
609        if idx + 1 < lines.len() {
610            text.push('\n');
611        }
612    }
613    text
614}
615
616fn trim_segment_end_whitespace(line: &str, start: usize, mut end: usize) -> usize {
617    while end > start {
618        let Some((idx, ch)) = line[start..end].char_indices().next_back() else {
619            break;
620        };
621        if ch.is_whitespace() {
622            end = start + idx;
623        } else {
624            break;
625        }
626    }
627    end
628}
629
630fn remap_annotated_subsequence_for_display(
631    source: &crate::text::AnnotatedString,
632    source_range: Range<usize>,
633    display_text: &str,
634) -> crate::text::AnnotatedString {
635    let source_text = &source.text[source_range.clone()];
636    if source_text == display_text {
637        return source.subsequence(source_range);
638    }
639
640    let display_chars = map_display_chars_to_source(source_text, display_text);
641    crate::text::AnnotatedString {
642        text: display_text.to_string(),
643        span_styles: remap_subsequence_range_styles(
644            &source.span_styles,
645            source_range.clone(),
646            &display_chars,
647        ),
648        paragraph_styles: remap_subsequence_range_styles(
649            &source.paragraph_styles,
650            source_range.clone(),
651            &display_chars,
652        ),
653        string_annotations: remap_subsequence_range_styles(
654            &source.string_annotations,
655            source_range.clone(),
656            &display_chars,
657        ),
658        link_annotations: remap_subsequence_range_styles(
659            &source.link_annotations,
660            source_range,
661            &display_chars,
662        ),
663    }
664}
665
666#[derive(Clone, Copy)]
667struct DisplayCharMap {
668    display_start: usize,
669    display_end: usize,
670    source_start: Option<usize>,
671}
672
673fn map_display_chars_to_source(source: &str, display: &str) -> Vec<DisplayCharMap> {
674    let source_chars: Vec<(usize, char)> = source.char_indices().collect();
675    let mut source_index = 0usize;
676    let mut maps = Vec::with_capacity(display.chars().count());
677
678    for (display_start, display_char) in display.char_indices() {
679        let display_end = display_start + display_char.len_utf8();
680        let mut source_start = None;
681        while source_index < source_chars.len() {
682            let (candidate_start, candidate_char) = source_chars[source_index];
683            source_index += 1;
684            if candidate_char == display_char {
685                source_start = Some(candidate_start);
686                break;
687            }
688        }
689        maps.push(DisplayCharMap {
690            display_start,
691            display_end,
692            source_start,
693        });
694    }
695
696    maps
697}
698
699fn remap_subsequence_range_styles<T: Clone>(
700    styles: &[crate::text::RangeStyle<T>],
701    source_range: Range<usize>,
702    display_chars: &[DisplayCharMap],
703) -> Vec<crate::text::RangeStyle<T>> {
704    let mut remapped = Vec::new();
705
706    for style in styles {
707        let overlap_start = style.range.start.max(source_range.start);
708        let overlap_end = style.range.end.min(source_range.end);
709        if overlap_start >= overlap_end {
710            continue;
711        }
712        let local_source_range =
713            (overlap_start - source_range.start)..(overlap_end - source_range.start);
714        let mut range_start = None;
715        let mut range_end = 0usize;
716
717        for map in display_chars {
718            let in_range = map.source_start.is_some_and(|source_start| {
719                source_start >= local_source_range.start && source_start < local_source_range.end
720            });
721
722            if in_range {
723                if range_start.is_none() {
724                    range_start = Some(map.display_start);
725                }
726                range_end = map.display_end;
727                continue;
728            }
729
730            if let Some(start) = range_start.take() {
731                if start < range_end {
732                    remapped.push(crate::text::RangeStyle {
733                        item: style.item.clone(),
734                        range: start..range_end,
735                    });
736                }
737            }
738        }
739
740        if let Some(start) = range_start.take() {
741            if start < range_end {
742                remapped.push(crate::text::RangeStyle {
743                    item: style.item.clone(),
744                    range: start..range_end,
745                });
746            }
747        }
748    }
749
750    remapped
751}
752
753fn normalize_max_width(max_width: Option<f32>) -> Option<f32> {
754    match max_width {
755        Some(width) if width.is_finite() && width > 0.0 => Some(width),
756        _ => None,
757    }
758}
759
760fn absolute_range(base: &Range<usize>, relative: Range<usize>) -> Range<usize> {
761    (base.start + relative.start)..(base.start + relative.end)
762}
763
764fn single_line_range(range: Range<usize>) -> Vec<Range<usize>> {
765    std::iter::once(range).collect()
766}
767
768fn wrap_line_to_width<M: TextMeasurer + ?Sized>(
769    measurer: &M,
770    text: &crate::text::AnnotatedString,
771    line_range: Range<usize>,
772    style: &TextStyle,
773    max_width: f32,
774    line_break: LineBreak,
775    hyphens: Hyphens,
776) -> Vec<Range<usize>> {
777    let line_text = &text.text[line_range.clone()];
778    if line_text.is_empty() {
779        return single_line_range(line_range.start..line_range.start);
780    }
781
782    if matches!(line_break, LineBreak::Heading | LineBreak::Paragraph)
783        && line_text.chars().any(char::is_whitespace)
784    {
785        if let Some(balanced) = wrap_line_with_word_balance(
786            measurer,
787            text,
788            line_range.clone(),
789            style,
790            max_width,
791            line_break,
792        ) {
793            return balanced;
794        }
795    }
796
797    wrap_line_greedy(
798        measurer, text, line_range, style, max_width, line_break, hyphens,
799    )
800}
801
802fn wrap_line_greedy<M: TextMeasurer + ?Sized>(
803    measurer: &M,
804    text: &crate::text::AnnotatedString,
805    line_range: Range<usize>,
806    style: &TextStyle,
807    max_width: f32,
808    line_break: LineBreak,
809    hyphens: Hyphens,
810) -> Vec<Range<usize>> {
811    let line_text = &text.text[line_range.clone()];
812    let boundaries = char_boundaries(line_text);
813    let mut wrapped = Vec::new();
814    let mut start_idx = 0usize;
815
816    while start_idx < boundaries.len() - 1 {
817        let mut low = start_idx + 1;
818        let mut high = boundaries.len() - 1;
819        let mut best = start_idx + 1;
820
821        while low <= high {
822            let mid = (low + high) / 2;
823            let segment_range = absolute_range(&line_range, boundaries[start_idx]..boundaries[mid]);
824            let width = measurer
825                .measure_subsequence(text, segment_range, style)
826                .width;
827            if width <= max_width + WRAP_EPSILON || mid == start_idx + 1 {
828                best = mid;
829                low = mid + 1;
830            } else {
831                if mid == 0 {
832                    break;
833                }
834                high = mid - 1;
835            }
836        }
837
838        let wrap_idx = choose_wrap_break(line_text, &boundaries, start_idx, best, line_break);
839        let mut effective_wrap_idx = wrap_idx;
840        let can_hyphenate = hyphens == Hyphens::Auto
841            && wrap_idx == best
842            && best < boundaries.len() - 1
843            && is_break_inside_word(line_text, &boundaries, wrap_idx);
844        if can_hyphenate {
845            effective_wrap_idx = resolve_auto_hyphen_break(
846                measurer,
847                line_text,
848                style,
849                &boundaries,
850                start_idx,
851                wrap_idx,
852            );
853        }
854
855        let segment_start = boundaries[start_idx];
856        let mut segment_end = boundaries[effective_wrap_idx];
857        if wrap_idx != best {
858            segment_end = trim_segment_end_whitespace(line_text, segment_start, segment_end);
859        }
860        wrapped.push(absolute_range(&line_range, segment_start..segment_end));
861
862        start_idx = if wrap_idx != best {
863            skip_leading_whitespace(line_text, &boundaries, wrap_idx)
864        } else {
865            effective_wrap_idx
866        };
867    }
868
869    if wrapped.is_empty() {
870        wrapped.push(line_range.start..line_range.start);
871    }
872
873    wrapped
874}
875
876fn wrap_line_with_word_balance<M: TextMeasurer + ?Sized>(
877    measurer: &M,
878    text: &crate::text::AnnotatedString,
879    line_range: Range<usize>,
880    style: &TextStyle,
881    max_width: f32,
882    line_break: LineBreak,
883) -> Option<Vec<Range<usize>>> {
884    let line_text = &text.text[line_range.clone()];
885    let boundaries = char_boundaries(line_text);
886    let breakpoints = collect_word_breakpoints(line_text, &boundaries);
887    if breakpoints.len() <= 2 {
888        return None;
889    }
890
891    let node_count = breakpoints.len();
892    let mut best_cost = vec![f32::INFINITY; node_count];
893    let mut next_index = vec![None; node_count];
894    best_cost[node_count - 1] = 0.0;
895
896    for start in (0..node_count - 1).rev() {
897        for end in start + 1..node_count {
898            let start_byte = boundaries[breakpoints[start]];
899            let end_byte = boundaries[breakpoints[end]];
900            let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
901            if trimmed_end <= start_byte {
902                continue;
903            }
904            let segment_range = absolute_range(&line_range, start_byte..trimmed_end);
905            let segment_width = measurer
906                .measure_subsequence(text, segment_range, style)
907                .width;
908            if segment_width > max_width + WRAP_EPSILON {
909                continue;
910            }
911            if !best_cost[end].is_finite() {
912                continue;
913            }
914            let slack = (max_width - segment_width).max(0.0);
915            let is_last = end == node_count - 1;
916            let segment_cost = match line_break {
917                LineBreak::Heading => slack * slack,
918                LineBreak::Paragraph => {
919                    if is_last {
920                        slack * slack * 0.16
921                    } else {
922                        slack * slack
923                    }
924                }
925                LineBreak::Simple | LineBreak::Unspecified => slack * slack,
926            };
927            let candidate = segment_cost + best_cost[end];
928            if candidate < best_cost[start] {
929                best_cost[start] = candidate;
930                next_index[start] = Some(end);
931            }
932        }
933    }
934
935    let mut wrapped = Vec::new();
936    let mut current = 0usize;
937    while current < node_count - 1 {
938        let next = next_index[current]?;
939        let start_byte = boundaries[breakpoints[current]];
940        let end_byte = boundaries[breakpoints[next]];
941        let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
942        if trimmed_end <= start_byte {
943            return None;
944        }
945        wrapped.push(absolute_range(&line_range, start_byte..trimmed_end));
946        current = next;
947    }
948
949    if wrapped.is_empty() {
950        return None;
951    }
952
953    Some(wrapped)
954}
955
956fn collect_word_breakpoints(line: &str, boundaries: &[usize]) -> Vec<usize> {
957    let mut points = vec![0usize];
958    for idx in 1..boundaries.len() - 1 {
959        let prev = &line[boundaries[idx - 1]..boundaries[idx]];
960        let current = &line[boundaries[idx]..boundaries[idx + 1]];
961        if prev.chars().all(char::is_whitespace) && !current.chars().all(char::is_whitespace) {
962            points.push(idx);
963        }
964    }
965    let end = boundaries.len() - 1;
966    if points.last().copied() != Some(end) {
967        points.push(end);
968    }
969    points
970}
971
972fn choose_wrap_break(
973    line: &str,
974    boundaries: &[usize],
975    start_idx: usize,
976    best: usize,
977    _line_break: LineBreak,
978) -> usize {
979    if best >= boundaries.len() - 1 {
980        return best;
981    }
982
983    if best <= start_idx + 1 {
984        return best;
985    }
986
987    for idx in (start_idx + 1..best).rev() {
988        let prev = &line[boundaries[idx - 1]..boundaries[idx]];
989        if prev.chars().all(char::is_whitespace) {
990            return idx;
991        }
992    }
993    best
994}
995
996fn is_break_inside_word(line: &str, boundaries: &[usize], break_idx: usize) -> bool {
997    if break_idx == 0 || break_idx >= boundaries.len() - 1 {
998        return false;
999    }
1000    let prev = &line[boundaries[break_idx - 1]..boundaries[break_idx]];
1001    let next = &line[boundaries[break_idx]..boundaries[break_idx + 1]];
1002    !prev.chars().all(char::is_whitespace) && !next.chars().all(char::is_whitespace)
1003}
1004
1005fn resolve_auto_hyphen_break<M: TextMeasurer + ?Sized>(
1006    measurer: &M,
1007    line: &str,
1008    style: &TextStyle,
1009    boundaries: &[usize],
1010    start_idx: usize,
1011    break_idx: usize,
1012) -> usize {
1013    if let Some(candidate) = measurer.choose_auto_hyphen_break(line, style, start_idx, break_idx) {
1014        if is_valid_auto_hyphen_break(line, boundaries, start_idx, break_idx, candidate) {
1015            return candidate;
1016        }
1017    }
1018    choose_auto_hyphen_break_fallback(boundaries, start_idx, break_idx)
1019}
1020
1021fn is_valid_auto_hyphen_break(
1022    line: &str,
1023    boundaries: &[usize],
1024    start_idx: usize,
1025    break_idx: usize,
1026    candidate_idx: usize,
1027) -> bool {
1028    let end_idx = boundaries.len().saturating_sub(1);
1029    candidate_idx > start_idx
1030        && candidate_idx < end_idx
1031        && candidate_idx <= break_idx
1032        && candidate_idx >= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS
1033        && is_break_inside_word(line, boundaries, candidate_idx)
1034}
1035
1036fn choose_auto_hyphen_break_fallback(
1037    boundaries: &[usize],
1038    start_idx: usize,
1039    break_idx: usize,
1040) -> usize {
1041    let end_idx = boundaries.len().saturating_sub(1);
1042    if break_idx >= end_idx {
1043        return break_idx;
1044    }
1045    let trailing_len = end_idx.saturating_sub(break_idx);
1046    if trailing_len > 2 || break_idx <= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS {
1047        return break_idx;
1048    }
1049
1050    let min_break = start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS;
1051    let max_break = break_idx.saturating_sub(1);
1052    if min_break > max_break {
1053        return break_idx;
1054    }
1055
1056    let mut best_break = break_idx;
1057    let mut best_penalty = usize::MAX;
1058    for idx in min_break..=max_break {
1059        let candidate_trailing_len = end_idx.saturating_sub(idx);
1060        let candidate_prefix_len = idx.saturating_sub(start_idx);
1061        if candidate_prefix_len < AUTO_HYPHEN_MIN_SEGMENT_CHARS
1062            || candidate_trailing_len < AUTO_HYPHEN_MIN_TRAILING_CHARS
1063        {
1064            continue;
1065        }
1066
1067        let penalty = candidate_trailing_len.abs_diff(AUTO_HYPHEN_PREFERRED_TRAILING_CHARS);
1068        if penalty < best_penalty {
1069            best_penalty = penalty;
1070            best_break = idx;
1071            if penalty == 0 {
1072                break;
1073            }
1074        }
1075    }
1076    best_break
1077}
1078
1079fn skip_leading_whitespace(line: &str, boundaries: &[usize], mut idx: usize) -> usize {
1080    while idx < boundaries.len() - 1 {
1081        let ch = &line[boundaries[idx]..boundaries[idx + 1]];
1082        if !ch.chars().all(char::is_whitespace) {
1083            break;
1084        }
1085        idx += 1;
1086    }
1087    idx
1088}
1089
1090fn apply_line_overflow<M: TextMeasurer + ?Sized>(
1091    measurer: &M,
1092    line: &str,
1093    style: &TextStyle,
1094    max_width: Option<f32>,
1095    options: TextLayoutOptions,
1096    is_last_visible_line: bool,
1097    single_line_ellipsis: bool,
1098) -> String {
1099    if options.overflow == TextOverflow::Clip || !is_last_visible_line {
1100        return line.to_string();
1101    }
1102
1103    let Some(width_limit) = max_width else {
1104        return match options.overflow {
1105            TextOverflow::Ellipsis => format!("{line}{ELLIPSIS}"),
1106            TextOverflow::StartEllipsis => format!("{ELLIPSIS}{line}"),
1107            TextOverflow::MiddleEllipsis => format!("{ELLIPSIS}{line}"),
1108            TextOverflow::Clip | TextOverflow::Visible => line.to_string(),
1109        };
1110    };
1111
1112    match options.overflow {
1113        TextOverflow::Clip | TextOverflow::Visible => line.to_string(),
1114        TextOverflow::Ellipsis => fit_end_ellipsis(measurer, line, style, width_limit),
1115        TextOverflow::StartEllipsis => {
1116            if single_line_ellipsis {
1117                fit_start_ellipsis(measurer, line, style, width_limit)
1118            } else {
1119                line.to_string()
1120            }
1121        }
1122        TextOverflow::MiddleEllipsis => {
1123            if single_line_ellipsis {
1124                fit_middle_ellipsis(measurer, line, style, width_limit)
1125            } else {
1126                line.to_string()
1127            }
1128        }
1129    }
1130}
1131
1132fn fit_end_ellipsis<M: TextMeasurer + ?Sized>(
1133    measurer: &M,
1134    line: &str,
1135    style: &TextStyle,
1136    max_width: f32,
1137) -> String {
1138    if measurer
1139        .measure(&crate::text::AnnotatedString::from(line), style)
1140        .width
1141        <= max_width + WRAP_EPSILON
1142    {
1143        return line.to_string();
1144    }
1145
1146    let ellipsis_width = measurer
1147        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
1148        .width;
1149    if ellipsis_width > max_width + WRAP_EPSILON {
1150        return String::new();
1151    }
1152
1153    let boundaries = char_boundaries(line);
1154    let mut low = 0usize;
1155    let mut high = boundaries.len() - 1;
1156    let mut best = 0usize;
1157
1158    while low <= high {
1159        let mid = (low + high) / 2;
1160        let prefix = &line[..boundaries[mid]];
1161        let candidate = format!("{prefix}{ELLIPSIS}");
1162        let width = measurer
1163            .measure(
1164                &crate::text::AnnotatedString::from(candidate.as_str()),
1165                style,
1166            )
1167            .width;
1168        if width <= max_width + WRAP_EPSILON {
1169            best = mid;
1170            low = mid + 1;
1171        } else if mid == 0 {
1172            break;
1173        } else {
1174            high = mid - 1;
1175        }
1176    }
1177
1178    format!("{}{}", &line[..boundaries[best]], ELLIPSIS)
1179}
1180
1181fn fit_start_ellipsis<M: TextMeasurer + ?Sized>(
1182    measurer: &M,
1183    line: &str,
1184    style: &TextStyle,
1185    max_width: f32,
1186) -> String {
1187    if measurer
1188        .measure(&crate::text::AnnotatedString::from(line), style)
1189        .width
1190        <= max_width + WRAP_EPSILON
1191    {
1192        return line.to_string();
1193    }
1194
1195    let ellipsis_width = measurer
1196        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
1197        .width;
1198    if ellipsis_width > max_width + WRAP_EPSILON {
1199        return String::new();
1200    }
1201
1202    let boundaries = char_boundaries(line);
1203    let mut low = 0usize;
1204    let mut high = boundaries.len() - 1;
1205    let mut best = boundaries.len() - 1;
1206
1207    while low <= high {
1208        let mid = (low + high) / 2;
1209        let suffix = &line[boundaries[mid]..];
1210        let candidate = format!("{ELLIPSIS}{suffix}");
1211        let width = measurer
1212            .measure(
1213                &crate::text::AnnotatedString::from(candidate.as_str()),
1214                style,
1215            )
1216            .width;
1217        if width <= max_width + WRAP_EPSILON {
1218            best = mid;
1219            if mid == 0 {
1220                break;
1221            }
1222            high = mid - 1;
1223        } else {
1224            low = mid + 1;
1225        }
1226    }
1227
1228    format!("{ELLIPSIS}{}", &line[boundaries[best]..])
1229}
1230
1231fn fit_middle_ellipsis<M: TextMeasurer + ?Sized>(
1232    measurer: &M,
1233    line: &str,
1234    style: &TextStyle,
1235    max_width: f32,
1236) -> String {
1237    if measurer
1238        .measure(&crate::text::AnnotatedString::from(line), style)
1239        .width
1240        <= max_width + WRAP_EPSILON
1241    {
1242        return line.to_string();
1243    }
1244
1245    let ellipsis_width = measurer
1246        .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
1247        .width;
1248    if ellipsis_width > max_width + WRAP_EPSILON {
1249        return String::new();
1250    }
1251
1252    let boundaries = char_boundaries(line);
1253    let total_chars = boundaries.len().saturating_sub(1);
1254    for keep in (0..=total_chars).rev() {
1255        let keep_start = keep.div_ceil(2);
1256        let keep_end = keep / 2;
1257        let start = &line[..boundaries[keep_start]];
1258        let end_start = boundaries[total_chars.saturating_sub(keep_end)];
1259        let end = &line[end_start..];
1260        let candidate = format!("{start}{ELLIPSIS}{end}");
1261        if measurer
1262            .measure(
1263                &crate::text::AnnotatedString::from(candidate.as_str()),
1264                style,
1265            )
1266            .width
1267            <= max_width + WRAP_EPSILON
1268        {
1269            return candidate;
1270        }
1271    }
1272
1273    ELLIPSIS.to_string()
1274}
1275
1276fn char_boundaries(text: &str) -> Vec<usize> {
1277    let mut out = Vec::with_capacity(text.chars().count() + 1);
1278    out.push(0);
1279    for (idx, _) in text.char_indices() {
1280        if idx != 0 {
1281            out.push(idx);
1282        }
1283    }
1284    out.push(text.len());
1285    out
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290    use super::*;
1291    use crate::text::{Hyphens, LineBreak, ParagraphStyle, TextUnit};
1292    use crate::text_layout_result::TextLayoutResult;
1293
1294    struct ContractBreakMeasurer {
1295        retreat: usize,
1296    }
1297
1298    impl TextMeasurer for ContractBreakMeasurer {
1299        fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
1300            MonospacedTextMeasurer.measure(
1301                &crate::text::AnnotatedString::from(text.text.as_str()),
1302                style,
1303            )
1304        }
1305
1306        fn get_offset_for_position(
1307            &self,
1308            text: &crate::text::AnnotatedString,
1309            style: &TextStyle,
1310            x: f32,
1311            y: f32,
1312        ) -> usize {
1313            MonospacedTextMeasurer.get_offset_for_position(
1314                &crate::text::AnnotatedString::from(text.text.as_str()),
1315                style,
1316                x,
1317                y,
1318            )
1319        }
1320
1321        fn get_cursor_x_for_offset(
1322            &self,
1323            text: &crate::text::AnnotatedString,
1324            style: &TextStyle,
1325            offset: usize,
1326        ) -> f32 {
1327            MonospacedTextMeasurer.get_cursor_x_for_offset(
1328                &crate::text::AnnotatedString::from(text.text.as_str()),
1329                style,
1330                offset,
1331            )
1332        }
1333
1334        fn layout(
1335            &self,
1336            text: &crate::text::AnnotatedString,
1337            style: &TextStyle,
1338        ) -> TextLayoutResult {
1339            MonospacedTextMeasurer.layout(
1340                &crate::text::AnnotatedString::from(text.text.as_str()),
1341                style,
1342            )
1343        }
1344
1345        fn choose_auto_hyphen_break(
1346            &self,
1347            _line: &str,
1348            _style: &TextStyle,
1349            _segment_start_char: usize,
1350            measured_break_char: usize,
1351        ) -> Option<usize> {
1352            measured_break_char.checked_sub(self.retreat)
1353        }
1354    }
1355
1356    fn style_with_line_break(line_break: LineBreak) -> TextStyle {
1357        TextStyle {
1358            span_style: crate::text::SpanStyle {
1359                font_size: TextUnit::Sp(10.0),
1360                ..Default::default()
1361            },
1362            paragraph_style: ParagraphStyle {
1363                line_break,
1364                ..Default::default()
1365            },
1366        }
1367    }
1368
1369    fn style_with_hyphens(hyphens: Hyphens) -> TextStyle {
1370        TextStyle {
1371            span_style: crate::text::SpanStyle {
1372                font_size: TextUnit::Sp(10.0),
1373                ..Default::default()
1374            },
1375            paragraph_style: ParagraphStyle {
1376                hyphens,
1377                ..Default::default()
1378            },
1379        }
1380    }
1381
1382    #[test]
1383    fn text_layout_options_wraps_and_limits_lines() {
1384        let style = TextStyle {
1385            span_style: crate::text::SpanStyle {
1386                font_size: TextUnit::Sp(10.0),
1387                ..Default::default()
1388            },
1389            ..Default::default()
1390        };
1391        let options = TextLayoutOptions {
1392            overflow: TextOverflow::Clip,
1393            soft_wrap: true,
1394            max_lines: 2,
1395            min_lines: 1,
1396        };
1397
1398        let prepared = prepare_text_layout(
1399            &crate::text::AnnotatedString::from("A B C D E F"),
1400            &style,
1401            options,
1402            Some(24.0), // roughly 4 chars in monospaced fallback
1403        );
1404
1405        assert!(prepared.did_overflow);
1406        assert!(prepared.metrics.line_count <= 2);
1407    }
1408
1409    #[test]
1410    fn text_layout_options_end_ellipsis_applies() {
1411        let style = TextStyle {
1412            span_style: crate::text::SpanStyle {
1413                font_size: TextUnit::Sp(10.0),
1414                ..Default::default()
1415            },
1416            ..Default::default()
1417        };
1418        let options = TextLayoutOptions {
1419            overflow: TextOverflow::Ellipsis,
1420            soft_wrap: false,
1421            max_lines: 1,
1422            min_lines: 1,
1423        };
1424
1425        let prepared = prepare_text_layout(
1426            &crate::text::AnnotatedString::from("Long long line"),
1427            &style,
1428            options,
1429            Some(20.0),
1430        );
1431        assert!(prepared.did_overflow);
1432        assert!(prepared.text.text.contains(ELLIPSIS));
1433    }
1434
1435    #[test]
1436    fn text_layout_options_visible_keeps_full_text() {
1437        let style = TextStyle {
1438            span_style: crate::text::SpanStyle {
1439                font_size: TextUnit::Sp(10.0),
1440                ..Default::default()
1441            },
1442            ..Default::default()
1443        };
1444        let options = TextLayoutOptions {
1445            overflow: TextOverflow::Visible,
1446            soft_wrap: false,
1447            max_lines: 1,
1448            min_lines: 1,
1449        };
1450
1451        let input = "This should remain unchanged";
1452        let prepared = prepare_text_layout(
1453            &crate::text::AnnotatedString::from(input),
1454            &style,
1455            options,
1456            Some(10.0),
1457        );
1458        assert_eq!(prepared.text.text, input);
1459    }
1460
1461    #[test]
1462    fn text_layout_options_respects_min_lines() {
1463        let style = TextStyle {
1464            span_style: crate::text::SpanStyle {
1465                font_size: TextUnit::Sp(10.0),
1466                ..Default::default()
1467            },
1468            ..Default::default()
1469        };
1470        let options = TextLayoutOptions {
1471            overflow: TextOverflow::Clip,
1472            soft_wrap: true,
1473            max_lines: 4,
1474            min_lines: 3,
1475        };
1476
1477        let prepared = prepare_text_layout(
1478            &crate::text::AnnotatedString::from("short"),
1479            &style,
1480            options,
1481            Some(100.0),
1482        );
1483        assert_eq!(prepared.metrics.line_count, 3);
1484    }
1485
1486    #[test]
1487    fn text_layout_options_middle_ellipsis_for_single_line() {
1488        let style = TextStyle {
1489            span_style: crate::text::SpanStyle {
1490                font_size: TextUnit::Sp(10.0),
1491                ..Default::default()
1492            },
1493            ..Default::default()
1494        };
1495        let options = TextLayoutOptions {
1496            overflow: TextOverflow::MiddleEllipsis,
1497            soft_wrap: false,
1498            max_lines: 1,
1499            min_lines: 1,
1500        };
1501
1502        let prepared = prepare_text_layout(
1503            &crate::text::AnnotatedString::from("abcdefghijk"),
1504            &style,
1505            options,
1506            Some(24.0),
1507        );
1508        assert!(prepared.text.text.contains(ELLIPSIS));
1509        assert!(prepared.did_overflow);
1510    }
1511
1512    #[test]
1513    fn text_layout_options_does_not_wrap_on_tiny_width_delta() {
1514        let style = TextStyle {
1515            span_style: crate::text::SpanStyle {
1516                font_size: TextUnit::Sp(10.0),
1517                ..Default::default()
1518            },
1519            ..Default::default()
1520        };
1521        let options = TextLayoutOptions {
1522            overflow: TextOverflow::Clip,
1523            soft_wrap: true,
1524            max_lines: usize::MAX,
1525            min_lines: 1,
1526        };
1527
1528        let text = "if counter % 2 == 0";
1529        let exact_width = measure_text(&crate::text::AnnotatedString::from(text), &style).width;
1530        let prepared = prepare_text_layout(
1531            &crate::text::AnnotatedString::from(text),
1532            &style,
1533            options,
1534            Some(exact_width - 0.1),
1535        );
1536
1537        assert!(
1538            !prepared.text.text.contains('\n'),
1539            "unexpected line split: {:?}",
1540            prepared.text
1541        );
1542    }
1543
1544    #[test]
1545    fn line_break_mode_changes_wrap_strategy_contract() {
1546        let text = "This is an example text";
1547        let options = TextLayoutOptions {
1548            overflow: TextOverflow::Clip,
1549            soft_wrap: true,
1550            max_lines: usize::MAX,
1551            min_lines: 1,
1552        };
1553
1554        let simple = prepare_text_layout(
1555            &crate::text::AnnotatedString::from(text),
1556            &style_with_line_break(LineBreak::Simple),
1557            options,
1558            Some(120.0),
1559        );
1560        let heading = prepare_text_layout(
1561            &crate::text::AnnotatedString::from(text),
1562            &style_with_line_break(LineBreak::Heading),
1563            options,
1564            Some(120.0),
1565        );
1566        let paragraph = prepare_text_layout(
1567            &crate::text::AnnotatedString::from(text),
1568            &style_with_line_break(LineBreak::Paragraph),
1569            options,
1570            Some(50.0),
1571        );
1572
1573        assert_eq!(
1574            simple.text.text.lines().collect::<Vec<_>>(),
1575            vec!["This is an example", "text"]
1576        );
1577        assert_eq!(
1578            heading.text.text.lines().collect::<Vec<_>>(),
1579            vec!["This is an", "example text"]
1580        );
1581        assert_eq!(
1582            paragraph.text.text.lines().collect::<Vec<_>>(),
1583            vec!["This", "is an", "example", "text"]
1584        );
1585    }
1586
1587    #[test]
1588    fn hyphens_mode_changes_wrap_strategy_contract() {
1589        let text = "Transformation";
1590        let options = TextLayoutOptions {
1591            overflow: TextOverflow::Clip,
1592            soft_wrap: true,
1593            max_lines: usize::MAX,
1594            min_lines: 1,
1595        };
1596
1597        let auto = prepare_text_layout(
1598            &crate::text::AnnotatedString::from(text),
1599            &style_with_hyphens(Hyphens::Auto),
1600            options,
1601            Some(24.0),
1602        );
1603        let none = prepare_text_layout(
1604            &crate::text::AnnotatedString::from(text),
1605            &style_with_hyphens(Hyphens::None),
1606            options,
1607            Some(24.0),
1608        );
1609
1610        assert_eq!(
1611            auto.text.text.lines().collect::<Vec<_>>(),
1612            vec!["Tran", "sfor", "ma", "tion"]
1613        );
1614        assert_eq!(
1615            none.text.text.lines().collect::<Vec<_>>(),
1616            vec!["Tran", "sfor", "mati", "on"]
1617        );
1618        assert!(
1619            !auto.text.text.contains('-'),
1620            "automatic hyphenation should influence breaks without mutating source text content"
1621        );
1622    }
1623
1624    #[test]
1625    fn hyphens_auto_uses_measurer_hyphen_contract_when_valid() {
1626        let text = "Transformation";
1627        let style = style_with_hyphens(Hyphens::Auto);
1628        let options = TextLayoutOptions {
1629            overflow: TextOverflow::Clip,
1630            soft_wrap: true,
1631            max_lines: usize::MAX,
1632            min_lines: 1,
1633        };
1634
1635        let prepared = prepare_text_layout_fallback(
1636            &ContractBreakMeasurer { retreat: 1 },
1637            &crate::text::AnnotatedString::from(text),
1638            &style,
1639            options,
1640            Some(24.0),
1641        );
1642
1643        assert_eq!(
1644            prepared.text.text.lines().collect::<Vec<_>>(),
1645            vec!["Tra", "nsf", "orm", "ati", "on"]
1646        );
1647    }
1648
1649    #[test]
1650    fn hyphens_auto_falls_back_when_measurer_hyphen_contract_is_invalid() {
1651        let text = "Transformation";
1652        let style = style_with_hyphens(Hyphens::Auto);
1653        let options = TextLayoutOptions {
1654            overflow: TextOverflow::Clip,
1655            soft_wrap: true,
1656            max_lines: usize::MAX,
1657            min_lines: 1,
1658        };
1659
1660        let prepared = prepare_text_layout_fallback(
1661            &ContractBreakMeasurer { retreat: 10 },
1662            &crate::text::AnnotatedString::from(text),
1663            &style,
1664            options,
1665            Some(24.0),
1666        );
1667
1668        assert_eq!(
1669            prepared.text.text.lines().collect::<Vec<_>>(),
1670            vec!["Tran", "sfor", "ma", "tion"]
1671        );
1672    }
1673
1674    #[test]
1675    fn transformed_text_keeps_span_ranges_within_display_bounds() {
1676        let style = TextStyle {
1677            span_style: crate::text::SpanStyle {
1678                font_size: TextUnit::Sp(10.0),
1679                ..Default::default()
1680            },
1681            ..Default::default()
1682        };
1683        let options = TextLayoutOptions {
1684            overflow: TextOverflow::Ellipsis,
1685            soft_wrap: false,
1686            max_lines: 1,
1687            min_lines: 1,
1688        };
1689        let annotated = crate::text::AnnotatedString::builder()
1690            .push_style(crate::text::SpanStyle {
1691                font_weight: Some(crate::text::FontWeight::BOLD),
1692                ..Default::default()
1693            })
1694            .append("Styled overflow text sample")
1695            .pop()
1696            .to_annotated_string();
1697
1698        let prepared = prepare_text_layout(&annotated, &style, options, Some(40.0));
1699        assert!(prepared.did_overflow);
1700        for span in &prepared.text.span_styles {
1701            assert!(span.range.start < span.range.end);
1702            assert!(span.range.end <= prepared.text.text.len());
1703            assert!(prepared.text.text.is_char_boundary(span.range.start));
1704            assert!(prepared.text.text.is_char_boundary(span.range.end));
1705        }
1706    }
1707
1708    #[test]
1709    fn wrapped_text_splits_styles_around_inserted_newlines() {
1710        let style = TextStyle {
1711            span_style: crate::text::SpanStyle {
1712                font_size: TextUnit::Sp(10.0),
1713                ..Default::default()
1714            },
1715            ..Default::default()
1716        };
1717        let options = TextLayoutOptions {
1718            overflow: TextOverflow::Clip,
1719            soft_wrap: true,
1720            max_lines: usize::MAX,
1721            min_lines: 1,
1722        };
1723        let annotated = crate::text::AnnotatedString::builder()
1724            .push_style(crate::text::SpanStyle {
1725                text_decoration: Some(crate::text::TextDecoration::UNDERLINE),
1726                ..Default::default()
1727            })
1728            .append("Wrapped style text example")
1729            .pop()
1730            .to_annotated_string();
1731
1732        let prepared = prepare_text_layout(&annotated, &style, options, Some(32.0));
1733        assert!(prepared.text.text.contains('\n'));
1734        assert!(!prepared.text.span_styles.is_empty());
1735        for span in &prepared.text.span_styles {
1736            assert!(span.range.end <= prepared.text.text.len());
1737        }
1738    }
1739
1740    #[test]
1741    fn mixed_font_size_segments_wrap_without_truncation() {
1742        let style = TextStyle {
1743            span_style: crate::text::SpanStyle {
1744                font_size: TextUnit::Sp(14.0),
1745                ..Default::default()
1746            },
1747            ..Default::default()
1748        };
1749        let options = TextLayoutOptions {
1750            overflow: TextOverflow::Clip,
1751            soft_wrap: true,
1752            max_lines: usize::MAX,
1753            min_lines: 1,
1754        };
1755        let annotated = crate::text::AnnotatedString::builder()
1756            .append("You can also ")
1757            .push_style(crate::text::SpanStyle {
1758                font_size: TextUnit::Sp(22.0),
1759                ..Default::default()
1760            })
1761            .append("change font size")
1762            .pop()
1763            .append(" dynamically mid-sentence!")
1764            .to_annotated_string();
1765
1766        let prepared = prepare_text_layout(&annotated, &style, options, Some(260.0));
1767        assert!(prepared.text.text.contains('\n'));
1768        assert!(prepared.text.text.contains("mid-sentence!"));
1769        assert!(!prepared.did_overflow);
1770    }
1771}