Skip to main content

rgpui_component/text/
state.rs

1use futures::Stream as _;
2use std::{pin::Pin, task::Poll};
3
4use rgpui::{
5    App, AppContext as _, Bounds, Context, FocusHandle, IntoElement, KeyBinding, ListState,
6    ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window,
7    prelude::FluentBuilder as _, px,
8};
9
10use crate::{
11    ActiveTheme, ElementExt, HighlightTheme,
12    async_util::{Receiver, Sender, unbounded},
13    input::{self, SelectAll},
14    scroll::AutoScroll,
15    text::{
16        CodeBlockActionsFn, TextViewStyle,
17        document::ParsedDocument,
18        format,
19        node::{self, NodeContext},
20    },
21    v_flex,
22};
23
24const CONTEXT: &'static str = "TextView";
25pub(crate) fn init(cx: &mut App) {
26    cx.bind_keys(vec![
27        #[cfg(target_os = "macos")]
28        KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
29        #[cfg(not(target_os = "macos"))]
30        KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
31        #[cfg(target_os = "macos")]
32        KeyBinding::new("cmd-a", input::SelectAll, Some(CONTEXT)),
33        #[cfg(not(target_os = "macos"))]
34        KeyBinding::new("ctrl-a", input::SelectAll, Some(CONTEXT)),
35    ]);
36}
37
38/// The content format of the text view.
39#[derive(Clone, Copy, PartialEq, Eq)]
40pub(super) enum TextViewFormat {
41    /// Markdown view
42    Markdown,
43    /// HTML view
44    Html,
45    /// 纯文本视图
46    Plain,
47}
48
49/// The state of a TextView.
50pub struct TextViewState {
51    pub(super) focus_handle: FocusHandle,
52    pub(super) entity_id: rgpui::EntityId,
53    pub(super) list_state: ListState,
54
55    /// The bounds of the text view
56    bounds: Bounds<Pixels>,
57
58    pub(super) selectable: bool,
59    pub(super) scrollable: bool,
60    pub(super) text_view_style: TextViewStyle,
61    pub(super) code_block_actions: Option<std::sync::Arc<CodeBlockActionsFn>>,
62
63    pub(super) is_selecting: bool,
64    multi_click_selection: Option<TextViewMultiClickSelection>,
65    selected_text_override: Option<String>,
66    select_all: bool,
67    pub(super) auto_scroll: AutoScroll,
68
69    pub(super) parsed_content: ParsedContent,
70    text: String,
71    parsed_error: Option<SharedString>,
72    tx: Sender<UpdateOptions>,
73    _parse_task: Task<()>,
74    _receive_task: Task<()>,
75}
76
77impl TextViewState {
78    /// Create a Markdown TextViewState.
79    pub fn markdown(text: &str, cx: &mut Context<Self>) -> Self {
80        Self::new(TextViewFormat::Markdown, text, cx)
81    }
82
83    /// Create a HTML TextViewState.
84    pub fn html(text: &str, cx: &mut Context<Self>) -> Self {
85        Self::new(TextViewFormat::Html, text, cx)
86    }
87
88    /// 创建一个纯文本 TextViewState。
89    pub fn plain(text: &str, cx: &mut Context<Self>) -> Self {
90        Self::new(TextViewFormat::Plain, text, cx)
91    }
92
93    /// Create a new TextViewState.
94    fn new(format: TextViewFormat, text: &str, cx: &mut Context<Self>) -> Self {
95        let focus_handle = cx.focus_handle();
96        let entity_id = cx.entity_id();
97
98        let (tx, rx) = unbounded::<UpdateOptions>();
99        let (tx_result, rx_result) = unbounded::<Result<ParsedContent, SharedString>>();
100        let _receive_task = cx.spawn({
101            async move |weak_self, cx| {
102                while let Ok(parsed_result) = rx_result.recv().await {
103                    _ = weak_self.update(cx, |state, cx| {
104                        match parsed_result {
105                            Ok(content) => {
106                                state.parsed_content = content;
107                                state.parsed_error = None;
108                            }
109                            Err(err) => {
110                                state.parsed_error = Some(err);
111                            }
112                        }
113                        // Don't interrupt an active drag-selection; the stored
114                        // positions remain valid for append-only updates and will
115                        // self-correct on the next mouse-move event.
116                        if !state.is_selecting {
117                            state.reset_selection();
118                        }
119                        cx.notify();
120                    });
121                }
122            }
123        });
124
125        let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result, cx));
126
127        let mut this = Self {
128            focus_handle,
129            entity_id,
130            bounds: Bounds::default(),
131            multi_click_selection: None,
132            selected_text_override: None,
133            select_all: false,
134            selectable: false,
135            scrollable: false,
136            list_state: ListState::new(0, rgpui::ListAlignment::Top, px(1000.)),
137            text_view_style: TextViewStyle::default(),
138            code_block_actions: None,
139            is_selecting: false,
140            auto_scroll: AutoScroll::default(),
141            parsed_content: Default::default(),
142            parsed_error: None,
143            text: text.to_string(),
144            tx,
145            _parse_task,
146            _receive_task,
147        };
148        this.increment_update(&text, false, cx);
149        this
150    }
151
152    /// Get the text content.
153    pub(crate) fn source(&self) -> SharedString {
154        self.parsed_content.document.source.clone()
155    }
156
157    /// Set whether the text is selectable, default false.
158    pub fn selectable(mut self, selectable: bool) -> Self {
159        self.selectable = selectable;
160        self
161    }
162
163    /// Set whether the text is selectable, default false.
164    pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
165        self.selectable = selectable;
166        cx.notify();
167    }
168
169    /// Set whether the text is selectable, default false.
170    pub fn scrollable(mut self, scrollable: bool) -> Self {
171        self.scrollable = scrollable;
172        self
173    }
174
175    /// Set whether the text is selectable, default false.
176    pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context<Self>) {
177        if !scrollable {
178            self.reset_selection();
179        }
180        self.scrollable = scrollable;
181        cx.notify();
182    }
183
184    /// Set the text content.
185    pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
186        if self.text.as_str() == text {
187            return;
188        }
189
190        self.text.clear();
191        self.text.push_str(text);
192        self.parsed_error = None;
193        self.increment_update(text, false, cx);
194    }
195
196    /// Append partial text content to the existing text.
197    pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
198        if new_text.is_empty() {
199            return;
200        }
201        self.text.push_str(new_text);
202        self.increment_update(new_text, true, cx);
203    }
204
205    /// Return the selected text.
206    pub fn selected_text(&self) -> String {
207        if self.select_all {
208            return self.parsed_content.document.text();
209        }
210
211        if let Some(text) = &self.selected_text_override {
212            return text.clone();
213        }
214
215        self.parsed_content.document.selected_text()
216    }
217
218    fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
219        let update_options = UpdateOptions {
220            append,
221            pending_text: text.to_string(),
222            highlight_theme: cx.theme().highlight_theme.clone(),
223        };
224
225        _ = self.tx.try_send(update_options);
226    }
227
228    /// Save bounds and unselect if bounds changed.
229    pub(super) fn update_bounds(&mut self, bounds: Bounds<Pixels>) {
230        if self.bounds.size != bounds.size {
231            self.reset_selection();
232        }
233        self.bounds = bounds;
234    }
235
236    pub(super) fn bounds(&self) -> Bounds<Pixels> {
237        self.bounds
238    }
239
240    /// Whether this view has a view-local selection (select-all, multi-click, or override),
241    /// independent of the window-level selection.
242    pub(super) fn has_view_selection(&self) -> bool {
243        self.select_all
244            || self.multi_click_selection.is_some()
245            || self.selected_text_override.is_some()
246    }
247
248    pub(super) fn stop_auto_scroll(&mut self) {
249        self.auto_scroll.stop();
250    }
251
252    fn reset_selection(&mut self) {
253        self.multi_click_selection = None;
254        self.selected_text_override = None;
255        self.select_all = false;
256        self.is_selecting = false;
257        self.auto_scroll.stop();
258        // Clear the inline selection state synchronously, so offscreen
259        // (virtualized) views that won't repaint don't leak stale selection
260        // text into a new cross-view copy.
261        self.parsed_content.document.clear_selection();
262    }
263
264    /// Clear the current text selection.
265    pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
266        self.reset_selection();
267        cx.notify();
268    }
269
270    pub(super) fn scroll_offset(&self) -> Point<Pixels> {
271        if self.scrollable {
272            self.list_state.scroll_px_offset_for_scrollbar()
273        } else {
274            Point::default()
275        }
276    }
277
278    /// Select all rendered text in this view.
279    pub fn select_all(&mut self, cx: &mut Context<Self>) {
280        self.multi_click_selection = None;
281        self.selected_text_override = None;
282        self.select_all = true;
283        self.is_selecting = false;
284        self.auto_scroll.stop();
285        cx.notify();
286    }
287
288    pub(crate) fn set_multi_click_selection(
289        &mut self,
290        pos: Point<Pixels>,
291        kind: TextViewMultiClickKind,
292        selected_text: String,
293    ) {
294        let scroll_offset = self.scroll_offset();
295        let pos = pos - self.bounds.origin - scroll_offset;
296        self.multi_click_selection = Some(TextViewMultiClickSelection { pos, kind });
297        self.selected_text_override = Some(selected_text);
298        self.select_all = false;
299        self.is_selecting = false;
300        self.auto_scroll.stop();
301    }
302
303    pub(super) fn set_auto_scroll(&mut self, delta: Option<Pixels>, cx: &mut Context<Self>) {
304        self.auto_scroll.set(delta, cx, |delta, state, cx| {
305            state.list_state.scroll_by(delta);
306            cx.notify();
307        });
308    }
309
310    /// Return the window selection (anchor, cursor) in window coordinates if
311    /// this view participates in it.
312    ///
313    /// Single-view fast path: when both endpoints are anchored inside one
314    /// TextView, only that view participates (identical to the previous
315    /// per-view behavior).
316    pub(crate) fn selection_points(
317        &self,
318        window: &Window,
319        cx: &App,
320    ) -> Option<(Point<Pixels>, Point<Pixels>)> {
321        if !self.selectable {
322            return None;
323        }
324        let root = window.root::<crate::Root>().flatten()?;
325        let selection = &root.read(cx).text_selection;
326        if let Some(view_id) = selection.single_view() {
327            if view_id != self.entity_id {
328                return None;
329            }
330        }
331        selection.resolved_points(cx)
332    }
333
334    pub(crate) fn has_selection(&self, window: &Window, cx: &App) -> bool {
335        self.has_view_selection() || self.selection_points(window, cx).is_some()
336    }
337
338    pub(super) fn on_action_select_all(
339        &mut self,
340        _: &SelectAll,
341        _: &mut Window,
342        cx: &mut Context<Self>,
343    ) {
344        if !self.selectable {
345            cx.propagate();
346            return;
347        }
348
349        self.select_all(cx);
350    }
351
352    pub(crate) fn is_selectable(&self) -> bool {
353        self.selectable
354    }
355
356    pub(crate) fn is_all_selected(&self) -> bool {
357        self.select_all
358    }
359
360    pub(crate) fn multi_click_selection(&self) -> Option<TextViewMultiClickSelection> {
361        let scroll_offset = self.scroll_offset();
362        self.multi_click_selection.map(|selection| {
363            let pos = selection.pos + scroll_offset + self.bounds.origin;
364            TextViewMultiClickSelection { pos, ..selection }
365        })
366    }
367}
368
369#[derive(Clone, Copy, Debug, PartialEq)]
370pub(crate) struct TextViewMultiClickSelection {
371    pub(crate) pos: Point<Pixels>,
372    pub(crate) kind: TextViewMultiClickKind,
373}
374
375#[derive(Clone, Copy, Debug, PartialEq, Eq)]
376pub(crate) enum TextViewMultiClickKind {
377    Word,
378    Paragraph,
379}
380
381impl Render for TextViewState {
382    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
383        let state = cx.entity();
384        let document = self.parsed_content.document.clone();
385        let mut node_cx = self.parsed_content.node_cx.clone();
386
387        node_cx.code_block_actions = self.code_block_actions.clone();
388        node_cx.style = self.text_view_style.clone();
389
390        v_flex()
391            .size_full()
392            .map(|this| match &mut self.parsed_error {
393                None => this.child(document.render_root(
394                    if self.scrollable {
395                        Some(self.list_state.clone())
396                    } else {
397                        None
398                    },
399                    &node_cx,
400                    window,
401                    cx,
402                )),
403                Some(err) => this.child(
404                    v_flex()
405                        .gap_1()
406                        .child("Failed to parse content")
407                        .child(err.to_string()),
408                ),
409            })
410            .on_prepaint(move |bounds, window, cx| {
411                let size_changed = state.read(cx).bounds().size != bounds.size;
412                let id = state.entity_id();
413                state.update(cx, |state, _| {
414                    state.update_bounds(bounds);
415                });
416                if size_changed {
417                    if let Some(root) = window.root::<crate::Root>().flatten() {
418                        root.update(cx, |root, cx| {
419                            root.clear_text_selection_for_resized_view(id, cx);
420                        });
421                    }
422                }
423            })
424    }
425}
426
427#[derive(Clone, PartialEq, Default)]
428pub(crate) struct ParsedContent {
429    pub(crate) document: ParsedDocument,
430    pub(crate) node_cx: node::NodeContext,
431}
432
433struct UpdateFuture {
434    format: TextViewFormat,
435    content: ParsedContent,
436    options: UpdateOptions,
437    pending_text: String,
438    rx: Pin<Box<Receiver<UpdateOptions>>>,
439    tx_result: Sender<Result<ParsedContent, SharedString>>,
440}
441
442impl UpdateFuture {
443    fn new(
444        format: TextViewFormat,
445        rx: Receiver<UpdateOptions>,
446        tx_result: Sender<Result<ParsedContent, SharedString>>,
447        cx: &App,
448    ) -> Self {
449        Self {
450            format,
451            content: Default::default(),
452            pending_text: String::new(),
453            options: UpdateOptions {
454                append: false,
455                pending_text: String::new(),
456                highlight_theme: cx.theme().highlight_theme.clone(),
457            },
458            rx: Box::pin(rx),
459            tx_result,
460        }
461    }
462}
463
464impl Future for UpdateFuture {
465    type Output = ();
466
467    fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
468        loop {
469            match self.rx.as_mut().poll_next(cx) {
470                Poll::Ready(Some(options)) => {
471                    if options.append {
472                        self.pending_text.push_str(options.pending_text.as_str());
473                    } else {
474                        self.pending_text = options.pending_text.clone();
475                    }
476                    self.options = options;
477
478                    // Process immediately without debounce
479                    let pending_text = std::mem::take(&mut self.pending_text);
480                    let options = UpdateOptions {
481                        pending_text,
482                        ..self.options.clone()
483                    };
484                    let res = parse_content(self.format, self.content.clone(), &options);
485                    if let Ok(content) = &res {
486                        self.content = content.clone();
487                    }
488                    _ = self.tx_result.try_send(res);
489                    continue;
490                }
491                Poll::Ready(None) => return Poll::Ready(()),
492                Poll::Pending => return Poll::Pending,
493            }
494        }
495    }
496}
497
498#[derive(Clone)]
499struct UpdateOptions {
500    pending_text: String,
501    append: bool,
502    highlight_theme: std::sync::Arc<HighlightTheme>,
503}
504
505fn parse_content(
506    format: TextViewFormat,
507    mut content: ParsedContent,
508    options: &UpdateOptions,
509) -> Result<ParsedContent, SharedString> {
510    let mut node_cx = NodeContext {
511        ..NodeContext::default()
512    };
513
514    let mut source = String::new();
515    if options.append
516        && let Some(last_block) = content.document.blocks.pop()
517        && let Some(span) = last_block.span()
518    {
519        node_cx.offset = span.start;
520        let last_source = &content.document.source[span.start..];
521        source.push_str(last_source);
522        source.push_str(&options.pending_text);
523    } else {
524        source = options.pending_text.to_string();
525    }
526
527    let new_document = match format {
528        TextViewFormat::Markdown => {
529            format::markdown::parse(&source, &mut node_cx, &options.highlight_theme)
530        }
531        TextViewFormat::Html => format::html::parse(&source, &mut node_cx),
532        TextViewFormat::Plain => format::plain::parse(&source, &mut node_cx),
533    }?;
534
535    if options.append {
536        content.document.source =
537            format!("{}{}", content.document.source, options.pending_text).into();
538        content.document.blocks.extend(new_document.blocks);
539    } else {
540        content.document = new_document;
541    }
542
543    Ok(content)
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use rgpui::TestAppContext;
550
551    #[rgpui::test]
552    fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) {
553        cx.update(crate::init);
554        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
555        cx.run_until_parked();
556
557        state.update(cx, |state, cx| {
558            state.set_text("", cx);
559            state.push_str("new", cx);
560            state.push_str(" text", cx);
561        });
562        cx.run_until_parked();
563
564        state.read_with(cx, |state, _| {
565            assert_eq!(state.text.as_str(), "new text");
566            assert_eq!(state.source().as_str(), "new text");
567        });
568
569        state.update(cx, |state, cx| {
570            state.set_text("", cx);
571        });
572        cx.run_until_parked();
573
574        state.read_with(cx, |state, _| {
575            assert_eq!(state.text.as_str(), "");
576            assert_eq!(state.source().as_str(), "");
577        });
578    }
579
580    #[rgpui::test]
581    fn select_all_returns_rendered_text(cx: &mut TestAppContext) {
582        cx.update(crate::init);
583        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("**quick** value", cx)));
584        cx.run_until_parked();
585
586        state.update(cx, |state, cx| {
587            state.select_all(cx);
588        });
589
590        state.read_with(cx, |state, _| {
591            assert!(state.has_view_selection());
592            assert_eq!(state.selected_text().trim(), "quick value");
593        });
594
595        state.update(cx, |state, cx| {
596            state.clear_selection(cx);
597        });
598
599        state.read_with(cx, |state, _| {
600            assert!(!state.has_view_selection());
601            assert_eq!(state.selected_text(), "");
602        });
603    }
604}