mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
da66f95237
commit
ba350974af
2 changed files with 412 additions and 8 deletions
|
|
@ -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))
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue