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