Skip to main content

gpui/text_system/
line_wrapper.rs

1use crate::{FontId, Pixels, SharedString, TextRun, TextSystem, px};
2use 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    /// Truncate text from the middle, preserving the start and end.
13    Middle,
14}
15
16/// The GPUI line wrapper, used to wrap lines of text to a given width.
17pub struct LineWrapper {
18    text_system: Arc<TextSystem>,
19    pub(crate) font_id: FontId,
20    pub(crate) font_size: Pixels,
21    letter_spacing: Option<Pixels>,
22    cached_ascii_char_widths: [Option<Pixels>; 128],
23    cached_other_char_widths: HashMap<char, Pixels>,
24}
25
26impl LineWrapper {
27    /// The maximum indent that can be applied to a line.
28    pub const MAX_INDENT: u32 = 256;
29
30    pub(crate) fn new(font_id: FontId, font_size: Pixels, text_system: Arc<TextSystem>) -> Self {
31        Self {
32            text_system,
33            font_id,
34            font_size,
35            letter_spacing: None,
36            cached_ascii_char_widths: [None; 128],
37            cached_other_char_widths: HashMap::default(),
38        }
39    }
40
41    pub(crate) fn set_letter_spacing(&mut self, letter_spacing: Option<Pixels>) {
42        self.letter_spacing = letter_spacing;
43    }
44
45    /// Wrap a line of text to the given width with this wrapper's font and font size.
46    pub fn wrap_line<'a>(
47        &'a mut self,
48        fragments: &'a [LineFragment],
49        wrap_width: Pixels,
50    ) -> impl Iterator<Item = Boundary> + 'a {
51        let mut width = px(0.);
52        let mut first_non_whitespace_ix = None;
53        let mut indent = None;
54        let mut last_candidate_ix = 0;
55        let mut last_candidate_width = px(0.);
56        let mut last_wrap_ix = 0;
57        let mut prev_c = '\0';
58        let mut index = 0;
59        let mut previous_text_character = false;
60        let mut candidates = fragments
61            .iter()
62            .flat_map(move |fragment| fragment.wrap_boundary_candidates())
63            .peekable();
64        iter::from_fn(move || {
65            for candidate in candidates.by_ref() {
66                let ix = index;
67                index += candidate.len_utf8();
68                let mut new_prev_c = prev_c;
69                let mut item_had_spacing = false;
70                let item_width = match candidate {
71                    WrapBoundaryCandidate::Char { character: c } => {
72                        if c == '\n' {
73                            previous_text_character = false;
74                            continue;
75                        }
76
77                        if Self::is_word_char(c) {
78                            if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() {
79                                last_candidate_ix = ix;
80                                last_candidate_width = width;
81                            }
82                        } else {
83                            // CJK may not be space separated, e.g.: `Hello world你好世界`
84                            if c != ' ' && first_non_whitespace_ix.is_some() {
85                                last_candidate_ix = ix;
86                                last_candidate_width = width;
87                            }
88                        }
89
90                        if c != ' ' && first_non_whitespace_ix.is_none() {
91                            first_non_whitespace_ix = Some(ix);
92                        }
93
94                        new_prev_c = c;
95
96                        let width = self.width_for_char(c);
97                        let spacing = if previous_text_character {
98                            self.letter_spacing.unwrap_or_default()
99                        } else {
100                            px(0.)
101                        };
102                        item_had_spacing = previous_text_character;
103                        previous_text_character = true;
104                        width + spacing
105                    }
106                    WrapBoundaryCandidate::Element {
107                        width: element_width,
108                        ..
109                    } => {
110                        if prev_c == ' ' && first_non_whitespace_ix.is_some() {
111                            last_candidate_ix = ix;
112                            last_candidate_width = width;
113                        }
114
115                        if first_non_whitespace_ix.is_none() {
116                            first_non_whitespace_ix = Some(ix);
117                        }
118
119                        element_width
120                    }
121                };
122
123                width += item_width;
124                if width > wrap_width && ix > last_wrap_ix {
125                    if let (None, Some(first_non_whitespace_ix)) = (indent, first_non_whitespace_ix)
126                    {
127                        indent = Some(
128                            Self::MAX_INDENT.min((first_non_whitespace_ix - last_wrap_ix) as u32),
129                        );
130                    }
131
132                    if last_candidate_ix > 0 {
133                        last_wrap_ix = last_candidate_ix;
134                        width -= last_candidate_width;
135                        width -= self.letter_spacing.unwrap_or_default();
136                        last_candidate_ix = 0;
137                    } else {
138                        last_wrap_ix = ix;
139                        width = if item_had_spacing {
140                            item_width - self.letter_spacing.unwrap_or_default()
141                        } else {
142                            item_width
143                        };
144                    }
145
146                    if let Some(indent) = indent {
147                        width += self.width_for_char(' ') * indent as f32;
148                    }
149
150                    return Some(Boundary::new(last_wrap_ix, indent.unwrap_or(0)));
151                }
152
153                prev_c = new_prev_c;
154            }
155
156            None
157        })
158    }
159
160    /// Determines if a line should be truncated based on its width.
161    ///
162    /// Returns the truncation index in `line`.
163    pub fn should_truncate_line(
164        &mut self,
165        line: &str,
166        truncate_width: Pixels,
167        truncation_affix: &str,
168        truncate_from: TruncateFrom,
169    ) -> Option<usize> {
170        let mut width = px(0.);
171        let suffix_width = self.width_for_text(truncation_affix)
172            + if truncation_affix.is_empty() {
173                px(0.)
174            } else {
175                self.letter_spacing.unwrap_or_default()
176            };
177        let mut truncate_ix = 0;
178
179        match truncate_from {
180            TruncateFrom::Start => {
181                let mut previous_text_character = false;
182                for (ix, c) in line.char_indices().rev() {
183                    if width + suffix_width < truncate_width {
184                        truncate_ix = ix;
185                    }
186
187                    let char_width =
188                        self.width_for_char_with_spacing(c, &mut previous_text_character);
189                    width += char_width;
190
191                    if width.floor() > truncate_width {
192                        return Some(truncate_ix);
193                    }
194                }
195            }
196            TruncateFrom::End => {
197                let mut previous_text_character = false;
198                for (ix, c) in line.char_indices() {
199                    if width + suffix_width < truncate_width {
200                        truncate_ix = ix;
201                    }
202
203                    let char_width =
204                        self.width_for_char_with_spacing(c, &mut previous_text_character);
205                    width += char_width;
206
207                    if width.floor() > truncate_width {
208                        return Some(truncate_ix);
209                    }
210                }
211            }
212            TruncateFrom::Middle => {}
213        }
214
215        None
216    }
217
218    fn should_truncate_line_middle(
219        &mut self,
220        line: &str,
221        truncate_width: Pixels,
222        truncation_affix: &str,
223    ) -> Option<(usize, usize)> {
224        let suffix_width = truncation_affix
225            .chars()
226            .map(|c| self.width_for_char(c))
227            .fold(px(0.0), |a, x| a + x);
228
229        let total_width: Pixels = line
230            .chars()
231            .map(|c| self.width_for_char(c))
232            .fold(px(0.0), |a, x| a + x);
233
234        if total_width <= truncate_width {
235            return None;
236        }
237
238        let content_budget = truncate_width - suffix_width;
239        if content_budget <= px(0.) {
240            return Some((0, line.len()));
241        }
242
243        let front_budget = content_budget * (2.0 / 3.0);
244        let back_budget = content_budget - front_budget;
245
246        let mut front_width = px(0.);
247        let mut front_end_ix = 0usize;
248        for (ix, c) in line.char_indices() {
249            let char_width = self.width_for_char(c);
250            if front_width + char_width > front_budget {
251                break;
252            }
253            front_width += char_width;
254            front_end_ix = ix + c.len_utf8();
255        }
256
257        let mut back_width = px(0.);
258        let mut back_start_ix = line.len();
259        for (ix, c) in line.char_indices().rev() {
260            let char_width = self.width_for_char(c);
261            if back_width + char_width > back_budget {
262                break;
263            }
264            back_width += char_width;
265            back_start_ix = ix;
266        }
267
268        if front_end_ix >= back_start_ix {
269            return Some((0, line.len()));
270        }
271
272        Some((front_end_ix, back_start_ix))
273    }
274
275    /// Truncate a line of text to the given width with this wrapper's font and font size.
276    pub fn truncate_line<'a>(
277        &mut self,
278        line: SharedString,
279        truncate_width: Pixels,
280        truncation_affix: &str,
281        runs: &'a [TextRun],
282        truncate_from: TruncateFrom,
283    ) -> (SharedString, Cow<'a, [TextRun]>) {
284        if truncate_from == TruncateFrom::Middle {
285            if let Some((front_end_ix, back_start_ix)) =
286                self.should_truncate_line_middle(&line, truncate_width, truncation_affix)
287            {
288                let result = SharedString::from(format!(
289                    "{}{truncation_affix}{}",
290                    &line[..front_end_ix],
291                    &line[back_start_ix..]
292                ));
293                let mut runs = runs.to_vec();
294                update_runs_after_middle_truncation(
295                    truncation_affix,
296                    &mut runs,
297                    front_end_ix,
298                    back_start_ix,
299                );
300                return (result, Cow::Owned(runs));
301            } else {
302                return (line, Cow::Borrowed(runs));
303            }
304        }
305
306        if let Some(truncate_ix) =
307            self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from)
308        {
309            let result = match truncate_from {
310                TruncateFrom::Start => SharedString::from(format!(
311                    "{truncation_affix}{}",
312                    &line[line.ceil_char_boundary(truncate_ix + 1)..]
313                )),
314                TruncateFrom::End => SharedString::from(format!(
315                    "{}{truncation_affix}",
316                    line[..truncate_ix]
317                        .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
318                )),
319                TruncateFrom::Middle => unreachable!("Middle truncation is handled above"),
320            };
321            let mut runs = runs.to_vec();
322            update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from);
323            (result, Cow::Owned(runs))
324        } else {
325            (line, Cow::Borrowed(runs))
326        }
327    }
328
329    /// Truncate text to fit within a given number of wrapped lines.
330    ///
331    /// Unlike `truncate_line` which treats the text as a flat width budget
332    /// (`width * max_lines`), this method accounts for word-boundary wrapping:
333    /// it walks through characters once, tracking wrap boundaries and the
334    /// truncation point simultaneously. When text overflows on the last
335    /// allowed line, it truncates there and appends the affix.
336    ///
337    /// For `max_lines == 1`, this delegates to `truncate_line`.
338    pub fn truncate_wrapped_line<'a>(
339        &mut self,
340        text: SharedString,
341        wrap_width: Pixels,
342        max_lines: usize,
343        truncation_affix: &str,
344        runs: &'a [TextRun],
345        truncate_from: TruncateFrom,
346    ) -> (SharedString, Cow<'a, [TextRun]>) {
347        if max_lines <= 1 || truncate_from == TruncateFrom::Start {
348            return self.truncate_line(
349                text,
350                wrap_width * max_lines,
351                truncation_affix,
352                runs,
353                truncate_from,
354            );
355        }
356        if truncate_from == TruncateFrom::Middle {
357            return self.truncate_line(text, wrap_width, truncation_affix, runs, truncate_from);
358        }
359
360        let affix_width = self.width_for_text(truncation_affix)
361            + if truncation_affix.is_empty() {
362                px(0.)
363            } else {
364                self.letter_spacing.unwrap_or_default()
365            };
366
367        let mut width = px(0.);
368        let mut line = 0usize;
369        let mut first_non_whitespace_ix = None;
370        let mut last_candidate_ix = 0usize;
371        let mut last_candidate_width = px(0.);
372        let mut last_wrap_ix = 0usize;
373        let mut prev_c = '\0';
374        let mut indent: Option<u32> = None;
375        let mut truncate_ix = 0usize;
376        let mut previous_text_character = false;
377
378        for (ix, c) in text.char_indices() {
379            if c == '\n' {
380                if line >= max_lines - 1 && !text[ix + 1..].trim().is_empty() {
381                    // Newline on the last allowed line with real content
382                    // below. Truncate here.
383                    let truncated = text[..truncate_ix]
384                        .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
385                    let result = SharedString::from(format!("{truncated}{truncation_affix}"));
386                    let mut runs = runs.to_vec();
387                    update_runs_after_truncation(
388                        &result,
389                        truncation_affix,
390                        &mut runs,
391                        TruncateFrom::End,
392                    );
393                    return (result, Cow::Owned(runs));
394                }
395
396                // Newline before the last line: it consumes a line.
397                line += 1;
398                width = px(0.);
399                first_non_whitespace_ix = None;
400                last_candidate_ix = 0;
401                last_candidate_width = px(0.);
402                last_wrap_ix = ix + 1;
403                prev_c = '\0';
404                indent = None;
405                truncate_ix = ix + 1;
406                previous_text_character = false;
407                continue;
408            }
409
410            let char_width = self.width_for_char_with_spacing(c, &mut previous_text_character);
411
412            if Self::is_word_char(c) {
413                if prev_c == ' ' && first_non_whitespace_ix.is_some() {
414                    last_candidate_ix = ix;
415                    last_candidate_width = width;
416                }
417            } else if c != ' ' && first_non_whitespace_ix.is_some() {
418                last_candidate_ix = ix;
419                last_candidate_width = width;
420            }
421
422            if c != ' ' && first_non_whitespace_ix.is_none() {
423                first_non_whitespace_ix = Some(ix);
424            }
425
426            width += char_width;
427
428            if line < max_lines - 1 {
429                // Before the last line: replicate wrap_line's boundary logic.
430                if width > wrap_width && ix > last_wrap_ix {
431                    if let (None, Some(first_nw)) = (indent, first_non_whitespace_ix) {
432                        indent = Some(Self::MAX_INDENT.min((first_nw - last_wrap_ix) as u32));
433                    }
434
435                    if last_candidate_ix > last_wrap_ix {
436                        last_wrap_ix = last_candidate_ix;
437                        width -= last_candidate_width;
438                        width -= self.letter_spacing.unwrap_or_default();
439                        last_candidate_ix = 0;
440                    } else {
441                        last_wrap_ix = ix;
442                        width = self.width_for_char(c);
443                    }
444
445                    if let Some(ind) = indent {
446                        width += self.width_for_char(' ') * ind as f32;
447                    }
448
449                    line += 1;
450                    truncate_ix = last_wrap_ix;
451                }
452            } else {
453                // On the last line: track the furthest point where the affix
454                // still fits, and stop as soon as the line overflows.
455                if width + affix_width <= wrap_width {
456                    truncate_ix = ix + c.len_utf8();
457                }
458
459                if width > wrap_width {
460                    let truncated = text[..truncate_ix]
461                        .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
462                    let result = SharedString::from(format!("{truncated}{truncation_affix}"));
463                    let mut runs = runs.to_vec();
464                    update_runs_after_truncation(
465                        &result,
466                        truncation_affix,
467                        &mut runs,
468                        TruncateFrom::End,
469                    );
470                    return (result, Cow::Owned(runs));
471                }
472            }
473
474            prev_c = c;
475        }
476
477        // Text fits within max_lines without truncation.
478        (text, Cow::Borrowed(runs))
479    }
480
481    /// Any character in this list should be treated as a word character,
482    /// meaning it can be part of a word that should not be wrapped.
483    pub(crate) fn is_word_char(c: char) -> bool {
484        // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
485        c.is_ascii_alphanumeric() ||
486        // Latin script in Unicode for French, German, Spanish, etc.
487        // Latin-1 Supplement
488        // https://en.wikipedia.org/wiki/Latin-1_Supplement
489        matches!(c, '\u{00C0}'..='\u{00FF}') ||
490        // Latin Extended-A
491        // https://en.wikipedia.org/wiki/Latin_Extended-A
492        matches!(c, '\u{0100}'..='\u{017F}') ||
493        // Latin Extended-B
494        // https://en.wikipedia.org/wiki/Latin_Extended-B
495        matches!(c, '\u{0180}'..='\u{024F}') ||
496        // Cyrillic for Russian, Ukrainian, etc.
497        // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
498        matches!(c, '\u{0400}'..='\u{04FF}') ||
499
500        // Vietnamese (https://vietunicode.sourceforge.net/charset/)
501        matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
502        matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
503
504        // Bengali (https://en.wikipedia.org/wiki/Bengali_(Unicode_block))
505        matches!(c, '\u{0980}'..='\u{09FF}') ||
506
507        // Some other known special characters that should be treated as word characters,
508        // e.g. `a-b`, `var_name`, `I'm`/`won’t`, '@mention`, `#hashtag`, `100%`, `3.1415`,
509        // `2^3`, `a~b`, `a=1`, `Self::new`, etc. Trailing punctuation like `,`, `.`, `:`, `;`
510        // is included so it stays attached to the preceding word when wrapping.
511        matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') ||
512        // `⋯` character is special used in Zed, to keep this at the end of the line.
513        matches!(c, '⋯') ||
514
515        // Non-breaking glue characters
516        matches!(c, '\u{202F}' | '\u{00A0}' | '\u{2011}')
517    }
518
519    #[inline(always)]
520    fn width_for_char(&mut self, c: char) -> Pixels {
521        if (c as u32) < 128 {
522            if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] {
523                cached_width
524            } else {
525                let width = self
526                    .text_system
527                    .layout_width(self.font_id, self.font_size, c);
528                self.cached_ascii_char_widths[c as usize] = Some(width);
529                width
530            }
531        } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) {
532            *cached_width
533        } else {
534            let width = self
535                .text_system
536                .layout_width(self.font_id, self.font_size, c);
537            self.cached_other_char_widths.insert(c, width);
538            width
539        }
540    }
541
542    fn width_for_char_with_spacing(
543        &mut self,
544        c: char,
545        previous_text_character: &mut bool,
546    ) -> Pixels {
547        let width = self.width_for_char(c);
548        let spacing = if *previous_text_character {
549            self.letter_spacing.unwrap_or_default()
550        } else {
551            px(0.)
552        };
553        *previous_text_character = true;
554        width + spacing
555    }
556
557    fn width_for_text(&mut self, text: &str) -> Pixels {
558        let mut previous_text_character = false;
559        text.chars()
560            .map(|character| {
561                self.width_for_char_with_spacing(character, &mut previous_text_character)
562            })
563            .sum()
564    }
565}
566
567fn update_runs_after_truncation(
568    result: &str,
569    ellipsis: &str,
570    runs: &mut Vec<TextRun>,
571    truncate_from: TruncateFrom,
572) {
573    let mut truncate_at = result.len() - ellipsis.len();
574    match truncate_from {
575        TruncateFrom::Start => {
576            for (run_index, run) in runs.iter_mut().enumerate().rev() {
577                if run.len <= truncate_at {
578                    truncate_at -= run.len;
579                } else {
580                    run.len = truncate_at + ellipsis.len();
581                    runs.splice(..run_index, std::iter::empty());
582                    break;
583                }
584            }
585        }
586        TruncateFrom::End => {
587            for (run_index, run) in runs.iter_mut().enumerate() {
588                if run.len <= truncate_at {
589                    truncate_at -= run.len;
590                } else {
591                    run.len = truncate_at + ellipsis.len();
592                    runs.truncate(run_index + 1);
593                    break;
594                }
595            }
596        }
597        TruncateFrom::Middle => {
598            unreachable!("Middle truncation calls this function with TruncateFrom::End directly")
599        }
600    }
601}
602
603fn update_runs_after_middle_truncation(
604    ellipsis: &str,
605    runs: &mut Vec<TextRun>,
606    front_end_ix: usize,
607    back_start_ix: usize,
608) {
609    let original_runs = std::mem::take(runs);
610    let mut result_runs: Vec<TextRun> = Vec::with_capacity(original_runs.len());
611
612    // Front segment [0, front_end_ix) + ellipsis: walk forward until the run
613    // that straddles or ends at front_end_ix, then extend that run's length
614    // to include the ellipsis.
615    let mut front_remaining = front_end_ix;
616    let mut front_done = false;
617    for run in &original_runs {
618        if front_done {
619            break;
620        }
621        if run.len <= front_remaining {
622            result_runs.push(run.clone());
623            front_remaining -= run.len;
624        } else {
625            let mut partial = run.clone();
626            partial.len = front_remaining + ellipsis.len();
627            result_runs.push(partial);
628            front_done = true;
629        }
630    }
631    if !front_done {
632        // front_end_ix landed exactly on a run boundary; append ellipsis to
633        // the last front run (or, if the front is empty, to the first back run).
634        if let Some(last) = result_runs.last_mut() {
635            last.len += ellipsis.len();
636        } else if let Some(first) = original_runs.first() {
637            let mut affix_run = first.clone();
638            affix_run.len = ellipsis.len();
639            result_runs.push(affix_run);
640        }
641    }
642
643    // Back segment [back_start_ix, original.len()): skip runs entirely in the
644    // removed middle, keep the rest.
645    let mut byte_pos = 0usize;
646    for run in &original_runs {
647        let run_end = byte_pos + run.len;
648        if run_end > back_start_ix {
649            if byte_pos < back_start_ix {
650                // Run straddles back_start_ix; keep only the tail.
651                let mut partial = run.clone();
652                partial.len = run_end - back_start_ix;
653                result_runs.push(partial);
654            } else {
655                result_runs.push(run.clone());
656            }
657        }
658        byte_pos = run_end;
659    }
660
661    *runs = result_runs;
662}
663
664/// A fragment of a line that can be wrapped.
665pub enum LineFragment<'a> {
666    /// A text fragment consisting of characters.
667    Text {
668        /// The text content of the fragment.
669        text: &'a str,
670    },
671    /// A non-text element with a fixed width.
672    Element {
673        /// The width of the element in pixels.
674        width: Pixels,
675        /// The UTF-8 encoded length of the element.
676        len_utf8: usize,
677    },
678}
679
680impl<'a> LineFragment<'a> {
681    /// Creates a new text fragment from the given text.
682    pub fn text(text: &'a str) -> Self {
683        LineFragment::Text { text }
684    }
685
686    /// Creates a new non-text element with the given width and UTF-8 encoded length.
687    pub fn element(width: Pixels, len_utf8: usize) -> Self {
688        LineFragment::Element { width, len_utf8 }
689    }
690
691    fn wrap_boundary_candidates(&self) -> impl Iterator<Item = WrapBoundaryCandidate> {
692        let text = match self {
693            LineFragment::Text { text } => text,
694            LineFragment::Element { .. } => "\0",
695        };
696        text.chars().map(move |character| {
697            if let LineFragment::Element { width, len_utf8 } = self {
698                WrapBoundaryCandidate::Element {
699                    width: *width,
700                    len_utf8: *len_utf8,
701                }
702            } else {
703                WrapBoundaryCandidate::Char { character }
704            }
705        })
706    }
707}
708
709enum WrapBoundaryCandidate {
710    Char { character: char },
711    Element { width: Pixels, len_utf8: usize },
712}
713
714impl WrapBoundaryCandidate {
715    pub fn len_utf8(&self) -> usize {
716        match self {
717            WrapBoundaryCandidate::Char { character } => character.len_utf8(),
718            WrapBoundaryCandidate::Element { len_utf8: len, .. } => *len,
719        }
720    }
721}
722
723/// A boundary between two lines of text.
724#[derive(Copy, Clone, Debug, PartialEq, Eq)]
725pub struct Boundary {
726    /// The index of the last character in a line
727    pub ix: usize,
728    /// The indent of the next line.
729    pub next_indent: u32,
730}
731
732impl Boundary {
733    fn new(ix: usize, next_indent: u32) -> Self {
734        Self { ix, next_indent }
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::{
742        Font, FontFeatures, FontRun, FontStyle, FontWeight, Hsla, TestAppContext, TestDispatcher,
743        TextRun, font,
744    };
745    #[cfg(target_os = "macos")]
746    use crate::{WindowTextSystem, WrapBoundary};
747
748    fn build_wrapper() -> LineWrapper {
749        let dispatcher = TestDispatcher::new(0);
750        let cx = TestAppContext::build(dispatcher, None);
751        let id = cx.text_system().resolve_font(&font(".ZedMono"));
752        LineWrapper::new(id, px(16.), cx.text_system().clone())
753    }
754
755    fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
756        input_run_len
757            .iter()
758            .map(|run_len| TextRun {
759                len: *run_len,
760                font: Font {
761                    family: "Dummy".into(),
762                    features: FontFeatures::default(),
763                    fallbacks: None,
764                    weight: FontWeight::default(),
765                    style: FontStyle::Normal,
766                },
767                ..Default::default()
768            })
769            .collect()
770    }
771
772    #[test]
773    fn test_tracking_changes_measured_width() {
774        let dispatcher = TestDispatcher::new(1);
775        let cx = TestAppContext::build(dispatcher, None);
776        let base = TextRun {
777            len: 4,
778            font: font(".ZedMono"),
779            color: Hsla::default(),
780            background_color: None,
781            underline: None,
782            strikethrough: None,
783            ..Default::default()
784        };
785        let font_id = cx.text_system().resolve_font(&base.font);
786        let platform = cx.text_system().platform_text_system_for_tests();
787        let no_tracking = platform.layout_line(
788            "TEST",
789            px(16.),
790            &[FontRun {
791                len: 4,
792                font_id,
793                letter_spacing: None,
794            }],
795        );
796        let wide = platform.layout_line(
797            "TEST",
798            px(16.),
799            &[FontRun {
800                len: 4,
801                font_id,
802                letter_spacing: Some(px(2.0)),
803            }],
804        );
805        let tight = platform.layout_line(
806            "TEST",
807            px(16.),
808            &[FontRun {
809                len: 4,
810                font_id,
811                letter_spacing: Some(px(-0.5)),
812            }],
813        );
814
815        assert!(wide.width > no_tracking.width);
816        assert!(tight.width <= no_tracking.width);
817    }
818
819    #[test]
820    fn test_tracking_changes_truncation_width() {
821        let mut wrapper = build_wrapper();
822        let no_tracking = wrapper.width_for_text("TEST…");
823        wrapper.set_letter_spacing(Some(px(2.0)));
824        let wide = wrapper.width_for_text("TEST…");
825
826        assert!(wide > no_tracking);
827    }
828
829    #[test]
830    fn test_wrap_line() {
831        let mut wrapper = build_wrapper();
832
833        assert_eq!(
834            wrapper
835                .wrap_line(&[LineFragment::text("aa bbb cccc ddddd eeee")], px(72.))
836                .collect::<Vec<_>>(),
837            &[
838                Boundary::new(7, 0),
839                Boundary::new(12, 0),
840                Boundary::new(18, 0)
841            ],
842        );
843        assert_eq!(
844            wrapper
845                .wrap_line(&[LineFragment::text("aaa aaaaaaaaaaaaaaaaaa")], px(72.0))
846                .collect::<Vec<_>>(),
847            &[
848                Boundary::new(4, 0),
849                Boundary::new(11, 0),
850                Boundary::new(18, 0)
851            ],
852        );
853        assert_eq!(
854            wrapper
855                .wrap_line(&[LineFragment::text("     aaaaaaa")], px(72.))
856                .collect::<Vec<_>>(),
857            &[
858                Boundary::new(7, 5),
859                Boundary::new(9, 5),
860                Boundary::new(11, 5),
861            ]
862        );
863        assert_eq!(
864            wrapper
865                .wrap_line(
866                    &[LineFragment::text("                            ")],
867                    px(72.)
868                )
869                .collect::<Vec<_>>(),
870            &[
871                Boundary::new(7, 0),
872                Boundary::new(14, 0),
873                Boundary::new(21, 0)
874            ]
875        );
876        assert_eq!(
877            wrapper
878                .wrap_line(&[LineFragment::text("          aaaaaaaaaaaaaa")], px(72.))
879                .collect::<Vec<_>>(),
880            &[
881                Boundary::new(7, 0),
882                Boundary::new(14, 3),
883                Boundary::new(18, 3),
884                Boundary::new(22, 3),
885            ]
886        );
887
888        // Test wrapping multiple text fragments
889        assert_eq!(
890            wrapper
891                .wrap_line(
892                    &[
893                        LineFragment::text("aa bbb "),
894                        LineFragment::text("cccc ddddd eeee")
895                    ],
896                    px(72.)
897                )
898                .collect::<Vec<_>>(),
899            &[
900                Boundary::new(7, 0),
901                Boundary::new(12, 0),
902                Boundary::new(18, 0)
903            ],
904        );
905
906        // Test wrapping with a mix of text and element fragments
907        assert_eq!(
908            wrapper
909                .wrap_line(
910                    &[
911                        LineFragment::text("aa "),
912                        LineFragment::element(px(20.), 1),
913                        LineFragment::text(" bbb "),
914                        LineFragment::element(px(30.), 1),
915                        LineFragment::text(" cccc")
916                    ],
917                    px(72.)
918                )
919                .collect::<Vec<_>>(),
920            &[
921                Boundary::new(5, 0),
922                Boundary::new(9, 0),
923                Boundary::new(11, 0)
924            ],
925        );
926
927        // Test with element at the beginning and text afterward
928        assert_eq!(
929            wrapper
930                .wrap_line(
931                    &[
932                        LineFragment::element(px(50.), 1),
933                        LineFragment::text(" aaaa bbbb cccc dddd")
934                    ],
935                    px(72.)
936                )
937                .collect::<Vec<_>>(),
938            &[
939                Boundary::new(2, 0),
940                Boundary::new(7, 0),
941                Boundary::new(12, 0),
942                Boundary::new(17, 0)
943            ],
944        );
945
946        // Test with a large element that forces wrapping by itself
947        assert_eq!(
948            wrapper
949                .wrap_line(
950                    &[
951                        LineFragment::text("short text "),
952                        LineFragment::element(px(100.), 1),
953                        LineFragment::text(" more text")
954                    ],
955                    px(72.)
956                )
957                .collect::<Vec<_>>(),
958            &[
959                Boundary::new(6, 0),
960                Boundary::new(11, 0),
961                Boundary::new(12, 0),
962                Boundary::new(18, 0)
963            ],
964        );
965
966        // Test with non-breaking glue characters
967        assert_eq!(
968            wrapper
969                .wrap_line(
970                    &[LineFragment::text("a\u{202F}b\u{00A0}c\u{2011}d e")],
971                    px(72.0)
972                )
973                .collect::<Vec<_>>(),
974            &[Boundary::new(12, 0),], // special chars above take up 3, 2 and 3 bytes, so boundary ends up at 12
975        );
976    }
977
978    #[test]
979    fn test_truncate_line_end() {
980        let mut wrapper = build_wrapper();
981
982        fn perform_test(
983            wrapper: &mut LineWrapper,
984            text: &'static str,
985            expected: &'static str,
986            ellipsis: &str,
987        ) {
988            let dummy_run_lens = vec![text.len()];
989            let dummy_runs = generate_test_runs(&dummy_run_lens);
990            let (result, dummy_runs) = wrapper.truncate_line(
991                text.into(),
992                px(220.),
993                ellipsis,
994                &dummy_runs,
995                TruncateFrom::End,
996            );
997            assert_eq!(result, expected);
998            assert_eq!(dummy_runs.first().unwrap().len, result.len());
999        }
1000
1001        perform_test(
1002            &mut wrapper,
1003            "aa bbb cccc ddddd eeee ffff gggg",
1004            "aa bbb cccc ddddd eeee",
1005            "",
1006        );
1007        perform_test(
1008            &mut wrapper,
1009            "aa bbb cccc ddddd eeee ffff gggg",
1010            "aa bbb cccc ddddd eee…",
1011            "…",
1012        );
1013        perform_test(
1014            &mut wrapper,
1015            "aa bbb cccc ddddd eeee ffff gggg",
1016            "aa bbb cccc dddd......",
1017            "......",
1018        );
1019        perform_test(
1020            &mut wrapper,
1021            "aa bbb cccc 🦀🦀🦀🦀🦀 eeee ffff gggg",
1022            "aa bbb cccc 🦀🦀🦀🦀…",
1023            "…",
1024        );
1025    }
1026
1027    #[test]
1028    fn test_truncate_line_start() {
1029        let mut wrapper = build_wrapper();
1030
1031        #[track_caller]
1032        fn perform_test(
1033            wrapper: &mut LineWrapper,
1034            text: &'static str,
1035            expected: &'static str,
1036            ellipsis: &str,
1037        ) {
1038            let dummy_run_lens = vec![text.len()];
1039            let dummy_runs = generate_test_runs(&dummy_run_lens);
1040            let (result, dummy_runs) = wrapper.truncate_line(
1041                text.into(),
1042                px(220.),
1043                ellipsis,
1044                &dummy_runs,
1045                TruncateFrom::Start,
1046            );
1047            assert_eq!(result, expected);
1048            assert_eq!(dummy_runs.first().unwrap().len, result.len());
1049        }
1050
1051        perform_test(
1052            &mut wrapper,
1053            "aaaa bbbb cccc ddddd eeee fff gg",
1054            "cccc ddddd eeee fff gg",
1055            "",
1056        );
1057        perform_test(
1058            &mut wrapper,
1059            "aaaa bbbb cccc ddddd eeee fff gg",
1060            "…ccc ddddd eeee fff gg",
1061            "…",
1062        );
1063        perform_test(
1064            &mut wrapper,
1065            "aaaa bbbb cccc ddddd eeee fff gg",
1066            "......dddd eeee fff gg",
1067            "......",
1068        );
1069        perform_test(
1070            &mut wrapper,
1071            "aaaa bbbb cccc 🦀🦀🦀🦀🦀 eeee fff gg",
1072            "…🦀🦀🦀🦀 eeee fff gg",
1073            "…",
1074        );
1075    }
1076
1077    #[test]
1078    fn test_truncate_multiple_runs_end() {
1079        let mut wrapper = build_wrapper();
1080
1081        fn perform_test(
1082            wrapper: &mut LineWrapper,
1083            text: &'static str,
1084            expected: &str,
1085            run_lens: &[usize],
1086            result_run_len: &[usize],
1087            line_width: Pixels,
1088        ) {
1089            let dummy_runs = generate_test_runs(run_lens);
1090            let (result, dummy_runs) =
1091                wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End);
1092            assert_eq!(result, expected);
1093            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
1094                assert_eq!(run.len, *result_len);
1095            }
1096        }
1097        // Case 0: Normal
1098        // Text: abcdefghijkl
1099        // Runs: Run0 { len: 12, ... }
1100        //
1101        // Truncate res: abcd… (truncate_at = 4)
1102        // Run res: Run0 { string: abcd…, len: 7, ... }
1103        perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
1104        // Case 1: Drop some runs
1105        // Text: abcdefghijkl
1106        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1107        //
1108        // Truncate res: abcdef… (truncate_at = 6)
1109        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
1110        // 5, ... }
1111        perform_test(
1112            &mut wrapper,
1113            "abcdefghijkl",
1114            "abcdef…",
1115            &[4, 4, 4],
1116            &[4, 5],
1117            px(70.),
1118        );
1119        // Case 2: Truncate at start of some run
1120        // Text: abcdefghijkl
1121        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1122        //
1123        // Truncate res: abcdefgh… (truncate_at = 8)
1124        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
1125        // 4, ... }, Run2 { string: …, len: 3, ... }
1126        perform_test(
1127            &mut wrapper,
1128            "abcdefghijkl",
1129            "abcdefgh…",
1130            &[4, 4, 4],
1131            &[4, 4, 3],
1132            px(90.),
1133        );
1134    }
1135
1136    #[test]
1137    fn test_truncate_multiple_runs_start() {
1138        let mut wrapper = build_wrapper();
1139
1140        #[track_caller]
1141        fn perform_test(
1142            wrapper: &mut LineWrapper,
1143            text: &'static str,
1144            expected: &str,
1145            run_lens: &[usize],
1146            result_run_len: &[usize],
1147            line_width: Pixels,
1148        ) {
1149            let dummy_runs = generate_test_runs(run_lens);
1150            let (result, dummy_runs) = wrapper.truncate_line(
1151                text.into(),
1152                line_width,
1153                "…",
1154                &dummy_runs,
1155                TruncateFrom::Start,
1156            );
1157            assert_eq!(result, expected);
1158            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
1159                assert_eq!(run.len, *result_len);
1160            }
1161        }
1162        // Case 0: Normal
1163        // Text: abcdefghijkl
1164        // Runs: Run0 { len: 12, ... }
1165        //
1166        // Truncate res: …ijkl (truncate_at = 9)
1167        // Run res: Run0 { string: …ijkl, len: 7, ... }
1168        perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
1169        // Case 1: Drop some runs
1170        // Text: abcdefghijkl
1171        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1172        //
1173        // Truncate res: …ghijkl (truncate_at = 7)
1174        // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len:
1175        // 4, ... }
1176        perform_test(
1177            &mut wrapper,
1178            "abcdefghijkl",
1179            "…ghijkl",
1180            &[4, 4, 4],
1181            &[5, 4],
1182            px(70.),
1183        );
1184        // Case 2: Truncate at start of some run
1185        // Text: abcdefghijkl
1186        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1187        //
1188        // Truncate res: abcdefgh… (truncate_at = 3)
1189        // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len:
1190        // 4, ... }, Run2 { string: ijkl, len: 4, ... }
1191        perform_test(
1192            &mut wrapper,
1193            "abcdefghijkl",
1194            "…efghijkl",
1195            &[4, 4, 4],
1196            &[3, 4, 4],
1197            px(90.),
1198        );
1199    }
1200
1201    #[test]
1202    fn test_update_run_after_truncation_end() {
1203        fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
1204            let mut dummy_runs = generate_test_runs(run_lens);
1205            update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End);
1206            for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
1207                assert_eq!(run.len, *result_len);
1208            }
1209        }
1210        // Case 0: Normal
1211        // Text: abcdefghijkl
1212        // Runs: Run0 { len: 12, ... }
1213        //
1214        // Truncate res: abcd… (truncate_at = 4)
1215        // Run res: Run0 { string: abcd…, len: 7, ... }
1216        perform_test("abcd…", &[12], &[7]);
1217        // Case 1: Drop some runs
1218        // Text: abcdefghijkl
1219        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1220        //
1221        // Truncate res: abcdef… (truncate_at = 6)
1222        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
1223        // 5, ... }
1224        perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
1225        // Case 2: Truncate at start of some run
1226        // Text: abcdefghijkl
1227        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1228        //
1229        // Truncate res: abcdefgh… (truncate_at = 8)
1230        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
1231        // 4, ... }, Run2 { string: …, len: 3, ... }
1232        perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
1233    }
1234
1235    #[test]
1236    fn test_is_word_char() {
1237        #[track_caller]
1238        fn assert_word(word: &str) {
1239            for c in word.chars() {
1240                assert!(
1241                    LineWrapper::is_word_char(c),
1242                    "assertion failed for '{}' (unicode 0x{:x})",
1243                    c,
1244                    c as u32
1245                );
1246            }
1247        }
1248
1249        #[track_caller]
1250        fn assert_not_word(word: &str) {
1251            let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
1252            assert!(found, "assertion failed for '{}'", word);
1253        }
1254
1255        assert_word("Hello123");
1256        assert_word("non-English");
1257        assert_word("var_name");
1258        assert_word("123456");
1259        assert_word("3.1415");
1260        assert_word("10^2");
1261        assert_word("1~2");
1262        assert_word("100%");
1263        assert_word("@mention");
1264        assert_word("#hashtag");
1265        assert_word("$variable");
1266        assert_word("a=1");
1267        assert_word("Self::is_word_char");
1268        assert_word("on;");
1269        assert_word("more⋯");
1270        assert_word("won’t");
1271        assert_word("‘twas");
1272
1273        // Space
1274        assert_not_word("foo bar");
1275
1276        // URL case
1277        assert_word("github.com");
1278        assert_not_word("zed-industries/zed");
1279        assert_not_word("zed-industries\\zed");
1280        assert_not_word("a=1&b=2");
1281        assert_not_word("foo?b=2");
1282
1283        // Latin-1 Supplement
1284        assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
1285        // Latin Extended-A
1286        assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
1287        // Latin Extended-B
1288        assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
1289        // Cyrillic
1290        assert_word("АБВГДЕЖЗИЙКЛМНОП");
1291        // Vietnamese (https://github.com/zed-industries/zed/issues/23245)
1292        assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
1293        // Bengali
1294        assert_word("গিয়েছিলেন");
1295        assert_word("ছেলে");
1296        assert_word("হচ্ছিল");
1297
1298        // non-word characters
1299        assert_not_word("你好");
1300        assert_not_word("안녕하세요");
1301        assert_not_word("こんにちは");
1302        assert_not_word("😀😁😂");
1303        assert_not_word("()[]{}<>");
1304
1305        // Non-breaking ("Glue") characters, see https://www.unicode.org/reports/tr14/
1306        // (https://github.com/zed-industries/zed/issues/59664)
1307        assert_word("\u{202F}"); // NNBSP " "
1308        assert_word("\u{00A0}"); // NBSP " "
1309        assert_word("\u{2011}"); // NBH "‑"
1310    }
1311
1312    // For compatibility with the test macro
1313    #[cfg(target_os = "macos")]
1314    use crate as gpui;
1315
1316    // These seem to vary wildly based on the text system.
1317    #[cfg(target_os = "macos")]
1318    #[crate::test]
1319    fn test_wrap_shaped_line(cx: &mut TestAppContext) {
1320        cx.update(|cx| {
1321            let text_system = WindowTextSystem::new(cx.text_system().clone());
1322
1323            let normal = TextRun {
1324                len: 0,
1325                font: font("Helvetica"),
1326                color: Default::default(),
1327                underline: Default::default(),
1328                ..Default::default()
1329            };
1330            let bold = TextRun {
1331                len: 0,
1332                font: font("Helvetica").bold(),
1333                ..Default::default()
1334            };
1335
1336            let text = SharedString::from("aa bbb cccc ddddd eeee");
1337            let lines = text_system
1338                .shape_text(
1339                    text,
1340                    px(16.),
1341                    &[
1342                        normal.with_len(4),
1343                        bold.with_len(5),
1344                        normal.with_len(6),
1345                        bold.with_len(1),
1346                        normal.with_len(7),
1347                    ],
1348                    Some(px(72.)),
1349                    None,
1350                )
1351                .unwrap();
1352
1353            assert_eq!(
1354                lines[0].layout.wrap_boundaries(),
1355                &[
1356                    WrapBoundary {
1357                        run_ix: 0,
1358                        glyph_ix: 7
1359                    },
1360                    WrapBoundary {
1361                        run_ix: 0,
1362                        glyph_ix: 12
1363                    },
1364                    WrapBoundary {
1365                        run_ix: 0,
1366                        glyph_ix: 18
1367                    }
1368                ],
1369            );
1370        });
1371    }
1372
1373    #[test]
1374    fn test_multiline_truncation_fits_within_wrapped_lines() {
1375        let mut wrapper = build_wrapper();
1376
1377        // With .ZedMono at 16px, each char is 9.6px wide.
1378        // wrap_width = 72px fits ~7 chars per line.
1379        //
1380        // "aa bbbbbb cccccc dddddd eeee ffff" with wrap_width=72px wraps as:
1381        //   Line 1: "aa "       (28.8px, wraps because "bbbbbb" won't fit)
1382        //   Line 2: "bbbbbb "   (67.2px)
1383        //   Line 3: "cccccc "   (67.2px)
1384        //   ...
1385        //
1386        // truncate_wrapped_line should wrap first to find line 2 starts at
1387        // "bbbbbb...", then truncate only that line to fit with ellipsis.
1388        let text: &str = "aa bbbbbb cccccc dddddd eeee ffff";
1389        let wrap_width = px(72.);
1390        let max_lines: usize = 2;
1391
1392        let runs = generate_test_runs(&[text.len()]);
1393        let (truncated, _) = wrapper.truncate_wrapped_line(
1394            text.into(),
1395            wrap_width,
1396            max_lines,
1397            "\u{2026}",
1398            &runs,
1399            TruncateFrom::End,
1400        );
1401
1402        // The truncated text, when wrapped, must fit within max_lines lines.
1403        let wrap_count = wrapper
1404            .wrap_line(&[LineFragment::text(&truncated)], wrap_width)
1405            .count();
1406
1407        assert!(
1408            wrap_count < max_lines,
1409            "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1410            truncated,
1411            wrap_count + 1,
1412            max_lines
1413        );
1414
1415        // The truncated text should end with the ellipsis.
1416        assert!(
1417            truncated.ends_with('\u{2026}'),
1418            "Truncated text '{}' should end with ellipsis",
1419            truncated
1420        );
1421    }
1422
1423    #[test]
1424    fn test_multiline_truncation_no_truncation_needed() {
1425        let mut wrapper = build_wrapper();
1426
1427        // Text that fits in 2 lines shouldn't be truncated.
1428        // Line 1: "aa bbb " (67.2px), Line 2: "cccccc" (57.6px)
1429        let text: &str = "aa bbb cccccc";
1430        let wrap_width = px(72.);
1431        let max_lines: usize = 2;
1432
1433        let runs = generate_test_runs(&[text.len()]);
1434        let (result, _) = wrapper.truncate_wrapped_line(
1435            text.into(),
1436            wrap_width,
1437            max_lines,
1438            "\u{2026}",
1439            &runs,
1440            TruncateFrom::End,
1441        );
1442
1443        assert_eq!(
1444            result.as_ref(),
1445            text,
1446            "Text that fits should not be modified"
1447        );
1448    }
1449
1450    #[test]
1451    fn test_multiline_truncation_three_lines() {
1452        let mut wrapper = build_wrapper();
1453
1454        let text: &str = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj";
1455        let wrap_width = px(72.);
1456        let max_lines: usize = 3;
1457
1458        let runs = generate_test_runs(&[text.len()]);
1459        let (truncated, _) = wrapper.truncate_wrapped_line(
1460            text.into(),
1461            wrap_width,
1462            max_lines,
1463            "\u{2026}",
1464            &runs,
1465            TruncateFrom::End,
1466        );
1467
1468        let wrap_count = wrapper
1469            .wrap_line(&[LineFragment::text(&truncated)], wrap_width)
1470            .count();
1471
1472        assert!(
1473            wrap_count < max_lines,
1474            "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1475            truncated,
1476            wrap_count + 1,
1477            max_lines
1478        );
1479
1480        assert!(
1481            truncated.ends_with('\u{2026}'),
1482            "Truncated text '{}' should end with ellipsis",
1483            truncated
1484        );
1485    }
1486
1487    #[test]
1488    fn test_multiline_truncation_with_newlines() {
1489        let mut wrapper = build_wrapper();
1490
1491        // "hello\nworld foo bar baz" with line_clamp(2):
1492        // shape_text splits on \n, giving physical lines "hello" and
1493        // "world foo bar baz". The newline consumes line 1, so the
1494        // second physical line should be truncated on line 2.
1495        let text: &str = "hello\nworld foo bar baz";
1496        let wrap_width = px(72.);
1497        let max_lines: usize = 2;
1498
1499        let runs = generate_test_runs(&[text.len()]);
1500        let (truncated, _) = wrapper.truncate_wrapped_line(
1501            text.into(),
1502            wrap_width,
1503            max_lines,
1504            "\u{2026}",
1505            &runs,
1506            TruncateFrom::End,
1507        );
1508
1509        // The newline should be preserved.
1510        let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1511        assert_eq!(
1512            parts.len(),
1513            2,
1514            "Newline should be preserved: '{}'",
1515            truncated
1516        );
1517        assert_eq!(parts[0], "hello");
1518
1519        // The second line should fit within wrap_width and end with ellipsis.
1520        let second_line_width: Pixels = parts[1].chars().map(|c| wrapper.width_for_char(c)).sum();
1521        assert!(
1522            second_line_width <= wrap_width,
1523            "Second line '{}' ({}px) exceeds wrap_width ({}px)",
1524            parts[1],
1525            second_line_width,
1526            wrap_width
1527        );
1528        assert!(
1529            truncated.ends_with('\u{2026}'),
1530            "Should end with ellipsis: '{}'",
1531            truncated
1532        );
1533    }
1534
1535    #[test]
1536    fn test_multiline_truncation_newline_on_last_line() {
1537        let mut wrapper = build_wrapper();
1538
1539        // "hello\nworld\nmore" with line_clamp(2):
1540        // Line 1: "hello", Line 2: "world" — but there's a third line,
1541        // so line 2 should be truncated with ellipsis.
1542        let text: &str = "hello\nworld\nmore";
1543        let wrap_width = px(72.);
1544        let max_lines: usize = 2;
1545
1546        let runs = generate_test_runs(&[text.len()]);
1547        let (truncated, _) = wrapper.truncate_wrapped_line(
1548            text.into(),
1549            wrap_width,
1550            max_lines,
1551            "\u{2026}",
1552            &runs,
1553            TruncateFrom::End,
1554        );
1555
1556        let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1557        assert_eq!(parts[0], "hello");
1558        assert!(
1559            truncated.ends_with('\u{2026}'),
1560            "Should end with ellipsis since there's more content: '{}'",
1561            truncated
1562        );
1563    }
1564
1565    #[test]
1566    fn test_truncate_line_middle() {
1567        let mut wrapper = build_wrapper();
1568
1569        // No truncation when text fits within a very wide budget.
1570        let short_text = "hello world";
1571        let runs = generate_test_runs(&[short_text.len()]);
1572        let (result, result_runs) = wrapper.truncate_line(
1573            short_text.into(),
1574            px(10000.),
1575            "…",
1576            &runs,
1577            TruncateFrom::Middle,
1578        );
1579        assert_eq!(result.as_ref(), short_text);
1580        assert_eq!(result_runs.len(), 1);
1581        assert_eq!(result_runs[0].len, short_text.len());
1582
1583        // Basic middle truncation: long string with px(100.) budget.
1584        let long_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
1585        let runs = generate_test_runs(&[long_text.len()]);
1586        let (result, _result_runs) =
1587            wrapper.truncate_line(long_text.into(), px(100.), "…", &runs, TruncateFrom::Middle);
1588        assert!(
1589            result.contains('…'),
1590            "Middle-truncated result should contain '…', got: '{}'",
1591            result
1592        );
1593        assert!(
1594            result.chars().count() < long_text.chars().count(),
1595            "Middle-truncated result should be shorter than original"
1596        );
1597        assert_eq!(
1598            result.chars().next(),
1599            long_text.chars().next(),
1600            "Result should start with the same first character as original"
1601        );
1602        assert_eq!(
1603            result.chars().last(),
1604            long_text.chars().last(),
1605            "Result should end with the same last character as original"
1606        );
1607
1608        // Degenerate case: budget so narrow that middle truncation cannot find a valid split.
1609        // Still show the truncation affix instead of returning the original overflowing text.
1610        let text = "abcdef";
1611        let runs = generate_test_runs(&[text.len()]);
1612        let (result, result_runs) =
1613            wrapper.truncate_line(text.into(), px(1.), "…", &runs, TruncateFrom::Middle);
1614        assert_eq!(result.as_ref(), "…");
1615        assert_eq!(result_runs.len(), 1);
1616        assert_eq!(result_runs[0].len, "…".len());
1617
1618        // Run adjustment correctness: multiple runs across the string.
1619        // Verify that the returned runs' lengths sum to result.len().
1620        let multi_run_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
1621        let run_lens = [20, 20, multi_run_text.len() - 40];
1622        let runs = generate_test_runs(&run_lens);
1623        let (result, result_runs) = wrapper.truncate_line(
1624            multi_run_text.into(),
1625            px(100.),
1626            "…",
1627            &runs,
1628            TruncateFrom::Middle,
1629        );
1630        let total_run_len: usize = result_runs.iter().map(|r| r.len).sum();
1631        assert_eq!(
1632            total_run_len,
1633            result.len(),
1634            "Sum of run lengths ({}) should equal result byte length ({})",
1635            total_run_len,
1636            result.len()
1637        );
1638    }
1639
1640    #[test]
1641    fn test_multiline_truncation_trailing_newline() {
1642        let mut wrapper = build_wrapper();
1643
1644        // "hello\nworld\n" with line_clamp(2):
1645        // The trailing newline has no content after it, so no ellipsis.
1646        let text: &str = "hello\nworld\n";
1647        let wrap_width = px(72.);
1648        let max_lines: usize = 2;
1649
1650        let runs = generate_test_runs(&[text.len()]);
1651        let (result, _) = wrapper.truncate_wrapped_line(
1652            text.into(),
1653            wrap_width,
1654            max_lines,
1655            "\u{2026}",
1656            &runs,
1657            TruncateFrom::End,
1658        );
1659
1660        assert!(
1661            !result.ends_with('\u{2026}'),
1662            "Trailing newline with no content should not add ellipsis: '{}'",
1663            result
1664        );
1665    }
1666
1667    #[test]
1668    fn test_multiline_truncation_newline_fits_exactly() {
1669        let mut wrapper = build_wrapper();
1670
1671        // "hello\nworld" with line_clamp(2):
1672        // Exactly 2 lines, no truncation needed.
1673        let text: &str = "hello\nworld";
1674        let wrap_width = px(72.);
1675        let max_lines: usize = 2;
1676
1677        let runs = generate_test_runs(&[text.len()]);
1678        let (result, _) = wrapper.truncate_wrapped_line(
1679            text.into(),
1680            wrap_width,
1681            max_lines,
1682            "\u{2026}",
1683            &runs,
1684            TruncateFrom::End,
1685        );
1686
1687        assert_eq!(
1688            result.as_ref(),
1689            text,
1690            "Text that fits exactly should not be modified: '{}'",
1691            result
1692        );
1693    }
1694}