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"
Jason Lee and Marshall Bowers created
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>
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 ++++++++++++------
crates/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(-)
@@ -178,3 +178,7 @@ path = "examples/input.rs"
[[example]]
name = "shadow"
path = "examples/shadow.rs"
+
+[[example]]
+name = "text_wrapper"
+path = "examples/text_wrapper.rs"
@@ -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();
+ });
+}
@@ -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<Bounds<Pixels>>,
}
+const ELLIPSIS: &str = "β¦";
+
impl TextLayout {
fn lock(&self) -> MutexGuard<Option<TextLayoutInner>> {
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 {
@@ -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<Truncate>,
}
impl Default for TextStyle {
@@ -345,6 +358,7 @@ impl Default for TextStyle {
underline: None,
strikethrough: None,
white_space: WhiteSpace::Normal,
+ truncate: 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, 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 {
@@ -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::<Vec<_>>(),
- &[
- Boundary::new(7, 0),
- Boundary::new(12, 0),
- Boundary::new(18, 0)
- ],
- );
- assert_eq!(
- wrapper
- .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0))
- .collect::<Vec<_>>(),
- &[
- Boundary::new(4, 0),
- Boundary::new(11, 0),
- Boundary::new(18, 0)
- ],
- );
- assert_eq!(
- wrapper
- .wrap_line(" aaaaaaa", px(72.))
- .collect::<Vec<_>>(),
- &[
- Boundary::new(7, 5),
- Boundary::new(9, 5),
- Boundary::new(11, 5),
- ]
- );
- assert_eq!(
- wrapper
- .wrap_line(" ", px(72.))
- .collect::<Vec<_>>(),
- &[
- Boundary::new(7, 0),
- Boundary::new(14, 0),
- Boundary::new(21, 0)
- ]
- );
- assert_eq!(
- wrapper
- .wrap_line(" aaaaaaaaaaaaaa", px(72.))
- .collect::<Vec<_>>(),
- &[
- 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::<Vec<_>>(),
+ &[
+ Boundary::new(7, 0),
+ Boundary::new(12, 0),
+ Boundary::new(18, 0)
+ ],
+ );
+ assert_eq!(
+ wrapper
+ .wrap_line("aaa aaaaaaaaaaaaaaaaaa", px(72.0))
+ .collect::<Vec<_>>(),
+ &[
+ Boundary::new(4, 0),
+ Boundary::new(11, 0),
+ Boundary::new(18, 0)
+ ],
+ );
+ assert_eq!(
+ wrapper
+ .wrap_line(" aaaaaaa", px(72.))
+ .collect::<Vec<_>>(),
+ &[
+ Boundary::new(7, 5),
+ Boundary::new(9, 5),
+ Boundary::new(11, 5),
+ ]
+ );
+ assert_eq!(
+ wrapper
+ .wrap_line(" ", px(72.))
+ .collect::<Vec<_>>(),
+ &[
+ Boundary::new(7, 0),
+ Boundary::new(14, 0),
+ Boundary::new(21, 0)
+ ]
+ );
+ assert_eq!(
+ wrapper
+ .wrap_line(" aaaaaaaaaaaaaa", px(72.))
+ .collect::<Vec<_>>(),
+ &[
+ 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]
@@ -518,6 +518,7 @@ impl ConfigurationView {
underline: None,
strikethrough: None,
white_space: WhiteSpace::Normal,
+ truncate: None,
};
EditorElement::new(
&self.api_key_editor,
@@ -403,6 +403,7 @@ impl ConfigurationView {
underline: None,
strikethrough: None,
white_space: WhiteSpace::Normal,
+ truncate: None,
};
EditorElement::new(
&self.api_key_editor,
@@ -460,6 +460,7 @@ impl ConfigurationView {
underline: None,
strikethrough: None,
white_space: WhiteSpace::Normal,
+ truncate: None,
};
EditorElement::new(
&self.api_key_editor,
@@ -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,
@@ -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,