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/// Controls how soft-wrapped continuation lines are indented.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub enum IndentAdjustment {
19    /// No indent - continuation lines start at column 0.
20    NoIndent,
21    /// Match the original line's leading whitespace.
22    #[default]
23    SameIndent,
24    /// Add N extra columns of indent (in space-character widths).
25    ExtraColumns(u32),
26}
27
28/// The GPUI line wrapper, used to wrap lines of text to a given width.
29pub struct LineWrapper {
30    text_system: Arc<TextSystem>,
31    pub(crate) font_id: FontId,
32    pub(crate) font_size: Pixels,
33    cached_ascii_char_widths: [Option<Pixels>; 128],
34    cached_other_char_widths: HashMap<char, Pixels>,
35}
36
37impl LineWrapper {
38    /// The maximum indent that can be applied to a line.
39    pub const MAX_INDENT: u32 = 256;
40
41    pub(crate) fn new(font_id: FontId, font_size: Pixels, text_system: Arc<TextSystem>) -> Self {
42        Self {
43            text_system,
44            font_id,
45            font_size,
46            cached_ascii_char_widths: [None; 128],
47            cached_other_char_widths: HashMap::default(),
48        }
49    }
50
51    /// Wrap a line of text to the given width with this wrapper's font and font size.
52    pub fn wrap_line<'a>(
53        &'a mut self,
54        fragments: &'a [LineFragment],
55        wrap_width: Pixels,
56        indent_adjustment: IndentAdjustment,
57    ) -> impl Iterator<Item = Boundary> + 'a {
58        let mut width = px(0.);
59        let mut first_non_whitespace_ix = None;
60        let mut base_indent = None;
61        let mut last_candidate_ix = 0;
62        let mut last_candidate_width = px(0.);
63        let mut last_wrap_ix = 0;
64        let mut prev_c = '\0';
65        let mut index = 0;
66        let mut candidates = fragments
67            .iter()
68            .flat_map(move |fragment| fragment.wrap_boundary_candidates())
69            .peekable();
70        iter::from_fn(move || {
71            for candidate in candidates.by_ref() {
72                let ix = index;
73                index += candidate.len_utf8();
74                let mut new_prev_c = prev_c;
75                let item_width = match candidate {
76                    WrapBoundaryCandidate::Char { character: c } => {
77                        if c == '\n' {
78                            continue;
79                        }
80
81                        if Self::is_word_char(c) {
82                            if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() {
83                                last_candidate_ix = ix;
84                                last_candidate_width = width;
85                            }
86                        } else {
87                            // CJK may not be space separated, e.g.: `Hello world你好世界`
88                            if c != ' ' && first_non_whitespace_ix.is_some() {
89                                last_candidate_ix = ix;
90                                last_candidate_width = width;
91                            }
92                        }
93
94                        if c != ' ' && first_non_whitespace_ix.is_none() {
95                            first_non_whitespace_ix = Some(ix);
96                        }
97
98                        new_prev_c = c;
99
100                        self.width_for_char(c)
101                    }
102                    WrapBoundaryCandidate::Element {
103                        width: element_width,
104                        ..
105                    } => {
106                        if prev_c == ' ' && first_non_whitespace_ix.is_some() {
107                            last_candidate_ix = ix;
108                            last_candidate_width = width;
109                        }
110
111                        if first_non_whitespace_ix.is_none() {
112                            first_non_whitespace_ix = Some(ix);
113                        }
114
115                        element_width
116                    }
117                };
118
119                width += item_width;
120                if width > wrap_width && ix > last_wrap_ix {
121                    let wrap_at_candidate =
122                        last_candidate_ix > 0 && width - last_candidate_width <= wrap_width;
123
124                    let carried_width = if wrap_at_candidate {
125                        width - last_candidate_width
126                    } else {
127                        item_width
128                    };
129
130                    // Compute base indentation from the first non-whitespace character on the line
131                    // and retain it for all subsequent wrap rows. If the line begins with leading
132                    // whitespace that wraps before any non-whitespace character (or is all whitespace),
133                    // base_indent remains None so continuation rows within the leading whitespace
134                    // do not receive ExtraColumns indentation and degrade into cascading short rows.
135                    if base_indent.is_none() {
136                        if let Some(first_non_whitespace_ix) = first_non_whitespace_ix {
137                            base_indent =
138                                Some(Self::MAX_INDENT.min(
139                                    first_non_whitespace_ix.saturating_sub(last_wrap_ix) as u32,
140                                ));
141                        }
142                    }
143
144                    let next_indent = match indent_adjustment {
145                        IndentAdjustment::NoIndent => 0,
146                        IndentAdjustment::SameIndent => base_indent.unwrap_or(0),
147                        IndentAdjustment::ExtraColumns(extra) => {
148                            if let Some(base_indent) = base_indent {
149                                let candidate = base_indent.saturating_add(extra);
150                                let candidate_indent_width =
151                                    self.width_for_char(' ') * candidate as f32;
152                                // Reserve headroom for any carried suffix from an earlier word boundary
153                                // (and at least 2 columns for a full-width character) so the continuation
154                                // line does not immediately exceed wrap width.
155                                let min_headroom =
156                                    carried_width.max(self.width_for_char(' ') * 2.0);
157                                if candidate_indent_width + min_headroom > wrap_width {
158                                    0
159                                } else {
160                                    Self::MAX_INDENT.min(candidate)
161                                }
162                            } else {
163                                0
164                            }
165                        }
166                    };
167
168                    if wrap_at_candidate {
169                        last_wrap_ix = last_candidate_ix;
170                        width -= last_candidate_width;
171                    } else {
172                        last_wrap_ix = ix;
173                        width = item_width;
174                    }
175                    last_candidate_ix = 0;
176
177                    width += self.width_for_char(' ') * next_indent as f32;
178
179                    return Some(Boundary::new(last_wrap_ix, next_indent));
180                }
181
182                prev_c = new_prev_c;
183            }
184
185            None
186        })
187    }
188
189    /// Determines if a line should be truncated based on its width.
190    ///
191    /// Returns the truncation index in `line`.
192    pub fn should_truncate_line(
193        &mut self,
194        line: &str,
195        truncate_width: Pixels,
196        truncation_affix: &str,
197        truncate_from: TruncateFrom,
198    ) -> Option<usize> {
199        let mut width = px(0.);
200        let suffix_width = truncation_affix
201            .chars()
202            .map(|c| self.width_for_char(c))
203            .fold(px(0.0), |a, x| a + x);
204        let mut truncate_ix = 0;
205
206        match truncate_from {
207            TruncateFrom::Start => {
208                for (ix, c) in line.char_indices().rev() {
209                    if width + suffix_width < truncate_width {
210                        truncate_ix = ix;
211                    }
212
213                    let char_width = self.width_for_char(c);
214                    width += char_width;
215
216                    if width.floor() > truncate_width {
217                        return Some(truncate_ix);
218                    }
219                }
220            }
221            TruncateFrom::End => {
222                for (ix, c) in line.char_indices() {
223                    if width + suffix_width < truncate_width {
224                        truncate_ix = ix;
225                    }
226
227                    let char_width = self.width_for_char(c);
228                    width += char_width;
229
230                    if width.floor() > truncate_width {
231                        return Some(truncate_ix);
232                    }
233                }
234            }
235            TruncateFrom::Middle => {}
236        }
237
238        None
239    }
240
241    fn should_truncate_line_middle(
242        &mut self,
243        line: &str,
244        truncate_width: Pixels,
245        truncation_affix: &str,
246    ) -> Option<(usize, usize)> {
247        let suffix_width = truncation_affix
248            .chars()
249            .map(|c| self.width_for_char(c))
250            .fold(px(0.0), |a, x| a + x);
251
252        let total_width: Pixels = line
253            .chars()
254            .map(|c| self.width_for_char(c))
255            .fold(px(0.0), |a, x| a + x);
256
257        if total_width <= truncate_width {
258            return None;
259        }
260
261        let content_budget = truncate_width - suffix_width;
262        if content_budget <= px(0.) {
263            return Some((0, line.len()));
264        }
265
266        let front_budget = content_budget * (2.0 / 3.0);
267        let back_budget = content_budget - front_budget;
268
269        let mut front_width = px(0.);
270        let mut front_end_ix = 0usize;
271        for (ix, c) in line.char_indices() {
272            let char_width = self.width_for_char(c);
273            if front_width + char_width > front_budget {
274                break;
275            }
276            front_width += char_width;
277            front_end_ix = ix + c.len_utf8();
278        }
279
280        let mut back_width = px(0.);
281        let mut back_start_ix = line.len();
282        for (ix, c) in line.char_indices().rev() {
283            let char_width = self.width_for_char(c);
284            if back_width + char_width > back_budget {
285                break;
286            }
287            back_width += char_width;
288            back_start_ix = ix;
289        }
290
291        if front_end_ix >= back_start_ix {
292            return Some((0, line.len()));
293        }
294
295        Some((front_end_ix, back_start_ix))
296    }
297
298    /// Truncate a line of text to the given width with this wrapper's font and font size.
299    pub fn truncate_line<'a>(
300        &mut self,
301        line: SharedString,
302        truncate_width: Pixels,
303        truncation_affix: &str,
304        runs: &'a [TextRun],
305        truncate_from: TruncateFrom,
306    ) -> (SharedString, Cow<'a, [TextRun]>) {
307        if truncate_from == TruncateFrom::Middle {
308            if let Some((front_end_ix, back_start_ix)) =
309                self.should_truncate_line_middle(&line, truncate_width, truncation_affix)
310            {
311                let result = SharedString::from(format!(
312                    "{}{truncation_affix}{}",
313                    &line[..front_end_ix],
314                    &line[back_start_ix..]
315                ));
316                let mut runs = runs.to_vec();
317                update_runs_after_middle_truncation(
318                    truncation_affix,
319                    &mut runs,
320                    front_end_ix,
321                    back_start_ix,
322                );
323                return (result, Cow::Owned(runs));
324            } else {
325                return (line, Cow::Borrowed(runs));
326            }
327        }
328
329        if let Some(truncate_ix) =
330            self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from)
331        {
332            let result = match truncate_from {
333                TruncateFrom::Start => SharedString::from(format!(
334                    "{truncation_affix}{}",
335                    &line[line.ceil_char_boundary(truncate_ix + 1)..]
336                )),
337                TruncateFrom::End => SharedString::from(format!(
338                    "{}{truncation_affix}",
339                    line[..truncate_ix]
340                        .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
341                )),
342                TruncateFrom::Middle => unreachable!("Middle truncation is handled above"),
343            };
344            let mut runs = runs.to_vec();
345            update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from);
346            (result, Cow::Owned(runs))
347        } else {
348            (line, Cow::Borrowed(runs))
349        }
350    }
351
352    /// Truncate text to fit within a given number of wrapped lines.
353    ///
354    /// Unlike `truncate_line` which treats the text as a flat width budget
355    /// (`width * max_lines`), this method accounts for word-boundary wrapping:
356    /// it walks through characters once, tracking wrap boundaries and the
357    /// truncation point simultaneously. When text overflows on the last
358    /// allowed line, it truncates there and appends the affix.
359    ///
360    /// For `max_lines == 1`, this delegates to `truncate_line`.
361    pub fn truncate_wrapped_line<'a>(
362        &mut self,
363        text: SharedString,
364        wrap_width: Pixels,
365        max_lines: usize,
366        truncation_affix: &str,
367        runs: &'a [TextRun],
368        truncate_from: TruncateFrom,
369    ) -> (SharedString, Cow<'a, [TextRun]>) {
370        if max_lines <= 1 || truncate_from == TruncateFrom::Start {
371            return self.truncate_line(
372                text,
373                wrap_width * max_lines,
374                truncation_affix,
375                runs,
376                truncate_from,
377            );
378        }
379        if truncate_from == TruncateFrom::Middle {
380            return self.truncate_line(text, wrap_width, truncation_affix, runs, truncate_from);
381        }
382
383        let affix_width: Pixels = truncation_affix
384            .chars()
385            .map(|c| self.width_for_char(c))
386            .sum();
387
388        let mut width = px(0.);
389        let mut line = 0usize;
390        let mut first_non_whitespace_ix = None;
391        let mut last_candidate_ix = 0usize;
392        let mut last_candidate_width = px(0.);
393        let mut last_wrap_ix = 0usize;
394        let mut prev_c = '\0';
395        let mut indent: Option<u32> = None;
396        let mut truncate_ix = 0usize;
397
398        for (ix, c) in text.char_indices() {
399            if c == '\n' {
400                if line >= max_lines - 1 && !text[ix + 1..].trim().is_empty() {
401                    // Newline on the last allowed line with real content
402                    // below. Truncate here.
403                    let truncated = text[..truncate_ix]
404                        .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
405                    let result = SharedString::from(format!("{truncated}{truncation_affix}"));
406                    let mut runs = runs.to_vec();
407                    update_runs_after_truncation(
408                        &result,
409                        truncation_affix,
410                        &mut runs,
411                        TruncateFrom::End,
412                    );
413                    return (result, Cow::Owned(runs));
414                }
415
416                // Newline before the last line: it consumes a line.
417                line += 1;
418                width = px(0.);
419                first_non_whitespace_ix = None;
420                last_candidate_ix = 0;
421                last_candidate_width = px(0.);
422                last_wrap_ix = ix + 1;
423                prev_c = '\0';
424                indent = None;
425                truncate_ix = ix + 1;
426                continue;
427            }
428
429            let char_width = self.width_for_char(c);
430
431            if Self::is_word_char(c) {
432                if prev_c == ' ' && first_non_whitespace_ix.is_some() {
433                    last_candidate_ix = ix;
434                    last_candidate_width = width;
435                }
436            } else if c != ' ' && first_non_whitespace_ix.is_some() {
437                last_candidate_ix = ix;
438                last_candidate_width = width;
439            }
440
441            if c != ' ' && first_non_whitespace_ix.is_none() {
442                first_non_whitespace_ix = Some(ix);
443            }
444
445            width += char_width;
446
447            if line < max_lines - 1 {
448                // Before the last line: replicate wrap_line's boundary logic.
449                if width > wrap_width && ix > last_wrap_ix {
450                    if let (None, Some(first_nw)) = (indent, first_non_whitespace_ix) {
451                        indent = Some(Self::MAX_INDENT.min((first_nw - last_wrap_ix) as u32));
452                    }
453
454                    if last_candidate_ix > last_wrap_ix {
455                        last_wrap_ix = last_candidate_ix;
456                        width -= last_candidate_width;
457                        last_candidate_ix = 0;
458                    } else {
459                        last_wrap_ix = ix;
460                        width = char_width;
461                    }
462
463                    if let Some(ind) = indent {
464                        width += self.width_for_char(' ') * ind as f32;
465                    }
466
467                    line += 1;
468                    truncate_ix = last_wrap_ix;
469                }
470            } else {
471                // On the last line: track the furthest point where the affix
472                // still fits, and stop as soon as the line overflows.
473                if width + affix_width <= wrap_width {
474                    truncate_ix = ix + c.len_utf8();
475                }
476
477                if width > wrap_width {
478                    let truncated = text[..truncate_ix]
479                        .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
480                    let result = SharedString::from(format!("{truncated}{truncation_affix}"));
481                    let mut runs = runs.to_vec();
482                    update_runs_after_truncation(
483                        &result,
484                        truncation_affix,
485                        &mut runs,
486                        TruncateFrom::End,
487                    );
488                    return (result, Cow::Owned(runs));
489                }
490            }
491
492            prev_c = c;
493        }
494
495        // Text fits within max_lines without truncation.
496        (text, Cow::Borrowed(runs))
497    }
498
499    /// Any character in this list should be treated as a word character,
500    /// meaning it can be part of a word that should not be wrapped.
501    pub(crate) fn is_word_char(c: char) -> bool {
502        // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
503        c.is_ascii_alphanumeric() ||
504        // Latin script in Unicode for French, German, Spanish, etc.
505        // Latin-1 Supplement
506        // https://en.wikipedia.org/wiki/Latin-1_Supplement
507        matches!(c, '\u{00C0}'..='\u{00FF}') ||
508        // Latin Extended-A
509        // https://en.wikipedia.org/wiki/Latin_Extended-A
510        matches!(c, '\u{0100}'..='\u{017F}') ||
511        // Latin Extended-B
512        // https://en.wikipedia.org/wiki/Latin_Extended-B
513        matches!(c, '\u{0180}'..='\u{024F}') ||
514        // Cyrillic for Russian, Ukrainian, etc.
515        // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
516        matches!(c, '\u{0400}'..='\u{04FF}') ||
517
518        // Vietnamese (https://vietunicode.sourceforge.net/charset/)
519        matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
520        matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
521
522        // Bengali (https://en.wikipedia.org/wiki/Bengali_(Unicode_block))
523        matches!(c, '\u{0980}'..='\u{09FF}') ||
524
525        // Some other known special characters that should be treated as word characters,
526        // e.g. `a-b`, `var_name`, `I'm`/`won’t`, '@mention`, `#hashtag`, `100%`, `3.1415`,
527        // `2^3`, `a~b`, `a=1`, `Self::new`, etc. Trailing punctuation like `,`, `.`, `:`, `;`
528        // is included so it stays attached to the preceding word when wrapping.
529        matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') ||
530        // Closing punctuation never starts a line (UAX #14 LB13: no break
531        // before `!`, `)`, `]`, `}`, closing quotes or an ellipsis) — `plz!`,
532        // `see)`, `quoted”` wrap as one word instead of orphaning the mark on
533        // the next line. `/` and `?` stay break opportunities so long paths
534        // and URLs (`a/b`, `foo?b=2`) can wrap.
535        matches!(c, '!' | ')' | ']' | '}' | '"' | '”' | '»' | '…') ||
536        // `⋯` character is special used in Zed, to keep this at the end of the line.
537        matches!(c, '⋯') ||
538
539        // Non-breaking glue characters
540        matches!(c, '\u{202F}' | '\u{00A0}' | '\u{2011}')
541    }
542
543    #[inline(always)]
544    fn width_for_char(&mut self, c: char) -> Pixels {
545        if (c as u32) < 128 {
546            if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] {
547                cached_width
548            } else {
549                let width = self
550                    .text_system
551                    .layout_width(self.font_id, self.font_size, c);
552                self.cached_ascii_char_widths[c as usize] = Some(width);
553                width
554            }
555        } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) {
556            *cached_width
557        } else {
558            let width = self
559                .text_system
560                .layout_width(self.font_id, self.font_size, c);
561            self.cached_other_char_widths.insert(c, width);
562            width
563        }
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::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font};
742    #[cfg(target_os = "macos")]
743    use crate::{TextRun, WindowTextSystem, WrapBoundary};
744
745    fn build_wrapper() -> LineWrapper {
746        let dispatcher = TestDispatcher::new(0);
747        let cx = TestAppContext::build(dispatcher, None);
748        let id = cx.text_system().resolve_font(&font(".ZedMono"));
749        LineWrapper::new(id, px(16.), cx.text_system().clone())
750    }
751
752    fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
753        input_run_len
754            .iter()
755            .map(|run_len| TextRun {
756                len: *run_len,
757                font: Font {
758                    family: "Dummy".into(),
759                    features: FontFeatures::default(),
760                    fallbacks: None,
761                    weight: FontWeight::default(),
762                    style: FontStyle::Normal,
763                },
764                ..Default::default()
765            })
766            .collect()
767    }
768
769    #[test]
770    fn test_wrap_line() {
771        let mut wrapper = build_wrapper();
772
773        assert_eq!(
774            wrapper
775                .wrap_line(
776                    &[LineFragment::text("aa bbb cccc ddddd eeee")],
777                    px(72.),
778                    IndentAdjustment::default()
779                )
780                .collect::<Vec<_>>(),
781            &[
782                Boundary::new(7, 0),
783                Boundary::new(12, 0),
784                Boundary::new(18, 0)
785            ],
786        );
787        assert_eq!(
788            wrapper
789                .wrap_line(
790                    &[LineFragment::text("aaa aaaaaaaaaaaaaaaaaa")],
791                    px(72.0),
792                    IndentAdjustment::default()
793                )
794                .collect::<Vec<_>>(),
795            &[
796                Boundary::new(4, 0),
797                Boundary::new(11, 0),
798                Boundary::new(18, 0)
799            ],
800        );
801        assert_eq!(
802            wrapper
803                .wrap_line(
804                    &[LineFragment::text("     aaaaaaa")],
805                    px(72.),
806                    IndentAdjustment::default()
807                )
808                .collect::<Vec<_>>(),
809            &[
810                Boundary::new(7, 5),
811                Boundary::new(9, 5),
812                Boundary::new(11, 5),
813            ]
814        );
815        assert_eq!(
816            wrapper
817                .wrap_line(
818                    &[LineFragment::text("                            ")],
819                    px(72.),
820                    IndentAdjustment::default()
821                )
822                .collect::<Vec<_>>(),
823            &[
824                Boundary::new(7, 0),
825                Boundary::new(14, 0),
826                Boundary::new(21, 0)
827            ]
828        );
829        assert_eq!(
830            wrapper
831                .wrap_line(
832                    &[LineFragment::text("          aaaaaaaaaaaaaa")],
833                    px(72.),
834                    IndentAdjustment::default()
835                )
836                .collect::<Vec<_>>(),
837            &[
838                Boundary::new(7, 0),
839                Boundary::new(14, 3),
840                Boundary::new(18, 3),
841                Boundary::new(22, 3),
842            ]
843        );
844
845        // Test wrapping multiple text fragments
846        assert_eq!(
847            wrapper
848                .wrap_line(
849                    &[
850                        LineFragment::text("aa bbb "),
851                        LineFragment::text("cccc ddddd eeee")
852                    ],
853                    px(72.),
854                    IndentAdjustment::default()
855                )
856                .collect::<Vec<_>>(),
857            &[
858                Boundary::new(7, 0),
859                Boundary::new(12, 0),
860                Boundary::new(18, 0)
861            ],
862        );
863
864        // Test wrapping with a mix of text and element fragments
865        assert_eq!(
866            wrapper
867                .wrap_line(
868                    &[
869                        LineFragment::text("aa "),
870                        LineFragment::element(px(20.), 1),
871                        LineFragment::text(" bbb "),
872                        LineFragment::element(px(30.), 1),
873                        LineFragment::text(" cccc")
874                    ],
875                    px(72.),
876                    IndentAdjustment::default()
877                )
878                .collect::<Vec<_>>(),
879            &[
880                Boundary::new(5, 0),
881                Boundary::new(9, 0),
882                Boundary::new(11, 0)
883            ],
884        );
885
886        // Test with element at the beginning and text afterward
887        assert_eq!(
888            wrapper
889                .wrap_line(
890                    &[
891                        LineFragment::element(px(50.), 1),
892                        LineFragment::text(" aaaa bbbb cccc dddd")
893                    ],
894                    px(72.),
895                    IndentAdjustment::default()
896                )
897                .collect::<Vec<_>>(),
898            &[
899                Boundary::new(2, 0),
900                Boundary::new(7, 0),
901                Boundary::new(12, 0),
902                Boundary::new(17, 0)
903            ],
904        );
905
906        // Test with a large element that forces wrapping by itself
907        assert_eq!(
908            wrapper
909                .wrap_line(
910                    &[
911                        LineFragment::text("short text "),
912                        LineFragment::element(px(100.), 1),
913                        LineFragment::text(" more text")
914                    ],
915                    px(72.),
916                    IndentAdjustment::default()
917                )
918                .collect::<Vec<_>>(),
919            &[
920                Boundary::new(6, 0),
921                Boundary::new(11, 0),
922                Boundary::new(12, 0),
923                Boundary::new(18, 0)
924            ],
925        );
926
927        // Test with non-breaking glue characters
928        assert_eq!(
929            wrapper
930                .wrap_line(
931                    &[LineFragment::text("a\u{202F}b\u{00A0}c\u{2011}d e")],
932                    px(72.0),
933                    IndentAdjustment::default()
934                )
935                .collect::<Vec<_>>(),
936            &[Boundary::new(12, 0),], // special chars above take up 3, 2 and 3 bytes, so boundary ends up at 12
937        );
938    }
939
940    #[test]
941    fn test_truncate_line_end() {
942        let mut wrapper = build_wrapper();
943
944        fn perform_test(
945            wrapper: &mut LineWrapper,
946            text: &'static str,
947            expected: &'static str,
948            ellipsis: &str,
949        ) {
950            let dummy_run_lens = vec![text.len()];
951            let dummy_runs = generate_test_runs(&dummy_run_lens);
952            let (result, dummy_runs) = wrapper.truncate_line(
953                text.into(),
954                px(220.),
955                ellipsis,
956                &dummy_runs,
957                TruncateFrom::End,
958            );
959            assert_eq!(result, expected);
960            assert_eq!(dummy_runs.first().unwrap().len, result.len());
961        }
962
963        perform_test(
964            &mut wrapper,
965            "aa bbb cccc ddddd eeee ffff gggg",
966            "aa bbb cccc ddddd eeee",
967            "",
968        );
969        perform_test(
970            &mut wrapper,
971            "aa bbb cccc ddddd eeee ffff gggg",
972            "aa bbb cccc ddddd eee…",
973            "…",
974        );
975        perform_test(
976            &mut wrapper,
977            "aa bbb cccc ddddd eeee ffff gggg",
978            "aa bbb cccc dddd......",
979            "......",
980        );
981        perform_test(
982            &mut wrapper,
983            "aa bbb cccc 🦀🦀🦀🦀🦀 eeee ffff gggg",
984            "aa bbb cccc 🦀🦀🦀🦀…",
985            "…",
986        );
987    }
988
989    #[test]
990    fn test_truncate_line_start() {
991        let mut wrapper = build_wrapper();
992
993        #[track_caller]
994        fn perform_test(
995            wrapper: &mut LineWrapper,
996            text: &'static str,
997            expected: &'static str,
998            ellipsis: &str,
999        ) {
1000            let dummy_run_lens = vec![text.len()];
1001            let dummy_runs = generate_test_runs(&dummy_run_lens);
1002            let (result, dummy_runs) = wrapper.truncate_line(
1003                text.into(),
1004                px(220.),
1005                ellipsis,
1006                &dummy_runs,
1007                TruncateFrom::Start,
1008            );
1009            assert_eq!(result, expected);
1010            assert_eq!(dummy_runs.first().unwrap().len, result.len());
1011        }
1012
1013        perform_test(
1014            &mut wrapper,
1015            "aaaa bbbb cccc ddddd eeee fff gg",
1016            "cccc ddddd eeee fff gg",
1017            "",
1018        );
1019        perform_test(
1020            &mut wrapper,
1021            "aaaa bbbb cccc ddddd eeee fff gg",
1022            "…ccc ddddd eeee fff gg",
1023            "…",
1024        );
1025        perform_test(
1026            &mut wrapper,
1027            "aaaa bbbb cccc ddddd eeee fff gg",
1028            "......dddd eeee fff gg",
1029            "......",
1030        );
1031        perform_test(
1032            &mut wrapper,
1033            "aaaa bbbb cccc 🦀🦀🦀🦀🦀 eeee fff gg",
1034            "…🦀🦀🦀🦀 eeee fff gg",
1035            "…",
1036        );
1037    }
1038
1039    #[test]
1040    fn test_truncate_multiple_runs_end() {
1041        let mut wrapper = build_wrapper();
1042
1043        fn perform_test(
1044            wrapper: &mut LineWrapper,
1045            text: &'static str,
1046            expected: &str,
1047            run_lens: &[usize],
1048            result_run_len: &[usize],
1049            line_width: Pixels,
1050        ) {
1051            let dummy_runs = generate_test_runs(run_lens);
1052            let (result, dummy_runs) =
1053                wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End);
1054            assert_eq!(result, expected);
1055            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
1056                assert_eq!(run.len, *result_len);
1057            }
1058        }
1059        // Case 0: Normal
1060        // Text: abcdefghijkl
1061        // Runs: Run0 { len: 12, ... }
1062        //
1063        // Truncate res: abcd… (truncate_at = 4)
1064        // Run res: Run0 { string: abcd…, len: 7, ... }
1065        perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
1066        // Case 1: Drop some runs
1067        // Text: abcdefghijkl
1068        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1069        //
1070        // Truncate res: abcdef… (truncate_at = 6)
1071        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
1072        // 5, ... }
1073        perform_test(
1074            &mut wrapper,
1075            "abcdefghijkl",
1076            "abcdef…",
1077            &[4, 4, 4],
1078            &[4, 5],
1079            px(70.),
1080        );
1081        // Case 2: Truncate at start of some run
1082        // Text: abcdefghijkl
1083        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1084        //
1085        // Truncate res: abcdefgh… (truncate_at = 8)
1086        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
1087        // 4, ... }, Run2 { string: …, len: 3, ... }
1088        perform_test(
1089            &mut wrapper,
1090            "abcdefghijkl",
1091            "abcdefgh…",
1092            &[4, 4, 4],
1093            &[4, 4, 3],
1094            px(90.),
1095        );
1096    }
1097
1098    #[test]
1099    fn test_truncate_multiple_runs_start() {
1100        let mut wrapper = build_wrapper();
1101
1102        #[track_caller]
1103        fn perform_test(
1104            wrapper: &mut LineWrapper,
1105            text: &'static str,
1106            expected: &str,
1107            run_lens: &[usize],
1108            result_run_len: &[usize],
1109            line_width: Pixels,
1110        ) {
1111            let dummy_runs = generate_test_runs(run_lens);
1112            let (result, dummy_runs) = wrapper.truncate_line(
1113                text.into(),
1114                line_width,
1115                "…",
1116                &dummy_runs,
1117                TruncateFrom::Start,
1118            );
1119            assert_eq!(result, expected);
1120            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
1121                assert_eq!(run.len, *result_len);
1122            }
1123        }
1124        // Case 0: Normal
1125        // Text: abcdefghijkl
1126        // Runs: Run0 { len: 12, ... }
1127        //
1128        // Truncate res: …ijkl (truncate_at = 9)
1129        // Run res: Run0 { string: …ijkl, len: 7, ... }
1130        perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
1131        // Case 1: Drop some runs
1132        // Text: abcdefghijkl
1133        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1134        //
1135        // Truncate res: …ghijkl (truncate_at = 7)
1136        // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len:
1137        // 4, ... }
1138        perform_test(
1139            &mut wrapper,
1140            "abcdefghijkl",
1141            "…ghijkl",
1142            &[4, 4, 4],
1143            &[5, 4],
1144            px(70.),
1145        );
1146        // Case 2: Truncate at start of some run
1147        // Text: abcdefghijkl
1148        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1149        //
1150        // Truncate res: abcdefgh… (truncate_at = 3)
1151        // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len:
1152        // 4, ... }, Run2 { string: ijkl, len: 4, ... }
1153        perform_test(
1154            &mut wrapper,
1155            "abcdefghijkl",
1156            "…efghijkl",
1157            &[4, 4, 4],
1158            &[3, 4, 4],
1159            px(90.),
1160        );
1161    }
1162
1163    #[test]
1164    fn test_update_run_after_truncation_end() {
1165        fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
1166            let mut dummy_runs = generate_test_runs(run_lens);
1167            update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End);
1168            for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
1169                assert_eq!(run.len, *result_len);
1170            }
1171        }
1172        // Case 0: Normal
1173        // Text: abcdefghijkl
1174        // Runs: Run0 { len: 12, ... }
1175        //
1176        // Truncate res: abcd… (truncate_at = 4)
1177        // Run res: Run0 { string: abcd…, len: 7, ... }
1178        perform_test("abcd…", &[12], &[7]);
1179        // Case 1: Drop some runs
1180        // Text: abcdefghijkl
1181        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1182        //
1183        // Truncate res: abcdef… (truncate_at = 6)
1184        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
1185        // 5, ... }
1186        perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
1187        // Case 2: Truncate at start of some run
1188        // Text: abcdefghijkl
1189        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1190        //
1191        // Truncate res: abcdefgh… (truncate_at = 8)
1192        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
1193        // 4, ... }, Run2 { string: …, len: 3, ... }
1194        perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
1195    }
1196
1197    #[test]
1198    fn test_is_word_char() {
1199        #[track_caller]
1200        fn assert_word(word: &str) {
1201            for c in word.chars() {
1202                assert!(
1203                    LineWrapper::is_word_char(c),
1204                    "assertion failed for '{}' (unicode 0x{:x})",
1205                    c,
1206                    c as u32
1207                );
1208            }
1209        }
1210
1211        #[track_caller]
1212        fn assert_not_word(word: &str) {
1213            let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
1214            assert!(found, "assertion failed for '{}'", word);
1215        }
1216
1217        assert_word("Hello123");
1218        assert_word("non-English");
1219        assert_word("var_name");
1220        assert_word("123456");
1221        assert_word("3.1415");
1222        assert_word("10^2");
1223        assert_word("1~2");
1224        assert_word("100%");
1225        assert_word("@mention");
1226        assert_word("#hashtag");
1227        assert_word("$variable");
1228        assert_word("a=1");
1229        assert_word("Self::is_word_char");
1230        assert_word("on;");
1231        assert_word("more⋯");
1232        assert_word("won’t");
1233        assert_word("‘twas");
1234        assert_word("plz!");
1235        assert_word("see)");
1236        assert_word("quoted”");
1237        assert_word("well…");
1238
1239        // Space
1240        assert_not_word("foo bar");
1241
1242        // URL case
1243        assert_word("github.com");
1244        assert_not_word("zed-industries/zed");
1245        assert_not_word("zed-industries\\zed");
1246        assert_not_word("a=1&b=2");
1247        assert_not_word("foo?b=2");
1248
1249        // Latin-1 Supplement
1250        assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
1251        // Latin Extended-A
1252        assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
1253        // Latin Extended-B
1254        assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
1255        // Cyrillic
1256        assert_word("АБВГДЕЖЗИЙКЛМНОП");
1257        // Vietnamese (https://github.com/zed-industries/zed/issues/23245)
1258        assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
1259        // Bengali
1260        assert_word("গিয়েছিলেন");
1261        assert_word("ছেলে");
1262        assert_word("হচ্ছিল");
1263
1264        // non-word characters
1265        assert_not_word("你好");
1266        assert_not_word("안녕하세요");
1267        assert_not_word("こんにちは");
1268        assert_not_word("😀😁😂");
1269        assert_not_word("()[]{}<>");
1270
1271        // Non-breaking ("Glue") characters, see https://www.unicode.org/reports/tr14/
1272        // (https://github.com/zed-industries/zed/issues/59664)
1273        assert_word("\u{202F}"); // NNBSP " "
1274        assert_word("\u{00A0}"); // NBSP " "
1275        assert_word("\u{2011}"); // NBH "‑"
1276    }
1277
1278    // For compatibility with the test macro
1279    #[cfg(target_os = "macos")]
1280    use crate as gpui;
1281
1282    // These seem to vary wildly based on the text system.
1283    #[cfg(target_os = "macos")]
1284    #[crate::test]
1285    fn test_wrap_shaped_line(cx: &mut TestAppContext) {
1286        cx.update(|cx| {
1287            let text_system = WindowTextSystem::new(cx.text_system().clone());
1288
1289            let normal = TextRun {
1290                len: 0,
1291                font: font("Helvetica"),
1292                color: Default::default(),
1293                underline: Default::default(),
1294                ..Default::default()
1295            };
1296            let bold = TextRun {
1297                len: 0,
1298                font: font("Helvetica").bold(),
1299                ..Default::default()
1300            };
1301
1302            let text = "aa bbb cccc ddddd eeee".into();
1303            let lines = text_system
1304                .shape_text(
1305                    text,
1306                    px(16.),
1307                    &[
1308                        normal.with_len(4),
1309                        bold.with_len(5),
1310                        normal.with_len(6),
1311                        bold.with_len(1),
1312                        normal.with_len(7),
1313                    ],
1314                    Some(px(72.)),
1315                    None,
1316                )
1317                .unwrap();
1318
1319            assert_eq!(
1320                lines[0].layout.wrap_boundaries(),
1321                &[
1322                    WrapBoundary {
1323                        run_ix: 0,
1324                        glyph_ix: 7
1325                    },
1326                    WrapBoundary {
1327                        run_ix: 0,
1328                        glyph_ix: 12
1329                    },
1330                    WrapBoundary {
1331                        run_ix: 0,
1332                        glyph_ix: 18
1333                    }
1334                ],
1335            );
1336        });
1337    }
1338
1339    #[test]
1340    fn test_multiline_truncation_fits_within_wrapped_lines() {
1341        let mut wrapper = build_wrapper();
1342
1343        // With .ZedMono at 16px, each char is 9.6px wide.
1344        // wrap_width = 72px fits ~7 chars per line.
1345        //
1346        // "aa bbbbbb cccccc dddddd eeee ffff" with wrap_width=72px wraps as:
1347        //   Line 1: "aa "       (28.8px, wraps because "bbbbbb" won't fit)
1348        //   Line 2: "bbbbbb "   (67.2px)
1349        //   Line 3: "cccccc "   (67.2px)
1350        //   ...
1351        //
1352        // truncate_wrapped_line should wrap first to find line 2 starts at
1353        // "bbbbbb...", then truncate only that line to fit with ellipsis.
1354        let text: &str = "aa bbbbbb cccccc dddddd eeee ffff";
1355        let wrap_width = px(72.);
1356        let max_lines: usize = 2;
1357
1358        let runs = generate_test_runs(&[text.len()]);
1359        let (truncated, _) = wrapper.truncate_wrapped_line(
1360            text.into(),
1361            wrap_width,
1362            max_lines,
1363            "\u{2026}",
1364            &runs,
1365            TruncateFrom::End,
1366        );
1367
1368        // The truncated text, when wrapped, must fit within max_lines lines.
1369        let wrap_count = wrapper
1370            .wrap_line(
1371                &[LineFragment::text(&truncated)],
1372                wrap_width,
1373                IndentAdjustment::default(),
1374            )
1375            .count();
1376
1377        assert!(
1378            wrap_count < max_lines,
1379            "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1380            truncated,
1381            wrap_count + 1,
1382            max_lines
1383        );
1384
1385        // The truncated text should end with the ellipsis.
1386        assert!(
1387            truncated.ends_with('\u{2026}'),
1388            "Truncated text '{}' should end with ellipsis",
1389            truncated
1390        );
1391    }
1392
1393    #[test]
1394    fn test_multiline_truncation_no_truncation_needed() {
1395        let mut wrapper = build_wrapper();
1396
1397        // Text that fits in 2 lines shouldn't be truncated.
1398        // Line 1: "aa bbb " (67.2px), Line 2: "cccccc" (57.6px)
1399        let text: &str = "aa bbb cccccc";
1400        let wrap_width = px(72.);
1401        let max_lines: usize = 2;
1402
1403        let runs = generate_test_runs(&[text.len()]);
1404        let (result, _) = wrapper.truncate_wrapped_line(
1405            text.into(),
1406            wrap_width,
1407            max_lines,
1408            "\u{2026}",
1409            &runs,
1410            TruncateFrom::End,
1411        );
1412
1413        assert_eq!(
1414            result.as_ref(),
1415            text,
1416            "Text that fits should not be modified"
1417        );
1418    }
1419
1420    #[test]
1421    fn test_multiline_truncation_three_lines() {
1422        let mut wrapper = build_wrapper();
1423
1424        let text: &str = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj";
1425        let wrap_width = px(72.);
1426        let max_lines: usize = 3;
1427
1428        let runs = generate_test_runs(&[text.len()]);
1429        let (truncated, _) = wrapper.truncate_wrapped_line(
1430            text.into(),
1431            wrap_width,
1432            max_lines,
1433            "\u{2026}",
1434            &runs,
1435            TruncateFrom::End,
1436        );
1437
1438        let wrap_count = wrapper
1439            .wrap_line(
1440                &[LineFragment::text(&truncated)],
1441                wrap_width,
1442                IndentAdjustment::default(),
1443            )
1444            .count();
1445
1446        assert!(
1447            wrap_count < max_lines,
1448            "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1449            truncated,
1450            wrap_count + 1,
1451            max_lines
1452        );
1453
1454        assert!(
1455            truncated.ends_with('\u{2026}'),
1456            "Truncated text '{}' should end with ellipsis",
1457            truncated
1458        );
1459    }
1460
1461    #[test]
1462    fn test_multiline_truncation_with_newlines() {
1463        let mut wrapper = build_wrapper();
1464
1465        // "hello\nworld foo bar baz" with line_clamp(2):
1466        // shape_text splits on \n, giving physical lines "hello" and
1467        // "world foo bar baz". The newline consumes line 1, so the
1468        // second physical line should be truncated on line 2.
1469        let text: &str = "hello\nworld foo bar baz";
1470        let wrap_width = px(72.);
1471        let max_lines: usize = 2;
1472
1473        let runs = generate_test_runs(&[text.len()]);
1474        let (truncated, _) = wrapper.truncate_wrapped_line(
1475            text.into(),
1476            wrap_width,
1477            max_lines,
1478            "\u{2026}",
1479            &runs,
1480            TruncateFrom::End,
1481        );
1482
1483        // The newline should be preserved.
1484        let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1485        assert_eq!(
1486            parts.len(),
1487            2,
1488            "Newline should be preserved: '{}'",
1489            truncated
1490        );
1491        assert_eq!(parts[0], "hello");
1492
1493        // The second line should fit within wrap_width and end with ellipsis.
1494        let second_line_width: Pixels = parts[1].chars().map(|c| wrapper.width_for_char(c)).sum();
1495        assert!(
1496            second_line_width <= wrap_width,
1497            "Second line '{}' ({}px) exceeds wrap_width ({}px)",
1498            parts[1],
1499            second_line_width,
1500            wrap_width
1501        );
1502        assert!(
1503            truncated.ends_with('\u{2026}'),
1504            "Should end with ellipsis: '{}'",
1505            truncated
1506        );
1507    }
1508
1509    #[test]
1510    fn test_multiline_truncation_newline_on_last_line() {
1511        let mut wrapper = build_wrapper();
1512
1513        // "hello\nworld\nmore" with line_clamp(2):
1514        // Line 1: "hello", Line 2: "world" — but there's a third line,
1515        // so line 2 should be truncated with ellipsis.
1516        let text: &str = "hello\nworld\nmore";
1517        let wrap_width = px(72.);
1518        let max_lines: usize = 2;
1519
1520        let runs = generate_test_runs(&[text.len()]);
1521        let (truncated, _) = wrapper.truncate_wrapped_line(
1522            text.into(),
1523            wrap_width,
1524            max_lines,
1525            "\u{2026}",
1526            &runs,
1527            TruncateFrom::End,
1528        );
1529
1530        let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1531        assert_eq!(parts[0], "hello");
1532        assert!(
1533            truncated.ends_with('\u{2026}'),
1534            "Should end with ellipsis since there's more content: '{}'",
1535            truncated
1536        );
1537    }
1538
1539    #[test]
1540    fn test_truncate_line_middle() {
1541        let mut wrapper = build_wrapper();
1542
1543        // No truncation when text fits within a very wide budget.
1544        let short_text = "hello world";
1545        let runs = generate_test_runs(&[short_text.len()]);
1546        let (result, result_runs) = wrapper.truncate_line(
1547            short_text.into(),
1548            px(10000.),
1549            "…",
1550            &runs,
1551            TruncateFrom::Middle,
1552        );
1553        assert_eq!(result.as_ref(), short_text);
1554        assert_eq!(result_runs.len(), 1);
1555        assert_eq!(result_runs[0].len, short_text.len());
1556
1557        // Basic middle truncation: long string with px(100.) budget.
1558        let long_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
1559        let runs = generate_test_runs(&[long_text.len()]);
1560        let (result, _result_runs) =
1561            wrapper.truncate_line(long_text.into(), px(100.), "…", &runs, TruncateFrom::Middle);
1562        assert!(
1563            result.contains('…'),
1564            "Middle-truncated result should contain '…', got: '{}'",
1565            result
1566        );
1567        assert!(
1568            result.chars().count() < long_text.chars().count(),
1569            "Middle-truncated result should be shorter than original"
1570        );
1571        assert_eq!(
1572            result.chars().next(),
1573            long_text.chars().next(),
1574            "Result should start with the same first character as original"
1575        );
1576        assert_eq!(
1577            result.chars().last(),
1578            long_text.chars().last(),
1579            "Result should end with the same last character as original"
1580        );
1581
1582        // Degenerate case: budget so narrow that middle truncation cannot find a valid split.
1583        // Still show the truncation affix instead of returning the original overflowing text.
1584        let text = "abcdef";
1585        let runs = generate_test_runs(&[text.len()]);
1586        let (result, result_runs) =
1587            wrapper.truncate_line(text.into(), px(1.), "…", &runs, TruncateFrom::Middle);
1588        assert_eq!(result.as_ref(), "…");
1589        assert_eq!(result_runs.len(), 1);
1590        assert_eq!(result_runs[0].len, "…".len());
1591
1592        // Run adjustment correctness: multiple runs across the string.
1593        // Verify that the returned runs' lengths sum to result.len().
1594        let multi_run_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
1595        let run_lens = [20, 20, multi_run_text.len() - 40];
1596        let runs = generate_test_runs(&run_lens);
1597        let (result, result_runs) = wrapper.truncate_line(
1598            multi_run_text.into(),
1599            px(100.),
1600            "…",
1601            &runs,
1602            TruncateFrom::Middle,
1603        );
1604        let total_run_len: usize = result_runs.iter().map(|r| r.len).sum();
1605        assert_eq!(
1606            total_run_len,
1607            result.len(),
1608            "Sum of run lengths ({}) should equal result byte length ({})",
1609            total_run_len,
1610            result.len()
1611        );
1612    }
1613
1614    #[test]
1615    fn test_multiline_truncation_trailing_newline() {
1616        let mut wrapper = build_wrapper();
1617
1618        // "hello\nworld\n" with line_clamp(2):
1619        // The trailing newline has no content after it, so no ellipsis.
1620        let text: &str = "hello\nworld\n";
1621        let wrap_width = px(72.);
1622        let max_lines: usize = 2;
1623
1624        let runs = generate_test_runs(&[text.len()]);
1625        let (result, _) = wrapper.truncate_wrapped_line(
1626            text.into(),
1627            wrap_width,
1628            max_lines,
1629            "\u{2026}",
1630            &runs,
1631            TruncateFrom::End,
1632        );
1633
1634        assert!(
1635            !result.ends_with('\u{2026}'),
1636            "Trailing newline with no content should not add ellipsis: '{}'",
1637            result
1638        );
1639    }
1640
1641    #[test]
1642    fn test_multiline_truncation_newline_fits_exactly() {
1643        let mut wrapper = build_wrapper();
1644
1645        // "hello\nworld" with line_clamp(2):
1646        // Exactly 2 lines, no truncation needed.
1647        let text: &str = "hello\nworld";
1648        let wrap_width = px(72.);
1649        let max_lines: usize = 2;
1650
1651        let runs = generate_test_runs(&[text.len()]);
1652        let (result, _) = wrapper.truncate_wrapped_line(
1653            text.into(),
1654            wrap_width,
1655            max_lines,
1656            "\u{2026}",
1657            &runs,
1658            TruncateFrom::End,
1659        );
1660
1661        assert_eq!(
1662            result.as_ref(),
1663            text,
1664            "Text that fits exactly should not be modified: '{}'",
1665            result
1666        );
1667    }
1668
1669    #[test]
1670    fn test_extra_columns_overflow_guard() {
1671        let mut wrapper = build_wrapper();
1672        let space_width = wrapper.width_for_char(' ');
1673
1674        // 6 spaces indent, wrap width 10 columns.
1675        let text = "      ab cd ef gh";
1676        let wrap_width = space_width * 10.0;
1677
1678        // When base_indent + extra overflows wrap width (6 + 8 + 2 > 10),
1679        // indent must fall back to 0 instead of degrading to one character per row.
1680        //
1681        // Expected wrapped lines (10 columns):
1682        //   |      ab  |  (row 0: 6 spaces + "ab ", len 9)
1683        //   |cd ef gh  |  (row 1: 0 indent + "cd ef gh", len 8)
1684        assert_eq!(
1685            wrapper
1686                .wrap_line(
1687                    &[LineFragment::text(text)],
1688                    wrap_width,
1689                    IndentAdjustment::ExtraColumns(8),
1690                )
1691                .collect::<Vec<_>>(),
1692            &[Boundary::new(9, 0)]
1693        );
1694
1695        // When base_indent + extra fits within wrap width (6 + 2 + 2 <= 10),
1696        // the extra indent is applied and not clamped.
1697        //
1698        // Expected wrapped lines (10 columns):
1699        //   |      ab  |  (row 0: 6 spaces + "ab ", len 9)
1700        //   |        cd|  (row 1: 8 spaces + "cd", len 10)
1701        //   |        ef|  (row 2: 8 spaces + "ef", len 10)
1702        //   |        gh|  (row 3: 8 spaces + "gh", len 10)
1703        assert_eq!(
1704            wrapper
1705                .wrap_line(
1706                    &[LineFragment::text(text)],
1707                    wrap_width,
1708                    IndentAdjustment::ExtraColumns(2),
1709                )
1710                .collect::<Vec<_>>(),
1711            &[
1712                Boundary::new(9, 8),
1713                Boundary::new(11, 8),
1714                Boundary::new(13, 8),
1715                Boundary::new(15, 8),
1716            ]
1717        );
1718
1719        // When text contains full-width (two-column) glyphs, reserving headroom for a
1720        // two-column character (candidate + 2 > wrap_width) ensures that candidate
1721        // indents leaving only 1 column cannot accept the indent and overflow.
1722        //
1723        // 1 space indent, wrap width 10 columns.
1724        // " 🦀🦀🦀🦀🦀🦀🦀🦀" with extra=8 (extra_two with tab_size=4):
1725        // candidate = 1 + 8 = 9.
1726        // With +2 headroom (9 + 2 > 10), indent falls back to 0.
1727        //
1728        // Expected wrapped lines (10 columns):
1729        //   | 🦀🦀🦀🦀 |  (row 0: 1 space + 4 two-column glyphs, 9 cols)
1730        //   |🦀🦀🦀🦀  |  (row 1: 0 indent + 4 two-column glyphs, 8 cols)
1731        let full_width_text = " 🦀🦀🦀🦀🦀🦀🦀🦀";
1732        assert_eq!(
1733            wrapper
1734                .wrap_line(
1735                    &[LineFragment::text(full_width_text)],
1736                    wrap_width,
1737                    IndentAdjustment::ExtraColumns(8),
1738                )
1739                .collect::<Vec<_>>(),
1740            &[Boundary::new(17, 0)]
1741        );
1742
1743        // When the carried suffix fits within wrap width alongside extra indent
1744        // (1 indent + 9 carried suffix = 10 <= 10), the extra indent is applied.
1745        //
1746        // Expected wrapped lines (10 columns):
1747        //   |a         |  (row 0: "a ", len 2)
1748        //   | abcdefghi|  (row 1: 1 indent + "abcdefghi", len 10)
1749        assert_eq!(
1750            wrapper
1751                .wrap_line(
1752                    &[LineFragment::text("a abcdefghi")],
1753                    wrap_width,
1754                    IndentAdjustment::ExtraColumns(1),
1755                )
1756                .collect::<Vec<_>>(),
1757            &[Boundary::new(2, 1)]
1758        );
1759
1760        // When extra indent exceeds wrap width by even 1 column
1761        // (2 indent + 9 carried suffix = 11 > 10), indent falls back to 0.
1762        //
1763        // Expected wrapped lines (10 columns):
1764        //   |a         |  (row 0: "a ", len 2)
1765        //   |abcdefghi |  (row 1: 0 indent + "abcdefghi", len 9)
1766        assert_eq!(
1767            wrapper
1768                .wrap_line(
1769                    &[LineFragment::text("a abcdefghi")],
1770                    wrap_width,
1771                    IndentAdjustment::ExtraColumns(2),
1772                )
1773                .collect::<Vec<_>>(),
1774            &[Boundary::new(2, 0)]
1775        );
1776
1777        // When text wraps at an earlier word boundary, the carried suffix
1778        // must be accounted for so indent + carried_suffix <= wrap_width.
1779        //
1780        // "a abcdefghij" with wrap_width 10 columns, ExtraColumns(8):
1781        // candidate = 0 + 8 = 8.
1782        // carried suffix = "abcdefghi" (9 columns).
1783        // 8 indent + 9 carried suffix = 17 > 10, so indent falls back to 0.
1784        //
1785        // Expected wrapped lines (10 columns):
1786        //   |a         |  (row 0: "a ", len 2)
1787        //   |abcdefghij|  (row 1: 0 indent + "abcdefghij", len 10)
1788        let carried_text = "a abcdefghij";
1789        assert_eq!(
1790            wrapper
1791                .wrap_line(
1792                    &[LineFragment::text(carried_text)],
1793                    wrap_width,
1794                    IndentAdjustment::ExtraColumns(8),
1795                )
1796                .collect::<Vec<_>>(),
1797            &[Boundary::new(2, 0)]
1798        );
1799
1800        // When a wider inline element is encountered after the first wrap,
1801        // the overflow guard must evaluate whether the element fits with the extra indent.
1802        // If text continues after the element row and wraps again, subsequent continuation
1803        // lines resume the extra indent if their carried content fits.
1804        //
1805        // "abcdefghijk " followed by an 8-column element and "z 12":
1806        // Row 0: "abcdefghij" (len 10)
1807        // Row 1: "k " (2 cols) with 8 indent (len 10)
1808        // Row 2: element (8 cols) cannot fit with 8 indent (8 + 8 = 16 > 10),
1809        //        so indent falls back to 0. Element (8 cols) + "z " (2 cols) = len 10.
1810        // Row 3: "12" (2 cols) fits with 8 indent (8 + 2 = 10 <= 10).
1811        //
1812        // Expected wrapped lines (10 columns):
1813        //   |abcdefghij|  (row 0: 10 cols)
1814        //   |        k |  (row 1: 8 indent + "k ", len 10)
1815        //   |[ELEMENT]z|  (row 2: 0 indent + [ELEMENT (8)] + "z ", len 10)
1816        //   |        12|  (row 3: 8 indent + "12", len 10)
1817        let element_fragments = [
1818            LineFragment::text("abcdefghijk "),
1819            LineFragment::element(space_width * 8.0, 1),
1820            LineFragment::text("z 12"),
1821        ];
1822        assert_eq!(
1823            wrapper
1824                .wrap_line(
1825                    &element_fragments,
1826                    wrap_width,
1827                    IndentAdjustment::ExtraColumns(8),
1828                )
1829                .collect::<Vec<_>>(),
1830            &[
1831                Boundary::new(10, 8),
1832                Boundary::new(12, 0),
1833                Boundary::new(15, 8),
1834            ]
1835        );
1836
1837        // A line of only whitespace wrapping before any non-whitespace character
1838        // must not have extra columns added to subsequent rows.
1839        let spaces = "                    "; // 20 spaces
1840        assert_eq!(
1841            wrapper
1842                .wrap_line(
1843                    &[LineFragment::text(spaces)],
1844                    wrap_width,
1845                    IndentAdjustment::ExtraColumns(8),
1846                )
1847                .collect::<Vec<_>>(),
1848            &[Boundary::new(10, 0)]
1849        );
1850
1851        // When leading whitespace wraps before the first non-whitespace character,
1852        // base_indent should reflect the leading whitespace on the row where non-whitespace begins.
1853        // 14 spaces followed by "ab cd ef gh", wrap width 10:
1854        // Row 0: 10 spaces (len 10) -> wraps at ix 10 with indent 0
1855        // Row 1: 4 spaces + "ab cd" (len 9) -> wraps at ix 20
1856        //        base_indent is 14 - 10 = 4.
1857        //        With ExtraColumns(2), candidate = 4 + 2 = 6. 6 + 2 (headroom) = 8 <= 10.
1858        //        Row 2 and subsequent rows get indent 6.
1859        let multiline_indent_text = "              ab cd ef gh";
1860        assert_eq!(
1861            wrapper
1862                .wrap_line(
1863                    &[LineFragment::text(multiline_indent_text)],
1864                    wrap_width,
1865                    IndentAdjustment::ExtraColumns(2),
1866                )
1867                .collect::<Vec<_>>(),
1868            &[
1869                Boundary::new(10, 0),
1870                Boundary::new(20, 6),
1871                Boundary::new(23, 6),
1872            ]
1873        );
1874
1875        // When a word boundary precedes an oversized carried suffix that itself
1876        // exceeds wrap_width (e.g. text followed by an inline element), wrapping
1877        // at the earlier candidate would force the continuation row to immediately
1878        // overflow even with 0 indent. The wrapper must reject the candidate and
1879        // break before the overflowing item instead.
1880        //
1881        // "aaaaaaaaaaaaaaaaaaa a" (21 chars: 19 'a's, space, 'a') followed by 10-column element:
1882        // Row 0: "aaaaaaaaaa" (10 chars, len 10) -> wraps at ix 10
1883        // Row 1: "aaaaaaaa" (8 chars) with 2 indent (len 10) -> wraps at ix 18
1884        // Row 2: "a a" (3 chars) with 2 indent (len 5)
1885        //        candidate boundary at ix 20 (space)
1886        //        Then element (10 cols): width becomes 5 + 10 = 15 > wrap_width (10).
1887        //        Candidate would carry 'a' (1 col) + element (10 cols) = 11 cols > 10.
1888        //        Because carried suffix (11) > wrap_width (10), candidate is rejected.
1889        //        Wrapper breaks at ix 21 (before element) with indent 0.
1890        // Row 3: element (10 cols, len 10)
1891        let oversized_suffix_fragments = [
1892            LineFragment::text("aaaaaaaaaaaaaaaaaaa a"),
1893            LineFragment::element(space_width * 10.0, 1),
1894        ];
1895        assert_eq!(
1896            wrapper
1897                .wrap_line(
1898                    &oversized_suffix_fragments,
1899                    wrap_width,
1900                    IndentAdjustment::ExtraColumns(2),
1901                )
1902                .collect::<Vec<_>>(),
1903            &[
1904                Boundary::new(10, 2),
1905                Boundary::new(18, 2),
1906                Boundary::new(21, 0),
1907            ]
1908        );
1909
1910        assert_eq!(
1911            wrapper
1912                .wrap_line(
1913                    &[LineFragment::text("  aaaaaaaa")],
1914                    space_width * 4.0,
1915                    IndentAdjustment::ExtraColumns(u32::MAX),
1916                )
1917                .collect::<Vec<_>>(),
1918            &[Boundary::new(4, 0), Boundary::new(8, 0)]
1919        );
1920    }
1921}