Skip to main content

liora_components/
paragraph.rs

1use crate::{SelectableText, SelectableTextOptions, SelectableTextWrap, Text};
2use gpui::{
3    App, Component, ElementId, IntoElement, RenderOnce, SharedString, StyledText, TextRun,
4    TextStyle, WhiteSpace, Window, div, prelude::*, px,
5};
6use liora_core::Config;
7
8pub struct Paragraph {
9    children: Vec<Text>,
10    selectable: bool,
11    id: SharedString,
12}
13
14impl Paragraph {
15    pub fn new() -> Self {
16        Self {
17            children: Vec::new(),
18            selectable: true,
19            id: liora_core::unique_id("paragraph"),
20        }
21    }
22
23    pub fn with_text(text: impl Into<SharedString>) -> Self {
24        Self {
25            children: vec![Text::new(text)],
26            selectable: true,
27            id: liora_core::unique_id("paragraph"),
28        }
29    }
30
31    pub fn child(mut self, child: Text) -> Self {
32        self.children.push(child);
33        self
34    }
35
36    pub fn children(mut self, children: impl IntoIterator<Item = Text>) -> Self {
37        self.children.extend(children);
38        self
39    }
40
41    pub fn selectable(mut self, selectable: bool) -> Self {
42        self.selectable = selectable;
43        self
44    }
45
46    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
47        self.id = id.into();
48        self
49    }
50
51    pub fn register_key_bindings(cx: &mut App) {
52        SelectableText::register_key_bindings(cx);
53    }
54
55    fn default_text_style(theme: &liora_theme::Theme) -> TextStyle {
56        let font_size = px(theme.font_size.md);
57        let mut style = TextStyle::default();
58        style.color = theme.neutral.text_2;
59        style.font_size = font_size.into();
60        style.line_height = px(theme.font_size.md * 1.6).into();
61        style.white_space = WhiteSpace::Normal;
62        style.text_overflow = None;
63        style.line_clamp = None;
64        style
65    }
66
67    fn styled_text_parts(&self, theme: &liora_theme::Theme) -> (SharedString, Vec<TextRun>) {
68        let default_style = Self::default_text_style(theme);
69        let mut full_text = String::new();
70        let mut runs: Vec<TextRun> = Vec::new();
71
72        for segment in &self.children {
73            if segment.content.is_empty() {
74                continue;
75            }
76
77            let segment_text = segment.content.clone();
78            let text = segment_text.as_ref();
79            let leading_glue_len = if runs.is_empty() {
80                0
81            } else {
82                leading_no_line_start_len(text)
83            };
84
85            if leading_glue_len > 0 {
86                full_text.push_str(&text[..leading_glue_len]);
87                if let Some(previous_run) = runs.last_mut() {
88                    previous_run.len += leading_glue_len;
89                }
90            }
91
92            let remaining = &text[leading_glue_len..];
93            if !remaining.is_empty() {
94                full_text.push_str(remaining);
95                let mut run = segment.to_text_run(&default_style);
96                run.len = remaining.len();
97                runs.push(run);
98            }
99        }
100
101        (full_text.into(), runs)
102    }
103}
104
105fn leading_no_line_start_len(text: &str) -> usize {
106    let Some(first) = text.chars().next() else {
107        return 0;
108    };
109
110    if !is_no_line_start_punctuation(first) {
111        return 0;
112    }
113
114    let mut end = 0;
115    let mut saw_punctuation = false;
116    for (index, ch) in text.char_indices() {
117        if is_no_line_start_punctuation(ch) {
118            saw_punctuation = true;
119            end = index + ch.len_utf8();
120            continue;
121        }
122
123        if saw_punctuation && ch.is_whitespace() {
124            end = index + ch.len_utf8();
125            continue;
126        }
127
128        break;
129    }
130
131    end
132}
133
134fn is_no_line_start_punctuation(ch: char) -> bool {
135    matches!(
136        ch,
137        ':' | ':'
138            | ','
139            | ','
140            | '.'
141            | '。'
142            | ';'
143            | ';'
144            | '!'
145            | '!'
146            | '?'
147            | '?'
148            | '、'
149            | ')'
150            | ')'
151            | ']'
152            | '】'
153            | '}'
154            | '》'
155            | '」'
156            | '』'
157            | '”'
158            | '’'
159    )
160}
161
162impl RenderOnce for Paragraph {
163    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
164        let theme = &cx.global::<Config>().theme;
165        let (full_text, runs) = self.styled_text_parts(theme);
166        let font_size = px(theme.font_size.md);
167
168        if self.selectable {
169            return SelectableText::view(
170                SelectableTextOptions {
171                    id: ElementId::from(self.id.clone()),
172                    text: full_text,
173                    runs,
174                    font_size,
175                    line_height: font_size * 1.6,
176                    text_color: theme.neutral.text_2,
177                    wrap: SelectableTextWrap::Normal,
178                    key_context: "SelectableText",
179                    fill_width: true,
180                },
181                _window,
182                cx,
183            );
184        }
185
186        div()
187            .w_full()
188            .text_size(font_size)
189            .line_height(font_size * 1.6)
190            .text_color(theme.neutral.text_2)
191            .whitespace_normal()
192            .child(StyledText::new(full_text).with_runs(runs))
193            .into_any_element()
194    }
195}
196
197impl IntoElement for Paragraph {
198    type Element = Component<Self>;
199    fn into_element(self) -> Self::Element {
200        Component::new(self)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use gpui::{FontStyle, FontWeight};
208
209    #[test]
210    fn text_and_paragraph_use_selectable_text_for_native_selection() {
211        let text_source = include_str!("text.rs");
212        let paragraph_source = include_str!("paragraph.rs");
213        let selectable_source = include_str!("selectable_text.rs");
214
215        assert!(text_source.contains("SelectableText::view"));
216        assert!(paragraph_source.contains("SelectableText::view"));
217        assert!(text_source.contains("pub fn selectable"));
218        assert!(paragraph_source.contains("pub fn selectable"));
219        assert!(selectable_source.contains("event.click_count == 2"));
220        assert!(selectable_source.contains("ClipboardItem::new_string"));
221        assert!(selectable_source.contains(r#"KeyBinding::new("ctrl-c""#));
222        assert!(selectable_source.contains("window.capture_pointer"));
223        assert!(selectable_source.contains("cx.on_blur(&self.focus_handle"));
224        assert!(selectable_source.contains("fn clear_selection"));
225    }
226
227    #[test]
228    fn paragraph_composes_segments_into_one_styled_text_run_list() {
229        let theme = liora_theme::Theme::light();
230        let (text, runs) = Paragraph::new()
231            .child(Text::new("Hello ").bold())
232            .child(Text::new("世界").italic())
233            .styled_text_parts(&theme);
234
235        assert_eq!(text.as_ref(), "Hello 世界");
236        assert_eq!(runs.len(), 2);
237        assert_eq!(runs[0].len, "Hello ".len());
238        assert_eq!(runs[1].len, "世界".len());
239        assert_eq!(runs[0].font.weight, FontWeight::BOLD);
240        assert_eq!(runs[1].font.style, FontStyle::Italic);
241    }
242
243    #[test]
244    fn paragraph_glues_line_start_forbidden_punctuation_to_previous_run() {
245        let theme = liora_theme::Theme::light();
246        let (text, runs) = Paragraph::new()
247            .child(Text::new("crates/liora-components").code_style(&theme))
248            .child(Text::new(":所有可复用组件,例如 "))
249            .child(Text::new("Button").code_style(&theme))
250            .child(Text::new("、"))
251            .child(Text::new("Input").code_style(&theme))
252            .child(Text::new("。"))
253            .styled_text_parts(&theme);
254
255        assert_eq!(
256            text.as_ref(),
257            "crates/liora-components:所有可复用组件,例如 Button、Input。"
258        );
259        assert_eq!(runs[0].len, "crates/liora-components:".len());
260        assert_eq!(runs[2].len, "Button、".len());
261        assert_eq!(runs[3].len, "Input。".len());
262    }
263
264    #[test]
265    fn text_segments_map_inline_code_style_to_text_runs() {
266        let theme = liora_theme::Theme::light();
267        let default_style = Paragraph::default_text_style(&theme);
268        let run = Text::new("code")
269            .code_style(&theme)
270            .bold()
271            .underline()
272            .to_text_run(&default_style);
273
274        assert_eq!(run.len, "code".len());
275        assert_eq!(run.font.family.as_ref(), "Monospace");
276        assert_eq!(run.font.weight, FontWeight::BOLD);
277        assert_eq!(run.color, theme.danger.base);
278        assert_eq!(run.background_color, Some(theme.neutral.hover));
279        assert!(run.underline.is_some());
280    }
281
282    #[test]
283    fn paragraph_default_style_keeps_native_wrapping_without_truncation() {
284        let theme = liora_theme::Theme::light();
285        let style = Paragraph::default_text_style(&theme);
286
287        assert_eq!(style.white_space, WhiteSpace::Normal);
288        assert!(style.text_overflow.is_none());
289        assert!(style.line_clamp.is_none());
290    }
291}