Skip to main content

open_gpui/text_system/
line_wrapper.rs

1use crate::{FontId, Pixels, SharedString, TextRun, TextSystem, px};
2use open_gpui_collections::HashMap;
3use std::{borrow::Cow, iter, sync::Arc};
4
5/// Determines whether to truncate text from the start or end.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum TruncateFrom {
8    /// Truncate text from the start.
9    Start,
10    /// Truncate text from the end.
11    End,
12}
13
14/// The GPUI line wrapper, used to wrap lines of text to a given width.
15pub 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    /// The maximum indent that can be applied to a line.
25    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    /// Wrap a line of text to the given width with this wrapper's font and font size.
38    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                            // CJK may not be space separated, e.g.: `Hello world你好世界`
73                            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    /// Determines if a line should be truncated based on its width.
137    ///
138    /// Returns the truncation index in `line`.
139    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    /// Truncate a line of text to the given width with this wrapper's font and font size.
188    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    /// Truncate text to fit within a given number of wrapped lines.
219    ///
220    /// Unlike `truncate_line` which treats the text as a flat width budget
221    /// (`width * max_lines`), this method accounts for word-boundary wrapping:
222    /// it walks through characters once, tracking wrap boundaries and the
223    /// truncation point simultaneously. When text overflows on the last
224    /// allowed line, it truncates there and appends the affix.
225    ///
226    /// For `max_lines == 1`, this delegates to `truncate_line`.
227    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                    // Newline on the last allowed line with real content
265                    // below. Truncate here.
266                    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                // Newline before the last line: it consumes a line.
280                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                // Before the last line: replicate wrap_line's boundary logic.
312                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                // On the last line: track the furthest point where the affix
335                // still fits, and stop as soon as the line overflows.
336                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 fits within max_lines without truncation.
359        (text, Cow::Borrowed(runs))
360    }
361
362    /// Any character in this list should be treated as a word character,
363    /// meaning it can be part of a word that should not be wrapped.
364    pub(crate) fn is_word_char(c: char) -> bool {
365        // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
366        c.is_ascii_alphanumeric() ||
367        // Latin script in Unicode for French, German, Spanish, etc.
368        // Latin-1 Supplement
369        // https://en.wikipedia.org/wiki/Latin-1_Supplement
370        matches!(c, '\u{00C0}'..='\u{00FF}') ||
371        // Latin Extended-A
372        // https://en.wikipedia.org/wiki/Latin_Extended-A
373        matches!(c, '\u{0100}'..='\u{017F}') ||
374        // Latin Extended-B
375        // https://en.wikipedia.org/wiki/Latin_Extended-B
376        matches!(c, '\u{0180}'..='\u{024F}') ||
377        // Cyrillic for Russian, Ukrainian, etc.
378        // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
379        matches!(c, '\u{0400}'..='\u{04FF}') ||
380
381        // Vietnamese (https://vietunicode.sourceforge.net/charset/)
382        matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
383        matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
384
385        // Bengali (https://en.wikipedia.org/wiki/Bengali_(Unicode_block))
386        matches!(c, '\u{0980}'..='\u{09FF}') ||
387
388        // Some other known special characters that should be treated as word characters,
389        // e.g. `a-b`, `var_name`, `I'm`/`won’t`, '@mention`, `#hashtag`, `100%`, `3.1415`,
390        // `2^3`, `a~b`, `a=1`, `Self::new`, etc. Trailing punctuation like `,`, `.`, `:`, `;`
391        // is included so it stays attached to the preceding word when wrapping.
392        matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') ||
393        // `⋯` character is special used in Zed, to keep this at the end of the line.
394        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
454/// A fragment of a line that can be wrapped.
455pub enum LineFragment<'a> {
456    /// A text fragment consisting of characters.
457    Text {
458        /// The text content of the fragment.
459        text: &'a str,
460    },
461    /// A non-text element with a fixed width.
462    Element {
463        /// The width of the element in pixels.
464        width: Pixels,
465        /// The UTF-8 encoded length of the element.
466        len_utf8: usize,
467    },
468}
469
470impl<'a> LineFragment<'a> {
471    /// Creates a new text fragment from the given text.
472    pub fn text(text: &'a str) -> Self {
473        LineFragment::Text { text }
474    }
475
476    /// Creates a new non-text element with the given width and UTF-8 encoded length.
477    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/// A boundary between two lines of text.
514#[derive(Copy, Clone, Debug, PartialEq, Eq)]
515pub struct Boundary {
516    /// The index of the last character in a line
517    pub ix: usize,
518    /// The indent of the next line.
519    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        // Test wrapping multiple text fragments
619        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        // Test wrapping with a mix of text and element fragments
637        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        // Test with element at the beginning and text afterward
658        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        // Test with a large element that forces wrapping by itself
677        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        // Case 0: Normal
817        // Text: abcdefghijkl
818        // Runs: Run0 { len: 12, ... }
819        //
820        // Truncate res: abcd… (truncate_at = 4)
821        // Run res: Run0 { string: abcd…, len: 7, ... }
822        perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
823        // Case 1: Drop some runs
824        // Text: abcdefghijkl
825        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
826        //
827        // Truncate res: abcdef… (truncate_at = 6)
828        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
829        // 5, ... }
830        perform_test(
831            &mut wrapper,
832            "abcdefghijkl",
833            "abcdef…",
834            &[4, 4, 4],
835            &[4, 5],
836            px(70.),
837        );
838        // Case 2: Truncate at start of some run
839        // Text: abcdefghijkl
840        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
841        //
842        // Truncate res: abcdefgh… (truncate_at = 8)
843        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
844        // 4, ... }, Run2 { string: …, len: 3, ... }
845        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        // Case 0: Normal
882        // Text: abcdefghijkl
883        // Runs: Run0 { len: 12, ... }
884        //
885        // Truncate res: …ijkl (truncate_at = 9)
886        // Run res: Run0 { string: …ijkl, len: 7, ... }
887        perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
888        // Case 1: Drop some runs
889        // Text: abcdefghijkl
890        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
891        //
892        // Truncate res: …ghijkl (truncate_at = 7)
893        // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len:
894        // 4, ... }
895        perform_test(
896            &mut wrapper,
897            "abcdefghijkl",
898            "…ghijkl",
899            &[4, 4, 4],
900            &[5, 4],
901            px(70.),
902        );
903        // Case 2: Truncate at start of some run
904        // Text: abcdefghijkl
905        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
906        //
907        // Truncate res: abcdefgh… (truncate_at = 3)
908        // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len:
909        // 4, ... }, Run2 { string: ijkl, len: 4, ... }
910        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        // Case 0: Normal
930        // Text: abcdefghijkl
931        // Runs: Run0 { len: 12, ... }
932        //
933        // Truncate res: abcd… (truncate_at = 4)
934        // Run res: Run0 { string: abcd…, len: 7, ... }
935        perform_test("abcd…", &[12], &[7]);
936        // Case 1: Drop some runs
937        // Text: abcdefghijkl
938        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
939        //
940        // Truncate res: abcdef… (truncate_at = 6)
941        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
942        // 5, ... }
943        perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
944        // Case 2: Truncate at start of some run
945        // Text: abcdefghijkl
946        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
947        //
948        // Truncate res: abcdefgh… (truncate_at = 8)
949        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
950        // 4, ... }, Run2 { string: …, len: 3, ... }
951        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        // Space
993        assert_not_word("foo bar");
994
995        // URL case
996        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        // Latin-1 Supplement
1003        assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
1004        // Latin Extended-A
1005        assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
1006        // Latin Extended-B
1007        assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
1008        // Cyrillic
1009        assert_word("АБВГДЕЖЗИЙКЛМНОП");
1010        // Vietnamese (https://github.com/zed-industries/zed/issues/23245)
1011        assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
1012        // Bengali
1013        assert_word("গিয়েছিলেন");
1014        assert_word("ছেলে");
1015        assert_word("হচ্ছিল");
1016
1017        // non-word characters
1018        assert_not_word("你好");
1019        assert_not_word("안녕하세요");
1020        assert_not_word("こんにちは");
1021        assert_not_word("😀😁😂");
1022        assert_not_word("()[]{}<>");
1023    }
1024
1025    // For compatibility with the test macro
1026    #[cfg(target_os = "macos")]
1027    use crate as gpui;
1028
1029    // These seem to vary wildly based on the text system.
1030    #[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        // With .ZedMono at 16px, each char is 9.6px wide.
1091        // wrap_width = 72px fits ~7 chars per line.
1092        //
1093        // "aa bbbbbb cccccc dddddd eeee ffff" with wrap_width=72px wraps as:
1094        //   Line 1: "aa "       (28.8px, wraps because "bbbbbb" won't fit)
1095        //   Line 2: "bbbbbb "   (67.2px)
1096        //   Line 3: "cccccc "   (67.2px)
1097        //   ...
1098        //
1099        // truncate_wrapped_line should wrap first to find line 2 starts at
1100        // "bbbbbb...", then truncate only that line to fit with ellipsis.
1101        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        // The truncated text, when wrapped, must fit within max_lines lines.
1116        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        // The truncated text should end with the ellipsis.
1129        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        // Text that fits in 2 lines shouldn't be truncated.
1141        // Line 1: "aa bbb " (67.2px), Line 2: "cccccc" (57.6px)
1142        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        // "hello\nworld foo bar baz" with line_clamp(2):
1205        // shape_text splits on \n, giving physical lines "hello" and
1206        // "world foo bar baz". The newline consumes line 1, so the
1207        // second physical line should be truncated on line 2.
1208        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        // The newline should be preserved.
1223        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        // The second line should fit within wrap_width and end with ellipsis.
1233        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        // "hello\nworld\nmore" with line_clamp(2):
1253        // Line 1: "hello", Line 2: "world" — but there's a third line,
1254        // so line 2 should be truncated with ellipsis.
1255        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        // "hello\nworld\n" with line_clamp(2):
1283        // The trailing newline has no content after it, so no ellipsis.
1284        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        // "hello\nworld" with line_clamp(2):
1310        // Exactly 2 lines, no truncation needed.
1311        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}