Add `truncate_start` method

Danilo Leal created

Change summary

crates/gpui/src/elements/text.rs             |  28 +++-
crates/gpui/src/style.rs                     |   8 +
crates/gpui/src/styled.rs                    |  10 +
crates/gpui/src/text_system/line_wrapper.rs  | 106 ++++++++++++++++++++++
crates/ui/src/components/label/label.rs      |   9 +
crates/ui/src/components/label/label_like.rs |  13 ++
6 files changed, 160 insertions(+), 14 deletions(-)

Detailed changes

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))
                 };

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

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);

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<Tex
     }
 }
 
+fn update_runs_after_start_truncation(
+    result: &str,
+    prefix: &str,
+    bytes_removed: usize,
+    runs: &mut Vec<TextRun>,
+) {
+    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.

crates/ui/src/components/label/label.rs πŸ”—

@@ -56,6 +56,12 @@ impl Label {
     pub fn set_text(&mut self, text: impl Into<SharedString>) {
         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()),
                         ],
                     ),
                 ])

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