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