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 offset = 0usize;
395
396 for (idx, line) in lines.iter().enumerate() {
397 text.push_str(line.text.as_str());
398 for span in &line.span_styles {
399 span_styles.push(crate::text::RangeStyle {
400 item: span.item.clone(),
401 range: (span.range.start + offset)..(span.range.end + offset),
402 });
403 }
404 for span in &line.paragraph_styles {
405 paragraph_styles.push(crate::text::RangeStyle {
406 item: span.item.clone(),
407 range: (span.range.start + offset)..(span.range.end + offset),
408 });
409 }
410
411 offset += line.text.len();
412 if idx + 1 < lines.len() {
413 text.push('\n');
414 offset += 1;
415 }
416 }
417
418 crate::text::AnnotatedString {
419 text,
420 span_styles,
421 paragraph_styles,
422 }
423}
424
425fn trim_segment_end_whitespace(line: &str, start: usize, mut end: usize) -> usize {
426 while end > start {
427 let Some((idx, ch)) = line[start..end].char_indices().next_back() else {
428 break;
429 };
430 if ch.is_whitespace() {
431 end = start + idx;
432 } else {
433 break;
434 }
435 }
436 end
437}
438
439fn remap_annotated_for_display(
440 source: &crate::text::AnnotatedString,
441 display_text: &str,
442) -> crate::text::AnnotatedString {
443 if source.text == display_text {
444 return source.clone();
445 }
446
447 let display_chars = map_display_chars_to_source(source.text.as_str(), display_text);
448 crate::text::AnnotatedString {
449 text: display_text.to_string(),
450 span_styles: remap_range_styles(&source.span_styles, &display_chars),
451 paragraph_styles: remap_range_styles(&source.paragraph_styles, &display_chars),
452 }
453}
454
455#[derive(Clone, Copy)]
456struct DisplayCharMap {
457 display_start: usize,
458 display_end: usize,
459 source_start: Option<usize>,
460}
461
462fn map_display_chars_to_source(source: &str, display: &str) -> Vec<DisplayCharMap> {
463 let source_chars: Vec<(usize, char)> = source.char_indices().collect();
464 let mut source_index = 0usize;
465 let mut maps = Vec::with_capacity(display.chars().count());
466
467 for (display_start, display_char) in display.char_indices() {
468 let display_end = display_start + display_char.len_utf8();
469 let mut source_start = None;
470 while source_index < source_chars.len() {
471 let (candidate_start, candidate_char) = source_chars[source_index];
472 source_index += 1;
473 if candidate_char == display_char {
474 source_start = Some(candidate_start);
475 break;
476 }
477 }
478 maps.push(DisplayCharMap {
479 display_start,
480 display_end,
481 source_start,
482 });
483 }
484
485 maps
486}
487
488fn remap_range_styles<T: Clone>(
489 styles: &[crate::text::RangeStyle<T>],
490 display_chars: &[DisplayCharMap],
491) -> Vec<crate::text::RangeStyle<T>> {
492 let mut remapped = Vec::new();
493
494 for style in styles {
495 let mut range_start = None;
496 let mut range_end = 0usize;
497
498 for map in display_chars {
499 let in_range = map.source_start.is_some_and(|source_start| {
500 source_start >= style.range.start && source_start < style.range.end
501 });
502
503 if in_range {
504 if range_start.is_none() {
505 range_start = Some(map.display_start);
506 }
507 range_end = map.display_end;
508 continue;
509 }
510
511 if let Some(start) = range_start.take() {
512 if start < range_end {
513 remapped.push(crate::text::RangeStyle {
514 item: style.item.clone(),
515 range: start..range_end,
516 });
517 }
518 }
519 }
520
521 if let Some(start) = range_start.take() {
522 if start < range_end {
523 remapped.push(crate::text::RangeStyle {
524 item: style.item.clone(),
525 range: start..range_end,
526 });
527 }
528 }
529 }
530
531 remapped
532}
533
534fn normalize_max_width(max_width: Option<f32>) -> Option<f32> {
535 match max_width {
536 Some(width) if width.is_finite() && width > 0.0 => Some(width),
537 _ => None,
538 }
539}
540
541fn wrap_line_to_width<M: TextMeasurer + ?Sized>(
542 measurer: &M,
543 line: &crate::text::AnnotatedString,
544 style: &TextStyle,
545 max_width: f32,
546 line_break: LineBreak,
547 hyphens: Hyphens,
548) -> Vec<crate::text::AnnotatedString> {
549 if line.text.is_empty() {
550 return vec![crate::text::AnnotatedString::from("")];
551 }
552
553 if matches!(line_break, LineBreak::Heading | LineBreak::Paragraph)
554 && line.text.chars().any(char::is_whitespace)
555 {
556 if let Some(balanced) =
557 wrap_line_with_word_balance(measurer, line, style, max_width, line_break)
558 {
559 return balanced;
560 }
561 }
562
563 wrap_line_greedy(measurer, line, style, max_width, line_break, hyphens)
564}
565
566fn wrap_line_greedy<M: TextMeasurer + ?Sized>(
567 measurer: &M,
568 line: &crate::text::AnnotatedString,
569 style: &TextStyle,
570 max_width: f32,
571 line_break: LineBreak,
572 hyphens: Hyphens,
573) -> Vec<crate::text::AnnotatedString> {
574 let line_text = line.text.as_str();
575 let boundaries = char_boundaries(line_text);
576 let mut wrapped = Vec::new();
577 let mut start_idx = 0usize;
578
579 while start_idx < boundaries.len() - 1 {
580 let mut low = start_idx + 1;
581 let mut high = boundaries.len() - 1;
582 let mut best = start_idx + 1;
583
584 while low <= high {
585 let mid = (low + high) / 2;
586 let segment = line.subsequence(boundaries[start_idx]..boundaries[mid]);
587 let width = measurer.measure(&segment, style).width;
588 if width <= max_width + WRAP_EPSILON || mid == start_idx + 1 {
589 best = mid;
590 low = mid + 1;
591 } else {
592 if mid == 0 {
593 break;
594 }
595 high = mid - 1;
596 }
597 }
598
599 let wrap_idx = choose_wrap_break(line_text, &boundaries, start_idx, best, line_break);
600 let mut effective_wrap_idx = wrap_idx;
601 let can_hyphenate = hyphens == Hyphens::Auto
602 && wrap_idx == best
603 && best < boundaries.len() - 1
604 && is_break_inside_word(line_text, &boundaries, wrap_idx);
605 if can_hyphenate {
606 effective_wrap_idx = resolve_auto_hyphen_break(
607 measurer,
608 line_text,
609 style,
610 &boundaries,
611 start_idx,
612 wrap_idx,
613 );
614 }
615
616 let segment_start = boundaries[start_idx];
617 let mut segment_end = boundaries[effective_wrap_idx];
618 if wrap_idx != best {
619 segment_end = trim_segment_end_whitespace(line_text, segment_start, segment_end);
620 }
621 wrapped.push(line.subsequence(segment_start..segment_end));
622
623 start_idx = if wrap_idx != best {
624 skip_leading_whitespace(line_text, &boundaries, wrap_idx)
625 } else {
626 effective_wrap_idx
627 };
628 }
629
630 if wrapped.is_empty() {
631 wrapped.push(crate::text::AnnotatedString::from(""));
632 }
633
634 wrapped
635}
636
637fn wrap_line_with_word_balance<M: TextMeasurer + ?Sized>(
638 measurer: &M,
639 line: &crate::text::AnnotatedString,
640 style: &TextStyle,
641 max_width: f32,
642 line_break: LineBreak,
643) -> Option<Vec<crate::text::AnnotatedString>> {
644 let line_text = line.text.as_str();
645 let boundaries = char_boundaries(line_text);
646 let breakpoints = collect_word_breakpoints(line_text, &boundaries);
647 if breakpoints.len() <= 2 {
648 return None;
649 }
650
651 let node_count = breakpoints.len();
652 let mut best_cost = vec![f32::INFINITY; node_count];
653 let mut next_index = vec![None; node_count];
654 best_cost[node_count - 1] = 0.0;
655
656 for start in (0..node_count - 1).rev() {
657 for end in start + 1..node_count {
658 let start_byte = boundaries[breakpoints[start]];
659 let end_byte = boundaries[breakpoints[end]];
660 let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
661 if trimmed_end <= start_byte {
662 continue;
663 }
664 let segment = line.subsequence(start_byte..trimmed_end);
665 let segment_width = measurer.measure(&segment, style).width;
666 if segment_width > max_width + WRAP_EPSILON {
667 continue;
668 }
669 if !best_cost[end].is_finite() {
670 continue;
671 }
672 let slack = (max_width - segment_width).max(0.0);
673 let is_last = end == node_count - 1;
674 let segment_cost = match line_break {
675 LineBreak::Heading => slack * slack,
676 LineBreak::Paragraph => {
677 if is_last {
678 slack * slack * 0.16
679 } else {
680 slack * slack
681 }
682 }
683 LineBreak::Simple | LineBreak::Unspecified => slack * slack,
684 };
685 let candidate = segment_cost + best_cost[end];
686 if candidate < best_cost[start] {
687 best_cost[start] = candidate;
688 next_index[start] = Some(end);
689 }
690 }
691 }
692
693 let mut wrapped = Vec::new();
694 let mut current = 0usize;
695 while current < node_count - 1 {
696 let next = next_index[current]?;
697 let start_byte = boundaries[breakpoints[current]];
698 let end_byte = boundaries[breakpoints[next]];
699 let trimmed_end = trim_segment_end_whitespace(line_text, start_byte, end_byte);
700 if trimmed_end <= start_byte {
701 return None;
702 }
703 wrapped.push(line.subsequence(start_byte..trimmed_end));
704 current = next;
705 }
706
707 if wrapped.is_empty() {
708 return None;
709 }
710
711 Some(wrapped)
712}
713
714fn collect_word_breakpoints(line: &str, boundaries: &[usize]) -> Vec<usize> {
715 let mut points = vec![0usize];
716 for idx in 1..boundaries.len() - 1 {
717 let prev = &line[boundaries[idx - 1]..boundaries[idx]];
718 let current = &line[boundaries[idx]..boundaries[idx + 1]];
719 if prev.chars().all(char::is_whitespace) && !current.chars().all(char::is_whitespace) {
720 points.push(idx);
721 }
722 }
723 let end = boundaries.len() - 1;
724 if points.last().copied() != Some(end) {
725 points.push(end);
726 }
727 points
728}
729
730fn choose_wrap_break(
731 line: &str,
732 boundaries: &[usize],
733 start_idx: usize,
734 best: usize,
735 _line_break: LineBreak,
736) -> usize {
737 if best >= boundaries.len() - 1 {
738 return best;
739 }
740
741 if best <= start_idx + 1 {
742 return best;
743 }
744
745 for idx in (start_idx + 1..best).rev() {
746 let prev = &line[boundaries[idx - 1]..boundaries[idx]];
747 if prev.chars().all(char::is_whitespace) {
748 return idx;
749 }
750 }
751 best
752}
753
754fn is_break_inside_word(line: &str, boundaries: &[usize], break_idx: usize) -> bool {
755 if break_idx == 0 || break_idx >= boundaries.len() - 1 {
756 return false;
757 }
758 let prev = &line[boundaries[break_idx - 1]..boundaries[break_idx]];
759 let next = &line[boundaries[break_idx]..boundaries[break_idx + 1]];
760 !prev.chars().all(char::is_whitespace) && !next.chars().all(char::is_whitespace)
761}
762
763fn resolve_auto_hyphen_break<M: TextMeasurer + ?Sized>(
764 measurer: &M,
765 line: &str,
766 style: &TextStyle,
767 boundaries: &[usize],
768 start_idx: usize,
769 break_idx: usize,
770) -> usize {
771 if let Some(candidate) = measurer.choose_auto_hyphen_break(line, style, start_idx, break_idx) {
772 if is_valid_auto_hyphen_break(line, boundaries, start_idx, break_idx, candidate) {
773 return candidate;
774 }
775 }
776 choose_auto_hyphen_break_fallback(boundaries, start_idx, break_idx)
777}
778
779fn is_valid_auto_hyphen_break(
780 line: &str,
781 boundaries: &[usize],
782 start_idx: usize,
783 break_idx: usize,
784 candidate_idx: usize,
785) -> bool {
786 let end_idx = boundaries.len().saturating_sub(1);
787 candidate_idx > start_idx
788 && candidate_idx < end_idx
789 && candidate_idx <= break_idx
790 && candidate_idx >= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS
791 && is_break_inside_word(line, boundaries, candidate_idx)
792}
793
794fn choose_auto_hyphen_break_fallback(
795 boundaries: &[usize],
796 start_idx: usize,
797 break_idx: usize,
798) -> usize {
799 let end_idx = boundaries.len().saturating_sub(1);
800 if break_idx >= end_idx {
801 return break_idx;
802 }
803 let trailing_len = end_idx.saturating_sub(break_idx);
804 if trailing_len > 2 || break_idx <= start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS {
805 return break_idx;
806 }
807
808 let min_break = start_idx + AUTO_HYPHEN_MIN_SEGMENT_CHARS;
809 let max_break = break_idx.saturating_sub(1);
810 if min_break > max_break {
811 return break_idx;
812 }
813
814 let mut best_break = break_idx;
815 let mut best_penalty = usize::MAX;
816 for idx in min_break..=max_break {
817 let candidate_trailing_len = end_idx.saturating_sub(idx);
818 let candidate_prefix_len = idx.saturating_sub(start_idx);
819 if candidate_prefix_len < AUTO_HYPHEN_MIN_SEGMENT_CHARS
820 || candidate_trailing_len < AUTO_HYPHEN_MIN_TRAILING_CHARS
821 {
822 continue;
823 }
824
825 let penalty = candidate_trailing_len.abs_diff(AUTO_HYPHEN_PREFERRED_TRAILING_CHARS);
826 if penalty < best_penalty {
827 best_penalty = penalty;
828 best_break = idx;
829 if penalty == 0 {
830 break;
831 }
832 }
833 }
834 best_break
835}
836
837fn skip_leading_whitespace(line: &str, boundaries: &[usize], mut idx: usize) -> usize {
838 while idx < boundaries.len() - 1 {
839 let ch = &line[boundaries[idx]..boundaries[idx + 1]];
840 if !ch.chars().all(char::is_whitespace) {
841 break;
842 }
843 idx += 1;
844 }
845 idx
846}
847
848fn apply_line_overflow<M: TextMeasurer + ?Sized>(
849 measurer: &M,
850 line: &str,
851 style: &TextStyle,
852 max_width: Option<f32>,
853 options: TextLayoutOptions,
854 is_last_visible_line: bool,
855 single_line_ellipsis: bool,
856) -> String {
857 if options.overflow == TextOverflow::Clip || !is_last_visible_line {
858 return line.to_string();
859 }
860
861 let Some(width_limit) = max_width else {
862 return match options.overflow {
863 TextOverflow::Ellipsis => format!("{line}{ELLIPSIS}"),
864 TextOverflow::StartEllipsis => format!("{ELLIPSIS}{line}"),
865 TextOverflow::MiddleEllipsis => format!("{ELLIPSIS}{line}"),
866 TextOverflow::Clip | TextOverflow::Visible => line.to_string(),
867 };
868 };
869
870 match options.overflow {
871 TextOverflow::Clip | TextOverflow::Visible => line.to_string(),
872 TextOverflow::Ellipsis => fit_end_ellipsis(measurer, line, style, width_limit),
873 TextOverflow::StartEllipsis => {
874 if single_line_ellipsis {
875 fit_start_ellipsis(measurer, line, style, width_limit)
876 } else {
877 line.to_string()
878 }
879 }
880 TextOverflow::MiddleEllipsis => {
881 if single_line_ellipsis {
882 fit_middle_ellipsis(measurer, line, style, width_limit)
883 } else {
884 line.to_string()
885 }
886 }
887 }
888}
889
890fn fit_end_ellipsis<M: TextMeasurer + ?Sized>(
891 measurer: &M,
892 line: &str,
893 style: &TextStyle,
894 max_width: f32,
895) -> String {
896 if measurer
897 .measure(&crate::text::AnnotatedString::from(line), style)
898 .width
899 <= max_width + WRAP_EPSILON
900 {
901 return line.to_string();
902 }
903
904 let ellipsis_width = measurer
905 .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
906 .width;
907 if ellipsis_width > max_width + WRAP_EPSILON {
908 return String::new();
909 }
910
911 let boundaries = char_boundaries(line);
912 let mut low = 0usize;
913 let mut high = boundaries.len() - 1;
914 let mut best = 0usize;
915
916 while low <= high {
917 let mid = (low + high) / 2;
918 let prefix = &line[..boundaries[mid]];
919 let candidate = format!("{prefix}{ELLIPSIS}");
920 let width = measurer
921 .measure(
922 &crate::text::AnnotatedString::from(candidate.as_str()),
923 style,
924 )
925 .width;
926 if width <= max_width + WRAP_EPSILON {
927 best = mid;
928 low = mid + 1;
929 } else if mid == 0 {
930 break;
931 } else {
932 high = mid - 1;
933 }
934 }
935
936 format!("{}{}", &line[..boundaries[best]], ELLIPSIS)
937}
938
939fn fit_start_ellipsis<M: TextMeasurer + ?Sized>(
940 measurer: &M,
941 line: &str,
942 style: &TextStyle,
943 max_width: f32,
944) -> String {
945 if measurer
946 .measure(&crate::text::AnnotatedString::from(line), style)
947 .width
948 <= max_width + WRAP_EPSILON
949 {
950 return line.to_string();
951 }
952
953 let ellipsis_width = measurer
954 .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
955 .width;
956 if ellipsis_width > max_width + WRAP_EPSILON {
957 return String::new();
958 }
959
960 let boundaries = char_boundaries(line);
961 let mut low = 0usize;
962 let mut high = boundaries.len() - 1;
963 let mut best = boundaries.len() - 1;
964
965 while low <= high {
966 let mid = (low + high) / 2;
967 let suffix = &line[boundaries[mid]..];
968 let candidate = format!("{ELLIPSIS}{suffix}");
969 let width = measurer
970 .measure(
971 &crate::text::AnnotatedString::from(candidate.as_str()),
972 style,
973 )
974 .width;
975 if width <= max_width + WRAP_EPSILON {
976 best = mid;
977 if mid == 0 {
978 break;
979 }
980 high = mid - 1;
981 } else {
982 low = mid + 1;
983 }
984 }
985
986 format!("{ELLIPSIS}{}", &line[boundaries[best]..])
987}
988
989fn fit_middle_ellipsis<M: TextMeasurer + ?Sized>(
990 measurer: &M,
991 line: &str,
992 style: &TextStyle,
993 max_width: f32,
994) -> String {
995 if measurer
996 .measure(&crate::text::AnnotatedString::from(line), style)
997 .width
998 <= max_width + WRAP_EPSILON
999 {
1000 return line.to_string();
1001 }
1002
1003 let ellipsis_width = measurer
1004 .measure(&crate::text::AnnotatedString::from(ELLIPSIS), style)
1005 .width;
1006 if ellipsis_width > max_width + WRAP_EPSILON {
1007 return String::new();
1008 }
1009
1010 let boundaries = char_boundaries(line);
1011 let total_chars = boundaries.len().saturating_sub(1);
1012 for keep in (0..=total_chars).rev() {
1013 let keep_start = keep.div_ceil(2);
1014 let keep_end = keep / 2;
1015 let start = &line[..boundaries[keep_start]];
1016 let end_start = boundaries[total_chars.saturating_sub(keep_end)];
1017 let end = &line[end_start..];
1018 let candidate = format!("{start}{ELLIPSIS}{end}");
1019 if measurer
1020 .measure(
1021 &crate::text::AnnotatedString::from(candidate.as_str()),
1022 style,
1023 )
1024 .width
1025 <= max_width + WRAP_EPSILON
1026 {
1027 return candidate;
1028 }
1029 }
1030
1031 ELLIPSIS.to_string()
1032}
1033
1034fn char_boundaries(text: &str) -> Vec<usize> {
1035 let mut out = Vec::with_capacity(text.chars().count() + 1);
1036 out.push(0);
1037 for (idx, _) in text.char_indices() {
1038 if idx != 0 {
1039 out.push(idx);
1040 }
1041 }
1042 out.push(text.len());
1043 out
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049 use crate::text::{Hyphens, LineBreak, ParagraphStyle, TextUnit};
1050 use crate::text_layout_result::TextLayoutResult;
1051
1052 struct ContractBreakMeasurer {
1053 retreat: usize,
1054 }
1055
1056 impl TextMeasurer for ContractBreakMeasurer {
1057 fn measure(&self, text: &crate::text::AnnotatedString, style: &TextStyle) -> TextMetrics {
1058 MonospacedTextMeasurer.measure(
1059 &crate::text::AnnotatedString::from(text.text.as_str()),
1060 style,
1061 )
1062 }
1063
1064 fn get_offset_for_position(
1065 &self,
1066 text: &crate::text::AnnotatedString,
1067 style: &TextStyle,
1068 x: f32,
1069 y: f32,
1070 ) -> usize {
1071 MonospacedTextMeasurer.get_offset_for_position(
1072 &crate::text::AnnotatedString::from(text.text.as_str()),
1073 style,
1074 x,
1075 y,
1076 )
1077 }
1078
1079 fn get_cursor_x_for_offset(
1080 &self,
1081 text: &crate::text::AnnotatedString,
1082 style: &TextStyle,
1083 offset: usize,
1084 ) -> f32 {
1085 MonospacedTextMeasurer.get_cursor_x_for_offset(
1086 &crate::text::AnnotatedString::from(text.text.as_str()),
1087 style,
1088 offset,
1089 )
1090 }
1091
1092 fn layout(
1093 &self,
1094 text: &crate::text::AnnotatedString,
1095 style: &TextStyle,
1096 ) -> TextLayoutResult {
1097 MonospacedTextMeasurer.layout(
1098 &crate::text::AnnotatedString::from(text.text.as_str()),
1099 style,
1100 )
1101 }
1102
1103 fn choose_auto_hyphen_break(
1104 &self,
1105 _line: &str,
1106 _style: &TextStyle,
1107 _segment_start_char: usize,
1108 measured_break_char: usize,
1109 ) -> Option<usize> {
1110 measured_break_char.checked_sub(self.retreat)
1111 }
1112 }
1113
1114 fn style_with_line_break(line_break: LineBreak) -> TextStyle {
1115 TextStyle {
1116 span_style: crate::text::SpanStyle {
1117 font_size: TextUnit::Sp(10.0),
1118 ..Default::default()
1119 },
1120 paragraph_style: ParagraphStyle {
1121 line_break,
1122 ..Default::default()
1123 },
1124 }
1125 }
1126
1127 fn style_with_hyphens(hyphens: Hyphens) -> TextStyle {
1128 TextStyle {
1129 span_style: crate::text::SpanStyle {
1130 font_size: TextUnit::Sp(10.0),
1131 ..Default::default()
1132 },
1133 paragraph_style: ParagraphStyle {
1134 hyphens,
1135 ..Default::default()
1136 },
1137 }
1138 }
1139
1140 #[test]
1141 fn text_layout_options_wraps_and_limits_lines() {
1142 let style = TextStyle {
1143 span_style: crate::text::SpanStyle {
1144 font_size: TextUnit::Sp(10.0),
1145 ..Default::default()
1146 },
1147 ..Default::default()
1148 };
1149 let options = TextLayoutOptions {
1150 overflow: TextOverflow::Clip,
1151 soft_wrap: true,
1152 max_lines: 2,
1153 min_lines: 1,
1154 };
1155
1156 let prepared = prepare_text_layout(
1157 &crate::text::AnnotatedString::from("A B C D E F"),
1158 &style,
1159 options,
1160 Some(24.0), );
1162
1163 assert!(prepared.did_overflow);
1164 assert!(prepared.metrics.line_count <= 2);
1165 }
1166
1167 #[test]
1168 fn text_layout_options_end_ellipsis_applies() {
1169 let style = TextStyle {
1170 span_style: crate::text::SpanStyle {
1171 font_size: TextUnit::Sp(10.0),
1172 ..Default::default()
1173 },
1174 ..Default::default()
1175 };
1176 let options = TextLayoutOptions {
1177 overflow: TextOverflow::Ellipsis,
1178 soft_wrap: false,
1179 max_lines: 1,
1180 min_lines: 1,
1181 };
1182
1183 let prepared = prepare_text_layout(
1184 &crate::text::AnnotatedString::from("Long long line"),
1185 &style,
1186 options,
1187 Some(20.0),
1188 );
1189 assert!(prepared.did_overflow);
1190 assert!(prepared.text.text.contains(ELLIPSIS));
1191 }
1192
1193 #[test]
1194 fn text_layout_options_visible_keeps_full_text() {
1195 let style = TextStyle {
1196 span_style: crate::text::SpanStyle {
1197 font_size: TextUnit::Sp(10.0),
1198 ..Default::default()
1199 },
1200 ..Default::default()
1201 };
1202 let options = TextLayoutOptions {
1203 overflow: TextOverflow::Visible,
1204 soft_wrap: false,
1205 max_lines: 1,
1206 min_lines: 1,
1207 };
1208
1209 let input = "This should remain unchanged";
1210 let prepared = prepare_text_layout(
1211 &crate::text::AnnotatedString::from(input),
1212 &style,
1213 options,
1214 Some(10.0),
1215 );
1216 assert_eq!(prepared.text.text, input);
1217 }
1218
1219 #[test]
1220 fn text_layout_options_respects_min_lines() {
1221 let style = TextStyle {
1222 span_style: crate::text::SpanStyle {
1223 font_size: TextUnit::Sp(10.0),
1224 ..Default::default()
1225 },
1226 ..Default::default()
1227 };
1228 let options = TextLayoutOptions {
1229 overflow: TextOverflow::Clip,
1230 soft_wrap: true,
1231 max_lines: 4,
1232 min_lines: 3,
1233 };
1234
1235 let prepared = prepare_text_layout(
1236 &crate::text::AnnotatedString::from("short"),
1237 &style,
1238 options,
1239 Some(100.0),
1240 );
1241 assert_eq!(prepared.metrics.line_count, 3);
1242 }
1243
1244 #[test]
1245 fn text_layout_options_middle_ellipsis_for_single_line() {
1246 let style = TextStyle {
1247 span_style: crate::text::SpanStyle {
1248 font_size: TextUnit::Sp(10.0),
1249 ..Default::default()
1250 },
1251 ..Default::default()
1252 };
1253 let options = TextLayoutOptions {
1254 overflow: TextOverflow::MiddleEllipsis,
1255 soft_wrap: false,
1256 max_lines: 1,
1257 min_lines: 1,
1258 };
1259
1260 let prepared = prepare_text_layout(
1261 &crate::text::AnnotatedString::from("abcdefghijk"),
1262 &style,
1263 options,
1264 Some(24.0),
1265 );
1266 assert!(prepared.text.text.contains(ELLIPSIS));
1267 assert!(prepared.did_overflow);
1268 }
1269
1270 #[test]
1271 fn text_layout_options_does_not_wrap_on_tiny_width_delta() {
1272 let style = TextStyle {
1273 span_style: crate::text::SpanStyle {
1274 font_size: TextUnit::Sp(10.0),
1275 ..Default::default()
1276 },
1277 ..Default::default()
1278 };
1279 let options = TextLayoutOptions {
1280 overflow: TextOverflow::Clip,
1281 soft_wrap: true,
1282 max_lines: usize::MAX,
1283 min_lines: 1,
1284 };
1285
1286 let text = "if counter % 2 == 0";
1287 let exact_width = measure_text(&crate::text::AnnotatedString::from(text), &style).width;
1288 let prepared = prepare_text_layout(
1289 &crate::text::AnnotatedString::from(text),
1290 &style,
1291 options,
1292 Some(exact_width - 0.1),
1293 );
1294
1295 assert!(
1296 !prepared.text.text.contains('\n'),
1297 "unexpected line split: {:?}",
1298 prepared.text
1299 );
1300 }
1301
1302 #[test]
1303 fn line_break_mode_changes_wrap_strategy_contract() {
1304 let text = "This is an example text";
1305 let options = TextLayoutOptions {
1306 overflow: TextOverflow::Clip,
1307 soft_wrap: true,
1308 max_lines: usize::MAX,
1309 min_lines: 1,
1310 };
1311
1312 let simple = prepare_text_layout(
1313 &crate::text::AnnotatedString::from(text),
1314 &style_with_line_break(LineBreak::Simple),
1315 options,
1316 Some(120.0),
1317 );
1318 let heading = prepare_text_layout(
1319 &crate::text::AnnotatedString::from(text),
1320 &style_with_line_break(LineBreak::Heading),
1321 options,
1322 Some(120.0),
1323 );
1324 let paragraph = prepare_text_layout(
1325 &crate::text::AnnotatedString::from(text),
1326 &style_with_line_break(LineBreak::Paragraph),
1327 options,
1328 Some(50.0),
1329 );
1330
1331 assert_eq!(
1332 simple.text.text.lines().collect::<Vec<_>>(),
1333 vec!["This is an example", "text"]
1334 );
1335 assert_eq!(
1336 heading.text.text.lines().collect::<Vec<_>>(),
1337 vec!["This is an", "example text"]
1338 );
1339 assert_eq!(
1340 paragraph.text.text.lines().collect::<Vec<_>>(),
1341 vec!["This", "is an", "example", "text"]
1342 );
1343 }
1344
1345 #[test]
1346 fn hyphens_mode_changes_wrap_strategy_contract() {
1347 let text = "Transformation";
1348 let options = TextLayoutOptions {
1349 overflow: TextOverflow::Clip,
1350 soft_wrap: true,
1351 max_lines: usize::MAX,
1352 min_lines: 1,
1353 };
1354
1355 let auto = prepare_text_layout(
1356 &crate::text::AnnotatedString::from(text),
1357 &style_with_hyphens(Hyphens::Auto),
1358 options,
1359 Some(24.0),
1360 );
1361 let none = prepare_text_layout(
1362 &crate::text::AnnotatedString::from(text),
1363 &style_with_hyphens(Hyphens::None),
1364 options,
1365 Some(24.0),
1366 );
1367
1368 assert_eq!(
1369 auto.text.text.lines().collect::<Vec<_>>(),
1370 vec!["Tran", "sfor", "ma", "tion"]
1371 );
1372 assert_eq!(
1373 none.text.text.lines().collect::<Vec<_>>(),
1374 vec!["Tran", "sfor", "mati", "on"]
1375 );
1376 assert!(
1377 !auto.text.text.contains('-'),
1378 "automatic hyphenation should influence breaks without mutating source text content"
1379 );
1380 }
1381
1382 #[test]
1383 fn hyphens_auto_uses_measurer_hyphen_contract_when_valid() {
1384 let text = "Transformation";
1385 let style = style_with_hyphens(Hyphens::Auto);
1386 let options = TextLayoutOptions {
1387 overflow: TextOverflow::Clip,
1388 soft_wrap: true,
1389 max_lines: usize::MAX,
1390 min_lines: 1,
1391 };
1392
1393 let prepared = prepare_text_layout_fallback(
1394 &ContractBreakMeasurer { retreat: 1 },
1395 &crate::text::AnnotatedString::from(text),
1396 &style,
1397 options,
1398 Some(24.0),
1399 );
1400
1401 assert_eq!(
1402 prepared.text.text.lines().collect::<Vec<_>>(),
1403 vec!["Tra", "nsf", "orm", "ati", "on"]
1404 );
1405 }
1406
1407 #[test]
1408 fn hyphens_auto_falls_back_when_measurer_hyphen_contract_is_invalid() {
1409 let text = "Transformation";
1410 let style = style_with_hyphens(Hyphens::Auto);
1411 let options = TextLayoutOptions {
1412 overflow: TextOverflow::Clip,
1413 soft_wrap: true,
1414 max_lines: usize::MAX,
1415 min_lines: 1,
1416 };
1417
1418 let prepared = prepare_text_layout_fallback(
1419 &ContractBreakMeasurer { retreat: 10 },
1420 &crate::text::AnnotatedString::from(text),
1421 &style,
1422 options,
1423 Some(24.0),
1424 );
1425
1426 assert_eq!(
1427 prepared.text.text.lines().collect::<Vec<_>>(),
1428 vec!["Tran", "sfor", "ma", "tion"]
1429 );
1430 }
1431
1432 #[test]
1433 fn transformed_text_keeps_span_ranges_within_display_bounds() {
1434 let style = TextStyle {
1435 span_style: crate::text::SpanStyle {
1436 font_size: TextUnit::Sp(10.0),
1437 ..Default::default()
1438 },
1439 ..Default::default()
1440 };
1441 let options = TextLayoutOptions {
1442 overflow: TextOverflow::Ellipsis,
1443 soft_wrap: false,
1444 max_lines: 1,
1445 min_lines: 1,
1446 };
1447 let annotated = crate::text::AnnotatedString::builder()
1448 .push_style(crate::text::SpanStyle {
1449 font_weight: Some(crate::text::FontWeight::BOLD),
1450 ..Default::default()
1451 })
1452 .append("Styled overflow text sample")
1453 .pop()
1454 .to_annotated_string();
1455
1456 let prepared = prepare_text_layout(&annotated, &style, options, Some(40.0));
1457 assert!(prepared.did_overflow);
1458 for span in &prepared.text.span_styles {
1459 assert!(span.range.start < span.range.end);
1460 assert!(span.range.end <= prepared.text.text.len());
1461 assert!(prepared.text.text.is_char_boundary(span.range.start));
1462 assert!(prepared.text.text.is_char_boundary(span.range.end));
1463 }
1464 }
1465
1466 #[test]
1467 fn wrapped_text_splits_styles_around_inserted_newlines() {
1468 let style = TextStyle {
1469 span_style: crate::text::SpanStyle {
1470 font_size: TextUnit::Sp(10.0),
1471 ..Default::default()
1472 },
1473 ..Default::default()
1474 };
1475 let options = TextLayoutOptions {
1476 overflow: TextOverflow::Clip,
1477 soft_wrap: true,
1478 max_lines: usize::MAX,
1479 min_lines: 1,
1480 };
1481 let annotated = crate::text::AnnotatedString::builder()
1482 .push_style(crate::text::SpanStyle {
1483 text_decoration: Some(crate::text::TextDecoration::UNDERLINE),
1484 ..Default::default()
1485 })
1486 .append("Wrapped style text example")
1487 .pop()
1488 .to_annotated_string();
1489
1490 let prepared = prepare_text_layout(&annotated, &style, options, Some(32.0));
1491 assert!(prepared.text.text.contains('\n'));
1492 assert!(!prepared.text.span_styles.is_empty());
1493 for span in &prepared.text.span_styles {
1494 assert!(span.range.end <= prepared.text.text.len());
1495 }
1496 }
1497
1498 #[test]
1499 fn mixed_font_size_segments_wrap_without_truncation() {
1500 let style = TextStyle {
1501 span_style: crate::text::SpanStyle {
1502 font_size: TextUnit::Sp(14.0),
1503 ..Default::default()
1504 },
1505 ..Default::default()
1506 };
1507 let options = TextLayoutOptions {
1508 overflow: TextOverflow::Clip,
1509 soft_wrap: true,
1510 max_lines: usize::MAX,
1511 min_lines: 1,
1512 };
1513 let annotated = crate::text::AnnotatedString::builder()
1514 .append("You can also ")
1515 .push_style(crate::text::SpanStyle {
1516 font_size: TextUnit::Sp(22.0),
1517 ..Default::default()
1518 })
1519 .append("change font size")
1520 .pop()
1521 .append(" dynamically mid-sentence!")
1522 .to_annotated_string();
1523
1524 let prepared = prepare_text_layout(&annotated, &style, options, Some(260.0));
1525 assert!(prepared.text.text.contains('\n'));
1526 assert!(prepared.text.text.contains("mid-sentence!"));
1527 assert!(!prepared.did_overflow);
1528 }
1529}