Skip to main content

gpui_base/text/
text_view.rs

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