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