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:
parent
12dda5fa1b
commit
938d93a64c
11 changed files with 238 additions and 66 deletions
|
@ -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"
|
||||||
|
|
59
crates/gpui/examples/text_wrapper.rs
Normal file
59
crates/gpui/examples/text_wrapper.rs
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue