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}