Skip to main content

cranpose_ui/text/
measure.rs

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