gpui: Add `truncate` and `text_ellipsis` to TextStyle (#14850)

Jason Lee and Marshall Bowers created

Release Notes:

- N/A

Ref issue #4996

## Demo

```
cargo run -p gpui --example text_wrapper 
```



https://github.com/user-attachments/assets/a7fcebf7-f287-4517-960d-76b12722a2d7

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

crates/gpui/Cargo.toml                          |   4 
crates/gpui/examples/text_wrapper.rs            |  59 ++++++
crates/gpui/src/elements/text.rs                |  32 ++
crates/gpui/src/style.rs                        |  14 +
crates/gpui/src/styled.rs                       |  20 ++
crates/gpui/src/text_system/line_wrapper.rs     | 170 ++++++++++++------
crates/language_model/src/provider/anthropic.rs |   1 
crates/language_model/src/provider/google.rs    |   1 
crates/language_model/src/provider/open_ai.rs   |   1 
crates/repl/src/stdio.rs                        |   1 
crates/terminal_view/src/terminal_element.rs    |   1 
11 files changed, 238 insertions(+), 66 deletions(-)

Detailed changes

crates/gpui/Cargo.toml πŸ”—

@@ -178,3 +178,7 @@ path = "examples/input.rs"
 [[example]]
 name = "shadow"
 path = "examples/shadow.rs"
+
+[[example]]
+name = "text_wrapper"
+path = "examples/text_wrapper.rs"

crates/gpui/examples/text_wrapper.rs πŸ”—

@@ -0,0 +1,59 @@
+use gpui::*;
+
+struct HelloWorld {}
+
+impl Render for HelloWorld {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let text = "The longest word in any of the major English language δ»₯εŠδΈ­ζ–‡ηš„ζ΅‹θ―• dictionaries is pneumonoultramicroscopicsilicovolcanoconiosis, a word that refers to a lung disease contracted from the inhalation of very fine silica particles, specifically from a volcano; medically, it is the same as silicosis.";
+        div()
+            .id("page")
+            .size_full()
+            .flex()
+            .flex_col()
+            .p_2()
+            .gap_2()
+            .bg(gpui::white())
+            .child(
+                div()
+                    .text_xl()
+                    .overflow_hidden()
+                    .text_ellipsis()
+                    .border_1()
+                    .border_color(gpui::red())
+                    .child("ELLIPSIS: ".to_owned() + text),
+            )
+            .child(
+                div()
+                    .text_xl()
+                    .overflow_hidden()
+                    .truncate()
+                    .border_1()
+                    .border_color(gpui::green())
+                    .child("TRUNCATE: ".to_owned() + text),
+            )
+            .child(
+                div()
+                    .text_xl()
+                    .whitespace_nowrap()
+                    .overflow_hidden()
+                    .border_1()
+                    .border_color(gpui::blue())
+                    .child("NOWRAP: ".to_owned() + text),
+            )
+            .child(div().text_xl().w_full().child(text))
+    }
+}
+
+fn main() {
+    App::new().run(|cx: &mut AppContext| {
+        let bounds = Bounds::centered(None, size(px(600.0), px(480.0)), cx);
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |cx| cx.new_view(|_cx| HelloWorld {}),
+        )
+        .unwrap();
+    });
+}

crates/gpui/src/elements/text.rs πŸ”—

@@ -1,8 +1,8 @@
 use crate::{
     ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementId, GlobalElementId,
     HighlightStyle, Hitbox, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
-    Pixels, Point, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine,
-    TOOLTIP_DELAY,
+    Pixels, Point, SharedString, Size, TextRun, TextStyle, Truncate, WhiteSpace, WindowContext,
+    WrappedLine, TOOLTIP_DELAY,
 };
 use anyhow::anyhow;
 use parking_lot::{Mutex, MutexGuard};
@@ -244,6 +244,8 @@ struct TextLayoutInner {
     bounds: Option<Bounds<Pixels>>,
 }
 
