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

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>
This commit is contained in:
Jason Lee 2024-08-24 02:02:51 +08:00 committed by GitHub
parent 12dda5fa1b
commit 938d93a64c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 238 additions and 66 deletions

View file

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

View file

@ -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();
});
}

View file

@ -1,8 +1,8 @@
use crate::{ use crate::{
ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementId, GlobalElementId, ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementId, GlobalElementId,
HighlightStyle, Hitbox, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, HighlightStyle, Hitbox, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
Pixels, Point, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine, Pixels, Point, SharedString, Size, TextRun, TextStyle, Truncate, WhiteSpace, WindowContext,
TOOLTIP_DELAY, WrappedLine, TOOLTIP_DELAY,
}; };
use anyhow::anyhow; use anyhow::anyhow;
use parking_lot::{Mutex, MutexGuard}; use parking_lot::{Mutex, MutexGuard};
@ -244,6 +244,8 @@ 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()
@ -280,6 +282,20 @@ impl TextLayout {
None 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 let Some(text_layout) = element_state.0.lock().as_ref() {
if text_layout.size.is_some() if text_layout.size.is_some()
&& (wrap_width.is_none() || wrap_width == text_layout.wrap_width) && (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 let Some(lines) = cx
.text_system() .text_system()
.shape_text( .shape_text(
text.clone(), text, font_size, &runs, wrap_width, // Wrap if we know the width.
font_size,
&runs,
wrap_width, // Wrap if we know the width.
) )
.log_err() .log_err()
else { else {

View file

@ -282,6 +282,16 @@ pub enum WhiteSpace {
Nowrap, 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 /// The properties that can be used to style text in GPUI
#[derive(Refineable, Clone, Debug, PartialEq)] #[derive(Refineable, Clone, Debug, PartialEq)]
#[refineable(Debug)] #[refineable(Debug)]
@ -321,6 +331,9 @@ pub struct TextStyle {
/// How to handle whitespace in the text /// How to handle whitespace in the text
pub white_space: WhiteSpace, 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 { impl Default for TextStyle {
@ -345,6 +358,7 @@ impl Default for TextStyle {
underline: None, underline: None,
strikethrough: None, strikethrough: None,
white_space: WhiteSpace::Normal, white_space: WhiteSpace::Normal,
truncate: 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, StyleRefinement, WhiteSpace, SharedString, StyleRefinement, 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,
@ -59,6 +59,24 @@ pub trait Styled: Sized {
self 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`. /// 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

@ -1,4 +1,4 @@
use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem}; use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem, SharedString};
use collections::HashMap; use collections::HashMap;
use std::{iter, sync::Arc}; 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 { pub(crate) fn is_word_char(c: char) -> bool {
// ASCII alphanumeric characters, for English, numbers: `Hello123`, etc. // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc.
c.is_ascii_alphanumeric() || c.is_ascii_alphanumeric() ||
@ -181,8 +207,7 @@ mod tests {
use crate::{TextRun, WindowTextSystem, WrapBoundary}; use crate::{TextRun, WindowTextSystem, WrapBoundary};
use rand::prelude::*; use rand::prelude::*;
#[test] fn build_wrapper() -> LineWrapper {
fn test_wrap_line() {
let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0));
let cx = TestAppContext::new(dispatcher, None); let cx = TestAppContext::new(dispatcher, None);
cx.text_system() cx.text_system()
@ -193,63 +218,90 @@ mod tests {
.into()]) .into()])
.unwrap(); .unwrap();
let id = cx.text_system().font_id(&font("Zed Plex Mono")).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| { #[test]
let text_system = cx.text_system().clone(); fn test_wrap_line() {
let mut wrapper = let mut wrapper = build_wrapper();
LineWrapper::new(id, px(16.), text_system.platform_text_system.clone());
assert_eq!( assert_eq!(
wrapper wrapper
.wrap_line("aa bbb cccc ddddd eeee", px(72.)) .wrap_line("aa bbb cccc ddddd eeee", px(72.))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
&[ &[
Boundary::new(7, 0), Boundary::new(7, 0),
Boundary::new(12, 0), Boundary::new(12, 0),
Boundary::new(18, 0) Boundary::new(18, 0)
], ],
); );
assert_eq!( assert_eq!(
wrapper wrapper
.wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0)) .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
&[ &[
Boundary::new(4, 0), Boundary::new(4, 0),
Boundary::new(11, 0), Boundary::new(11, 0),
Boundary::new(18, 0) Boundary::new(18, 0)
], ],
); );
assert_eq!( assert_eq!(
wrapper wrapper
.wrap_line(" aaaaaaa", px(72.)) .wrap_line(" aaaaaaa", px(72.))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
&[ &[
Boundary::new(7, 5), Boundary::new(7, 5),
Boundary::new(9, 5), Boundary::new(9, 5),
Boundary::new(11, 5), Boundary::new(11, 5),
] ]
); );
assert_eq!( assert_eq!(
wrapper wrapper
.wrap_line(" ", px(72.)) .wrap_line(" ", px(72.))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
&[ &[
Boundary::new(7, 0), Boundary::new(7, 0),
Boundary::new(14, 0), Boundary::new(14, 0),
Boundary::new(21, 0) Boundary::new(21, 0)
] ]
); );
assert_eq!( assert_eq!(
wrapper wrapper
.wrap_line(" aaaaaaaaaaaaaa", px(72.)) .wrap_line(" aaaaaaaaaaaaaa", px(72.))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
&[ &[
Boundary::new(7, 0), Boundary::new(7, 0),
Boundary::new(14, 3), Boundary::new(14, 3),
Boundary::new(18, 3), Boundary::new(18, 3),
Boundary::new(22, 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] #[test]

View file

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

View file

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

View file

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

View file

@ -45,6 +45,7 @@ pub fn text_style(cx: &mut WindowContext) -> TextStyle {
line_height: cx.line_height().into(), line_height: cx.line_height().into(),
background_color: Some(theme.colors().terminal_background), background_color: Some(theme.colors().terminal_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, underline: None,
strikethrough: None, strikethrough: None,

View file

@ -667,6 +667,7 @@ impl Element for TerminalElement {
line_height: line_height.into(), line_height: line_height.into(),
background_color: Some(theme.colors().terminal_background), background_color: Some(theme.colors().terminal_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, underline: None,
strikethrough: None, strikethrough: None,