1use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, px};
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 runs: &mut Vec<TextRun>,
108 ) -> SharedString {
109 let mut width = px(0.);
110 let mut ellipsis_width = px(0.);
111 if let Some(ellipsis) = ellipsis {
112 for c in ellipsis.chars() {
113 ellipsis_width += self.width_for_char(c);
114 }
115 }
116
117 let mut char_indices = line.char_indices();
118 let mut truncate_ix = 0;
119 for (ix, c) in char_indices {
120 if width + ellipsis_width < truncate_width {
121 truncate_ix = ix;
122 }
123
124 let char_width = self.width_for_char(c);
125 width += char_width;
126
127 if width.floor() > truncate_width {
128 let ellipsis = ellipsis.unwrap_or("");
129 let result = SharedString::from(format!("{}{}", &line[..truncate_ix], ellipsis));
130 update_runs_after_truncation(&result, ellipsis, runs);
131
132 return result;
133 }
134 }
135
136 line
137 }
138
139 pub(crate) fn is_word_char(c: char) -> bool {
140 // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
141 c.is_ascii_alphanumeric() ||
142 // Latin script in Unicode for French, German, Spanish, etc.
143 // Latin-1 Supplement
144 // https://en.wikipedia.org/wiki/Latin-1_Supplement
145 matches!(c, '\u{00C0}'..='\u{00FF}') ||
146 // Latin Extended-A
147 // https://en.wikipedia.org/wiki/Latin_Extended-A
148 matches!(c, '\u{0100}'..='\u{017F}') ||
149 // Latin Extended-B
150 // https://en.wikipedia.org/wiki/Latin_Extended-B
151 matches!(c, '\u{0180}'..='\u{024F}') ||
152 // Cyrillic for Russian, Ukrainian, etc.
153 // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
154 matches!(c, '\u{0400}'..='\u{04FF}') ||
155 // Some other known special characters that should be treated as word characters,
156 // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, `2^3`, `a~b`, etc.
157 matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',') ||
158 // Characters that used in URL, e.g. `https://github.com/zed-industries/zed?a=1&b=2` for better wrapping a long URL.
159 matches!(c, '/' | ':' | '?' | '&' | '=') ||
160 // `⋯` character is special used in Zed, to keep this at the end of the line.
161 matches!(c, '⋯')
162 }
163
164 #[inline(always)]
165 fn width_for_char(&mut self, c: char) -> Pixels {
166 if (c as u32) < 128 {
167 if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] {
168 cached_width
169 } else {
170 let width = self.compute_width_for_char(c);
171 self.cached_ascii_char_widths[c as usize] = Some(width);
172 width
173 }
174 } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) {
175 *cached_width
176 } else {
177 let width = self.compute_width_for_char(c);
178 self.cached_other_char_widths.insert(c, width);
179 width
180 }
181 }
182
183 fn compute_width_for_char(&self, c: char) -> Pixels {
184 let mut buffer = [0; 4];
185 let buffer = c.encode_utf8(&mut buffer);
186 self.platform_text_system
187 .layout_line(
188 buffer,
189 self.font_size,
190 &[FontRun {
191 len: buffer.len(),
192 font_id: self.font_id,
193 }],
194 )
195 .width
196 }
197}
198
199fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec<TextRun>) {
200 let mut truncate_at = result.len() - ellipsis.len();
201 let mut run_end = None;
202 for (run_index, run) in runs.iter_mut().enumerate() {
203 if run.len <= truncate_at {
204 truncate_at -= run.len;
205 } else {
206 run.len = truncate_at + ellipsis.len();
207 run_end = Some(run_index + 1);
208 break;
209 }
210 }
211 if let Some(run_end) = run_end {
212 runs.truncate(run_end);
213 }
214}
215
216/// A boundary between two lines of text.
217#[derive(Copy, Clone, Debug, PartialEq, Eq)]
218pub struct Boundary {
219 /// The index of the last character in a line
220 pub ix: usize,
221 /// The indent of the next line.
222 pub next_indent: u32,
223}
224
225impl Boundary {
226 fn new(ix: usize, next_indent: u32) -> Self {
227 Self { ix, next_indent }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::{
235 Font, FontFeatures, FontStyle, FontWeight, Hsla, TestAppContext, TestDispatcher, font,
236 };
237 #[cfg(target_os = "macos")]
238 use crate::{TextRun, WindowTextSystem, WrapBoundary};
239 use rand::prelude::*;
240
241 fn build_wrapper() -> LineWrapper {
242 let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
243 let cx = TestAppContext::new(dispatcher, None);
244 cx.text_system()
245 .add_fonts(vec![
246 std::fs::read("../../assets/fonts/plex-mono/ZedPlexMono-Regular.ttf")
247 .unwrap()
248 .into(),
249 ])
250 .unwrap();
251 let id = cx.text_system().font_id(&font("Zed Plex Mono")).unwrap();
252 LineWrapper::new(id, px(16.), cx.text_system().platform_text_system.clone())
253 }
254
255 fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
256 input_run_len
257 .iter()
258 .map(|run_len| TextRun {
259 len: *run_len,
260 font: Font {
261 family: "Dummy".into(),
262 features: FontFeatures::default(),
263 fallbacks: None,
264 weight: FontWeight::default(),
265 style: FontStyle::Normal,
266 },
267 color: Hsla::default(),
268 background_color: None,
269 underline: None,
270 strikethrough: None,
271 })
272 .collect()
273 }
274
275 #[test]
276 fn test_wrap_line() {
277 let mut wrapper = build_wrapper();
278
279 assert_eq!(
280 wrapper
281 .wrap_line("aa bbb cccc ddddd eeee", px(72.))
282 .collect::<Vec<_>>(),
283 &[
284 Boundary::new(7, 0),
285 Boundary::new(12, 0),
286 Boundary::new(18, 0)
287 ],
288 );
289 assert_eq!(
290 wrapper
291 .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0))
292 .collect::<Vec<_>>(),
293 &[
294 Boundary::new(4, 0),
295 Boundary::new(11, 0),
296 Boundary::new(18, 0)
297 ],
298 );
299 assert_eq!(
300 wrapper
301 .wrap_line(" aaaaaaa", px(72.))
302 .collect::<Vec<_>>(),
303 &[
304 Boundary::new(7, 5),
305 Boundary::new(9, 5),
306 Boundary::new(11, 5),
307 ]
308 );
309 assert_eq!(
310 wrapper
311 .wrap_line(" ", px(72.))
312 .collect::<Vec<_>>(),
313 &[
314 Boundary::new(7, 0),
315 Boundary::new(14, 0),
316 Boundary::new(21, 0)
317 ]
318 );
319 assert_eq!(
320 wrapper
321 .wrap_line(" aaaaaaaaaaaaaa", px(72.))
322 .collect::<Vec<_>>(),
323 &[
324 Boundary::new(7, 0),
325 Boundary::new(14, 3),
326 Boundary::new(18, 3),
327 Boundary::new(22, 3),
328 ]
329 );
330 }
331
332 #[test]
333 fn test_truncate_line() {
334 let mut wrapper = build_wrapper();
335
336 fn perform_test(
337 wrapper: &mut LineWrapper,
338 text: &'static str,
339 result: &'static str,
340 ellipsis: Option<&str>,
341 ) {
342 let dummy_run_lens = vec![text.len()];
343 let mut dummy_runs = generate_test_runs(&dummy_run_lens);
344 assert_eq!(
345 wrapper.truncate_line(text.into(), px(220.), ellipsis, &mut dummy_runs),
346 result
347 );
348 assert_eq!(dummy_runs.first().unwrap().len, result.len());
349 }
350
351 perform_test(
352 &mut wrapper,
353 "aa bbb cccc ddddd eeee ffff gggg",
354 "aa bbb cccc ddddd eeee",
355 None,
356 );
357 perform_test(
358 &mut wrapper,
359 "aa bbb cccc ddddd eeee ffff gggg",
360 "aa bbb cccc ddddd eee…",
361 Some("…"),
362 );
363 perform_test(
364 &mut wrapper,
365 "aa bbb cccc ddddd eeee ffff gggg",
366 "aa bbb cccc dddd......",
367 Some("......"),
368 );
369 }
370
371 #[test]
372 fn test_truncate_multiple_runs() {
373 let mut wrapper = build_wrapper();
374
375 fn perform_test(
376 wrapper: &mut LineWrapper,
377 text: &'static str,
378 result: &str,
379 run_lens: &[usize],
380 result_run_len: &[usize],
381 line_width: Pixels,
382 ) {
383 let mut dummy_runs = generate_test_runs(run_lens);
384 assert_eq!(
385 wrapper.truncate_line(text.into(), line_width, Some("…"), &mut dummy_runs),
386 result
387 );
388 for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
389 assert_eq!(run.len, *result_len);
390 }
391 }
392 // Case 0: Normal
393 // Text: abcdefghijkl
394 // Runs: Run0 { len: 12, ... }
395 //
396 // Truncate res: abcd… (truncate_at = 4)
397 // Run res: Run0 { string: abcd…, len: 7, ... }
398 perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
399 // Case 1: Drop some runs
400 // Text: abcdefghijkl
401 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
402 //
403 // Truncate res: abcdef… (truncate_at = 6)
404 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
405 // 5, ... }
406 perform_test(
407 &mut wrapper,
408 "abcdefghijkl",
409 "abcdef…",
410 &[4, 4, 4],
411 &[4, 5],
412 px(70.),
413 );
414 // Case 2: Truncate at start of some run
415 // Text: abcdefghijkl
416 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
417 //
418 // Truncate res: abcdefgh… (truncate_at = 8)
419 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
420 // 4, ... }, Run2 { string: …, len: 3, ... }
421 perform_test(
422 &mut wrapper,
423 "abcdefghijkl",
424 "abcdefgh…",
425 &[4, 4, 4],
426 &[4, 4, 3],
427 px(90.),
428 );
429 }
430
431 #[test]
432 fn test_update_run_after_truncation() {
433 fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
434 let mut dummy_runs = generate_test_runs(run_lens);
435 update_runs_after_truncation(result, "…", &mut dummy_runs);
436 for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
437 assert_eq!(run.len, *result_len);
438 }
439 }
440 // Case 0: Normal
441 // Text: abcdefghijkl
442 // Runs: Run0 { len: 12, ... }
443 //
444 // Truncate res: abcd… (truncate_at = 4)
445 // Run res: Run0 { string: abcd…, len: 7, ... }
446 perform_test("abcd…", &[12], &[7]);
447 // Case 1: Drop some runs
448 // Text: abcdefghijkl
449 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
450 //
451 // Truncate res: abcdef… (truncate_at = 6)
452 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
453 // 5, ... }
454 perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
455 // Case 2: Truncate at start of some run
456 // Text: abcdefghijkl
457 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
458 //
459 // Truncate res: abcdefgh… (truncate_at = 8)
460 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
461 // 4, ... }, Run2 { string: …, len: 3, ... }
462 perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
463 }
464
465 #[test]
466 fn test_is_word_char() {
467 #[track_caller]
468 fn assert_word(word: &str) {
469 for c in word.chars() {
470 assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c);
471 }
472 }
473
474 #[track_caller]
475 fn assert_not_word(word: &str) {
476 let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
477 assert!(found, "assertion failed for '{}'", word);
478 }
479
480 assert_word("Hello123");
481 assert_word("non-English");
482 assert_word("var_name");
483 assert_word("123456");
484 assert_word("3.1415");
485 assert_word("10^2");
486 assert_word("1~2");
487 assert_word("100%");
488 assert_word("@mention");
489 assert_word("#hashtag");
490 assert_word("$variable");
491 assert_word("more⋯");
492
493 // Space
494 assert_not_word("foo bar");
495
496 // URL case
497 assert_word("https://github.com/zed-industries/zed/");
498 assert_word("github.com");
499 assert_word("a=1&b=2");
500
501 // Latin-1 Supplement
502 assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
503 // Latin Extended-A
504 assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
505 // Latin Extended-B
506 assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
507 // Cyrillic
508 assert_word("АБВГДЕЖЗИЙКЛМНОП");
509
510 // non-word characters
511 assert_not_word("你好");
512 assert_not_word("안녕하세요");
513 assert_not_word("こんにちは");
514 assert_not_word("😀😁😂");
515 assert_not_word("()[]{}<>");
516 }
517
518 // For compatibility with the test macro
519 #[cfg(target_os = "macos")]
520 use crate as gpui;
521
522 // These seem to vary wildly based on the text system.
523 #[cfg(target_os = "macos")]
524 #[crate::test]
525 fn test_wrap_shaped_line(cx: &mut TestAppContext) {
526 cx.update(|cx| {
527 let text_system = WindowTextSystem::new(cx.text_system().clone());
528
529 let normal = TextRun {
530 len: 0,
531 font: font("Helvetica"),
532 color: Default::default(),
533 underline: Default::default(),
534 strikethrough: None,
535 background_color: None,
536 };
537 let bold = TextRun {
538 len: 0,
539 font: font("Helvetica").bold(),
540 color: Default::default(),
541 underline: Default::default(),
542 strikethrough: None,
543 background_color: None,
544 };
545
546 let text = "aa bbb cccc ddddd eeee".into();
547 let lines = text_system
548 .shape_text(
549 text,
550 px(16.),
551 &[
552 normal.with_len(4),
553 bold.with_len(5),
554 normal.with_len(6),
555 bold.with_len(1),
556 normal.with_len(7),
557 ],
558 Some(px(72.)),
559 None,
560 )
561 .unwrap();
562
563 assert_eq!(
564 lines[0].layout.wrap_boundaries(),
565 &[
566 WrapBoundary {
567 run_ix: 1,
568 glyph_ix: 3
569 },
570 WrapBoundary {
571 run_ix: 2,
572 glyph_ix: 3
573 },
574 WrapBoundary {
575 run_ix: 4,
576 glyph_ix: 2
577 }
578 ],
579 );
580 });
581 }
582}