+const ELLIPSIS: &str = "…";
+
 impl TextLayout {
     fn lock(&self) -> MutexGuard<Option<TextLayoutInner>> {
         self.0.lock()
@@ -280,6 +282,20 @@ impl TextLayout {
                     None
                 };
 
+                let (truncate_width, ellipsis) = if let Some(truncate) = text_style.truncate {
+                    let width = known_dimensions.width.or(match available_space.width {
+                        crate::AvailableSpace::Definite(x) => Some(x),
+                        _ => None,
+                    });
+
+                    match truncate {
+                        Truncate::Truncate => (width, None),
+                        Truncate::Ellipsis => (width, Some(ELLIPSIS)),
+                    }
+                } else {
+                    (None, None)
+                };
+
                 if let Some(text_layout) = element_state.0.lock().as_ref() {
                     if text_layout.size.is_some()
                         && (wrap_width.is_none() || wrap_width == text_layout.wrap_width)
@@ -288,13 +304,17 @@ impl TextLayout {
                     }
                 }
 
+                let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
+                let text = if let Some(truncate_width) = truncate_width {
+                    line_wrapper.truncate_line(text.clone(), truncate_width, ellipsis)
+                } else {
+                    text.clone()
+                };
+
                 let Some(lines) = cx
                     .text_system()
                     .shape_text(
-                        text.clone(),
-                        font_size,
-                        &runs,
-                        wrap_width, // Wrap if we know the width.
+                        text, font_size, &runs, wrap_width, // Wrap if we know the width.
                     )
                     .log_err()
                 else {

crates/gpui/src/style.rs πŸ”—

@@ -282,6 +282,16 @@ pub enum WhiteSpace {
     Nowrap,
 }
 
+/// How to truncate text that overflows the width of the element
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+pub enum Truncate {
+    /// Truncate the text without an ellipsis
+    #[default]
+    Truncate,
+    /// Truncate the text with an ellipsis
+    Ellipsis,
+}
+
 /// The properties that can be used to style text in GPUI
 #[derive(Refineable, Clone, Debug, PartialEq)]
 #[refineable(Debug)]
@@ -321,6 +331,9 @@ pub struct TextStyle {
 
     /// How to handle whitespace in the text
     pub white_space: WhiteSpace,
+
+    /// The text should be truncated if it overflows the width of the element
+    pub truncate: Option<Truncate>,
 }
 
 impl Default for TextStyle {
@@ -345,6 +358,7 @@ impl Default for TextStyle {
             underline: None,
             strikethrough: None,
             white_space: WhiteSpace::Normal,
+            truncate: None,
         }
     }
 }

crates/gpui/src/styled.rs πŸ”—

@@ -1,9 +1,9 @@
-use crate::TextStyleRefinement;
 use crate::{
     self as gpui, px, relative, rems, AbsoluteLength, AlignItems, CursorStyle, DefiniteLength,
     Fill, FlexDirection, FlexWrap, Font, FontStyle, FontWeight, Hsla, JustifyContent, Length,
     SharedString, StyleRefinement, WhiteSpace,
 };
+use crate::{TextStyleRefinement, Truncate};
 pub use gpui_macros::{
     border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods,
     overflow_style_methods, padding_style_methods, position_style_methods,
@@ -59,6 +59,24 @@ pub trait Styled: Sized {
         self
     }
 
+    /// Sets the truncate overflowing text with an ellipsis (…) if needed.
+    /// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis)
+    fn text_ellipsis(mut self) -> Self {
+        self.text_style()
+            .get_or_insert_with(Default::default)
+            .truncate = Some(Truncate::Ellipsis);
+        self
+    }
+
+    /// Sets the truncate overflowing text.
+    /// [Docs](https://tailwindcss.com/docs/text-overflow#truncate)
+    fn truncate(mut self) -> Self {
+        self.text_style()
+            .get_or_insert_with(Default::default)
+            .truncate = Some(Truncate::Truncate);
+        self
+    }
+
     /// Sets the flex direction of the element to `column`.
     /// [Docs](https://tailwindcss.com/docs/flex-direction#column)
     fn flex_col(mut self) -> Self {

crates/gpui/src/text_system/line_wrapper.rs πŸ”—

@@ -1,4 +1,4 @@
-use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem};
+use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem, SharedString};
 use collections::HashMap;
 use std::{iter, sync::Arc};
 
@@ -98,6 +98,32 @@ impl LineWrapper {
         })
     }
 
