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