Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:45:21.790Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

line_wrapper.rs

1578 lines · 53.8 KB · rust
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        // `⋯` character is special used in Zed, to keep this at the end of the line.
480        matches!(c, '⋯') ||
481
482        // Non-breaking glue characters
483        matches!(c, '\u{202F}' | '\u{00A0}' | '\u{2011}')
484    }
485
486    #[inline(always)]
487    fn width_for_char(&mut self, c: char) -> Pixels {
488        if (c as u32) < 128 {
489            if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] {
490                cached_width
491            } else {
492                let width = self
493                    .text_system
494                    .layout_width(self.font_id, self.font_size, c);
495                self.cached_ascii_char_widths[c as usize] = Some(width);
496                width
497            }
498        } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) {
499            *cached_width
500        } else {
501            let width = self
502                .text_system
503                .layout_width(self.font_id, self.font_size, c);
504            self.cached_other_char_widths.insert(c, width);
505            width
506        }
507    }
508}
509
510fn update_runs_after_truncation(
511    result: &str,
512    ellipsis: &str,
513    runs: &mut Vec<TextRun>,
514    truncate_from: TruncateFrom,
515) {
516    let mut truncate_at = result.len() - ellipsis.len();
517    match truncate_from {
518        TruncateFrom::Start => {
519            for (run_index, run) in runs.iter_mut().enumerate().rev() {
520                if run.len <= truncate_at {
521                    truncate_at -= run.len;
522                } else {
523                    run.len = truncate_at + ellipsis.len();
524                    runs.splice(..run_index, std::iter::empty());
525                    break;
526                }
527            }
528        }
529        TruncateFrom::End => {
530            for (run_index, run) in runs.iter_mut().enumerate() {
531                if run.len <= truncate_at {
532                    truncate_at -= run.len;
533                } else {
534                    run.len = truncate_at + ellipsis.len();
535                    runs.truncate(run_index + 1);
536                    break;
537                }
538            }
539        }
540        TruncateFrom::Middle => {
541            unreachable!("Middle truncation calls this function with TruncateFrom::End directly")
542        }
543    }
544}
545
546fn update_runs_after_middle_truncation(
547    ellipsis: &str,
548    runs: &mut Vec<TextRun>,
549    front_end_ix: usize,
550    back_start_ix: usize,
551) {
552    let original_runs = std::mem::take(runs);
553    let mut result_runs: Vec<TextRun> = Vec::with_capacity(original_runs.len());
554
555    // Front segment [0, front_end_ix) + ellipsis: walk forward until the run
556    // that straddles or ends at front_end_ix, then extend that run's length
557    // to include the ellipsis.
558    let mut front_remaining = front_end_ix;
559    let mut front_done = false;
560    for run in &original_runs {
561        if front_done {
562            break;
563        }
564        if run.len <= front_remaining {
565            result_runs.push(run.clone());
566            front_remaining -= run.len;
567        } else {
568            let mut partial = run.clone();
569            partial.len = front_remaining + ellipsis.len();
570            result_runs.push(partial);
571            front_done = true;
572        }
573    }
574    if !front_done {
575        // front_end_ix landed exactly on a run boundary; append ellipsis to
576        // the last front run (or, if the front is empty, to the first back run).
577        if let Some(last) = result_runs.last_mut() {
578            last.len += ellipsis.len();
579        } else if let Some(first) = original_runs.first() {
580            let mut affix_run = first.clone();
581            affix_run.len = ellipsis.len();
582            result_runs.push(affix_run);
583        }
584    }
585
586    // Back segment [back_start_ix, original.len()): skip runs entirely in the
587    // removed middle, keep the rest.
588    let mut byte_pos = 0usize;
589    for run in &original_runs {
590        let run_end = byte_pos + run.len;
591        if run_end > back_start_ix {
592            if byte_pos < back_start_ix {
593                // Run straddles back_start_ix; keep only the tail.
594                let mut partial = run.clone();
595                partial.len = run_end - back_start_ix;
596                result_runs.push(partial);
597            } else {
598                result_runs.push(run.clone());
599            }
600        }
601        byte_pos = run_end;
602    }
603
604    *runs = result_runs;
605}
606
607/// A fragment of a line that can be wrapped.
608pub enum LineFragment<'a> {
609    /// A text fragment consisting of characters.
610    Text {
611        /// The text content of the fragment.
612        text: &'a str,
613    },
614    /// A non-text element with a fixed width.
615    Element {
616        /// The width of the element in pixels.
617        width: Pixels,
618        /// The UTF-8 encoded length of the element.
619        len_utf8: usize,
620    },
621}
622
623impl<'a> LineFragment<'a> {
624    /// Creates a new text fragment from the given text.
625    pub fn text(text: &'a str) -> Self {
626        LineFragment::Text { text }
627    }
628
629    /// Creates a new non-text element with the given width and UTF-8 encoded length.
630    pub fn element(width: Pixels, len_utf8: usize) -> Self {
631        LineFragment::Element { width, len_utf8 }
632    }
633
634    fn wrap_boundary_candidates(&self) -> impl Iterator<Item = WrapBoundaryCandidate> {
635        let text = match self {
636            LineFragment::Text { text } => text,
637            LineFragment::Element { .. } => "\0",
638        };
639        text.chars().map(move |character| {
640            if let LineFragment::Element { width, len_utf8 } = self {
641                WrapBoundaryCandidate::Element {
642                    width: *width,
643                    len_utf8: *len_utf8,
644                }
645            } else {
646                WrapBoundaryCandidate::Char { character }
647            }
648        })
649    }
650}
651
652enum WrapBoundaryCandidate {
653    Char { character: char },
654    Element { width: Pixels, len_utf8: usize },
655}
656
657impl WrapBoundaryCandidate {
658    pub fn len_utf8(&self) -> usize {
659        match self {
660            WrapBoundaryCandidate::Char { character } => character.len_utf8(),
661            WrapBoundaryCandidate::Element { len_utf8: len, .. } => *len,
662        }
663    }
664}
665
666/// A boundary between two lines of text.
667#[derive(Copy, Clone, Debug, PartialEq, Eq)]
668pub struct Boundary {
669    /// The index of the last character in a line
670    pub ix: usize,
671    /// The indent of the next line.
672    pub next_indent: u32,
673}
674
675impl Boundary {
676    fn new(ix: usize, next_indent: u32) -> Self {
677        Self { ix, next_indent }
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use crate::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font};
685    #[cfg(target_os = "macos")]
686    use crate::{TextRun, WindowTextSystem, WrapBoundary};
687
688    fn build_wrapper() -> LineWrapper {
689        let dispatcher = TestDispatcher::new(0);
690        let cx = TestAppContext::build(dispatcher, None);
691        let id = cx.text_system().resolve_font(&font(".ZedMono"));
692        LineWrapper::new(id, px(16.), cx.text_system().clone())
693    }
694
695    fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
696        input_run_len
697            .iter()
698            .map(|run_len| TextRun {
699                len: *run_len,
700                font: Font {
701                    family: "Dummy".into(),
702                    features: FontFeatures::default(),
703                    fallbacks: None,
704                    weight: FontWeight::default(),
705                    style: FontStyle::Normal,
706                },
707                ..Default::default()
708            })
709            .collect()
710    }
711
712    #[test]
713    fn test_wrap_line() {
714        let mut wrapper = build_wrapper();
715
716        assert_eq!(
717            wrapper
718                .wrap_line(&[LineFragment::text("aa bbb cccc ddddd eeee")], px(72.))
719                .collect::<Vec<_>>(),
720            &[
721                Boundary::new(7, 0),
722                Boundary::new(12, 0),
723                Boundary::new(18, 0)
724            ],
725        );
726        assert_eq!(
727            wrapper
728                .wrap_line(&[LineFragment::text("aaa aaaaaaaaaaaaaaaaaa")], px(72.0))
729                .collect::<Vec<_>>(),
730            &[
731                Boundary::new(4, 0),
732                Boundary::new(11, 0),
733                Boundary::new(18, 0)
734            ],
735        );
736        assert_eq!(
737            wrapper
738                .wrap_line(&[LineFragment::text("     aaaaaaa")], px(72.))
739                .collect::<Vec<_>>(),
740            &[
741                Boundary::new(7, 5),
742                Boundary::new(9, 5),
743                Boundary::new(11, 5),
744            ]
745        );
746        assert_eq!(
747            wrapper
748                .wrap_line(
749                    &[LineFragment::text("                            ")],
750                    px(72.)
751                )
752                .collect::<Vec<_>>(),
753            &[
754                Boundary::new(7, 0),
755                Boundary::new(14, 0),
756                Boundary::new(21, 0)
757            ]
758        );
759        assert_eq!(
760            wrapper
761                .wrap_line(&[LineFragment::text("          aaaaaaaaaaaaaa")], px(72.))
762                .collect::<Vec<_>>(),
763            &[
764                Boundary::new(7, 0),
765                Boundary::new(14, 3),
766                Boundary::new(18, 3),
767                Boundary::new(22, 3),
768            ]
769        );
770
771        // Test wrapping multiple text fragments
772        assert_eq!(
773            wrapper
774                .wrap_line(
775                    &[
776                        LineFragment::text("aa bbb "),
777                        LineFragment::text("cccc ddddd eeee")
778                    ],
779                    px(72.)
780                )
781                .collect::<Vec<_>>(),
782            &[
783                Boundary::new(7, 0),
784                Boundary::new(12, 0),
785                Boundary::new(18, 0)
786            ],
787        );
788
789        // Test wrapping with a mix of text and element fragments
790        assert_eq!(
791            wrapper
792                .wrap_line(
793                    &[
794                        LineFragment::text("aa "),
795                        LineFragment::element(px(20.), 1),
796                        LineFragment::text(" bbb "),
797                        LineFragment::element(px(30.), 1),
798                        LineFragment::text(" cccc")
799                    ],
800                    px(72.)
801                )
802                .collect::<Vec<_>>(),
803            &[
804                Boundary::new(5, 0),
805                Boundary::new(9, 0),
806                Boundary::new(11, 0)
807            ],
808        );
809
810        // Test with element at the beginning and text afterward
811        assert_eq!(
812            wrapper
813                .wrap_line(
814                    &[
815                        LineFragment::element(px(50.), 1),
816                        LineFragment::text(" aaaa bbbb cccc dddd")
817                    ],
818                    px(72.)
819                )
820                .collect::<Vec<_>>(),
821            &[
822                Boundary::new(2, 0),
823                Boundary::new(7, 0),
824                Boundary::new(12, 0),
825                Boundary::new(17, 0)
826            ],
827        );
828
829        // Test with a large element that forces wrapping by itself
830        assert_eq!(
831            wrapper
832                .wrap_line(
833                    &[
834                        LineFragment::text("short text "),
835                        LineFragment::element(px(100.), 1),
836                        LineFragment::text(" more text")
837                    ],
838                    px(72.)
839                )
840                .collect::<Vec<_>>(),
841            &[
842                Boundary::new(6, 0),
843                Boundary::new(11, 0),
844                Boundary::new(12, 0),
845                Boundary::new(18, 0)
846            ],
847        );
848
849        // Test with non-breaking glue characters
850        assert_eq!(
851            wrapper
852                .wrap_line(
853                    &[LineFragment::text("a\u{202F}b\u{00A0}c\u{2011}d e")],
854                    px(72.0)
855                )
856                .collect::<Vec<_>>(),
857            &[Boundary::new(12, 0),], // special chars above take up 3, 2 and 3 bytes, so boundary ends up at 12
858        );
859    }
860
861    #[test]
862    fn test_truncate_line_end() {
863        let mut wrapper = build_wrapper();
864
865        fn perform_test(
866            wrapper: &mut LineWrapper,
867            text: &'static str,
868            expected: &'static str,
869            ellipsis: &str,
870        ) {
871            let dummy_run_lens = vec![text.len()];
872            let dummy_runs = generate_test_runs(&dummy_run_lens);
873            let (result, dummy_runs) = wrapper.truncate_line(
874                text.into(),
875                px(220.),
876                ellipsis,
877                &dummy_runs,
878                TruncateFrom::End,
879            );
880            assert_eq!(result, expected);
881            assert_eq!(dummy_runs.first().unwrap().len, result.len());
882        }
883
884        perform_test(
885            &mut wrapper,
886            "aa bbb cccc ddddd eeee ffff gggg",
887            "aa bbb cccc ddddd eeee",
888            "",
889        );
890        perform_test(
891            &mut wrapper,
892            "aa bbb cccc ddddd eeee ffff gggg",
893            "aa bbb cccc ddddd eee…",
894            "…",
895        );
896        perform_test(
897            &mut wrapper,
898            "aa bbb cccc ddddd eeee ffff gggg",
899            "aa bbb cccc dddd......",
900            "......",
901        );
902        perform_test(
903            &mut wrapper,
904            "aa bbb cccc 🦀🦀🦀🦀🦀 eeee ffff gggg",
905            "aa bbb cccc 🦀🦀🦀🦀…",
906            "…",
907        );
908    }
909
910    #[test]
911    fn test_truncate_line_start() {
912        let mut wrapper = build_wrapper();
913
914        #[track_caller]
915        fn perform_test(
916            wrapper: &mut LineWrapper,
917            text: &'static str,
918            expected: &'static str,
919            ellipsis: &str,
920        ) {
921            let dummy_run_lens = vec![text.len()];
922            let dummy_runs = generate_test_runs(&dummy_run_lens);
923            let (result, dummy_runs) = wrapper.truncate_line(
924                text.into(),
925                px(220.),
926                ellipsis,
927                &dummy_runs,
928                TruncateFrom::Start,
929            );
930            assert_eq!(result, expected);
931            assert_eq!(dummy_runs.first().unwrap().len, result.len());
932        }
933
934        perform_test(
935            &mut wrapper,
936            "aaaa bbbb cccc ddddd eeee fff gg",
937            "cccc ddddd eeee fff gg",
938            "",
939        );
940        perform_test(
941            &mut wrapper,
942            "aaaa bbbb cccc ddddd eeee fff gg",
943            "…ccc ddddd eeee fff gg",
944            "…",
945        );
946        perform_test(
947            &mut wrapper,
948            "aaaa bbbb cccc ddddd eeee fff gg",
949            "......dddd eeee fff gg",
950            "......",
951        );
952        perform_test(
953            &mut wrapper,
954            "aaaa bbbb cccc 🦀🦀🦀🦀🦀 eeee fff gg",
955            "…🦀🦀🦀🦀 eeee fff gg",
956            "…",
957        );
958    }
959
960    #[test]
961    fn test_truncate_multiple_runs_end() {
962        let mut wrapper = build_wrapper();
963
964        fn perform_test(
965            wrapper: &mut LineWrapper,
966            text: &'static str,
967            expected: &str,
968            run_lens: &[usize],
969            result_run_len: &[usize],
970            line_width: Pixels,
971        ) {
972            let dummy_runs = generate_test_runs(run_lens);
973            let (result, dummy_runs) =
974                wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End);
975            assert_eq!(result, expected);
976            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
977                assert_eq!(run.len, *result_len);
978            }
979        }
980        // Case 0: Normal
981        // Text: abcdefghijkl
982        // Runs: Run0 { len: 12, ... }
983        //
984        // Truncate res: abcd… (truncate_at = 4)
985        // Run res: Run0 { string: abcd…, len: 7, ... }
986        perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
987        // Case 1: Drop some runs
988        // Text: abcdefghijkl
989        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
990        //
991        // Truncate res: abcdef… (truncate_at = 6)
992        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
993        // 5, ... }
994        perform_test(
995            &mut wrapper,
996            "abcdefghijkl",
997            "abcdef…",
998            &[4, 4, 4],
999            &[4, 5],
1000            px(70.),
1001        );
1002        // Case 2: Truncate at start of some run
1003        // Text: abcdefghijkl
1004        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1005        //
1006        // Truncate res: abcdefgh… (truncate_at = 8)
1007        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
1008        // 4, ... }, Run2 { string: …, len: 3, ... }
1009        perform_test(
1010            &mut wrapper,
1011            "abcdefghijkl",
1012            "abcdefgh…",
1013            &[4, 4, 4],
1014            &[4, 4, 3],
1015            px(90.),
1016        );
1017    }
1018
1019    #[test]
1020    fn test_truncate_multiple_runs_start() {
1021        let mut wrapper = build_wrapper();
1022
1023        #[track_caller]
1024        fn perform_test(
1025            wrapper: &mut LineWrapper,
1026            text: &'static str,
1027            expected: &str,
1028            run_lens: &[usize],
1029            result_run_len: &[usize],
1030            line_width: Pixels,
1031        ) {
1032            let dummy_runs = generate_test_runs(run_lens);
1033            let (result, dummy_runs) = wrapper.truncate_line(
1034                text.into(),
1035                line_width,
1036                "…",
1037                &dummy_runs,
1038                TruncateFrom::Start,
1039            );
1040            assert_eq!(result, expected);
1041            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
1042                assert_eq!(run.len, *result_len);
1043            }
1044        }
1045        // Case 0: Normal
1046        // Text: abcdefghijkl
1047        // Runs: Run0 { len: 12, ... }
1048        //
1049        // Truncate res: …ijkl (truncate_at = 9)
1050        // Run res: Run0 { string: …ijkl, len: 7, ... }
1051        perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
1052        // Case 1: Drop some runs
1053        // Text: abcdefghijkl
1054        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1055        //
1056        // Truncate res: …ghijkl (truncate_at = 7)
1057        // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len:
1058        // 4, ... }
1059        perform_test(
1060            &mut wrapper,
1061            "abcdefghijkl",
1062            "…ghijkl",
1063            &[4, 4, 4],
1064            &[5, 4],
1065            px(70.),
1066        );
1067        // Case 2: Truncate at start of some run
1068        // Text: abcdefghijkl
1069        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1070        //
1071        // Truncate res: abcdefgh… (truncate_at = 3)
1072        // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len:
1073        // 4, ... }, Run2 { string: ijkl, len: 4, ... }
1074        perform_test(
1075            &mut wrapper,
1076            "abcdefghijkl",
1077            "…efghijkl",
1078            &[4, 4, 4],
1079            &[3, 4, 4],
1080            px(90.),
1081        );
1082    }
1083
1084    #[test]
1085    fn test_update_run_after_truncation_end() {
1086        fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
1087            let mut dummy_runs = generate_test_runs(run_lens);
1088            update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End);
1089            for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
1090                assert_eq!(run.len, *result_len);
1091            }
1092        }
1093        // Case 0: Normal
1094        // Text: abcdefghijkl
1095        // Runs: Run0 { len: 12, ... }
1096        //
1097        // Truncate res: abcd… (truncate_at = 4)
1098        // Run res: Run0 { string: abcd…, len: 7, ... }
1099        perform_test("abcd…", &[12], &[7]);
1100        // Case 1: Drop some runs
1101        // Text: abcdefghijkl
1102        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1103        //
1104        // Truncate res: abcdef… (truncate_at = 6)
1105        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
1106        // 5, ... }
1107        perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
1108        // Case 2: Truncate at start of some run
1109        // Text: abcdefghijkl
1110        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1111        //
1112        // Truncate res: abcdefgh… (truncate_at = 8)
1113        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
1114        // 4, ... }, Run2 { string: …, len: 3, ... }
1115        perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
1116    }
1117
1118    #[test]
1119    fn test_is_word_char() {
1120        #[track_caller]
1121        fn assert_word(word: &str) {
1122            for c in word.chars() {
1123                assert!(
1124                    LineWrapper::is_word_char(c),
1125                    "assertion failed for '{}' (unicode 0x{:x})",
1126                    c,
1127                    c as u32
1128                );
1129            }
1130        }
1131
1132        #[track_caller]
1133        fn assert_not_word(word: &str) {
1134            let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
1135            assert!(found, "assertion failed for '{}'", word);
1136        }
1137
1138        assert_word("Hello123");
1139        assert_word("non-English");
1140        assert_word("var_name");
1141        assert_word("123456");
1142        assert_word("3.1415");
1143        assert_word("10^2");
1144        assert_word("1~2");
1145        assert_word("100%");
1146        assert_word("@mention");
1147        assert_word("#hashtag");
1148        assert_word("$variable");
1149        assert_word("a=1");
1150        assert_word("Self::is_word_char");
1151        assert_word("on;");
1152        assert_word("more⋯");
1153        assert_word("won’t");
1154        assert_word("‘twas");
1155
1156        // Space
1157        assert_not_word("foo bar");
1158
1159        // URL case
1160        assert_word("github.com");
1161        assert_not_word("zed-industries/zed");
1162        assert_not_word("zed-industries\\zed");
1163        assert_not_word("a=1&b=2");
1164        assert_not_word("foo?b=2");
1165
1166        // Latin-1 Supplement
1167        assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
1168        // Latin Extended-A
1169        assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
1170        // Latin Extended-B
1171        assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
1172        // Cyrillic
1173        assert_word("АБВГДЕЖЗИЙКЛМНОП");
1174        // Vietnamese (https://github.com/zed-industries/zed/issues/23245)
1175        assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
1176        // Bengali
1177        assert_word("গিয়েছিলেন");
1178        assert_word("ছেলে");
1179        assert_word("হচ্ছিল");
1180
1181        // non-word characters
1182        assert_not_word("你好");
1183        assert_not_word("안녕하세요");
1184        assert_not_word("こんにちは");
1185        assert_not_word("😀😁😂");
1186        assert_not_word("()[]{}<>");
1187
1188        // Non-breaking ("Glue") characters, see https://www.unicode.org/reports/tr14/
1189        // (https://github.com/zed-industries/zed/issues/59664)
1190        assert_word("\u{202F}"); // NNBSP " "
1191        assert_word("\u{00A0}"); // NBSP " "
1192        assert_word("\u{2011}"); // NBH "‑"
1193    }
1194
1195    // For compatibility with the test macro
1196    #[cfg(target_os = "macos")]
1197    use crate as gpui;
1198
1199    // These seem to vary wildly based on the text system.
1200    #[cfg(target_os = "macos")]
1201    #[crate::test]
1202    fn test_wrap_shaped_line(cx: &mut TestAppContext) {
1203        cx.update(|cx| {
1204            let text_system = WindowTextSystem::new(cx.text_system().clone());
1205
1206            let normal = TextRun {
1207                len: 0,
1208                font: font("Helvetica"),
1209                color: Default::default(),
1210                underline: Default::default(),
1211                ..Default::default()
1212            };
1213            let bold = TextRun {
1214                len: 0,
1215                font: font("Helvetica").bold(),
1216                ..Default::default()
1217            };
1218
1219            let text = "aa bbb cccc ddddd eeee".into();
1220            let lines = text_system
1221                .shape_text(
1222                    text,
1223                    px(16.),
1224                    &[
1225                        normal.with_len(4),
1226                        bold.with_len(5),
1227                        normal.with_len(6),
1228                        bold.with_len(1),
1229                        normal.with_len(7),
1230                    ],
1231                    Some(px(72.)),
1232                    None,
1233                )
1234                .unwrap();
1235
1236            assert_eq!(
1237                lines[0].layout.wrap_boundaries(),
1238                &[
1239                    WrapBoundary {
1240                        run_ix: 0,
1241                        glyph_ix: 7
1242                    },
1243                    WrapBoundary {
1244                        run_ix: 0,
1245                        glyph_ix: 12
1246                    },
1247                    WrapBoundary {
1248                        run_ix: 0,
1249                        glyph_ix: 18
1250                    }
1251                ],
1252            );
1253        });
1254    }
1255
1256    #[test]
1257    fn test_multiline_truncation_fits_within_wrapped_lines() {
1258        let mut wrapper = build_wrapper();
1259
1260        // With .ZedMono at 16px, each char is 9.6px wide.
1261        // wrap_width = 72px fits ~7 chars per line.
1262        //
1263        // "aa bbbbbb cccccc dddddd eeee ffff" with wrap_width=72px wraps as:
1264        //   Line 1: "aa "       (28.8px, wraps because "bbbbbb" won't fit)
1265        //   Line 2: "bbbbbb "   (67.2px)
1266        //   Line 3: "cccccc "   (67.2px)
1267        //   ...
1268        //
1269        // truncate_wrapped_line should wrap first to find line 2 starts at
1270        // "bbbbbb...", then truncate only that line to fit with ellipsis.
1271        let text: &str = "aa bbbbbb cccccc dddddd eeee ffff";
1272        let wrap_width = px(72.);
1273        let max_lines: usize = 2;
1274
1275        let runs = generate_test_runs(&[text.len()]);
1276        let (truncated, _) = wrapper.truncate_wrapped_line(
1277            text.into(),
1278            wrap_width,
1279            max_lines,
1280            "\u{2026}",
1281            &runs,
1282            TruncateFrom::End,
1283        );
1284
1285        // The truncated text, when wrapped, must fit within max_lines lines.
1286        let wrap_count = wrapper
1287            .wrap_line(&[LineFragment::text(&truncated)], wrap_width)
1288            .count();
1289
1290        assert!(
1291            wrap_count < max_lines,
1292            "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1293            truncated,
1294            wrap_count + 1,
1295            max_lines
1296        );
1297
1298        // The truncated text should end with the ellipsis.
1299        assert!(
1300            truncated.ends_with('\u{2026}'),
1301            "Truncated text '{}' should end with ellipsis",
1302            truncated
1303        );
1304    }
1305
1306    #[test]
1307    fn test_multiline_truncation_no_truncation_needed() {
1308        let mut wrapper = build_wrapper();
1309
1310        // Text that fits in 2 lines shouldn't be truncated.
1311        // Line 1: "aa bbb " (67.2px), Line 2: "cccccc" (57.6px)
1312        let text: &str = "aa bbb cccccc";
1313        let wrap_width = px(72.);
1314        let max_lines: usize = 2;
1315
1316        let runs = generate_test_runs(&[text.len()]);
1317        let (result, _) = wrapper.truncate_wrapped_line(
1318            text.into(),
1319            wrap_width,
1320            max_lines,
1321            "\u{2026}",
1322            &runs,
1323            TruncateFrom::End,
1324        );
1325
1326        assert_eq!(
1327            result.as_ref(),
1328            text,
1329            "Text that fits should not be modified"
1330        );
1331    }
1332
1333    #[test]
1334    fn test_multiline_truncation_three_lines() {
1335        let mut wrapper = build_wrapper();
1336
1337        let text: &str = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj";
1338        let wrap_width = px(72.);
1339        let max_lines: usize = 3;
1340
1341        let runs = generate_test_runs(&[text.len()]);
1342        let (truncated, _) = wrapper.truncate_wrapped_line(
1343            text.into(),
1344            wrap_width,
1345            max_lines,
1346            "\u{2026}",
1347            &runs,
1348            TruncateFrom::End,
1349        );
1350
1351        let wrap_count = wrapper
1352            .wrap_line(&[LineFragment::text(&truncated)], wrap_width)
1353            .count();
1354
1355        assert!(
1356            wrap_count < max_lines,
1357            "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1358            truncated,
1359            wrap_count + 1,
1360            max_lines
1361        );
1362
1363        assert!(
1364            truncated.ends_with('\u{2026}'),
1365            "Truncated text '{}' should end with ellipsis",
1366            truncated
1367        );
1368    }
1369
1370    #[test]
1371    fn test_multiline_truncation_with_newlines() {
1372        let mut wrapper = build_wrapper();
1373
1374        // "hello\nworld foo bar baz" with line_clamp(2):
1375        // shape_text splits on \n, giving physical lines "hello" and
1376        // "world foo bar baz". The newline consumes line 1, so the
1377        // second physical line should be truncated on line 2.
1378        let text: &str = "hello\nworld foo bar baz";
1379        let wrap_width = px(72.);
1380        let max_lines: usize = 2;
1381
1382        let runs = generate_test_runs(&[text.len()]);
1383        let (truncated, _) = wrapper.truncate_wrapped_line(
1384            text.into(),
1385            wrap_width,
1386            max_lines,
1387            "\u{2026}",
1388            &runs,
1389            TruncateFrom::End,
1390        );
1391
1392        // The newline should be preserved.
1393        let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1394        assert_eq!(
1395            parts.len(),
1396            2,
1397            "Newline should be preserved: '{}'",
1398            truncated
1399        );
1400        assert_eq!(parts[0], "hello");
1401
1402        // The second line should fit within wrap_width and end with ellipsis.
1403        let second_line_width: Pixels = parts[1].chars().map(|c| wrapper.width_for_char(c)).sum();
1404        assert!(
1405            second_line_width <= wrap_width,
1406            "Second line '{}' ({}px) exceeds wrap_width ({}px)",
1407            parts[1],
1408            second_line_width,
1409            wrap_width
1410        );
1411        assert!(
1412            truncated.ends_with('\u{2026}'),
1413            "Should end with ellipsis: '{}'",
1414            truncated
1415        );
1416    }
1417
1418    #[test]
1419    fn test_multiline_truncation_newline_on_last_line() {
1420        let mut wrapper = build_wrapper();
1421
1422        // "hello\nworld\nmore" with line_clamp(2):
1423        // Line 1: "hello", Line 2: "world" — but there's a third line,
1424        // so line 2 should be truncated with ellipsis.
1425        let text: &str = "hello\nworld\nmore";
1426        let wrap_width = px(72.);
1427        let max_lines: usize = 2;
1428
1429        let runs = generate_test_runs(&[text.len()]);
1430        let (truncated, _) = wrapper.truncate_wrapped_line(
1431            text.into(),
1432            wrap_width,
1433            max_lines,
1434            "\u{2026}",
1435            &runs,
1436            TruncateFrom::End,
1437        );
1438
1439        let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1440        assert_eq!(parts[0], "hello");
1441        assert!(
1442            truncated.ends_with('\u{2026}'),
1443            "Should end with ellipsis since there's more content: '{}'",
1444            truncated
1445        );
1446    }
1447
1448    #[test]
1449    fn test_truncate_line_middle() {
1450        let mut wrapper = build_wrapper();
1451
1452        // No truncation when text fits within a very wide budget.
1453        let short_text = "hello world";
1454        let runs = generate_test_runs(&[short_text.len()]);
1455        let (result, result_runs) = wrapper.truncate_line(
1456            short_text.into(),
1457            px(10000.),
1458            "…",
1459            &runs,
1460            TruncateFrom::Middle,
1461        );
1462        assert_eq!(result.as_ref(), short_text);
1463        assert_eq!(result_runs.len(), 1);
1464        assert_eq!(result_runs[0].len, short_text.len());
1465
1466        // Basic middle truncation: long string with px(100.) budget.
1467        let long_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
1468        let runs = generate_test_runs(&[long_text.len()]);
1469        let (result, _result_runs) =
1470            wrapper.truncate_line(long_text.into(), px(100.), "…", &runs, TruncateFrom::Middle);
1471        assert!(
1472            result.contains('…'),
1473            "Middle-truncated result should contain '…', got: '{}'",
1474            result
1475        );
1476        assert!(
1477            result.chars().count() < long_text.chars().count(),
1478            "Middle-truncated result should be shorter than original"
1479        );
1480        assert_eq!(
1481            result.chars().next(),
1482            long_text.chars().next(),
1483            "Result should start with the same first character as original"
1484        );
1485        assert_eq!(
1486            result.chars().last(),
1487            long_text.chars().last(),
1488            "Result should end with the same last character as original"
1489        );
1490
1491        // Degenerate case: budget so narrow that middle truncation cannot find a valid split.
1492        // Still show the truncation affix instead of returning the original overflowing text.
1493        let text = "abcdef";
1494        let runs = generate_test_runs(&[text.len()]);
1495        let (result, result_runs) =
1496            wrapper.truncate_line(text.into(), px(1.), "…", &runs, TruncateFrom::Middle);
1497        assert_eq!(result.as_ref(), "…");
1498        assert_eq!(result_runs.len(), 1);
1499        assert_eq!(result_runs[0].len, "…".len());
1500
1501        // Run adjustment correctness: multiple runs across the string.
1502        // Verify that the returned runs' lengths sum to result.len().
1503        let multi_run_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
1504        let run_lens = [20, 20, multi_run_text.len() - 40];
1505        let runs = generate_test_runs(&run_lens);
1506        let (result, result_runs) = wrapper.truncate_line(
1507            multi_run_text.into(),
1508            px(100.),
1509            "…",
1510            &runs,
1511            TruncateFrom::Middle,
1512        );
1513        let total_run_len: usize = result_runs.iter().map(|r| r.len).sum();
1514        assert_eq!(
1515            total_run_len,
1516            result.len(),
1517            "Sum of run lengths ({}) should equal result byte length ({})",
1518            total_run_len,
1519            result.len()
1520        );
1521    }
1522
1523    #[test]
1524    fn test_multiline_truncation_trailing_newline() {
1525        let mut wrapper = build_wrapper();
1526
1527        // "hello\nworld\n" with line_clamp(2):
1528        // The trailing newline has no content after it, so no ellipsis.
1529        let text: &str = "hello\nworld\n";
1530        let wrap_width = px(72.);
1531        let max_lines: usize = 2;
1532
1533        let runs = generate_test_runs(&[text.len()]);
1534        let (result, _) = wrapper.truncate_wrapped_line(
1535            text.into(),
1536            wrap_width,
1537            max_lines,
1538            "\u{2026}",
1539            &runs,
1540            TruncateFrom::End,
1541        );
1542
1543        assert!(
1544            !result.ends_with('\u{2026}'),
1545            "Trailing newline with no content should not add ellipsis: '{}'",
1546            result
1547        );
1548    }
1549
1550    #[test]
1551    fn test_multiline_truncation_newline_fits_exactly() {
1552        let mut wrapper = build_wrapper();
1553
1554        // "hello\nworld" with line_clamp(2):
1555        // Exactly 2 lines, no truncation needed.
1556        let text: &str = "hello\nworld";
1557        let wrap_width = px(72.);
1558        let max_lines: usize = 2;
1559
1560        let runs = generate_test_runs(&[text.len()]);
1561        let (result, _) = wrapper.truncate_wrapped_line(
1562            text.into(),
1563            wrap_width,
1564            max_lines,
1565            "\u{2026}",
1566            &runs,
1567            TruncateFrom::End,
1568        );
1569
1570        assert_eq!(
1571            result.as_ref(),
1572            text,
1573            "Text that fits exactly should not be modified: '{}'",
1574            result
1575        );
1576    }
1577}
1578
Served at tenant.openagents/omega Member data and write actions are omitted.