line_wrapper.rs

  1use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem, SharedString};
  2use collections::HashMap;
  3use std::{iter, sync::Arc};
  4
  5/// The GPUI line wrapper, used to wrap lines of text to a given width.
  6pub struct LineWrapper {
  7    platform_text_system: Arc<dyn PlatformTextSystem>,
  8    pub(crate) font_id: FontId,
  9    pub(crate) font_size: Pixels,
 10    cached_ascii_char_widths: [Option<Pixels>; 128],
 11    cached_other_char_widths: HashMap<char, Pixels>,
 12}
 13
 14impl LineWrapper {
 15    /// The maximum indent that can be applied to a line.
 16    pub const MAX_INDENT: u32 = 256;
 17
 18    pub(crate) fn new(
 19        font_id: FontId,
 20        font_size: Pixels,
 21        text_system: Arc<dyn PlatformTextSystem>,
 22    ) -> Self {
 23        Self {
 24            platform_text_system: text_system,
 25            font_id,
 26            font_size,
 27            cached_ascii_char_widths: [None; 128],
 28            cached_other_char_widths: HashMap::default(),
 29        }
 30    }
 31
 32    /// Wrap a line of text to the given width with this wrapper's font and font size.
 33    pub fn wrap_line<'a>(
 34        &'a mut self,
 35        line: &'a str,
 36        wrap_width: Pixels,
 37    ) -> impl Iterator<Item = Boundary> + 'a {
 38        let mut width = px(0.);
 39        let mut first_non_whitespace_ix = None;
 40        let mut indent = None;
 41        let mut last_candidate_ix = 0;
 42        let mut last_candidate_width = px(0.);
 43        let mut last_wrap_ix = 0;
 44        let mut prev_c = '\0';
 45        let mut char_indices = line.char_indices();
 46        iter::from_fn(move || {
 47            for (ix, c) in char_indices.by_ref() {
 48                if c == '\n' {
 49                    continue;
 50                }
 51
 52                if Self::is_word_char(c) {
 53                    if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() {
 54                        last_candidate_ix = ix;
 55                        last_candidate_width = width;
 56                    }
 57                } else {
 58                    // CJK may not be space separated, e.g.: `Hello world你好世界`
 59                    if c != ' ' && first_non_whitespace_ix.is_some() {
 60                        last_candidate_ix = ix;
 61                        last_candidate_width = width;
 62                    }
 63                }
 64
 65                if c != ' ' && first_non_whitespace_ix.is_none() {
 66                    first_non_whitespace_ix = Some(ix);
 67                }
 68
 69                let char_width = self.width_for_char(c);
 70                width += char_width;
 71                if width > wrap_width && ix > last_wrap_ix {
 72                    if let (None, Some(first_non_whitespace_ix)) = (indent, first_non_whitespace_ix)
 73                    {
 74                        indent = Some(
 75                            Self::MAX_INDENT.min((first_non_whitespace_ix - last_wrap_ix) as u32),
 76                        );
 77                    }
 78
 79                    if last_candidate_ix > 0 {
 80                        last_wrap_ix = last_candidate_ix;
 81                        width -= last_candidate_width;
 82                        last_candidate_ix = 0;
 83                    } else {
 84                        last_wrap_ix = ix;
 85                        width = char_width;
 86                    }
 87
 88                    if let Some(indent) = indent {
 89                        width += self.width_for_char(' ') * indent as f32;
 90                    }
 91
 92                    return Some(Boundary::new(last_wrap_ix, indent.unwrap_or(0)));
 93                }
 94                prev_c = c;
 95            }
 96
 97            None
 98        })
 99    }
