gpui_component/text/
text_view.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::Poll;
5use std::time::Duration;
6
7use gpui::prelude::FluentBuilder;
8use gpui::{
9    div, AnyElement, App, AppContext, Bounds, ClipboardItem, Context, Element, ElementId, Entity,
10    EntityId, FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
11    KeyBinding, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
12    Point, RenderOnce, SharedString, Size, Styled, Timer, Window,
13};
14use smol::stream::StreamExt;
15
16use crate::highlighter::HighlightTheme;
17use crate::{
18    global_state::GlobalState,
19    input::{self},
20    text::{
21        node::{self, NodeContext},
22        TextViewStyle,
23    },
24};
25use crate::{v_flex, ActiveTheme};
26
27const CONTEXT: &'static str = "TextView";
28
29pub(crate) fn init(cx: &mut App) {
30    cx.bind_keys(vec![
31        #[cfg(target_os = "macos")]
32        KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
33        #[cfg(not(target_os = "macos"))]
34        KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
35    ]);
36}
37
38#[derive(IntoElement, Clone)]
39struct TextViewElement {
40    state: Entity<TextViewState>,
41}
42
43impl RenderOnce for TextViewElement {
44    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
45        self.state.update(cx, |state, cx| {
46            div().map(|this| match &mut state.parsed_result {
47                Some(Ok(content)) => this.child(content.root_node.render(
48                    None,
49                    true,
50                    true,
51                    &content.node_cx,
52                    window,
53                    cx,
54                )),
55                Some(Err(err)) => this.child(
56                    v_flex()
57                        .gap_1()
58                        .child("Failed to parse content")
59                        .child(err.to_string()),
60                ),
61                None => this,
62            })
63        })
64    }
65}
66
67/// A text view that can render Markdown or HTML.
68///
69/// ## Goals
70///
71/// - Provide a rich text rendering component for such as Markdown or HTML,
72/// used to display rich text in GPUI application (e.g., Help messages, Release notes)
73/// - Support Markdown GFM and HTML (Simple HTML like Safari Reader Mode) for showing most common used markups.
74/// - Support Heading, Paragraph, Bold, Italic, StrikeThrough, Code, Link, Image, Blockquote, List, Table, HorizontalRule, CodeBlock ...
75///
76/// ## Not Goals
77///
78/// - Customization of the complex style (some simple styles will be supported)
79/// - As a Markdown editor or viewer (If you want to like this, you must fork your version).
80/// - As a HTML viewer, we not support CSS, we only support basic HTML tags for used to as a content reader.
81///
82/// See also [`MarkdownElement`], [`HtmlElement`]
83#[derive(Clone)]
84pub struct TextView {
85    id: ElementId,
86    init_state: Option<InitState>,
87    state: Entity<TextViewState>,
88    selectable: bool,
89}
90
91#[derive(PartialEq)]
92pub(crate) struct ParsedContent {
93    pub(crate) root_node: node::Node,
94    pub(crate) node_cx: node::NodeContext,
95}
96
97/// The type of the text view.
98#[derive(Clone, Copy, PartialEq, Eq)]
99enum TextViewType {
100    /// Markdown view
101    Markdown,
102    /// HTML view
103    Html,
104}
105
106enum Update {
107    Text(SharedString),
108    Style(Box<TextViewStyle>),
109}
110
111struct UpdateFuture {
112    type_: TextViewType,
113    highlight_theme: Arc<HighlightTheme>,
114    current_style: TextViewStyle,
115    current_text: SharedString,
116    timer: Timer,
117    rx: smol::channel::Receiver<Update>,
118    tx_result: smol::channel::Sender<Result<ParsedContent, SharedString>>,
119    delay: Duration,
120}
121
122impl UpdateFuture {
123    fn new(
124        type_: TextViewType,
125        style: TextViewStyle,
126        text: SharedString,
127        highlight_theme: Arc<HighlightTheme>,
128        rx: smol::channel::Receiver<Update>,
129        tx_result: smol::channel::Sender<Result<ParsedContent, SharedString>>,
130        delay: Duration,
131    ) -> Self {
132        Self {
133            type_,
134            highlight_theme,
135            current_style: style,
136            current_text: text,
137            timer: Timer::never(),
138            rx,
139            tx_result,
140            delay,
141        }
142    }
143}
144
145impl Future for UpdateFuture {
146    type Output = ();
147
148    fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
149        loop {
150            match self.rx.poll_next(cx) {
151                Poll::Ready(Some(update)) => {
152                    let changed = match update {
153                        Update::Text(text) if self.current_text != text => {
154                            self.current_text = text;
155                            true
156                        }
157                        Update::Style(style) if self.current_style != *style => {
158                            self.current_style = *style;
159                            true
160                        }
161                        _ => false,
162                    };
163                    if changed {
164                        let delay = self.delay;
165                        self.timer.set_after(delay);
166                    }
167                    continue;
168                }
169                Poll::Ready(None) => return Poll::Ready(()),
170                Poll::Pending => {}
171            }
172
173            match self.timer.poll_next(cx) {
174                Poll::Ready(Some(_)) => {
175                    let res = parse_content(
176                        self.type_,
177                        &self.current_text,
178                        self.current_style.clone(),
179                        &self.highlight_theme,
180                    );
181                    _ = self.tx_result.try_send(res);
182                    continue;
183                }
184                Poll::Ready(None) | Poll::Pending => return Poll::Pending,
185            }
186        }
187    }
188}
189
190#[derive(Clone)]
191enum InitState {
192    Initializing {
193        type_: TextViewType,
194        text: SharedString,
195        style: Box<TextViewStyle>,
196        highlight_theme: Arc<HighlightTheme>,
197    },
198    Initialized {
199        tx: smol::channel::Sender<Update>,
200    },
201}
202
203pub(crate) struct TextViewState {
204    parent_entity: Option<EntityId>,
205    tx: Option<smol::channel::Sender<Update>>,
206    parsed_result: Option<Result<ParsedContent, SharedString>>,
207    focus_handle: Option<FocusHandle>,
208
209    /// The bounds of the text view
210    bounds: Bounds<Pixels>,
211    /// The local (in TextView) position of the selection.
212    selection_positions: (Option<Point<Pixels>>, Option<Point<Pixels>>),
213    /// Is current in selection.
214    is_selecting: bool,
215    is_selectable: bool,
216}
217
218impl TextViewState {
219    fn new(cx: &mut Context<TextViewState>) -> Self {
220        let focus_handle = cx.focus_handle();
221        Self {
222            parent_entity: None,
223            tx: None,
224            parsed_result: None,
225            focus_handle: Some(focus_handle),
226            bounds: Bounds::default(),
227            selection_positions: (None, None),
228            is_selecting: false,
229            is_selectable: false,
230        }
231    }
232}
233
234impl TextViewState {
235    /// Save bounds and unselect if bounds changed.
236    fn update_bounds(&mut self, bounds: Bounds<Pixels>) {
237        if self.bounds.size != bounds.size {
238            self.clear_selection();
239        }
240        self.bounds = bounds;
241    }
242
243    fn clear_selection(&mut self) {
244        self.selection_positions = (None, None);
245        self.is_selecting = false;
246    }
247
248    fn start_selection(&mut self, pos: Point<Pixels>) {
249        let pos = pos - self.bounds.origin;
250        self.selection_positions = (Some(pos), Some(pos));
251        self.is_selecting = true;
252    }
253
254    fn update_selection(&mut self, pos: Point<Pixels>) {
255        let pos = pos - self.bounds.origin;
256        if let (Some(start), Some(_)) = self.selection_positions {
257            self.selection_positions = (Some(start), Some(pos))
258        }
259    }
260
261    fn end_selection(&mut self) {
262        self.is_selecting = false;
263    }
264
265    pub(crate) fn has_selection(&self) -> bool {
266        if let (Some(start), Some(end)) = self.selection_positions {
267            start != end
268        } else {
269            false
270        }
271    }
272
273    pub(crate) fn is_selectable(&self) -> bool {
274        self.is_selectable
275    }
276
277    /// Return the bounds of the selection in window coordinates.
278    pub(crate) fn selection_bounds(&self) -> Bounds<Pixels> {
279        selection_bounds(
280            self.selection_positions.0,
281            self.selection_positions.1,
282            self.bounds,
283        )
284    }
285
286    fn selection_text(&self) -> Option<String> {
287        Some(
288            self.parsed_result
289                .as_ref()?
290                .as_ref()
291                .ok()?
292                .root_node
293                .selected_text(),
294        )
295    }
296}
297
298#[derive(IntoElement, Clone)]
299pub enum Text {
300    String(SharedString),
301    TextView(Box<TextView>),
302}
303
304impl From<SharedString> for Text {
305    fn from(s: SharedString) -> Self {
306        Self::String(s)
307    }
308}
309
310impl From<&str> for Text {
311    fn from(s: &str) -> Self {
312        Self::String(SharedString::from(s.to_string()))
313    }
314}
315
316impl From<String> for Text {
317    fn from(s: String) -> Self {
318        Self::String(s.into())
319    }
320}
321
322impl From<TextView> for Text {
323    fn from(e: TextView) -> Self {
324        Self::TextView(Box::new(e))
325    }
326}
327
328impl Text {
329    /// Set the style for [`TextView`].
330    ///
331    /// Do nothing if this is `String`.
332    pub fn style(self, style: TextViewStyle) -> Self {
333        match self {
334            Self::String(s) => Self::String(s),
335            Self::TextView(e) => Self::TextView(Box::new(e.style(style))),
336        }
337    }
338}
339
340impl RenderOnce for Text {
341    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
342        match self {
343            Self::String(s) => s.into_any_element(),
344            Self::TextView(e) => e.into_any_element(),
345        }
346    }
347}
348
349impl TextView {
350    fn create_init_state(
351        type_: TextViewType,
352        text: &SharedString,
353        highlight_theme: &Arc<HighlightTheme>,
354        state: &Entity<TextViewState>,
355        cx: &mut App,
356    ) -> InitState {
357        let state = state.read(cx);
358        if let Some(tx) = &state.tx {
359            InitState::Initialized { tx: tx.clone() }
360        } else {
361            InitState::Initializing {
362                type_,
363                text: text.clone(),
364                style: Default::default(),
365                highlight_theme: highlight_theme.clone(),
366            }
367        }
368    }
369
370    /// Create a new markdown text view.
371    pub fn markdown(
372        id: impl Into<ElementId>,
373        markdown: impl Into<SharedString>,
374        window: &mut Window,
375        cx: &mut App,
376    ) -> Self {
377        let id: ElementId = id.into();
378        let markdown = markdown.into();
379        let highlight_theme = cx.theme().highlight_theme.clone();
380        let state =
381            window.use_keyed_state(SharedString::from(format!("{}/state", id)), cx, |_, cx| {
382                TextViewState::new(cx)
383            });
384        let init_state = Self::create_init_state(
385            TextViewType::Markdown,
386            &markdown,
387            &highlight_theme,
388            &state,
389            cx,
390        );
391        if let Some(tx) = &state.read(cx).tx {
392            let _ = tx.try_send(Update::Text(markdown));
393        }
394        Self {
395            id,
396            init_state: Some(init_state),
397            state,
398            selectable: false,
399        }
400    }
401
402    /// Create a new html text view.
403    pub fn html(
404        id: impl Into<ElementId>,
405        html: impl Into<SharedString>,
406        window: &mut Window,
407        cx: &mut App,
408    ) -> Self {
409        let id: ElementId = id.into();
410        let html = html.into();
411        let highlight_theme = cx.theme().highlight_theme.clone();
412        let state =
413            window.use_keyed_state(SharedString::from(format!("{}/state", id)), cx, |_, cx| {
414                TextViewState::new(cx)
415            });
416        let init_state =
417            Self::create_init_state(TextViewType::Html, &html, &highlight_theme, &state, cx);
418        if let Some(tx) = &state.read(cx).tx {
419            let _ = tx.try_send(Update::Text(html));
420        }
421        Self {
422            id,
423            init_state: Some(init_state),
424            state,
425            selectable: false,
426        }
427    }
428
429    /// Set the source text of the text view.
430    pub fn text(mut self, raw: impl Into<SharedString>) -> Self {
431        if let Some(init_state) = &mut self.init_state {
432            match init_state {
433                InitState::Initializing { text, .. } => *text = raw.into(),
434                InitState::Initialized { tx } => {
435                    let _ = tx.try_send(Update::Text(raw.into()));
436                }
437            }
438        }
439
440        self
441    }
442
443    /// Set [`TextViewStyle`].
444    pub fn style(mut self, style: TextViewStyle) -> Self {
445        if let Some(init_state) = &mut self.init_state {
446            match init_state {
447                InitState::Initializing { style: s, .. } => *s = Box::new(style),
448                InitState::Initialized { tx } => {
449                    let _ = tx.try_send(Update::Style(Box::new(style)));
450                }
451            }
452        }
453        self
454    }
455
456    /// Set the text view to be selectable, default is false.
457    pub fn selectable(mut self) -> Self {
458        self.selectable = true;
459        self
460    }
461
462    fn on_action_copy(state: &Entity<TextViewState>, cx: &mut App) {
463        let Some(selected_text) = state.read(cx).selection_text() else {
464            return;
465        };
466
467        cx.write_to_clipboard(ClipboardItem::new_string(selected_text.trim().to_string()));
468    }
469}
470
471impl IntoElement for TextView {
472    type Element = Self;
473
474    fn into_element(self) -> Self::Element {
475        self
476    }
477}
478
479impl Element for TextView {
480    type RequestLayoutState = AnyElement;
481    type PrepaintState = ();
482
483    fn id(&self) -> Option<ElementId> {
484        Some(self.id.clone())
485    }
486
487    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
488        None
489    }
490
491    fn request_layout(
492        &mut self,
493        _: Option<&GlobalElementId>,
494        _: Option<&InspectorElementId>,
495        window: &mut Window,
496        cx: &mut App,
497    ) -> (LayoutId, Self::RequestLayoutState) {
498        if let Some(InitState::Initializing {
499            type_,
500            text,
501            style,
502            highlight_theme,
503        }) = self.init_state.take()
504        {
505            let style = *style;
506            let highlight_theme = highlight_theme.clone();
507            let (tx, rx) = smol::channel::unbounded::<Update>();
508            let (tx_result, rx_result) =
509                smol::channel::unbounded::<Result<ParsedContent, SharedString>>();
510            let parsed_result = parse_content(type_, &text, style.clone(), &highlight_theme);
511
512            self.state.update(cx, {
513                let tx = tx.clone();
514                |state, _| {
515                    state.parsed_result = Some(parsed_result);
516                    state.tx = Some(tx);
517                }
518            });
519
520            cx.spawn({
521                let state = self.state.downgrade();
522                async move |cx| {
523                    while let Ok(parsed_result) = rx_result.recv().await {
524                        if let Some(state) = state.upgrade() {
525                            _ = state.update(cx, |state, cx| {
526                                state.parsed_result = Some(parsed_result);
527                                if let Some(parent_entity) = state.parent_entity {
528                                    let app = &mut **cx;
529                                    app.notify(parent_entity);
530                                }
531                                state.clear_selection();
532                            });
533                        } else {
534                            // state released, stopping processing
535                            break;
536                        }
537                    }
538                }
539            })
540            .detach();
541
542            cx.background_spawn(UpdateFuture::new(
543                type_,
544                style,
545                text,
546                highlight_theme,
547                rx,
548                tx_result,
549                Duration::from_millis(200),
550            ))
551            .detach();
552
553            self.init_state = Some(InitState::Initialized { tx });
554        }
555
556        let focus_handle = self
557            .state
558            .read(cx)
559            .focus_handle
560            .as_ref()
561            .expect("focus_handle should init by TextViewState::new");
562
563        let mut el = div()
564            .key_context(CONTEXT)
565            .track_focus(focus_handle)
566            .on_action({
567                let state = self.state.clone();
568                move |_: &input::Copy, _, cx| {
569                    Self::on_action_copy(&state, cx);
570                }
571            })
572            .child(TextViewElement {
573                state: self.state.clone(),
574            })
575            .into_any_element();
576        let layout_id = el.request_layout(window, cx);
577        (layout_id, el)
578    }
579
580    fn prepaint(
581        &mut self,
582        _: Option<&GlobalElementId>,
583        _: Option<&InspectorElementId>,
584        _: Bounds<Pixels>,
585        request_layout: &mut Self::RequestLayoutState,
586        window: &mut Window,
587        cx: &mut App,
588    ) -> Self::PrepaintState {
589        request_layout.prepaint(window, cx);
590    }
591
592    fn paint(
593        &mut self,
594        _: Option<&GlobalElementId>,
595        _: Option<&InspectorElementId>,
596        bounds: Bounds<Pixels>,
597        request_layout: &mut Self::RequestLayoutState,
598        _: &mut Self::PrepaintState,
599        window: &mut Window,
600        cx: &mut App,
601    ) {
602        let entity_id = window.current_view();
603        let is_selectable = self.selectable;
604
605        self.state.update(cx, |state, _| {
606            state.parent_entity = Some(entity_id);
607            state.update_bounds(bounds);
608            state.is_selectable = is_selectable;
609        });
610
611        GlobalState::global_mut(cx)
612            .text_view_state_stack
613            .push(self.state.clone());
614        request_layout.paint(window, cx);
615        GlobalState::global_mut(cx).text_view_state_stack.pop();
616
617        if self.selectable {
618            let is_selecting = self.state.read(cx).is_selecting;
619            let has_selection = self.state.read(cx).has_selection();
620
621            window.on_mouse_event({
622                let state = self.state.clone();
623                move |event: &MouseDownEvent, phase, _, cx| {
624                    if !bounds.contains(&event.position) || !phase.bubble() {
625                        return;
626                    }
627
628                    state.update(cx, |state, _| {
629                        state.start_selection(event.position);
630                    });
631                    cx.notify(entity_id);
632                }
633            });
634
635            if is_selecting {
636                // move to update end position.
637                window.on_mouse_event({
638                    let state = self.state.clone();
639                    move |event: &MouseMoveEvent, _, _, cx| {
640                        state.update(cx, |state, _| {
641                            state.update_selection(event.position);
642                        });
643                        cx.notify(entity_id);
644                    }
645                });
646
647                // up to end selection
648                window.on_mouse_event({
649                    let state = self.state.clone();
650                    move |_: &MouseUpEvent, _, _, cx| {
651                        state.update(cx, |state, _| {
652                            state.end_selection();
653                        });
654                        cx.notify(entity_id);
655                    }
656                });
657            }
658
659            if has_selection {
660                // down outside to clear selection
661                window.on_mouse_event({
662                    let state = self.state.clone();
663                    move |event: &MouseDownEvent, _, _, cx| {
664                        if bounds.contains(&event.position) {
665                            return;
666                        }
667
668                        state.update(cx, |state, _| {
669                            state.clear_selection();
670                        });
671                        cx.notify(entity_id);
672                    }
673                });
674            }
675        }
676    }
677}
678
679fn parse_content(
680    type_: TextViewType,
681    text: &str,
682    style: TextViewStyle,
683    highlight_theme: &HighlightTheme,
684) -> Result<ParsedContent, SharedString> {
685    let mut node_cx = NodeContext {
686        style: style.clone(),
687        ..NodeContext::default()
688    };
689
690    let res = match type_ {
691        TextViewType::Markdown => {
692            super::format::markdown::parse(text, &style, &mut node_cx, highlight_theme)
693        }
694        TextViewType::Html => super::format::html::parse(text, &mut node_cx),
695    };
696    res.map(move |root_node| ParsedContent { root_node, node_cx })
697}
698
699fn selection_bounds(
700    start: Option<Point<Pixels>>,
701    end: Option<Point<Pixels>>,
702    bounds: Bounds<Pixels>,
703) -> Bounds<Pixels> {
704    if let (Some(start), Some(end)) = (start, end) {
705        let start = start + bounds.origin;
706        let end = end + bounds.origin;
707
708        let origin = Point {
709            x: start.x.min(end.x),
710            y: start.y.min(end.y),
711        };
712        let size = Size {
713            width: (start.x - end.x).abs(),
714            height: (start.y - end.y).abs(),
715        };
716
717        return Bounds { origin, size };
718    }
719
720    Bounds::default()
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use gpui::{point, px, size, Bounds};
727
728    #[test]
729    fn test_text_view_state_selection_bounds() {
730        assert_eq!(
731            selection_bounds(None, None, Default::default()),
732            Bounds::default()
733        );
734        assert_eq!(
735            selection_bounds(None, Some(point(px(10.), px(20.))), Default::default()),
736            Bounds::default()
737        );
738        assert_eq!(
739            selection_bounds(Some(point(px(10.), px(20.))), None, Default::default()),
740            Bounds::default()
741        );
742
743        // 10,10 start
744        //   |------|
745        //   |      |
746        //   |------|
747        //         50,50
748        assert_eq!(
749            selection_bounds(
750                Some(point(px(10.), px(10.))),
751                Some(point(px(50.), px(50.))),
752                Default::default()
753            ),
754            Bounds {
755                origin: point(px(10.), px(10.)),
756                size: size(px(40.), px(40.))
757            }
758        );
759        // 10,10
760        //   |------|
761        //   |      |
762        //   |------|
763        //         50,50 start
764        assert_eq!(
765            selection_bounds(
766                Some(point(px(50.), px(50.))),
767                Some(point(px(10.), px(10.))),
768                Default::default()
769            ),
770            Bounds {
771                origin: point(px(10.), px(10.)),
772                size: size(px(40.), px(40.))
773            }
774        );
775        //        50,10 start
776        //   |------|
777        //   |      |
778        //   |------|
779        // 10,50
780        assert_eq!(
781            selection_bounds(
782                Some(point(px(50.), px(10.))),
783                Some(point(px(10.), px(50.))),
784                Default::default()
785            ),
786            Bounds {
787                origin: point(px(10.), px(10.)),
788                size: size(px(40.), px(40.))
789            }
790        );
791        //        50,10
792        //   |------|
793        //   |      |
794        //   |------|
795        // 10,50 start
796        assert_eq!(
797            selection_bounds(
798                Some(point(px(10.), px(50.))),
799                Some(point(px(50.), px(10.))),
800                Default::default()
801            ),
802            Bounds {
803                origin: point(px(10.), px(10.)),
804                size: size(px(40.), px(40.))
805            }
806        );
807    }
808}