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;
    ```
<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](6b41c2772f)
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.
This commit is contained in:
Jason Lee 2025-01-30 04:14:24 +08:00 committed by GitHub
parent baac01cea4
commit 706f7be5e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 118 additions and 66 deletions

View file

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

View file

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

View file

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

View file

@ -287,13 +287,10 @@ pub enum WhiteSpace {
} }
/// How to truncate text that overflows the width of the element /// How to truncate text that overflows the width of the element
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Truncate { pub enum TextOverflow {
/// Truncate the text without an ellipsis /// Truncate the text with an ellipsis, same as: `text-overflow: ellipsis;` in CSS
#[default] Ellipsis(&'static str),
Truncate,
/// Truncate the text with an ellipsis
Ellipsis,
} }
/// The properties that can be used to style text in GPUI /// The properties that can be used to style text in GPUI
@ -337,7 +334,9 @@ pub struct TextStyle {
pub white_space: WhiteSpace, pub white_space: WhiteSpace,
/// The text should be truncated if it overflows the width of the element /// 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 { impl Default for TextStyle {
@ -362,7 +361,8 @@ impl Default for TextStyle {
underline: None, underline: None,
strikethrough: None, strikethrough: None,
white_space: WhiteSpace::Normal, white_space: WhiteSpace::Normal,
truncate: None, text_overflow: None,
line_clamp: None,
} }
} }
} }

View file

@ -1,9 +1,9 @@
use crate::TextStyleRefinement;
use crate::{ use crate::{
self as gpui, px, relative, rems, AbsoluteLength, AlignItems, CursorStyle, DefiniteLength, self as gpui, px, relative, rems, AbsoluteLength, AlignItems, CursorStyle, DefiniteLength,
Fill, FlexDirection, FlexWrap, Font, FontStyle, FontWeight, Hsla, JustifyContent, Length, 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::{ pub use gpui_macros::{
border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods, border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods,
overflow_style_methods, padding_style_methods, position_style_methods, overflow_style_methods, padding_style_methods, position_style_methods,
@ -11,6 +11,8 @@ pub use gpui_macros::{
}; };
use taffy::style::{AlignContent, Display}; use taffy::style::{AlignContent, Display};
const ELLIPSIS: &str = "";
/// A trait for elements that can be styled. /// A trait for elements that can be styled.
/// Use this to opt-in to a utility CSS-like styling API. /// Use this to opt-in to a utility CSS-like styling API.
pub trait Styled: Sized { pub trait Styled: Sized {
@ -64,19 +66,32 @@ pub trait Styled: Sized {
fn text_ellipsis(mut self) -> Self { fn text_ellipsis(mut self) -> Self {
self.text_style() self.text_style()
.get_or_insert_with(Default::default) .get_or_insert_with(Default::default)
.truncate = Some(Truncate::Ellipsis); .text_overflow = Some(TextOverflow::Ellipsis(ELLIPSIS));
self self
} }
/// Sets the truncate overflowing text. /// Sets the text overflow behavior of the element.
/// [Docs](https://tailwindcss.com/docs/text-overflow#truncate) fn text_overflow(mut self, overflow: TextOverflow) -> Self {
fn truncate(mut self) -> Self {
self.text_style() self.text_style()
.get_or_insert_with(Default::default) .get_or_insert_with(Default::default)
.truncate = Some(Truncate::Truncate); .text_overflow = Some(overflow);
self 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`. /// Sets the flex direction of the element to `column`.
/// [Docs](https://tailwindcss.com/docs/flex-direction#column) /// [Docs](https://tailwindcss.com/docs/flex-direction#column)
fn flex_col(mut self) -> Self { fn flex_col(mut self) -> Self {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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