100
101    /// Truncate a line of text to the given width with this wrapper's font and font size.
102    pub fn truncate_line(
103        &mut self,
104        line: SharedString,
105        truncate_width: Pixels,
106        ellipsis: Option<&str>,
107    ) -> SharedString {
108        let mut width = px(0.);
109        let mut ellipsis_width = px(0.);
110        if let Some(ellipsis) = ellipsis {
111            for c in ellipsis.chars() {
112                ellipsis_width += self.width_for_char(c);
113            }
114        }
115
116        let mut char_indices = line.char_indices();
117        let mut truncate_ix = 0;
118        for (ix, c) in char_indices {
119            if width + ellipsis_width <= truncate_width {
120                truncate_ix = ix;
121            }
122
123            let char_width = self.width_for_char(c);
124            width += char_width;
125
126            if width.floor() > truncate_width {
127                return SharedString::from(format!(
128                    "{}{}",
129                    &line[..truncate_ix],
130                    ellipsis.unwrap_or("")
131                ));
132            }
133        }
134
135        line.clone()
136    }
137
138    pub(crate) fn is_word_char(c: char) -> bool {
139        // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
140        c.is_ascii_alphanumeric() ||
141        // Latin script in Unicode for French, German, Spanish, etc.
142        // Latin-1 Supplement
143        // https://en.wikipedia.org/wiki/Latin-1_Supplement
144        matches!(c, '\u{00C0}'..='\u{00FF}') ||
145        // Latin Extended-A
146        // https://en.wikipedia.org/wiki/Latin_Extended-A
147        matches!(c, '\u{0100}'..='\u{017F}') ||
148        // Latin Extended-B
149        // https://en.wikipedia.org/wiki/Latin_Extended-B
150        matches!(c, '\u{0180}'..='\u{024F}') ||
151        // Cyrillic for Russian, Ukrainian, etc.
152        // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
153        matches!(c, '\u{0400}'..='\u{04FF}') ||
154        // Some other known special characters that should be treated as word characters,
155        // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, `2^3`, `a~b`, etc.
156        matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',') ||
157        // Characters that used in URL, e.g. `https://github.com/zed-industries/zed?a=1&b=2` for better wrapping a long URL.
158        matches!(c,  '/' | ':' | '?' | '&' | '=') ||
159        // `⋯` character is special used in Zed, to keep this at the end of the line.
160        matches!(c, '⋯')
161    }
162
163    #[inline(always)]
164    fn width_for_char(&mut self, c: char) -> Pixels {
165        if (c as u32) < 128 {
166            if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] {
167                cached_width
168            } else {
169                let width = self.compute_width_for_char(c);
170                self.cached_ascii_char_widths[c as usize] = Some(width);
171                width
172            }
173        } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) {
174            *cached_width
175        } else {
176            let width = self.compute_width_for_char(c);
177            self.cached_other_char_widths.insert(c, width);
178            width
179        }
180    }
181
182    fn compute_width_for_char(&self, c: char) -> Pixels {
183        let mut buffer = [0; 4];
184        let buffer = c.encode_utf8(&mut buffer);
185        self.platform_text_system
186            .layout_line(
187                buffer,
188                self.font_size,
189                &[FontRun {
190                    len: buffer.len(),
191                    font_id: self.font_id,
192                }],
193            )
194            .width
195    }
196}
197
198/// A boundary between two lines of text.
199#[derive(Copy, Clone, Debug, PartialEq, Eq)]
200pub struct Boundary {
201    /// The index of the last character in a line
202    pub ix: usize,
203    /// The indent of the next line.
204    pub next_indent: u32,
205}
206
207impl Boundary {
208    fn new(ix: usize, next_indent: u32) -> Self {
209        Self { ix, next_indent }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::{font, TestAppContext, TestDispatcher};
217    #[cfg(target_os = "macos")]
218    use crate::{TextRun, WindowTextSystem, WrapBoundary};
219    use rand::prelude::*;
220
221    fn build_wrapper() -> LineWrapper {
222        let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
223        let cx = TestAppContext::new(dispatcher, None);
224        cx.text_system()
225            .add_fonts(vec![std::fs::read(
226                "../../assets/fonts/plex-mono/ZedPlexMono-Regular.ttf",
227            )
228            .unwrap()
229            .into()])
230            .unwrap();
231        let id = cx.text_system().font_id(&font("Zed Plex Mono")).unwrap();
232        LineWrapper::new(id, px(16.), cx.text_system().platform_text_system.clone())
233    }
234
235    #[test]
236    fn test_wrap_line() {
237        let mut wrapper = build_wrapper();
238
239        assert_eq!(
240            wrapper
241                .wrap_line("aa bbb cccc ddddd eeee", px(72.))
242                .collect::<Vec<_>>(),
243            &[
244                Boundary::new(7, 0),
245                Boundary::new(12, 0),
246                Boundary::new(18, 0)
247            ],
248        );
249        assert_eq!(
250            wrapper
251                .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0))
252                .collect::<Vec<_>>(),
253            &[
254                Boundary::new(4, 0),
255                Boundary::new(11, 0),
256                Boundary::new(18, 0)
257            ],
258        );
259        assert_eq!(
260            wrapper
261                .wrap_line("     aaaaaaa", px(72.))
262                .collect::<Vec<_>>(),
263            &[
264                Boundary::new(7, 5),
265                Boundary::new(9, 5),
266                Boundary::new(11, 5),
267            ]
268        );
269        assert_eq!(
270            wrapper
271                .wrap_line("                            ", px(72.))
272                .collect::<Vec<_>>(),
273            &[
274                Boundary::new(7, 0),
275                Boundary::new(14, 0),
276                Boundary::new(21, 0)
277            ]
278        );
279        assert_eq!(
280            wrapper
281                .wrap_line("          aaaaaaaaaaaaaa", px(72.))
282                .collect::<Vec<_>>(),
283            &[
284                Boundary::new(7, 0),
285                Boundary::new(14, 3),
286                Boundary::new(18, 3),
287                Boundary::new(22, 3),
288            ]
289        );
290    }
291
292    #[test]
293    fn test_truncate_line() {
294        let mut wrapper = build_wrapper();
295
296        assert_eq!(
297            wrapper.truncate_line("aa bbb cccc ddddd eeee ffff gggg".into(), px(220.), None),
298            "aa bbb cccc ddddd eeee"
299        );
300        assert_eq!(
301            wrapper.truncate_line(
302                "aa bbb cccc ddddd eeee ffff gggg".into(),
303                px(220.),
304                Some("")
305            ),
306            "aa bbb cccc ddddd eee…"
307        );
308        assert_eq!(
309            wrapper.truncate_line(
310                "aa bbb cccc ddddd eeee ffff gggg".into(),
311                px(220.),
312                Some("......")
313            ),
314            "aa bbb cccc dddd......"
315        );
316    }
317
318    #[test]
319    fn test_is_word_char() {
320        #[track_caller]
321        fn assert_word(word: &str) {
322            for c in word.chars() {
323                assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c);
324            }
325        }
326
327        #[track_caller]
328        fn assert_not_word(word: &str) {
329            let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
330            assert!(found, "assertion failed for '{}'", word);
331        }
332
333        assert_word("Hello123");
334        assert_word("non-English");
335        assert_word("var_name");
336        assert_word("123456");
337        assert_word("3.1415");
338        assert_word("10^2");
339        assert_word("1~2");
340        assert_word("100%");
341        assert_word("@mention");
342        assert_word("#hashtag");
343        assert_word("$variable");
344        assert_word("more⋯");
345
346        // Space
347        assert_not_word("foo bar");
348
349        // URL case
350        assert_word("https://github.com/zed-industries/zed/");
351        assert_word("github.com");
352        assert_word("a=1&b=2");
353
354        // Latin-1 Supplement
355        assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
356        // Latin Extended-A
357        assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
358        // Latin Extended-B
359        assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
360        // Cyrillic
361        assert_word("АБВГДЕЖЗИЙКЛМНОП");
362
363        // non-word characters
364        assert_not_word("你好");
365        assert_not_word("안녕하세요");
366        assert_not_word("こんにちは");
367        assert_not_word("😀😁😂");
368        assert_not_word("()[]{}<>");
369    }
370
371    // For compatibility with the test macro
372    #[cfg(target_os = "macos")]
373    use crate as gpui;
374
375    // These seem to vary wildly based on the text system.
376    #[cfg(target_os = "macos")]
377    #[crate::test]
378    fn test_wrap_shaped_line(cx: &mut TestAppContext) {
379        cx.update(|cx| {
380            let text_system = WindowTextSystem::new(cx.text_system().clone());
381
382            let normal = TextRun {
383                len: 0,
384                font: font("Helvetica"),
385                color: Default::default(),
386                underline: Default::default(),
387                strikethrough: None,
388                background_color: None,
389            };
390            let bold = TextRun {
391                len: 0,
392                font: font("Helvetica").bold(),
393                color: Default::default(),
394                underline: Default::default(),
395                strikethrough: None,
396                background_color: None,
397            };
398
399            impl TextRun {
400                fn with_len(&self, len: usize) -> Self {
401                    let mut this = self.clone();
402                    this.len = len;
403                    this
404                }
405            }
406
407            let text = "aa bbb cccc ddddd eeee".into();
408            let lines = text_system
409                .shape_text(
410                    text,
411                    px(16.),
412                    &[
413                        normal.with_len(4),
414                        bold.with_len(5),
415                        normal.with_len(6),
416                        bold.with_len(1),
417                        normal.with_len(7),
418                    ],
419                    Some(px(72.)),
420                )
421                .unwrap();
422
423            assert_eq!(
424                lines[0].layout.wrap_boundaries(),
425                &[
426                    WrapBoundary {
427                        run_ix: 1,
428                        glyph_ix: 3
429                    },
430                    WrapBoundary {
431                        run_ix: 2,
432                        glyph_ix: 3
433                    },
434                    WrapBoundary {
435                        run_ix: 4,
436                        glyph_ix: 2
437                    }
438                ],
439            );
440        });
441    }
442}