Skip to main content

gpui_base/text/
text_view.rs

1use std::{ops::Range, sync::Arc};
2
3use gpui::prelude::FluentBuilder as _;
4use gpui::{
5    AnyElement, App, Bounds, ClickEvent, ContentMask, Element, ElementId, Entity, Global,
6    GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
7    LayoutId, MouseButton, ParentElement, Pixels, Refineable as _, SharedString, StyleRefinement,
8    Styled, Window, div, point, px,
9};
10
11use crate::StyledExt;
12use crate::text::TextViewFormat;
13use crate::text::markdown_ext::{MarkdownExtensions, MarkdownNode, MarkdownPlugin};
14use crate::text::node::{CodeBlock, TableData};
15use crate::text::state::{LineSpan, SelectionFormat, TextViewState};
16use crate::text::stream_fade::TextViewMotion;
17use crate::{GlobalState, TextSelection, text::TextViewStyle};
18
19/// Type for code block actions generator function.
20pub(crate) type CodeBlockActionsFn =
21    dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync;
22
23pub(crate) type CodeBlockHighlighterFn =
24    dyn Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync;
25
26/// Application-wide defaults for TextViews that do not provide explicit
27/// presentation or syntax-highlighting overrides.
28#[derive(Clone, Default)]
29pub struct TextViewDefaults {
30    style: Option<TextViewStyle>,
31    code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
32}
33
34impl Global for TextViewDefaults {}
35
36impl TextViewDefaults {
37    /// Creates defaults that leave every text view as Base renders it.
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Sets the style every text view starts from.
43    pub fn with_style(mut self, style: TextViewStyle) -> Self {
44        self.style = Some(style);
45        self
46    }
47
48    /// Sets the syntax highlighter used for fenced code blocks.
49    pub fn with_code_block_highlighter<F>(mut self, highlighter: F) -> Self
50    where
51        F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
52    {
53        self.code_block_highlighter = Some(Arc::new(highlighter));
54        self
55    }
56
57    /// Installs these defaults for the whole application.
58    pub fn install(self, cx: &mut App) {
59        cx.set_global(self);
60    }
61
62    /// Returns the installed defaults, or the Base ones when none were.
63    pub fn global(cx: &App) -> Self {
64        cx.try_global::<Self>().cloned().unwrap_or_default()
65    }
66
67    /// Whether a syntax highlighter was installed.
68    pub fn has_code_block_highlighter(&self) -> bool {
69        self.code_block_highlighter.is_some()
70    }
71}
72
73/// Type for the table actions generator function.
74pub(crate) type TableActionsFn =
75    dyn Fn(&TableData, &mut Window, &mut App) -> AnyElement + Send + Sync;
76
77pub(crate) type LinkClickHandlerFn =
78    dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync;
79
80pub(crate) fn handle_link_click(
81    handler: &Option<Arc<LinkClickHandlerFn>>,
82    url: SharedString,
83    event: ClickEvent,
84    window: &mut Window,
85    cx: &mut App,
86) {
87    if let Some(handler) = handler {
88        handler(&url, &event, window, cx);
89    } else if match &event {
90        ClickEvent::Mouse(click) => {
91            matches!(click.up.button, MouseButton::Left | MouseButton::Middle)
92        }
93        ClickEvent::Keyboard(_) => true,
94        ClickEvent::Touch(click) => !click.long_press,
95    } {
96        cx.open_url(&url);
97    }
98}
99
100/// A text view that can render Markdown or HTML.
101///
102/// ## Goals
103///
104/// - Provide a rich text rendering component for such as Markdown or HTML,
105/// used to display rich text in GPUI application (e.g., Help messages, Release notes)
106/// - Support Markdown GFM and HTML (Simple HTML like Safari Reader Mode) for showing most common used markups.
107/// - Support Heading, Paragraph, Bold, Italic, StrikeThrough, Code, Link, Image, Blockquote, List, Table, HorizontalRule, CodeBlock ...
108///
109/// ## Not Goals
110///
111/// - Customization of the complex style (some simple styles will be supported)
112/// - As a Markdown editor or viewer (If you want to like this, you must fork your version).
113/// - As a HTML viewer, we not support CSS, we only support basic HTML tags for used to as a content reader.
114///
115/// See also [`MarkdownElement`], [`HtmlElement`]
116#[derive(Clone)]
117pub struct TextView {
118    id: ElementId,
119    format: Option<TextViewFormat>,
120    text: Option<SharedString>,
121    pub(crate) state: Option<Entity<TextViewState>>,
122    text_view_style: Option<TextViewStyle>,
123    style: StyleRefinement,
124    selectable: bool,
125    selection_format: SelectionFormat,
126    scrollable: bool,
127    max_lines: Option<usize>,
128    code_block_actions: Option<Arc<CodeBlockActionsFn>>,
129    code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
130    table_actions: Option<Arc<TableActionsFn>>,
131    link_click_handler: Option<Arc<LinkClickHandlerFn>>,
132    markdown_extensions: Arc<MarkdownExtensions>,
133    motion: Option<TextViewMotion>,
134}
135
136/// A plugin that can configure a [`TextView`].
137pub trait TextViewPlugin {
138    fn setup(self, text_view: TextView) -> TextView;
139}
140
141impl<P> TextViewPlugin for P
142where
143    P: MarkdownPlugin,
144{
145    fn setup(self, mut text_view: TextView) -> TextView {
146        let extensions = Arc::make_mut(&mut text_view.markdown_extensions);
147        let current = std::mem::take(extensions);
148        *extensions = current.plugin(self);
149        text_view
150    }
151}
152
153impl Styled for TextView {
154    fn style(&mut self) -> &mut StyleRefinement {
155        &mut self.style
156    }
157}
158
159impl TextView {
160    /// Create new TextView with managed state.
161    pub fn new(state: &Entity<TextViewState>) -> Self {
162        Self {
163            id: ElementId::Name(state.entity_id().to_string().into()),
164            state: Some(state.clone()),
165            format: None,
166            text: None,
167            text_view_style: None,
168            style: StyleRefinement::default(),
169            selectable: true,
170            selection_format: SelectionFormat::default(),
171            scrollable: false,
172            max_lines: None,
173            code_block_actions: None,
174            code_block_highlighter: None,
175            table_actions: None,
176            link_click_handler: None,
177            markdown_extensions: Arc::default(),
178            motion: None,
179        }
180    }
181
182    /// Create a new markdown text view.
183    pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
184        Self {
185            id: id.into(),
186            format: Some(TextViewFormat::Markdown),
187            text: Some(markdown.into()),
188            text_view_style: None,
189            style: StyleRefinement::default(),
190            state: None,
191            selectable: true,
192            selection_format: SelectionFormat::default(),
193            scrollable: false,
194            max_lines: None,
195            code_block_actions: None,
196            code_block_highlighter: None,
197            table_actions: None,
198            link_click_handler: None,
199            markdown_extensions: Arc::default(),
200            motion: None,
201        }
202    }
203
204    /// Create a new html text view.
205    pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
206        Self {
207            id: id.into(),
208            format: Some(TextViewFormat::Html),
209            text: Some(html.into()),
210            text_view_style: None,
211            style: StyleRefinement::default(),
212            state: None,
213            selectable: true,
214            selection_format: SelectionFormat::default(),
215            scrollable: false,
216            max_lines: None,
217            code_block_actions: None,
218            code_block_highlighter: None,
219            table_actions: None,
220            link_click_handler: None,
221            markdown_extensions: Arc::default(),
222            motion: None,
223        }
224    }
225
226    /// Set [`TextViewStyle`].
227    pub fn style(mut self, style: TextViewStyle) -> Self {
228        self.text_view_style = Some(style);
229        self
230    }
231
232    /// Set whether the text view is selectable, default is true.
233    pub fn selectable(mut self, selectable: bool) -> Self {
234        self.selectable = selectable;
235        self
236    }
237
238    /// Set the [`SelectionFormat`], default is [`SelectionFormat::Plain`].
239    ///
240    /// With [`SelectionFormat::Source`], selecting inside `**bold**` yields
241    /// `**bold**` (the Markdown source) rather than `bold`.
242    pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
243        self.selection_format = selection_format;
244        self
245    }
246
247    /// Set the text view to be scrollable, default is false.
248    ///
249    /// ## If true for `scrollable`
250    ///
251    /// The `scrollable` mode used for large content,
252    /// will show scrollbar, but requires the parent to have a fixed height,
253    /// and use [`gpui::list`] to render the content in a virtualized way.
254    ///
255    /// ## If false to fit content
256    ///
257    /// The TextView will expand to fit all content, no scrollbar.
258    /// This mode is suitable for small content, such as a few lines of text, a label, etc.
259    pub fn scrollable(mut self, scrollable: bool) -> Self {
260        self.scrollable = scrollable;
261        self
262    }
263
264    /// Clamp the rendered content to at most `n` lines of body text.
265    ///
266    /// The view's height is capped at `n` × the base line height, and a line
267    /// of glyphs is never cut in half: a line that would straddle the bottom
268    /// of the box is left out whole, across paragraphs, lists, headings, code
269    /// blocks and tables. Nothing is shown with less than a line of itself to
270    /// show, so the border and padding a table row leads with never strands at
271    /// the bottom; whatever has more than that is cut on the box edge and keeps
272    /// the part that fits, rather than disappearing and leaving blank space
273    /// behind.
274    ///
275    /// Check [`TextViewState::is_clamped`] (which answers for the frame that
276    /// was last painted) to decide whether to show an "expand" affordance.
277    ///
278    /// `n` counts lines of body text, so paragraph spacing and taller lines
279    /// mean fewer of them fit inside the capped height. A line taller than the
280    /// whole budget keeps the part that fits rather than emptying the box.
281    /// Ignored when [`Self::scrollable`] is set.
282    pub fn max_lines(mut self, max_lines: usize) -> Self {
283        self.max_lines = Some(max_lines);
284        self
285    }
286
287    /// Set custom block actions for code blocks.
288    ///
289    /// The closure receives the [`CodeBlock`],
290    /// and returns an element to display.
291    pub fn code_block_actions<F, E>(mut self, f: F) -> Self
292    where
293        F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
294        E: IntoElement,
295    {
296        self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
297            f(&code_block, window, cx).into_any_element()
298        }));
299        self
300    }
301
302    /// Adds opt-in syntax highlighting for fenced code blocks.
303    ///
304    /// Returned byte ranges are relative to [`CodeBlock::code`]. Invalid
305    /// ranges are discarded. Without this callback, code is unhighlighted.
306    pub fn code_block_highlighter<F>(mut self, highlighter: F) -> Self
307    where
308        F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
309    {
310        self.code_block_highlighter = Some(Arc::new(highlighter));
311        self
312    }
313
314    /// Set custom actions to be rendered below each Markdown table.
315    ///
316    /// The closure receives the [`TableData`],
317    /// and returns an element to display.
318    pub fn table_actions<F, E>(mut self, f: F) -> Self
319    where
320        F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
321        E: IntoElement,
322    {
323        self.table_actions = Some(Arc::new(move |table, window, cx| {
324            f(table, window, cx).into_any_element()
325        }));
326        self
327    }
328
329    /// Handle pointer events on rendered links.
330    ///
331    /// The handler receives the resolved URL and the original GPUI click event.
332    /// Without a handler, links open through App::open_url.
333    pub fn on_link_click<F>(mut self, handler: F) -> Self
334    where
335        F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
336    {
337        self.link_click_handler = Some(Arc::new(handler));
338        self
339    }
340
341    /// Replace the Markdown extension registry.
342    pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self {
343        self.markdown_extensions = Arc::new(extensions);
344        self
345    }
346
347    /// Set the motion policy; see [`TextViewMotion`]. Without one, the
348    /// state's own policy applies, which plays no motion by default.
349    pub fn motion(mut self, motion: TextViewMotion) -> Self {
350        self.motion = Some(motion);
351        self
352    }
353
354    /// Enable MDX JSX/expression parsing.
355    ///
356    /// This disables raw HTML parsing because `markdown-rs` gives HTML
357    /// priority over MDX when both are enabled.
358    pub fn markdown_mdx(mut self) -> Self {
359        let extensions = Arc::make_mut(&mut self.markdown_extensions);
360        *extensions = extensions.clone().mdx();
361        self
362    }
363
364    /// Register a custom block-level Markdown parser.
365    ///
366    /// The parser runs during Markdown AST conversion and must be independent
367    /// of [`Window`] / [`App`]. Store any parsed data in [`MarkdownNode`] and
368    /// render it later with [`Self::markdown_block_renderer`].
369    pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
370    where
371        F: for<'a> Fn(
372                &markdown::mdast::Node,
373                &crate::text::MarkdownParseContext<'a>,
374            ) -> Option<MarkdownNode>
375            + Send
376            + Sync
377            + 'static,
378    {
379        Arc::make_mut(&mut self.markdown_extensions).push_block_parser(parser);
380        self
381    }
382
383    /// Register a renderer for a custom block-level Markdown node name.
384    pub fn markdown_block_renderer<F, E>(
385        mut self,
386        name: impl Into<SharedString>,
387        renderer: F,
388    ) -> Self
389    where
390        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
391        E: IntoElement,
392    {
393        Arc::make_mut(&mut self.markdown_extensions).push_block_renderer(name, renderer);
394        self
395    }
396
397    /// Apply a reusable text view plugin.
398    pub fn plugin<P>(self, plugin: P) -> Self
399    where
400        P: TextViewPlugin,
401    {
402        plugin.setup(self)
403    }
404}
405
406impl IntoElement for TextView {
407    type Element = Self;
408
409    fn into_element(self) -> Self::Element {
410        self
411    }
412}
413
414pub struct TextViewLayoutState {
415    state: Entity<TextViewState>,
416    element: AnyElement,
417}
418
419pub struct TextViewPrepaintState {
420    hitbox: Hitbox,
421    /// Where paint has to pull the `max_lines` clip up to, because a glyph line
422    /// straddles the bottom of the box. `None` leaves the clip at the box edge,
423    /// where the container's hidden overflow already applies it.
424    clip_bottom: Option<Pixels>,
425    /// The touch handles this view owns, with their hitboxes.
426    touch_handles: crate::TouchHandleLayout,
427}
428
429/// Absorbs sub-pixel layout jitter: a line ending within a pixel of the box
430/// bottom counts as fitting inside it.
431const CLIP_EPSILON: Pixels = px(1.);
432
433/// The bottom of the last whole line at or above `y`, with the height of a line
434/// where it sits.
435fn last_line_bottom_above(spans: &[LineSpan], y: Pixels) -> Option<(Pixels, Pixels)> {
436    let mut last: Option<(Pixels, Pixels)> = None;
437    let mut keep = |bottom: Pixels, line_height: Pixels| {
438        if bottom <= y + CLIP_EPSILON && last.is_none_or(|(last, _)| bottom > last) {
439            last = Some((bottom, line_height));
440        }
441    };
442
443    for span in spans {
444        if span.line_height <= px(0.) {
445            continue;
446        }
447        let mut bottom = span.top + span.line_height;
448        while bottom <= span.bottom + CLIP_EPSILON {
449            keep(bottom, span.line_height);
450            bottom += span.line_height;
451        }
452        // The span's own bottom covers a last line taller than the rest.
453        keep(span.bottom, span.line_height);
454    }
455
456    last
457}
458
459/// Where to clip, given the lines a descendant `Inline` reported. `None` leaves
460/// the clip on the box edge.
461///
462/// Two things are never shown: half a line of glyphs, and anything with less
463/// than a line of itself to show. A line straddling `box_bottom` is left out
464/// whole, and so is the strip between it and the line before — the border and
465/// padding a table row leads with reads as a rendering fault rather than as a
466/// row. Whatever has more than a line to show is cut on the edge and keeps the
467/// part that fits, so the box holds no blank space it could have filled.
468fn line_safe_clip_bottom(
469    spans: &[LineSpan],
470    box_bottom: Pixels,
471    content_bottom: Pixels,
472) -> Option<Pixels> {
473    let mut clip = box_bottom;
474
475    for span in spans {
476        if span.line_height <= px(0.)
477            || span.top >= box_bottom
478            || span.bottom <= box_bottom + CLIP_EPSILON
479        {
480            continue;
481        }
482        let whole_lines = ((box_bottom - span.top) / span.line_height).floor();
483        let line_top = span.top + span.line_height * whole_lines;
484        // A line starting on the box edge is not straddling it.
485        if line_top < box_bottom - CLIP_EPSILON {
486            clip = clip.min(line_top);
487        }
488    }
489
490    let Some((last_line_bottom, line_height)) = last_line_bottom_above(spans, clip) else {
491        // Leaving the straddling line out would leave nothing at all — a first
492        // line taller than the whole budget, a heading in a one-line box. It
493        // keeps the part that fits instead, because an empty clamp reads as
494        // broken where a cut one reads as more to come.
495        return None;
496    };
497
498    // Snap away a scrap. Only content that continues past the box can leave
499    // one: the space under the last line of a document that fits is the box's
500    // own, not a piece of something below.
501    if content_bottom > box_bottom + CLIP_EPSILON {
502        let strip = clip - last_line_bottom;
503        if strip > CLIP_EPSILON && strip < line_height {
504            clip = last_line_bottom;
505        }
506    }
507
508    (clip < box_bottom - CLIP_EPSILON).then_some(clip)
509}
510
511impl Element for TextView {
512    type RequestLayoutState = TextViewLayoutState;
513    type PrepaintState = TextViewPrepaintState;
514
515    fn id(&self) -> Option<ElementId> {
516        Some(self.id.clone())
517    }
518
519    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
520        None
521    }
522
523    fn request_layout(
524        &mut self,
525        _: Option<&GlobalElementId>,
526        _: Option<&InspectorElementId>,
527        window: &mut Window,
528        cx: &mut App,
529    ) -> (LayoutId, Self::RequestLayoutState) {
530        let state = if let Some(state) = self.state.clone() {
531            state
532        } else {
533            let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
534            let default_text = self.text.clone().unwrap_or_default();
535
536            let state = window.use_keyed_state(
537                SharedString::from(format!("{}/state", self.id)),
538                cx,
539                move |_, cx| {
540                    if default_format == TextViewFormat::Markdown {
541                        TextViewState::markdown(default_text.as_str(), cx)
542                    } else {
543                        TextViewState::html(default_text.as_str(), cx)
544                    }
545                },
546            );
547            self.state = Some(state.clone());
548            state
549        };
550
551        // `max_lines` needs the whole document laid out to snap the clip to a
552        // whole line, so it only applies to the fit-content mode.
553        let max_lines = self.max_lines.filter(|_| !self.scrollable);
554
555        // Resolve the style by reference: this runs every frame, and the
556        // style only reaches the state when it changed.
557        let defaults = cx.try_global::<TextViewDefaults>();
558        let theme_style;
559        let text_view_style = match (
560            &self.text_view_style,
561            defaults.and_then(|d| d.style.as_ref()),
562        ) {
563            (Some(style), _) | (None, Some(style)) => style,
564            (None, None) => {
565                theme_style = TextViewStyle::from_theme(&crate::Theme::global(cx));
566                &theme_style
567            }
568        };
569        let foreground = text_view_style.foreground();
570        let text_view_style = (*state.read(cx).text_view_style != *text_view_style)
571            .then(|| Arc::new(text_view_style.clone()));
572        let code_block_highlighter = self
573            .code_block_highlighter
574            .clone()
575            .or_else(|| defaults.and_then(|d| d.code_block_highlighter.clone()));
576
577        state.update(cx, |state, cx| {
578            state.code_block_actions = self.code_block_actions.clone();
579            state.code_block_highlighter = code_block_highlighter;
580            state.table_actions = self.table_actions.clone();
581            state.link_click_handler = self.link_click_handler.clone();
582            state.set_markdown_extensions(self.markdown_extensions.clone(), cx);
583            if let Some(motion) = &self.motion {
584                state.set_motion(motion.clone());
585            }
586            state.selectable = self.selectable;
587            state.selection_format = self.selection_format;
588            state.scrollable = self.scrollable;
589            state.max_lines = max_lines;
590            if let Some(text_view_style) = text_view_style {
591                state.selection_revision = state.selection_revision.wrapping_add(1);
592                state.text_view_style = text_view_style;
593            }
594
595            if let Some(text) = &self.text {
596                state.set_element_text(text, cx);
597            }
598        });
599
600        let focus_handle = state.read(cx).focus_handle.clone();
601        let list_state = state.read(cx).list_state.clone();
602        // Cap the box at `n` body-text lines (the effective text style may be
603        // refined by this view's own style, e.g. `.text_sm()`); hidden
604        // overflow also clips descendant hitboxes to the box during prepaint.
605        let max_lines_cap = max_lines.map(|max_lines| {
606            let mut text_style = window.text_style();
607            text_style.refine(&self.style.text);
608            text_style.line_height_in_pixels(window.rem_size()) * max_lines as f32
609        });
610
611        let mut el = div()
612            .id(("text-view-scroll", state.entity_id()))
613            .key_context("TextView")
614            .track_focus(&focus_handle)
615            .when(self.scrollable, |this| this.size_full())
616            .when_some(max_lines_cap, |this, cap| this.max_h(cap).overflow_hidden())
617            .relative()
618            .text_color(foreground)
619            .on_action(move |_: &crate::input::Copy, window, cx| {
620                let text = TextSelection::selected_text(window, cx).trim().to_string();
621                if text.is_empty() {
622                    cx.propagate();
623                    return;
624                }
625                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
626            })
627            .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
628            .child(state.clone())
629            // Overlay controls must paint after the document, otherwise rich
630            // content and selection backgrounds cover the thumb and hitbox.
631            .when(self.scrollable, |this| {
632                this.child(
633                    div().absolute().inset_0().child(
634                        crate::Scrollbar::vertical(&list_state)
635                            .id(("text-view-scrollbar", state.entity_id()))
636                            .viewport_from_layout(),
637                    ),
638                )
639            })
640            .refine_style(&self.style)
641            .into_any_element();
642        let layout_id = el.request_layout(window, cx);
643        (layout_id, TextViewLayoutState { state, element: el })
644    }
645
646    fn prepaint(
647        &mut self,
648        _: Option<&GlobalElementId>,
649        _: Option<&InspectorElementId>,
650        bounds: Bounds<Pixels>,
651        request_layout: &mut Self::RequestLayoutState,
652        window: &mut Window,
653        cx: &mut App,
654    ) -> Self::PrepaintState {
655        let state = request_layout.state.clone();
656        let max_lines_active = state.read(cx).max_lines.is_some();
657        if max_lines_active {
658            if let Ok(mut line_spans) = state.read(cx).line_spans.lock() {
659                line_spans.clear();
660            }
661            // Descendant `Inline`s report their line spans through the state
662            // stack during prepaint (in addition to the paint-time push below).
663            GlobalState::global_mut(cx)
664                .text_view_state_stack
665                .push(state.clone());
666        }
667        request_layout.element.prepaint(window, cx);
668        if max_lines_active {
669            GlobalState::global_mut(cx).text_view_state_stack.pop();
670        }
671
672        let mut clip_bottom = None;
673        if max_lines_active {
674            let (line_spans, content_bottom) = {
675                let state = state.read(cx);
676                (
677                    state
678                        .line_spans
679                        .lock()
680                        .map(|spans| spans.clone())
681                        .unwrap_or_default(),
682                    state.bounds().bottom(),
683                )
684            };
685            // The content keeps its natural height inside the capped box, so
686            // this sees everything the box cannot show — including a tall image
687            // that reports no lines of its own.
688            let clipped = content_bottom > bounds.bottom() + px(1.);
689            // Notify on change so observers (e.g. an "expand" button gated on
690            // `is_clamped`) re-render once the flag flips.
691            if state.read(cx).clamped != clipped {
692                state.update(cx, |state, cx| {
693                    state.clamped = clipped;
694                    cx.notify();
695                });
696            }
697            if clipped {
698                clip_bottom = line_safe_clip_bottom(&line_spans, bounds.bottom(), content_bottom);
699            }
700        }
701
702        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
703        // Over the text, so after its hitbox.
704        let touch_handles = if self.selectable {
705            state
706                .read(cx)
707                .selection_adapter
708                .prepaint_touch_handles(window, cx)
709        } else {
710            crate::TouchHandleLayout::default()
711        };
712        TextViewPrepaintState {
713            hitbox,
714            clip_bottom,
715            touch_handles,
716        }
717    }
718
719    fn paint(
720        &mut self,
721        _: Option<&GlobalElementId>,
722        _: Option<&InspectorElementId>,
723        bounds: Bounds<Pixels>,
724        request_layout: &mut Self::RequestLayoutState,
725        prepaint: &mut Self::PrepaintState,
726        window: &mut Window,
727        cx: &mut App,
728    ) {
729        let state = &request_layout.state;
730        if self.selectable {
731            state.update(cx, |state, _| state.selection_adapter.begin_frame());
732        }
733
734        GlobalState::global_mut(cx)
735            .text_view_state_stack
736            .push(state.clone());
737        if let Some(clip_bottom) = prepaint.clip_bottom {
738            // Snap the `max_lines` clip to the last whole line that fits, so a
739            // line of glyphs is never cut in half.
740            let mask = ContentMask {
741                bounds: Bounds::from_corners(bounds.origin, point(bounds.right(), clip_bottom)),
742            };
743            window.with_content_mask(Some(mask), |window| {
744                request_layout.element.paint(window, cx);
745            });
746        } else {
747            request_layout.element.paint(window, cx);
748        }
749        GlobalState::global_mut(cx).text_view_state_stack.pop();
750
751        if self.selectable {
752            let (adapter, scroll_offset, content_bounds, self_scroll, handle_color) = {
753                let state = state.read(cx);
754                (
755                    state.selection_adapter.clone(),
756                    state.scroll_offset(),
757                    state.bounds(),
758                    state.scrollable,
759                    state.text_view_style.selection().alpha(1.),
760                )
761            };
762            let document_order = GlobalState::global_mut(cx).next_selection_document_order();
763            adapter.register(
764                prepaint.hitbox.clone(),
765                content_bounds,
766                scroll_offset,
767                document_order,
768                self_scroll,
769                window,
770                cx,
771            );
772            // The handles of a touch selection go over the text, and under
773            // whatever is painted over the text after it.
774            adapter.paint_touch_handles(&prepaint.touch_handles, handle_color, window, cx);
775        }
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use std::sync::{
782        Arc,
783        atomic::{AtomicUsize, Ordering},
784    };
785
786    use super::{TextView, TextViewPlugin};
787    use crate::text::{TableData, TextViewState, TextViewStyle};
788    use gpui::{
789        AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
790        Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
791        Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
792        TestAppContext, VisualTestContext, Window, div, point, px,
793    };
794
795    struct TextViewTestRoot {
796        text_view: Entity<TextViewState>,
797    }
798
799    struct InlineHoverTestRoot {
800        view: Entity<TextViewState>,
801        builds: Arc<AtomicUsize>,
802        format: crate::text::SelectionFormat,
803    }
804
805    struct InlineHoverCard;
806    impl Render for InlineHoverCard {
807        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
808            div()
809                .debug_selector(|| "inline-test-card".into())
810                .w(px(120.))
811                .h(px(40.))
812                .child("Member profile")
813        }
814    }
815
816    impl Render for InlineHoverTestRoot {
817        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
818            let builds = self.builds.clone();
819            div()
820                .pl(px(150.))
821                .w(px(300.))
822                .text_size(px(16.))
823                .child(crate::TextSelectionLayer)
824                .child(
825                    TextView::new(&self.view)
826                        .selection_format(self.format)
827                        .plugin(
828                            crate::text::markdown_ext::TestInlinePlugin::new("mention")
829                                .parse_with(|node, _| {
830                                    let markdown::mdast::Node::Link(link) = node else {
831                                        return None;
832                                    };
833                                    let handle = link.url.strip_prefix("mention:")?;
834                                    Some(
835                                        crate::text::MarkdownNode::new("mention", ())
836                                            .text(format!("@{handle}")),
837                                    )
838                                })
839                                .render_with(move |_, _, _, _| {
840                                    let builds = builds.clone();
841                                    Some(crate::text::InlineElement::new(
842                                        crate::HoverCard::new("mention-hover")
843                                            .anchor(gpui::Anchor::TopCenter)
844                                            .trigger(div().child("@member"))
845                                            .content(move |_, _, cx| {
846                                                builds.fetch_add(1, Ordering::Relaxed);
847                                                div()
848                                                    .id("hover-content")
849                                                    .child(cx.new(|_| InlineHoverCard))
850                                            }),
851                                    ))
852                                }),
853                        ),
854                )
855        }
856    }
857
858    #[gpui::test]
859    fn inline_plugin_reuses_render_and_preserves_native_child_events(cx: &mut TestAppContext) {
860        use std::sync::Mutex;
861        struct ControlPlugin(Arc<Mutex<Vec<String>>>);
862        impl crate::text::MarkdownPlugin for ControlPlugin {
863            fn name(&self) -> &str {
864                "control"
865            }
866            fn parse(
867                &self,
868                node: &markdown::mdast::Node,
869                _: &crate::text::MarkdownParseContext<'_>,
870            ) -> Option<crate::text::MarkdownNode> {
871                let markdown::mdast::Node::Link(link) = node else {
872                    return None;
873                };
874                let label = link.url.strip_prefix("control:")?;
875                Some(crate::text::MarkdownNode::new("control", ()).text(label.to_string()))
876            }
877            fn render(
878                &self,
879                node: &crate::text::MarkdownNode,
880                _: &mut Window,
881                _: &mut gpui::App,
882            ) -> impl IntoElement {
883                let clicks = self.0.clone();
884                let label = node.as_text().to_string();
885                div()
886                    .id("same-control-id")
887                    .w(px(60.))
888                    .h(px(24.))
889                    .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
890                    .on_click(move |_, _, _| clicks.lock().unwrap().push(label.clone()))
891                    .child(node.as_text().to_string())
892            }
893        }
894        struct Root {
895            view: Entity<TextViewState>,
896            clicks: Arc<Mutex<Vec<String>>>,
897        }
898        impl Render for Root {
899            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
900                div()
901                    .w(px(300.))
902                    .child(crate::TextSelectionLayer)
903                    .child(TextView::new(&self.view).plugin(ControlPlugin(self.clicks.clone())))
904            }
905        }
906        cx.update(crate::init);
907        let clicks = Arc::new(Mutex::new(Vec::new()));
908        let captured = clicks.clone();
909        let (root, cx) = cx.add_window_view(move |_, cx| Root {
910            view: cx.new(|cx| TextViewState::markdown("[one](control:one)[two](control:two)", cx)),
911            clicks,
912        });
913        cx.run_until_parked();
914        cx.update(|window, cx| {
915            window.draw(cx).clear(cx);
916        });
917        let bounds = root.read_with(cx, |root, cx| {
918            root.view.read(cx).selection_adapter.text_bounds()
919        });
920        assert_eq!(bounds.len(), 2);
921        for bounds in bounds {
922            cx.simulate_click(bounds.center(), Modifiers::default());
923            cx.update(|window, cx| {
924                window.draw(cx).clear(cx);
925            });
926        }
927        assert_eq!(*captured.lock().unwrap(), vec!["one", "two"]);
928    }
929
930    #[gpui::test]
931    fn inline_hover_card_is_lazy_and_retains_atomic_copy(cx: &mut TestAppContext) {
932        cx.update(crate::init);
933        let builds = Arc::new(AtomicUsize::new(0));
934        let source = "[@member](mention:member)";
935        let (root, cx) = cx.add_window_view(|_, cx| InlineHoverTestRoot {
936            view: cx.new(|cx| TextViewState::markdown(source, cx)),
937            builds: builds.clone(),
938            format: crate::text::SelectionFormat::Plain,
939        });
940        cx.run_until_parked();
941        cx.update(|window, cx| window.draw(cx).clear(cx));
942        assert_eq!(builds.load(Ordering::Relaxed), 0);
943        let view = root.read_with(cx, |root, _| root.view.clone());
944        let bounds = view.read_with(cx, |view, _| view.selection_adapter.text_bounds()[0]);
945        cx.update(|window, cx| {
946            window.simulate_mouse_move(point(bounds.right() - px(1.), bounds.center().y), cx)
947        });
948        cx.run_until_parked();
949        cx.executor()
950            .advance_clock(std::time::Duration::from_secs(1));
951        cx.run_until_parked();
952        cx.update(|window, cx| window.draw(cx).clear(cx));
953        assert!(
954            builds.load(Ordering::Relaxed) > 0,
955            "hover did not build the card"
956        );
957        cx.update(|window, cx| window.draw(cx).clear(cx));
958        let card = cx
959            .debug_bounds("inline-test-card")
960            .expect("hover card should be painted");
961        assert!(
962            (card.center().x - bounds.center().x).abs() < px(1.),
963            "card {card:?} must be centered on mention {bounds:?}"
964        );
965        assert!(
966            card.top() >= bounds.bottom(),
967            "card should be below the mention"
968        );
969        for (format, expected) in [
970            (crate::text::SelectionFormat::Plain, "@member"),
971            (crate::text::SelectionFormat::Source, source),
972        ] {
973            root.update(cx, |root, cx| {
974                root.format = format;
975                cx.notify();
976            });
977            view.update(cx, |view, cx| {
978                view.set_selection_format(format, cx);
979                view.select_all(cx);
980            });
981            cx.update(|window, cx| window.draw(cx).clear(cx));
982            assert_eq!(
983                view.read_with(cx, |view, _| view.selected_text()).trim(),
984                expected
985            );
986        }
987    }
988
989    struct InlinePluginTestRoot {
990        text_view: Entity<TextViewState>,
991        width: Pixels,
992        font_size: Pixels,
993        source_format: bool,
994        prepared_size: Option<Arc<AtomicUsize>>,
995    }
996
997    impl Render for InlinePluginTestRoot {
998        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
999            let prepared_size = self.prepared_size.clone();
1000            div().w(self.width).text_size(self.font_size).child(crate::TextSelectionLayer).child(
1001                TextView::new(&self.text_view)
1002                    .selection_format(if self.source_format { crate::text::SelectionFormat::Source }
1003                        else { crate::text::SelectionFormat::Plain })
1004
1005                    .plugin(crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(|node, _| {
1006                        let markdown::mdast::Node::InlineMath(math) = node else { return None };
1007                        Some(crate::text::MarkdownNode::new("math", ()).text(format!("{}²", math.value)))
1008                    }).render_with(move |_, context, _, _| {
1009                        let value = prepared_size.as_ref()?.load(Ordering::Relaxed) as f32;
1010                        let image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Svg,
1011                            b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><path d=\"M0 0L40 40\" stroke=\"black\"/></svg>".to_vec()));
1012                        let unit = context.font_size() / 16.;
1013                        Some(crate::text::InlineElement::new(gpui::img(image).w(unit * value).h(unit * value)).with_baseline(unit * value * 0.75))
1014                    })))
1015        }
1016    }
1017
1018    #[gpui::test]
1019    fn inline_plugins_drag_and_copy_across_formulas_in_both_directions(cx: &mut TestAppContext) {
1020        cx.update(crate::init);
1021        let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1022            text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1023            width: px(420.),
1024            font_size: px(16.),
1025            source_format: false,
1026            prepared_size: None,
1027        });
1028        let cx: &mut VisualTestContext = cx;
1029        for font_size in [16., 24., 32.] {
1030            for width in [60., 160., 420.] {
1031                for prepared in [None, Some(32)] {
1032                    for source_format in [false, true] {
1033                        root.update(cx, |root, cx| {
1034                            root.source_format = source_format;
1035                            root.width = px(width);
1036                            root.font_size = px(font_size);
1037                            root.prepared_size =
1038                                prepared.map(|value| Arc::new(AtomicUsize::new(value)));
1039                            cx.notify();
1040                        });
1041                        cx.run_until_parked();
1042                        cx.update(|window, cx| window.draw(cx).clear(cx));
1043                        let bounds =
1044                            root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1045                        let text_bounds = root.read_with(cx, |root, cx| {
1046                            root.text_view.read(cx).selection_adapter.text_bounds()
1047                        });
1048                        let first = text_bounds.first().unwrap();
1049                        let last = text_bounds.last().unwrap();
1050                        let left =
1051                            point(first.left() + px(0.1), first.top() + first.size.height / 2.);
1052                        let right =
1053                            point(last.right() - px(0.1), last.top() + last.size.height / 2.);
1054                        for (start, end) in [(left, right), (right, left)] {
1055                            cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1056                            cx.update(|window, cx| window.draw(cx).clear(cx));
1057                            cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1058                            cx.update(|window, cx| window.draw(cx).clear(cx));
1059                            cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1060                            cx.update(|window, cx| window.draw(cx).clear(cx));
1061                            let selected = root
1062                                .read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1063                            assert_eq!(
1064                                selected.trim(),
1065                                if source_format {
1066                                    "中文 $x$ $y$ English"
1067                                } else {
1068                                    "中文 x² y² English"
1069                                },
1070                                "start={start:?} end={end:?} bounds={bounds:?} width={width} font_size={font_size} prepared={prepared:?}"
1071                            );
1072                        }
1073                    }
1074                }
1075            }
1076        }
1077    }
1078
1079    #[gpui::test]
1080    fn inline_resource_size_update_reflows_without_changing_selection(cx: &mut TestAppContext) {
1081        cx.update(crate::init);
1082        let prepared_size = Arc::new(AtomicUsize::new(16));
1083        let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1084            text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1085            width: px(160.),
1086            font_size: px(16.),
1087            source_format: false,
1088            prepared_size: Some(prepared_size.clone()),
1089        });
1090        let cx: &mut VisualTestContext = cx;
1091        cx.run_until_parked();
1092        cx.update(|window, cx| window.draw(cx).clear(cx));
1093        let before = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1094        let regions = root.read_with(cx, |root, cx| {
1095            root.text_view.read(cx).selection_adapter.text_bounds()
1096        });
1097        let first = regions.first().unwrap();
1098        let last = regions.last().unwrap();
1099        let start = point(first.left() + px(0.1), first.top() + px(10.));
1100        let end = point(last.right() - px(0.1), last.top() + px(10.));
1101        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1102        cx.update(|window, cx| window.draw(cx).clear(cx));
1103        cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1104        cx.update(|window, cx| window.draw(cx).clear(cx));
1105        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1106        cx.update(|window, cx| window.draw(cx).clear(cx));
1107        assert_eq!(
1108            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1109                .trim(),
1110            "中文 x² y² English"
1111        );
1112        prepared_size.store(100, Ordering::Relaxed);
1113        root.update(cx, |root, cx| {
1114            root.text_view
1115                .update(cx, |state, cx| state.invalidate_inline_layout(cx))
1116        });
1117        cx.run_until_parked();
1118        cx.update(|window, cx| window.draw(cx).clear(cx));
1119        let after = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1120        assert!(after.size.height > before.size.height * 2.);
1121        assert_eq!(
1122            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1123                .trim(),
1124            "中文 x² y² English"
1125        );
1126    }
1127
1128    #[gpui::test]
1129    fn triple_click_on_formula_selects_its_entire_mixed_line(cx: &mut TestAppContext) {
1130        cx.update(crate::init);
1131        let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1132            text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1133            width: px(420.),
1134            font_size: px(16.),
1135            source_format: false,
1136            prepared_size: None,
1137        });
1138        let cx: &mut VisualTestContext = cx;
1139        cx.run_until_parked();
1140        cx.update(|window, cx| window.draw(cx).clear(cx));
1141        let regions = root.read_with(cx, |root, cx| {
1142            root.text_view.read(cx).selection_adapter.text_bounds()
1143        });
1144        let formula = regions[1];
1145        let position = formula.center();
1146        cx.simulate_event(MouseDownEvent {
1147            position,
1148            modifiers: Modifiers::default(),
1149            button: MouseButton::Left,
1150            click_count: 3,
1151            first_mouse: false,
1152        });
1153        cx.simulate_event(MouseUpEvent {
1154            position,
1155            modifiers: Modifiers::default(),
1156            button: MouseButton::Left,
1157            click_count: 3,
1158        });
1159        cx.update(|window, cx| window.draw(cx).clear(cx));
1160        assert_eq!(
1161            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1162                .trim(),
1163            "before x² after"
1164        );
1165        root.update(cx, |root, cx| {
1166            root.source_format = true;
1167            cx.notify();
1168        });
1169        cx.update(|window, cx| window.draw(cx).clear(cx));
1170        assert_eq!(
1171            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1172                .trim(),
1173            "before $x$ after"
1174        );
1175    }
1176
1177    #[gpui::test]
1178    fn double_click_on_formula_selects_only_that_object(cx: &mut TestAppContext) {
1179        cx.update(crate::init);
1180        let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1181            text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1182            width: px(420.),
1183            font_size: px(16.),
1184            source_format: false,
1185            prepared_size: None,
1186        });
1187        let cx: &mut VisualTestContext = cx;
1188        cx.run_until_parked();
1189        cx.update(|window, cx| window.draw(cx).clear(cx));
1190        let regions = root.read_with(cx, |root, cx| {
1191            root.text_view.read(cx).selection_adapter.text_bounds()
1192        });
1193        let position = regions[1].center();
1194        cx.simulate_event(MouseDownEvent {
1195            position,
1196            modifiers: Modifiers::default(),
1197            button: MouseButton::Left,
1198            click_count: 2,
1199            first_mouse: false,
1200        });
1201        cx.simulate_event(MouseUpEvent {
1202            position,
1203            modifiers: Modifiers::default(),
1204            button: MouseButton::Left,
1205            click_count: 2,
1206        });
1207        cx.update(|window, cx| window.draw(cx).clear(cx));
1208        // Two clicks stop at the object; only three take the whole line.
1209        assert_eq!(
1210            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1211                .trim(),
1212            "x²"
1213        );
1214    }
1215
1216    /// A scrollable viewport, so the list has a bounded height to measure
1217    /// against and `max_offset_for_scrollbar` reports a real scroll extent.
1218    struct ScrollExtentTestRoot {
1219        text_view: Entity<TextViewState>,
1220    }
1221
1222    impl Render for ScrollExtentTestRoot {
1223        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1224            div()
1225                .w(px(400.))
1226                .h(px(200.))
1227                .overflow_hidden()
1228                .child(TextView::new(&self.text_view).scrollable(true))
1229        }
1230    }
1231
1232    /// `count` paragraphs, each `words` words long, so two documents can share
1233    /// a block count while differing wildly in height.
1234    fn document_of(count: usize, words: usize) -> String {
1235        (1..=count)
1236            .map(|i| format!("Block {i}: {}", "lorem ipsum dolor ".repeat(words)))
1237            .collect::<Vec<_>>()
1238            .join("\n\n")
1239    }
1240
1241    /// Replacing a document with one that happens to have the *same* block
1242    /// count must still re-measure. `Document::render_root` only resets the
1243    /// list when the count changes, so without an explicit re-measure every
1244    /// cached height stays with the previous document and the scroll extent
1245    /// keeps describing it.
1246    #[gpui::test]
1247    fn replacing_a_document_with_an_equal_block_count_remeasures(cx: &mut TestAppContext) {
1248        cx.update(crate::init);
1249
1250        const BLOCKS: usize = 24;
1251        let short = document_of(BLOCKS, 1);
1252        let tall = document_of(BLOCKS, 60);
1253
1254        let (root, cx) = cx.add_window_view(|_, cx| ScrollExtentTestRoot {
1255            text_view: cx.new(|cx| TextViewState::markdown(&short, cx)),
1256        });
1257        let cx: &mut VisualTestContext = cx;
1258
1259        // The list is populated and measured during layout, so every
1260        // assertion below has to follow a real frame.
1261        let settle = |cx: &mut VisualTestContext| {
1262            cx.run_until_parked();
1263            cx.update(|window, cx| window.draw(cx).clear(cx));
1264            cx.run_until_parked();
1265        };
1266        settle(cx);
1267
1268        let scroll_extent = |cx: &mut VisualTestContext| {
1269            root.read_with(cx, |root, cx| {
1270                root.text_view
1271                    .read(cx)
1272                    .list_state()
1273                    .max_offset_for_scrollbar()
1274                    .y
1275            })
1276        };
1277
1278        let short_extent = scroll_extent(cx);
1279
1280        root.update(cx, |root, cx| {
1281            root.text_view
1282                .update(cx, |state, cx| state.set_text(&tall, cx));
1283        });
1284        settle(cx);
1285
1286        root.read_with(cx, |root, cx| {
1287            assert_eq!(
1288                root.text_view.read(cx).list_state().item_count(),
1289                BLOCKS,
1290                "the replacement must keep the block count, or the list resets and the bug cannot occur"
1291            );
1292        });
1293
1294        let tall_extent = scroll_extent(cx);
1295        assert!(
1296            tall_extent > short_extent * 5.,
1297            "a much taller document must grow the scroll extent, but it went from \
1298             {short_extent:?} to {tall_extent:?}"
1299        );
1300    }
1301
1302    struct StatelessMarkdownRoot {
1303        renders: Arc<AtomicUsize>,
1304    }
1305
1306    impl Render for StatelessMarkdownRoot {
1307        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1308            self.renders.fetch_add(1, Ordering::Relaxed);
1309            div().child(
1310                TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
1311                    .markdown_block_parser(|_, _| None),
1312            )
1313        }
1314    }
1315
1316    struct DummyTextViewPlugin;
1317
1318    impl TextViewPlugin for DummyTextViewPlugin {
1319        fn setup(self, mut text_view: TextView) -> TextView {
1320            text_view.selectable = true;
1321            text_view
1322        }
1323    }
1324
1325    #[gpui::test]
1326    fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
1327        cx.update(crate::init);
1328        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
1329
1330        assert!(TextView::new(&state).selectable);
1331        assert!(TextView::markdown("markdown", "text").selectable);
1332        assert!(TextView::html("html", "<p>text</p>").selectable);
1333    }
1334
1335    #[gpui::test]
1336    fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
1337        cx.update(crate::init);
1338        let renders = Arc::new(AtomicUsize::new(0));
1339        let (_, cx) = cx.add_window_view({
1340            let renders = renders.clone();
1341            move |_, _| StatelessMarkdownRoot { renders }
1342        });
1343        let cx: &mut VisualTestContext = cx;
1344
1345        cx.run_until_parked();
1346        cx.update(|window, cx| window.draw(cx).clear(cx));
1347        let renders_after_redraw = renders.load(Ordering::Relaxed);
1348        cx.run_until_parked();
1349        assert_eq!(
1350            renders.load(Ordering::Relaxed),
1351            renders_after_redraw,
1352            "an unchanged TextView must not schedule another render after its parser is rebuilt",
1353        );
1354    }
1355
1356    #[gpui::test]
1357    fn markdown_data_url_image_is_decoded_inline(cx: &mut TestAppContext) {
1358        use gpui::{Image, ImageFormat, ImageSource};
1359
1360        // A 1x1 red PNG.
1361        const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
1362
1363        cx.update(crate::init);
1364        let markdown = format!("Inline ![dot](data:image/png;base64,{PNG_BASE64}) image");
1365        let (_, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new(&markdown, cx));
1366        let cx: &mut VisualTestContext = cx;
1367
1368        cx.run_until_parked();
1369        cx.update(|window, cx| window.draw(cx).clear(cx));
1370
1371        // `Image` keys the asset system by a hash of its bytes, so rebuilding it
1372        // from the same body finds the entry the text view's `img` registered
1373        // when it rendered — proof the body was decoded in place instead of
1374        // being fetched over HTTP.
1375        let bytes = data_url::DataUrl::process(&format!("data:image/png;base64,{PNG_BASE64}"))
1376            .unwrap()
1377            .decode_to_vec()
1378            .unwrap()
1379            .0;
1380        let image = Arc::new(Image::from_bytes(ImageFormat::Png, bytes));
1381        assert!(
1382            cx.update(|_, cx| ImageSource::Image(image).is_asset_cached(cx)),
1383            "the data URL image must be handed to GPUI as decoded bytes",
1384        );
1385    }
1386
1387    #[gpui::test]
1388    fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
1389        cx.update(crate::init);
1390        cx.update(|cx| {
1391            let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
1392            colors.primary = gpui::rgb(0x55aaff).into();
1393            colors.selection = gpui::rgb(0x335577).into();
1394        });
1395        let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
1396        let cx: &mut VisualTestContext = cx;
1397
1398        cx.run_until_parked();
1399        root.read_with(cx, |root, cx| {
1400            let style = &root.text_view.read(cx).text_view_style;
1401            assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
1402            assert_eq!(style.selection(), gpui::rgb(0x335577).into());
1403        });
1404    }
1405
1406    impl TextViewTestRoot {
1407        fn new(text: &str, cx: &mut Context<Self>) -> Self {
1408            let text = text.to_string();
1409            let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
1410            Self { text_view }
1411        }
1412    }
1413
1414    impl Render for TextViewTestRoot {
1415        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1416            div()
1417                .w(px(160.))
1418                .child(
1419                    div()
1420                        .h(px(24.))
1421                        .overflow_hidden()
1422                        .child(TextView::new(&self.text_view).selectable(true)),
1423                )
1424                .child(div().h(px(40.)).child("footer"))
1425        }
1426    }
1427
1428    struct TableSelectionTestRoot {
1429        text_view: Entity<TextViewState>,
1430    }
1431
1432    impl Render for TableSelectionTestRoot {
1433        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1434            div()
1435                .debug_selector(|| "table-selection-root".into())
1436                .w(px(520.))
1437                .child(crate::TextSelectionLayer)
1438                .child(TextView::new(&self.text_view))
1439        }
1440    }
1441
1442    #[gpui::test]
1443    fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
1444        cx.update(crate::init);
1445        let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
1446            text_view: cx.new(|cx| {
1447                TextViewState::markdown(
1448                    "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
1449                    cx,
1450                )
1451            }),
1452        });
1453        let cx: &mut VisualTestContext = cx;
1454
1455        cx.run_until_parked();
1456        let bounds = cx
1457            .debug_bounds("table-selection-root")
1458            .expect("table bounds");
1459        let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
1460        let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
1461        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1462        cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1463        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1464
1465        assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
1466        assert_eq!(
1467            cx.update(|window, cx| window.simulate_next_frame(cx)),
1468            0,
1469            "finished table selection must not continuously request frames"
1470        );
1471    }
1472
1473    struct InlineImageTextViewTestRoot {
1474        text_view: Entity<TextViewState>,
1475    }
1476
1477    impl InlineImageTextViewTestRoot {
1478        fn new(cx: &mut Context<Self>) -> Self {
1479            let text_view = cx.new(|cx| {
1480                TextViewState::markdown(
1481                    "Build Status ![inline image](https://example.com/image.svg) after",
1482                    cx,
1483                )
1484            });
1485            Self { text_view }
1486        }
1487    }
1488
1489    impl Render for InlineImageTextViewTestRoot {
1490        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1491            div()
1492                .w(px(420.))
1493                .child(TextView::new(&self.text_view).selectable(true))
1494        }
1495    }
1496
1497    #[gpui::test]
1498    fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
1499        cx.update(crate::init);
1500        let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
1501        let cx: &mut VisualTestContext = cx;
1502
1503        cx.run_until_parked();
1504        cx.update(|window, cx| {
1505            let _ = window.draw(cx);
1506        });
1507
1508        let inline_bounds = content.read_with(cx, |content, cx| {
1509            content.text_view.read(cx).selection_adapter.text_bounds()
1510        });
1511
1512        assert_eq!(inline_bounds.len(), 2);
1513        assert_eq!(
1514            inline_bounds[0].top(),
1515            inline_bounds[1].top(),
1516            "text before and after an inline image should share a rendered line"
1517        );
1518        assert!(
1519            inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
1520            "inline image should reserve horizontal space in the text layout"
1521        );
1522        assert!(
1523            inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
1524            "unloaded inline image fallback should stay generic and compact"
1525        );
1526    }
1527
1528    #[gpui::test]
1529    fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
1530        cx.update(crate::init);
1531        let (_, cx) = cx.add_window_view(|_, cx| {
1532            TextViewTestRoot::new(
1533                "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
1534                cx,
1535            )
1536        });
1537        let cx: &mut VisualTestContext = cx;
1538
1539        cx.run_until_parked();
1540        cx.update(|window, cx| {
1541            let _ = window.draw(cx);
1542        });
1543    }
1544
1545    #[gpui::test]
1546    fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
1547        struct ListItemBlockRoot;
1548
1549        impl Render for ListItemBlockRoot {
1550            fn render(
1551                &mut self,
1552                _window: &mut Window,
1553                _cx: &mut Context<Self>,
1554            ) -> impl IntoElement {
1555                div().w(px(840.)).h(px(400.)).child(
1556                    crate::h_resizable("markdown-width-test")
1557                        .child(crate::resizable_panel().child(div()))
1558                        .child(crate::resizable_panel().child(
1559                            TextView::markdown(
1560                                "list-with-code",
1561                                "1. List item\n   ```rust\n   nested code\n   ```\n\n```rust\ntop-level code\n```",
1562                            )
1563                            .code_block_actions(|code_block, _, _| {
1564                                let selector = if code_block.code().contains("nested") {
1565                                    "nested-code-action"
1566                                } else {
1567                                    "top-level-code-action"
1568                                };
1569                                div()
1570                                    .debug_selector(move || selector.into())
1571                                    .child("Copy")
1572                            })
1573                            .scrollable(true)
1574                            .p_5()
1575                            .flex_none(),
1576                        )),
1577                )
1578            }
1579        }
1580
1581        cx.update(crate::init);
1582        let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
1583        let cx: &mut VisualTestContext = cx;
1584
1585        cx.run_until_parked();
1586        cx.update(|window, cx| {
1587            let _ = window.draw(cx);
1588        });
1589
1590        let nested_action = cx.debug_bounds("nested-code-action").unwrap();
1591        let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
1592        assert!(
1593            top_level_action.right() - nested_action.right() < px(32.),
1594            "nested code block should fill the list item's available width"
1595        );
1596    }
1597
1598    /// Draw a Markdown table with a `table_actions` hook installed, and return
1599    /// the painted bounds of the actions element plus the data it received.
1600    /// `scroll` opts into the horizontally scrollable table layout.
1601    fn draw_table_with_actions(
1602        cx: &mut TestAppContext,
1603        scroll: bool,
1604    ) -> (Bounds<Pixels>, TableData) {
1605        use std::sync::{Arc, Mutex};
1606
1607        struct TableRoot {
1608            scroll: bool,
1609            captured: Arc<Mutex<Vec<TableData>>>,
1610        }
1611
1612        impl Render for TableRoot {
1613            fn render(
1614                &mut self,
1615                _window: &mut Window,
1616                _cx: &mut Context<Self>,
1617            ) -> impl IntoElement {
1618                let captured = self.captured.clone();
1619                let mut table_style = StyleRefinement::default();
1620                if self.scroll {
1621                    table_style.overflow.x = Some(Overflow::Scroll);
1622                }
1623
1624                div().w(px(320.)).child(
1625                    TextView::markdown(
1626                        "table-actions",
1627                        "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
1628                    )
1629                    .style(TextViewStyle::default().with_table(table_style))
1630                    .table_actions(move |table, _, _| {
1631                        if let Ok(mut captured) = captured.lock() {
1632                            captured.push(table.clone());
1633                        }
1634                        div().debug_selector(|| "table-action".into()).child("Copy")
1635                    }),
1636                )
1637            }
1638        }
1639
1640        cx.update(crate::init);
1641        let captured = Arc::new(Mutex::new(Vec::new()));
1642        let (_, cx) = cx.add_window_view({
1643            let captured = captured.clone();
1644            move |_, _| TableRoot { scroll, captured }
1645        });
1646        let cx: &mut VisualTestContext = cx;
1647
1648        cx.run_until_parked();
1649        cx.update(|window, cx| {
1650            let _ = window.draw(cx);
1651        });
1652
1653        let bounds = cx
1654            .debug_bounds("table-action")
1655            .expect("table actions should be painted");
1656        let data = captured
1657            .lock()
1658            .expect("captured table data")
1659            .last()
1660            .cloned()
1661            .expect("table actions hook should receive the table");
1662
1663        (bounds, data)
1664    }
1665
1666    #[gpui::test]
1667    fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
1668        for scroll in [false, true] {
1669            let (bounds, data) = draw_table_with_actions(cx, scroll);
1670
1671            // Header plus two data rows are painted above the actions row.
1672            assert!(
1673                bounds.top() > px(40.),
1674                "actions should sit below the table (scroll: {scroll}), got {:?}",
1675                bounds.top()
1676            );
1677            assert_eq!(data.headers, vec!["Name", "Age"]);
1678            assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
1679            assert_eq!(
1680                data.markdown,
1681                "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
1682            );
1683            assert_eq!(data.span, Some(0..52));
1684        }
1685    }
1686
1687    #[test]
1688    fn plugin_accepts_text_view_plugins_beyond_markdown() {
1689        let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
1690
1691        assert!(view.selectable);
1692    }
1693
1694    #[test]
1695    fn syntax_highlighting_is_opt_in() {
1696        let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
1697        assert!(default_view.code_block_highlighter.is_none());
1698
1699        let view = default_view.code_block_highlighter(|block| {
1700            vec![(
1701                0..block.code().len(),
1702                gpui::HighlightStyle {
1703                    color: Some(gpui::rgb(0x3366ff).into()),
1704                    ..Default::default()
1705                },
1706            )]
1707        });
1708        assert!(view.code_block_highlighter.is_some());
1709    }
1710
1711    #[gpui::test]
1712    fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
1713        cx.update(crate::init);
1714        let (_, cx) = cx.add_window_view(|_, cx| {
1715            TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
1716        });
1717        let cx: &mut VisualTestContext = cx;
1718
1719        cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
1720
1721        assert_eq!(cx.opened_url(), None);
1722    }
1723
1724    struct MaxLinesTestRoot {
1725        text_view: Entity<TextViewState>,
1726        max_lines: usize,
1727    }
1728
1729    impl MaxLinesTestRoot {
1730        fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
1731            let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
1732            Self {
1733                text_view,
1734                max_lines,
1735            }
1736        }
1737    }
1738
1739    impl Render for MaxLinesTestRoot {
1740        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1741            div()
1742                .w(px(200.))
1743                .child(TextView::new(&self.text_view).max_lines(self.max_lines))
1744        }
1745    }
1746
1747    #[test]
1748    fn the_clip_only_moves_for_a_straddling_glyph_line() {
1749        use super::line_safe_clip_bottom;
1750        use crate::text::state::LineSpan;
1751
1752        let spans = [
1753            // Lines end at 20 / 40 / 60.
1754            LineSpan {
1755                top: px(0.),
1756                bottom: px(60.),
1757                line_height: px(20.),
1758            },
1759            // A second block after an 8px gap; lines end at 88 / 108 / 128.
1760            LineSpan {
1761                top: px(68.),
1762                bottom: px(128.),
1763                line_height: px(20.),
1764            },
1765        ];
1766
1767        // Content continues well past the box in every case but the last.
1768        let below = px(400.);
1769
1770        // A box ending inside the line 88..108 leaves that line out whole.
1771        assert_eq!(
1772            line_safe_clip_bottom(&spans, px(100.), below),
1773            Some(px(88.))
1774        );
1775
1776        // A box ending on a line boundary has nothing to pull the clip up for.
1777        assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
1778
1779        // A strip below the last line shorter than a line — the border and
1780        // padding a block leads with — is not worth showing.
1781        assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
1782
1783        // One taller than a line is: whatever crosses the edge keeps the part
1784        // that fits rather than leaving the box half empty.
1785        let one_block = [LineSpan {
1786            top: px(0.),
1787            bottom: px(60.),
1788            line_height: px(20.),
1789        }];
1790        assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
1791
1792        // Nothing crosses the edge at all: the space under the last line is
1793        // the box's own, not a scrap of something below.
1794        assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
1795    }
1796
1797    #[test]
1798    fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
1799        use super::line_safe_clip_bottom;
1800        use crate::text::state::LineSpan;
1801
1802        // A heading line of 28px, in a box capped at one 26px body line.
1803        let heading = [LineSpan {
1804            top: px(70.),
1805            bottom: px(98.),
1806            line_height: px(28.),
1807        }];
1808
1809        assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
1810    }
1811
1812    #[test]
1813    fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
1814        use super::line_safe_clip_bottom;
1815        use crate::text::state::LineSpan;
1816
1817        // Two table rows, each one line of text, 9px of border and padding
1818        // between them.
1819        let rows = [
1820            LineSpan {
1821                top: px(100.),
1822                bottom: px(126.),
1823                line_height: px(26.),
1824            },
1825            LineSpan {
1826                top: px(135.),
1827                bottom: px(161.),
1828                line_height: px(26.),
1829            },
1830        ];
1831
1832        // Leaving out the second row's text would strand the 9px it leads
1833        // with, so the clip goes back to the row above it.
1834        assert_eq!(
1835            line_safe_clip_bottom(&rows, px(148.), px(400.)),
1836            Some(px(126.))
1837        );
1838    }
1839
1840    /// A clamped view nested the way an application nests one: inside a card,
1841    /// inside a region that fills a window of a known height. The height an
1842    /// ancestor hands down must not reach the clamped content and hide the
1843    /// overflow the clamp measures — with the content stretched to the capped
1844    /// box, nothing looks clipped and lines get cut in half.
1845    struct ClampedPageRoot {
1846        text_view: Entity<TextViewState>,
1847        max_lines: usize,
1848    }
1849
1850    impl Render for ClampedPageRoot {
1851        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1852            use crate::{h_flex, v_flex};
1853
1854            v_flex()
1855                .size_full()
1856                .p_4()
1857                .gap_4()
1858                .child(h_flex().max_w(px(480.)).gap_3().child("header"))
1859                .child(
1860                    v_flex()
1861                        .flex_1()
1862                        .min_h_0()
1863                        .gap_4()
1864                        .id("clamped-page-scroll")
1865                        .child(
1866                            v_flex()
1867                                .max_w(px(480.))
1868                                .p_3()
1869                                .gap_2()
1870                                .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
1871                        )
1872                        .overflow_y_scroll(),
1873                )
1874        }
1875    }
1876
1877    #[gpui::test]
1878    fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
1879        cx.update(crate::init);
1880        let (root, cx) = cx.add_window_view(|_, cx| {
1881            let text_view = cx.new(|cx| {
1882                TextViewState::markdown(
1883                    "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
1884                    cx,
1885                )
1886            });
1887            ClampedPageRoot {
1888                text_view,
1889                max_lines: 3,
1890            }
1891        });
1892        let cx: &mut VisualTestContext = cx;
1893
1894        assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1895    }
1896
1897    #[gpui::test]
1898    fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
1899        cx.update(crate::init);
1900        let (root, cx) = cx.add_window_view(|_, cx| {
1901            MaxLinesTestRoot::new(
1902                "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
1903                2,
1904                cx,
1905            )
1906        });
1907        let cx: &mut VisualTestContext = cx;
1908
1909        assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1910    }
1911
1912    #[gpui::test]
1913    fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
1914        cx.update(crate::init);
1915        let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
1916        let cx: &mut VisualTestContext = cx;
1917
1918        assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1919    }
1920
1921    #[gpui::test]
1922    fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
1923        cx.update(crate::init);
1924        let (_, cx) = cx.add_window_view(|_, cx| {
1925            MaxLinesTestRoot::new(
1926                "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
1927                2,
1928                cx,
1929            )
1930        });
1931        let cx: &mut VisualTestContext = cx;
1932
1933        // Click far below the clamped box, where the link would sit unclamped.
1934        cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
1935
1936        assert_eq!(cx.opened_url(), None);
1937    }
1938
1939    #[gpui::test]
1940    fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) {
1941        struct SelectionRoot {
1942            text_view: Entity<TextViewState>,
1943            format: crate::text::SelectionFormat,
1944        }
1945        impl Render for SelectionRoot {
1946            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1947                div()
1948                    .w(px(160.))
1949                    .child(crate::TextSelectionLayer)
1950                    .child(TextView::new(&self.text_view).selection_format(self.format))
1951            }
1952        }
1953        cx.update(crate::init);
1954        let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot {
1955            format: crate::text::SelectionFormat::Plain,
1956            text_view: cx
1957                .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)),
1958        });
1959        let cx: &mut VisualTestContext = cx;
1960        cx.run_until_parked();
1961        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1962        assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
1963        cx.simulate_mouse_down(
1964            point(px(3.), px(8.)),
1965            MouseButton::Left,
1966            Modifiers::default(),
1967        );
1968        cx.update(|window, cx| {
1969            let _ = window.draw(cx);
1970        });
1971        cx.simulate_mouse_move(
1972            point(px(155.), px(20.)),
1973            Some(MouseButton::Left),
1974            Modifiers::default(),
1975        );
1976        cx.update(|window, cx| {
1977            let _ = window.draw(cx);
1978        });
1979        cx.simulate_mouse_up(
1980            point(px(155.), px(20.)),
1981            MouseButton::Left,
1982            Modifiers::default(),
1983        );
1984        cx.update(|window, cx| {
1985            let _ = window.draw(cx);
1986        });
1987        let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
1988        assert_eq!(selected.trim(), "code after");
1989        view.update(cx, |view, cx| {
1990            view.format = crate::text::SelectionFormat::Source;
1991            cx.notify();
1992        });
1993        cx.update(|window, cx| {
1994            let _ = window.draw(cx);
1995        });
1996        let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
1997        assert_eq!(selected.trim(), "[`code`](https://example.com) after");
1998    }
1999
2000    /// Inline-code Markdown takes the deferred `InlineFlow` path. Its layout
2001    /// must use the heading's resolved typography rather than the ambient body
2002    /// style that remains after the heading's style stack has been popped.
2003    ///
2004    /// This is a real request-layout → measured-layout → prepaint → paint test:
2005    /// the deterministic text system makes bold glyphs wider than body glyphs.
2006    /// Before the fix, the code heading was allocated using normal body metrics
2007    /// but its fragments painted bold, so the wrapped heading text crossed into
2008    /// the following paragraph.
2009    #[test]
2010    fn inline_code_heading_reserves_painted_wrapped_lines_and_list_baseline() {
2011        use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2012        use gpui::{TestApp, rems};
2013
2014        const MARKDOWN: &str = "The same words with inline code.\n\nThe same words with `inline code`.\n\n- The same words with inline code.\n- The same words with `inline code`.\n\n# Heading with inline code\n# Heading with `inline code`\n\nthis is a test";
2015
2016        struct MarkdownRoot {
2017            text_view: Entity<TextViewState>,
2018            width: Pixels,
2019            preview_zoom: f32,
2020        }
2021
2022        impl Render for MarkdownRoot {
2023            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2024                div()
2025                    .w(self.width)
2026                    // Match example-markdown: preview zoom changes TextView's
2027                    // inherited text size, not the window rem size.
2028                    .text_size(rems(self.preview_zoom))
2029                    .child(crate::TextSelectionLayer)
2030                    .child(TextView::new(&self.text_view).selectable(true))
2031            }
2032        }
2033
2034        fn draw(
2035            app: &mut TestApp,
2036            width: Pixels,
2037            preview_zoom: f32,
2038        ) -> (Bounds<Pixels>, Vec<Bounds<Pixels>>, String) {
2039            let mut window = app.open_window(|_, cx| MarkdownRoot {
2040                text_view: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
2041                width,
2042                preview_zoom,
2043            });
2044            window.draw();
2045            app.run_until_parked();
2046            window.draw();
2047            window.update(|root, _, cx| {
2048                root.text_view.update(cx, |state, cx| state.select_all(cx));
2049            });
2050            window.draw();
2051            window.read(|root, cx| {
2052                let state = root.text_view.read(cx);
2053                (
2054                    state.bounds(),
2055                    state.selection_adapter.text_bounds(),
2056                    state.selected_text(),
2057                )
2058            })
2059        }
2060
2061        let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2062        app.update(|cx| {
2063            crate::init(cx);
2064            crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2065        });
2066
2067        for (case, width, preview_zoom) in [
2068            // At the root 16px size the plain heading fits, while bold inline
2069            // fragments need their own measured widths.
2070            ("default-16px", px(600.), 1.),
2071            // A narrow view exercises intentional heading wrapping.
2072            ("narrow-16px", px(320.), 1.),
2073            // Preview zoom applies through `.text_size(rems(zoom))`.
2074            ("zoom-1.25", px(600.), 1.25),
2075        ] {
2076            let (view_bounds, text_bounds, selected_text) = draw(&mut app, width, preview_zoom);
2077            assert!(
2078                text_bounds.len() > 8,
2079                "{case}: expected all markdown fragments"
2080            );
2081            assert_eq!(
2082                selected_text.trim(),
2083                "The same words with inline code.\nThe same words with inline code.\nThe same words with inline code.\nThe same words with inline code.\nHeading with inline code\nHeading with inline code\nthis is a test",
2084                "{case}: wrapped inline-code text was lost or duplicated"
2085            );
2086            let following = text_bounds.last().expect("following paragraph must paint");
2087            let painted_bottom = text_bounds
2088                .iter()
2089                .map(|bounds| bounds.bottom())
2090                .max()
2091                .expect("markdown must paint text");
2092            // This is intentionally not a fragment-count or selection-only
2093            // assertion: `text_bounds` are the shaped Inline layouts produced
2094            // during paint. No painted wrapped line may reach the following
2095            // paragraph's origin, and the TextView allocation must contain the
2096            // complete painted document.
2097            assert!(
2098                text_bounds[..text_bounds.len() - 1]
2099                    .iter()
2100                    .all(|bounds| bounds.bottom() <= following.top()),
2101                "{case}: heading/list text painted through the following paragraph;                  following={following:?}, text_bounds={text_bounds:?}, view={view_bounds:?}"
2102            );
2103            assert!(
2104                painted_bottom <= view_bounds.bottom(),
2105                "{case}: TextView height did not reserve its painted text;                  painted_bottom={painted_bottom:?}, view={view_bounds:?}"
2106            );
2107        }
2108    }
2109
2110    /// The code-bearing list item takes `InlineFlow`; the plain item takes the
2111    /// ordinary text path. Their first body glyphs must begin at the same row
2112    /// position relative to their own TextView origins.
2113    #[test]
2114    fn inline_code_list_body_paint_origin_matches_plain_list_item() {
2115        use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2116        use gpui::{TestApp, rems};
2117
2118        struct ListRoot {
2119            plain: Entity<TextViewState>,
2120            code: Entity<TextViewState>,
2121            preview_zoom: f32,
2122        }
2123
2124        impl Render for ListRoot {
2125            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2126                div()
2127                    .w(px(600.))
2128                    .text_size(rems(self.preview_zoom))
2129                    .child(crate::TextSelectionLayer)
2130                    .child(TextView::new(&self.plain).selectable(true))
2131                    .child(TextView::new(&self.code).selectable(true))
2132            }
2133        }
2134
2135        fn draw(app: &mut TestApp, preview_zoom: f32) -> (Pixels, Pixels) {
2136            let mut window = app.open_window(|_, cx| ListRoot {
2137                plain: cx.new(|cx| TextViewState::markdown("- plain body words", cx)),
2138                code: cx.new(|cx| TextViewState::markdown("- plain `code` words", cx)),
2139                preview_zoom,
2140            });
2141            window.draw();
2142            app.run_until_parked();
2143            window.draw();
2144            window.read(|root, cx| {
2145                let first_body_line_top = |view: &Entity<TextViewState>| {
2146                    let view = view.read(cx);
2147                    let painted_line = view
2148                        .selection_adapter
2149                        .text_bounds()
2150                        .into_iter()
2151                        .next()
2152                        .expect("list item should paint its first body line");
2153                    painted_line.top() - view.bounds().top()
2154                };
2155                (
2156                    first_body_line_top(&root.plain),
2157                    first_body_line_top(&root.code),
2158                )
2159            })
2160        }
2161
2162        let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2163        app.update(|cx| {
2164            crate::init(cx);
2165            crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2166        });
2167        for (case, preview_zoom) in [("16px", 1.), ("zoom-1.25", 1.25), ("zoom-1.5", 1.5)] {
2168            let (plain_origin, code_origin) = draw(&mut app, preview_zoom);
2169            assert_eq!(
2170                code_origin, plain_origin,
2171                "{case}: code list body paint origin {code_origin:?} must match plain {plain_origin:?}"
2172            );
2173        }
2174    }
2175
2176    #[test]
2177    fn inline_code_fragment_does_not_paint_past_its_reserved_row() {
2178        use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2179        use gpui::TestApp;
2180
2181        const TEXT_BACKGROUND: u32 = 0x20f0b0;
2182
2183        struct MarkdownRoot {
2184            text_view: Entity<TextViewState>,
2185        }
2186
2187        impl Render for MarkdownRoot {
2188            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2189                div()
2190                    .w(px(640.))
2191                    .text_size(px(17.9))
2192                    .text_bg(gpui::rgb(TEXT_BACKGROUND))
2193                    .child(TextView::new(&self.text_view))
2194            }
2195        }
2196
2197        let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2198        app.update(|cx| {
2199            crate::init(cx);
2200            crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2201        });
2202        let mut window = app.open_window(|_, cx| MarkdownRoot {
2203            text_view: cx.new(|cx| TextViewState::markdown("`main` starts the paragraph", cx)),
2204        });
2205        window.draw();
2206        app.run_until_parked();
2207        window.draw();
2208
2209        let (view_bounds, painted) = window.update(|root, window, cx| {
2210            let view_bounds = root
2211                .text_view
2212                .read(cx)
2213                .bounds()
2214                .scale(window.scale_factor());
2215            let text_background: gpui::Background = gpui::rgb(TEXT_BACKGROUND).into();
2216            let painted = window
2217                .painted_quads()
2218                .into_iter()
2219                .filter(|quad| quad.background == text_background)
2220                .map(|quad| quad.bounds)
2221                .collect::<Vec<_>>();
2222            (view_bounds, painted)
2223        });
2224
2225        assert!(
2226            !painted.is_empty(),
2227            "the inherited text background must make actual text paint observable"
2228        );
2229        assert!(
2230            painted
2231                .iter()
2232                .all(|bounds| bounds.bottom() <= view_bounds.bottom()),
2233            "an inline-code fragment wrapped a second time after InlineFlow reserved one row; \
2234             text background quads={painted:?}, reserved TextView bounds={view_bounds:?}"
2235        );
2236    }
2237
2238    #[gpui::test]
2239    fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
2240        cx.update(crate::init);
2241        let (_, cx) =
2242            cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2243        let cx: &mut VisualTestContext = cx;
2244
2245        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2246
2247        assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2248    }
2249
2250    #[gpui::test]
2251    fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
2252        cx.update(crate::init);
2253        let (_, cx) =
2254            cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2255        let cx: &mut VisualTestContext = cx;
2256
2257        cx.simulate_mouse_down(
2258            point(px(10.), px(10.)),
2259            MouseButton::Right,
2260            Modifiers::default(),
2261        );
2262        cx.simulate_mouse_up(
2263            point(px(10.), px(10.)),
2264            MouseButton::Right,
2265            Modifiers::default(),
2266        );
2267
2268        assert_eq!(cx.opened_url(), None);
2269    }
2270
2271    #[gpui::test]
2272    fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
2273        use std::sync::{Arc, Mutex};
2274
2275        struct LinkRoot {
2276            text_view: Entity<TextViewState>,
2277            clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
2278        }
2279
2280        impl Render for LinkRoot {
2281            fn render(
2282                &mut self,
2283                _window: &mut Window,
2284                _cx: &mut Context<Self>,
2285            ) -> impl IntoElement {
2286                let clicks = self.clicks.clone();
2287                div()
2288                    .w(px(240.))
2289                    .child(
2290                        TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
2291                            clicks.lock().unwrap().push((url.clone(), event.clone()));
2292                        }),
2293                    )
2294            }
2295        }
2296
2297        cx.update(crate::init);
2298        let clicks = Arc::new(Mutex::new(Vec::new()));
2299        let captured = clicks.clone();
2300        let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
2301            text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
2302            clicks,
2303        });
2304        let cx: &mut VisualTestContext = cx;
2305
2306        let mut modifiers = Modifiers::default();
2307        modifiers.control = true;
2308        cx.simulate_click(point(px(10.), px(10.)), modifiers);
2309        cx.simulate_mouse_down(
2310            point(px(10.), px(10.)),
2311            MouseButton::Middle,
2312            Modifiers::default(),
2313        );
2314        cx.simulate_mouse_up(
2315            point(px(10.), px(10.)),
2316            MouseButton::Middle,
2317            Modifiers::default(),
2318        );
2319        cx.simulate_mouse_down(
2320            point(px(10.), px(10.)),
2321            MouseButton::Right,
2322            Modifiers::default(),
2323        );
2324        cx.simulate_mouse_up(
2325            point(px(10.), px(10.)),
2326            MouseButton::Right,
2327            Modifiers::default(),
2328        );
2329
2330        let clicks = captured.lock().unwrap();
2331        assert_eq!(clicks.len(), 3);
2332        assert_eq!(clicks[0].0, "https://example.com");
2333        assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
2334        assert!(clicks[0].1.modifiers().control);
2335        assert!(clicks[1].1.is_middle_click());
2336        assert!(clicks[2].1.is_right_click());
2337        assert_eq!(cx.opened_url(), None);
2338    }
2339
2340    #[gpui::test]
2341    fn inline_object_inherits_bold_and_link_clicks_without_opening_on_drag(
2342        cx: &mut TestAppContext,
2343    ) {
2344        use std::sync::{Arc, Mutex};
2345        struct Root {
2346            state: Entity<TextViewState>,
2347            clicks: Arc<Mutex<Vec<SharedString>>>,
2348        }
2349        impl Render for Root {
2350            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2351                let clicks = self.clicks.clone();
2352                let extensions = crate::text::MarkdownExtensions::default().plugin(
2353                    crate::text::markdown_ext::TestInlinePlugin::new("math")
2354                        .parse_with(|node, _| {
2355                            matches!(node, markdown::mdast::Node::InlineMath(_)).then(|| {
2356                                super::super::MarkdownNode::new("math", ()).text("formula")
2357                            })
2358                        })
2359                        .render_with(|_, context, _, _| {
2360                            assert_eq!(context.text_style().font_weight, gpui::FontWeight::BOLD);
2361                            Some(super::super::InlineElement::new(div().child("formula")))
2362                        }),
2363                );
2364                div().w(px(300.)).child(crate::TextSelectionLayer).child(
2365                    TextView::new(&self.state)
2366                        .markdown_extensions(extensions)
2367                        .on_link_click(move |url, _, _, _| {
2368                            clicks.lock().unwrap().push(url.clone())
2369                        }),
2370                )
2371            }
2372        }
2373        cx.update(crate::init);
2374        let clicks = Arc::new(Mutex::new(Vec::new()));
2375        let captured = clicks.clone();
2376        let (root, cx) = cx.add_window_view(move |_, cx| Root {
2377            state: cx.new(|cx| TextViewState::markdown("**[$x$](https://example.com)**", cx)),
2378            clicks,
2379        });
2380        let cx: &mut VisualTestContext = cx;
2381        cx.run_until_parked();
2382        cx.update(|window, cx| {
2383            let _ = window.draw(cx);
2384        });
2385        let bounds = root.read_with(cx, |root, cx| {
2386            root.state.read(cx).selection_adapter.text_bounds()[0]
2387        });
2388        for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
2389            cx.simulate_mouse_down(bounds.center(), button, Modifiers::default());
2390            cx.simulate_mouse_up(bounds.center(), button, Modifiers::default());
2391        }
2392        assert_eq!(captured.lock().unwrap().len(), 3);
2393        let start = point(bounds.left() + px(1.), bounds.center().y);
2394        let end = point(bounds.right() - px(1.), bounds.center().y);
2395        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
2396        cx.update(|window, cx| {
2397            let _ = window.draw(cx);
2398        });
2399        cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
2400        cx.update(|window, cx| {
2401            let _ = window.draw(cx);
2402        });
2403        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
2404        assert_eq!(captured.lock().unwrap().len(), 3);
2405    }
2406
2407    #[gpui::test]
2408    fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
2409        use std::sync::{Arc, Mutex};
2410
2411        struct LinkedImageRoot {
2412            text_view: Entity<TextViewState>,
2413            clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
2414        }
2415
2416        impl Render for LinkedImageRoot {
2417            fn render(
2418                &mut self,
2419                _window: &mut Window,
2420                _cx: &mut Context<Self>,
2421            ) -> impl IntoElement {
2422                let clicks = self.clicks.clone();
2423                div().w(px(160.)).child(
2424                    TextView::new(&self.text_view)
2425                        .selectable(true)
2426                        .on_link_click(move |url, event, _, _| {
2427                            clicks.lock().unwrap().push((url.clone(), event.clone()));
2428                        }),
2429                )
2430            }
2431        }
2432
2433        cx.update(crate::init);
2434        let clicks = Arc::new(Mutex::new(Vec::new()));
2435        let captured = clicks.clone();
2436        let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
2437                text_view: cx.new(|cx| {
2438                    TextViewState::markdown(
2439                        r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
2440                        cx,
2441                    )
2442                }),
2443                clicks,
2444            }
2445        );
2446        let cx: &mut VisualTestContext = cx;
2447        cx.run_until_parked();
2448        cx.update(|window, cx| {
2449            let _ = window.draw(cx);
2450        });
2451
2452        let inline_bounds = content.read_with(cx, |content, cx| {
2453            content.text_view.read(cx).selection_adapter.text_bounds()
2454        });
2455        assert!(
2456            inline_bounds.len() >= 2,
2457            "linked image needs text bounds on both sides: {inline_bounds:?}"
2458        );
2459        assert!(
2460            inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
2461            "linked image did not reserve the expected click target: {inline_bounds:?}"
2462        );
2463        let position = point(
2464            inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
2465            inline_bounds[0].top() + px(8.),
2466        );
2467        for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
2468            cx.simulate_mouse_down(position, button, Modifiers::default());
2469            cx.simulate_mouse_up(position, button, Modifiers::default());
2470        }
2471
2472        let clicks = captured.lock().unwrap();
2473        assert_eq!(clicks.len(), 3);
2474        assert!(
2475            clicks
2476                .iter()
2477                .all(|(url, _)| url == "https://example.com/image-link")
2478        );
2479        assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
2480        assert!(clicks[1].1.is_middle_click());
2481        assert!(clicks[2].1.is_right_click());
2482        assert_eq!(cx.opened_url(), None);
2483    }
2484
2485    #[gpui::test]
2486    fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
2487        cx.update(crate::init);
2488        let (view, cx) = cx
2489            .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
2490        let cx: &mut VisualTestContext = cx;
2491
2492        cx.simulate_mouse_down(
2493            point(px(10.), px(34.)),
2494            MouseButton::Left,
2495            Modifiers::default(),
2496        );
2497        cx.simulate_mouse_move(
2498            point(px(90.), px(34.)),
2499            Some(MouseButton::Left),
2500            Modifiers::default(),
2501        );
2502        cx.simulate_mouse_up(
2503            point(px(90.), px(34.)),
2504            MouseButton::Left,
2505            Modifiers::default(),
2506        );
2507
2508        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2509        assert!(
2510            selected_text.is_empty(),
2511            "unexpected selection: {selected_text:?}"
2512        );
2513    }
2514
2515    /// A tall selectable TextView clipped by a short `overflow_hidden` viewport,
2516    /// with a large blank footer below so a drag can extend the selection band
2517    /// past the bottom of the clip while still proxy-anchoring to the view.
2518    struct ClippedTallTextViewTestRoot {
2519        text_view: Entity<TextViewState>,
2520    }
2521
2522    impl ClippedTallTextViewTestRoot {
2523        fn new(cx: &mut Context<Self>) -> Self {
2524            // Four separate blocks; only the first (and maybe part of the
2525            // second) fit inside the 40px clip. "charlie"/"delta" render well
2526            // below it.
2527            let text_view =
2528                cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
2529            Self { text_view }
2530        }
2531    }
2532
2533    impl Render for ClippedTallTextViewTestRoot {
2534        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2535            div()
2536                .w(px(200.))
2537                .child(crate::TextSelectionLayer)
2538                .child(
2539                    div()
2540                        .h(px(40.))
2541                        .overflow_hidden()
2542                        .child(TextView::new(&self.text_view).selectable(true)),
2543                )
2544                // A tall blank footer so a drag can reach a y below the clipped
2545                // text; a press there proxy-anchors to the TextView above.
2546                .child(div().h(px(160.)))
2547        }
2548    }
2549
2550    /// Regression for copying a selection taller than the visible viewport.
2551    ///
2552    /// The selection band runs from visible text at the top down to a point
2553    /// far below the clip. Every glyph of the painted TextView is laid out even
2554    /// though the lower ones are clipped away, so the copied text must include
2555    /// the clipped-out "charlie"/"delta" — not just what is on screen. This
2556    /// guards against re-adding a `content_mask` gate in
2557    /// `Inline::layout_selections`.
2558    #[gpui::test]
2559    fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
2560        cx.update(crate::init);
2561        let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
2562        let cx: &mut VisualTestContext = cx;
2563
2564        cx.run_until_parked();
2565        cx.update(|window, cx| {
2566            let _ = window.draw(cx);
2567        });
2568
2569        // Anchor on visible text near the top, then drag to a point well below
2570        // the 40px clip (into the blank footer) and to the far right so the
2571        // last line is fully covered.
2572        cx.simulate_mouse_down(
2573            point(px(2.), px(8.)),
2574            MouseButton::Left,
2575            Modifiers::default(),
2576        );
2577        cx.update(|window, cx| {
2578            let _ = window.draw(cx);
2579        });
2580        cx.simulate_mouse_move(
2581            point(px(180.), px(150.)),
2582            Some(MouseButton::Left),
2583            Modifiers::default(),
2584        );
2585        cx.update(|window, cx| {
2586            let _ = window.draw(cx);
2587        });
2588        cx.simulate_mouse_up(
2589            point(px(180.), px(150.)),
2590            MouseButton::Left,
2591            Modifiers::default(),
2592        );
2593        cx.update(|window, cx| {
2594            let _ = window.draw(cx);
2595        });
2596
2597        let selected_text =
2598            content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2599        assert!(
2600            selected_text.contains("delta"),
2601            "clipped-out text was not copied: {selected_text:?}"
2602        );
2603        assert!(
2604            selected_text.contains("charlie"),
2605            "clipped-out text was not copied: {selected_text:?}"
2606        );
2607    }
2608
2609    #[gpui::test]
2610    fn double_click_selects_word(cx: &mut TestAppContext) {
2611        cx.update(crate::init);
2612        let (view, cx) =
2613            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
2614
2615        let cx: &mut VisualTestContext = cx;
2616        cx.run_until_parked();
2617        cx.update(|window, cx| {
2618            let _ = window.draw(cx);
2619        });
2620        let position = point(px(10.), px(16.));
2621        cx.simulate_event(MouseDownEvent {
2622            position,
2623            modifiers: Modifiers::default(),
2624            button: MouseButton::Left,
2625            click_count: 2,
2626            first_mouse: false,
2627        });
2628        cx.simulate_event(MouseUpEvent {
2629            position,
2630            modifiers: Modifiers::default(),
2631            button: MouseButton::Left,
2632            click_count: 2,
2633        });
2634        cx.update(|window, cx| {
2635            let _ = window.draw(cx);
2636        });
2637
2638        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2639        assert_eq!(selected_text.trim(), "quick");
2640    }
2641
2642    #[gpui::test]
2643    fn long_press_selects_word_then_drag_extends_selection(cx: &mut TestAppContext) {
2644        struct TouchRoot {
2645            text_view: Entity<TextViewState>,
2646        }
2647        impl Render for TouchRoot {
2648            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2649                div()
2650                    .w(px(300.))
2651                    .child(crate::TextSelectionLayer)
2652                    .child(TextView::new(&self.text_view).selectable(true))
2653            }
2654        }
2655        cx.update(crate::init);
2656        let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
2657            text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
2658        });
2659        cx.run_until_parked();
2660        cx.update(|window, cx| {
2661            let _ = window.draw(cx);
2662        });
2663        let start_position = point(px(10.), px(16.));
2664        cx.simulate_event(gpui::LongPressEvent {
2665            phase: gpui::TouchPhase::Started,
2666            start_position,
2667            position: start_position,
2668        });
2669        cx.update(|window, cx| {
2670            let _ = window.draw(cx);
2671        });
2672        assert_eq!(
2673            view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
2674                .trim(),
2675            "quick"
2676        );
2677        for phase in [gpui::TouchPhase::Moved, gpui::TouchPhase::Ended] {
2678            cx.simulate_event(gpui::LongPressEvent {
2679                phase,
2680                start_position,
2681                position: point(px(220.), px(16.)),
2682            });
2683        }
2684        cx.update(|window, cx| {
2685            let _ = window.draw(cx);
2686        });
2687        assert_eq!(
2688            view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
2689                .trim(),
2690            "quick select value"
2691        );
2692    }
2693
2694    #[gpui::test]
2695    fn long_press_release_keeps_handles_which_drag_the_selection(cx: &mut TestAppContext) {
2696        use crate::{SelectionEdge, TextSelection};
2697
2698        struct TouchRoot {
2699            text_view: Entity<TextViewState>,
2700        }
2701        impl Render for TouchRoot {
2702            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2703                div()
2704                    .w(px(300.))
2705                    .child(crate::TextSelectionLayer)
2706                    .child(TextView::new(&self.text_view).selectable(true))
2707            }
2708        }
2709        cx.update(crate::init);
2710        let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
2711            text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
2712        });
2713        let draw = |cx: &mut VisualTestContext| {
2714            cx.update(|window, cx| {
2715                let _ = window.draw(cx);
2716            });
2717        };
2718        let selected = |cx: &mut VisualTestContext| {
2719            view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
2720                .trim()
2721                .to_string()
2722        };
2723        cx.run_until_parked();
2724        draw(cx);
2725
2726        let start_position = point(px(70.), px(16.));
2727        for phase in [gpui::TouchPhase::Started, gpui::TouchPhase::Ended] {
2728            cx.simulate_event(gpui::LongPressEvent {
2729                phase,
2730                start_position,
2731                position: start_position,
2732            });
2733            draw(cx);
2734        }
2735        assert_eq!(selected(cx), "select");
2736        let snapshot = cx
2737            .update(|window, cx| TextSelection::touch_selection(window, cx))
2738            .expect("a released long press keeps its handles");
2739        assert!(snapshot.is_menu_open());
2740        assert!(!snapshot.is_empty());
2741        assert!(snapshot.start().left() < snapshot.end().left());
2742
2743        // Drag the end handle to the end of the line. The finger holds the
2744        // knob below the line, the selection follows along the line.
2745        let end = snapshot.end();
2746        let finger = point(end.left(), end.bottom() + px(20.));
2747        cx.update(|window, cx| {
2748            TextSelection::begin_edge_drag(SelectionEdge::End, finger, window, cx);
2749        });
2750        draw(cx);
2751        let snapshot = cx
2752            .update(|window, cx| TextSelection::touch_selection(window, cx))
2753            .unwrap();
2754        assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
2755        assert!(!snapshot.is_menu_open());
2756        cx.update(|window, cx| {
2757            TextSelection::update_edge_drag(point(px(290.), finger.y), window, cx);
2758        });
2759        draw(cx);
2760        assert_eq!(selected(cx), "select value");
2761        cx.update(|window, cx| TextSelection::end_edge_drag(window, cx));
2762        draw(cx);
2763        let snapshot = cx
2764            .update(|window, cx| TextSelection::touch_selection(window, cx))
2765            .unwrap();
2766        assert!(snapshot.is_menu_open());
2767        assert_eq!(snapshot.dragging(), None);
2768        let select_start = snapshot.start().left();
2769
2770        // Select All from the menu is a view-local selection; its handles
2771        // still drag, turning it back into a point selection.
2772        cx.update(|_, cx| {
2773            view.update(cx, |root, cx| {
2774                root.text_view.update(cx, |state, cx| state.select_all(cx));
2775            });
2776        });
2777        draw(cx);
2778        assert_eq!(selected(cx), "quick select value");
2779        let snapshot = cx
2780            .update(|window, cx| TextSelection::touch_selection(window, cx))
2781            .expect("select all keeps the touch selection");
2782        let start = snapshot.start();
2783        cx.update(|window, cx| {
2784            TextSelection::begin_edge_drag(SelectionEdge::Start, start.origin, window, cx);
2785            TextSelection::update_edge_drag(point(select_start, start.origin.y), window, cx);
2786            TextSelection::end_edge_drag(window, cx);
2787        });
2788        draw(cx);
2789        assert_eq!(selected(cx), "select value");
2790
2791        // A press on the menu leaves the selection alone; one on the text
2792        // clears it.
2793        let menu = gpui::Bounds::new(point(px(0.), px(200.)), gpui::size(px(120.), px(32.)));
2794        cx.update(|window, cx| TextSelection::register_touch_ui(menu, window, cx));
2795        cx.simulate_event(MouseDownEvent {
2796            position: point(px(10.), px(210.)),
2797            modifiers: Modifiers::default(),
2798            button: MouseButton::Left,
2799            click_count: 1,
2800            first_mouse: false,
2801        });
2802        assert_eq!(selected(cx), "select value");
2803        cx.simulate_event(MouseDownEvent {
2804            position: point(px(10.), px(16.)),
2805            modifiers: Modifiers::default(),
2806            button: MouseButton::Left,
2807            click_count: 1,
2808            first_mouse: false,
2809        });
2810        cx.simulate_event(MouseUpEvent {
2811            position: point(px(10.), px(16.)),
2812            modifiers: Modifiers::default(),
2813            button: MouseButton::Left,
2814            click_count: 1,
2815        });
2816        draw(cx);
2817        assert!(
2818            cx.update(|window, cx| TextSelection::touch_selection(window, cx))
2819                .is_none()
2820        );
2821    }
2822
2823    #[gpui::test]
2824    fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
2825        cx.update(crate::init);
2826        let (view, cx) =
2827            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
2828
2829        let cx: &mut VisualTestContext = cx;
2830        cx.run_until_parked();
2831        cx.update(|window, cx| {
2832            let _ = window.draw(cx);
2833        });
2834
2835        let position = point(px(10.), px(10.));
2836        cx.simulate_event(MouseDownEvent {
2837            position,
2838            modifiers: Modifiers::default(),
2839            button: MouseButton::Left,
2840            click_count: 3,
2841            first_mouse: false,
2842        });
2843        cx.simulate_event(MouseUpEvent {
2844            position,
2845            modifiers: Modifiers::default(),
2846            button: MouseButton::Left,
2847            click_count: 3,
2848        });
2849        cx.update(|window, cx| {
2850            let _ = window.draw(cx);
2851        });
2852
2853        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2854        assert_eq!(selected_text.trim(), "quick select value");
2855    }
2856
2857    // Regression: markdown `TextView` items inside an outer `gpui::list` with
2858    // `measure_all` must keep a stable total content height while scrolling.
2859    // Before synchronous full-replace parsing, off-screen markdown views were
2860    // first measured with empty content and the scrollbar thumb jittered as the
2861    // total height grew during scrolling.
2862    #[gpui::test]
2863    fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
2864        use gpui::{ListAlignment, ListState, list};
2865
2866        const ITEMS: &[&str] = &[
2867            "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
2868            "Short.",
2869            "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
2870            "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
2871            "Only one line.",
2872            "**Bold**: medium length text with `code` mixed with regular words.",
2873            "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
2874            "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
2875        ];
2876        let n = 40usize;
2877
2878        struct ListRoot {
2879            state: ListState,
2880        }
2881        impl Render for ListRoot {
2882            fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2883                div().w(px(360.)).h(px(500.)).child(
2884                    list(self.state.clone(), |ix, _w, _cx| {
2885                        div()
2886                            .w_full()
2887                            .child(TextView::markdown(
2888                                ("md", ix as u64),
2889                                ITEMS[ix % ITEMS.len()],
2890                            ))
2891                            .into_any_element()
2892                    })
2893                    .size_full(),
2894                )
2895            }
2896        }
2897
2898        cx.update(crate::init);
2899        let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
2900        let probe = state.clone();
2901        let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
2902        let cx: &mut VisualTestContext = cx;
2903
2904        cx.run_until_parked();
2905        cx.update(|w, cx| {
2906            let _ = w.draw(cx);
2907        });
2908        cx.run_until_parked();
2909        cx.update(|w, cx| {
2910            let _ = w.draw(cx);
2911        });
2912
2913        let total = |p: &ListState| {
2914            f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
2915        };
2916        let mut totals = vec![total(&probe)];
2917        for _ in 0..20 {
2918            probe.scroll_by(px(150.));
2919            cx.update(|w, cx| {
2920                let _ = w.draw(cx);
2921            });
2922            cx.run_until_parked();
2923            totals.push(total(&probe));
2924        }
2925        let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
2926        let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
2927        println!(
2928            "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
2929            max - min
2930        );
2931        assert!(
2932            (max - min) < 2.0,
2933            "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
2934        );
2935    }
2936}