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"
/>

## 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:
parent
baac01cea4
commit
706f7be5e7
14 changed files with 118 additions and 66 deletions
|
@ -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()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.truncate()
|
||||
.border_1()
|
||||
.border_color(gpui::red())
|
||||
.border_color(gpui::blue())
|
||||
.child("ELLIPSIS: ".to_owned() + text),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xl()
|
||||
.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_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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1677,6 +1677,7 @@ impl Interactivity {
|
|||
FONT_SIZE,
|
||||
&[window.text_style().to_run(str_len)],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.ok()
|
||||
.and_then(|mut text| text.pop())
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -466,7 +466,7 @@ impl ConfigurationView {
|
|||
underline: None,
|
||||
strikethrough: None,
|
||||
white_space: WhiteSpace::Normal,
|
||||
truncate: None,
|
||||
..Default::default()
|
||||
};
|
||||
EditorElement::new(
|
||||
&self.api_key_editor,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue