From 938d93a64c94bae48b34a7983a34ca7748745fc1 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 24 Aug 2024 02:02:51 +0800 Subject: [PATCH] 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 --- crates/gpui/Cargo.toml | 4 + crates/gpui/examples/text_wrapper.rs | 59 ++++++ crates/gpui/src/elements/text.rs | 32 +++- crates/gpui/src/style.rs | 14 ++ crates/gpui/src/styled.rs | 20 ++- crates/gpui/src/text_system/line_wrapper.rs | 170 ++++++++++++------ .../language_model/src/provider/anthropic.rs | 1 + crates/language_model/src/provider/google.rs | 1 + crates/language_model/src/provider/open_ai.rs | 1 + crates/repl/src/stdio.rs | 1 + crates/terminal_view/src/terminal_element.rs | 1 + 11 files changed, 238 insertions(+), 66 deletions(-) create mode 100644 crates/gpui/examples/text_wrapper.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index eb81dfee3f..6d627dbed6 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -178,3 +178,7 @@ path = "examples/input.rs" [[example]] name = "shadow" path = "examples/shadow.rs" + +[[example]] +name = "text_wrapper" +path = "examples/text_wrapper.rs" diff --git a/crates/gpui/examples/text_wrapper.rs b/crates/gpui/examples/text_wrapper.rs new file mode 100644 index 0000000000..37902f2dd8 --- /dev/null +++ b/crates/gpui/examples/text_wrapper.rs @@ -0,0 +1,59 @@ +use gpui::*; + +struct HelloWorld {} + +impl Render for HelloWorld { + fn render(&mut self, _cx: &mut ViewContext) -> 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(); + }); +} diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index e34033dfe5..881a657274 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -1,8 +1,8 @@ use crate::{ ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementId, GlobalElementId, HighlightStyle, Hitbox, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, Point, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine, - TOOLTIP_DELAY, + Pixels, Point, SharedString, Size, TextRun, TextStyle, Truncate, WhiteSpace, WindowContext, + WrappedLine, TOOLTIP_DELAY, }; use anyhow::anyhow; use parking_lot::{Mutex, MutexGuard}; @@ -244,6 +244,8 @@ struct TextLayoutInner { bounds: Option>, } +const ELLIPSIS: &str = "…"; + impl TextLayout { fn lock(&self) -> MutexGuard> { self.0.lock() @@ -280,6 +282,20 @@ 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, + }); + + 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 text_layout.size.is_some() && (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 .text_system() .shape_text( - text.clone(), - font_size, - &runs, - wrap_width, // Wrap if we know the width. + text, font_size, &runs, wrap_width, // Wrap if we know the width. ) .log_err() else { diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 086be8f58f..77cf6ac52f 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -282,6 +282,16 @@ pub enum WhiteSpace { 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 #[derive(Refineable, Clone, Debug, PartialEq)] #[refineable(Debug)] @@ -321,6 +331,9 @@ pub struct TextStyle { /// How to handle whitespace in the text pub white_space: WhiteSpace, + + /// The text should be truncated if it overflows the width of the element + pub truncate: Option, } impl Default for TextStyle { @@ -345,6 +358,7 @@ impl Default for TextStyle { underline: None, strikethrough: None, white_space: WhiteSpace::Normal, + truncate: None, } } } diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index cc54909a8c..8196cc2598 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, StyleRefinement, 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, @@ -59,6 +59,24 @@ pub trait Styled: Sized { 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`. /// [Docs](https://tailwindcss.com/docs/flex-direction#column) fn flex_col(mut self) -> Self { diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 132ffe362a..229dc3fc02 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -1,4 +1,4 @@ -use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem}; +use crate::{px, FontId, FontRun, Pixels, PlatformTextSystem, SharedString}; use collections::HashMap; 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 { // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc. c.is_ascii_alphanumeric() || @@ -181,8 +207,7 @@ mod tests { use crate::{TextRun, WindowTextSystem, WrapBoundary}; use rand::prelude::*; - #[test] - fn test_wrap_line() { + fn build_wrapper() -> LineWrapper { let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); let cx = TestAppContext::new(dispatcher, None); cx.text_system() @@ -193,63 +218,90 @@ mod tests { .into()]) .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| { - let text_system = cx.text_system().clone(); - let mut wrapper = - LineWrapper::new(id, px(16.), text_system.platform_text_system.clone()); - assert_eq!( - wrapper - .wrap_line("aa bbb cccc ddddd eeee", px(72.)) - .collect::>(), - &[ - Boundary::new(7, 0), - Boundary::new(12, 0), - Boundary::new(18, 0) - ], - ); - assert_eq!( - wrapper - .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0)) - .collect::>(), - &[ - Boundary::new(4, 0), - Boundary::new(11, 0), - Boundary::new(18, 0) - ], - ); - assert_eq!( - wrapper - .wrap_line(" aaaaaaa", px(72.)) - .collect::>(), - &[ - Boundary::new(7, 5), - Boundary::new(9, 5), - Boundary::new(11, 5), - ] - ); - assert_eq!( - wrapper - .wrap_line(" ", px(72.)) - .collect::>(), - &[ - Boundary::new(7, 0), - Boundary::new(14, 0), - Boundary::new(21, 0) - ] - ); - assert_eq!( - wrapper - .wrap_line(" aaaaaaaaaaaaaa", px(72.)) - .collect::>(), - &[ - Boundary::new(7, 0), - Boundary::new(14, 3), - Boundary::new(18, 3), - Boundary::new(22, 3), - ] - ); - }); + #[test] + fn test_wrap_line() { + let mut wrapper = build_wrapper(); + + assert_eq!( + wrapper + .wrap_line("aa bbb cccc ddddd eeee", px(72.)) + .collect::>(), + &[ + Boundary::new(7, 0), + Boundary::new(12, 0), + Boundary::new(18, 0) + ], + ); + assert_eq!( + wrapper + .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0)) + .collect::>(), + &[ + Boundary::new(4, 0), + Boundary::new(11, 0), + Boundary::new(18, 0) + ], + ); + assert_eq!( + wrapper + .wrap_line(" aaaaaaa", px(72.)) + .collect::>(), + &[ + Boundary::new(7, 5), + Boundary::new(9, 5), + Boundary::new(11, 5), + ] + ); + assert_eq!( + wrapper + .wrap_line(" ", px(72.)) + .collect::>(), + &[ + Boundary::new(7, 0), + Boundary::new(14, 0), + Boundary::new(21, 0) + ] + ); + assert_eq!( + wrapper + .wrap_line(" aaaaaaaaaaaaaa", px(72.)) + .collect::>(), + &[ + Boundary::new(7, 0), + Boundary::new(14, 3), + Boundary::new(18, 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] diff --git a/crates/language_model/src/provider/anthropic.rs b/crates/language_model/src/provider/anthropic.rs index 479a4d97de..c1e3b75341 100644 --- a/crates/language_model/src/provider/anthropic.rs +++ b/crates/language_model/src/provider/anthropic.rs @@ -518,6 +518,7 @@ impl ConfigurationView { underline: None, strikethrough: None, white_space: WhiteSpace::Normal, + truncate: None, }; EditorElement::new( &self.api_key_editor, diff --git a/crates/language_model/src/provider/google.rs b/crates/language_model/src/provider/google.rs index 2eaf92cd66..3f4bac4b46 100644 --- a/crates/language_model/src/provider/google.rs +++ b/crates/language_model/src/provider/google.rs @@ -403,6 +403,7 @@ impl ConfigurationView { underline: None, strikethrough: None, white_space: WhiteSpace::Normal, + truncate: None, }; EditorElement::new( &self.api_key_editor, diff --git a/crates/language_model/src/provider/open_ai.rs b/crates/language_model/src/provider/open_ai.rs index af2c0eb41d..f7a98eb67c 100644 --- a/crates/language_model/src/provider/open_ai.rs +++ b/crates/language_model/src/provider/open_ai.rs @@ -460,6 +460,7 @@ impl ConfigurationView { underline: None, strikethrough: None, white_space: WhiteSpace::Normal, + truncate: None, }; EditorElement::new( &self.api_key_editor, diff --git a/crates/repl/src/stdio.rs b/crates/repl/src/stdio.rs index 23079a96e4..47a7ac0f54 100644 --- a/crates/repl/src/stdio.rs +++ b/crates/repl/src/stdio.rs @@ -45,6 +45,7 @@ pub fn text_style(cx: &mut WindowContext) -> TextStyle { line_height: cx.line_height().into(), background_color: Some(theme.colors().terminal_background), white_space: WhiteSpace::Normal, + truncate: None, // These are going to be overridden per-cell underline: None, strikethrough: None, diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 395fb51cf6..57e9692450 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -667,6 +667,7 @@ impl Element for TerminalElement { line_height: line_height.into(), background_color: Some(theme.colors().terminal_background), white_space: WhiteSpace::Normal, + truncate: None, // These are going to be overridden per-cell underline: None, strikethrough: None,