1use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, px};
2use collections::HashMap;
3use std::{borrow::Cow, 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 fragments: &'a [LineFragment],
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 index = 0;
46 let mut candidates = fragments
47 .iter()
48 .flat_map(move |fragment| fragment.wrap_boundary_candidates())
49 .peekable();
50 iter::from_fn(move || {
51 for candidate in candidates.by_ref() {
52 let ix = index;
53 index += candidate.len_utf8();
54 let mut new_prev_c = prev_c;
55 let item_width = match candidate {
56 WrapBoundaryCandidate::Char { character: c } => {
57 if c == '\n' {
58 continue;
59 }
60
61 if Self::is_word_char(c) {
62 if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() {
63 last_candidate_ix = ix;
64 last_candidate_width = width;
65 }
66 } else {
67 // CJK may not be space separated, e.g.: `Hello world你好世界`
68 if c != ' ' && first_non_whitespace_ix.is_some() {
69 last_candidate_ix = ix;
70 last_candidate_width = width;
71 }
72 }
73
74 if c != ' ' && first_non_whitespace_ix.is_none() {
75 first_non_whitespace_ix = Some(ix);
76 }
77
78 new_prev_c = c;
79
80 self.width_for_char(c)
81 }
82 WrapBoundaryCandidate::Element {
83 width: element_width,
84 ..
85 } => {
86 if prev_c == ' ' && first_non_whitespace_ix.is_some() {
87 last_candidate_ix = ix;
88 last_candidate_width = width;
89 }
90
91 if first_non_whitespace_ix.is_none() {
92 first_non_whitespace_ix = Some(ix);
93 }
94
95 element_width
96 }
97 };
98
99 width += item_width;
100 if width > wrap_width && ix > last_wrap_ix {
101 if let (None, Some(first_non_whitespace_ix)) = (indent, first_non_whitespace_ix)
102 {
103 indent = Some(
104 Self::MAX_INDENT.min((first_non_whitespace_ix - last_wrap_ix) as u32),
105 );
106 }
107
108 if last_candidate_ix > 0 {
109 last_wrap_ix = last_candidate_ix;
110 width -= last_candidate_width;
111 last_candidate_ix = 0;
112 } else {
113 last_wrap_ix = ix;
114 width = item_width;
115 }
116
117 if let Some(indent) = indent {
118 width += self.width_for_char(' ') * indent as f32;
119 }
120
121 return Some(Boundary::new(last_wrap_ix, indent.unwrap_or(0)));
122 }
123
124 prev_c = new_prev_c;
125 }
126
127 None
128 })
129 }
130
131 /// Determines if a line should be truncated based on its width.
132 pub fn should_truncate_line(
133 &mut self,
134 line: &str,
135 truncate_width: Pixels,
136 truncation_suffix: &str,
137 ) -> Option<usize> {
138 let mut width = px(0.);
139 let suffix_width = truncation_suffix
140 .chars()
141 .map(|c| self.width_for_char(c))
142 .fold(px(0.0), |a, x| a + x);
143 let mut truncate_ix = 0;
144
145 for (ix, c) in line.char_indices() {
146 if width + suffix_width < truncate_width {
147 truncate_ix = ix;
148 }
149
150 let char_width = self.width_for_char(c);
151 width += char_width;
152
153 if width.floor() > truncate_width {
154 return Some(truncate_ix);
155 }
156 }
157
158 None
159 }
160
161 /// Truncate a line of text to the given width with this wrapper's font and font size.
162 pub fn truncate_line<'a>(
163 &mut self,
164 line: SharedString,
165 truncate_width: Pixels,
166 truncation_suffix: &str,
167 runs: &'a [TextRun],
168 ) -> (SharedString, Cow<'a, [TextRun]>) {
169 if let Some(truncate_ix) =
170 self.should_truncate_line(&line, truncate_width, truncation_suffix)
171 {
172 let result =
173 SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
174 let mut runs = runs.to_vec();
175 update_runs_after_truncation(&result, truncation_suffix, &mut runs);
176 (result, Cow::Owned(runs))
177 } else {
178 (line, Cow::Borrowed(runs))
179 }
180 }
181
182 /// Truncate a line of text from the start to the given width.
183 /// Truncates from the beginning, e.g., "…ong text here"
184 pub fn truncate_line_start<'a>(
185 &mut self,
186 line: SharedString,
187 truncate_width: Pixels,
188 truncation_prefix: &str,
189 runs: &'a [TextRun],
190 ) -> (SharedString, Cow<'a, [TextRun]>) {
191 // First, measure the full line width to see if truncation is needed
192 let full_width: Pixels = line.chars().map(|c| self.width_for_char(c)).sum();
193
194 if full_width <= truncate_width {
195 return (line, Cow::Borrowed(runs));
196 }
197
198 let prefix_width: Pixels = truncation_prefix
199 .chars()
200 .map(|c| self.width_for_char(c))
201 .sum();
202
203 let available_width = truncate_width - prefix_width;
204
205 if available_width <= px(0.) {
206 return (
207 SharedString::from(truncation_prefix.to_string()),
208 Cow::Owned(vec![]),
209 );
210 }
211
212 // Work backwards from the end to find where to start the visible text
213 let char_indices: Vec<(usize, char)> = line.char_indices().collect();
214 let mut width_from_end = px(0.);
215 let mut start_byte_index = line.len();
216
217 for (byte_index, c) in char_indices.iter().rev() {
218 let char_width = self.width_for_char(*c);
219 if width_from_end + char_width > available_width {
220 break;
221 }
222 width_from_end += char_width;
223 start_byte_index = *byte_index;
224 }
225
226 if start_byte_index == 0 {
227 return (line, Cow::Borrowed(runs));
228 }
229
230 let result = SharedString::from(format!(
231 "{}{}",
232 truncation_prefix,
233 &line[start_byte_index..]
234 ));
235 let mut runs = runs.to_vec();
236 update_runs_after_start_truncation(&result, truncation_prefix, start_byte_index, &mut runs);
237
238 (result, Cow::Owned(runs))
239 }
240
241 /// Any character in this list should be treated as a word character,
242 /// meaning it can be part of a word that should not be wrapped.
243 pub(crate) fn is_word_char(c: char) -> bool {
244 // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
245 c.is_ascii_alphanumeric() ||
246 // Latin script in Unicode for French, German, Spanish, etc.
247 // Latin-1 Supplement
248 // https://en.wikipedia.org/wiki/Latin-1_Supplement
249 matches!(c, '\u{00C0}'..='\u{00FF}') ||
250 // Latin Extended-A
251 // https://en.wikipedia.org/wiki/Latin_Extended-A
252 matches!(c, '\u{0100}'..='\u{017F}') ||
253 // Latin Extended-B
254 // https://en.wikipedia.org/wiki/Latin_Extended-B
255 matches!(c, '\u{0180}'..='\u{024F}') ||
256 // Cyrillic for Russian, Ukrainian, etc.
257 // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
258 matches!(c, '\u{0400}'..='\u{04FF}') ||
259
260 // Vietnamese (https://vietunicode.sourceforge.net/charset/)
261 matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
262 matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
263
264 // Some other known special characters that should be treated as word characters,
265 // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`,
266 // `2^3`, `a~b`, `a=1`, `Self::new`, etc.
267 matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':') ||
268 // `⋯` character is special used in Zed, to keep this at the end of the line.
269 matches!(c, '⋯')
270 }
271
272 #[inline(always)]
273 fn width_for_char(&mut self, c: char) -> Pixels {
274 if (c as u32) < 128 {
275 if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] {
276 cached_width
277 } else {
278 let width = self.compute_width_for_char(c);
279 self.cached_ascii_char_widths[c as usize] = Some(width);
280 width
281 }
282 } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) {
283 *cached_width
284 } else {
285 let width = self.compute_width_for_char(c);
286 self.cached_other_char_widths.insert(c, width);
287 width
288 }
289 }
290
291 fn compute_width_for_char(&self, c: char) -> Pixels {
292 let mut buffer = [0; 4];
293 let buffer = c.encode_utf8(&mut buffer);
294 self.platform_text_system
295 .layout_line(
296 buffer,
297 self.font_size,
298 &[FontRun {
299 len: buffer.len(),
300 font_id: self.font_id,
301 }],
302 )
303 .width
304 }
305}
306
307fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec<TextRun>) {
308 let mut truncate_at = result.len() - ellipsis.len();
309 for (run_index, run) in runs.iter_mut().enumerate() {
310 if run.len <= truncate_at {
311 truncate_at -= run.len;
312 } else {
313 run.len = truncate_at + ellipsis.len();
314 runs.truncate(run_index + 1);
315 break;
316 }
317 }
318}
319
320fn update_runs_after_start_truncation(
321 result: &str,
322 prefix: &str,
323 bytes_removed: usize,
324 runs: &mut Vec<TextRun>,
325) {
326 let prefix_len = prefix.len();
327
328 let mut bytes_to_skip = bytes_removed;
329 let mut first_relevant_run = 0;
330
331 for (index, run) in runs.iter().enumerate() {
332 if bytes_to_skip >= run.len {
333 bytes_to_skip -= run.len;
334 first_relevant_run = index + 1;
335 } else {
336 break;
337 }
338 }
339
340 if first_relevant_run > 0 {
341 runs.drain(0..first_relevant_run);
342 }
343
344 if !runs.is_empty() && bytes_to_skip > 0 {
345 runs[0].len -= bytes_to_skip;
346 }
347
348 if !runs.is_empty() {
349 runs[0].len += prefix_len;
350 } else {
351 runs.push(TextRun {
352 len: result.len(),
353 ..Default::default()
354 });
355 }
356
357 let total_run_len: usize = runs.iter().map(|r| r.len).sum();
358 if total_run_len != result.len() && !runs.is_empty() {
359 let diff = result.len() as isize - total_run_len as isize;
360 if let Some(last) = runs.last_mut() {
361 last.len = (last.len as isize + diff) as usize;
362 }
363 }
364}
365
366/// A fragment of a line that can be wrapped.
367pub enum LineFragment<'a> {
368 /// A text fragment consisting of characters.
369 Text {
370 /// The text content of the fragment.
371 text: &'a str,
372 },
373 /// A non-text element with a fixed width.
374 Element {
375 /// The width of the element in pixels.
376 width: Pixels,
377 /// The UTF-8 encoded length of the element.
378 len_utf8: usize,
379 },
380}
381
382impl<'a> LineFragment<'a> {
383 /// Creates a new text fragment from the given text.
384 pub fn text(text: &'a str) -> Self {
385 LineFragment::Text { text }
386 }
387
388 /// Creates a new non-text element with the given width and UTF-8 encoded length.
389 pub fn element(width: Pixels, len_utf8: usize) -> Self {
390 LineFragment::Element { width, len_utf8 }
391 }
392
393 fn wrap_boundary_candidates(&self) -> impl Iterator<Item = WrapBoundaryCandidate> {
394 let text = match self {
395 LineFragment::Text { text } => text,
396 LineFragment::Element { .. } => "\0",
397 };
398 text.chars().map(move |character| {
399 if let LineFragment::Element { width, len_utf8 } = self {
400 WrapBoundaryCandidate::Element {
401 width: *width,
402 len_utf8: *len_utf8,
403 }
404 } else {
405 WrapBoundaryCandidate::Char { character }
406 }
407 })
408 }
409}
410
411enum WrapBoundaryCandidate {
412 Char { character: char },
413 Element { width: Pixels, len_utf8: usize },
414}
415
416impl WrapBoundaryCandidate {
417 pub fn len_utf8(&self) -> usize {
418 match self {
419 WrapBoundaryCandidate::Char { character } => character.len_utf8(),
420 WrapBoundaryCandidate::Element { len_utf8: len, .. } => *len,
421 }
422 }
423}
424
425/// A boundary between two lines of text.
426#[derive(Copy, Clone, Debug, PartialEq, Eq)]
427pub struct Boundary {
428 /// The index of the last character in a line
429 pub ix: usize,
430 /// The indent of the next line.
431 pub next_indent: u32,
432}
433
434impl Boundary {
435 fn new(ix: usize, next_indent: u32) -> Self {
436 Self { ix, next_indent }
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use crate::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font};
444 #[cfg(target_os = "macos")]
445 use crate::{TextRun, WindowTextSystem, WrapBoundary};
446 use rand::prelude::*;
447
448 fn build_wrapper() -> LineWrapper {
449 let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
450 let cx = TestAppContext::build(dispatcher, None);
451 let id = cx.text_system().resolve_font(&font(".ZedMono"));
452 LineWrapper::new(id, px(16.), cx.text_system().platform_text_system.clone())
453 }
454
455 fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
456 input_run_len
457 .iter()
458 .map(|run_len| TextRun {
459 len: *run_len,
460 font: Font {
461 family: "Dummy".into(),
462 features: FontFeatures::default(),
463 fallbacks: None,
464 weight: FontWeight::default(),
465 style: FontStyle::Normal,
466 },
467 ..Default::default()
468 })
469 .collect()
470 }
471
472 #[test]
473 fn test_wrap_line() {
474 let mut wrapper = build_wrapper();
475
476 assert_eq!(
477 wrapper
478 .wrap_line(&[LineFragment::text("aa bbb cccc ddddd eeee")], px(72.))
479 .collect::<Vec<_>>(),
480 &[
481 Boundary::new(7, 0),
482 Boundary::new(12, 0),
483 Boundary::new(18, 0)
484 ],
485 );
486 assert_eq!(
487 wrapper
488 .wrap_line(&[LineFragment::text("aaa aaaaaaaaaaaaaaaaaa")], px(72.0))
489 .collect::<Vec<_>>(),
490 &[
491 Boundary::new(4, 0),
492 Boundary::new(11, 0),
493 Boundary::new(18, 0)
494 ],
495 );
496 assert_eq!(
497 wrapper
498 .wrap_line(&[LineFragment::text(" aaaaaaa")], px(72.))
499 .collect::<Vec<_>>(),
500 &[
501 Boundary::new(7, 5),
502 Boundary::new(9, 5),
503 Boundary::new(11, 5),
504 ]
505 );
506 assert_eq!(
507 wrapper
508 .wrap_line(
509 &[LineFragment::text(" ")],
510 px(72.)
511 )
512 .collect::<Vec<_>>(),
513 &[
514 Boundary::new(7, 0),
515 Boundary::new(14, 0),
516 Boundary::new(21, 0)
517 ]
518 );
519 assert_eq!(
520 wrapper
521 .wrap_line(&[LineFragment::text(" aaaaaaaaaaaaaa")], px(72.))
522 .collect::<Vec<_>>(),
523 &[
524 Boundary::new(7, 0),
525 Boundary::new(14, 3),
526 Boundary::new(18, 3),
527 Boundary::new(22, 3),
528 ]
529 );
530
531 // Test wrapping multiple text fragments
532 assert_eq!(
533 wrapper
534 .wrap_line(
535 &[
536 LineFragment::text("aa bbb "),
537 LineFragment::text("cccc ddddd eeee")
538 ],
539 px(72.)
540 )
541 .collect::<Vec<_>>(),
542 &[
543 Boundary::new(7, 0),
544 Boundary::new(12, 0),
545 Boundary::new(18, 0)
546 ],
547 );
548
549 // Test wrapping with a mix of text and element fragments
550 assert_eq!(
551 wrapper
552 .wrap_line(
553 &[
554 LineFragment::text("aa "),
555 LineFragment::element(px(20.), 1),
556 LineFragment::text(" bbb "),
557 LineFragment::element(px(30.), 1),
558 LineFragment::text(" cccc")
559 ],
560 px(72.)
561 )
562 .collect::<Vec<_>>(),
563 &[
564 Boundary::new(5, 0),
565 Boundary::new(9, 0),
566 Boundary::new(11, 0)
567 ],
568 );
569
570 // Test with element at the beginning and text afterward
571 assert_eq!(
572 wrapper
573 .wrap_line(
574 &[
575 LineFragment::element(px(50.), 1),
576 LineFragment::text(" aaaa bbbb cccc dddd")
577 ],
578 px(72.)
579 )
580 .collect::<Vec<_>>(),
581 &[
582 Boundary::new(2, 0),
583 Boundary::new(7, 0),
584 Boundary::new(12, 0),
585 Boundary::new(17, 0)
586 ],
587 );
588
589 // Test with a large element that forces wrapping by itself
590 assert_eq!(
591 wrapper
592 .wrap_line(
593 &[
594 LineFragment::text("short text "),
595 LineFragment::element(px(100.), 1),
596 LineFragment::text(" more text")
597 ],
598 px(72.)
599 )
600 .collect::<Vec<_>>(),
601 &[
602 Boundary::new(6, 0),
603 Boundary::new(11, 0),
604 Boundary::new(12, 0),
605 Boundary::new(18, 0)
606 ],
607 );
608 }
609
610 #[test]
611 fn test_truncate_line() {
612 let mut wrapper = build_wrapper();
613
614 fn perform_test(
615 wrapper: &mut LineWrapper,
616 text: &'static str,
617 expected: &'static str,
618 ellipsis: &str,
619 ) {
620 let dummy_run_lens = vec![text.len()];
621 let dummy_runs = generate_test_runs(&dummy_run_lens);
622 let (result, dummy_runs) =
623 wrapper.truncate_line(text.into(), px(220.), ellipsis, &dummy_runs);
624 assert_eq!(result, expected);
625 assert_eq!(dummy_runs.first().unwrap().len, result.len());
626 }
627
628 perform_test(
629 &mut wrapper,
630 "aa bbb cccc ddddd eeee ffff gggg",
631 "aa bbb cccc ddddd eeee",
632 "",
633 );
634 perform_test(
635 &mut wrapper,
636 "aa bbb cccc ddddd eeee ffff gggg",
637 "aa bbb cccc ddddd eee…",
638 "…",
639 );
640 perform_test(
641 &mut wrapper,
642 "aa bbb cccc ddddd eeee ffff gggg",
643 "aa bbb cccc dddd......",
644 "......",
645 );
646 }
647
648 #[test]
649 fn test_truncate_multiple_runs() {
650 let mut wrapper = build_wrapper();
651
652 fn perform_test(
653 wrapper: &mut LineWrapper,
654 text: &'static str,
655 expected: &str,
656 run_lens: &[usize],
657 result_run_len: &[usize],
658 line_width: Pixels,
659 ) {
660 let dummy_runs = generate_test_runs(run_lens);
661 let (result, dummy_runs) =
662 wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs);
663 assert_eq!(result, expected);
664 for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
665 assert_eq!(run.len, *result_len);
666 }
667 }
668 // Case 0: Normal
669 // Text: abcdefghijkl
670 // Runs: Run0 { len: 12, ... }
671 //
672 // Truncate res: abcd… (truncate_at = 4)
673 // Run res: Run0 { string: abcd…, len: 7, ... }
674 perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
675 // Case 1: Drop some runs
676 // Text: abcdefghijkl
677 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
678 //
679 // Truncate res: abcdef… (truncate_at = 6)
680 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
681 // 5, ... }
682 perform_test(
683 &mut wrapper,
684 "abcdefghijkl",
685 "abcdef…",
686 &[4, 4, 4],
687 &[4, 5],
688 px(70.),
689 );
690 // Case 2: Truncate at start of some run
691 // Text: abcdefghijkl
692 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
693 //
694 // Truncate res: abcdefgh… (truncate_at = 8)
695 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
696 // 4, ... }, Run2 { string: …, len: 3, ... }
697 perform_test(
698 &mut wrapper,
699 "abcdefghijkl",
700 "abcdefgh…",
701 &[4, 4, 4],
702 &[4, 4, 3],
703 px(90.),
704 );
705 }
706
707 #[test]
708 fn test_update_run_after_truncation() {
709 fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
710 let mut dummy_runs = generate_test_runs(run_lens);
711 update_runs_after_truncation(result, "…", &mut dummy_runs);
712 for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
713 assert_eq!(run.len, *result_len);
714 }
715 }
716 // Case 0: Normal
717 // Text: abcdefghijkl
718 // Runs: Run0 { len: 12, ... }
719 //
720 // Truncate res: abcd… (truncate_at = 4)
721 // Run res: Run0 { string: abcd…, len: 7, ... }
722 perform_test("abcd…", &[12], &[7]);
723 // Case 1: Drop some runs
724 // Text: abcdefghijkl
725 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
726 //
727 // Truncate res: abcdef… (truncate_at = 6)
728 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
729 // 5, ... }
730 perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
731 // Case 2: Truncate at start of some run
732 // Text: abcdefghijkl
733 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
734 //
735 // Truncate res: abcdefgh… (truncate_at = 8)
736 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
737 // 4, ... }, Run2 { string: …, len: 3, ... }
738 perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
739 }
740
741 #[test]
742 fn test_is_word_char() {
743 #[track_caller]
744 fn assert_word(word: &str) {
745 for c in word.chars() {
746 assert!(
747 LineWrapper::is_word_char(c),
748 "assertion failed for '{}' (unicode 0x{:x})",
749 c,
750 c as u32
751 );
752 }
753 }
754
755 #[track_caller]
756 fn assert_not_word(word: &str) {
757 let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
758 assert!(found, "assertion failed for '{}'", word);
759 }
760
761 assert_word("Hello123");
762 assert_word("non-English");
763 assert_word("var_name");
764 assert_word("123456");
765 assert_word("3.1415");
766 assert_word("10^2");
767 assert_word("1~2");
768 assert_word("100%");
769 assert_word("@mention");
770 assert_word("#hashtag");
771 assert_word("$variable");
772 assert_word("a=1");
773 assert_word("Self::is_word_char");
774 assert_word("more⋯");
775
776 // Space
777 assert_not_word("foo bar");
778
779 // URL case
780 assert_word("github.com");
781 assert_not_word("zed-industries/zed");
782 assert_not_word("zed-industries\\zed");
783 assert_not_word("a=1&b=2");
784 assert_not_word("foo?b=2");
785
786 // Latin-1 Supplement
787 assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
788 // Latin Extended-A
789 assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
790 // Latin Extended-B
791 assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
792 // Cyrillic
793 assert_word("АБВГДЕЖЗИЙКЛМНОП");
794 // Vietnamese (https://github.com/zed-industries/zed/issues/23245)
795 assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
796
797 // non-word characters
798 assert_not_word("你好");
799 assert_not_word("안녕하세요");
800 assert_not_word("こんにちは");
801 assert_not_word("😀😁😂");
802 assert_not_word("()[]{}<>");
803 }
804
805 // For compatibility with the test macro
806 #[cfg(target_os = "macos")]
807 use crate as gpui;
808
809 // These seem to vary wildly based on the text system.
810 #[cfg(target_os = "macos")]
811 #[crate::test]
812 fn test_wrap_shaped_line(cx: &mut TestAppContext) {
813 cx.update(|cx| {
814 let text_system = WindowTextSystem::new(cx.text_system().clone());
815
816 let normal = TextRun {
817 len: 0,
818 font: font("Helvetica"),
819 color: Default::default(),
820 underline: Default::default(),
821 ..Default::default()
822 };
823 let bold = TextRun {
824 len: 0,
825 font: font("Helvetica").bold(),
826 ..Default::default()
827 };
828
829 let text = "aa bbb cccc ddddd eeee".into();
830 let lines = text_system
831 .shape_text(
832 text,
833 px(16.),
834 &[
835 normal.with_len(4),
836 bold.with_len(5),
837 normal.with_len(6),
838 bold.with_len(1),
839 normal.with_len(7),
840 ],
841 Some(px(72.)),
842 None,
843 )
844 .unwrap();
845
846 assert_eq!(
847 lines[0].layout.wrap_boundaries(),
848 &[
849 WrapBoundary {
850 run_ix: 0,
851 glyph_ix: 7
852 },
853 WrapBoundary {
854 run_ix: 0,
855 glyph_ix: 12
856 },
857 WrapBoundary {
858 run_ix: 0,
859 glyph_ix: 18
860 }
861 ],
862 );
863 });
864 }
865}