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 pub line_height: f32,
20 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 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; 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), );
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}