+    /// Truncate a line of text to the given width with this wrapper's font and font size.
+    pub fn truncate_line(
+        &mut self,
+        line: SharedString,
+        truncate_width: Pixels,
+        ellipsis: Option<&str>,
+    ) -> SharedString {
+        let mut width = px(0.);
+        if let Some(ellipsis) = ellipsis {
+            for c in ellipsis.chars() {
+                width += self.width_for_char(c);
+            }
+        }
+
+        let mut char_indices = line.char_indices();
+        for (ix, c) in char_indices {
+            let char_width = self.width_for_char(c);
+            width += char_width;
+            if width > truncate_width {
+                return SharedString::from(format!("{}{}", &line[..ix], ellipsis.unwrap_or("")));
+            }
+        }
+
+        line.clone()
+    }
+
     pub(crate) fn is_word_char(c: char) -> bool {
         // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
         c.is_ascii_alphanumeric() ||
@@ -181,8 +207,7 @@ mod tests {
     use crate::{TextRun, WindowTextSystem, WrapBoundary};
     use rand::prelude::*;
 
-    #[test]
-    fn test_wrap_line() {
+    fn build_wrapper() -> LineWrapper {
         let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
         let cx = TestAppContext::new(dispatcher, None);
         cx.text_system()
@@ -193,63 +218,90 @@ mod tests {
             .into()])
             .unwrap();
         let id = cx.text_system().font_id(&font("Zed Plex Mono")).unwrap();
+        LineWrapper::new(id, px(16.), cx.text_system().platform_text_system.clone())
+    }
 
-        cx.update(|cx| {
-            let text_system = cx.text_system().clone();
-            let mut wrapper =
-                LineWrapper::new(id, px(16.), text_system.platform_text_system.clone());
-            assert_eq!(
-                wrapper
-                    .wrap_line("aa bbb cccc ddddd eeee", px(72.))
-                    .collect::<Vec<_>>(),
-                &[
-                    Boundary::new(7, 0),
-                    Boundary::new(12, 0),
-                    Boundary::new(18, 0)
-                ],
-            );
-            assert_eq!(
-                wrapper
-                    .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0))
-                    .collect::<Vec<_>>(),
-                &[
-                    Boundary::new(4, 0),
-                    Boundary::new(11, 0),
-                    Boundary::new(18, 0)
-                ],
-            );
-            assert_eq!(
-                wrapper
-                    .wrap_line("     aaaaaaa", px(72.))
-                    .collect::<Vec<_>>(),
-                &[
-                    Boundary::new(7, 5),
-                    Boundary::new(9, 5),
-                    Boundary::new(11, 5),
-                ]
-            );
-            assert_eq!(
-                wrapper
-                    .wrap_line("                            ", px(72.))
-                    .collect::<Vec<_>>(),
-                &[
-                    Boundary::new(7, 0),
-                    Boundary::new(14, 0),
-                    Boundary::new(21, 0)
-                ]
-            );
-            assert_eq!(
-                wrapper
-                    .wrap_line("          aaaaaaaaaaaaaa", px(72.))
-                    .collect::<Vec<_>>(),
-                &[
-                    Boundary::new(7, 0),
-                    Boundary::new(14, 3),
-                    Boundary::new(18, 3),
-                    Boundary::new(22, 3),
-                ]
-            );
-        });
+    #[test]
+    fn test_wrap_line() {
+        let mut wrapper = build_wrapper();
+
+        assert_eq!(
+            wrapper
+                .wrap_line("aa bbb cccc ddddd eeee", px(72.))
+                .collect::<Vec<_>>(),
+            &[
+                Boundary::new(7, 0),
+                Boundary::new(12, 0),
+                Boundary::new(18, 0)
+            ],
+        );
+        assert_eq!(
+            wrapper
+                .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0))
+                .collect::<Vec<_>>(),
+            &[
+                Boundary::new(4, 0),
+                Boundary::new(11, 0),
+                Boundary::new(18, 0)
+            ],
+        );
+        assert_eq!(
+            wrapper
+                .wrap_line("     aaaaaaa", px(72.))
+                .collect::<Vec<_>>(),
+            &[
+                Boundary::new(7, 5),
+                Boundary::new(9, 5),
+                Boundary::new(11, 5),
+            ]
+        );
+        assert_eq!(
+            wrapper
+                .wrap_line("                            ", px(72.))
+                .collect::<Vec<_>>(),
+            &[
+                Boundary::new(7, 0),
+                Boundary::new(14, 0),
+                Boundary::new(21, 0)
+            ]
+        );
+        assert_eq!(
+            wrapper
+                .wrap_line("          aaaaaaaaaaaaaa", px(72.))
+                .collect::<Vec<_>>(),
+            &[
+                Boundary::new(7, 0),
+                Boundary::new(14, 3),
+                Boundary::new(18, 3),
+                Boundary::new(22, 3),
+            ]
+        );
+    }
+
+    #[test]
+    fn test_truncate_line() {
+        let mut wrapper = build_wrapper();
+
+        assert_eq!(
+            wrapper.truncate_line("aa bbb cccc ddddd eeee ffff gggg".into(), px(220.), None),
+            "aa bbb cccc ddddd eeee"
+        );
+        assert_eq!(
+            wrapper.truncate_line(
+                "aa bbb cccc ddddd eeee ffff gggg".into(),
+                px(220.),
+                Some("…")
+            ),
+            "aa bbb cccc ddddd eee…"
+        );
+        assert_eq!(
+            wrapper.truncate_line(
+                "aa bbb cccc ddddd eeee ffff gggg".into(),
+                px(220.),
+                Some("......")
+            ),
+            "aa bbb cccc dddd......"
+        );
     }
 
     #[test]

crates/repl/src/stdio.rs πŸ”—

@@ -45,6 +45,7 @@ pub fn text_style(cx: &mut WindowContext) -> TextStyle {
         line_height: cx.line_height().into(),
         background_color: Some(theme.colors().terminal_background),
         white_space: WhiteSpace::Normal,
+        truncate: None,
         // These are going to be overridden per-cell
         underline: None,
         strikethrough: None,

crates/terminal_view/src/terminal_element.rs πŸ”—

@@ -667,6 +667,7 @@ impl Element for TerminalElement {
                     line_height: line_height.into(),
                     background_color: Some(theme.colors().terminal_background),
                     white_space: WhiteSpace::Normal,
+                    truncate: None,
                     // These are going to be overridden per-cell
                     underline: None,
                     strikethrough: None,