Skip to main content

rgpui_component/text/
text_view.rs

1use std::sync::Arc;
2
3use rgpui::prelude::FluentBuilder as _;
4use rgpui::{
5    AnyElement, App, Bounds, Element, ElementId, Entity, GlobalElementId, Hitbox, HitboxBehavior,
6    InspectorElementId, InteractiveElement, IntoElement, LayoutId, ParentElement, Pixels,
7    SharedString, StyleRefinement, Styled, Window, div,
8};
9
10use crate::StyledExt;
11use crate::scroll::ScrollableElement;
12use crate::text::TextViewFormat;
13use crate::text::node::CodeBlock;
14use crate::text::state::TextViewState;
15use crate::{global_state::GlobalState, text::TextViewStyle};
16
17/// Type for code block actions generator function.
18pub(crate) type CodeBlockActionsFn =
19    dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync;
20
21/// A text view that can render Markdown or HTML.
22///
23/// ## Goals
24///
25/// - Provide a rich text rendering component for such as Markdown or HTML,
26/// used to display rich text in GPUI application (e.g., Help messages, Release notes)
27/// - Support Markdown GFM and HTML (Simple HTML like Safari Reader Mode) for showing most common used markups.
28/// - Support Heading, Paragraph, Bold, Italic, StrikeThrough, Code, Link, Image, Blockquote, List, Table, HorizontalRule, CodeBlock ...
29///
30/// ## Not Goals
31///
32/// - Customization of the complex style (some simple styles will be supported)
33/// - As a Markdown editor or viewer (If you want to like this, you must fork your version).
34/// - As a HTML viewer, we not support CSS, we only support basic HTML tags for used to as a content reader.
35///
36/// See also [`MarkdownElement`], [`HtmlElement`]
37#[derive(Clone)]
38pub struct TextView {
39    id: ElementId,
40    format: Option<TextViewFormat>,
41    text: Option<SharedString>,
42    pub(crate) state: Option<Entity<TextViewState>>,
43    text_view_style: TextViewStyle,
44    style: StyleRefinement,
45    selectable: bool,
46    scrollable: bool,
47    code_block_actions: Option<Arc<CodeBlockActionsFn>>,
48}
49
50impl Styled for TextView {
51    fn style(&mut self) -> &mut StyleRefinement {
52        &mut self.style
53    }
54}
55
56impl TextView {
57    /// Create new TextView with managed state.
58    pub fn new(state: &Entity<TextViewState>) -> Self {
59        Self {
60            id: ElementId::Name(state.entity_id().to_string().into()),
61            state: Some(state.clone()),
62            format: None,
63            text: None,
64            text_view_style: TextViewStyle::default(),
65            style: StyleRefinement::default(),
66            selectable: false,
67            scrollable: false,
68            code_block_actions: None,
69        }
70    }
71
72    /// Create a new markdown text view.
73    pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
74        Self {
75            id: id.into(),
76            format: Some(TextViewFormat::Markdown),
77            text: Some(markdown.into()),
78            text_view_style: TextViewStyle::default(),
79            style: StyleRefinement::default(),
80            state: None,
81            selectable: false,
82            scrollable: false,
83            code_block_actions: None,
84        }
85    }
86
87    /// Create a new html text view.
88    pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
89        Self {
90            id: id.into(),
91            format: Some(TextViewFormat::Html),
92            text: Some(html.into()),
93            text_view_style: TextViewStyle::default(),
94            style: StyleRefinement::default(),
95            state: None,
96            selectable: false,
97            scrollable: false,
98            code_block_actions: None,
99        }
100    }
101
102    /// 创建一个新的纯文本文本视图,支持文本选择。
103    pub fn plain(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
104        Self {
105            id: id.into(),
106            format: Some(TextViewFormat::Plain),
107            text: Some(text.into()),
108            text_view_style: TextViewStyle::default(),
109            style: StyleRefinement::default(),
110            state: None,
111            selectable: false,
112            scrollable: false,
113            code_block_actions: None,
114        }
115    }
116
117    /// Set [`TextViewStyle`].
118    pub fn style(mut self, style: TextViewStyle) -> Self {
119        self.text_view_style = style;
120        self
121    }
122
123    /// Set the text view to be selectable, default is false.
124    pub fn selectable(mut self, selectable: bool) -> Self {
125        self.selectable = selectable;
126        self
127    }
128
129    /// Set the text view to be scrollable, default is false.
130    ///
131    /// ## If true for `scrollable`
132    ///
133    /// The `scrollable` mode used for large content,
134    /// will show scrollbar, but requires the parent to have a fixed height,
135    /// and use [`rgpui::list`] to render the content in a virtualized way.
136    ///
137    /// ## If false to fit content
138    ///
139    /// The TextView will expand to fit all content, no scrollbar.
140    /// This mode is suitable for small content, such as a few lines of text, a label, etc.
141    pub fn scrollable(mut self, scrollable: bool) -> Self {
142        self.scrollable = scrollable;
143        self
144    }
145
146    /// Set custom block actions for code blocks.
147    ///
148    /// The closure receives the [`CodeBlock`],
149    /// and returns an element to display.
150    pub fn code_block_actions<F, E>(mut self, f: F) -> Self
151    where
152        F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
153        E: IntoElement,
154    {
155        self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
156            f(&code_block, window, cx).into_any_element()
157        }));
158        self
159    }
160}
161
162impl IntoElement for TextView {
163    type Element = Self;
164
165    fn into_element(self) -> Self::Element {
166        self
167    }
168}
169
170pub struct TextViewLayoutState {
171    state: Entity<TextViewState>,
172    element: AnyElement,
173}
174
175impl Element for TextView {
176    type RequestLayoutState = TextViewLayoutState;
177    type PrepaintState = Hitbox;
178
179    fn id(&self) -> Option<ElementId> {
180        Some(self.id.clone())
181    }
182
183    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
184        None
185    }
186
187    fn request_layout(
188        &mut self,
189        _: Option<&GlobalElementId>,
190        _: Option<&InspectorElementId>,
191        window: &mut Window,
192        cx: &mut App,
193    ) -> (LayoutId, Self::RequestLayoutState) {
194        let state = if let Some(state) = self.state.clone() {
195            state
196        } else {
197            let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
198            let default_text = self.text.clone().unwrap_or_default();
199
200            let state = window.use_keyed_state(
201                SharedString::from(format!("{}/state", self.id)),
202                cx,
203                move |_, cx| match default_format {
204                    TextViewFormat::Markdown => TextViewState::markdown(default_text.as_str(), cx),
205                    TextViewFormat::Html => TextViewState::html(default_text.as_str(), cx),
206                    TextViewFormat::Plain => TextViewState::plain(default_text.as_str(), cx),
207                },
208            );
209            self.state = Some(state.clone());
210            state
211        };
212
213        state.update(cx, |state, cx| {
214            state.code_block_actions = self.code_block_actions.clone();
215            state.selectable = self.selectable;
216            state.scrollable = self.scrollable;
217            state.text_view_style = self.text_view_style.clone();
218
219            if let Some(text) = self.text.clone() {
220                state.set_text(text.as_str(), cx);
221            }
222        });
223
224        let focus_handle = state.read(cx).focus_handle.clone();
225        let list_state = state.read(cx).list_state.clone();
226
227        let mut el = div()
228            .key_context("TextView")
229            .track_focus(&focus_handle)
230            .when(self.scrollable, |this| {
231                this.size_full().vertical_scrollbar(&list_state)
232            })
233            .relative()
234            .on_action(move |_: &crate::input::Copy, window, cx| {
235                use crate::WindowExt as _;
236                let text = window.selected_text(cx).trim().to_string();
237                if text.is_empty() {
238                    cx.propagate();
239                    return;
240                }
241                cx.write_to_clipboard(rgpui::ClipboardItem::new_string(text));
242            })
243            .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
244            .child(state.clone())
245            .refine_style(&self.style)
246            .into_any_element();
247        let layout_id = el.request_layout(window, cx);
248        (layout_id, TextViewLayoutState { state, element: el })
249    }
250
251    fn prepaint(
252        &mut self,
253        _: Option<&GlobalElementId>,
254        _: Option<&InspectorElementId>,
255        bounds: Bounds<Pixels>,
256        request_layout: &mut Self::RequestLayoutState,
257        window: &mut Window,
258        cx: &mut App,
259    ) -> Self::PrepaintState {
260        request_layout.element.prepaint(window, cx);
261        window.insert_hitbox(bounds, HitboxBehavior::Normal)
262    }
263
264    fn paint(
265        &mut self,
266        _: Option<&GlobalElementId>,
267        _: Option<&InspectorElementId>,
268        _bounds: Bounds<Pixels>,
269        request_layout: &mut Self::RequestLayoutState,
270        hitbox: &mut Self::PrepaintState,
271        window: &mut Window,
272        cx: &mut App,
273    ) {
274        let state = &request_layout.state;
275        GlobalState::global_mut(cx)
276            .text_view_state_stack
277            .push(state.clone());
278        request_layout.element.paint(window, cx);
279        GlobalState::global_mut(cx).text_view_state_stack.pop();
280
281        if self.selectable {
282            // Window-level selection: register this view (with its clipped
283            // hitbox) so the Root selection controller can hit-test and collect
284            // selected text. All mouse handling lives in TextSelectionController.
285            crate::Root::register_selectable_text_view(state, hitbox, window, cx);
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::TextView;
293    use crate::text::TextViewState;
294    use rgpui::{
295        AppContext as _, Context, Entity, IntoElement, Modifiers, MouseButton, MouseDownEvent,
296        MouseUpEvent, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext,
297        Window, div, point, px,
298    };
299
300    struct TextViewTestRoot {
301        text_view: Entity<TextViewState>,
302    }
303
304    impl TextViewTestRoot {
305        fn new(text: &str, cx: &mut Context<Self>) -> Self {
306            let text = text.to_string();
307            let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
308            Self { text_view }
309        }
310    }
311
312    impl Render for TextViewTestRoot {
313        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
314            div()
315                .w(px(160.))
316                .child(
317                    div()
318                        .h(px(24.))
319                        .overflow_hidden()
320                        .child(TextView::new(&self.text_view).selectable(true)),
321                )
322                .child(div().h(px(40.)).child("footer"))
323        }
324    }
325
326    #[rgpui::test]
327    fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
328        cx.update(crate::init);
329        let (_, cx) = cx.add_window_view(|_, cx| {
330            TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
331        });
332        let cx: &mut VisualTestContext = cx;
333
334        cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
335
336        assert_eq!(cx.opened_url(), None);
337    }
338
339    #[rgpui::test]
340    fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
341        cx.update(crate::init);
342        let (view, cx) = cx
343            .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
344        let cx: &mut VisualTestContext = cx;
345
346        cx.simulate_mouse_down(
347            point(px(10.), px(34.)),
348            MouseButton::Left,
349            Modifiers::default(),
350        );
351        cx.simulate_mouse_move(
352            point(px(90.), px(34.)),
353            Some(MouseButton::Left),
354            Modifiers::default(),
355        );
356        cx.simulate_mouse_up(
357            point(px(90.), px(34.)),
358            MouseButton::Left,
359            Modifiers::default(),
360        );
361
362        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
363        assert!(
364            selected_text.is_empty(),
365            "unexpected selection: {selected_text:?}"
366        );
367    }
368
369    #[rgpui::test]
370    fn double_click_selects_word(cx: &mut TestAppContext) {
371        cx.update(crate::init);
372        let (view, cx) =
373            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
374
375        let cx: &mut VisualTestContext = cx;
376        cx.run_until_parked();
377        cx.update(|window, cx| {
378            let _ = window.draw(cx);
379        });
380        let position = point(px(10.), px(16.));
381        cx.simulate_event(MouseDownEvent {
382            position,
383            modifiers: Modifiers::default(),
384            button: MouseButton::Left,
385            click_count: 2,
386            first_mouse: false,
387        });
388        cx.simulate_event(MouseUpEvent {
389            position,
390            modifiers: Modifiers::default(),
391            button: MouseButton::Left,
392            click_count: 2,
393        });
394        cx.update(|window, cx| {
395            let _ = window.draw(cx);
396        });
397
398        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
399        assert_eq!(selected_text.trim(), "quick");
400    }
401
402    #[rgpui::test]
403    fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
404        cx.update(crate::init);
405        let (view, cx) =
406            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
407
408        let cx: &mut VisualTestContext = cx;
409        cx.run_until_parked();
410        cx.update(|window, cx| {
411            let _ = window.draw(cx);
412        });
413
414        let position = point(px(10.), px(10.));
415        cx.simulate_event(MouseDownEvent {
416            position,
417            modifiers: Modifiers::default(),
418            button: MouseButton::Left,
419            click_count: 3,
420            first_mouse: false,
421        });
422        cx.simulate_event(MouseUpEvent {
423            position,
424            modifiers: Modifiers::default(),
425            button: MouseButton::Left,
426            click_count: 3,
427        });
428        cx.update(|window, cx| {
429            let _ = window.draw(cx);
430        });
431
432        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
433        assert_eq!(selected_text.trim(), "quick select value");
434    }
435}