From 62d36b22fd1c497ae586f89f21b7a80ea6d8091a Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Fri, 19 Dec 2025 10:25:19 -0300
Subject: [PATCH] gpui: Add `text_ellipsis_start` method (#45122)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR is an additive change introducing the `truncate_start` method to
labels, which gives us the ability to add an ellipsis at the beginning
of the text as opposed to the regular `truncate`. This will be generally
used for truncating file paths, where the end is typically more relevant
than the beginning, but given it's a general method, there's the
possibility to be used anywhere else, too.
Release Notes:
- N/A
---------
Co-authored-by: Lukas Wirth
---
crates/editor/src/code_context_menus.rs | 8 +-
crates/gpui/src/elements/text.rs | 14 +-
crates/gpui/src/style.rs | 8 +-
crates/gpui/src/styled.rs | 10 +-
crates/gpui/src/text_system/line_wrapper.rs | 228 ++++++++++++++++---
crates/ui/src/components/label/label.rs | 9 +-
crates/ui/src/components/label/label_like.rs | 13 +-
7 files changed, 248 insertions(+), 42 deletions(-)
diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs
index 96739defc506414f573e2454dc31f9c32d8e4adf..e5520be88e34307220126ebafdba6c6371a5db12 100644
--- a/crates/editor/src/code_context_menus.rs
+++ b/crates/editor/src/code_context_menus.rs
@@ -1615,8 +1615,12 @@ impl CodeActionsMenu {
window.text_style().font(),
window.text_style().font_size.to_pixels(window.rem_size()),
);
- let is_truncated =
- line_wrapper.should_truncate_line(&label, CODE_ACTION_MENU_MAX_WIDTH, "…");
+ let is_truncated = line_wrapper.should_truncate_line(
+ &label,
+ CODE_ACTION_MENU_MAX_WIDTH,
+ "…",
+ gpui::TruncateFrom::End,
+ );
if is_truncated.is_none() {
return None;
diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs
index 1b1bfd778c7bc746c67551eb31cf70f60b1485ea..942a0a326526431dc65f389e9cff67bac252d571 100644
--- a/crates/gpui/src/elements/text.rs
+++ b/crates/gpui/src/elements/text.rs
@@ -2,8 +2,8 @@ use crate::{
ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId,
HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextOverflow,
- TextRun, TextStyle, TooltipId, WhiteSpace, Window, WrappedLine, WrappedLineLayout,
- register_tooltip_mouse_handlers, set_tooltip_on_window,
+ TextRun, TextStyle, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine,
+ WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window,
};
use anyhow::Context as _;
use itertools::Itertools;
@@ -354,7 +354,7 @@ impl TextLayout {
None
};
- let (truncate_width, truncation_suffix) =
+ let (truncate_width, truncation_affix, truncate_from) =
if let Some(text_overflow) = text_style.text_overflow.clone() {
let width = known_dimensions.width.or(match available_space.width {
crate::AvailableSpace::Definite(x) => match text_style.line_clamp {
@@ -365,10 +365,11 @@ impl TextLayout {
});
match text_overflow {
- TextOverflow::Truncate(s) => (width, s),
+ TextOverflow::Truncate(s) => (width, s, TruncateFrom::End),
+ TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start),
}
} else {
- (None, "".into())
+ (None, "".into(), TruncateFrom::End)
};
if let Some(text_layout) = element_state.0.borrow().as_ref()
@@ -383,8 +384,9 @@ impl TextLayout {
line_wrapper.truncate_line(
text.clone(),
truncate_width,
- &truncation_suffix,
+ &truncation_affix,
&runs,
+ truncate_from,
)
} else {
(text.clone(), Cow::Borrowed(&*runs))
diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs
index 4d6e6f490d81d967692a3e9d8316af75a7a4d306..7481b8001e5752599b90625450d7adb0c66ea2ca 100644
--- a/crates/gpui/src/style.rs
+++ b/crates/gpui/src/style.rs
@@ -334,9 +334,13 @@ pub enum WhiteSpace {
/// How to truncate text that overflows the width of the element
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum TextOverflow {
- /// Truncate the text when it doesn't fit, and represent this truncation by displaying the
- /// provided string.
+ /// Truncate the text at the end when it doesn't fit, and represent this truncation by
+ /// displaying the provided string (e.g., "very long te…").
Truncate(SharedString),
+ /// Truncate the text at the start when it doesn't fit, and represent this truncation by
+ /// displaying the provided string at the beginning (e.g., "…ong text here").
+ /// Typically more adequate for file paths where the end is more important than the beginning.
+ TruncateStart(SharedString),
}
/// How to align text within the element
diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs
index e8088a84d7fc141d0a320988c6399afe2b93ce07..c5eef0d4496edea4d30c665c82dc0a9f00bb83be 100644
--- a/crates/gpui/src/styled.rs
+++ b/crates/gpui/src/styled.rs
@@ -75,13 +75,21 @@ pub trait Styled: Sized {
self
}
- /// Sets the truncate overflowing text with an ellipsis (…) if needed.
+ /// Sets the truncate overflowing text with an ellipsis (…) at the end if needed.
/// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis)
fn text_ellipsis(mut self) -> Self {
self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS));
self
}
+ /// Sets the truncate overflowing text with an ellipsis (…) at the start if needed.
+ /// Typically more adequate for file paths where the end is more important than the beginning.
+ /// Note: This doesn't exist in Tailwind CSS.
+ fn text_ellipsis_start(mut self) -> Self {
+ self.text_style().text_overflow = Some(TextOverflow::TruncateStart(ELLIPSIS));
+ self
+ }
+
/// Sets the text overflow behavior of the element.
fn text_overflow(mut self, overflow: TextOverflow) -> Self {
self.text_style().text_overflow = Some(overflow);
diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs
index 95cd55d04443c6b2c351bf8533ccb57d49e8dcd9..457316f353a48fa112de1736b2b7eaa2d4c72313 100644
--- a/crates/gpui/src/text_system/line_wrapper.rs
+++ b/crates/gpui/src/text_system/line_wrapper.rs
@@ -2,6 +2,15 @@ use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun,
use collections::HashMap;
use std::{borrow::Cow, iter, sync::Arc};
+/// Determines whether to truncate text from the start or end.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum TruncateFrom {
+ /// Truncate text from the start.
+ Start,
+ /// Truncate text from the end.
+ End,
+}
+
/// The GPUI line wrapper, used to wrap lines of text to a given width.
pub struct LineWrapper {
platform_text_system: Arc,
@@ -129,29 +138,50 @@ impl LineWrapper {
}
/// Determines if a line should be truncated based on its width.
+ ///
+ /// Returns the truncation index in `line`.
pub fn should_truncate_line(
&mut self,
line: &str,
truncate_width: Pixels,
- truncation_suffix: &str,
+ truncation_affix: &str,
+ truncate_from: TruncateFrom,
) -> Option {
let mut width = px(0.);
- let suffix_width = truncation_suffix
+ let suffix_width = truncation_affix
.chars()
.map(|c| self.width_for_char(c))
.fold(px(0.0), |a, x| a + x);
let mut truncate_ix = 0;
- for (ix, c) in line.char_indices() {
- if width + suffix_width < truncate_width {
- truncate_ix = ix;
+ match truncate_from {
+ TruncateFrom::Start => {
+ for (ix, c) in line.char_indices().rev() {
+ if width + suffix_width < truncate_width {
+ truncate_ix = ix;
+ }
+
+ let char_width = self.width_for_char(c);
+ width += char_width;
+
+ if width.floor() > truncate_width {
+ return Some(truncate_ix);
+ }
+ }
}
+ TruncateFrom::End => {
+ for (ix, c) in line.char_indices() {
+ if width + suffix_width < truncate_width {
+ truncate_ix = ix;
+ }
- let char_width = self.width_for_char(c);
- width += char_width;
+ let char_width = self.width_for_char(c);
+ width += char_width;
- if width.floor() > truncate_width {
- return Some(truncate_ix);
+ if width.floor() > truncate_width {
+ return Some(truncate_ix);
+ }
+ }
}
}
@@ -163,16 +193,23 @@ impl LineWrapper {
&mut self,
line: SharedString,
truncate_width: Pixels,
- truncation_suffix: &str,
+ truncation_affix: &str,
runs: &'a [TextRun],
+ truncate_from: TruncateFrom,
) -> (SharedString, Cow<'a, [TextRun]>) {
if let Some(truncate_ix) =
- self.should_truncate_line(&line, truncate_width, truncation_suffix)
+ self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from)
{
- let result =
- SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
+ let result = match truncate_from {
+ TruncateFrom::Start => {
+ SharedString::from(format!("{truncation_affix}{}", &line[truncate_ix + 1..]))
+ }
+ TruncateFrom::End => {
+ SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix]))
+ }
+ };
let mut runs = runs.to_vec();
- update_runs_after_truncation(&result, truncation_suffix, &mut runs);
+ update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from);
(result, Cow::Owned(runs))
} else {
(line, Cow::Borrowed(runs))
@@ -245,15 +282,35 @@ impl LineWrapper {
}
}
-fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec) {
+fn update_runs_after_truncation(
+ result: &str,
+ ellipsis: &str,
+ runs: &mut Vec,
+ truncate_from: TruncateFrom,
+) {
let mut truncate_at = result.len() - ellipsis.len();
- for (run_index, run) in runs.iter_mut().enumerate() {
- if run.len <= truncate_at {
- truncate_at -= run.len;
- } else {
- run.len = truncate_at + ellipsis.len();
- runs.truncate(run_index + 1);
- break;
+ match truncate_from {
+ TruncateFrom::Start => {
+ for (run_index, run) in runs.iter_mut().enumerate().rev() {
+ if run.len <= truncate_at {
+ truncate_at -= run.len;
+ } else {
+ run.len = truncate_at + ellipsis.len();
+ runs.splice(..run_index, std::iter::empty());
+ break;
+ }
+ }
+ }
+ TruncateFrom::End => {
+ for (run_index, run) in runs.iter_mut().enumerate() {
+ if run.len <= truncate_at {
+ truncate_at -= run.len;
+ } else {
+ run.len = truncate_at + ellipsis.len();
+ runs.truncate(run_index + 1);
+ break;
+ }
+ }
}
}
}
@@ -503,7 +560,7 @@ mod tests {
}
#[test]
- fn test_truncate_line() {
+ fn test_truncate_line_end() {
let mut wrapper = build_wrapper();
fn perform_test(
@@ -514,8 +571,13 @@ mod tests {
) {
let dummy_run_lens = vec![text.len()];
let dummy_runs = generate_test_runs(&dummy_run_lens);
- let (result, dummy_runs) =
- wrapper.truncate_line(text.into(), px(220.), ellipsis, &dummy_runs);
+ let (result, dummy_runs) = wrapper.truncate_line(
+ text.into(),
+ px(220.),
+ ellipsis,
+ &dummy_runs,
+ TruncateFrom::End,
+ );
assert_eq!(result, expected);
assert_eq!(dummy_runs.first().unwrap().len, result.len());
}
@@ -541,7 +603,50 @@ mod tests {
}
#[test]
- fn test_truncate_multiple_runs() {
+ fn test_truncate_line_start() {
+ let mut wrapper = build_wrapper();
+
+ fn perform_test(
+ wrapper: &mut LineWrapper,
+ text: &'static str,
+ expected: &'static str,
+ ellipsis: &str,
+ ) {
+ let dummy_run_lens = vec![text.len()];
+ let dummy_runs = generate_test_runs(&dummy_run_lens);
+ let (result, dummy_runs) = wrapper.truncate_line(
+ text.into(),
+ px(220.),
+ ellipsis,
+ &dummy_runs,
+ TruncateFrom::Start,
+ );
+ assert_eq!(result, expected);
+ assert_eq!(dummy_runs.first().unwrap().len, result.len());
+ }
+
+ perform_test(
+ &mut wrapper,
+ "aaaa bbbb cccc ddddd eeee fff gg",
+ "cccc ddddd eeee fff gg",
+ "",
+ );
+ perform_test(
+ &mut wrapper,
+ "aaaa bbbb cccc ddddd eeee fff gg",
+ "…ccc ddddd eeee fff gg",
+ "…",
+ );
+ perform_test(
+ &mut wrapper,
+ "aaaa bbbb cccc ddddd eeee fff gg",
+ "......dddd eeee fff gg",
+ "......",
+ );
+ }
+
+ #[test]
+ fn test_truncate_multiple_runs_end() {
let mut wrapper = build_wrapper();
fn perform_test(
@@ -554,7 +659,7 @@ mod tests {
) {
let dummy_runs = generate_test_runs(run_lens);
let (result, dummy_runs) =
- wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs);
+ wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End);
assert_eq!(result, expected);
for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
assert_eq!(run.len, *result_len);
@@ -600,10 +705,75 @@ mod tests {
}
#[test]
- fn test_update_run_after_truncation() {
+ fn test_truncate_multiple_runs_start() {
+ let mut wrapper = build_wrapper();
+
+ #[track_caller]
+ fn perform_test(
+ wrapper: &mut LineWrapper,
+ text: &'static str,
+ expected: &str,
+ run_lens: &[usize],
+ result_run_len: &[usize],
+ line_width: Pixels,
+ ) {
+ let dummy_runs = generate_test_runs(run_lens);
+ let (result, dummy_runs) = wrapper.truncate_line(
+ text.into(),
+ line_width,
+ "…",
+ &dummy_runs,
+ TruncateFrom::Start,
+ );
+ assert_eq!(result, expected);
+ for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
+ assert_eq!(run.len, *result_len);
+ }
+ }
+ // Case 0: Normal
+ // Text: abcdefghijkl
+ // Runs: Run0 { len: 12, ... }
+ //
+ // Truncate res: …ijkl (truncate_at = 9)
+ // Run res: Run0 { string: …ijkl, len: 7, ... }
+ perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
+ // Case 1: Drop some runs
+ // Text: abcdefghijkl
+ // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+ //
+ // Truncate res: …ghijkl (truncate_at = 7)
+ // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len:
+ // 4, ... }
+ perform_test(
+ &mut wrapper,
+ "abcdefghijkl",
+ "…ghijkl",
+ &[4, 4, 4],
+ &[5, 4],
+ px(70.),
+ );
+ // Case 2: Truncate at start of some run
+ // Text: abcdefghijkl
+ // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+ //
+ // Truncate res: abcdefgh… (truncate_at = 3)
+ // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len:
+ // 4, ... }, Run2 { string: ijkl, len: 4, ... }
+ perform_test(
+ &mut wrapper,
+ "abcdefghijkl",
+ "…efghijkl",
+ &[4, 4, 4],
+ &[3, 4, 4],
+ px(90.),
+ );
+ }
+
+ #[test]
+ fn test_update_run_after_truncation_end() {
fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
let mut dummy_runs = generate_test_runs(run_lens);
- update_runs_after_truncation(result, "…", &mut dummy_runs);
+ update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End);
for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
assert_eq!(run.len, *result_len);
}
diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs
index 49e2de94a1f86196c10e41879797b02070517e65..d0f50c00336eb971621e2da7bbaf53cf09569caa 100644
--- a/crates/ui/src/components/label/label.rs
+++ b/crates/ui/src/components/label/label.rs
@@ -56,6 +56,12 @@ impl Label {
pub fn set_text(&mut self, text: impl Into) {
self.label = text.into();
}
+
+ /// Truncates the label from the start, keeping the end visible.
+ pub fn truncate_start(mut self) -> Self {
+ self.base = self.base.truncate_start();
+ self
+ }
}
// Style methods.
@@ -256,7 +262,8 @@ impl Component for Label {
"Special Cases",
vec![
single_example("Single Line", Label::new("Line 1\nLine 2\nLine 3").single_line().into_any_element()),
- single_example("Text Ellipsis", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()),
+ single_example("Regular Truncation", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()),
+ single_example("Start Truncation", div().max_w_24().child(Label::new("zed/crates/ui/src/components/label/truncate/label/label.rs").truncate_start()).into_any_element()),
],
),
])
diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs
index 31fb7bfd88f1343ac6145c86f228bdcbd6a22e10..10d54845dabf371b8da6fed5ebbcd2b8d82ea711 100644
--- a/crates/ui/src/components/label/label_like.rs
+++ b/crates/ui/src/components/label/label_like.rs
@@ -88,6 +88,7 @@ pub struct LabelLike {
underline: bool,
single_line: bool,
truncate: bool,
+ truncate_start: bool,
}
impl Default for LabelLike {
@@ -113,6 +114,7 @@ impl LabelLike {
underline: false,
single_line: false,
truncate: false,
+ truncate_start: false,
}
}
}
@@ -126,6 +128,12 @@ impl LabelLike {
gpui::margin_style_methods!({
visibility: pub
});
+
+ /// Truncates overflowing text with an ellipsis (`…`) at the start if needed.
+ pub fn truncate_start(mut self) -> Self {
+ self.truncate_start = true;
+ self
+ }
}
impl LabelCommon for LabelLike {
@@ -169,7 +177,7 @@ impl LabelCommon for LabelLike {
self
}
- /// Truncates overflowing text with an ellipsis (`…`) if needed.
+ /// Truncates overflowing text with an ellipsis (`…`) at the end if needed.
fn truncate(mut self) -> Self {
self.truncate = true;
self
@@ -235,6 +243,9 @@ impl RenderOnce for LabelLike {
.when(self.truncate, |this| {
this.overflow_x_hidden().text_ellipsis()
})
+ .when(self.truncate_start, |this| {
+ this.overflow_x_hidden().text_ellipsis_start()
+ })
.text_color(color)
.font_weight(
self.weight