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}