Skip to repository content1578 lines · 53.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:45:21.790Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
line_wrapper.rs
1use crate::{FontId, Pixels, SharedString, TextRun, TextSystem, px};
2use collections::HashMap;
3use std::{borrow::Cow, iter, sync::Arc};
4
5/// Determines whether to truncate text from the start or end.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum TruncateFrom {
8 /// Truncate text from the start.
9 Start,
10 /// Truncate text from the end.
11 End,
12 /// Truncate text from the middle, preserving the start and end.
13 Middle,
14}
15
16/// The GPUI line wrapper, used to wrap lines of text to a given width.
17pub struct LineWrapper {
18 text_system: Arc<TextSystem>,
19 pub(crate) font_id: FontId,
20 pub(crate) font_size: Pixels,
21 cached_ascii_char_widths: [Option<Pixels>; 128],
22 cached_other_char_widths: HashMap<char, Pixels>,
23}
24
25impl LineWrapper {
26 /// The maximum indent that can be applied to a line.
27 pub const MAX_INDENT: u32 = 256;
28
29 pub(crate) fn new(font_id: FontId, font_size: Pixels, text_system: Arc<TextSystem>) -> Self {
30 Self {
31 text_system,
32 font_id,
33 font_size,
34 cached_ascii_char_widths: [None; 128],
35 cached_other_char_widths: HashMap::default(),
36 }
37 }
38
39 /// Wrap a line of text to the given width with this wrapper's font and font size.
40 pub fn wrap_line<'a>(
41 &'a mut self,
42 fragments: &'a [LineFragment],
43 wrap_width: Pixels,
44 ) -> impl Iterator<Item = Boundary> + 'a {
45 let mut width = px(0.);
46 let mut first_non_whitespace_ix = None;
47 let mut indent = None;
48 let mut last_candidate_ix = 0;
49 let mut last_candidate_width = px(0.);
50 let mut last_wrap_ix = 0;
51 let mut prev_c = '\0';
52 let mut index = 0;
53 let mut candidates = fragments
54 .iter()
55 .flat_map(move |fragment| fragment.wrap_boundary_candidates())
56 .peekable();
57 iter::from_fn(move || {
58 for candidate in candidates.by_ref() {
59 let ix = index;
60 index += candidate.len_utf8();
61 let mut new_prev_c = prev_c;
62 let item_width = match candidate {
63 WrapBoundaryCandidate::Char { character: c } => {
64 if c == '\n' {
65 continue;
66 }
67
68 if Self::is_word_char(c) {
69 if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() {
70 last_candidate_ix = ix;
71 last_candidate_width = width;
72 }
73 } else {
74 // CJK may not be space separated, e.g.: `Hello world你好世界`
75 if c != ' ' && first_non_whitespace_ix.is_some() {
76 last_candidate_ix = ix;
77 last_candidate_width = width;
78 }
79 }
80
81 if c != ' ' && first_non_whitespace_ix.is_none() {
82 first_non_whitespace_ix = Some(ix);
83 }
84
85 new_prev_c = c;
86
87 self.width_for_char(c)
88 }
89 WrapBoundaryCandidate::Element {
90 width: element_width,
91 ..
92 } => {
93 if prev_c == ' ' && first_non_whitespace_ix.is_some() {
94 last_candidate_ix = ix;
95 last_candidate_width = width;
96 }
97
98 if first_non_whitespace_ix.is_none() {
99 first_non_whitespace_ix = Some(ix);
100 }
101
102 element_width
103 }
104 };
105
106 width += item_width;
107 if width > wrap_width && ix > last_wrap_ix {
108 if let (None, Some(first_non_whitespace_ix)) = (indent, first_non_whitespace_ix)
109 {
110 indent = Some(
111 Self::MAX_INDENT.min((first_non_whitespace_ix - last_wrap_ix) as u32),
112 );
113 }
114
115 if last_candidate_ix > 0 {
116 last_wrap_ix = last_candidate_ix;
117 width -= last_candidate_width;
118 last_candidate_ix = 0;
119 } else {
120 last_wrap_ix = ix;
121 width = item_width;
122 }
123
124 if let Some(indent) = indent {
125 width += self.width_for_char(' ') * indent as f32;
126 }
127
128 return Some(Boundary::new(last_wrap_ix, indent.unwrap_or(0)));
129 }
130
131 prev_c = new_prev_c;
132 }
133
134 None
135 })
136 }
137
138 /// Determines if a line should be truncated based on its width.
139 ///
140 /// Returns the truncation index in `line`.
141 pub fn should_truncate_line(
142 &mut self,
143 line: &str,
144 truncate_width: Pixels,
145 truncation_affix: &str,
146 truncate_from: TruncateFrom,
147 ) -> Option<usize> {
148 let mut width = px(0.);
149 let suffix_width = truncation_affix
150 .chars()
151 .map(|c| self.width_for_char(c))
152 .fold(px(0.0), |a, x| a + x);
153 let mut truncate_ix = 0;
154
155 match truncate_from {
156 TruncateFrom::Start => {
157 for (ix, c) in line.char_indices().rev() {
158 if width + suffix_width < truncate_width {
159 truncate_ix = ix;
160 }
161
162 let char_width = self.width_for_char(c);
163 width += char_width;
164
165 if width.floor() > truncate_width {
166 return Some(truncate_ix);
167 }
168 }
169 }
170 TruncateFrom::End => {
171 for (ix, c) in line.char_indices() {
172 if width + suffix_width < truncate_width {
173 truncate_ix = ix;
174 }
175
176 let char_width = self.width_for_char(c);
177 width += char_width;
178
179 if width.floor() > truncate_width {
180 return Some(truncate_ix);
181 }
182 }
183 }
184 TruncateFrom::Middle => {}
185 }
186
187 None
188 }
189
190 fn should_truncate_line_middle(
191 &mut self,
192 line: &str,
193 truncate_width: Pixels,
194 truncation_affix: &str,
195 ) -> Option<(usize, usize)> {
196 let suffix_width = truncation_affix
197 .chars()
198 .map(|c| self.width_for_char(c))
199 .fold(px(0.0), |a, x| a + x);
200
201 let total_width: Pixels = line
202 .chars()
203 .map(|c| self.width_for_char(c))
204 .fold(px(0.0), |a, x| a + x);
205
206 if total_width <= truncate_width {
207 return None;
208 }
209
210 let content_budget = truncate_width - suffix_width;
211 if content_budget <= px(0.) {
212 return Some((0, line.len()));
213 }
214
215 let front_budget = content_budget * (2.0 / 3.0);
216 let back_budget = content_budget - front_budget;
217
218 let mut front_width = px(0.);
219 let mut front_end_ix = 0usize;
220 for (ix, c) in line.char_indices() {
221 let char_width = self.width_for_char(c);
222 if front_width + char_width > front_budget {
223 break;
224 }
225 front_width += char_width;
226 front_end_ix = ix + c.len_utf8();
227 }
228
229 let mut back_width = px(0.);
230 let mut back_start_ix = line.len();
231 for (ix, c) in line.char_indices().rev() {
232 let char_width = self.width_for_char(c);
233 if back_width + char_width > back_budget {
234 break;
235 }
236 back_width += char_width;
237 back_start_ix = ix;
238 }
239
240 if front_end_ix >= back_start_ix {
241 return Some((0, line.len()));
242 }
243
244 Some((front_end_ix, back_start_ix))
245 }
246
247 /// Truncate a line of text to the given width with this wrapper's font and font size.
248 pub fn truncate_line<'a>(
249 &mut self,
250 line: SharedString,
251 truncate_width: Pixels,
252 truncation_affix: &str,
253 runs: &'a [TextRun],
254 truncate_from: TruncateFrom,
255 ) -> (SharedString, Cow<'a, [TextRun]>) {
256 if truncate_from == TruncateFrom::Middle {
257 if let Some((front_end_ix, back_start_ix)) =
258 self.should_truncate_line_middle(&line, truncate_width, truncation_affix)
259 {
260 let result = SharedString::from(format!(
261 "{}{truncation_affix}{}",
262 &line[..front_end_ix],
263 &line[back_start_ix..]
264 ));
265 let mut runs = runs.to_vec();
266 update_runs_after_middle_truncation(
267 truncation_affix,
268 &mut runs,
269 front_end_ix,
270 back_start_ix,
271 );
272 return (result, Cow::Owned(runs));
273 } else {
274 return (line, Cow::Borrowed(runs));
275 }
276 }
277
278 if let Some(truncate_ix) =
279 self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from)
280 {
281 let result = match truncate_from {
282 TruncateFrom::Start => SharedString::from(format!(
283 "{truncation_affix}{}",
284 &line[line.ceil_char_boundary(truncate_ix + 1)..]
285 )),
286 TruncateFrom::End => SharedString::from(format!(
287 "{}{truncation_affix}",
288 line[..truncate_ix]
289 .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
290 )),
291 TruncateFrom::Middle => unreachable!("Middle truncation is handled above"),
292 };
293 let mut runs = runs.to_vec();
294 update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from);
295 (result, Cow::Owned(runs))
296 } else {
297 (line, Cow::Borrowed(runs))
298 }
299 }
300
301 /// Truncate text to fit within a given number of wrapped lines.
302 ///
303 /// Unlike `truncate_line` which treats the text as a flat width budget
304 /// (`width * max_lines`), this method accounts for word-boundary wrapping:
305 /// it walks through characters once, tracking wrap boundaries and the
306 /// truncation point simultaneously. When text overflows on the last
307 /// allowed line, it truncates there and appends the affix.
308 ///
309 /// For `max_lines == 1`, this delegates to `truncate_line`.
310 pub fn truncate_wrapped_line<'a>(
311 &mut self,
312 text: SharedString,
313 wrap_width: Pixels,
314 max_lines: usize,
315 truncation_affix: &str,
316 runs: &'a [TextRun],
317 truncate_from: TruncateFrom,
318 ) -> (SharedString, Cow<'a, [TextRun]>) {
319 if max_lines <= 1 || truncate_from == TruncateFrom::Start {
320 return self.truncate_line(
321 text,
322 wrap_width * max_lines,
323 truncation_affix,
324 runs,
325 truncate_from,
326 );
327 }
328 if truncate_from == TruncateFrom::Middle {
329 return self.truncate_line(text, wrap_width, truncation_affix, runs, truncate_from);
330 }
331
332 let affix_width: Pixels = truncation_affix
333 .chars()
334 .map(|c| self.width_for_char(c))
335 .sum();
336
337 let mut width = px(0.);
338 let mut line = 0usize;
339 let mut first_non_whitespace_ix = None;
340 let mut last_candidate_ix = 0usize;
341 let mut last_candidate_width = px(0.);
342 let mut last_wrap_ix = 0usize;
343 let mut prev_c = '\0';
344 let mut indent: Option<u32> = None;
345 let mut truncate_ix = 0usize;
346
347 for (ix, c) in text.char_indices() {
348 if c == '\n' {
349 if line >= max_lines - 1 && !text[ix + 1..].trim().is_empty() {
350 // Newline on the last allowed line with real content
351 // below. Truncate here.
352 let truncated = text[..truncate_ix]
353 .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
354 let result = SharedString::from(format!("{truncated}{truncation_affix}"));
355 let mut runs = runs.to_vec();
356 update_runs_after_truncation(
357 &result,
358 truncation_affix,
359 &mut runs,
360 TruncateFrom::End,
361 );
362 return (result, Cow::Owned(runs));
363 }
364
365 // Newline before the last line: it consumes a line.
366 line += 1;
367 width = px(0.);
368 first_non_whitespace_ix = None;
369 last_candidate_ix = 0;
370 last_candidate_width = px(0.);
371 last_wrap_ix = ix + 1;
372 prev_c = '\0';
373 indent = None;
374 truncate_ix = ix + 1;
375 continue;
376 }
377
378 let char_width = self.width_for_char(c);
379
380 if Self::is_word_char(c) {
381 if prev_c == ' ' && first_non_whitespace_ix.is_some() {
382 last_candidate_ix = ix;
383 last_candidate_width = width;
384 }
385 } else if c != ' ' && first_non_whitespace_ix.is_some() {
386 last_candidate_ix = ix;
387 last_candidate_width = width;
388 }
389
390 if c != ' ' && first_non_whitespace_ix.is_none() {
391 first_non_whitespace_ix = Some(ix);
392 }
393
394 width += char_width;
395
396 if line < max_lines - 1 {
397 // Before the last line: replicate wrap_line's boundary logic.
398 if width > wrap_width && ix > last_wrap_ix {
399 if let (None, Some(first_nw)) = (indent, first_non_whitespace_ix) {
400 indent = Some(Self::MAX_INDENT.min((first_nw - last_wrap_ix) as u32));
401 }
402
403 if last_candidate_ix > last_wrap_ix {
404 last_wrap_ix = last_candidate_ix;
405 width -= last_candidate_width;
406 last_candidate_ix = 0;
407 } else {
408 last_wrap_ix = ix;
409 width = char_width;
410 }
411
412 if let Some(ind) = indent {
413 width += self.width_for_char(' ') * ind as f32;
414 }
415
416 line += 1;
417 truncate_ix = last_wrap_ix;
418 }
419 } else {
420 // On the last line: track the furthest point where the affix
421 // still fits, and stop as soon as the line overflows.
422 if width + affix_width <= wrap_width {
423 truncate_ix = ix + c.len_utf8();
424 }
425
426 if width > wrap_width {
427 let truncated = text[..truncate_ix]
428 .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
429 let result = SharedString::from(format!("{truncated}{truncation_affix}"));
430 let mut runs = runs.to_vec();
431 update_runs_after_truncation(
432 &result,
433 truncation_affix,
434 &mut runs,
435 TruncateFrom::End,
436 );
437 return (result, Cow::Owned(runs));
438 }
439 }
440
441 prev_c = c;
442 }
443
444 // Text fits within max_lines without truncation.
445 (text, Cow::Borrowed(runs))
446 }
447
448 /// Any character in this list should be treated as a word character,
449 /// meaning it can be part of a word that should not be wrapped.
450 pub(crate) fn is_word_char(c: char) -> bool {
451 // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
452 c.is_ascii_alphanumeric() ||
453 // Latin script in Unicode for French, German, Spanish, etc.
454 // Latin-1 Supplement
455 // https://en.wikipedia.org/wiki/Latin-1_Supplement
456 matches!(c, '\u{00C0}'..='\u{00FF}') ||
457 // Latin Extended-A
458 // https://en.wikipedia.org/wiki/Latin_Extended-A
459 matches!(c, '\u{0100}'..='\u{017F}') ||
460 // Latin Extended-B
461 // https://en.wikipedia.org/wiki/Latin_Extended-B
462 matches!(c, '\u{0180}'..='\u{024F}') ||
463 // Cyrillic for Russian, Ukrainian, etc.
464 // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
465 matches!(c, '\u{0400}'..='\u{04FF}') ||
466
467 // Vietnamese (https://vietunicode.sourceforge.net/charset/)
468 matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
469 matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
470
471 // Bengali (https://en.wikipedia.org/wiki/Bengali_(Unicode_block))
472 matches!(c, '\u{0980}'..='\u{09FF}') ||
473
474 // Some other known special characters that should be treated as word characters,
475 // e.g. `a-b`, `var_name`, `I'm`/`won’t`, '@mention`, `#hashtag`, `100%`, `3.1415`,
476 // `2^3`, `a~b`, `a=1`, `Self::new`, etc. Trailing punctuation like `,`, `.`, `:`, `;`
477 // is included so it stays attached to the preceding word when wrapping.
478 matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') ||
479 // `⋯` character is special used in Zed, to keep this at the end of the line.
480 matches!(c, '⋯') ||
481
482 // Non-breaking glue characters
483 matches!(c, '\u{202F}' | '\u{00A0}' | '\u{2011}')
484 }
485
486 #[inline(always)]
487 fn width_for_char(&mut self, c: char) -> Pixels {
488 if (c as u32) < 128 {
489 if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] {
490 cached_width
491 } else {
492 let width = self
493 .text_system
494 .layout_width(self.font_id, self.font_size, c);
495 self.cached_ascii_char_widths[c as usize] = Some(width);
496 width
497 }
498 } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) {
499 *cached_width
500 } else {
501 let width = self
502 .text_system
503 .layout_width(self.font_id, self.font_size, c);
504 self.cached_other_char_widths.insert(c, width);
505 width
506 }
507 }
508}
509
510fn update_runs_after_truncation(
511 result: &str,
512 ellipsis: &str,
513 runs: &mut Vec<TextRun>,
514 truncate_from: TruncateFrom,
515) {
516 let mut truncate_at = result.len() - ellipsis.len();
517 match truncate_from {
518 TruncateFrom::Start => {
519 for (run_index, run) in runs.iter_mut().enumerate().rev() {
520 if run.len <= truncate_at {
521 truncate_at -= run.len;
522 } else {
523 run.len = truncate_at + ellipsis.len();
524 runs.splice(..run_index, std::iter::empty());
525 break;
526 }
527 }
528 }
529 TruncateFrom::End => {
530 for (run_index, run) in runs.iter_mut().enumerate() {
531 if run.len <= truncate_at {
532 truncate_at -= run.len;
533 } else {
534 run.len = truncate_at + ellipsis.len();
535 runs.truncate(run_index + 1);
536 break;
537 }
538 }
539 }
540 TruncateFrom::Middle => {
541 unreachable!("Middle truncation calls this function with TruncateFrom::End directly")
542 }
543 }
544}
545
546fn update_runs_after_middle_truncation(
547 ellipsis: &str,
548 runs: &mut Vec<TextRun>,
549 front_end_ix: usize,
550 back_start_ix: usize,
551) {
552 let original_runs = std::mem::take(runs);
553 let mut result_runs: Vec<TextRun> = Vec::with_capacity(original_runs.len());
554
555 // Front segment [0, front_end_ix) + ellipsis: walk forward until the run
556 // that straddles or ends at front_end_ix, then extend that run's length
557 // to include the ellipsis.
558 let mut front_remaining = front_end_ix;
559 let mut front_done = false;
560 for run in &original_runs {
561 if front_done {
562 break;
563 }
564 if run.len <= front_remaining {
565 result_runs.push(run.clone());
566 front_remaining -= run.len;
567 } else {
568 let mut partial = run.clone();
569 partial.len = front_remaining + ellipsis.len();
570 result_runs.push(partial);
571 front_done = true;
572 }
573 }
574 if !front_done {
575 // front_end_ix landed exactly on a run boundary; append ellipsis to
576 // the last front run (or, if the front is empty, to the first back run).
577 if let Some(last) = result_runs.last_mut() {
578 last.len += ellipsis.len();
579 } else if let Some(first) = original_runs.first() {
580 let mut affix_run = first.clone();
581 affix_run.len = ellipsis.len();
582 result_runs.push(affix_run);
583 }
584 }
585
586 // Back segment [back_start_ix, original.len()): skip runs entirely in the
587 // removed middle, keep the rest.
588 let mut byte_pos = 0usize;
589 for run in &original_runs {
590 let run_end = byte_pos + run.len;
591 if run_end > back_start_ix {
592 if byte_pos < back_start_ix {
593 // Run straddles back_start_ix; keep only the tail.
594 let mut partial = run.clone();
595 partial.len = run_end - back_start_ix;
596 result_runs.push(partial);
597 } else {
598 result_runs.push(run.clone());
599 }
600 }
601 byte_pos = run_end;
602 }
603
604 *runs = result_runs;
605}
606
607/// A fragment of a line that can be wrapped.
608pub enum LineFragment<'a> {
609 /// A text fragment consisting of characters.
610 Text {
611 /// The text content of the fragment.
612 text: &'a str,
613 },
614 /// A non-text element with a fixed width.
615 Element {
616 /// The width of the element in pixels.
617 width: Pixels,
618 /// The UTF-8 encoded length of the element.
619 len_utf8: usize,
620 },
621}
622
623impl<'a> LineFragment<'a> {
624 /// Creates a new text fragment from the given text.
625 pub fn text(text: &'a str) -> Self {
626 LineFragment::Text { text }
627 }
628
629 /// Creates a new non-text element with the given width and UTF-8 encoded length.
630 pub fn element(width: Pixels, len_utf8: usize) -> Self {
631 LineFragment::Element { width, len_utf8 }
632 }
633
634 fn wrap_boundary_candidates(&self) -> impl Iterator<Item = WrapBoundaryCandidate> {
635 let text = match self {
636 LineFragment::Text { text } => text,
637 LineFragment::Element { .. } => "\0",
638 };
639 text.chars().map(move |character| {
640 if let LineFragment::Element { width, len_utf8 } = self {
641 WrapBoundaryCandidate::Element {
642 width: *width,
643 len_utf8: *len_utf8,
644 }
645 } else {
646 WrapBoundaryCandidate::Char { character }
647 }
648 })
649 }
650}
651
652enum WrapBoundaryCandidate {
653 Char { character: char },
654 Element { width: Pixels, len_utf8: usize },
655}
656
657impl WrapBoundaryCandidate {
658 pub fn len_utf8(&self) -> usize {
659 match self {
660 WrapBoundaryCandidate::Char { character } => character.len_utf8(),
661 WrapBoundaryCandidate::Element { len_utf8: len, .. } => *len,
662 }
663 }
664}
665
666/// A boundary between two lines of text.
667#[derive(Copy, Clone, Debug, PartialEq, Eq)]
668pub struct Boundary {
669 /// The index of the last character in a line
670 pub ix: usize,
671 /// The indent of the next line.
672 pub next_indent: u32,
673}
674
675impl Boundary {
676 fn new(ix: usize, next_indent: u32) -> Self {
677 Self { ix, next_indent }
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use crate::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font};
685 #[cfg(target_os = "macos")]
686 use crate::{TextRun, WindowTextSystem, WrapBoundary};
687
688 fn build_wrapper() -> LineWrapper {
689 let dispatcher = TestDispatcher::new(0);
690 let cx = TestAppContext::build(dispatcher, None);
691 let id = cx.text_system().resolve_font(&font(".ZedMono"));
692 LineWrapper::new(id, px(16.), cx.text_system().clone())
693 }
694
695 fn generate_test_runs(input_run_len: &[usize]) -> Vec<TextRun> {
696 input_run_len
697 .iter()
698 .map(|run_len| TextRun {
699 len: *run_len,
700 font: Font {
701 family: "Dummy".into(),
702 features: FontFeatures::default(),
703 fallbacks: None,
704 weight: FontWeight::default(),
705 style: FontStyle::Normal,
706 },
707 ..Default::default()
708 })
709 .collect()
710 }
711
712 #[test]
713 fn test_wrap_line() {
714 let mut wrapper = build_wrapper();
715
716 assert_eq!(
717 wrapper
718 .wrap_line(&[LineFragment::text("aa bbb cccc ddddd eeee")], px(72.))
719 .collect::<Vec<_>>(),
720 &[
721 Boundary::new(7, 0),
722 Boundary::new(12, 0),
723 Boundary::new(18, 0)
724 ],
725 );
726 assert_eq!(
727 wrapper
728 .wrap_line(&[LineFragment::text("aaa aaaaaaaaaaaaaaaaaa")], px(72.0))
729 .collect::<Vec<_>>(),
730 &[
731 Boundary::new(4, 0),
732 Boundary::new(11, 0),
733 Boundary::new(18, 0)
734 ],
735 );
736 assert_eq!(
737 wrapper
738 .wrap_line(&[LineFragment::text(" aaaaaaa")], px(72.))
739 .collect::<Vec<_>>(),
740 &[
741 Boundary::new(7, 5),
742 Boundary::new(9, 5),
743 Boundary::new(11, 5),
744 ]
745 );
746 assert_eq!(
747 wrapper
748 .wrap_line(
749 &[LineFragment::text(" ")],
750 px(72.)
751 )
752 .collect::<Vec<_>>(),
753 &[
754 Boundary::new(7, 0),
755 Boundary::new(14, 0),
756 Boundary::new(21, 0)
757 ]
758 );
759 assert_eq!(
760 wrapper
761 .wrap_line(&[LineFragment::text(" aaaaaaaaaaaaaa")], px(72.))
762 .collect::<Vec<_>>(),
763 &[
764 Boundary::new(7, 0),
765 Boundary::new(14, 3),
766 Boundary::new(18, 3),
767 Boundary::new(22, 3),
768 ]
769 );
770
771 // Test wrapping multiple text fragments
772 assert_eq!(
773 wrapper
774 .wrap_line(
775 &[
776 LineFragment::text("aa bbb "),
777 LineFragment::text("cccc ddddd eeee")
778 ],
779 px(72.)
780 )
781 .collect::<Vec<_>>(),
782 &[
783 Boundary::new(7, 0),
784 Boundary::new(12, 0),
785 Boundary::new(18, 0)
786 ],
787 );
788
789 // Test wrapping with a mix of text and element fragments
790 assert_eq!(
791 wrapper
792 .wrap_line(
793 &[
794 LineFragment::text("aa "),
795 LineFragment::element(px(20.), 1),
796 LineFragment::text(" bbb "),
797 LineFragment::element(px(30.), 1),
798 LineFragment::text(" cccc")
799 ],
800 px(72.)
801 )
802 .collect::<Vec<_>>(),
803 &[
804 Boundary::new(5, 0),
805 Boundary::new(9, 0),
806 Boundary::new(11, 0)
807 ],
808 );
809
810 // Test with element at the beginning and text afterward
811 assert_eq!(
812 wrapper
813 .wrap_line(
814 &[
815 LineFragment::element(px(50.), 1),
816 LineFragment::text(" aaaa bbbb cccc dddd")
817 ],
818 px(72.)
819 )
820 .collect::<Vec<_>>(),
821 &[
822 Boundary::new(2, 0),
823 Boundary::new(7, 0),
824 Boundary::new(12, 0),
825 Boundary::new(17, 0)
826 ],
827 );
828
829 // Test with a large element that forces wrapping by itself
830 assert_eq!(
831 wrapper
832 .wrap_line(
833 &[
834 LineFragment::text("short text "),
835 LineFragment::element(px(100.), 1),
836 LineFragment::text(" more text")
837 ],
838 px(72.)
839 )
840 .collect::<Vec<_>>(),
841 &[
842 Boundary::new(6, 0),
843 Boundary::new(11, 0),
844 Boundary::new(12, 0),
845 Boundary::new(18, 0)
846 ],
847 );
848
849 // Test with non-breaking glue characters
850 assert_eq!(
851 wrapper
852 .wrap_line(
853 &[LineFragment::text("a\u{202F}b\u{00A0}c\u{2011}d e")],
854 px(72.0)
855 )
856 .collect::<Vec<_>>(),
857 &[Boundary::new(12, 0),], // special chars above take up 3, 2 and 3 bytes, so boundary ends up at 12
858 );
859 }
860
861 #[test]
862 fn test_truncate_line_end() {
863 let mut wrapper = build_wrapper();
864
865 fn perform_test(
866 wrapper: &mut LineWrapper,
867 text: &'static str,
868 expected: &'static str,
869 ellipsis: &str,
870 ) {
871 let dummy_run_lens = vec![text.len()];
872 let dummy_runs = generate_test_runs(&dummy_run_lens);
873 let (result, dummy_runs) = wrapper.truncate_line(
874 text.into(),
875 px(220.),
876 ellipsis,
877 &dummy_runs,
878 TruncateFrom::End,
879 );
880 assert_eq!(result, expected);
881 assert_eq!(dummy_runs.first().unwrap().len, result.len());
882 }
883
884 perform_test(
885 &mut wrapper,
886 "aa bbb cccc ddddd eeee ffff gggg",
887 "aa bbb cccc ddddd eeee",
888 "",
889 );
890 perform_test(
891 &mut wrapper,
892 "aa bbb cccc ddddd eeee ffff gggg",
893 "aa bbb cccc ddddd eee…",
894 "…",
895 );
896 perform_test(
897 &mut wrapper,
898 "aa bbb cccc ddddd eeee ffff gggg",
899 "aa bbb cccc dddd......",
900 "......",
901 );
902 perform_test(
903 &mut wrapper,
904 "aa bbb cccc 🦀🦀🦀🦀🦀 eeee ffff gggg",
905 "aa bbb cccc 🦀🦀🦀🦀…",
906 "…",
907 );
908 }
909
910 #[test]
911 fn test_truncate_line_start() {
912 let mut wrapper = build_wrapper();
913
914 #[track_caller]
915 fn perform_test(
916 wrapper: &mut LineWrapper,
917 text: &'static str,
918 expected: &'static str,
919 ellipsis: &str,
920 ) {
921 let dummy_run_lens = vec![text.len()];
922 let dummy_runs = generate_test_runs(&dummy_run_lens);
923 let (result, dummy_runs) = wrapper.truncate_line(
924 text.into(),
925 px(220.),
926 ellipsis,
927 &dummy_runs,
928 TruncateFrom::Start,
929 );
930 assert_eq!(result, expected);
931 assert_eq!(dummy_runs.first().unwrap().len, result.len());
932 }
933
934 perform_test(
935 &mut wrapper,
936 "aaaa bbbb cccc ddddd eeee fff gg",
937 "cccc ddddd eeee fff gg",
938 "",
939 );
940 perform_test(
941 &mut wrapper,
942 "aaaa bbbb cccc ddddd eeee fff gg",
943 "…ccc ddddd eeee fff gg",
944 "…",
945 );
946 perform_test(
947 &mut wrapper,
948 "aaaa bbbb cccc ddddd eeee fff gg",
949 "......dddd eeee fff gg",
950 "......",
951 );
952 perform_test(
953 &mut wrapper,
954 "aaaa bbbb cccc 🦀🦀🦀🦀🦀 eeee fff gg",
955 "…🦀🦀🦀🦀 eeee fff gg",
956 "…",
957 );
958 }
959
960 #[test]
961 fn test_truncate_multiple_runs_end() {
962 let mut wrapper = build_wrapper();
963
964 fn perform_test(
965 wrapper: &mut LineWrapper,
966 text: &'static str,
967 expected: &str,
968 run_lens: &[usize],
969 result_run_len: &[usize],
970 line_width: Pixels,
971 ) {
972 let dummy_runs = generate_test_runs(run_lens);
973 let (result, dummy_runs) =
974 wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End);
975 assert_eq!(result, expected);
976 for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
977 assert_eq!(run.len, *result_len);
978 }
979 }
980 // Case 0: Normal
981 // Text: abcdefghijkl
982 // Runs: Run0 { len: 12, ... }
983 //
984 // Truncate res: abcd… (truncate_at = 4)
985 // Run res: Run0 { string: abcd…, len: 7, ... }
986 perform_test(&mut wrapper, "abcdefghijkl", "abcd…", &[12], &[7], px(50.));
987 // Case 1: Drop some runs
988 // Text: abcdefghijkl
989 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
990 //
991 // Truncate res: abcdef… (truncate_at = 6)
992 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
993 // 5, ... }
994 perform_test(
995 &mut wrapper,
996 "abcdefghijkl",
997 "abcdef…",
998 &[4, 4, 4],
999 &[4, 5],
1000 px(70.),
1001 );
1002 // Case 2: Truncate at start of some run
1003 // Text: abcdefghijkl
1004 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1005 //
1006 // Truncate res: abcdefgh… (truncate_at = 8)
1007 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
1008 // 4, ... }, Run2 { string: …, len: 3, ... }
1009 perform_test(
1010 &mut wrapper,
1011 "abcdefghijkl",
1012 "abcdefgh…",
1013 &[4, 4, 4],
1014 &[4, 4, 3],
1015 px(90.),
1016 );
1017 }
1018
1019 #[test]
1020 fn test_truncate_multiple_runs_start() {
1021 let mut wrapper = build_wrapper();
1022
1023 #[track_caller]
1024 fn perform_test(
1025 wrapper: &mut LineWrapper,
1026 text: &'static str,
1027 expected: &str,
1028 run_lens: &[usize],
1029 result_run_len: &[usize],
1030 line_width: Pixels,
1031 ) {
1032 let dummy_runs = generate_test_runs(run_lens);
1033 let (result, dummy_runs) = wrapper.truncate_line(
1034 text.into(),
1035 line_width,
1036 "…",
1037 &dummy_runs,
1038 TruncateFrom::Start,
1039 );
1040 assert_eq!(result, expected);
1041 for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
1042 assert_eq!(run.len, *result_len);
1043 }
1044 }
1045 // Case 0: Normal
1046 // Text: abcdefghijkl
1047 // Runs: Run0 { len: 12, ... }
1048 //
1049 // Truncate res: …ijkl (truncate_at = 9)
1050 // Run res: Run0 { string: …ijkl, len: 7, ... }
1051 perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
1052 // Case 1: Drop some runs
1053 // Text: abcdefghijkl
1054 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1055 //
1056 // Truncate res: …ghijkl (truncate_at = 7)
1057 // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len:
1058 // 4, ... }
1059 perform_test(
1060 &mut wrapper,
1061 "abcdefghijkl",
1062 "…ghijkl",
1063 &[4, 4, 4],
1064 &[5, 4],
1065 px(70.),
1066 );
1067 // Case 2: Truncate at start of some run
1068 // Text: abcdefghijkl
1069 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1070 //
1071 // Truncate res: abcdefgh… (truncate_at = 3)
1072 // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len:
1073 // 4, ... }, Run2 { string: ijkl, len: 4, ... }
1074 perform_test(
1075 &mut wrapper,
1076 "abcdefghijkl",
1077 "…efghijkl",
1078 &[4, 4, 4],
1079 &[3, 4, 4],
1080 px(90.),
1081 );
1082 }
1083
1084 #[test]
1085 fn test_update_run_after_truncation_end() {
1086 fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
1087 let mut dummy_runs = generate_test_runs(run_lens);
1088 update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End);
1089 for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
1090 assert_eq!(run.len, *result_len);
1091 }
1092 }
1093 // Case 0: Normal
1094 // Text: abcdefghijkl
1095 // Runs: Run0 { len: 12, ... }
1096 //
1097 // Truncate res: abcd… (truncate_at = 4)
1098 // Run res: Run0 { string: abcd…, len: 7, ... }
1099 perform_test("abcd…", &[12], &[7]);
1100 // Case 1: Drop some runs
1101 // Text: abcdefghijkl
1102 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1103 //
1104 // Truncate res: abcdef… (truncate_at = 6)
1105 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef…, len:
1106 // 5, ... }
1107 perform_test("abcdef…", &[4, 4, 4], &[4, 5]);
1108 // Case 2: Truncate at start of some run
1109 // Text: abcdefghijkl
1110 // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
1111 //
1112 // Truncate res: abcdefgh… (truncate_at = 8)
1113 // Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
1114 // 4, ... }, Run2 { string: …, len: 3, ... }
1115 perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]);
1116 }
1117
1118 #[test]
1119 fn test_is_word_char() {
1120 #[track_caller]
1121 fn assert_word(word: &str) {
1122 for c in word.chars() {
1123 assert!(
1124 LineWrapper::is_word_char(c),
1125 "assertion failed for '{}' (unicode 0x{:x})",
1126 c,
1127 c as u32
1128 );
1129 }
1130 }
1131
1132 #[track_caller]
1133 fn assert_not_word(word: &str) {
1134 let found = word.chars().any(|c| !LineWrapper::is_word_char(c));
1135 assert!(found, "assertion failed for '{}'", word);
1136 }
1137
1138 assert_word("Hello123");
1139 assert_word("non-English");
1140 assert_word("var_name");
1141 assert_word("123456");
1142 assert_word("3.1415");
1143 assert_word("10^2");
1144 assert_word("1~2");
1145 assert_word("100%");
1146 assert_word("@mention");
1147 assert_word("#hashtag");
1148 assert_word("$variable");
1149 assert_word("a=1");
1150 assert_word("Self::is_word_char");
1151 assert_word("on;");
1152 assert_word("more⋯");
1153 assert_word("won’t");
1154 assert_word("‘twas");
1155
1156 // Space
1157 assert_not_word("foo bar");
1158
1159 // URL case
1160 assert_word("github.com");
1161 assert_not_word("zed-industries/zed");
1162 assert_not_word("zed-industries\\zed");
1163 assert_not_word("a=1&b=2");
1164 assert_not_word("foo?b=2");
1165
1166 // Latin-1 Supplement
1167 assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ");
1168 // Latin Extended-A
1169 assert_word("ĀāĂ㥹ĆćĈĉĊċČčĎď");
1170 // Latin Extended-B
1171 assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
1172 // Cyrillic
1173 assert_word("АБВГДЕЖЗИЙКЛМНОП");
1174 // Vietnamese (https://github.com/zed-industries/zed/issues/23245)
1175 assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
1176 // Bengali
1177 assert_word("গিয়েছিলেন");
1178 assert_word("ছেলে");
1179 assert_word("হচ্ছিল");
1180
1181 // non-word characters
1182 assert_not_word("你好");
1183 assert_not_word("안녕하세요");
1184 assert_not_word("こんにちは");
1185 assert_not_word("😀😁😂");
1186 assert_not_word("()[]{}<>");
1187
1188 // Non-breaking ("Glue") characters, see https://www.unicode.org/reports/tr14/
1189 // (https://github.com/zed-industries/zed/issues/59664)
1190 assert_word("\u{202F}"); // NNBSP " "
1191 assert_word("\u{00A0}"); // NBSP " "
1192 assert_word("\u{2011}"); // NBH "‑"
1193 }
1194
1195 // For compatibility with the test macro
1196 #[cfg(target_os = "macos")]
1197 use crate as gpui;
1198
1199 // These seem to vary wildly based on the text system.
1200 #[cfg(target_os = "macos")]
1201 #[crate::test]
1202 fn test_wrap_shaped_line(cx: &mut TestAppContext) {
1203 cx.update(|cx| {
1204 let text_system = WindowTextSystem::new(cx.text_system().clone());
1205
1206 let normal = TextRun {
1207 len: 0,
1208 font: font("Helvetica"),
1209 color: Default::default(),
1210 underline: Default::default(),
1211 ..Default::default()
1212 };
1213 let bold = TextRun {
1214 len: 0,
1215 font: font("Helvetica").bold(),
1216 ..Default::default()
1217 };
1218
1219 let text = "aa bbb cccc ddddd eeee".into();
1220 let lines = text_system
1221 .shape_text(
1222 text,
1223 px(16.),
1224 &[
1225 normal.with_len(4),
1226 bold.with_len(5),
1227 normal.with_len(6),
1228 bold.with_len(1),
1229 normal.with_len(7),
1230 ],
1231 Some(px(72.)),
1232 None,
1233 )
1234 .unwrap();
1235
1236 assert_eq!(
1237 lines[0].layout.wrap_boundaries(),
1238 &[
1239 WrapBoundary {
1240 run_ix: 0,
1241 glyph_ix: 7
1242 },
1243 WrapBoundary {
1244 run_ix: 0,
1245 glyph_ix: 12
1246 },
1247 WrapBoundary {
1248 run_ix: 0,
1249 glyph_ix: 18
1250 }
1251 ],
1252 );
1253 });
1254 }
1255
1256 #[test]
1257 fn test_multiline_truncation_fits_within_wrapped_lines() {
1258 let mut wrapper = build_wrapper();
1259
1260 // With .ZedMono at 16px, each char is 9.6px wide.
1261 // wrap_width = 72px fits ~7 chars per line.
1262 //
1263 // "aa bbbbbb cccccc dddddd eeee ffff" with wrap_width=72px wraps as:
1264 // Line 1: "aa " (28.8px, wraps because "bbbbbb" won't fit)
1265 // Line 2: "bbbbbb " (67.2px)
1266 // Line 3: "cccccc " (67.2px)
1267 // ...
1268 //
1269 // truncate_wrapped_line should wrap first to find line 2 starts at
1270 // "bbbbbb...", then truncate only that line to fit with ellipsis.
1271 let text: &str = "aa bbbbbb cccccc dddddd eeee ffff";
1272 let wrap_width = px(72.);
1273 let max_lines: usize = 2;
1274
1275 let runs = generate_test_runs(&[text.len()]);
1276 let (truncated, _) = wrapper.truncate_wrapped_line(
1277 text.into(),
1278 wrap_width,
1279 max_lines,
1280 "\u{2026}",
1281 &runs,
1282 TruncateFrom::End,
1283 );
1284
1285 // The truncated text, when wrapped, must fit within max_lines lines.
1286 let wrap_count = wrapper
1287 .wrap_line(&[LineFragment::text(&truncated)], wrap_width)
1288 .count();
1289
1290 assert!(
1291 wrap_count < max_lines,
1292 "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1293 truncated,
1294 wrap_count + 1,
1295 max_lines
1296 );
1297
1298 // The truncated text should end with the ellipsis.
1299 assert!(
1300 truncated.ends_with('\u{2026}'),
1301 "Truncated text '{}' should end with ellipsis",
1302 truncated
1303 );
1304 }
1305
1306 #[test]
1307 fn test_multiline_truncation_no_truncation_needed() {
1308 let mut wrapper = build_wrapper();
1309
1310 // Text that fits in 2 lines shouldn't be truncated.
1311 // Line 1: "aa bbb " (67.2px), Line 2: "cccccc" (57.6px)
1312 let text: &str = "aa bbb cccccc";
1313 let wrap_width = px(72.);
1314 let max_lines: usize = 2;
1315
1316 let runs = generate_test_runs(&[text.len()]);
1317 let (result, _) = wrapper.truncate_wrapped_line(
1318 text.into(),
1319 wrap_width,
1320 max_lines,
1321 "\u{2026}",
1322 &runs,
1323 TruncateFrom::End,
1324 );
1325
1326 assert_eq!(
1327 result.as_ref(),
1328 text,
1329 "Text that fits should not be modified"
1330 );
1331 }
1332
1333 #[test]
1334 fn test_multiline_truncation_three_lines() {
1335 let mut wrapper = build_wrapper();
1336
1337 let text: &str = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj";
1338 let wrap_width = px(72.);
1339 let max_lines: usize = 3;
1340
1341 let runs = generate_test_runs(&[text.len()]);
1342 let (truncated, _) = wrapper.truncate_wrapped_line(
1343 text.into(),
1344 wrap_width,
1345 max_lines,
1346 "\u{2026}",
1347 &runs,
1348 TruncateFrom::End,
1349 );
1350
1351 let wrap_count = wrapper
1352 .wrap_line(&[LineFragment::text(&truncated)], wrap_width)
1353 .count();
1354
1355 assert!(
1356 wrap_count < max_lines,
1357 "Truncated text '{}' wraps into {} visual lines, expected at most {}",
1358 truncated,
1359 wrap_count + 1,
1360 max_lines
1361 );
1362
1363 assert!(
1364 truncated.ends_with('\u{2026}'),
1365 "Truncated text '{}' should end with ellipsis",
1366 truncated
1367 );
1368 }
1369
1370 #[test]
1371 fn test_multiline_truncation_with_newlines() {
1372 let mut wrapper = build_wrapper();
1373
1374 // "hello\nworld foo bar baz" with line_clamp(2):
1375 // shape_text splits on \n, giving physical lines "hello" and
1376 // "world foo bar baz". The newline consumes line 1, so the
1377 // second physical line should be truncated on line 2.
1378 let text: &str = "hello\nworld foo bar baz";
1379 let wrap_width = px(72.);
1380 let max_lines: usize = 2;
1381
1382 let runs = generate_test_runs(&[text.len()]);
1383 let (truncated, _) = wrapper.truncate_wrapped_line(
1384 text.into(),
1385 wrap_width,
1386 max_lines,
1387 "\u{2026}",
1388 &runs,
1389 TruncateFrom::End,
1390 );
1391
1392 // The newline should be preserved.
1393 let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1394 assert_eq!(
1395 parts.len(),
1396 2,
1397 "Newline should be preserved: '{}'",
1398 truncated
1399 );
1400 assert_eq!(parts[0], "hello");
1401
1402 // The second line should fit within wrap_width and end with ellipsis.
1403 let second_line_width: Pixels = parts[1].chars().map(|c| wrapper.width_for_char(c)).sum();
1404 assert!(
1405 second_line_width <= wrap_width,
1406 "Second line '{}' ({}px) exceeds wrap_width ({}px)",
1407 parts[1],
1408 second_line_width,
1409 wrap_width
1410 );
1411 assert!(
1412 truncated.ends_with('\u{2026}'),
1413 "Should end with ellipsis: '{}'",
1414 truncated
1415 );
1416 }
1417
1418 #[test]
1419 fn test_multiline_truncation_newline_on_last_line() {
1420 let mut wrapper = build_wrapper();
1421
1422 // "hello\nworld\nmore" with line_clamp(2):
1423 // Line 1: "hello", Line 2: "world" — but there's a third line,
1424 // so line 2 should be truncated with ellipsis.
1425 let text: &str = "hello\nworld\nmore";
1426 let wrap_width = px(72.);
1427 let max_lines: usize = 2;
1428
1429 let runs = generate_test_runs(&[text.len()]);
1430 let (truncated, _) = wrapper.truncate_wrapped_line(
1431 text.into(),
1432 wrap_width,
1433 max_lines,
1434 "\u{2026}",
1435 &runs,
1436 TruncateFrom::End,
1437 );
1438
1439 let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
1440 assert_eq!(parts[0], "hello");
1441 assert!(
1442 truncated.ends_with('\u{2026}'),
1443 "Should end with ellipsis since there's more content: '{}'",
1444 truncated
1445 );
1446 }
1447
1448 #[test]
1449 fn test_truncate_line_middle() {
1450 let mut wrapper = build_wrapper();
1451
1452 // No truncation when text fits within a very wide budget.
1453 let short_text = "hello world";
1454 let runs = generate_test_runs(&[short_text.len()]);
1455 let (result, result_runs) = wrapper.truncate_line(
1456 short_text.into(),
1457 px(10000.),
1458 "…",
1459 &runs,
1460 TruncateFrom::Middle,
1461 );
1462 assert_eq!(result.as_ref(), short_text);
1463 assert_eq!(result_runs.len(), 1);
1464 assert_eq!(result_runs[0].len, short_text.len());
1465
1466 // Basic middle truncation: long string with px(100.) budget.
1467 let long_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
1468 let runs = generate_test_runs(&[long_text.len()]);
1469 let (result, _result_runs) =
1470 wrapper.truncate_line(long_text.into(), px(100.), "…", &runs, TruncateFrom::Middle);
1471 assert!(
1472 result.contains('…'),
1473 "Middle-truncated result should contain '…', got: '{}'",
1474 result
1475 );
1476 assert!(
1477 result.chars().count() < long_text.chars().count(),
1478 "Middle-truncated result should be shorter than original"
1479 );
1480 assert_eq!(
1481 result.chars().next(),
1482 long_text.chars().next(),
1483 "Result should start with the same first character as original"
1484 );
1485 assert_eq!(
1486 result.chars().last(),
1487 long_text.chars().last(),
1488 "Result should end with the same last character as original"
1489 );
1490
1491 // Degenerate case: budget so narrow that middle truncation cannot find a valid split.
1492 // Still show the truncation affix instead of returning the original overflowing text.
1493 let text = "abcdef";
1494 let runs = generate_test_runs(&[text.len()]);
1495 let (result, result_runs) =
1496 wrapper.truncate_line(text.into(), px(1.), "…", &runs, TruncateFrom::Middle);
1497 assert_eq!(result.as_ref(), "…");
1498 assert_eq!(result_runs.len(), 1);
1499 assert_eq!(result_runs[0].len, "…".len());
1500
1501 // Run adjustment correctness: multiple runs across the string.
1502 // Verify that the returned runs' lengths sum to result.len().
1503 let multi_run_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz";
1504 let run_lens = [20, 20, multi_run_text.len() - 40];
1505 let runs = generate_test_runs(&run_lens);
1506 let (result, result_runs) = wrapper.truncate_line(
1507 multi_run_text.into(),
1508 px(100.),
1509 "…",
1510 &runs,
1511 TruncateFrom::Middle,
1512 );
1513 let total_run_len: usize = result_runs.iter().map(|r| r.len).sum();
1514 assert_eq!(
1515 total_run_len,
1516 result.len(),
1517 "Sum of run lengths ({}) should equal result byte length ({})",
1518 total_run_len,
1519 result.len()
1520 );
1521 }
1522
1523 #[test]
1524 fn test_multiline_truncation_trailing_newline() {
1525 let mut wrapper = build_wrapper();
1526
1527 // "hello\nworld\n" with line_clamp(2):
1528 // The trailing newline has no content after it, so no ellipsis.
1529 let text: &str = "hello\nworld\n";
1530 let wrap_width = px(72.);
1531 let max_lines: usize = 2;
1532
1533 let runs = generate_test_runs(&[text.len()]);
1534 let (result, _) = wrapper.truncate_wrapped_line(
1535 text.into(),
1536 wrap_width,
1537 max_lines,
1538 "\u{2026}",
1539 &runs,
1540 TruncateFrom::End,
1541 );
1542
1543 assert!(
1544 !result.ends_with('\u{2026}'),
1545 "Trailing newline with no content should not add ellipsis: '{}'",
1546 result
1547 );
1548 }
1549
1550 #[test]
1551 fn test_multiline_truncation_newline_fits_exactly() {
1552 let mut wrapper = build_wrapper();
1553
1554 // "hello\nworld" with line_clamp(2):
1555 // Exactly 2 lines, no truncation needed.
1556 let text: &str = "hello\nworld";
1557 let wrap_width = px(72.);
1558 let max_lines: usize = 2;
1559
1560 let runs = generate_test_runs(&[text.len()]);
1561 let (result, _) = wrapper.truncate_wrapped_line(
1562 text.into(),
1563 wrap_width,
1564 max_lines,
1565 "\u{2026}",
1566 &runs,
1567 TruncateFrom::End,
1568 );
1569
1570 assert_eq!(
1571 result.as_ref(),
1572 text,
1573 "Text that fits exactly should not be modified: '{}'",
1574 result
1575 );
1576 }
1577}
1578