liora_components/
paragraph.rs1use 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}