gpui: Add `line_clamp` to truncate text after a specified number of lines (#23058)

Jason Lee created

Release Notes:

- N/A

Add this feature for some case we need keep 2 or 3 lines, but truncate.
For example the blog post summary.

- Added `line_clamp` method.
    Ref: https://tailwindcss.com/docs/line-clamp


## Break changes:

- Renamed `gpui::Truncate` to `gpui::TextOverflow` to match
[CSS](https://developer.mozilla.org/en-US/docs/Web/CSS/text-overflow).
- Update `truncate` style method to match [Tailwind
CSS](https://tailwindcss.com/docs/text-overflow) behavior:

    ```css
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    ```
<img width="538" alt="image"
src="https://github.com/user-attachments/assets/c69c4213-eac9-4087-9daa-ce7afe18c758"
/>


## Show case

<img width="816" alt="image"
src="https://github.com/user-attachments/assets/e0660290-8042-4954-b93c-c729d609484a"
/>

![CleanShot 2025-01-13 at 17 22
05](https://github.com/user-attachments/assets/38644892-79fe-4254-af9e-88c1349561bd)

## Describe changes

The [second
commit](https://github.com/zed-industries/zed/commit/6b41c2772f9d1c5f4efe69fe83206a14407af4d4)
for make sure text layout to match with the line clamp. Before this
change, they may wrap many lines in sometimes. And I also make
line_clamp default to 1 if we used `truncate` to ensure no wrap.

> TODO: There is still a tiny detail that is not easy to fix. This
problem only occurs in the case of certain long words. I will think
about how to improve it later. At present, this has some flaws but does
not affect the use.

Change summary

crates/gpui/examples/text_wrapper.rs             | 40 ++++++++++++++---
crates/gpui/src/elements/div.rs                  |  1 
crates/gpui/src/elements/text.rs                 | 38 +++++++++-------
crates/gpui/src/style.rs                         | 18 ++++----
crates/gpui/src/styled.rs                        | 29 +++++++++---
crates/gpui/src/text_system.rs                   | 14 ++++-
crates/gpui/src/text_system/line_layout.rs       | 14 ++++-
crates/gpui/src/text_system/line_wrapper.rs      |  3 
crates/language_models/src/provider/anthropic.rs |  5 -
crates/language_models/src/provider/deepseek.rs  |  2 
crates/language_models/src/provider/google.rs    |  5 -
crates/language_models/src/provider/open_ai.rs   |  5 -
crates/repl/src/outputs/plain.rs                 |  4 -
crates/terminal_view/src/terminal_element.rs     |  4 -
14 files changed, 117 insertions(+), 65 deletions(-)

Detailed changes

crates/gpui/examples/text_wrapper.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{
-    div, prelude::*, px, size, App, Application, Bounds, Context, Window, WindowBounds,
-    WindowOptions,
+    div, prelude::*, px, size, App, Application, Bounds, Context, TextOverflow, Window,
+    WindowBounds, WindowOptions,
 };
 
 struct HelloWorld {}
@@ -20,6 +20,7 @@ impl Render for HelloWorld {
                 div()
                     .flex()
                     .flex_row()
+                    .flex_shrink_0()
                     .gap_2()
                     .child(
                         div()
@@ -49,29 +50,53 @@ impl Render for HelloWorld {
             )
             .child(
                 div()
+                    .flex_shrink_0()
+                    .text_xl()
+                    .truncate()
+                    .border_1()
+                    .border_color(gpui::blue())
+                    .child("ELLIPSIS: ".to_owned() + text),
+            )
+            .child(
+                div()
+                    .flex_shrink_0()
                     .text_xl()
                     .overflow_hidden()
                     .text_ellipsis()
+                    .line_clamp(2)
                     .border_1()
-                    .border_color(gpui::red())
-                    .child("ELLIPSIS: ".to_owned() + text),
+                    .border_color(gpui::blue())
+                    .child("ELLIPSIS 2 lines: ".to_owned() + text),
             )
             .child(
                 div()
+                    .flex_shrink_0()
                     .text_xl()
                     .overflow_hidden()
-                    .truncate()
+                    .text_overflow(TextOverflow::Ellipsis(""))
                     .border_1()
                     .border_color(gpui::green())
                     .child("TRUNCATE: ".to_owned() + text),
             )
             .child(
                 div()
+                    .flex_shrink_0()
+                    .text_xl()
+                    .overflow_hidden()
+                    .text_overflow(TextOverflow::Ellipsis(""))
+                    .line_clamp(3)
+                    .border_1()
+                    .border_color(gpui::green())
+                    .child("TRUNCATE 3 lines: ".to_owned() + text),
+            )
+            .child(
+                div()
+                    .flex_shrink_0()
                     .text_xl()
                     .whitespace_nowrap()
                     .overflow_hidden()
                     .border_1()
-                    .border_color(gpui::blue())
+                    .border_color(gpui::black())
                     .child("NOWRAP: ".to_owned() + text),
             )
             .child(div().text_xl().w_full().child(text))
@@ -80,7 +105,7 @@ impl Render for HelloWorld {
 
 fn main() {
     Application::new().run(|cx: &mut App| {
-        let bounds = Bounds::centered(None, size(px(600.0), px(480.0)), cx);
+        let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx);
         cx.open_window(
             WindowOptions {
                 window_bounds: Some(WindowBounds::Windowed(bounds)),
@@ -89,5 +114,6 @@ fn main() {
             |_, cx| cx.new(|_| HelloWorld {}),
         )
         .unwrap();
+        cx.activate(true);
     });
 }

crates/gpui/src/elements/div.rs 🔗

@@ -1677,6 +1677,7 @@ impl Interactivity {
                         FONT_SIZE,
                         &[window.text_style().to_run(str_len)],
                         None,
+                        None,
                     )
                     .ok()
                     .and_then(|mut text| text.pop())

crates/gpui/src/elements/text.rs 🔗

@@ -2,7 +2,8 @@ use crate::{
     register_tooltip_mouse_handlers, set_tooltip_on_window, ActiveTooltip, AnyView, App, Bounds,
     DispatchPhase, Element, ElementId, GlobalElementId, HighlightStyle, Hitbox, IntoElement,
     LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size,
-    TextRun, TextStyle, TooltipId, Truncate, WhiteSpace, Window, WrappedLine, WrappedLineLayout,
+    TextOverflow, TextRun, TextStyle, TooltipId, WhiteSpace, Window, WrappedLine,
+    WrappedLineLayout,
 };
 use anyhow::anyhow;
 use parking_lot::{Mutex, MutexGuard};
@@ -255,8 +256,6 @@ struct TextLayoutInner {
     bounds: Option<Bounds<Pixels>>,
 }
 
-const ELLIPSIS: &str = "…";
-
 impl TextLayout {
     fn lock(&self) -> MutexGuard<Option<TextLayoutInner>> {
         self.0.lock()
@@ -294,19 +293,22 @@ 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,
-                    });
+                let (truncate_width, ellipsis) =
+                    if let Some(text_overflow) = text_style.text_overflow {
+                        let width = known_dimensions.width.or(match available_space.width {
+                            crate::AvailableSpace::Definite(x) => match text_style.line_clamp {
+                                Some(max_lines) => Some(x * max_lines),
+                                None => Some(x),
+                            },
+                            _ => None,
+                        });
 
-                    match truncate {
-                        Truncate::Truncate => (width, None),
-                        Truncate::Ellipsis => (width, Some(ELLIPSIS)),
-                    }
-                } else {
-                    (None, None)
-                };
+                        match text_overflow {
+                            TextOverflow::Ellipsis(s) => (width, Some(s)),
+                        }
+                    } else {
+                        (None, None)
+                    };
 
                 if let Some(text_layout) = element_state.0.lock().as_ref() {
                     if text_layout.size.is_some()
@@ -326,7 +328,11 @@ impl TextLayout {
                 let Some(lines) = window
                     .text_system()
                     .shape_text(
-                        text, font_size, &runs, wrap_width, // Wrap if we know the width.
+                        text,
+                        font_size,
+                        &runs,
+                        wrap_width,            // Wrap if we know the width.
+                        text_style.line_clamp, // Limit the number of lines if line_clamp is set.
                     )
                     .log_err()
                 else {

crates/gpui/src/style.rs 🔗

@@ -287,13 +287,10 @@ pub enum WhiteSpace {
 }
 
 /// 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,
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum TextOverflow {
+    /// Truncate the text with an ellipsis, same as: `text-overflow: ellipsis;` in CSS
+    Ellipsis(&'static str),
 }
 
 /// The properties that can be used to style text in GPUI
@@ -337,7 +334,9 @@ pub struct TextStyle {
     pub white_space: WhiteSpace,
 
     /// The text should be truncated if it overflows the width of the element
-    pub truncate: Option<Truncate>,
+    pub text_overflow: Option<TextOverflow>,
+    /// The number of lines to display before truncating the text
+    pub line_clamp: Option<usize>,
 }
 
 impl Default for TextStyle {
@@ -362,7 +361,8 @@ impl Default for TextStyle {
             underline: None,
             strikethrough: None,
             white_space: WhiteSpace::Normal,
-            truncate: None,
+            text_overflow: None,
+            line_clamp: 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, StrikethroughStyle, StyleRefinement, WhiteSpace,
+    SharedString, StrikethroughStyle, StyleRefinement, TextOverflow, 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,
@@ -11,6 +11,8 @@ pub use gpui_macros::{
 };
 use taffy::style::{AlignContent, Display};
 
+const ELLIPSIS: &str = "…";
+
 /// A trait for elements that can be styled.
 /// Use this to opt-in to a utility CSS-like styling API.
 pub trait Styled: Sized {
@@ -64,19 +66,32 @@ pub trait Styled: Sized {
     fn text_ellipsis(mut self) -> Self {
         self.text_style()
             .get_or_insert_with(Default::default)
-            .truncate = Some(Truncate::Ellipsis);
+            .text_overflow = Some(TextOverflow::Ellipsis(ELLIPSIS));
         self
     }
 
-    /// Sets the truncate overflowing text.
-    /// [Docs](https://tailwindcss.com/docs/text-overflow#truncate)
-    fn truncate(mut self) -> Self {
+    /// Sets the text overflow behavior of the element.
+    fn text_overflow(mut self, overflow: TextOverflow) -> Self {
         self.text_style()
             .get_or_insert_with(Default::default)
-            .truncate = Some(Truncate::Truncate);
+            .text_overflow = Some(overflow);
         self
     }
 
+    /// Sets the truncate to prevent text from wrapping and truncate overflowing text with an ellipsis (…) if needed.
+    /// [Docs](https://tailwindcss.com/docs/text-overflow#truncate)
+    fn truncate(mut self) -> Self {
+        self.overflow_hidden().whitespace_nowrap().text_ellipsis()
+    }
+
+    /// Sets number of lines to show before truncating the text.
+    /// [Docs](https://tailwindcss.com/docs/line-clamp)
+    fn line_clamp(mut self, lines: usize) -> Self {
+        let mut text_style = self.text_style().get_or_insert_with(Default::default);
+        text_style.line_clamp = Some(lines);
+        self.overflow_hidden()
+    }
+
     /// 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.rs 🔗

@@ -374,12 +374,15 @@ impl WindowTextSystem {
         font_size: Pixels,
         runs: &[TextRun],
         wrap_width: Option<Pixels>,
+        line_clamp: Option<usize>,
     ) -> Result<SmallVec<[WrappedLine; 1]>> {
         let mut runs = runs.iter().filter(|run| run.len > 0).cloned().peekable();
         let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default();
 
         let mut lines = SmallVec::new();
         let mut line_start = 0;
+        let mut max_wrap_lines = line_clamp.unwrap_or(usize::MAX);
+        let mut wrapped_lines = 0;
 
         let mut process_line = |line_text: SharedString| {
             let line_end = line_start + line_text.len();
@@ -430,9 +433,14 @@ impl WindowTextSystem {
                 run_start += run_len_within_line;
             }
 
-            let layout = self
-                .line_layout_cache
-                .layout_wrapped_line(&line_text, font_size, &font_runs, wrap_width);
+            let layout = self.line_layout_cache.layout_wrapped_line(
+                &line_text,
+                font_size,
+                &font_runs,
+                wrap_width,
+                Some(max_wrap_lines - wrapped_lines),
+            );
+            wrapped_lines += layout.wrap_boundaries.len();
 
             lines.push(WrappedLine {
                 layout,

crates/gpui/src/text_system/line_layout.rs 🔗

@@ -129,9 +129,9 @@ impl LineLayout {
         &self,
         text: &str,
         wrap_width: Pixels,
+        max_lines: Option<usize>,
     ) -> SmallVec<[WrapBoundary; 1]> {
         let mut boundaries = SmallVec::new();
-
         let mut first_non_whitespace_ix = None;
         let mut last_candidate_ix = None;
         let mut last_candidate_x = px(0.);
@@ -182,7 +182,15 @@ impl LineLayout {
 
             let next_x = glyphs.peek().map_or(self.width, |(_, _, x)| *x);
             let width = next_x - last_boundary_x;
+
             if width > wrap_width && boundary > last_boundary {
+                // When used line_clamp, we should limit the number of lines.
+                if let Some(max_lines) = max_lines {
+                    if boundaries.len() >= max_lines - 1 {
+                        break;
+                    }
+                }
+
                 if let Some(last_candidate_ix) = last_candidate_ix.take() {
                     last_boundary = last_candidate_ix;
                     last_boundary_x = last_candidate_x;
@@ -190,7 +198,6 @@ impl LineLayout {
                     last_boundary = boundary;
                     last_boundary_x = x;
                 }
-
                 boundaries.push(last_boundary);
             }
             prev_ch = ch;
@@ -434,6 +441,7 @@ impl LineLayoutCache {
         font_size: Pixels,
         runs: &[FontRun],
         wrap_width: Option<Pixels>,
+        max_lines: Option<usize>,
     ) -> Arc<WrappedLineLayout>
     where
         Text: AsRef<str>,
@@ -464,7 +472,7 @@ impl LineLayoutCache {
             let text = SharedString::from(text);
             let unwrapped_layout = self.layout_line::<&SharedString>(&text, font_size, runs);
             let wrap_boundaries = if let Some(wrap_width) = wrap_width {
-                unwrapped_layout.compute_wrap_boundaries(text.as_ref(), wrap_width)
+                unwrapped_layout.compute_wrap_boundaries(text.as_ref(), wrap_width, max_lines)
             } else {
                 SmallVec::new()
             };

crates/gpui/src/text_system/line_wrapper.rs 🔗

@@ -117,7 +117,7 @@ impl LineWrapper {
         let mut char_indices = line.char_indices();
         let mut truncate_ix = 0;
         for (ix, c) in char_indices {
-            if width + ellipsis_width <= truncate_width {
+            if width + ellipsis_width < truncate_width {
                 truncate_ix = ix;
             }
 
@@ -564,6 +564,7 @@ mod tests {
                         normal.with_len(7),
                     ],
                     Some(px(72.)),
+                    None,
                 )
                 .unwrap();
 

crates/language_models/src/provider/anthropic.rs 🔗

@@ -647,11 +647,8 @@ impl ConfigurationView {
             font_weight: settings.ui_font.weight,
             font_style: FontStyle::Normal,
             line_height: relative(1.3),
-            background_color: None,
-            underline: None,
-            strikethrough: None,
             white_space: WhiteSpace::Normal,
-            truncate: None,
+            ..Default::default()
         };
         EditorElement::new(
             &self.api_key_editor,

crates/language_models/src/provider/deepseek.rs 🔗

@@ -466,7 +466,7 @@ impl ConfigurationView {
             underline: None,
             strikethrough: None,
             white_space: WhiteSpace::Normal,
-            truncate: None,
+            ..Default::default()
         };
         EditorElement::new(
             &self.api_key_editor,

crates/language_models/src/provider/google.rs 🔗

@@ -409,11 +409,8 @@ impl ConfigurationView {
             font_weight: settings.ui_font.weight,
             font_style: FontStyle::Normal,
             line_height: relative(1.3),
-            background_color: None,
-            underline: None,
-            strikethrough: None,
             white_space: WhiteSpace::Normal,
-            truncate: None,
+            ..Default::default()
         };
         EditorElement::new(
             &self.api_key_editor,

crates/language_models/src/provider/open_ai.rs 🔗

@@ -458,11 +458,8 @@ impl ConfigurationView {
             font_weight: settings.ui_font.weight,
             font_style: FontStyle::Normal,
             line_height: relative(1.3),
-            background_color: None,
-            underline: None,
-            strikethrough: None,
             white_space: WhiteSpace::Normal,
-            truncate: None,
+            ..Default::default()
         };
         EditorElement::new(
             &self.api_key_editor,

crates/repl/src/outputs/plain.rs 🔗

@@ -77,11 +77,9 @@ pub fn text_style(window: &mut Window, cx: &mut App) -> TextStyle {
         line_height: window.line_height().into(),
         background_color: Some(theme.colors().terminal_ansi_background),
         white_space: WhiteSpace::Normal,
-        truncate: None,
         // These are going to be overridden per-cell
-        underline: None,
-        strikethrough: None,
         color: theme.colors().terminal_foreground,
+        ..Default::default()
     };
 
     text_style

crates/terminal_view/src/terminal_element.rs 🔗

@@ -674,11 +674,9 @@ impl Element for TerminalElement {
                     line_height: line_height.into(),
                     background_color: Some(theme.colors().terminal_ansi_background),
                     white_space: WhiteSpace::Normal,
-                    truncate: None,
                     // These are going to be overridden per-cell
-                    underline: None,
-                    strikethrough: None,
                     color: theme.colors().terminal_foreground,
+                    ..Default::default()
                 };
 
                 let text_system = cx.text_system();