Skip to main content

gpui_base/text/
state.rs

1use futures::Stream as _;
2use std::{
3    ops::RangeInclusive,
4    pin::Pin,
5    sync::{Arc, Mutex},
6    task::Poll,
7};
8
9use gpui::{
10    App, AppContext as _, Bounds, Context, FocusHandle, IntoElement, KeyBinding, ListState,
11    ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window,
12    prelude::FluentBuilder as _, px,
13};
14
15use crate::{
16    AutoScroll, ElementExt, TextSelection,
17    async_util::{Receiver, Sender, unbounded},
18    input::{self, SelectAll},
19    text::{
20        CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions,
21        TableActionsFn, TextViewStyle,
22        document::ParsedDocument,
23        format,
24        node::{self, NodeContext},
25        selection_adapter::TextViewSelectionAdapter,
26    },
27    v_flex,
28};
29
30const CONTEXT: &'static str = "TextView";
31// Keep coalescing bounded so sustained streams still render intermediate updates.
32const MAX_COALESCED_UPDATES_PER_PARSE: usize = 64;
33// Preserve exact first-layout height for small documents while bounding the
34// amount of source parsed synchronously on the UI thread.
35const MAX_SYNC_FULL_REPLACE_BYTES: usize = 4 * 1024;
36
37pub(crate) fn init(cx: &mut App) {
38    cx.bind_keys(vec![
39        #[cfg(target_os = "macos")]
40        KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
41        #[cfg(not(target_os = "macos"))]
42        KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
43        #[cfg(target_os = "macos")]
44        KeyBinding::new("cmd-a", input::SelectAll, Some(CONTEXT)),
45        #[cfg(not(target_os = "macos"))]
46        KeyBinding::new("ctrl-a", input::SelectAll, Some(CONTEXT)),
47    ]);
48}
49
50/// The content format of the text view.
51#[derive(Clone, Copy, PartialEq, Eq)]
52pub(super) enum TextViewFormat {
53    /// Markdown view
54    Markdown,
55    /// HTML view
56    Html,
57}
58
59/// The format of the text returned by
60/// [`TextViewState::selected_text`], which is also what copy writes to the
61/// clipboard.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub enum SelectionFormat {
64    /// The rendered text, without any markup.
65    #[default]
66    Plain,
67    /// The source of the selection.
68    ///
69    /// Select-all returns the original source verbatim, a partial selection is
70    /// reconstructed as Markdown from the parsed nodes (e.g. selecting inside
71    /// a `**bold**` run yields `**bold**`).
72    Source,
73}
74
75/// One text element's laid-out vertical extent, reported by `Inline` during
76/// prepaint so `TextView` can snap its `max_lines` clip to a whole-line
77/// boundary.
78#[derive(Clone, Copy)]
79pub(super) struct LineSpan {
80    pub(super) top: Pixels,
81    pub(super) bottom: Pixels,
82    pub(super) line_height: Pixels,
83}
84
85/// The state of a TextView.
86pub struct TextViewState {
87    pub(super) focus_handle: FocusHandle,
88    pub(super) list_state: ListState,
89
90    /// The bounds of the text view
91    bounds: Bounds<Pixels>,
92
93    pub(super) selectable: bool,
94    pub(super) selection_format: SelectionFormat,
95    pub(super) scrollable: bool,
96    pub(super) max_lines: Option<usize>,
97    /// Line spans reported by `Inline` during prepaint (collected only while
98    /// [`Self::max_lines`] is set); cleared by `TextView` at each frame start.
99    pub(super) line_spans: Arc<Mutex<Vec<LineSpan>>>,
100    /// Whether the last painted frame clipped content due to `max_lines`.
101    pub(super) clamped: bool,
102    pub(super) text_view_style: TextViewStyle,
103    pub(super) code_block_actions: Option<std::sync::Arc<CodeBlockActionsFn>>,
104    pub(super) code_block_highlighter: Option<std::sync::Arc<CodeBlockHighlighterFn>>,
105    pub(super) table_actions: Option<std::sync::Arc<TableActionsFn>>,
106    pub(super) link_click_handler: Option<std::sync::Arc<LinkClickHandlerFn>>,
107    pub(super) markdown_extensions: Arc<MarkdownExtensions>,
108
109    pub(super) is_selecting: bool,
110    multi_click_selection: Option<TextViewMultiClickSelection>,
111    selected_text_override: Option<String>,
112    select_all: bool,
113    pub(super) auto_scroll: AutoScroll,
114    pub(super) selection_adapter: TextViewSelectionAdapter,
115
116    pub(super) parsed_content: ParsedContent,
117    /// Content format (markdown / html), used for bounded synchronous parsing
118    /// of small full-replace updates.
119    format: TextViewFormat,
120    text: String,
121    revision: usize,
122    pub(super) selection_revision: usize,
123    compatible_layout_update: bool,
124    parsed_error: Option<SharedString>,
125    tx: Sender<UpdateOptions>,
126    _parse_task: Task<()>,
127    _receive_task: Task<()>,
128}
129
130impl TextViewState {
131    /// Create a Markdown TextViewState.
132    pub fn markdown(text: &str, cx: &mut Context<Self>) -> Self {
133        Self::new(TextViewFormat::Markdown, text, cx)
134    }
135
136    /// Create a HTML TextViewState.
137    pub fn html(text: &str, cx: &mut Context<Self>) -> Self {
138        Self::new(TextViewFormat::Html, text, cx)
139    }
140
141    /// Create a new TextViewState.
142    fn new(format: TextViewFormat, text: &str, cx: &mut Context<Self>) -> Self {
143        let focus_handle = cx.focus_handle();
144        let selection_adapter = TextViewSelectionAdapter::new(cx.entity().downgrade(), cx);
145
146        let (tx, rx) = unbounded::<UpdateOptions>();
147        let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
148        let _receive_task = cx.spawn({
149            async move |weak_self, cx| {
150                while let Ok(parsed_update) = rx_result.recv().await {
151                    _ = weak_self.update(cx, |state, cx| {
152                        if parsed_update.revision != state.revision {
153                            return;
154                        }
155                        if parsed_update.baseline_ack {
156                            debug_assert!(parsed_update.full_parse);
157                            return;
158                        }
159
160                        match parsed_update.result {
161                            Ok(content) => {
162                                state.parsed_content = content;
163                                state.parsed_error = None;
164                                state.compatible_layout_update = parsed_update.selection_compatible;
165                            }
166                            Err(err) => {
167                                state.parsed_error = Some(err);
168                            }
169                        }
170                        // Don't interrupt an active drag-selection; the stored
171                        // positions remain valid for append-only updates and will
172                        // self-correct on the next mouse-move event.
173                        if !parsed_update.selection_compatible && !state.is_selecting {
174                            state.reset_selection_and_adapter(cx);
175                        }
176                        cx.notify();
177                    });
178                }
179            }
180        });
181
182        let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result));
183
184        let mut this = Self {
185            focus_handle,
186            bounds: Bounds::default(),
187            multi_click_selection: None,
188            selected_text_override: None,
189            select_all: false,
190            selectable: false,
191            selection_format: SelectionFormat::default(),
192            scrollable: false,
193            max_lines: None,
194            line_spans: Arc::default(),
195            clamped: false,
196            // Measure all blocks (not just visible ones) so the scrollbar
197            // thumb size stays stable. Without this, off-screen blocks count
198            // as zero height until scrolled into view, which makes the
199            // scrollbar jitter as more blocks get measured during scrolling.
200            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)).measure_all(),
201            text_view_style: TextViewStyle::default(),
202            code_block_actions: None,
203            code_block_highlighter: None,
204            table_actions: None,
205            link_click_handler: None,
206            markdown_extensions: Arc::default(),
207            is_selecting: false,
208            auto_scroll: AutoScroll::default(),
209            selection_adapter,
210            parsed_content: Default::default(),
211            format,
212            parsed_error: None,
213            text: text.to_string(),
214            revision: 0,
215            selection_revision: 0,
216            compatible_layout_update: false,
217            tx,
218            _parse_task,
219            _receive_task,
220        };
221        this.increment_update(&text, false, cx);
222        this
223    }
224
225    /// Get the text content.
226    pub(crate) fn source(&self) -> SharedString {
227        self.parsed_content.document.source.clone()
228    }
229
230    /// Set whether the text is selectable, default false.
231    pub fn selectable(mut self, selectable: bool) -> Self {
232        self.selectable = selectable;
233        self
234    }
235
236    /// Set whether the text is selectable, default false.
237    pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
238        self.selectable = selectable;
239        cx.notify();
240    }
241
242    /// Set the [`SelectionFormat`], default is [`SelectionFormat::Plain`].
243    pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
244        self.selection_format = selection_format;
245        self
246    }
247
248    /// Set the [`SelectionFormat`], default is [`SelectionFormat::Plain`].
249    pub fn set_selection_format(
250        &mut self,
251        selection_format: SelectionFormat,
252        cx: &mut Context<Self>,
253    ) {
254        self.selection_format = selection_format;
255        cx.notify();
256    }
257
258    /// Set whether the text view scrolls internally, default false.
259    pub fn scrollable(mut self, scrollable: bool) -> Self {
260        self.scrollable = scrollable;
261        self
262    }
263
264    /// Set whether the text view scrolls internally, default false.
265    pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context<Self>) {
266        if !scrollable {
267            self.reset_selection_and_adapter(cx);
268        }
269        self.scrollable = scrollable;
270        cx.notify();
271    }
272
273    /// Whether the last painted frame clipped content because of
274    /// [`TextView::max_lines`](crate::text::TextView::max_lines).
275    pub fn is_clamped(&self) -> bool {
276        self.clamped
277    }
278
279    /// Set the text content.
280    pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
281        if self.text.as_str() == text {
282            return;
283        }
284
285        self.text.clear();
286        self.text.push_str(text);
287        self.parsed_error = None;
288        self.increment_update(text, false, cx);
289    }
290
291    /// Append partial text content to the existing text.
292    pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
293        if new_text.is_empty() {
294            return;
295        }
296        self.text.push_str(new_text);
297        self.increment_update(new_text, true, cx);
298    }
299
300    pub(crate) fn set_markdown_extensions(
301        &mut self,
302        markdown_extensions: Arc<MarkdownExtensions>,
303        cx: &mut Context<Self>,
304    ) {
305        if self.markdown_extensions.revision() == markdown_extensions.revision() {
306            return;
307        }
308
309        let parser_configuration_changed = !self
310            .markdown_extensions
311            .has_same_parser_configuration(&markdown_extensions);
312        self.markdown_extensions = markdown_extensions;
313        if parser_configuration_changed && self.format == TextViewFormat::Markdown {
314            let text = self.text.clone();
315            self.increment_update(&text, false, cx);
316        }
317    }
318
319    /// Return the selected text, in the view's [`SelectionFormat`].
320    pub fn selected_text(&self) -> String {
321        self.selected_text_in(None)
322    }
323
324    /// The format to copy in, which is [`SelectionFormat::Plain`] whenever the
325    /// requested one cannot be produced.
326    ///
327    /// Only a Markdown view can return source. Reconstructing HTML would mean
328    /// spelling every attribute back out — a mark's color, an image's
329    /// dimensions, a cell's alignment — with a new way to lose one at each
330    /// step, and html5ever records no source offsets to fall back on (it
331    /// reports only line numbers), so there is no original text to copy from
332    /// either.
333    fn effective_format(&self) -> SelectionFormat {
334        match self.format {
335            TextViewFormat::Markdown => self.selection_format,
336            TextViewFormat::Html => SelectionFormat::Plain,
337        }
338    }
339
340    /// Return the selected text, with `blocks` bounding which top-level blocks
341    /// the selection covers.
342    ///
343    /// The range comes from the selection endpoints, which know their block
344    /// even after it scrolls out of view; see
345    /// [`ParsedDocument::selected_text`](crate::text::document::ParsedDocument).
346    pub(super) fn selected_text_in(&self, blocks: Option<RangeInclusive<usize>>) -> String {
347        let format = self.effective_format();
348
349        if self.select_all {
350            if format == SelectionFormat::Source {
351                return self.source().to_string();
352            }
353
354            return self.parsed_content.document.text();
355        }
356
357        // A multi-click stores the plain text it selected, which is a shortcut
358        // past the block walk. Source mode cannot take it: the word it stored
359        // has lost its markup. The click also set the inline selection it came
360        // from, so the walk reconstructs the same range with the markup intact.
361        if format != SelectionFormat::Source
362            && let Some(text) = &self.selected_text_override
363        {
364            return text.clone();
365        }
366
367        self.parsed_content.document.selected_text(format, blocks)
368    }
369
370    fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
371        self.revision += 1;
372        if !append {
373            self.selection_revision = self.selection_revision.wrapping_add(1);
374        }
375        let parse_synchronously = !append && text.len() <= MAX_SYNC_FULL_REPLACE_BYTES;
376        let update_options = UpdateOptions {
377            revision: self.revision,
378            append,
379            mode: if append {
380                ParseMode::Compatible
381            } else if parse_synchronously {
382                ParseMode::BaselineAck
383            } else {
384                ParseMode::Replace
385            },
386            pending_text: text.to_string(),
387            markdown_extensions: self.markdown_extensions.clone(),
388        };
389
390        // Keep small full replacements synchronous so their first layout has
391        // the exact content height. Larger replacements use the existing
392        // background parser, bounding synchronous parser input on the UI thread.
393        if parse_synchronously {
394            match parse_content(self.format, ParsedContent::default(), &update_options) {
395                Ok(content) => {
396                    self.parsed_content = content;
397                    self.parsed_error = None;
398                    if !self.is_selecting {
399                        self.reset_selection_and_adapter(cx);
400                    }
401                }
402                Err(err) => {
403                    self.parsed_error = Some(err);
404                }
405            }
406            // Keep the background parser's accumulated document in sync so a
407            // later append extends this baseline instead of parsing the delta
408            // as a standalone document.
409            _ = self.tx.try_send(update_options);
410            cx.notify();
411            return;
412        }
413
414        _ = self.tx.try_send(update_options);
415    }
416
417    /// Save bounds and unselect if bounds changed.
418    pub(super) fn update_bounds(&mut self, bounds: Bounds<Pixels>, _cx: &mut App) {
419        self.bounds = bounds;
420    }
421
422    /// The index of the top-level block at `content_y`, in this view's content
423    /// coordinates (the same space the base selection endpoint stores its point in).
424    ///
425    /// Only laid-out blocks can be located, which is enough for a selection
426    /// endpoint: the user can only put one where they can see it. Returns
427    /// `None` for a view that is not virtualized, where every block paints and
428    /// the range is not needed.
429    pub(super) fn block_ix_at(&self, content_y: Pixels) -> Option<usize> {
430        if !self.scrollable {
431            return None;
432        }
433
434        let origin = self.bounds.origin.y + self.scroll_offset().y;
435        let count = self.list_state.item_count();
436        let mut ix = self.list_state.logical_scroll_top().item_ix;
437        while ix < count {
438            let bounds = self.list_state.bounds_for_item(ix)?;
439            if content_y < bounds.bottom() - origin {
440                return Some(ix);
441            }
442            ix += 1;
443        }
444
445        count.checked_sub(1)
446    }
447
448    #[doc(hidden)]
449    pub fn bounds(&self) -> Bounds<Pixels> {
450        self.bounds
451    }
452
453    #[doc(hidden)]
454    pub fn list_state(&self) -> &ListState {
455        &self.list_state
456    }
457
458    #[doc(hidden)]
459    pub fn is_selecting(&self) -> bool {
460        self.is_selecting
461    }
462
463    #[doc(hidden)]
464    pub fn focus_handle(&self) -> &FocusHandle {
465        &self.focus_handle
466    }
467
468    /// Whether this view has a view-local selection (select-all, multi-click, or override),
469    /// independent of the window-level selection.
470    pub(super) fn has_view_selection(&self) -> bool {
471        self.select_all
472            || self.multi_click_selection.is_some()
473            || self.selected_text_override.is_some()
474    }
475
476    pub(super) fn stop_auto_scroll(&mut self) {
477        self.auto_scroll.stop();
478    }
479
480    pub(super) fn reset_selection(&mut self) {
481        self.multi_click_selection = None;
482        self.selected_text_override = None;
483        self.select_all = false;
484        self.is_selecting = false;
485        self.auto_scroll.stop();
486        // Clear the inline selection state synchronously, so offscreen
487        // (virtualized) views that won't repaint don't leak stale selection
488        // text into a new cross-view copy.
489        self.parsed_content.document.clear_selection();
490    }
491
492    fn reset_selection_and_adapter(&mut self, cx: &mut App) {
493        self.reset_selection();
494        self.selection_adapter.set_local_selection(false, cx);
495    }
496
497    /// Clear the current text selection.
498    pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
499        self.reset_selection_and_adapter(cx);
500        cx.notify();
501    }
502
503    pub(super) fn scroll_offset(&self) -> Point<Pixels> {
504        if self.scrollable {
505            self.list_state.scroll_px_offset_for_scrollbar()
506        } else {
507            Point::default()
508        }
509    }
510
511    /// Select all rendered text in this view.
512    pub fn select_all(&mut self, cx: &mut Context<Self>) {
513        self.multi_click_selection = None;
514        self.selected_text_override = None;
515        self.select_all = true;
516        self.is_selecting = false;
517        self.auto_scroll.stop();
518        self.selection_adapter.set_local_selection(true, cx);
519        cx.notify();
520    }
521
522    pub(crate) fn set_multi_click_selection(
523        &mut self,
524        pos: Point<Pixels>,
525        kind: TextViewMultiClickKind,
526        selected_text: String,
527        cx: &mut App,
528    ) {
529        let scroll_offset = self.scroll_offset();
530        let pos = pos - self.bounds.origin - scroll_offset;
531        self.multi_click_selection = Some(TextViewMultiClickSelection { pos, kind });
532        self.selected_text_override = Some(selected_text);
533        self.select_all = false;
534        self.is_selecting = false;
535        self.auto_scroll.stop();
536        self.selection_adapter.set_local_selection(true, cx);
537    }
538
539    pub(super) fn set_auto_scroll(&mut self, delta: Option<Pixels>, cx: &mut Context<Self>) {
540        self.auto_scroll.set(delta, cx, |delta, state, cx| {
541            state.list_state.scroll_by(delta);
542            cx.notify();
543        });
544    }
545
546    /// Return the window selection (anchor, cursor) in window coordinates if
547    /// this view participates in it.
548    ///
549    /// Single-view fast path: when both endpoints are anchored inside one
550    /// TextView, only that view participates (identical to the previous
551    /// per-view behavior).
552    pub(crate) fn selection_points(&self, cx: &App) -> Option<(Point<Pixels>, Point<Pixels>)> {
553        if !self.selectable {
554            return None;
555        }
556        self.selection_adapter.selection_points(cx)
557    }
558
559    pub(crate) fn has_selection(&self, cx: &App) -> bool {
560        self.has_view_selection() || self.selection_points(cx).is_some()
561    }
562
563    pub(super) fn on_action_select_all(
564        &mut self,
565        _: &SelectAll,
566        _: &mut Window,
567        cx: &mut Context<Self>,
568    ) {
569        if !self.selectable {
570            cx.propagate();
571            return;
572        }
573
574        self.select_all(cx);
575    }
576
577    pub(crate) fn is_selectable(&self) -> bool {
578        self.selectable
579    }
580
581    pub(crate) fn is_all_selected(&self) -> bool {
582        self.select_all
583    }
584
585    pub(crate) fn multi_click_selection(&self) -> Option<TextViewMultiClickSelection> {
586        let scroll_offset = self.scroll_offset();
587        self.multi_click_selection.map(|selection| {
588            let pos = selection.pos + scroll_offset + self.bounds.origin;
589            TextViewMultiClickSelection { pos, ..selection }
590        })
591    }
592}
593
594#[derive(Clone, Copy, Debug, PartialEq)]
595pub(crate) struct TextViewMultiClickSelection {
596    pub(crate) pos: Point<Pixels>,
597    pub(crate) kind: TextViewMultiClickKind,
598}
599
600#[derive(Clone, Copy, Debug, PartialEq, Eq)]
601pub(crate) enum TextViewMultiClickKind {
602    Word,
603    Paragraph,
604}
605
606impl Render for TextViewState {
607    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
608        let state = cx.entity();
609        let document = self.parsed_content.document.clone();
610        let mut node_cx = self.parsed_content.node_cx.clone();
611
612        node_cx.code_block_actions = self.code_block_actions.clone();
613        node_cx.code_block_highlighter = self.code_block_highlighter.clone();
614        node_cx.table_actions = self.table_actions.clone();
615        node_cx.link_click_handler = self.link_click_handler.clone();
616        node_cx.markdown_extensions = self.markdown_extensions.clone();
617        node_cx.style = self.text_view_style.clone();
618
619        v_flex()
620            .w_full()
621            // Clamped content must keep its natural height: stretching it to
622            // the capped box would hide the overflow the clamp has to measure.
623            .when(self.max_lines.is_none(), |this| this.h_full())
624            .map(|this| match &mut self.parsed_error {
625                None => this.child(document.render_root(
626                    if self.scrollable {
627                        Some(self.list_state.clone())
628                    } else {
629                        None
630                    },
631                    &node_cx,
632                    window,
633                    cx,
634                )),
635                Some(err) => this.child(
636                    v_flex()
637                        .gap_1()
638                        .child("Failed to parse content")
639                        .child(err.to_string()),
640                ),
641            })
642            .on_prepaint(move |bounds, window, cx| {
643                let (
644                    size_changed,
645                    selection_involves_view,
646                    has_selection_snapshot,
647                    is_selecting,
648                    compatible_layout_update,
649                ) = {
650                    let state = state.read(cx);
651                    (
652                        state.bounds().size != bounds.size,
653                        state.selection_adapter.is_part_of_window_selection(cx),
654                        state.selection_adapter.has_selection_snapshot(cx),
655                        state.is_selecting,
656                        state.compatible_layout_update,
657                    )
658                };
659                let mut revision_changed = false;
660                state.update(cx, |state, cx| {
661                    revision_changed = state
662                        .selection_adapter
663                        .update_layout_revision(state.selection_revision, state.is_selecting);
664                    state.update_bounds(bounds, cx);
665                    state.compatible_layout_update = false;
666                });
667                if !is_selecting
668                    && ((size_changed && selection_involves_view && !compatible_layout_update)
669                        || (revision_changed && has_selection_snapshot))
670                {
671                    TextSelection::clear(window, cx);
672                }
673            })
674    }
675}
676
677#[derive(Clone, PartialEq, Default)]
678pub(crate) struct ParsedContent {
679    pub(crate) document: ParsedDocument,
680    pub(crate) node_cx: node::NodeContext,
681}
682
683struct UpdateFuture {
684    format: TextViewFormat,
685    content: ParsedContent,
686    rx: Pin<Box<Receiver<UpdateOptions>>>,
687    tx_result: Sender<ParsedUpdate>,
688}
689
690impl UpdateFuture {
691    fn new(
692        format: TextViewFormat,
693        rx: Receiver<UpdateOptions>,
694        tx_result: Sender<ParsedUpdate>,
695    ) -> Self {
696        Self {
697            format,
698            content: Default::default(),
699            rx: Box::pin(rx),
700            tx_result,
701        }
702    }
703}
704
705impl Future for UpdateFuture {
706    type Output = ();
707
708    fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
709        loop {
710            match self.rx.as_mut().poll_next(cx) {
711                Poll::Ready(Some(mut options)) => {
712                    let hit_coalesce_budget =
713                        merge_pending_options(&mut options, self.rx.as_ref().get_ref());
714
715                    let res = parse_content(self.format, self.content.clone(), &options);
716                    if let Ok(content) = &res {
717                        self.content = content.clone();
718                    }
719                    _ = self.tx_result.try_send(ParsedUpdate {
720                        revision: options.revision,
721                        full_parse: !options.append,
722                        selection_compatible: options.mode == ParseMode::Compatible,
723                        baseline_ack: options.mode == ParseMode::BaselineAck,
724                        result: res,
725                    });
726                    if hit_coalesce_budget {
727                        cx.waker().wake_by_ref();
728                        return Poll::Pending;
729                    }
730                    continue;
731                }
732                Poll::Ready(None) => return Poll::Ready(()),
733                Poll::Pending => return Poll::Pending,
734            }
735        }
736    }
737}
738
739#[derive(Clone)]
740struct UpdateOptions {
741    revision: usize,
742    pending_text: String,
743    append: bool,
744    mode: ParseMode,
745    markdown_extensions: Arc<MarkdownExtensions>,
746}
747
748impl UpdateOptions {
749    fn merge(&mut self, next: UpdateOptions) {
750        if next.append {
751            self.pending_text.push_str(&next.pending_text);
752            self.revision = next.revision;
753            if self.mode != ParseMode::Replace {
754                self.mode = ParseMode::Compatible;
755            }
756        } else {
757            *self = next;
758        }
759    }
760}
761
762struct ParsedUpdate {
763    revision: usize,
764    full_parse: bool,
765    selection_compatible: bool,
766    baseline_ack: bool,
767    result: Result<ParsedContent, SharedString>,
768}
769
770#[derive(Clone, Copy, Debug, PartialEq, Eq)]
771enum ParseMode {
772    BaselineAck,
773    Replace,
774    Compatible,
775}
776
777fn merge_pending_options(options: &mut UpdateOptions, rx: &Receiver<UpdateOptions>) -> bool {
778    let mut update_count = 1;
779
780    while update_count < MAX_COALESCED_UPDATES_PER_PARSE {
781        match rx.try_recv() {
782            Ok(next_options) => {
783                options.merge(next_options);
784                update_count += 1;
785            }
786            Err(_) => return false,
787        }
788    }
789
790    true
791}
792
793fn parse_content(
794    format: TextViewFormat,
795    mut content: ParsedContent,
796    options: &UpdateOptions,
797) -> Result<ParsedContent, SharedString> {
798    let mut node_cx = NodeContext {
799        markdown_extensions: options.markdown_extensions.clone(),
800        ..NodeContext::default()
801    };
802
803    // Re-parse the last block together with the appended text, so a block the
804    // new text continues (an unclosed list, a fenced code block) is not split
805    // in two. A block without a span cannot be located in `source` — the HTML
806    // parser never records spans — so it is left in place and only the
807    // appended text is parsed, positioned at the end of the current source.
808    let last_span = options
809        .append
810        .then(|| {
811            content
812                .document
813                .blocks
814                .last()
815                .and_then(|block| block.span())
816        })
817        .flatten();
818
819    let mut source = String::new();
820    if let Some(span) = last_span {
821        Arc::make_mut(&mut content.document.blocks).pop();
822        node_cx.offset = span.start;
823        source.push_str(&content.document.source[span.start..]);
824        source.push_str(&options.pending_text);
825    } else {
826        if options.append {
827            node_cx.offset = content.document.source.len();
828        }
829        source.push_str(&options.pending_text);
830    }
831
832    let new_document = match format {
833        TextViewFormat::Markdown => format::markdown::parse(&source, &mut node_cx),
834        TextViewFormat::Html => format::html::parse(&source, &mut node_cx),
835    }?;
836
837    if options.append {
838        content.document.source =
839            format!("{}{}", content.document.source, options.pending_text).into();
840        Arc::make_mut(&mut content.document.blocks)
841            .extend(Arc::unwrap_or_clone(new_document.blocks));
842    } else {
843        content.document = new_document;
844    }
845
846    Ok(content)
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use crate::text::MarkdownNode;
853    use gpui::TestAppContext;
854
855    #[gpui::test]
856    fn small_full_replace_parses_before_background_executor_runs(cx: &mut TestAppContext) {
857        cx.update(crate::init);
858        let markdown = "# ready";
859        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
860
861        state.read_with(cx, |state, _| {
862            assert_eq!(state.source().as_str(), markdown);
863            assert_eq!(state.parsed_content.document.blocks.len(), 1);
864        });
865    }
866
867    #[gpui::test]
868    fn large_markdown_and_html_full_replacements_wait_for_background_executor(
869        cx: &mut TestAppContext,
870    ) {
871        cx.update(crate::init);
872        let markdown = "# x\n\n".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1);
873        let html = format!("<p>{}</p>", "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1));
874        assert!(markdown.len() > MAX_SYNC_FULL_REPLACE_BYTES);
875        assert!(html.len() > MAX_SYNC_FULL_REPLACE_BYTES);
876
877        let (markdown_state, html_state) = cx.update(|cx| {
878            (
879                cx.new(|cx| TextViewState::markdown(&markdown, cx)),
880                cx.new(|cx| TextViewState::html(&html, cx)),
881            )
882        });
883
884        markdown_state.read_with(cx, |state, _| {
885            assert_eq!(state.text.as_str(), markdown.as_str());
886            assert!(state.source().as_str().is_empty());
887            assert!(state.parsed_content.document.blocks.is_empty());
888        });
889        html_state.read_with(cx, |state, _| {
890            assert_eq!(state.text.as_str(), html.as_str());
891            assert!(state.source().as_str().is_empty());
892            assert!(state.parsed_content.document.blocks.is_empty());
893        });
894
895        cx.run_until_parked();
896
897        markdown_state.read_with(cx, |state, _| {
898            assert_eq!(state.source().as_str(), markdown.as_str());
899            assert!(!state.parsed_content.document.blocks.is_empty());
900        });
901        html_state.read_with(cx, |state, _| {
902            assert_eq!(state.source().as_str(), html.as_str());
903            assert!(!state.parsed_content.document.blocks.is_empty());
904        });
905    }
906
907    #[gpui::test]
908    fn async_full_replace_then_push_str_preserves_complete_source(cx: &mut TestAppContext) {
909        cx.update(crate::init);
910        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
911        cx.run_until_parked();
912
913        let replacement = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
914        let expected = format!("{replacement} tail");
915        state.update(cx, |state, cx| {
916            state.set_text(&replacement, cx);
917            state.push_str(" tail", cx);
918        });
919        cx.run_until_parked();
920
921        state.read_with(cx, |state, _| {
922            assert_eq!(state.text.as_str(), expected.as_str());
923            assert_eq!(state.source().as_str(), expected.as_str());
924        });
925    }
926
927    #[gpui::test]
928    fn html_push_str_keeps_earlier_blocks(cx: &mut TestAppContext) {
929        cx.update(crate::init);
930        let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>first</p>", cx)));
931        cx.run_until_parked();
932
933        state.update(cx, |state, cx| {
934            state.push_str("<p>second</p>", cx);
935        });
936        cx.run_until_parked();
937
938        state.read_with(cx, |state, _| {
939            assert_eq!(state.source().as_str(), "<p>first</p><p>second</p>");
940            let text = state
941                .parsed_content
942                .document
943                .blocks
944                .iter()
945                .map(|block| block.text())
946                .collect::<String>();
947            assert!(text.contains("first"), "lost the first block: {text:?}");
948            assert!(text.contains("second"), "lost the appended block: {text:?}");
949        });
950    }
951
952    #[gpui::test]
953    fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) {
954        cx.update(crate::init);
955        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
956        cx.run_until_parked();
957
958        state.update(cx, |state, cx| {
959            state.set_text("", cx);
960            state.push_str("new", cx);
961            state.push_str(" text", cx);
962        });
963        cx.run_until_parked();
964
965        state.read_with(cx, |state, _| {
966            assert_eq!(state.text.as_str(), "new text");
967            assert_eq!(state.source().as_str(), "new text");
968        });
969
970        state.update(cx, |state, cx| {
971            state.set_text("", cx);
972        });
973        cx.run_until_parked();
974
975        state.read_with(cx, |state, _| {
976            assert_eq!(state.text.as_str(), "");
977            assert_eq!(state.source().as_str(), "");
978        });
979    }
980
981    #[gpui::test]
982    fn full_parse_coalesced_with_append_preserves_new_select_all(cx: &mut TestAppContext) {
983        cx.update(crate::init);
984        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
985        cx.run_until_parked();
986
987        state.update(cx, |state, cx| {
988            state.set_text("new", cx);
989            state.push_str(" text", cx);
990            state.select_all(cx);
991        });
992        cx.run_until_parked();
993
994        state.read_with(cx, |state, _| {
995            assert!(state.select_all);
996            assert_eq!(state.selected_text().trim(), "new text");
997        });
998    }
999
1000    #[test]
1001    fn update_options_merge_keeps_latest_full_text() {
1002        let mut options = UpdateOptions {
1003            revision: 1,
1004            pending_text: "old".to_string(),
1005            append: true,
1006            mode: ParseMode::Compatible,
1007            markdown_extensions: Arc::default(),
1008        };
1009
1010        options.merge(UpdateOptions {
1011            revision: 2,
1012            pending_text: "new".to_string(),
1013            append: false,
1014            mode: ParseMode::BaselineAck,
1015            markdown_extensions: Arc::default(),
1016        });
1017        options.merge(UpdateOptions {
1018            revision: 3,
1019            pending_text: " text".to_string(),
1020            append: true,
1021            mode: ParseMode::Compatible,
1022            markdown_extensions: Arc::default(),
1023        });
1024
1025        assert_eq!(options.revision, 3);
1026        assert_eq!(options.pending_text, "new text");
1027        assert!(!options.append);
1028    }
1029
1030    #[test]
1031    fn append_merged_into_async_replace_remains_a_replacement() {
1032        let mut options = UpdateOptions {
1033            revision: 1,
1034            pending_text: "new".to_string(),
1035            append: false,
1036            mode: ParseMode::Replace,
1037            markdown_extensions: Arc::default(),
1038        };
1039
1040        options.merge(UpdateOptions {
1041            revision: 2,
1042            pending_text: " text".to_string(),
1043            append: true,
1044            mode: ParseMode::Compatible,
1045            markdown_extensions: Arc::default(),
1046        });
1047
1048        assert_eq!(options.revision, 2);
1049        assert_eq!(options.pending_text, "new text");
1050        assert!(!options.append);
1051        assert_eq!(options.mode, ParseMode::Replace);
1052    }
1053
1054    #[test]
1055    fn update_future_yields_before_coalescing_all_queued_updates() {
1056        let (tx, rx) = unbounded::<UpdateOptions>();
1057        let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
1058        let total_updates = 128;
1059
1060        for revision in 1..=total_updates {
1061            tx.try_send(UpdateOptions {
1062                revision,
1063                pending_text: format!("{revision}\n"),
1064                append: revision != 1,
1065                mode: if revision == 1 {
1066                    ParseMode::BaselineAck
1067                } else {
1068                    ParseMode::Compatible
1069                },
1070                markdown_extensions: Arc::default(),
1071            })
1072            .unwrap();
1073        }
1074
1075        let mut future = Box::pin(UpdateFuture::new(TextViewFormat::Markdown, rx, tx_result));
1076        let waker = futures::task::noop_waker();
1077        let mut task_cx = std::task::Context::from_waker(&waker);
1078
1079        assert!(matches!(
1080            std::future::Future::poll(future.as_mut(), &mut task_cx),
1081            Poll::Pending
1082        ));
1083        let parsed_update = rx_result.try_recv().expect("parse result");
1084
1085        assert!(
1086            parsed_update.revision < total_updates,
1087            "single poll coalesced every queued update through revision {}",
1088            parsed_update.revision
1089        );
1090
1091        assert!(matches!(
1092            std::future::Future::poll(future.as_mut(), &mut task_cx),
1093            Poll::Pending
1094        ));
1095        let parsed_update = rx_result.try_recv().expect("next parse result");
1096        assert_eq!(parsed_update.revision, total_updates);
1097    }
1098
1099    #[gpui::test]
1100    fn select_all_returns_rendered_text(cx: &mut TestAppContext) {
1101        cx.update(crate::init);
1102        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("**quick** value", cx)));
1103        cx.run_until_parked();
1104
1105        state.update(cx, |state, cx| {
1106            state.select_all(cx);
1107        });
1108
1109        state.read_with(cx, |state, _| {
1110            assert!(state.has_view_selection());
1111            assert_eq!(state.selected_text().trim(), "quick value");
1112        });
1113
1114        state.update(cx, |state, cx| {
1115            state.clear_selection(cx);
1116        });
1117
1118        state.read_with(cx, |state, _| {
1119            assert!(!state.has_view_selection());
1120            assert_eq!(state.selected_text(), "");
1121        });
1122    }
1123
1124    #[gpui::test]
1125    fn select_all_in_source_format_returns_source(cx: &mut TestAppContext) {
1126        cx.update(crate::init);
1127        let markdown = "**quick** value";
1128        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1129        cx.run_until_parked();
1130
1131        state.update(cx, |state, cx| state.select_all(cx));
1132
1133        // The default (plain) mode strips the markup.
1134        state.read_with(cx, |state, _| {
1135            assert_eq!(state.selected_text().trim(), "quick value");
1136        });
1137
1138        state.update(cx, |state, cx| {
1139            state.set_selection_format(SelectionFormat::Source, cx)
1140        });
1141
1142        // Source mode yields the whole source verbatim.
1143        state.read_with(cx, |state, _| {
1144            assert_eq!(state.selected_text().trim(), markdown);
1145        });
1146    }
1147
1148    #[gpui::test]
1149    fn set_markdown_extensions_reparses_existing_text(cx: &mut TestAppContext) {
1150        cx.update(crate::init);
1151        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("$TSLA.US", cx)));
1152        cx.run_until_parked();
1153
1154        let extensions = MarkdownExtensions::default().block_parser(|node, cx| {
1155            let markdown::mdast::Node::Paragraph(paragraph) = node else {
1156                return None;
1157            };
1158            let [markdown::mdast::Node::Text(text)] = paragraph.children.as_slice() else {
1159                return None;
1160            };
1161            let symbol = text.value.strip_prefix('$')?.to_string();
1162            let node_text = format!("${symbol}");
1163
1164            Some(
1165                MarkdownNode::new("ticker", symbol)
1166                    .text(node_text)
1167                    .markdown(cx.node_source(node).unwrap_or_default()),
1168            )
1169        });
1170
1171        state.update(cx, |state, cx| {
1172            state.set_markdown_extensions(Arc::new(extensions), cx);
1173        });
1174        cx.run_until_parked();
1175
1176        state.read_with(cx, |state, _| {
1177            let node::BlockNode::Custom(node) = &state.parsed_content.document.blocks[0] else {
1178                panic!("expected custom markdown node");
1179            };
1180            assert_eq!(node.name(), "ticker");
1181            assert_eq!(node.data::<String>().map(String::as_str), Some("TSLA.US"));
1182        });
1183    }
1184}