diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 1b1bfd778c7bc746c67551eb31cf70f60b1485ea..e4b4cea46740cbdb1fb899c949f9f47481dd68a8 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -354,7 +354,7 @@ impl TextLayout { None }; - let (truncate_width, truncation_suffix) = + let (truncate_width, truncation_suffix, truncate_start) = 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, false), + TextOverflow::TruncateStart(s) => (width, s, true), } } else { - (None, "".into()) + (None, "".into(), false) }; if let Some(text_layout) = element_state.0.borrow().as_ref() @@ -380,12 +381,21 @@ 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_suffix, - &runs, - ) + if truncate_start { + line_wrapper.truncate_line_start( + text.clone(), + truncate_width, + &truncation_suffix, + &runs, + ) + } else { + line_wrapper.truncate_line( + text.clone(), + truncate_width, + &truncation_suffix, + &runs, + ) + } } 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 45159313b43c508029f2525234c80c6575d0f695..38aa2841783b61762203ddd9e6d99fe1d734aa63 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -129,6 +129,7 @@ impl LineWrapper { } /// Truncate a line of text to the given width with this wrapper's font and font size. + /// Truncates from the end, e.g., "very long te…" pub fn truncate_line<'a>( &mut self, line: SharedString, @@ -164,6 +165,65 @@ impl LineWrapper { (line, Cow::Borrowed(runs)) } + /// Truncate a line of text from the start to the given width. + /// Truncates from the beginning, e.g., "…ong text here" + pub fn truncate_line_start<'a>( + &mut self, + line: SharedString, + truncate_width: Pixels, + truncation_prefix: &str, + runs: &'a [TextRun], + ) -> (SharedString, Cow<'a, [TextRun]>) { + // First, measure the full line width to see if truncation is needed + let full_width: Pixels = line.chars().map(|c| self.width_for_char(c)).sum(); + + if full_width <= truncate_width { + return (line, Cow::Borrowed(runs)); + } + + let prefix_width: Pixels = truncation_prefix + .chars() + .map(|c| self.width_for_char(c)) + .sum(); + + let available_width = truncate_width - prefix_width; + + if available_width <= px(0.) { + return ( + SharedString::from(truncation_prefix.to_string()), + Cow::Owned(vec![]), + ); + } + + // Work backwards from the end to find where to start the visible text + let char_indices: Vec<(usize, char)> = line.char_indices().collect(); + let mut width_from_end = px(0.); + let mut start_byte_index = line.len(); + + for (byte_index, c) in char_indices.iter().rev() { + let char_width = self.width_for_char(*c); + if width_from_end + char_width > available_width { + break; + } + width_from_end += char_width; + start_byte_index = *byte_index; + } + + if start_byte_index == 0 { + return (line, Cow::Borrowed(runs)); + } + + let result = SharedString::from(format!( + "{}{}", + truncation_prefix, + &line[start_byte_index..] + )); + let mut runs = runs.to_vec(); + update_runs_after_start_truncation(&result, truncation_prefix, start_byte_index, &mut runs); + + (result, Cow::Owned(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 { @@ -238,6 +298,52 @@ fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec, +) { + let prefix_len = prefix.len(); + + let mut bytes_to_skip = bytes_removed; + let mut first_relevant_run = 0; + + for (index, run) in runs.iter().enumerate() { + if bytes_to_skip >= run.len { + bytes_to_skip -= run.len; + first_relevant_run = index + 1; + } else { + break; + } + } + + if first_relevant_run > 0 { + runs.drain(0..first_relevant_run); + } + + if !runs.is_empty() && bytes_to_skip > 0 { + runs[0].len -= bytes_to_skip; + } + + if !runs.is_empty() { + runs[0].len += prefix_len; + } else { + runs.push(TextRun { + len: result.len(), + ..Default::default() + }); + } + + let total_run_len: usize = runs.iter().map(|r| r.len).sum(); + if total_run_len != result.len() && !runs.is_empty() { + let diff = result.len() as isize - total_run_len as isize; + if let Some(last) = runs.last_mut() { + last.len = (last.len as isize + diff) as usize; + } + } +} + /// A fragment of a line that can be wrapped. pub enum LineFragment<'a> { /// A text fragment consisting of characters. 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