Skip to main content

gpui/text_system/
line_wrapper.rs

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