Fix multiline text truncation (#57450)

Improves .text_truncate() in combination with .line_clamp()

<img width="384" height="124" alt="Screenshot 2026-05-21 at 4 28 59 PM"
src="https://github.com/user-attachments/assets/3decc78c-8d5d-4d34-84c5-4274a2d12bea"
/>
<img width="385" height="143" alt="Screenshot 2026-05-21 at 4 26 23 PM"
src="https://github.com/user-attachments/assets/f807b19a-6834-4504-9749-e16b8d68a7aa"
/>

This was previously broken because we assumed lines would break evenly.

Release Notes:

- Improved truncation of multi-line text in the UI
This commit is contained in:
Conrad Irwin 2026-05-21 16:40:35 -06:00 committed by GitHub
parent da66f95237
commit ba350974af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 412 additions and 8 deletions

View file

@ -455,14 +455,27 @@ impl TextLayout {
}
let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
let (text, runs) = if let Some(truncate_width) = truncate_width {
line_wrapper.truncate_line(
text.clone(),
truncate_width,
&truncation_affix,
&runs,
truncate_from,
)
let (text, runs) = if truncate_width.is_some() {
if let Some(max_lines) = text_style.line_clamp
&& let Some(wrap_width) = wrap_width
{
line_wrapper.truncate_wrapped_line(
text.clone(),
wrap_width,
max_lines,
&truncation_affix,
&runs,
truncate_from,
)
} else {
line_wrapper.truncate_line(
text.clone(),
truncate_width.unwrap_or(Pixels::MAX),
&truncation_affix,
&runs,
truncate_from,
)
}
} else {
(text.clone(), Cow::Borrowed(&*runs))
};

View file

@ -215,6 +215,150 @@ impl LineWrapper {
}
}
/// Truncate text to fit within a given number of wrapped lines.
///
/// Unlike `truncate_line` which treats the text as a flat width budget
/// (`width * max_lines`), this method accounts for word-boundary wrapping:
/// it walks through characters once, tracking wrap boundaries and the
/// truncation point simultaneously. When text overflows on the last
/// allowed line, it truncates there and appends the affix.
///
/// For `max_lines == 1`, this delegates to `truncate_line`.
pub fn truncate_wrapped_line<'a>(
&mut self,
text: SharedString,
wrap_width: Pixels,
max_lines: usize,
truncation_affix: &str,
runs: &'a [TextRun],
truncate_from: TruncateFrom,
) -> (SharedString, Cow<'a, [TextRun]>) {
if max_lines <= 1 || truncate_from == TruncateFrom::Start {
return self.truncate_line(
text,
wrap_width * max_lines,
truncation_affix,
runs,
truncate_from,
);
}
let affix_width: Pixels = truncation_affix
.chars()
.map(|c| self.width_for_char(c))
.sum();
let mut width = px(0.);
let mut line = 0usize;
let mut first_non_whitespace_ix = None;
let mut last_candidate_ix = 0usize;
let mut last_candidate_width = px(0.);
let mut last_wrap_ix = 0usize;
let mut prev_c = '\0';
let mut indent: Option<u32> = None;
let mut truncate_ix = 0usize;
for (ix, c) in text.char_indices() {
if c == '\n' {
if line >= max_lines - 1 && !text[ix + 1..].trim().is_empty() {
// Newline on the last allowed line with real content
// below. Truncate here.
let truncated = text[..truncate_ix]
.trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
let result = SharedString::from(format!("{truncated}{truncation_affix}"));
let mut runs = runs.to_vec();
update_runs_after_truncation(
&result,
truncation_affix,
&mut runs,
TruncateFrom::End,
);
return (result, Cow::Owned(runs));
}
// Newline before the last line: it consumes a line.
line += 1;
width = px(0.);
first_non_whitespace_ix = None;
last_candidate_ix = 0;
last_candidate_width = px(0.);
last_wrap_ix = ix + 1;
prev_c = '\0';
indent = None;
truncate_ix = ix + 1;
continue;
}
let char_width = self.width_for_char(c);
if Self::is_word_char(c) {
if prev_c == ' ' && first_non_whitespace_ix.is_some() {
last_candidate_ix = ix;
last_candidate_width = width;
}
} else if c != ' ' && first_non_whitespace_ix.is_some() {
last_candidate_ix = ix;
last_candidate_width = width;
}
if c != ' ' && first_non_whitespace_ix.is_none() {
first_non_whitespace_ix = Some(ix);
}
width += char_width;
if line < max_lines - 1 {
// Before the last line: replicate wrap_line's boundary logic.
if width > wrap_width && ix > last_wrap_ix {
if let (None, Some(first_nw)) = (indent, first_non_whitespace_ix) {
indent = Some(Self::MAX_INDENT.min((first_nw - last_wrap_ix) as u32));
}
if last_candidate_ix > last_wrap_ix {
last_wrap_ix = last_candidate_ix;
width -= last_candidate_width;
last_candidate_ix = 0;
} else {
last_wrap_ix = ix;
width = char_width;
}
if let Some(ind) = indent {
width += self.width_for_char(' ') * ind as f32;
}
line += 1;
truncate_ix = last_wrap_ix;
}
} else {
// On the last line: track the furthest point where the affix
// still fits, and stop as soon as the line overflows.
if width + affix_width <= wrap_width {
truncate_ix = ix + c.len_utf8();
}
if width > wrap_width {
let truncated = text[..truncate_ix]
.trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation());
let result = SharedString::from(format!("{truncated}{truncation_affix}"));
let mut runs = runs.to_vec();
update_runs_after_truncation(
&result,
truncation_affix,
&mut runs,
TruncateFrom::End,
);
return (result, Cow::Owned(runs));
}
}
prev_c = c;
}
// Text fits within max_lines without truncation.
(text, Cow::Borrowed(runs))
}
/// Any character in this list should be treated as a word character,
/// meaning it can be part of a word that should not be wrapped.
pub(crate) fn is_word_char(c: char) -> bool {
@ -938,4 +1082,251 @@ mod tests {
);
});
}
#[test]
fn test_multiline_truncation_fits_within_wrapped_lines() {
let mut wrapper = build_wrapper();
// With .ZedMono at 16px, each char is 9.6px wide.
// wrap_width = 72px fits ~7 chars per line.
//
// "aa bbbbbb cccccc dddddd eeee ffff" with wrap_width=72px wraps as:
// Line 1: "aa " (28.8px, wraps because "bbbbbb" won't fit)
// Line 2: "bbbbbb " (67.2px)
// Line 3: "cccccc " (67.2px)
// ...
//
// truncate_wrapped_line should wrap first to find line 2 starts at
// "bbbbbb...", then truncate only that line to fit with ellipsis.
let text: &str = "aa bbbbbb cccccc dddddd eeee ffff";
let wrap_width = px(72.);
let max_lines: usize = 2;
let runs = generate_test_runs(&[text.len()]);
let (truncated, _) = wrapper.truncate_wrapped_line(
text.into(),
wrap_width,
max_lines,
"\u{2026}",
&runs,
TruncateFrom::End,
);
// The truncated text, when wrapped, must fit within max_lines lines.
let wrap_count = wrapper
.wrap_line(&[LineFragment::text(&truncated)], wrap_width)
.count();
assert!(
wrap_count < max_lines,
"Truncated text '{}' wraps into {} visual lines, expected at most {}",
truncated,
wrap_count + 1,
max_lines
);
// The truncated text should end with the ellipsis.
assert!(
truncated.ends_with('\u{2026}'),
"Truncated text '{}' should end with ellipsis",
truncated
);
}
#[test]
fn test_multiline_truncation_no_truncation_needed() {
let mut wrapper = build_wrapper();
// Text that fits in 2 lines shouldn't be truncated.
// Line 1: "aa bbb " (67.2px), Line 2: "cccccc" (57.6px)
let text: &str = "aa bbb cccccc";
let wrap_width = px(72.);
let max_lines: usize = 2;
let runs = generate_test_runs(&[text.len()]);
let (result, _) = wrapper.truncate_wrapped_line(
text.into(),
wrap_width,
max_lines,
"\u{2026}",
&runs,
TruncateFrom::End,
);
assert_eq!(
result.as_ref(),
text,
"Text that fits should not be modified"
);
}
#[test]
fn test_multiline_truncation_three_lines() {
let mut wrapper = build_wrapper();
let text: &str = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj";
let wrap_width = px(72.);
let max_lines: usize = 3;
let runs = generate_test_runs(&[text.len()]);
let (truncated, _) = wrapper.truncate_wrapped_line(
text.into(),
wrap_width,
max_lines,
"\u{2026}",
&runs,
TruncateFrom::End,
);
let wrap_count = wrapper
.wrap_line(&[LineFragment::text(&truncated)], wrap_width)
.count();
assert!(
wrap_count < max_lines,
"Truncated text '{}' wraps into {} visual lines, expected at most {}",
truncated,
wrap_count + 1,
max_lines
);
assert!(
truncated.ends_with('\u{2026}'),
"Truncated text '{}' should end with ellipsis",
truncated
);
}
#[test]
fn test_multiline_truncation_with_newlines() {
let mut wrapper = build_wrapper();
// "hello\nworld foo bar baz" with line_clamp(2):
// shape_text splits on \n, giving physical lines "hello" and
// "world foo bar baz". The newline consumes line 1, so the
// second physical line should be truncated on line 2.
let text: &str = "hello\nworld foo bar baz";
let wrap_width = px(72.);
let max_lines: usize = 2;
let runs = generate_test_runs(&[text.len()]);
let (truncated, _) = wrapper.truncate_wrapped_line(
text.into(),
wrap_width,
max_lines,
"\u{2026}",
&runs,
TruncateFrom::End,
);
// The newline should be preserved.
let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
assert_eq!(
parts.len(),
2,
"Newline should be preserved: '{}'",
truncated
);
assert_eq!(parts[0], "hello");
// The second line should fit within wrap_width and end with ellipsis.
let second_line_width: Pixels = parts[1].chars().map(|c| wrapper.width_for_char(c)).sum();
assert!(
second_line_width <= wrap_width,
"Second line '{}' ({}px) exceeds wrap_width ({}px)",
parts[1],
second_line_width,
wrap_width
);
assert!(
truncated.ends_with('\u{2026}'),
"Should end with ellipsis: '{}'",
truncated
);
}
#[test]
fn test_multiline_truncation_newline_on_last_line() {
let mut wrapper = build_wrapper();
// "hello\nworld\nmore" with line_clamp(2):
// Line 1: "hello", Line 2: "world" — but there's a third line,
// so line 2 should be truncated with ellipsis.
let text: &str = "hello\nworld\nmore";
let wrap_width = px(72.);
let max_lines: usize = 2;
let runs = generate_test_runs(&[text.len()]);
let (truncated, _) = wrapper.truncate_wrapped_line(
text.into(),
wrap_width,
max_lines,
"\u{2026}",
&runs,
TruncateFrom::End,
);
let parts: Vec<&str> = truncated.splitn(2, '\n').collect();
assert_eq!(parts[0], "hello");
assert!(
truncated.ends_with('\u{2026}'),
"Should end with ellipsis since there's more content: '{}'",
truncated
);
}
#[test]
fn test_multiline_truncation_trailing_newline() {
let mut wrapper = build_wrapper();
// "hello\nworld\n" with line_clamp(2):
// The trailing newline has no content after it, so no ellipsis.
let text: &str = "hello\nworld\n";
let wrap_width = px(72.);
let max_lines: usize = 2;
let runs = generate_test_runs(&[text.len()]);
let (result, _) = wrapper.truncate_wrapped_line(
text.into(),
wrap_width,
max_lines,
"\u{2026}",
&runs,
TruncateFrom::End,
);
assert!(
!result.ends_with('\u{2026}'),
"Trailing newline with no content should not add ellipsis: '{}'",
result
);
}
#[test]
fn test_multiline_truncation_newline_fits_exactly() {
let mut wrapper = build_wrapper();
// "hello\nworld" with line_clamp(2):
// Exactly 2 lines, no truncation needed.
let text: &str = "hello\nworld";
let wrap_width = px(72.);
let max_lines: usize = 2;
let runs = generate_test_runs(&[text.len()]);
let (result, _) = wrapper.truncate_wrapped_line(
text.into(),
wrap_width,
max_lines,
"\u{2026}",
&runs,
TruncateFrom::End,
);
assert_eq!(
result.as_ref(),
text,
"Text that fits exactly should not be modified: '{}'",
result
);
}
}