Skip to main content

gpui_core/text_system/
line_wrapper.rs

1use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, px};
2use collections::HashMap;
3use std::{iter, sync::Arc};
4
5/// Controls how soft-wrapped continuation lines are indented.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum IndentAdjustment {
8    /// No indent - continuation lines start at column 0.
9    NoIndent,
10    /// Match the original line's leading whitespace.
11    #[default]
12    SameIndent,
13    /// Add N extra columns of indent (in space-character widths).
14    ExtraColumns(u32),
15}
16
17/// The GPUI line wrapper, used to wrap lines of text to a given width.
18pub struct LineWrapper {
19    platform_text_system: Arc<dyn PlatformTextSystem>,
20    pub(crate) font_id: FontId,
21    pub(crate) font_size: Pixels,
22    char_widths: CharWidthCache,
23}
24
25/// Cached character widths for one font and size.
26pub(crate) struct CharWidthCache {
27    ascii: [Option<Pixels>; 128],
28    other: HashMap<char, Pixels>,
29}
30
31impl Default for CharWidthCache {
32    fn default() -> Self {
33        Self {
34            ascii: [None; 128],
35            other: HashMap::default(),
36        }
37    }
38}
39
40impl LineWrapper {
41    /// The maximum indent that can be applied to a line.
42    pub const MAX_INDENT: u32 = 256;
43
44    pub(crate) fn new(
45        font_id: FontId,
46        font_size: Pixels,
47        text_system: Arc<dyn PlatformTextSystem>,
48        char_widths: CharWidthCache,
49    ) -> Self {
50        Self {
51            platform_text_system: text_system,
52            font_id,
53            font_size,
54            char_widths,
55        }
56    }
57
58    pub(crate) fn into_char_widths(self) -> CharWidthCache {
59        self.char_widths
60    }
61
62    /// Wrap a line of text to the given width with this wrapper's font and font size.
63    ///
64    /// `indent_adjustment` controls the indent applied to each continuation row.
65    pub fn wrap_line<'a>(
66        &'a mut self,
67        fragments: &'a [LineFragment],
68        wrap_width: Pixels,
69        indent_adjustment: IndentAdjustment,
70    ) -> impl Iterator<Item = Boundary> + 'a {
71        let mut width = px(0.);
72        let mut first_non_whitespace_ix = None;
73        let mut base_indent = None;
74        let mut last_candidate_ix = 0;
75        let mut last_candidate_width = px(0.);
76        let mut last_wrap_ix = 0;
77        let mut prev_c = '\0';
78        let mut index = 0;
79        let mut candidates = fragments
80            .iter()
81            .flat_map(move |fragment| fragment.wrap_boundary_candidates())
82            .peekable();
83        iter::from_fn(move || {
84            for candidate in candidates.by_ref() {
85                let ix = index;
86                index += candidate.len_utf8();
87                let mut new_prev_c = prev_c;
88                let item_width = match candidate {
89                    WrapBoundaryCandidate::Char { character: c } => {
90                        if c == '\n' {
91                            continue;
92                        }
93
94                        if Self::is_word_char(c) {
95                            if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() {
96                                last_candidate_ix = ix;
97                                last_candidate_width = width;
98                            }
99                        } else {
100                            // CJK may not be space separated, e.g.: `Hello world你好世界`
101                            if c != ' ' && first_non_whitespace_ix.is_some() {
102                                last_candidate_ix = ix;
103                                last_candidate_width = width;
104                            }
105                        }
106
107                        if c != ' ' && first_non_whitespace_ix.is_none() {
108                            first_non_whitespace_ix = Some(ix);
109                        }
110
111                        new_prev_c = c;
112
113                        self.width_for_char(c)
114                    }
115                    WrapBoundaryCandidate::Element {
116                        width: element_width,
117                        ..
118                    } => {
119                        if prev_c == ' ' && first_non_whitespace_ix.is_some() {
120                            last_candidate_ix = ix;
121                            last_candidate_width = width;
122                        }
123
124                        if first_non_whitespace_ix.is_none() {
125                            first_non_whitespace_ix = Some(ix);
126                        }
127
128                        element_width
129                    }
130                };
131
132                width += item_width;
133                if width > wrap_width && ix > last_wrap_ix {
134                    let wrap_at_candidate =
135                        last_candidate_ix > 0 && width - last_candidate_width <= wrap_width;
136
137                    let carried_width = if wrap_at_candidate {
138                        width - last_candidate_width
139                    } else {
140                        item_width
141                    };
142
143                    // Compute base indentation from the first non-whitespace character on the
144                    // line and retain it for all subsequent wrap rows. If the line begins with
145                    // leading whitespace that wraps before any non-whitespace character (or is
146                    // all whitespace), base_indent remains None so continuation rows within the
147                    // leading whitespace do not receive ExtraColumns indentation.
148                    if base_indent.is_none()
149                        && let Some(first_non_whitespace_ix) = first_non_whitespace_ix
150                    {
151                        base_indent = Some(
152                            Self::MAX_INDENT
153                                .min(first_non_whitespace_ix.saturating_sub(last_wrap_ix) as u32),
154                        );
155                    }
156
157                    let next_indent = match indent_adjustment {
158                        IndentAdjustment::NoIndent => 0,
159                        IndentAdjustment::SameIndent => base_indent.unwrap_or(0),
160                        IndentAdjustment::ExtraColumns(extra) => {
161                            if let Some(base_indent) = base_indent {
162                                let candidate = base_indent.saturating_add(extra);
163                                let candidate_indent_width =
164                                    self.width_for_char(' ') * candidate as f32;
165                                // Reserve headroom for any carried suffix from an earlier word
166                                // boundary (and at least 2 columns for a full-width character)
167                                // so the continuation line does not immediately exceed wrap width.
168                                let min_headroom =
169                                    carried_width.max(self.width_for_char(' ') * 2.0);
170                                if candidate_indent_width + min_headroom > wrap_width {
171                                    0
172                                } else {
173                                    Self::MAX_INDENT.min(candidate)
174                                }
175                            } else {
176                                0
177                            }
178                        }
179                    };
180
181                    if wrap_at_candidate {
182                        last_wrap_ix = last_candidate_ix;
183                        width -= last_candidate_width;
184                    } else {
185                        last_wrap_ix = ix;
186                        width = item_width;
187                    }
188                    last_candidate_ix = 0;
189
190                    width += self.width_for_char(' ') * next_indent as f32;
191
192                    return Some(Boundary::new(last_wrap_ix, next_indent));
193                }
194
195                prev_c = new_prev_c;
196            }
197
198            None
199        })
200    }
201
202    /// Truncate a line of text to the given width with this wrapper's font and font size.
203    pub fn truncate_line(
204        &mut self,
205        line: SharedString,
206        truncate_width: Pixels,
207        truncation_suffix: &str,
208        runs: &mut Vec<TextRun>,
209    ) -> SharedString {
210        let mut width = px(0.);
211        let suffix_width = truncation_suffix
212            .chars()
213            .map(|c| self.width_for_char(c))
214            .fold(px(0.0), |a, x| a + x);
215        let mut truncate_ix = 0;
216        for (ix, c) in line.char_indices() {
217            if width + suffix_width < truncate_width {
218                truncate_ix = ix;
219            }
220
221            let char_width = self.width_for_char(c);
222            width += char_width;
223
224            if width.floor() > truncate_width {
225                let truncated = line[..truncate_ix]
226                    .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
227                let result = SharedString::from(format!("{}{}", truncated, truncation_suffix));
228                update_runs_after_truncation(&result, truncation_suffix, runs);
229
230                return result;
231            }
232        }
233
234        line
235    }
236
237    pub(crate) fn is_word_char(c: char) -> bool {
238        // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
239        c.is_ascii_alphanumeric() ||
240        // Latin script in Unicode for French, German, Spanish, etc.
241        // Latin-1 Supplement
242        // https://en.wikipedia.org/wiki/Latin-1_Supplement
243        matches!(c, '\u{00C0}'..='\u{00FF}') ||
244        // Latin Extended-A
245        // https://en.wikipedia.org/wiki/Latin_Extended-A
246        matches!(c, '\u{0100}'..='\u{017F}') ||
247        // Latin Extended-B
248        // https://en.wikipedia.org/wiki/Latin_Extended-B
249        matches!(c, '\u{0180}'..='\u{024F}') ||
250        // Cyrillic for Russian, Ukrainian, etc.
251        // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
252        matches!(c, '\u{0400}'..='\u{04FF}') ||
253        // Some other known special characters that should be treated as word characters,
254        // e.g. `a-b`, `var_name`, `I'm`/`won't`, '@mention`, `#hashtag`, `100%`, `3.1415`, `2^3`, `a~b`, etc.
255        matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '!' | ';' | '*') ||
256        // Characters that used in URL, e.g. `https://github.com/zed-industries/zed?a=1&b=2` for better wrapping a long URL.
257        matches!(c,  '/' | ':' | '?' | '&' | '=') ||
258        // Closing punctuation never starts a line (UAX #14 LB13: no break
259        // before `!`, `)`, `]`, `}`, closing quotes or an ellipsis) — `plz!`,
260        // `see)`, `quoted”` wrap as one word instead of orphaning the mark on
261        // the next line. `/` and `?` stay local URL-glue characters so long
262        // paths and query strings (`a/b`, `foo?b=2`) still wrap as one token.
263        matches!(c, ')' | ']' | '}' | '"' | '”' | '»' | '…') ||
264        // `⋯` character is special used in Zed, to keep this at the end of the line.
265        matches!(c, '⋯') ||
266        // Non-breaking glue characters.
267        matches!(c, '\u{202F}' | '\u{00A0}' | '\u{2011}')
268    }
269
270    #[inline(always)]
271    fn width_for_char(&mut self, c: char) -> Pixels {
272        if (c as u32) < 128 {
273            if let Some(cached_width) = self.char_widths.ascii[c as usize] {
274                cached_width
275            } else {
276                let width = self.compute_width_for_char(c);
277                self.char_widths.ascii[c as usize] = Some(width);
278                width
279            }
280        } else if let Some(cached_width) = self.char_widths.other.get(&c) {
281            *cached_width
282        } else {
283            let width = self.compute_width_for_char(c);
284            self.char_widths.other.insert(c, width);
285            width
286        }
287    }
288
289    fn compute_width_for_char(&self, c: char) -> Pixels {
290        let mut buffer = [0; 4];
291        let buffer = c.encode_utf8(&mut buffer);
292        self.platform_text_system
293            .layout_line(
294                buffer,
295                self.font_size,
296                &[FontRun {
297                    len: buffer.len(),
298                    font_id: self.font_id,
299                }],
300            )
301            .width
302    }
303}
304
305fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec<TextRun>) {
306    let mut truncate_at = result.len() - ellipsis.len();
307    let mut run_end = None;
308    for (run_index, run) in runs.iter_mut().enumerate() {
309        if run.len <= truncate_at {
310            truncate_at -= run.len;
311        } else {
312            run.len = truncate_at + ellipsis.len();
313            run_end = Some(run_index + 1);
314            break;
315        }
316    }
317    if let Some(run_end) = run_end {
318        runs.truncate(run_end);
319    }
320}
321
322/// A fragment of a line that can be wrapped.
323pub enum LineFragment<'a> {
324    /// A text fragment consisting of characters.
325    Text {
326        /// The text content of the fragment.
327        text: &'a str,
328    },
329    /// A non-text element with a fixed width.
330    Element {
331        /// The width of the element in pixels.
332        width: Pixels,
333        /// The UTF-8 encoded length of the element.
334        len_utf8: usize,
335    },
336}
337
338impl<'a> LineFragment<'a> {
339    /// Creates a new text fragment from the given text.
340    pub fn text(text: &'a str) -> Self {
341        LineFragment::Text { text }
342    }
343
344    /// Creates a new non-text element with the given width and UTF-8 encoded length.
345    pub fn element(width: Pixels, len_utf8: usize) -> Self {
346        LineFragment::Element { width, len_utf8 }
347    }
348
349    fn wrap_boundary_candidates(&self) -> impl Iterator<Item = WrapBoundaryCandidate> {
350        let text = match self {
351            LineFragment::Text { text } => text,
352            LineFragment::Element { .. } => "\0",
353        };
354        text.chars().map(move |character| {
355            if let LineFragment::Element { width, len_utf8 } = self {
356                WrapBoundaryCandidate::Element {
357                    width: *width,
358                    len_utf8: *len_utf8,
359                }
360            } else {
361                WrapBoundaryCandidate::Char { character }
362            }
363        })
364    }
365}
366
367enum WrapBoundaryCandidate {
368    Char { character: char },
369    Element { width: Pixels, len_utf8: usize },
370}
371
372impl WrapBoundaryCandidate {
373    pub fn len_utf8(&self) -> usize {
374        match self {
375            WrapBoundaryCandidate::Char { character } => character.len_utf8(),
376            WrapBoundaryCandidate::Element { len_utf8: len, .. } => *len,
377        }
378    }
379}
380
381/// A boundary between two lines of text.
382#[derive(Copy, Clone, Debug, PartialEq, Eq)]
383pub struct Boundary {
384    /// The index of the last character in a line
385    pub ix: usize,
386    /// The indent of the next line.
387    pub next_indent: u32,
388}
389
390impl Boundary {
391    fn new(ix: usize, next_indent: u32) -> Self {
392        Self { ix, next_indent }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::{
400        Font, FontFeatures, FontStyle, FontWeight, Hsla, TestAppContext, TestDispatcher, font,
401    };
402    #[cfg(target_os = "macos")]
403    use crate::{TextRun, WindowTextSystem, WrapBoundary};
404    use rand::prelude::*;
405
406    fn build_wrapper() -> LineWrapper {
407        let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
408        let cx = TestAppContext::build(dispatcher, None);
409        let id = cx.text_system().resolve_font(&font(".ZedMono"));
410        LineWrapper::new(
411            id,
412            px(16.),
413            cx.text_system().platform_text_system.clone(),
414            CharWidthCache::default(),
415        )
416    }
417
418    fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
419        input_run_len
420            .iter()
421            .map(|run_len| TextRun {
422                len: *run_len,
423                font: Font {
424                    family: "Dummy".into(),
425                    features: FontFeatures::default(),
426                    fallbacks: None,
427                    weight: FontWeight::default(),
428                    style: FontStyle::Normal,
429                },
430                color: Hsla::default(),
431                background_color: None,
432                underline: None,
433                strikethrough: None,
434            })
435            .collect()
436    }
437
438    #[test]
439    fn test_wrap_line() {
440        let mut wrapper = build_wrapper();
441
442        assert_eq!(
443            wrapper
444                .wrap_line(
445                    &[LineFragment::text("aa bbb cccc ddddd eeee")],
446                    px(72.),
447                    IndentAdjustment::default()
448                )
449                .collect::<Vec<_>>(),
450            &[
451                Boundary::new(7, 0),
452                Boundary::new(12, 0),
453                Boundary::new(18, 0)
454            ],
455        );
456        assert_eq!(
457            wrapper
458                .wrap_line(
459                    &[LineFragment::text("aaa aaaaaaaaaaaaaaaaaa")],
460                    px(72.0),
461                    IndentAdjustment::default()
462                )
463                .collect::<Vec<_>>(),
464            &[
465                Boundary::new(4, 0),
466                Boundary::new(11, 0),
467                Boundary::new(18, 0)
468            ],
469        );
470        assert_eq!(
471            wrapper
472                .wrap_line(
473                    &[LineFragment::text("     aaaaaaa")],
474                    px(72.),
475                    IndentAdjustment::default()
476                )
477                .collect::<Vec<_>>(),
478            &[
479                Boundary::new(7, 5),
480                Boundary::new(9, 5),
481                Boundary::new(11, 5),
482            ]
483        );
484        assert_eq!(
485            wrapper
486                .wrap_line(
487                    &[LineFragment::text("                            ")],
488                    px(72.),
489                    IndentAdjustment::default(),
490                )
491                .collect::<Vec<_>>(),
492            &[
493                Boundary::new(7, 0),
494                Boundary::new(14, 0),
495                Boundary::new(21, 0)
496            ]
497        );
498        assert_eq!(
499            wrapper
500                .wrap_line(
501                    &[LineFragment::text("          aaaaaaaaaaaaaa")],
502                    px(72.),
503                    IndentAdjustment::default()
504                )
505                .collect::<Vec<_>>(),
506            &[
507                Boundary::new(7, 0),
508                Boundary::new(14, 3),
509                Boundary::new(18, 3),
510                Boundary::new(22, 3),
511            ]
512        );
513
514        // Test wrapping multiple text fragments
515        assert_eq!(
516            wrapper
517                .wrap_line(
518                    &[
519                        LineFragment::text("aa bbb "),
520                        LineFragment::text("cccc ddddd eeee")
521                    ],
522                    px(72.),
523                    IndentAdjustment::default(),
524                )
525                .collect::<Vec<_>>(),
526            &[
527                Boundary::new(7, 0),
528                Boundary::new(12, 0),
529                Boundary::new(18, 0)
530            ],
531        );
532
533        // Test wrapping with a mix of text and element fragments
534        assert_eq!(
535            wrapper
536                .wrap_line(
537                    &[
538                        LineFragment::text("aa "),
539                        LineFragment::element(px(20.), 1),
540                        LineFragment::text(" bbb "),
541                        LineFragment::element(px(30.), 1),
542                        LineFragment::text(" cccc")
543                    ],
544                    px(72.),
545                    IndentAdjustment::default(),
546                )
547                .collect::<Vec<_>>(),
548            &[
549                Boundary::new(5, 0),
550                Boundary::new(9, 0),
551                Boundary::new(11, 0)
552            ],
553        );
554
555        // Test with element at the beginning and text afterward
556        assert_eq!(
557            wrapper
558                .wrap_line(
559                    &[
560                        LineFragment::element(px(50.), 1),
561                        LineFragment::text(" aaaa bbbb cccc dddd")
562                    ],
563                    px(72.),
564                    IndentAdjustment::default(),
565                )
566                .collect::<Vec<_>>(),
567            &[
568                Boundary::new(2, 0),
569                Boundary::new(7, 0),
570                Boundary::new(12, 0),
571                Boundary::new(17, 0)
572            ],
573        );
574
575        // Test with a large element that forces wrapping by itself
576        assert_eq!(
577            wrapper
578                .wrap_line(
579                    &[
580                        LineFragment::text("short text "),
581                        LineFragment::element(px(100.), 1),
582                        LineFragment::text(" more text")
583                    ],
584                    px(72.),
585                    IndentAdjustment::default(),
586                )
587                .collect::<Vec<_>>(),
588            &[
589                Boundary::new(6, 0),
590                Boundary::new(11, 0),
591                Boundary::new(12, 0),
592                Boundary::new(18, 0)
593            ],
594        );
595    }
596
597    #[test]
598    fn test_truncate_line() {
599        let mut wrapper = build_wrapper();
600
601        fn perform_test(
602            wrapper: &mut LineWrapper,
603            text: &'static str,
604            result: &'static str,
605            ellipsis: &str,
606        ) {
607            perform_test_with_width(wrapper, text, result, ellipsis, px(220.));
608        }
609
610        fn perform_test_with_width(
611            wrapper: &mut LineWrapper,
612            text: &'static str,
613            result: &'static str,
614            ellipsis: &str,
615            width: Pixels,
616        ) {
617            let dummy_run_lens = vec![text.len()];
618            let mut dummy_runs = generate_test_runs(&dummy_run_lens);
619            assert_eq!(
620                wrapper.truncate_line(text.into(), width, ellipsis, &mut dummy_runs),
621                result
622            );
623            assert_eq!(dummy_runs.first().unwrap().len, result.len());
624        }
625
626        perform_test(
627            &mut wrapper,
628            "aa bbb cccc ddddd eeee ffff gggg",
629            "aa bbb cccc ddddd eeee",
630            "",
631        );
632        perform_test(
633            &mut wrapper,
634            "aa bbb cccc ddddd eeee ffff gggg",
635            "aa bbb cccc ddddd eee…",
636            "…",
637        );
638        perform_test(
639            &mut wrapper,
640            "aa bbb cccc ddddd eeee ffff gggg",
641            "aa bbb cccc dddd......",
642            "......",
643        );
644        perform_test_with_width(
645            &mut wrapper,
646            "aa bbb cccc ddddd. eeee ffff gggg",
647            "aa bbb cccc ddddd…",
648            "…",
649            px(195.),
650        );
651        perform_test_with_width(
652            &mut wrapper,
653            "aa bbb cccc ddddd  eeee ffff gggg",
654            "aa bbb cccc ddddd…",
655            "…",
656            px(195.),
657        );
658    }
659
660    #[test]
661    fn test_truncate_multiple_runs() {
662        let mut wrapper = build_wrapper();
663
664        fn perform_test(
665            wrapper: &mut LineWrapper,
666            text: &'static str,
667            result: &str,
668            run_lens: &[usize],
669            result_run_len: &[usize],
670            line_width: Pixels,
671        ) {
672            let mut dummy_runs = generate_test_runs(run_lens);
673            assert_eq!(
674                wrapper.truncate_line(text.into(), line_width, "…", &mut dummy_runs),
675                result
676            );
677            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
678                assert_eq!(run.len, *result_len);
679            }
680        }
681        // Case 0: Normal
682        // Text: abcdefghijkl
683        // Runs: Run0 { len: 12, ... }
684        //
685        // Truncate res: abcd… (truncate_at = 4)
686        // Run res: Run0 { string: abcd…, len: 7, ... }
687        perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
688        // Case 1: Drop some runs
689        // Text: abcdefghijkl
690        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
691        //
692        // Truncate res: abcdef… (truncate_at = 6)
693        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
694        // 5, ... }
695        perform_test(
696            &mut wrapper,
697            "abcdefghijkl",
698            "abcdef…",
699            &[4, 4, 4],
700            &[4, 5],
701            px(70.),
702        );
703        // Case 2: Truncate at start of some run
704        // Text: abcdefghijkl
705        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
706        //
707        // Truncate res: abcdefgh… (truncate_at = 8)
708        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
709        // 4, ... }, Run2 { string: …, len: 3, ... }
710        perform_test(
711            &mut wrapper,
712            "abcdefghijkl",
713            "abcdefgh…",
714            &[4, 4, 4],
715            &[4, 4, 3],
716            px(90.),
717        );
718    }
719
720    #[test]
721    fn test_update_run_after_truncation() {
722        fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
723            let mut dummy_runs = generate_test_runs(run_lens);
724            update_runs_after_truncation(result, "…", &mut dummy_runs);
725            for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
726                assert_eq!(run.len, *result_len);
727            }
728        }
729        // Case 0: Normal
730        // Text: abcdefghijkl
731        // Runs: Run0 { len: 12, ... }
732        //
733        // Truncate res: abcd… (truncate_at = 4)
734        // Run res: Run0 { string: abcd…, len: 7, ... }
735        perform_test("abcd…", &[12], &[7]);
736        // Case 1: Drop some runs
737        // Text: abcdefghijkl
738        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
739        //
740        // Truncate res: abcdef… (truncate_at = 6)
741        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
742        // 5, ... }
743        perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
744        // Case 2: Truncate at start of some run
745        // Text: abcdefghijkl
746        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
747        //
748        // Truncate res: abcdefgh… (truncate_at = 8)
749        // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
750        // 4, ... }, Run2 { string: …, len: 3, ... }
751        perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
752    }
753
754    #[test]
755    fn test_is_word_char() {
756        #[track_caller]
757        fn assert_word(word: &str) {
758            for c in word.chars() {
759                assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c);
760            }
761        }
762
763        #[track_caller]
764        fn assert_not_word(word: &str) {
765            let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
766            assert!(found, "assertion failed for '{}'", word);
767        }
768
769        assert_word("Hello123");
770        assert_word("non-English");
771        assert_word("var_name");
772        assert_word("123456");
773        assert_word("3.1415");
774        assert_word("10^2");
775        assert_word("1~2");
776        assert_word("100%");
777        assert_word("@mention");
778        assert_word("#hashtag");
779        assert_word("$variable");
780        assert_word("more⋯");
781        assert_word("won’t");
782        assert_word("‘twas");
783        assert_word("plz!");
784        assert_word("see)");
785        assert_word("quoted”");
786        assert_word("well…");
787        assert_word("foo\u{00A0}bar");
788        assert_word("foo\u{202F}bar");
789        assert_word("foo\u{2011}bar");
790
791        // Space
792        assert_not_word("foo bar");
793
794        // URL case
795        assert_word("https://github.com/zed-industries/zed/");
796        assert_word("github.com");
797        assert_word("a=1&b=2");
798
799        // Latin-1 Supplement
800        assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
801        // Latin Extended-A
802        assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
803        // Latin Extended-B
804        assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
805        // Cyrillic
806        assert_word("АБВГДЕЖЗИЙКЛМНОП");
807
808        // non-word characters
809        assert_not_word("你好");
810        assert_not_word("안녕하세요");
811        assert_not_word("こんにちは");
812        assert_not_word("😀😁😂");
813        assert_not_word("()[]{}<>");
814    }
815
816    // These seem to vary wildly based on the text system.
817    #[cfg(target_os = "macos")]
818    #[crate::test]
819    fn test_wrap_shaped_line(cx: &mut TestAppContext) {
820        cx.update(|cx| {
821            let text_system = WindowTextSystem::new(cx.text_system().clone());
822
823            let normal = TextRun {
824                len: 0,
825                font: font("Helvetica"),
826                color: Default::default(),
827                underline: Default::default(),
828                strikethrough: None,
829                background_color: None,
830            };
831            let bold = TextRun {
832                len: 0,
833                font: font("Helvetica").bold(),
834                color: Default::default(),
835                underline: Default::default(),
836                strikethrough: None,
837                background_color: None,
838            };
839
840            let text = "aa bbb cccc ddddd eeee".into();
841            let lines = text_system
842                .shape_text(
843                    text,
844                    px(16.),
845                    &[
846                        normal.with_len(4),
847                        bold.with_len(5),
848                        normal.with_len(6),
849                        bold.with_len(1),
850                        normal.with_len(7),
851                    ],
852                    Some(px(72.)),
853                    None,
854                )
855                .unwrap();
856
857            assert_eq!(
858                lines[0].layout.wrap_boundaries(),
859                &[
860                    WrapBoundary {
861                        run_ix: 0,
862                        glyph_ix: 7
863                    },
864                    WrapBoundary {
865                        run_ix: 0,
866                        glyph_ix: 12
867                    },
868                    WrapBoundary {
869                        run_ix: 0,
870                        glyph_ix: 18
871                    }
872                ],
873            );
874        });
875    }
876
877    #[test]
878    fn test_extra_columns_overflow_guard() {
879        let mut wrapper = build_wrapper();
880        let space_width = wrapper.width_for_char(' ');
881
882        // 6 spaces indent, wrap width 10 columns.
883        let text = "      ab cd ef gh";
884        let wrap_width = space_width * 10.0;
885
886        // When base_indent + extra overflows wrap width (6 + 8 + 2 > 10),
887        // indent must fall back to 0 instead of degrading to one character per row.
888        //
889        // Expected wrapped lines (10 columns):
890        //   |      ab  |  (row 0: 6 spaces + "ab ", len 9)
891        //   |cd ef gh  |  (row 1: 0 indent + "cd ef gh", len 8)
892        assert_eq!(
893            wrapper
894                .wrap_line(
895                    &[LineFragment::text(text)],
896                    wrap_width,
897                    IndentAdjustment::ExtraColumns(8),
898                )
899                .collect::<Vec<_>>(),
900            &[Boundary::new(9, 0)]
901        );
902
903        // When base_indent + extra fits within wrap width (6 + 2 + 2 <= 10),
904        // the extra indent is applied and not clamped.
905        //
906        // Expected wrapped lines (10 columns):
907        //   |      ab  |  (row 0: 6 spaces + "ab ", len 9)
908        //   |        cd|  (row 1: 8 spaces + "cd", len 10)
909        //   |        ef|  (row 2: 8 spaces + "ef", len 10)
910        //   |        gh|  (row 3: 8 spaces + "gh", len 10)
911        assert_eq!(
912            wrapper
913                .wrap_line(
914                    &[LineFragment::text(text)],
915                    wrap_width,
916                    IndentAdjustment::ExtraColumns(2),
917                )
918                .collect::<Vec<_>>(),
919            &[
920                Boundary::new(9, 8),
921                Boundary::new(11, 8),
922                Boundary::new(13, 8),
923                Boundary::new(15, 8),
924            ]
925        );
926
927        // When text contains full-width (two-column) glyphs, reserving headroom for a
928        // two-column character (candidate + 2 > wrap_width) ensures that candidate
929        // indents leaving only 1 column cannot accept the indent and overflow.
930        //
931        // 1 space indent, wrap width 10 columns.
932        // " 🦀🦀🦀🦀🦀🦀🦀🦀" with extra=8 (extra_two with tab_size=4):
933        // candidate = 1 + 8 = 9.
934        // With +2 headroom (9 + 2 > 10), indent falls back to 0.
935        //
936        // Expected wrapped lines (10 columns):
937        //   | 🦀🦀🦀🦀 |  (row 0: 1 space + 4 two-column glyphs, 9 cols)
938        //   |🦀🦀🦀🦀  |  (row 1: 0 indent + 4 two-column glyphs, 8 cols)
939        let full_width_text = " 🦀🦀🦀🦀🦀🦀🦀🦀";
940        assert_eq!(
941            wrapper
942                .wrap_line(
943                    &[LineFragment::text(full_width_text)],
944                    wrap_width,
945                    IndentAdjustment::ExtraColumns(8),
946                )
947                .collect::<Vec<_>>(),
948            &[Boundary::new(17, 0)]
949        );
950
951        // When the carried suffix fits within wrap width alongside extra indent
952        // (1 indent + 9 carried suffix = 10 <= 10), the extra indent is applied.
953        //
954        // Expected wrapped lines (10 columns):
955        //   |a         |  (row 0: "a ", len 2)
956        //   | abcdefghi|  (row 1: 1 indent + "abcdefghi", len 10)
957        assert_eq!(
958            wrapper
959                .wrap_line(
960                    &[LineFragment::text("a abcdefghi")],
961                    wrap_width,
962                    IndentAdjustment::ExtraColumns(1),
963                )
964                .collect::<Vec<_>>(),
965            &[Boundary::new(2, 1)]
966        );
967
968        // When extra indent exceeds wrap width by even 1 column
969        // (2 indent + 9 carried suffix = 11 > 10), indent falls back to 0.
970        //
971        // Expected wrapped lines (10 columns):
972        //   |a         |  (row 0: "a ", len 2)
973        //   |abcdefghi |  (row 1: 0 indent + "abcdefghi", len 9)
974        assert_eq!(
975            wrapper
976                .wrap_line(
977                    &[LineFragment::text("a abcdefghi")],
978                    wrap_width,
979                    IndentAdjustment::ExtraColumns(2),
980                )
981                .collect::<Vec<_>>(),
982            &[Boundary::new(2, 0)]
983        );
984
985        // When text wraps at an earlier word boundary, the carried suffix
986        // must be accounted for so indent + carried_suffix <= wrap_width.
987        //
988        // "a abcdefghij" with wrap_width 10 columns, ExtraColumns(8):
989        // candidate = 0 + 8 = 8.
990        // carried suffix = "abcdefghi" (9 columns).
991        // 8 indent + 9 carried suffix = 17 > 10, so indent falls back to 0.
992        //
993        // Expected wrapped lines (10 columns):
994        //   |a         |  (row 0: "a ", len 2)
995        //   |abcdefghij|  (row 1: 0 indent + "abcdefghij", len 10)
996        let carried_text = "a abcdefghij";
997        assert_eq!(
998            wrapper
999                .wrap_line(
1000                    &[LineFragment::text(carried_text)],
1001                    wrap_width,
1002                    IndentAdjustment::ExtraColumns(8),
1003                )
1004                .collect::<Vec<_>>(),
1005            &[Boundary::new(2, 0)]
1006        );
1007
1008        // When a wider inline element is encountered after the first wrap,
1009        // the overflow guard must evaluate whether the element fits with the extra indent.
1010        // If text continues after the element row and wraps again, subsequent continuation
1011        // lines resume the extra indent if their carried content fits.
1012        //
1013        // "abcdefghijk " followed by an 8-column element and "z 12":
1014        // Row 0: "abcdefghij" (len 10)
1015        // Row 1: "k " (2 cols) with 8 indent (len 10)
1016        // Row 2: element (8 cols) cannot fit with 8 indent (8 + 8 = 16 > 10),
1017        //        so indent falls back to 0. Element (8 cols) + "z " (2 cols) = len 10.
1018        // Row 3: "12" (2 cols) fits with 8 indent (8 + 2 = 10 <= 10).
1019        //
1020        // Expected wrapped lines (10 columns):
1021        //   |abcdefghij|  (row 0: 10 cols)
1022        //   |        k |  (row 1: 8 indent + "k ", len 10)
1023        //   |[ELEMENT]z|  (row 2: 0 indent + [ELEMENT (8)] + "z ", len 10)
1024        //   |        12|  (row 3: 8 indent + "12", len 10)
1025        let element_fragments = [
1026            LineFragment::text("abcdefghijk "),
1027            LineFragment::element(space_width * 8.0, 1),
1028            LineFragment::text("z 12"),
1029        ];
1030        assert_eq!(
1031            wrapper
1032                .wrap_line(
1033                    &element_fragments,
1034                    wrap_width,
1035                    IndentAdjustment::ExtraColumns(8),
1036                )
1037                .collect::<Vec<_>>(),
1038            &[
1039                Boundary::new(10, 8),
1040                Boundary::new(12, 0),
1041                Boundary::new(15, 8),
1042            ]
1043        );
1044
1045        // A line of only whitespace wrapping before any non-whitespace character
1046        // must not have extra columns added to subsequent rows.
1047        let spaces = "                    "; // 20 spaces
1048        assert_eq!(
1049            wrapper
1050                .wrap_line(
1051                    &[LineFragment::text(spaces)],
1052                    wrap_width,
1053                    IndentAdjustment::ExtraColumns(8),
1054                )
1055                .collect::<Vec<_>>(),
1056            &[Boundary::new(10, 0)]
1057        );
1058
1059        // When leading whitespace wraps before the first non-whitespace character,
1060        // base_indent should reflect the leading whitespace on the row where non-whitespace begins.
1061        // 14 spaces followed by "ab cd ef gh", wrap width 10:
1062        // Row 0: 10 spaces (len 10) -> wraps at ix 10 with indent 0
1063        // Row 1: 4 spaces + "ab cd" (len 9) -> wraps at ix 20
1064        //        base_indent is 14 - 10 = 4.
1065        //        With ExtraColumns(2), candidate = 4 + 2 = 6. 6 + 2 (headroom) = 8 <= 10.
1066        //        Row 2 and subsequent rows get indent 6.
1067        let multiline_indent_text = "              ab cd ef gh";
1068        assert_eq!(
1069            wrapper
1070                .wrap_line(
1071                    &[LineFragment::text(multiline_indent_text)],
1072                    wrap_width,
1073                    IndentAdjustment::ExtraColumns(2),
1074                )
1075                .collect::<Vec<_>>(),
1076            &[
1077                Boundary::new(10, 0),
1078                Boundary::new(20, 6),
1079                Boundary::new(23, 6),
1080            ]
1081        );
1082
1083        // When a word boundary precedes an oversized carried suffix that itself
1084        // exceeds wrap_width (e.g. text followed by an inline element), wrapping
1085        // at the earlier candidate would force the continuation row to immediately
1086        // overflow even with 0 indent. The wrapper must reject the candidate and
1087        // break before the overflowing item instead.
1088        //
1089        // "aaaaaaaaaaaaaaaaaaa a" (21 chars: 19 'a's, space, 'a') followed by 10-column element:
1090        // Row 0: "aaaaaaaaaa" (10 chars, len 10) -> wraps at ix 10
1091        // Row 1: "aaaaaaaa" (8 chars) with 2 indent (len 10) -> wraps at ix 18
1092        // Row 2: "a a" (3 chars) with 2 indent (len 5)
1093        //        candidate boundary at ix 20 (space)
1094        //        Then element (10 cols): width becomes 5 + 10 = 15 > wrap_width (10).
1095        //        Candidate would carry 'a' (1 col) + element (10 cols) = 11 cols > 10.
1096        //        Because carried suffix (11) > wrap_width (10), candidate is rejected.
1097        //        Wrapper breaks at ix 21 (before element) with indent 0.
1098        // Row 3: element (10 cols, len 10)
1099        let oversized_suffix_fragments = [
1100            LineFragment::text("aaaaaaaaaaaaaaaaaaa a"),
1101            LineFragment::element(space_width * 10.0, 1),
1102        ];
1103        assert_eq!(
1104            wrapper
1105                .wrap_line(
1106                    &oversized_suffix_fragments,
1107                    wrap_width,
1108                    IndentAdjustment::ExtraColumns(2),
1109                )
1110                .collect::<Vec<_>>(),
1111            &[
1112                Boundary::new(10, 2),
1113                Boundary::new(18, 2),
1114                Boundary::new(21, 0),
1115            ]
1116        );
1117
1118        assert_eq!(
1119            wrapper
1120                .wrap_line(
1121                    &[LineFragment::text("  aaaaaaaa")],
1122                    space_width * 4.0,
1123                    IndentAdjustment::ExtraColumns(u32::MAX),
1124                )
1125                .collect::<Vec<_>>(),
1126            &[Boundary::new(4, 0), Boundary::new(8, 0)]
1127        );
1128    }
1129}