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