1use crate::{FontId, Pixels, SharedString, TextRun, TextSystem, px};
2use open_gpui_collections::HashMap;
3use std::{borrow::Cow, iter, sync::Arc};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum TruncateFrom {
8 Start,
10 End,
12}
13
14pub struct LineWrapper {
16 text_system: Arc<TextSystem>,
17 pub(crate) font_id: FontId,
18 pub(crate) font_size: Pixels,
19 cached_ascii_char_widths: [Option<Pixels>; 128],
20 cached_other_char_widths: HashMap<char, Pixels>,
21}
22
23impl LineWrapper {
24 pub const MAX_INDENT: u32 = 256;
26
27 pub(crate) fn new(font_id: FontId, font_size: Pixels, text_system: Arc<TextSystem>) -> Self {
28 Self {
29 text_system,
30 font_id,
31 font_size,
32 cached_ascii_char_widths: [None; 128],
33 cached_other_char_widths: HashMap::default(),
34 }
35 }
36
37 pub fn wrap_line<'a>(
39 &'a mut self,
40 fragments: &'a [LineFragment],
41 wrap_width: Pixels,
42 ) -> impl Iterator<Item = Boundary> + 'a {
43 let mut width = px(0.);
44 let mut first_non_whitespace_ix = None;
45 let mut indent = None;
46 let mut last_candidate_ix = 0;
47 let mut last_candidate_width = px(0.);
48 let mut last_wrap_ix = 0;
49 let mut prev_c = '\0';
50 let mut index = 0;
51 let mut candidates = fragments
52 .iter()
53 .flat_map(move |fragment| fragment.wrap_boundary_candidates())
54 .peekable();
55 iter::from_fn(move || {
56 for candidate in candidates.by_ref() {
57 let ix = index;
58 index += candidate.len_utf8();
59 let mut new_prev_c = prev_c;
60 let item_width = match candidate {
61 WrapBoundaryCandidate::Char { character: c } => {
62 if c == '\n' {
63 continue;
64 }
65
66 if Self::is_word_char(c) {
67 if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() {
68 last_candidate_ix = ix;
69 last_candidate_width = width;
70 }
71 } else {
72 if c != ' ' && first_non_whitespace_ix.is_some() {
74 last_candidate_ix = ix;
75 last_candidate_width = width;
76 }
77 }
78
79 if c != ' ' && first_non_whitespace_ix.is_none() {
80 first_non_whitespace_ix = Some(ix);
81 }
82
83 new_prev_c = c;
84
85 self.width_for_char(c)
86 }
87 WrapBoundaryCandidate::Element {
88 width: element_width,
89 ..
90 } => {
91 if prev_c == ' ' && first_non_whitespace_ix.is_some() {
92 last_candidate_ix = ix;
93 last_candidate_width = width;
94 }
95
96 if first_non_whitespace_ix.is_none() {
97 first_non_whitespace_ix = Some(ix);
98 }
99
100 element_width
101 }
102 };
103
104 width += item_width;
105 if width > wrap_width && ix > last_wrap_ix {
106 if let (None, Some(first_non_whitespace_ix)) = (indent, first_non_whitespace_ix)
107 {
108 indent = Some(
109 Self::MAX_INDENT.min((first_non_whitespace_ix - last_wrap_ix) as u32),
110 );
111 }
112
113 if last_candidate_ix > 0 {
114 last_wrap_ix = last_candidate_ix;
115 width -= last_candidate_width;
116 last_candidate_ix = 0;
117 } else {
118 last_wrap_ix = ix;
119 width = item_width;
120 }
121
122 if let Some(indent) = indent {
123 width += self.width_for_char(' ') * indent as f32;
124 }
125
126 return Some(Boundary::new(last_wrap_ix, indent.unwrap_or(0)));
127 }
128
129 prev_c = new_prev_c;
130 }
131
132 None
133 })
134 }
135
136 pub fn should_truncate_line(
140 &mut self,
141 line: &str,
142 truncate_width: Pixels,
143 truncation_affix: &str,
144 truncate_from: TruncateFrom,
145 ) -> Option<usize> {
146 let mut width = px(0.);
147 let suffix_width = truncation_affix
148 .chars()
149 .map(|c| self.width_for_char(c))
150 .fold(px(0.0), |a, x| a + x);
151 let mut truncate_ix = 0;
152
153 match truncate_from {
154 TruncateFrom::Start => {
155 for (ix, c) in line.char_indices().rev() {
156 if width + suffix_width < truncate_width {
157 truncate_ix = ix;
158 }
159
160 let char_width = self.width_for_char(c);
161 width += char_width;
162
163 if width.floor() > truncate_width {
164 return Some(truncate_ix);
165 }
166 }
167 }
168 TruncateFrom::End => {
169 for (ix, c) in line.char_indices() {
170 if width + suffix_width < truncate_width {
171 truncate_ix = ix;
172 }
173
174 let char_width = self.width_for_char(c);
175 width += char_width;
176
177 if width.floor() > truncate_width {
178 return Some(truncate_ix);
179 }
180 }
181 }
182 }
183
184 None
185 }
186
187 pub fn truncate_line<'a>(
189 &mut self,
190 line: SharedString,
191 truncate_width: Pixels,
192 truncation_affix: &str,
193 runs: &'a [TextRun],
194 truncate_from: TruncateFrom,
195 ) -> (SharedString, Cow<'a, [TextRun]>) {
196 if let Some(truncate_ix) =
197 self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from)
198 {
199 let result = match truncate_from {
200 TruncateFrom::Start => SharedString::from(format!(
201 "{truncation_affix}{}",
202 &line[line.ceil_char_boundary(truncate_ix + 1)..]
203 )),
204 TruncateFrom::End => SharedString::from(format!(
205 "{}{truncation_affix}",
206 line[..truncate_ix]
207 .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
208 )),
209 };
210 let mut runs = runs.to_vec();
211 update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from);
212 (result, Cow::Owned(runs))
213 } else {
214 (line, Cow::Borrowed(runs))
215 }
216 }
217
218 pub fn truncate_wrapped_line<'a>(
228 &mut self,
229 text: SharedString,
230 wrap_width: Pixels,
231 max_lines: usize,
232 truncation_affix: &str,
233 runs: &'a [TextRun],
234 truncate_from: TruncateFrom,
235 ) -> (SharedString, Cow<'a, [TextRun]>) {
236 if max_lines <= 1 || truncate_from == TruncateFrom::Start {
237 return self.truncate_line(
238 text,
239 wrap_width * max_lines,
240 truncation_affix,
241 runs,
242 truncate_from,
243 );
244 }
245
246 let affix_width: Pixels = truncation_affix
247 .chars()
248 .map(|c| self.width_for_char(c))
249 .sum();
250
251 let mut width = px(0.);
252 let mut line = 0usize;
253 let mut first_non_whitespace_ix = None;
254 let mut last_candidate_ix = 0usize;
255 let mut last_candidate_width = px(0.);
256 let mut last_wrap_ix = 0usize;
257 let mut prev_c = '\0';
258 let mut indent: Option<u32> = None;
259 let mut truncate_ix = 0usize;
260
261 for (ix, c) in text.char_indices() {
262 if c == '\n' {
263 if line >= max_lines - 1 && !text[ix + 1..].trim().is_empty() {
264 let truncated = text[..truncate_ix]
267 .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
268 let result = SharedString::from(format!("{truncated}{truncation_affix}"));
269 let mut runs = runs.to_vec();
270 update_runs_after_truncation(
271 &result,
272 truncation_affix,
273 &mut runs,
274 TruncateFrom::End,
275 );
276 return (result, Cow::Owned(runs));
277 }
278
279 line += 1;
281 width = px(0.);
282 first_non_whitespace_ix = None;
283 last_candidate_ix = 0;
284 last_candidate_width = px(0.);
285 last_wrap_ix = ix + 1;
286 prev_c = '\0';
287 indent = None;
288 truncate_ix = ix + 1;
289 continue;
290 }
291
292 let char_width = self.width_for_char(c);
293
294 if Self::is_word_char(c) {
295 if prev_c == ' ' && first_non_whitespace_ix.is_some() {
296 last_candidate_ix = ix;
297 last_candidate_width = width;
298 }
299 } else if c != ' ' && first_non_whitespace_ix.is_some() {
300 last_candidate_ix = ix;
301 last_candidate_width = width;
302 }
303
304 if c != ' ' && first_non_whitespace_ix.is_none() {
305 first_non_whitespace_ix = Some(ix);
306 }
307
308 width += char_width;
309
310 if line < max_lines - 1 {
311 if width > wrap_width && ix > last_wrap_ix {
313 if let (None, Some(first_nw)) = (indent, first_non_whitespace_ix) {
314 indent = Some(Self::MAX_INDENT.min((first_nw - last_wrap_ix) as u32));
315 }
316
317 if last_candidate_ix > last_wrap_ix {
318 last_wrap_ix = last_candidate_ix;
319 width -= last_candidate_width;
320 last_candidate_ix = 0;
321 } else {
322 last_wrap_ix = ix;
323 width = char_width;
324 }
325
326 if let Some(ind) = indent {
327 width += self.width_for_char(' ') * ind as f32;
328 }
329
330 line += 1;
331 truncate_ix = last_wrap_ix;
332 }
333 } else {
334 if width + affix_width <= wrap_width {
337 truncate_ix = ix + c.len_utf8();
338 }
339
340 if width > wrap_width {
341 let truncated = text[..truncate_ix]
342 .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
343 let result = SharedString::from(format!("{truncated}{truncation_affix}"));
344 let mut runs = runs.to_vec();
345 update_runs_after_truncation(
346 &result,
347 truncation_affix,
348 &mut runs,
349 TruncateFrom::End,
350 );
351 return (result, Cow::Owned(runs));
352 }
353 }
354
355 prev_c = c;
356 }
357
358 (text, Cow::Borrowed(runs))
360 }
361
362 pub(crate) fn is_word_char(c: char) -> bool {
365 c.is_ascii_alphanumeric() ||
367 matches!(c, '\u{00C0}'..='\u{00FF}') ||
371 matches!(c, '\u{0100}'..='\u{017F}') ||
374 matches!(c, '\u{0180}'..='\u{024F}') ||
377 matches!(c, '\u{0400}'..='\u{04FF}') ||
380
381 matches!(c, '\u{1E00}'..='\u{1EFF}') || matches!(c, '\u{0300}'..='\u{036F}') || matches!(c, '\u{0980}'..='\u{09FF}') ||
387
388 matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') ||
393 matches!(c, '⋯')
395 }
396
397 #[inline(always)]
398 fn width_for_char(&mut self, c: char) -> Pixels {
399 if (c as u32) < 128 {
400 if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] {
401 cached_width
402 } else {
403 let width = self
404 .text_system
405 .layout_width(self.font_id, self.font_size, c);
406 self.cached_ascii_char_widths[c as usize] = Some(width);
407 width
408 }
409 } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) {
410 *cached_width
411 } else {
412 let width = self
413 .text_system
414 .layout_width(self.font_id, self.font_size, c);
415 self.cached_other_char_widths.insert(c, width);
416 width
417 }
418 }
419}
420
421fn update_runs_after_truncation(
422 result: &str,
423 ellipsis: &str,
424 runs: &mut Vec<TextRun>,
425 truncate_from: TruncateFrom,
426) {
427 let mut truncate_at = result.len() - ellipsis.len();
428 match truncate_from {
429 TruncateFrom::Start => {
430 for (run_index, run) in runs.iter_mut().enumerate().rev() {
431 if run.len <= truncate_at {
432 truncate_at -= run.len;
433 } else {
434 run.len = truncate_at + ellipsis.len();
435 runs.splice(..run_index, std::iter::empty());
436 break;
437 }
438 }
439 }
440 TruncateFrom::End => {
441 for (run_index, run) in runs.iter_mut().enumerate() {
442 if run.len <= truncate_at {
443 truncate_at -= run.len;
444 } else {
445 run.len = truncate_at + ellipsis.len();
446 runs.truncate(run_index + 1);
447 break;
448 }
449 }
450 }
451 }
452}
453
454pub enum LineFragment<'a> {
456 Text {
458 text: &'a str,
460 },
461 Element {
463 width: Pixels,
465 len_utf8: usize,
467 },
468}
469
470impl<'a> LineFragment<'a> {
471 pub fn text(text: &'a str) -> Self {
473 LineFragment::Text { text }
474 }
475
476 pub fn element(width: Pixels, len_utf8: usize) -> Self {
478 LineFragment::Element { width, len_utf8 }
479 }
480
481 fn wrap_boundary_candidates(&self) -> impl Iterator<Item = WrapBoundaryCandidate> {
482 let text = match self {
483 LineFragment::Text { text } => text,
484 LineFragment::Element { .. } => "\0",
485 };
486 text.chars().map(move |character| {
487 if let LineFragment::Element { width, len_utf8 } = self {
488 WrapBoundaryCandidate::Element {
489 width: *width,
490 len_utf8: *len_utf8,
491 }
492 } else {
493 WrapBoundaryCandidate::Char { character }
494 }
495 })
496 }
497}
498
499enum WrapBoundaryCandidate {
500 Char { character: char },
501 Element { width: Pixels, len_utf8: usize },
502}
503
504impl WrapBoundaryCandidate {
505 pub fn len_utf8(&self) -> usize {
506 match self {
507 WrapBoundaryCandidate::Char { character } => character.len_utf8(),
508 WrapBoundaryCandidate::Element { len_utf8: len, .. } => *len,
509 }
510 }
511}
512
513#[derive(Copy, Clone, Debug, PartialEq, Eq)]
515pub struct Boundary {
516 pub ix: usize,
518 pub next_indent: u32,
520}
521
522impl Boundary {
523 fn new(ix: usize, next_indent: u32) -> Self {
524 Self { ix, next_indent }
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font};
532 #[cfg(target_os = "macos")]
533 use crate::{TextRun, WindowTextSystem, WrapBoundary};
534
535 fn build_wrapper() -> LineWrapper {
536 let dispatcher = TestDispatcher::new(0);
537 let cx = TestAppContext::build(dispatcher, None);
538 let id = cx.text_system().resolve_font(&font(".ZedMono"));
539 LineWrapper::new(id, px(16.), cx.text_system().clone())
540 }
541
542 fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
543 input_run_len
544 .iter()
545 .map(|run_len| TextRun {
546 len: *run_len,
547 font: Font {
548 family: "Dummy".into(),
549 features: FontFeatures::default(),
550 fallbacks: None,
551 weight: FontWeight::default(),
552 style: FontStyle::Normal,
553 },
554 ..Default::default()
555 })
556 .collect()
557 }
558
559 #[test]
560 fn test_wrap_line() {
561 let mut wrapper = build_wrapper();
562
563 assert_eq!(
564 wrapper
565 .wrap_line(&[LineFragment::text("aa bbb cccc ddddd eeee")], px(72.))
566 .collect::<Vec<_>>(),
567 &[
568 Boundary::new(7, 0),
569 Boundary::new(12, 0),
570 Boundary::new(18, 0)
571 ],
572 );
573 assert_eq!(
574 wrapper
575 .wrap_line(&[LineFragment::text("aaa aaaaaaaaaaaaaaaaaa")], px(72.0))
576 .collect::<Vec<_>>(),
577 &[
578 Boundary::new(4, 0),
579 Boundary::new(11, 0),
580 Boundary::new(18, 0)
581 ],
582 );
583 assert_eq!(
584 wrapper
585 .wrap_line(&[LineFragment::text(" aaaaaaa")], px(72.))
586 .collect::<Vec<_>>(),
587 &[
588 Boundary::new(7, 5),
589 Boundary::new(9, 5),
590 Boundary::new(11, 5),
591 ]
592 );
593 assert_eq!(
594 wrapper
595 .wrap_line(
596 &[LineFragment::text(" ")],
597 px(72.)
598 )
599 .collect::<Vec<_>>(),
600 &[
601 Boundary::new(7, 0),
602 Boundary::new(14, 0),
603 Boundary::new(21, 0)
604 ]
605 );
606 assert_eq!(
607 wrapper
608 .wrap_line(&[LineFragment::text(" aaaaaaaaaaaaaa")], px(72.))
609 .collect::<Vec<_>>(),
610 &[
611 Boundary::new(7, 0),
612 Boundary::new(14, 3),
613 Boundary::new(18, 3),
614 Boundary::new(22, 3),
615 ]
616 );
617
618 assert_eq!(
620 wrapper
621 .wrap_line(
622 &[
623 LineFragment::text("aa bbb "),
624 LineFragment::text("cccc ddddd eeee")
625 ],
626 px(72.)
627 )
628 .collect::<Vec<_>>(),
629 &[
630 Boundary::new(7, 0),
631 Boundary::new(12, 0),
632 Boundary::new(18, 0)
633 ],
634 );
635
636 assert_eq!(
638 wrapper
639 .wrap_line(
640 &[
641 LineFragment::text("aa "),
642 LineFragment::element(px(20.), 1),
643 LineFragment::text(" bbb "),
644 LineFragment::element(px(30.), 1),
645 LineFragment::text(" cccc")
646 ],
647 px(72.)
648 )
649 .collect::<Vec<_>>(),
650 &[
651 Boundary::new(5, 0),
652 Boundary::new(9, 0),
653 Boundary::new(11, 0)
654 ],
655 );
656
657 assert_eq!(
659 wrapper
660 .wrap_line(
661 &[
662 LineFragment::element(px(50.), 1),
663 LineFragment::text(" aaaa bbbb cccc dddd")
664 ],
665 px(72.)
666 )
667 .collect::<Vec<_>>(),
668 &[
669 Boundary::new(2, 0),
670 Boundary::new(7, 0),
671 Boundary::new(12, 0),
672 Boundary::new(17, 0)
673 ],
674 );
675
676 assert_eq!(
678 wrapper
679 .wrap_line(
680 &[
681 LineFragment::text("short text "),
682 LineFragment::element(px(100.), 1),
683 LineFragment::text(" more text")
684 ],
685 px(72.)
686 )
687 .collect::<Vec<_>>(),
688 &[
689 Boundary::new(6, 0),
690 Boundary::new(11, 0),
691 Boundary::new(12, 0),
692 Boundary::new(18, 0)
693 ],
694 );
695 }
696
697 #[test]
698 fn test_truncate_line_end() {
699 let mut wrapper = build_wrapper();
700
701 fn perform_test(
702 wrapper: &mut LineWrapper,
703 text: &'static str,
704 expected: &'static str,
705 ellipsis: &str,
706 ) {
707 let dummy_run_lens = vec![text.len()];
708 let dummy_runs = generate_test_runs(&dummy_run_lens);
709 let (result, dummy_runs) = wrapper.truncate_line(
710 text.into(),
711 px(220.),
712 ellipsis,
713 &dummy_runs,
714 TruncateFrom::End,
715 );
716 assert_eq!(result, expected);
717 assert_eq!(dummy_runs.first().unwrap().len, result.len());
718 }
719
720 perform_test(
721 &mut wrapper,
722 "aa bbb cccc ddddd eeee ffff gggg",
723 "aa bbb cccc ddddd eeee",
724 "",
725 );
726 perform_test(
727 &mut wrapper,
728 "aa bbb cccc ddddd eeee ffff gggg",
729 "aa bbb cccc ddddd eee…",
730 "…",
731 );
732 perform_test(
733 &mut wrapper,
734 "aa bbb cccc ddddd eeee ffff gggg",
735 "aa bbb cccc dddd......",
736 "......",
737 );
738 perform_test(
739 &mut wrapper,
740 "aa bbb cccc 🦀🦀🦀🦀🦀 eeee ffff gggg",
741 "aa bbb cccc 🦀🦀🦀🦀…",
742 "…",
743 );
744 }
745
746 #[test]
747 fn test_truncate_line_start() {
748 let mut wrapper = build_wrapper();
749
750 #[track_caller]
751 fn perform_test(
752 wrapper: &mut LineWrapper,
753 text: &'static str,
754 expected: &'static str,
755 ellipsis: &str,
756 ) {
757 let dummy_run_lens = vec![text.len()];
758 let dummy_runs = generate_test_runs(&dummy_run_lens);
759 let (result, dummy_runs) = wrapper.truncate_line(
760 text.into(),
761 px(220.),
762 ellipsis,
763 &dummy_runs,
764 TruncateFrom::Start,
765 );
766 assert_eq!(result, expected);
767 assert_eq!(dummy_runs.first().unwrap().len, result.len());
768 }
769
770 perform_test(
771 &mut wrapper,
772 "aaaa bbbb cccc ddddd eeee fff gg",
773 "cccc ddddd eeee fff gg",
774 "",
775 );
776 perform_test(
777 &mut wrapper,
778 "aaaa bbbb cccc ddddd eeee fff gg",
779 "…ccc ddddd eeee fff gg",
780 "…",
781 );
782 perform_test(
783 &mut wrapper,
784 "aaaa bbbb cccc ddddd eeee fff gg",
785 "......dddd eeee fff gg",
786 "......",
787 );
788 perform_test(
789 &mut wrapper,
790 "aaaa bbbb cccc 🦀🦀🦀🦀🦀 eeee fff gg",
791 "…🦀🦀🦀🦀 eeee fff gg",
792 "…",
793 );
794 }
795
796 #[test]
797 fn test_truncate_multiple_runs_end() {
798 let mut wrapper = build_wrapper();
799
800 fn perform_test(
801 wrapper: &mut LineWrapper,
802 text: &'static str,
803 expected: &str,
804 run_lens: &[usize],
805 result_run_len: &[usize],
806 line_width: Pixels,
807 ) {
808 let dummy_runs = generate_test_runs(run_lens);
809 let (result, dummy_runs) =
810 wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End);
811 assert_eq!(result, expected);
812 for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
813 assert_eq!(run.len, *result_len);
814 }
815 }
816 perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
823 perform_test(
831 &mut wrapper,
832 "abcdefghijkl",
833 "abcdef…",
834 &[4, 4, 4],
835 &[4, 5],
836 px(70.),
837 );
838 perform_test(
846 &mut wrapper,
847 "abcdefghijkl",
848 "abcdefgh…",
849 &[4, 4, 4],
850 &[4, 4, 3],
851 px(90.),
852 );
853 }
854
855 #[test]
856 fn test_truncate_multiple_runs_start() {
857 let mut wrapper = build_wrapper();
858
859 #[track_caller]
860 fn perform_test(
861 wrapper: &mut LineWrapper,
862 text: &'static str,
863 expected: &str,
864 run_lens: &[usize],
865 result_run_len: &[usize],
866 line_width: Pixels,
867 ) {
868 let dummy_runs = generate_test_runs(run_lens);
869 let (result, dummy_runs) = wrapper.truncate_line(
870 text.into(),
871 line_width,
872 "…",
873 &dummy_runs,
874 TruncateFrom::Start,
875 );
876 assert_eq!(result, expected);
877 for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
878 assert_eq!(run.len, *result_len);
879 }
880 }
881 perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
888 perform_test(
896 &mut wrapper,
897 "abcdefghijkl",
898 "…ghijkl",
899 &[4, 4, 4],
900 &[5, 4],
901 px(70.),
902 );
903 perform_test(
911 &mut wrapper,
912 "abcdefghijkl",
913 "…efghijkl",
914 &[4, 4, 4],
915 &[3, 4, 4],
916 px(90.),
917 );
918 }
919
920 #[test]
921 fn test_update_run_after_truncation_end() {
922 fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
923 let mut dummy_runs = generate_test_runs(run_lens);
924 update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End);
925 for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
926 assert_eq!(run.len, *result_len);
927 }
928 }
929 perform_test("abcd…", &[12], &[7]);
936 perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
944 perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
952 }
953
954 #[test]
955 fn test_is_word_char() {
956 #[track_caller]
957 fn assert_word(word: &str) {
958 for c in word.chars() {
959 assert!(
960 LineWrapper::is_word_char(c),
961 "assertion failed for '{}' (unicode 0x{:x})",
962 c,
963 c as u32
964 );
965 }
966 }
967
968 #[track_caller]
969 fn assert_not_word(word: &str) {
970 let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
971 assert!(found, "assertion failed for '{}'", word);
972 }
973
974 assert_word("Hello123");
975 assert_word("non-English");
976 assert_word("var_name");
977 assert_word("123456");
978 assert_word("3.1415");
979 assert_word("10^2");
980 assert_word("1~2");
981 assert_word("100%");
982 assert_word("@mention");
983 assert_word("#hashtag");
984 assert_word("$variable");
985 assert_word("a=1");
986 assert_word("Self::is_word_char");
987 assert_word("on;");
988 assert_word("more⋯");
989 assert_word("won’t");
990 assert_word("‘twas");
991
992 assert_not_word("foo bar");
994
995 assert_word("github.com");
997 assert_not_word("zed-industries/zed");
998 assert_not_word("zed-industries\\zed");
999 assert_not_word("a=1&b=2");
1000 assert_not_word("foo?b=2");
1001
1002 assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
1004 assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
1006 assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
1008 assert_word("АБВГДЕЖЗИЙКЛМНОП");
1010 assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
1012 assert_word("গিয়েছিলেন");
1014 assert_word("ছেলে");
1015 assert_word("হচ্ছিল");
1016
1017 assert_not_word("你好");
1019 assert_not_word("안녕하세요");
1020 assert_not_word("こんにちは");
1021 assert_not_word("😀😁😂");
1022 assert_not_word("()[]{}<>");
1023 }
1024
1025 #[cfg(target_os = "macos")]
1027 use crate as gpui;
1028
1029 #[cfg(target_os = "macos")]
1031 #[crate::test]
1032 fn test_wrap_shaped_line(cx: &mut TestAppContext) {
1033 cx.update(|cx| {
1034 let text_system = WindowTextSystem::new(cx.text_system().clone());
1035
1036 let normal = TextRun {
1037 len: 0,
1038 font: font("Helvetica"),
1039 color: Default::default(),
1040 underline: Default::default(),
1041 ..Default::default()
1042 };
1043 let bold = TextRun {
1044 len: 0,
1045 font: font("Helvetica").bold(),
1046 ..Default::default()
1047 };
1048
1049 let text = "aa bbb cccc ddddd eeee".into();
1050 let lines = text_system
1051 .shape_text(
1052 text,
1053 px(16.),
1054 &[
1055 normal.with_len(4),
1056 bold.with_len(5),
1057 normal.with_len(6),
1058 bold.with_len(1),
1059 normal.with_len(7),
1060 ],
1061 Some(px(72.)),
1062 None,
1063 )
1064 .unwrap();
1065
1066 assert_eq!(
1067 lines[0].layout.wrap_boundaries(),
1068 &[
1069 WrapBoundary {
1070 run_ix: 0,
1071 glyph_ix: 7
1072 },
1073 WrapBoundary {
1074 run_ix: 0,
1075 glyph_ix: 12
1076 },
1077 WrapBoundary {
1078 run_ix: 0,
1079 glyph_ix: 18
1080 }
1081 ],
1082 );
1083 });
1084 }
1085
1086 #[test]
1087 fn test_multiline_truncation_fits_within_wrapped_lines() {
1088 let mut wrapper = build_wrapper();
1089
1090 let text: &str = "aa bbbbbb cccccc dddddd eeee ffff";
1102 let wrap_width = px(72.);
1103 let max_lines: usize = 2;
1104
1105 let runs = generate_test_runs(&[text.len()]);
1106 let (truncated, _) = wrapper.truncate_wrapped_line(
1107 text.into(),
1108 wrap_width,
1109 max_lines,
1110 "\u{2026}",
1111 &runs,
1112 TruncateFrom::End,
1113 );
1114
1115 let wrap_count = wrapper
1117 .wrap_line(&[LineFragment::text(&truncated)], wrap_width)
1118 .count();
1119
1120 assert!(
1121 wrap_count < max_lines,
1122 "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1123 truncated,
1124 wrap_count + 1,
1125 max_lines
1126 );
1127
1128 assert!(
1130 truncated.ends_with('\u{2026}'),
1131 "Truncated text '{}' should end with ellipsis",
1132 truncated
1133 );
1134 }
1135
1136 #[test]
1137 fn test_multiline_truncation_no_truncation_needed() {
1138 let mut wrapper = build_wrapper();
1139
1140 let text: &str = "aa bbb cccccc";
1143 let wrap_width = px(72.);
1144 let max_lines: usize = 2;
1145
1146 let runs = generate_test_runs(&[text.len()]);
1147 let (result, _) = wrapper.truncate_wrapped_line(
1148 text.into(),
1149 wrap_width,
1150 max_lines,
1151 "\u{2026}",
1152 &runs,
1153 TruncateFrom::End,
1154 );
1155
1156 assert_eq!(
1157 result.as_ref(),
1158 text,
1159 "Text that fits should not be modified"
1160 );
1161 }
1162
1163 #[test]
1164 fn test_multiline_truncation_three_lines() {
1165 let mut wrapper = build_wrapper();
1166
1167 let text: &str = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj";
1168 let wrap_width = px(72.);
1169 let max_lines: usize = 3;
1170
1171 let runs = generate_test_runs(&[text.len()]);
1172 let (truncated, _) = wrapper.truncate_wrapped_line(
1173 text.into(),
1174 wrap_width,
1175 max_lines,
1176 "\u{2026}",
1177 &runs,
1178 TruncateFrom::End,
1179 );
1180
1181 let wrap_count = wrapper
1182 .wrap_line(&[LineFragment::text(&truncated)], wrap_width)
1183 .count();
1184
1185 assert!(
1186 wrap_count < max_lines,
1187 "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1188 truncated,
1189 wrap_count + 1,
1190 max_lines
1191 );
1192
1193 assert!(
1194 truncated.ends_with('\u{2026}'),
1195 "Truncated text '{}' should end with ellipsis",
1196 truncated
1197 );
1198 }
1199
1200 #[test]
1201 fn test_multiline_truncation_with_newlines() {
1202 let mut wrapper = build_wrapper();
1203
1204 let text: &str = "hello\nworld foo bar baz";
1209 let wrap_width = px(72.);
1210 let max_lines: usize = 2;
1211
1212 let runs = generate_test_runs(&[text.len()]);
1213 let (truncated, _) = wrapper.truncate_wrapped_line(
1214 text.into(),
1215 wrap_width,
1216 max_lines,
1217 "\u{2026}",
1218 &runs,
1219 TruncateFrom::End,
1220 );
1221
1222 let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1224 assert_eq!(
1225 parts.len(),
1226 2,
1227 "Newline should be preserved: '{}'",
1228 truncated
1229 );
1230 assert_eq!(parts[0], "hello");
1231
1232 let second_line_width: Pixels = parts[1].chars().map(|c| wrapper.width_for_char(c)).sum();
1234 assert!(
1235 second_line_width <= wrap_width,
1236 "Second line '{}' ({}px) exceeds wrap_width ({}px)",
1237 parts[1],
1238 second_line_width,
1239 wrap_width
1240 );
1241 assert!(
1242 truncated.ends_with('\u{2026}'),
1243 "Should end with ellipsis: '{}'",
1244 truncated
1245 );
1246 }
1247
1248 #[test]
1249 fn test_multiline_truncation_newline_on_last_line() {
1250 let mut wrapper = build_wrapper();
1251
1252 let text: &str = "hello\nworld\nmore";
1256 let wrap_width = px(72.);
1257 let max_lines: usize = 2;
1258
1259 let runs = generate_test_runs(&[text.len()]);
1260 let (truncated, _) = wrapper.truncate_wrapped_line(
1261 text.into(),
1262 wrap_width,
1263 max_lines,
1264 "\u{2026}",
1265 &runs,
1266 TruncateFrom::End,
1267 );
1268
1269 let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1270 assert_eq!(parts[0], "hello");
1271 assert!(
1272 truncated.ends_with('\u{2026}'),
1273 "Should end with ellipsis since there's more content: '{}'",
1274 truncated
1275 );
1276 }
1277
1278 #[test]
1279 fn test_multiline_truncation_trailing_newline() {
1280 let mut wrapper = build_wrapper();
1281
1282 let text: &str = "hello\nworld\n";
1285 let wrap_width = px(72.);
1286 let max_lines: usize = 2;
1287
1288 let runs = generate_test_runs(&[text.len()]);
1289 let (result, _) = wrapper.truncate_wrapped_line(
1290 text.into(),
1291 wrap_width,
1292 max_lines,
1293 "\u{2026}",
1294 &runs,
1295 TruncateFrom::End,
1296 );
1297
1298 assert!(
1299 !result.ends_with('\u{2026}'),
1300 "Trailing newline with no content should not add ellipsis: '{}'",
1301 result
1302 );
1303 }
1304
1305 #[test]
1306 fn test_multiline_truncation_newline_fits_exactly() {
1307 let mut wrapper = build_wrapper();
1308
1309 let text: &str = "hello\nworld";
1312 let wrap_width = px(72.);
1313 let max_lines: usize = 2;
1314
1315 let runs = generate_test_runs(&[text.len()]);
1316 let (result, _) = wrapper.truncate_wrapped_line(
1317 text.into(),
1318 wrap_width,
1319 max_lines,
1320 "\u{2026}",
1321 &runs,
1322 TruncateFrom::End,
1323 );
1324
1325 assert_eq!(
1326 result.as_ref(),
1327 text,
1328 "Text that fits exactly should not be modified: '{}'",
1329 result
1330 );
1331 }
1332}