From 706f7be5e79a44fc767dfbeadce1f390e7f43d9f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 30 Jan 2025 04:14:24 +0800 Subject: [PATCH] gpui: Add `line_clamp` to truncate text after a specified number of lines (#23058) 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; ``` image ## Show case image ![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. --- 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 +- .../language_models/src/provider/anthropic.rs | 5 +-- .../language_models/src/provider/deepseek.rs | 2 +- crates/language_models/src/provider/google.rs | 5 +-- .../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(-) diff --git a/crates/gpui/examples/text_wrapper.rs b/crates/gpui/examples/text_wrapper.rs index 6563c870f7bcf1..6753fc4ae99051 100644 --- a/crates/gpui/examples/text_wrapper.rs +++ b/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); }); } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 0eb2c78ce9a1c2..b40bf2182e2868 100644 --- a/crates/gpui/src/elements/div.rs +++ b/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()) diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index dbeaf6da2078ca..b43a82dff6a172 100644 --- a/crates/gpui/src/elements/text.rs +++ b/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>, } -const ELLIPSIS: &str = "…"; - impl TextLayout { fn lock(&self) -> MutexGuard> { 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 { diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 39175b44c50e5c..2f9fa677999e2b 100644 --- a/crates/gpui/src/style.rs +++ b/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, + pub text_overflow: Option, + /// The number of lines to display before truncating the text + pub line_clamp: Option, } 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, } } } diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 6bb785128f6117..b3af2c5055e70d 100644 --- a/crates/gpui/src/styled.rs +++ b/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 { diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index b695aac21cac41..9c3b574442c0ab 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -374,12 +374,15 @@ impl WindowTextSystem { font_size: Pixels, runs: &[TextRun], wrap_width: Option, + line_clamp: Option, ) -> Result> { 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, diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index f2379feded5a03..b483949d731dc2 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -129,9 +129,9 @@ impl LineLayout { &self, text: &str, wrap_width: Pixels, + max_lines: Option, ) -> 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, + max_lines: Option, ) -> Arc where Text: AsRef, @@ -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() }; diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 1b99165eee6c08..08e9a69f61b8e1 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/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(); diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 0a3e01867442e1..7a09d03eecb418 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/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, diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 71c219c5ea0e21..17604b35d80c33 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/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, diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 54a7fb0be20001..791a03b78aec88 100644 --- a/crates/language_models/src/provider/google.rs +++ b/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, diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 846c98b13d03b3..3160caaa7f31d4 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/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, diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index 80bf3a788a9ce8..566833756319a9 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/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 diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index bd4399a2de0377..a8574fbc006a79 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/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();