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            if text_view_style.is_some()
676                || match (&state.code_block_highlighter, &code_block_highlighter) {
677                    (Some(previous), Some(next)) => !Arc::ptr_eq(previous, next),
678                    (None, None) => false,
679                    _ => true,
680                }
681            {
682                state.preserve_selection_for_reflow();
683            }
684            state.code_block_actions = self.code_block_actions.clone();
685            state.code_block_highlighter = code_block_highlighter;
686            state.table_actions = self.table_actions.clone();
687            state.link_click_handler = self.link_click_handler.clone();
688            state.image_source = self.image_source.clone();
689            state.set_markdown_extensions(self.markdown_extensions.clone(), cx);
690            if let Some(motion) = &self.motion {
691                state.set_motion(motion.clone());
692            }
693            state.selectable = self.selectable;
694            state.selection_format = self.selection_format;
695            state.scrollable = self.scrollable;
696            state.max_lines = max_lines;
697            if let Some(text_view_style) = text_view_style {
698                state.text_view_style = text_view_style;
699            }
700
701            if let Some(text) = &self.text {
702                state.set_element_text(text, cx);
703            }
704        });
705
706        let focus_handle = state.read(cx).focus_handle.clone();
707        let list_state = state.read(cx).list_state.clone();
708        // Cap the box at `n` body-text lines (the effective text style may be
709        // refined by this view's own style, e.g. `.text_sm()`); hidden
710        // overflow also clips descendant hitboxes to the box during prepaint.
711        let max_lines_cap = max_lines.map(|max_lines| {
712            let mut text_style = window.text_style();
713            text_style.refine(&self.style.text);
714            text_style.line_height_in_pixels(window.rem_size()) * max_lines as f32
715        });
716
717        let mut el = div()
718            .id(("text-view-scroll", state.entity_id()))
719            .key_context("TextView")
720            .track_focus(&focus_handle)
721            .when(self.scrollable, |this| this.size_full())
722            .when_some(max_lines_cap, |this, cap| this.max_h(cap).overflow_hidden())
723            .relative()
724            .text_color(foreground)
725            .on_action(move |_: &crate::input::Copy, window, cx| {
726                let text = TextSelection::selected_text(window, cx).trim().to_string();
727                if text.is_empty() {
728                    cx.propagate();
729                    return;
730                }
731                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
732            })
733            .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
734            .child(state.clone())
735            // Overlay controls must paint after the document, otherwise rich
736            // content and selection backgrounds cover the thumb and hitbox.
737            .when(self.scrollable, |this| {
738                this.child(
739                    div().absolute().inset_0().child(
740                        crate::Scrollbar::vertical(&list_state)
741                            .id(("text-view-scrollbar", state.entity_id()))
742                            .viewport_from_layout(),
743                    ),
744                )
745            })
746            .refine_style(&self.style)
747            .into_any_element();
748        let layout_id = el.request_layout(window, cx);
749        (layout_id, TextViewLayoutState { state, element: el })
750    }
751
752    fn prepaint(
753        &mut self,
754        _: Option<&GlobalElementId>,
755        _: Option<&InspectorElementId>,
756        bounds: Bounds<Pixels>,
757        request_layout: &mut Self::RequestLayoutState,
758        window: &mut Window,
759        cx: &mut App,
760    ) -> Self::PrepaintState {
761        let state = request_layout.state.clone();
762        let max_lines_active = state.read(cx).max_lines.is_some();
763        if max_lines_active {
764            if let Ok(mut line_spans) = state.read(cx).line_spans.lock() {
765                line_spans.clear();
766            }
767            // Descendant `Inline`s report their line spans through the state
768            // stack during prepaint (in addition to the paint-time push below).
769            GlobalState::global(cx).push_text_view_state(state.clone());
770        }
771        request_layout.element.prepaint(window, cx);
772        if max_lines_active {
773            GlobalState::global(cx).pop_text_view_state();
774        }
775
776        let mut clip_bottom = None;
777        if max_lines_active {
778            let (line_spans, content_bottom) = {
779                let state = state.read(cx);
780                (
781                    state
782                        .line_spans
783                        .lock()
784                        .map(|spans| spans.clone())
785                        .unwrap_or_default(),
786                    state.bounds().bottom(),
787                )
788            };
789            // The content keeps its natural height inside the capped box, so
790            // this sees everything the box cannot show — including a tall image
791            // that reports no lines of its own.
792            let clipped = content_bottom > bounds.bottom() + px(1.);
793            // Notify on change so observers (e.g. an "expand" button gated on
794            // `is_clamped`) re-render once the flag flips.
795            if state.read(cx).clamped != clipped {
796                state.update(cx, |state, cx| {
797                    state.clamped = clipped;
798                    cx.notify();
799                });
800            }
801            if clipped {
802                clip_bottom = line_safe_clip_bottom(&line_spans, bounds.bottom(), content_bottom);
803            }
804        }
805
806        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
807        // Over the text, so after its hitbox.
808        let touch_handles = if self.selectable {
809            state
810                .read(cx)
811                .selection_adapter
812                .prepaint_touch_handles(window, cx)
813        } else {
814            crate::TouchHandleLayout::default()
815        };
816        TextViewPrepaintState {
817            hitbox,
818            clip_bottom,
819            touch_handles,
820        }
821    }
822
823    fn paint(
824        &mut self,
825        _: Option<&GlobalElementId>,
826        _: Option<&InspectorElementId>,
827        bounds: Bounds<Pixels>,
828        request_layout: &mut Self::RequestLayoutState,
829        prepaint: &mut Self::PrepaintState,
830        window: &mut Window,
831        cx: &mut App,
832    ) {
833        let state = &request_layout.state;
834        if self.selectable {
835            state.read(cx).selection_adapter.begin_frame();
836        }
837
838        GlobalState::global(cx).push_text_view_state(state.clone());
839        if let Some(clip_bottom) = prepaint.clip_bottom {
840            // Snap the `max_lines` clip to the last whole line that fits, so a
841            // line of glyphs is never cut in half.
842            let mask = ContentMask {
843                bounds: Bounds::from_corners(bounds.origin, point(bounds.right(), clip_bottom)),
844            };
845            window.with_content_mask(Some(mask), |window| {
846                request_layout.element.paint(window, cx);
847            });
848        } else {
849            request_layout.element.paint(window, cx);
850        }
851        GlobalState::global(cx).pop_text_view_state();
852
853        // Every list has scrolled by now, so the line of a reveal is where
854        // it ends up this frame.
855        if state.read(cx).pending_reveal.is_some() {
856            let progress = state.update(cx, |state, _| {
857                state.pending_reveal.as_mut().map(PendingReveal::progress)
858            });
859            match progress {
860                Some(RevealProgress::Shown) => {
861                    state.update(cx, |state, _| state.pending_reveal = None);
862                }
863                Some(RevealProgress::Hidden(line)) => {
864                    if let Some(handler) = &self.reveal_handler {
865                        handler(line, window, cx);
866                    }
867                }
868                Some(RevealProgress::NotLaidOut) | None => {}
869            }
870        }
871
872        if self.selectable {
873            let (adapter, scroll_offset, content_bounds, self_scroll, handle_color) = {
874                let state = state.read(cx);
875                (
876                    state.selection_adapter.clone(),
877                    state.scroll_offset(),
878                    state.bounds(),
879                    state.scrollable,
880                    state.text_view_style.selection().alpha(1.),
881                )
882            };
883            let document_order = GlobalState::global(cx).next_selection_document_order();
884            adapter.register(
885                prepaint.hitbox.clone(),
886                content_bounds,
887                scroll_offset,
888                document_order,
889                self_scroll,
890                window,
891                cx,
892            );
893            // The handles of a touch selection go over the text, and under
894            // whatever is painted over the text after it.
895            adapter.paint_touch_handles(&prepaint.touch_handles, handle_color, window, cx);
896        }
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use std::sync::{
903        Arc,
904        atomic::{AtomicUsize, Ordering},
905    };
906
907    use super::{TextView, TextViewPlugin};
908    use crate::text::{TableData, TextViewState, TextViewStyle};
909    use gpui::{
910        AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
911        Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
912        Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
913        TestAppContext, VisualTestContext, Window, div, point, prelude::FluentBuilder as _, px,
914        rems,
915    };
916
917    struct TextViewTestRoot {
918        text_view: Entity<TextViewState>,
919    }
920
921    struct InlineHoverTestRoot {
922        view: Entity<TextViewState>,
923        builds: Arc<AtomicUsize>,
924        format: crate::text::SelectionFormat,
925    }
926
927    struct InlineHoverCard;
928    impl Render for InlineHoverCard {
929        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
930            div()
931                .debug_selector(|| "inline-test-card".into())
932                .w(px(120.))
933                .h(px(40.))
934                .child("Member profile")
935        }
936    }
937
938    impl Render for InlineHoverTestRoot {
939        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
940            let builds = self.builds.clone();
941            div()
942                .pl(px(150.))
943                .w(px(300.))
944                .text_size(px(16.))
945                .child(crate::TextSelectionLayer)
946                .child(
947                    TextView::new(&self.view)
948                        .selection_format(self.format)
949                        .plugin(
950                            crate::text::markdown_ext::TestInlinePlugin::new("mention")
951                                .parse_with(|node, _| {
952                                    let markdown::mdast::Node::Link(link) = node else {
953                                        return None;
954                                    };
955                                    let handle = link.url.strip_prefix("mention:")?;
956                                    Some(
957                                        crate::text::MarkdownNode::new("mention", ())
958                                            .text(format!("@{handle}")),
959                                    )
960                                })
961                                .render_with(move |_, _, _, _| {
962                                    let builds = builds.clone();
963                                    Some(crate::text::InlineElement::new(
964                                        crate::HoverCard::new("mention-hover")
965                                            .anchor(gpui::Anchor::TopCenter)
966                                            .trigger(div().child("@member"))
967                                            .content(move |_, _, cx| {
968                                                builds.fetch_add(1, Ordering::Relaxed);
969                                                div()
970                                                    .id("hover-content")
971                                                    .child(cx.new(|_| InlineHoverCard))
972                                            }),
973                                    ))
974                                }),
975                        ),
976                )
977        }
978    }
979
980    #[gpui::test]
981    fn text_view_follows_the_text_color_of_its_container(cx: &mut TestAppContext) {
982        struct Root {
983            inherited: Entity<TextViewState>,
984            explicit: Entity<TextViewState>,
985            surface_text: gpui::Hsla,
986        }
987        impl Render for Root {
988            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
989                div()
990                    .w(px(300.))
991                    .text_color(crate::ColorTokens::light().foreground)
992                    .child(
993                        div()
994                            .text_color(self.surface_text)
995                            .child(TextView::new(&self.inherited))
996                            .child(TextView::new(&self.explicit).style(TextViewStyle::default())),
997                    )
998            }
999        }
1000
1001        let colors = crate::ColorTokens::light();
1002        cx.update(|cx| {
1003            crate::init(cx);
1004            super::TextViewDefaults::new()
1005                .with_style(TextViewStyle::default())
1006                .with_inherit_text_color(true)
1007                .install(cx);
1008        });
1009        let (root, cx) = cx.add_window_view(|_, cx| Root {
1010            inherited: cx.new(|cx| TextViewState::markdown("[link](https://a.b) `code`", cx)),
1011            explicit: cx.new(|cx| TextViewState::markdown("text", cx)),
1012            surface_text: colors.primary_foreground,
1013        });
1014        cx.run_until_parked();
1015        cx.update(|window, cx| {
1016            window.draw(cx).clear(cx);
1017        });
1018
1019        root.read_with(cx, |root, cx| {
1020            let inherited = &root.inherited.read(cx).text_view_style;
1021            assert_eq!(inherited.foreground(), colors.primary_foreground);
1022            assert_eq!(inherited.link(), colors.primary_foreground);
1023            assert!(inherited.is_dark());
1024
1025            let explicit = &root.explicit.read(cx).text_view_style;
1026            assert_eq!(explicit.foreground(), colors.foreground);
1027            assert_eq!(explicit.link(), TextViewStyle::default().link());
1028        });
1029    }
1030
1031    #[gpui::test]
1032    fn inline_plugin_reuses_render_and_preserves_native_child_events(cx: &mut TestAppContext) {
1033        use std::sync::Mutex;
1034        struct ControlPlugin(Arc<Mutex<Vec<String>>>);
1035        impl crate::text::MarkdownPlugin for ControlPlugin {
1036            fn name(&self) -> &str {
1037                "control"
1038            }
1039            fn parse(
1040                &self,
1041                node: &markdown::mdast::Node,
1042                _: &crate::text::MarkdownParseContext<'_>,
1043            ) -> Option<crate::text::MarkdownNode> {
1044                let markdown::mdast::Node::Link(link) = node else {
1045                    return None;
1046                };
1047                let label = link.url.strip_prefix("control:")?;
1048                Some(crate::text::MarkdownNode::new("control", ()).text(label.to_string()))
1049            }
1050            fn render(
1051                &self,
1052                node: &crate::text::MarkdownNode,
1053                _: &mut Window,
1054                _: &mut gpui::App,
1055            ) -> impl IntoElement {
1056                let clicks = self.0.clone();
1057                let label = node.as_text().to_string();
1058                div()
1059                    .id("same-control-id")
1060                    .w(px(60.))
1061                    .h(px(24.))
1062                    .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1063                    .on_click(move |_, _, _| clicks.lock().unwrap().push(label.clone()))
1064                    .child(node.as_text().to_string())
1065            }
1066        }
1067        struct Root {
1068            view: Entity<TextViewState>,
1069            clicks: Arc<Mutex<Vec<String>>>,
1070        }
1071        impl Render for Root {
1072            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1073                div()
1074                    .w(px(300.))
1075                    .child(crate::TextSelectionLayer)
1076                    .child(TextView::new(&self.view).plugin(ControlPlugin(self.clicks.clone())))
1077            }
1078        }
1079        cx.update(crate::init);
1080        let clicks = Arc::new(Mutex::new(Vec::new()));
1081        let captured = clicks.clone();
1082        let (root, cx) = cx.add_window_view(move |_, cx| Root {
1083            view: cx.new(|cx| TextViewState::markdown("[one](control:one)[two](control:two)", cx)),
1084            clicks,
1085        });
1086        cx.run_until_parked();
1087        cx.update(|window, cx| {
1088            window.draw(cx).clear(cx);
1089        });
1090        let bounds = root.read_with(cx, |root, cx| {
1091            root.view.read(cx).selection_adapter.text_bounds()
1092        });
1093        assert_eq!(bounds.len(), 2);
1094        for bounds in bounds {
1095            cx.simulate_click(bounds.center(), Modifiers::default());
1096            cx.update(|window, cx| {
1097                window.draw(cx).clear(cx);
1098            });
1099        }
1100        assert_eq!(*captured.lock().unwrap(), vec!["one", "two"]);
1101    }
1102
1103    #[gpui::test]
1104    fn inline_hover_card_is_lazy_and_retains_atomic_copy(cx: &mut TestAppContext) {
1105        cx.update(crate::init);
1106        let builds = Arc::new(AtomicUsize::new(0));
1107        let source = "[@member](mention:member)";
1108        let (root, cx) = cx.add_window_view(|_, cx| InlineHoverTestRoot {
1109            view: cx.new(|cx| TextViewState::markdown(source, cx)),
1110            builds: builds.clone(),
1111            format: crate::text::SelectionFormat::Plain,
1112        });
1113        cx.run_until_parked();
1114        cx.update(|window, cx| window.draw(cx).clear(cx));
1115        assert_eq!(builds.load(Ordering::Relaxed), 0);
1116        let view = root.read_with(cx, |root, _| root.view.clone());
1117        let bounds = view.read_with(cx, |view, _| view.selection_adapter.text_bounds()[0]);
1118        cx.update(|window, cx| {
1119            window.simulate_mouse_move(point(bounds.right() - px(1.), bounds.center().y), cx)
1120        });
1121        cx.run_until_parked();
1122        cx.executor()
1123            .advance_clock(std::time::Duration::from_secs(1));
1124        cx.run_until_parked();
1125        cx.update(|window, cx| window.draw(cx).clear(cx));
1126        assert!(
1127            builds.load(Ordering::Relaxed) > 0,
1128            "hover did not build the card"
1129        );
1130        cx.update(|window, cx| window.draw(cx).clear(cx));
1131        let card = cx
1132            .debug_bounds("inline-test-card")
1133            .expect("hover card should be painted");
1134        assert!(
1135            (card.center().x - bounds.center().x).abs() < px(1.),
1136            "card {card:?} must be centered on mention {bounds:?}"
1137        );
1138        assert!(
1139            card.top() >= bounds.bottom(),
1140            "card should be below the mention"
1141        );
1142        for (format, expected) in [
1143            (crate::text::SelectionFormat::Plain, "@member"),
1144            (crate::text::SelectionFormat::Source, source),
1145        ] {
1146            root.update(cx, |root, cx| {
1147                root.format = format;
1148                cx.notify();
1149            });
1150            view.update(cx, |view, cx| {
1151                view.set_selection_format(format, cx);
1152                view.select_all(cx);
1153            });
1154            cx.update(|window, cx| window.draw(cx).clear(cx));
1155            assert_eq!(
1156                view.read_with(cx, |view, _| view.selected_text()).trim(),
1157                expected
1158            );
1159        }
1160    }
1161
1162    struct InlinePluginTestRoot {
1163        text_view: Entity<TextViewState>,
1164        width: Pixels,
1165        font_size: Pixels,
1166        source_format: bool,
1167        prepared_size: Option<Arc<AtomicUsize>>,
1168    }
1169
1170    impl Render for InlinePluginTestRoot {
1171        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1172            let prepared_size = self.prepared_size.clone();
1173            div().w(self.width).text_size(self.font_size).child(crate::TextSelectionLayer).child(
1174                TextView::new(&self.text_view)
1175                    .selection_format(if self.source_format { crate::text::SelectionFormat::Source }
1176                        else { crate::text::SelectionFormat::Plain })
1177
1178                    .plugin(crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(|node, _| {
1179                        let markdown::mdast::Node::InlineMath(math) = node else { return None };
1180                        Some(crate::text::MarkdownNode::new("math", ()).text(format!("{}²", math.value)))
1181                    }).render_with(move |_, context, _, _| {
1182                        let value = prepared_size.as_ref()?.load(Ordering::Relaxed) as f32;
1183                        let image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Svg,
1184                            b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><path d=\"M0 0L40 40\" stroke=\"black\"/></svg>".to_vec()));
1185                        let unit = context.font_size() / 16.;
1186                        Some(crate::text::InlineElement::new(gpui::img(image).w(unit * value).h(unit * value)).with_baseline(unit * value * 0.75))
1187                    })))
1188        }
1189    }
1190
1191    #[gpui::test]
1192    fn inline_plugins_drag_and_copy_across_formulas_in_both_directions(cx: &mut TestAppContext) {
1193        cx.update(crate::init);
1194        let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1195            text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1196            width: px(420.),
1197            font_size: px(16.),
1198            source_format: false,
1199            prepared_size: None,
1200        });
1201        let cx: &mut VisualTestContext = cx;
1202        for font_size in [16., 24., 32.] {
1203            for width in [60., 160., 420.] {
1204                for prepared in [None, Some(32)] {
1205                    for source_format in [false, true] {
1206                        root.update(cx, |root, cx| {
1207                            root.source_format = source_format;
1208                            root.width = px(width);
1209                            root.font_size = px(font_size);
1210                            root.prepared_size =
1211                                prepared.map(|value| Arc::new(AtomicUsize::new(value)));
1212                            cx.notify();
1213                        });
1214                        cx.run_until_parked();
1215                        cx.update(|window, cx| window.draw(cx).clear(cx));
1216                        let bounds =
1217                            root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1218                        let text_bounds = root.read_with(cx, |root, cx| {
1219                            root.text_view.read(cx).selection_adapter.text_bounds()
1220                        });
1221                        let first = text_bounds.first().unwrap();
1222                        let last = text_bounds.last().unwrap();
1223                        let left =
1224                            point(first.left() + px(0.1), first.top() + first.size.height / 2.);
1225                        let right =
1226                            point(last.right() - px(0.1), last.top() + last.size.height / 2.);
1227                        for (start, end) in [(left, right), (right, left)] {
1228                            cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1229                            cx.update(|window, cx| window.draw(cx).clear(cx));
1230                            cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1231                            cx.update(|window, cx| window.draw(cx).clear(cx));
1232                            cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1233                            cx.update(|window, cx| window.draw(cx).clear(cx));
1234                            let selected = root
1235                                .read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1236                            assert_eq!(
1237                                selected.trim(),
1238                                if source_format {
1239                                    "中文 $x$ $y$ English"
1240                                } else {
1241                                    "中文 x² y² English"
1242                                },
1243                                "start={start:?} end={end:?} bounds={bounds:?} width={width} font_size={font_size} prepared={prepared:?}"
1244                            );
1245                        }
1246                    }
1247                }
1248            }
1249        }
1250    }
1251
1252    #[gpui::test]
1253    fn inline_resource_size_update_reflows_without_changing_selection(cx: &mut TestAppContext) {
1254        cx.update(crate::init);
1255        let prepared_size = Arc::new(AtomicUsize::new(16));
1256        let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1257            text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1258            width: px(160.),
1259            font_size: px(16.),
1260            source_format: false,
1261            prepared_size: Some(prepared_size.clone()),
1262        });
1263        let cx: &mut VisualTestContext = cx;
1264        cx.run_until_parked();
1265        cx.update(|window, cx| window.draw(cx).clear(cx));
1266        let before = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1267        let regions = root.read_with(cx, |root, cx| {
1268            root.text_view.read(cx).selection_adapter.text_bounds()
1269        });
1270        let first = regions.first().unwrap();
1271        let last = regions.last().unwrap();
1272        let start = point(first.left() + px(0.1), first.top() + px(10.));
1273        let end = point(last.right() - px(0.1), last.top() + px(10.));
1274        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1275        cx.update(|window, cx| window.draw(cx).clear(cx));
1276        cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1277        cx.update(|window, cx| window.draw(cx).clear(cx));
1278        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1279        cx.update(|window, cx| window.draw(cx).clear(cx));
1280        assert_eq!(
1281            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1282                .trim(),
1283            "中文 x² y² English"
1284        );
1285        prepared_size.store(100, Ordering::Relaxed);
1286        root.update(cx, |root, cx| {
1287            root.text_view
1288                .update(cx, |state, cx| state.invalidate_inline_layout(cx))
1289        });
1290        cx.run_until_parked();
1291        cx.update(|window, cx| window.draw(cx).clear(cx));
1292        let after = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1293        assert!(after.size.height > before.size.height * 2.);
1294        assert_eq!(
1295            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1296                .trim(),
1297            "中文 x² y² English"
1298        );
1299    }
1300
1301    #[gpui::test]
1302    fn triple_click_on_formula_selects_its_entire_mixed_line(cx: &mut TestAppContext) {
1303        cx.update(crate::init);
1304        let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1305            text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1306            width: px(420.),
1307            font_size: px(16.),
1308            source_format: false,
1309            prepared_size: None,
1310        });
1311        let cx: &mut VisualTestContext = cx;
1312        cx.run_until_parked();
1313        cx.update(|window, cx| window.draw(cx).clear(cx));
1314        let regions = root.read_with(cx, |root, cx| {
1315            root.text_view.read(cx).selection_adapter.text_bounds()
1316        });
1317        let formula = regions[1];
1318        let position = formula.center();
1319        cx.simulate_event(MouseDownEvent {
1320            position,
1321            modifiers: Modifiers::default(),
1322            button: MouseButton::Left,
1323            click_count: 3,
1324            first_mouse: false,
1325        });
1326        cx.simulate_event(MouseUpEvent {
1327            position,
1328            modifiers: Modifiers::default(),
1329            button: MouseButton::Left,
1330            click_count: 3,
1331        });
1332        cx.update(|window, cx| window.draw(cx).clear(cx));
1333        assert_eq!(
1334            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1335                .trim(),
1336            "before x² after"
1337        );
1338        root.update(cx, |root, cx| {
1339            root.source_format = true;
1340            cx.notify();
1341        });
1342        cx.update(|window, cx| window.draw(cx).clear(cx));
1343        assert_eq!(
1344            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1345                .trim(),
1346            "before $x$ after"
1347        );
1348    }
1349
1350    #[gpui::test]
1351    fn double_click_on_formula_selects_only_that_object(cx: &mut TestAppContext) {
1352        cx.update(crate::init);
1353        let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1354            text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1355            width: px(420.),
1356            font_size: px(16.),
1357            source_format: false,
1358            prepared_size: None,
1359        });
1360        let cx: &mut VisualTestContext = cx;
1361        cx.run_until_parked();
1362        cx.update(|window, cx| window.draw(cx).clear(cx));
1363        let regions = root.read_with(cx, |root, cx| {
1364            root.text_view.read(cx).selection_adapter.text_bounds()
1365        });
1366        let position = regions[1].center();
1367        cx.simulate_event(MouseDownEvent {
1368            position,
1369            modifiers: Modifiers::default(),
1370            button: MouseButton::Left,
1371            click_count: 2,
1372            first_mouse: false,
1373        });
1374        cx.simulate_event(MouseUpEvent {
1375            position,
1376            modifiers: Modifiers::default(),
1377            button: MouseButton::Left,
1378            click_count: 2,
1379        });
1380        cx.update(|window, cx| window.draw(cx).clear(cx));
1381        // Two clicks stop at the object; only three take the whole line.
1382        assert_eq!(
1383            root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1384                .trim(),
1385            "x²"
1386        );
1387    }
1388
1389    /// A scrollable viewport, so the list has a bounded height to measure
1390    /// against and `max_offset_for_scrollbar` reports a real scroll extent.
1391    struct ScrollExtentTestRoot {
1392        text_view: Entity<TextViewState>,
1393    }
1394
1395    impl Render for ScrollExtentTestRoot {
1396        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1397            div()
1398                .w(px(400.))
1399                .h(px(200.))
1400                .overflow_hidden()
1401                .child(TextView::new(&self.text_view).scrollable(true))
1402        }
1403    }
1404
1405    /// `count` paragraphs, each `words` words long, so two documents can share
1406    /// a block count while differing wildly in height.
1407    fn document_of(count: usize, words: usize) -> String {
1408        (1..=count)
1409            .map(|i| format!("Block {i}: {}", "lorem ipsum dolor ".repeat(words)))
1410            .collect::<Vec<_>>()
1411            .join("\n\n")
1412    }
1413
1414    /// Replacing a document with one that happens to have the *same* block
1415    /// count must still re-measure. `Document::render_root` only resets the
1416    /// list when the count changes, so without an explicit re-measure every
1417    /// cached height stays with the previous document and the scroll extent
1418    /// keeps describing it.
1419    #[gpui::test]
1420    fn replacing_a_document_with_an_equal_block_count_remeasures(cx: &mut TestAppContext) {
1421        cx.update(crate::init);
1422
1423        const BLOCKS: usize = 24;
1424        let short = document_of(BLOCKS, 1);
1425        let tall = document_of(BLOCKS, 60);
1426
1427        let (root, cx) = cx.add_window_view(|_, cx| ScrollExtentTestRoot {
1428            text_view: cx.new(|cx| TextViewState::markdown(&short, cx)),
1429        });
1430        let cx: &mut VisualTestContext = cx;
1431
1432        // The list is populated and measured during layout, so every
1433        // assertion below has to follow a real frame.
1434        let settle = |cx: &mut VisualTestContext| {
1435            cx.run_until_parked();
1436            cx.update(|window, cx| window.draw(cx).clear(cx));
1437            cx.run_until_parked();
1438        };
1439        settle(cx);
1440
1441        let scroll_extent = |cx: &mut VisualTestContext| {
1442            root.read_with(cx, |root, cx| {
1443                root.text_view
1444                    .read(cx)
1445                    .list_state()
1446                    .max_offset_for_scrollbar()
1447                    .y
1448            })
1449        };
1450
1451        let short_extent = scroll_extent(cx);
1452
1453        root.update(cx, |root, cx| {
1454            root.text_view
1455                .update(cx, |state, cx| state.set_text(&tall, cx));
1456        });
1457        settle(cx);
1458
1459        root.read_with(cx, |root, cx| {
1460            assert_eq!(
1461                root.text_view.read(cx).list_state().item_count(),
1462                BLOCKS,
1463                "the replacement must keep the block count, or the list resets and the bug cannot occur"
1464            );
1465        });
1466
1467        let tall_extent = scroll_extent(cx);
1468        assert!(
1469            tall_extent > short_extent * 5.,
1470            "a much taller document must grow the scroll extent, but it went from \
1471             {short_extent:?} to {tall_extent:?}"
1472        );
1473    }
1474
1475    struct StatelessMarkdownRoot {
1476        renders: Arc<AtomicUsize>,
1477    }
1478
1479    impl Render for StatelessMarkdownRoot {
1480        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1481            self.renders.fetch_add(1, Ordering::Relaxed);
1482            div().child(
1483                TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
1484                    .markdown_block_parser(|_, _| None),
1485            )
1486        }
1487    }
1488
1489    struct DummyTextViewPlugin;
1490
1491    impl TextViewPlugin for DummyTextViewPlugin {
1492        fn setup(self, mut text_view: TextView) -> TextView {
1493            text_view.selectable = true;
1494            text_view
1495        }
1496    }
1497
1498    #[gpui::test]
1499    fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
1500        cx.update(crate::init);
1501        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
1502
1503        assert!(TextView::new(&state).selectable);
1504        assert!(TextView::markdown("markdown", "text").selectable);
1505        assert!(TextView::html("html", "<p>text</p>").selectable);
1506    }
1507
1508    #[gpui::test]
1509    fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
1510        cx.update(crate::init);
1511        let renders = Arc::new(AtomicUsize::new(0));
1512        let (_, cx) = cx.add_window_view({
1513            let renders = renders.clone();
1514            move |_, _| StatelessMarkdownRoot { renders }
1515        });
1516        let cx: &mut VisualTestContext = cx;
1517
1518        cx.run_until_parked();
1519        cx.update(|window, cx| window.draw(cx).clear(cx));
1520        let renders_after_redraw = renders.load(Ordering::Relaxed);
1521        cx.run_until_parked();
1522        assert_eq!(
1523            renders.load(Ordering::Relaxed),
1524            renders_after_redraw,
1525            "an unchanged TextView must not schedule another render after its parser is rebuilt",
1526        );
1527    }
1528
1529    #[gpui::test]
1530    fn markdown_data_url_image_is_decoded_inline(cx: &mut TestAppContext) {
1531        use gpui::{Image, ImageFormat, ImageSource};
1532
1533        // A 1x1 red PNG.
1534        const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
1535
1536        cx.update(crate::init);
1537        let markdown = format!("Inline ![dot](data:image/png;base64,{PNG_BASE64}) image");
1538        let (_, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new(&markdown, cx));
1539        let cx: &mut VisualTestContext = cx;
1540
1541        cx.run_until_parked();
1542        cx.update(|window, cx| window.draw(cx).clear(cx));
1543
1544        // `Image` keys the asset system by a hash of its bytes, so rebuilding it
1545        // from the same body finds the entry the text view's `img` registered
1546        // when it rendered — proof the body was decoded in place instead of
1547        // being fetched over HTTP.
1548        let bytes = data_url::DataUrl::process(&format!("data:image/png;base64,{PNG_BASE64}"))
1549            .unwrap()
1550            .decode_to_vec()
1551            .unwrap()
1552            .0;
1553        let image = Arc::new(Image::from_bytes(ImageFormat::Png, bytes));
1554        assert!(
1555            cx.update(|_, cx| ImageSource::Image(image).is_asset_cached(cx)),
1556            "the data URL image must be handed to GPUI as decoded bytes",
1557        );
1558    }
1559
1560    #[gpui::test]
1561    fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
1562        cx.update(crate::init);
1563        cx.update(|cx| {
1564            let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
1565            colors.primary = gpui::rgb(0x55aaff).into();
1566            colors.selection = gpui::rgb(0x335577).into();
1567        });
1568        let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
1569        let cx: &mut VisualTestContext = cx;
1570
1571        cx.run_until_parked();
1572        root.read_with(cx, |root, cx| {
1573            let style = &root.text_view.read(cx).text_view_style;
1574            assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
1575            assert_eq!(style.selection(), gpui::rgb(0x335577).into());
1576        });
1577    }
1578
1579    impl TextViewTestRoot {
1580        fn new(text: &str, cx: &mut Context<Self>) -> Self {
1581            let text = text.to_string();
1582            let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
1583            Self { text_view }
1584        }
1585    }
1586
1587    impl Render for TextViewTestRoot {
1588        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1589            div()
1590                .w(px(160.))
1591                .child(
1592                    div()
1593                        .h(px(24.))
1594                        .overflow_hidden()
1595                        .child(TextView::new(&self.text_view).selectable(true)),
1596                )
1597                .child(div().h(px(40.)).child("footer"))
1598        }
1599    }
1600
1601    struct TableSelectionTestRoot {
1602        text_view: Entity<TextViewState>,
1603    }
1604
1605    impl Render for TableSelectionTestRoot {
1606        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1607            div()
1608                .debug_selector(|| "table-selection-root".into())
1609                .w(px(520.))
1610                .child(crate::TextSelectionLayer)
1611                .child(TextView::new(&self.text_view))
1612        }
1613    }
1614
1615    #[gpui::test]
1616    fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
1617        cx.update(crate::init);
1618        let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
1619            text_view: cx.new(|cx| {
1620                TextViewState::markdown(
1621                    "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
1622                    cx,
1623                )
1624            }),
1625        });
1626        let cx: &mut VisualTestContext = cx;
1627
1628        cx.run_until_parked();
1629        let bounds = cx
1630            .debug_bounds("table-selection-root")
1631            .expect("table bounds");
1632        let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
1633        let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
1634        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1635        cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1636        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1637
1638        assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
1639        assert_eq!(
1640            cx.update(|window, cx| window.simulate_next_frame(cx)),
1641            0,
1642            "finished table selection must not continuously request frames"
1643        );
1644    }
1645
1646    struct InlineImageTextViewTestRoot {
1647        text_view: Entity<TextViewState>,
1648    }
1649
1650    impl InlineImageTextViewTestRoot {
1651        fn new(cx: &mut Context<Self>) -> Self {
1652            let text_view = cx.new(|cx| {
1653                TextViewState::markdown(
1654                    "Build Status ![inline image](https://example.com/image.svg) after",
1655                    cx,
1656                )
1657            });
1658            Self { text_view }
1659        }
1660    }
1661
1662    impl Render for InlineImageTextViewTestRoot {
1663        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1664            div()
1665                .w(px(420.))
1666                .child(TextView::new(&self.text_view).selectable(true))
1667        }
1668    }
1669
1670    #[gpui::test]
1671    fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
1672        cx.update(crate::init);
1673        let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
1674        let cx: &mut VisualTestContext = cx;
1675
1676        cx.run_until_parked();
1677        cx.update(|window, cx| {
1678            let _ = window.draw(cx);
1679        });
1680
1681        let inline_bounds = content.read_with(cx, |content, cx| {
1682            content.text_view.read(cx).selection_adapter.text_bounds()
1683        });
1684
1685        assert_eq!(inline_bounds.len(), 2);
1686        assert_eq!(
1687            inline_bounds[0].top(),
1688            inline_bounds[1].top(),
1689            "text before and after an inline image should share a rendered line"
1690        );
1691        assert!(
1692            inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
1693            "inline image should reserve horizontal space in the text layout"
1694        );
1695        assert!(
1696            inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
1697            "unloaded inline image fallback should stay generic and compact"
1698        );
1699    }
1700
1701    #[gpui::test]
1702    fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
1703        cx.update(crate::init);
1704        let (_, cx) = cx.add_window_view(|_, cx| {
1705            TextViewTestRoot::new(
1706                "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
1707                cx,
1708            )
1709        });
1710        let cx: &mut VisualTestContext = cx;
1711
1712        cx.run_until_parked();
1713        cx.update(|window, cx| {
1714            let _ = window.draw(cx);
1715        });
1716    }
1717
1718    #[gpui::test]
1719    fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
1720        struct ListItemBlockRoot;
1721
1722        impl Render for ListItemBlockRoot {
1723            fn render(
1724                &mut self,
1725                _window: &mut Window,
1726                _cx: &mut Context<Self>,
1727            ) -> impl IntoElement {
1728                div().w(px(840.)).h(px(400.)).child(
1729                    crate::h_resizable("markdown-width-test")
1730                        .child(crate::resizable_panel().child(div()))
1731                        .child(crate::resizable_panel().child(
1732                            TextView::markdown(
1733                                "list-with-code",
1734                                "1. List item\n   ```rust\n   nested code\n   ```\n\n```rust\ntop-level code\n```",
1735                            )
1736                            .code_block_actions(|code_block, _, _| {
1737                                let selector = if code_block.code().contains("nested") {
1738                                    "nested-code-action"
1739                                } else {
1740                                    "top-level-code-action"
1741                                };
1742                                div()
1743                                    .debug_selector(move || selector.into())
1744                                    .child("Copy")
1745                            })
1746                            .scrollable(true)
1747                            .p_5()
1748                            .flex_none(),
1749                        )),
1750                )
1751            }
1752        }
1753
1754        cx.update(crate::init);
1755        let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
1756        let cx: &mut VisualTestContext = cx;
1757
1758        cx.run_until_parked();
1759        cx.update(|window, cx| {
1760            let _ = window.draw(cx);
1761        });
1762
1763        let nested_action = cx.debug_bounds("nested-code-action").unwrap();
1764        let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
1765        assert!(
1766            top_level_action.right() - nested_action.right() < px(32.),
1767            "nested code block should fill the list item's available width"
1768        );
1769    }
1770
1771    /// Height cap of the scrolling code block in [`ScrollingCodeRoot`].
1772    const CODE_HEIGHT: Pixels = px(60.);
1773
1774    struct ScrollingCodeRoot {
1775        text_view: Entity<TextViewState>,
1776        actions: bool,
1777    }
1778
1779    impl Render for ScrollingCodeRoot {
1780        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1781            let mut code_block = StyleRefinement::default().max_h(CODE_HEIGHT);
1782            code_block.overflow.y = Some(Overflow::Scroll);
1783
1784            div().w(px(320.)).h(px(200.)).child(
1785                TextView::new(&self.text_view)
1786                    .style(TextViewStyle::default().with_code_block(code_block))
1787                    .when(self.actions, |this| {
1788                        this.code_block_actions(|_, _, _| {
1789                            div().debug_selector(|| "code-action".into()).child("Copy")
1790                        })
1791                    })
1792                    .scrollable(true),
1793            )
1794        }
1795    }
1796
1797    #[gpui::test]
1798    fn scrolling_code_block_keeps_wheel_from_the_list(cx: &mut TestAppContext) {
1799        cx.update(crate::init);
1800        let code = (0..50).map(|i| format!("line {i}\n")).collect::<String>();
1801        let tail = "Paragraph after the code.\n\n".repeat(20);
1802        let markdown = format!("```\n{code}```\n\n{tail}");
1803
1804        // Scrolling must not depend on `code_block_actions`, which is what
1805        // gives a non-scrolling block its id.
1806        for actions in [false, true] {
1807            let (root, cx) = cx.add_window_view(|_, cx| ScrollingCodeRoot {
1808                text_view: cx.new(|cx| TextViewState::markdown(&markdown, cx)),
1809                actions,
1810            });
1811            let cx: &mut VisualTestContext = cx;
1812            let draw = |cx: &mut VisualTestContext| {
1813                cx.run_until_parked();
1814                cx.update(|window, cx| window.draw(cx).clear(cx));
1815            };
1816            let step = px(20.);
1817            let wheel_at = |cx: &mut VisualTestContext, y: Pixels| {
1818                cx.simulate_event(gpui::ScrollWheelEvent {
1819                    position: point(px(20.), y),
1820                    delta: gpui::ScrollDelta::Pixels(point(px(0.), -step)),
1821                    ..Default::default()
1822                });
1823            };
1824            // The code block is first in the view, and painted lines are
1825            // clipped to its viewport: the first code line's bottom moves with
1826            // the code, the first line below the viewport moves with the list.
1827            let edges = |cx: &mut VisualTestContext| {
1828                let lines = root.read_with(cx, |root, cx| {
1829                    root.text_view.read(cx).selection_adapter.text_bounds()
1830                });
1831                let paragraph = lines.iter().find(|line| line.top() >= CODE_HEIGHT).unwrap();
1832                (lines[0].bottom(), paragraph.top())
1833            };
1834            draw(cx);
1835            let (code_bottom, paragraph_top) = edges(cx);
1836            let action = cx.debug_bounds("code-action");
1837
1838            // Over the code block: the code scrolls, the list and the pinned
1839            // actions stay put.
1840            wheel_at(cx, CODE_HEIGHT / 2.);
1841            draw(cx);
1842            assert_eq!(
1843                edges(cx),
1844                (code_bottom - step, paragraph_top),
1845                "actions: {actions}"
1846            );
1847            assert_eq!(cx.debug_bounds("code-action"), action);
1848
1849            // Below the code block the list still scrolls. Only inequality
1850            // holds: once the paragraph moves into the code's band, `edges`
1851            // picks the next one down.
1852            wheel_at(cx, CODE_HEIGHT * 2.5);
1853            draw(cx);
1854            assert_ne!(edges(cx).1, paragraph_top, "actions: {actions}");
1855        }
1856    }
1857
1858    /// Draw a Markdown table with a `table_actions` hook installed, and return
1859    /// the painted bounds of the actions element plus the data it received.
1860    /// `scroll` opts into the horizontally scrollable table layout.
1861    fn draw_table_with_actions(
1862        cx: &mut TestAppContext,
1863        scroll: bool,
1864    ) -> (Bounds<Pixels>, TableData) {
1865        use std::sync::{Arc, Mutex};
1866
1867        struct TableRoot {
1868            scroll: bool,
1869            captured: Arc<Mutex<Vec<TableData>>>,
1870        }
1871
1872        impl Render for TableRoot {
1873            fn render(
1874                &mut self,
1875                _window: &mut Window,
1876                _cx: &mut Context<Self>,
1877            ) -> impl IntoElement {
1878                let captured = self.captured.clone();
1879                let mut table_style = StyleRefinement::default();
1880                if self.scroll {
1881                    table_style.overflow.x = Some(Overflow::Scroll);
1882                }
1883
1884                div().w(px(320.)).child(
1885                    TextView::markdown(
1886                        "table-actions",
1887                        "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
1888                    )
1889                    .style(TextViewStyle::default().with_table(table_style))
1890                    .table_actions(move |table, _, _| {
1891                        if let Ok(mut captured) = captured.lock() {
1892                            captured.push(table.clone());
1893                        }
1894                        div().debug_selector(|| "table-action".into()).child("Copy")
1895                    }),
1896                )
1897            }
1898        }
1899
1900        cx.update(crate::init);
1901        let captured = Arc::new(Mutex::new(Vec::new()));
1902        let (_, cx) = cx.add_window_view({
1903            let captured = captured.clone();
1904            move |_, _| TableRoot { scroll, captured }
1905        });
1906        let cx: &mut VisualTestContext = cx;
1907
1908        cx.run_until_parked();
1909        cx.update(|window, cx| {
1910            let _ = window.draw(cx);
1911        });
1912
1913        let bounds = cx
1914            .debug_bounds("table-action")
1915            .expect("table actions should be painted");
1916        let data = captured
1917            .lock()
1918            .expect("captured table data")
1919            .last()
1920            .cloned()
1921            .expect("table actions hook should receive the table");
1922
1923        (bounds, data)
1924    }
1925
1926    #[gpui::test]
1927    fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
1928        for scroll in [false, true] {
1929            let (bounds, data) = draw_table_with_actions(cx, scroll);
1930
1931            // Header plus two data rows are painted above the actions row.
1932            assert!(
1933                bounds.top() > px(40.),
1934                "actions should sit below the table (scroll: {scroll}), got {:?}",
1935                bounds.top()
1936            );
1937            assert_eq!(data.headers, vec!["Name", "Age"]);
1938            assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
1939            assert_eq!(
1940                data.markdown,
1941                "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
1942            );
1943            assert_eq!(data.span, Some(0..52));
1944        }
1945    }
1946
1947    #[test]
1948    fn plugin_accepts_text_view_plugins_beyond_markdown() {
1949        let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
1950
1951        assert!(view.selectable);
1952    }
1953
1954    #[test]
1955    fn syntax_highlighting_is_opt_in() {
1956        let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
1957        assert!(default_view.code_block_highlighter.is_none());
1958
1959        let view = default_view.code_block_highlighter(|block| {
1960            vec![(
1961                0..block.code().len(),
1962                gpui::HighlightStyle {
1963                    color: Some(gpui::rgb(0x3366ff).into()),
1964                    ..Default::default()
1965                },
1966            )]
1967        });
1968        assert!(view.code_block_highlighter.is_some());
1969    }
1970
1971    #[gpui::test]
1972    fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
1973        cx.update(crate::init);
1974        let (_, cx) = cx.add_window_view(|_, cx| {
1975            TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
1976        });
1977        let cx: &mut VisualTestContext = cx;
1978
1979        cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
1980
1981        assert_eq!(cx.opened_url(), None);
1982    }
1983
1984    struct MaxLinesTestRoot {
1985        text_view: Entity<TextViewState>,
1986        max_lines: usize,
1987    }
1988
1989    impl MaxLinesTestRoot {
1990        fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
1991            let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
1992            Self {
1993                text_view,
1994                max_lines,
1995            }
1996        }
1997    }
1998
1999    impl Render for MaxLinesTestRoot {
2000        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2001            div()
2002                .w(px(200.))
2003                .child(TextView::new(&self.text_view).max_lines(self.max_lines))
2004        }
2005    }
2006
2007    #[test]
2008    fn the_clip_only_moves_for_a_straddling_glyph_line() {
2009        use super::line_safe_clip_bottom;
2010        use crate::text::state::LineSpan;
2011
2012        let spans = [
2013            // Lines end at 20 / 40 / 60.
2014            LineSpan {
2015                top: px(0.),
2016                bottom: px(60.),
2017                line_height: px(20.),
2018            },
2019            // A second block after an 8px gap; lines end at 88 / 108 / 128.
2020            LineSpan {
2021                top: px(68.),
2022                bottom: px(128.),
2023                line_height: px(20.),
2024            },
2025        ];
2026
2027        // Content continues well past the box in every case but the last.
2028        let below = px(400.);
2029
2030        // A box ending inside the line 88..108 leaves that line out whole.
2031        assert_eq!(
2032            line_safe_clip_bottom(&spans, px(100.), below),
2033            Some(px(88.))
2034        );
2035
2036        // A box ending on a line boundary has nothing to pull the clip up for.
2037        assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
2038
2039        // A strip below the last line shorter than a line — the border and
2040        // padding a block leads with — is not worth showing.
2041        assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
2042
2043        // One taller than a line is: whatever crosses the edge keeps the part
2044        // that fits rather than leaving the box half empty.
2045        let one_block = [LineSpan {
2046            top: px(0.),
2047            bottom: px(60.),
2048            line_height: px(20.),
2049        }];
2050        assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
2051
2052        // Nothing crosses the edge at all: the space under the last line is
2053        // the box's own, not a scrap of something below.
2054        assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
2055    }
2056
2057    #[test]
2058    fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
2059        use super::line_safe_clip_bottom;
2060        use crate::text::state::LineSpan;
2061
2062        // A heading line of 28px, in a box capped at one 26px body line.
2063        let heading = [LineSpan {
2064            top: px(70.),
2065            bottom: px(98.),
2066            line_height: px(28.),
2067        }];
2068
2069        assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
2070    }
2071
2072    #[test]
2073    fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
2074        use super::line_safe_clip_bottom;
2075        use crate::text::state::LineSpan;
2076
2077        // Two table rows, each one line of text, 9px of border and padding
2078        // between them.
2079        let rows = [
2080            LineSpan {
2081                top: px(100.),
2082                bottom: px(126.),
2083                line_height: px(26.),
2084            },
2085            LineSpan {
2086                top: px(135.),
2087                bottom: px(161.),
2088                line_height: px(26.),
2089            },
2090        ];
2091
2092        // Leaving out the second row's text would strand the 9px it leads
2093        // with, so the clip goes back to the row above it.
2094        assert_eq!(
2095            line_safe_clip_bottom(&rows, px(148.), px(400.)),
2096            Some(px(126.))
2097        );
2098    }
2099
2100    /// A clamped view nested the way an application nests one: inside a card,
2101    /// inside a region that fills a window of a known height. The height an
2102    /// ancestor hands down must not reach the clamped content and hide the
2103    /// overflow the clamp measures — with the content stretched to the capped
2104    /// box, nothing looks clipped and lines get cut in half.
2105    struct ClampedPageRoot {
2106        text_view: Entity<TextViewState>,
2107        max_lines: usize,
2108    }
2109
2110    impl Render for ClampedPageRoot {
2111        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2112            use crate::{h_flex, v_flex};
2113
2114            v_flex()
2115                .size_full()
2116                .p_4()
2117                .gap_4()
2118                .child(h_flex().max_w(px(480.)).gap_3().child("header"))
2119                .child(
2120                    v_flex()
2121                        .flex_1()
2122                        .min_h_0()
2123                        .gap_4()
2124                        .id("clamped-page-scroll")
2125                        .child(
2126                            v_flex()
2127                                .max_w(px(480.))
2128                                .p_3()
2129                                .gap_2()
2130                                .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
2131                        )
2132                        .overflow_y_scroll(),
2133                )
2134        }
2135    }
2136
2137    #[gpui::test]
2138    fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
2139        cx.update(crate::init);
2140        let (root, cx) = cx.add_window_view(|_, cx| {
2141            let text_view = cx.new(|cx| {
2142                TextViewState::markdown(
2143                    "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
2144                    cx,
2145                )
2146            });
2147            ClampedPageRoot {
2148                text_view,
2149                max_lines: 3,
2150            }
2151        });
2152        let cx: &mut VisualTestContext = cx;
2153
2154        assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2155    }
2156
2157    #[gpui::test]
2158    fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
2159        cx.update(crate::init);
2160        let (root, cx) = cx.add_window_view(|_, cx| {
2161            MaxLinesTestRoot::new(
2162                "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
2163                2,
2164                cx,
2165            )
2166        });
2167        let cx: &mut VisualTestContext = cx;
2168
2169        assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2170    }
2171
2172    #[gpui::test]
2173    fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
2174        cx.update(crate::init);
2175        let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
2176        let cx: &mut VisualTestContext = cx;
2177
2178        assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2179    }
2180
2181    #[gpui::test]
2182    fn heading_refinement_changes_rendered_heading_geometry(cx: &mut TestAppContext) {
2183        struct HeadingStyleRoot;
2184
2185        impl Render for HeadingStyleRoot {
2186            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2187                div()
2188                    .flex()
2189                    .items_start()
2190                    .child(
2191                        div()
2192                            .debug_selector(|| "default-h1".into())
2193                            .child(TextView::markdown("default-h1-view", "# Heading")),
2194                    )
2195                    .child(div().debug_selector(|| "custom-h1".into()).child(
2196                        TextView::markdown("custom-heading-view", "# Heading").style(
2197                            TextViewStyle::default().with_heading(|level| {
2198                                if level == 1 {
2199                                    StyleRefinement::default().pb(rems(2.))
2200                                } else {
2201                                    StyleRefinement::default()
2202                                }
2203                            }),
2204                        ),
2205                    ))
2206                    .child(
2207                        div()
2208                            .debug_selector(|| "default-h2".into())
2209                            .child(TextView::markdown("default-h2-view", "## Heading")),
2210                    )
2211                    .child(div().debug_selector(|| "custom-h2".into()).child(
2212                        TextView::markdown("custom-h2-view", "## Heading").style(
2213                            TextViewStyle::default().with_heading(|level| {
2214                                if level == 1 {
2215                                    StyleRefinement::default().pb(rems(2.))
2216                                } else {
2217                                    StyleRefinement::default()
2218                                }
2219                            }),
2220                        ),
2221                    ))
2222            }
2223        }
2224
2225        cx.update(crate::init);
2226        let (_, cx) = cx.add_window_view(|_, _| HeadingStyleRoot);
2227        cx.update(|window, cx| window.draw(cx).clear(cx));
2228
2229        let default_h1 = cx.debug_bounds("default-h1").unwrap();
2230        let custom_h1 = cx.debug_bounds("custom-h1").unwrap();
2231        let default_h2 = cx.debug_bounds("default-h2").unwrap();
2232        let custom_h2 = cx.debug_bounds("custom-h2").unwrap();
2233        assert!(custom_h1.size.height > default_h1.size.height);
2234        assert_eq!(custom_h2.size.height, default_h2.size.height);
2235    }
2236
2237    #[gpui::test]
2238    fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
2239        cx.update(crate::init);
2240        let (_, cx) = cx.add_window_view(|_, cx| {
2241            MaxLinesTestRoot::new(
2242                "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
2243                2,
2244                cx,
2245            )
2246        });
2247        let cx: &mut VisualTestContext = cx;
2248
2249        // Click far below the clamped box, where the link would sit unclamped.
2250        cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
2251
2252        assert_eq!(cx.opened_url(), None);
2253    }
2254
2255    #[gpui::test]
2256    fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) {
2257        struct SelectionRoot {
2258            text_view: Entity<TextViewState>,
2259            format: crate::text::SelectionFormat,
2260        }
2261        impl Render for SelectionRoot {
2262            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2263                div()
2264                    .w(px(160.))
2265                    .child(crate::TextSelectionLayer)
2266                    .child(TextView::new(&self.text_view).selection_format(self.format))
2267            }
2268        }
2269        cx.update(crate::init);
2270        let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot {
2271            format: crate::text::SelectionFormat::Plain,
2272            text_view: cx
2273                .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)),
2274        });
2275        let cx: &mut VisualTestContext = cx;
2276        cx.run_until_parked();
2277        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2278        assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2279        // Just past the code span's leading padding, on its first glyph.
2280        cx.simulate_mouse_down(
2281            point(px(5.), px(8.)),
2282            MouseButton::Left,
2283            Modifiers::default(),
2284        );
2285        cx.update(|window, cx| {
2286            let _ = window.draw(cx);
2287        });
2288        cx.simulate_mouse_move(
2289            point(px(155.), px(20.)),
2290            Some(MouseButton::Left),
2291            Modifiers::default(),
2292        );
2293        cx.update(|window, cx| {
2294            let _ = window.draw(cx);
2295        });
2296        cx.simulate_mouse_up(
2297            point(px(155.), px(20.)),
2298            MouseButton::Left,
2299            Modifiers::default(),
2300        );
2301        cx.update(|window, cx| {
2302            let _ = window.draw(cx);
2303        });
2304        let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2305        assert_eq!(selected.trim(), "code after");
2306        view.update(cx, |view, cx| {
2307            view.format = crate::text::SelectionFormat::Source;
2308            cx.notify();
2309        });
2310        cx.update(|window, cx| {
2311            let _ = window.draw(cx);
2312        });
2313        let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2314        assert_eq!(selected.trim(), "[`code`](https://example.com) after");
2315    }
2316
2317    /// Inline-code Markdown takes the deferred `InlineFlow` path. Its layout
2318    /// must use the heading's resolved typography rather than the ambient body
2319    /// style that remains after the heading's style stack has been popped.
2320    ///
2321    /// This is a real request-layout → measured-layout → prepaint → paint test:
2322    /// the deterministic text system makes bold glyphs wider than body glyphs.
2323    /// Before the fix, the code heading was allocated using normal body metrics
2324    /// but its fragments painted bold, so the wrapped heading text crossed into
2325    /// the following paragraph.
2326    #[test]
2327    fn inline_code_heading_reserves_painted_wrapped_lines_and_list_baseline() {
2328        use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2329        use gpui::{TestApp, rems};
2330
2331        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";
2332
2333        struct MarkdownRoot {
2334            text_view: Entity<TextViewState>,
2335            width: Pixels,
2336            preview_zoom: f32,
2337        }
2338
2339        impl Render for MarkdownRoot {
2340            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2341                div()
2342                    .w(self.width)
2343                    // Match example-markdown: preview zoom changes TextView's
2344                    // inherited text size, not the window rem size.
2345                    .text_size(rems(self.preview_zoom))
2346                    .child(crate::TextSelectionLayer)
2347                    .child(TextView::new(&self.text_view).selectable(true))
2348            }
2349        }
2350
2351        fn draw(
2352            app: &mut TestApp,
2353            width: Pixels,
2354            preview_zoom: f32,
2355        ) -> (Bounds<Pixels>, Vec<Bounds<Pixels>>, String) {
2356            let mut window = app.open_window(|_, cx| MarkdownRoot {
2357                text_view: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
2358                width,
2359                preview_zoom,
2360            });
2361            window.draw();
2362            app.run_until_parked();
2363            window.draw();
2364            window.update(|root, _, cx| {
2365                root.text_view.update(cx, |state, cx| state.select_all(cx));
2366            });
2367            window.draw();
2368            window.read(|root, cx| {
2369                let state = root.text_view.read(cx);
2370                (
2371                    state.bounds(),
2372                    state.selection_adapter.text_bounds(),
2373                    state.selected_text(),
2374                )
2375            })
2376        }
2377
2378        let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2379        app.update(|cx| {
2380            crate::init(cx);
2381            crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2382        });
2383
2384        for (case, width, preview_zoom) in [
2385            // At the root 16px size the plain heading fits, while bold inline
2386            // fragments need their own measured widths.
2387            ("default-16px", px(600.), 1.),
2388            // A narrow view exercises intentional heading wrapping.
2389            ("narrow-16px", px(320.), 1.),
2390            // Preview zoom applies through `.text_size(rems(zoom))`.
2391            ("zoom-1.25", px(600.), 1.25),
2392        ] {
2393            let (view_bounds, text_bounds, selected_text) = draw(&mut app, width, preview_zoom);
2394            assert!(
2395                text_bounds.len() > 8,
2396                "{case}: expected all markdown fragments"
2397            );
2398            assert_eq!(
2399                selected_text.trim(),
2400                "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",
2401                "{case}: wrapped inline-code text was lost or duplicated"
2402            );
2403            let following = text_bounds.last().expect("following paragraph must paint");
2404            let painted_bottom = text_bounds
2405                .iter()
2406                .map(|bounds| bounds.bottom())
2407                .max()
2408                .expect("markdown must paint text");
2409            // This is intentionally not a fragment-count or selection-only
2410            // assertion: `text_bounds` are the shaped Inline layouts produced
2411            // during paint. No painted wrapped line may reach the following
2412            // paragraph's origin, and the TextView allocation must contain the
2413            // complete painted document.
2414            assert!(
2415                text_bounds[..text_bounds.len() - 1]
2416                    .iter()
2417                    .all(|bounds| bounds.bottom() <= following.top()),
2418                "{case}: heading/list text painted through the following paragraph;                  following={following:?}, text_bounds={text_bounds:?}, view={view_bounds:?}"
2419            );
2420            assert!(
2421                painted_bottom <= view_bounds.bottom(),
2422                "{case}: TextView height did not reserve its painted text;                  painted_bottom={painted_bottom:?}, view={view_bounds:?}"
2423            );
2424        }
2425    }
2426
2427    #[test]
2428    fn ordered_markdown_list_start_reaches_layout_marker() {
2429        use crate::text::inline::test_fonts::{WideMonoTextSystem, record_shaped_lines};
2430        use gpui::TestApp;
2431
2432        struct MarkdownRoot {
2433            text_view: Entity<TextViewState>,
2434        }
2435
2436        impl Render for MarkdownRoot {
2437            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2438                div().w(px(400.)).child(TextView::new(&self.text_view))
2439            }
2440        }
2441
2442        #[derive(Clone, Copy)]
2443        enum Format {
2444            Markdown,
2445            Html,
2446        }
2447
2448        fn shaped_markers(format: Format, source: &str) -> Vec<String> {
2449            let (_, shaped_lines) = record_shaped_lines(|| {
2450                let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2451                app.update(crate::init);
2452                let mut window = app.open_window(|_, cx| MarkdownRoot {
2453                    text_view: cx.new(|cx| match format {
2454                        Format::Markdown => TextViewState::markdown(source, cx),
2455                        Format::Html => TextViewState::html(source, cx),
2456                    }),
2457                });
2458                window.draw();
2459                app.run_until_parked();
2460                window.draw();
2461            });
2462
2463            let mut markers = shaped_lines
2464                .into_iter()
2465                .filter(|line| line.len() <= 3 && line.ends_with('.'))
2466                .collect::<Vec<_>>();
2467            markers.dedup();
2468            markers
2469        }
2470
2471        let starts_at_one = "1. one\n2. two";
2472        assert_eq!(
2473            shaped_markers(Format::Markdown, starts_at_one),
2474            ["1.", "2."]
2475        );
2476        assert_eq!(
2477            shaped_markers(Format::Html, "<ol><li>one</li><li>two</li></ol>"),
2478            ["1.", "2."]
2479        );
2480
2481        assert_eq!(
2482            shaped_markers(Format::Markdown, "3. hello\n4. world"),
2483            ["3.", "4."]
2484        );
2485
2486        let nested_starts_at_four = "1. outer\n\n   4. nested\n   5. again";
2487        assert_eq!(
2488            shaped_markers(Format::Markdown, nested_starts_at_four),
2489            ["1.", "D.", "E."]
2490        );
2491
2492        let nested_starts_at_zero = "1. outer\n\n   0. zero";
2493        assert_eq!(
2494            shaped_markers(Format::Markdown, nested_starts_at_zero),
2495            ["1.", "0."]
2496        );
2497    }
2498
2499    /// A line with inline code takes `InlineFlow` and a plain line takes the
2500    /// ordinary text path; both must be the same height, or a list with one
2501    /// code item is unevenly spaced (#3162). The fractional scale factor and
2502    /// zooms give line heights that are not whole logical pixels.
2503    #[test]
2504    fn inline_code_line_is_as_tall_as_a_plain_line() {
2505        use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2506        use gpui::{TestApp, rems};
2507
2508        struct LineRoot {
2509            plain: Entity<TextViewState>,
2510            code: Entity<TextViewState>,
2511            preview_zoom: f32,
2512        }
2513
2514        impl Render for LineRoot {
2515            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2516                div()
2517                    .w(px(600.))
2518                    .text_size(rems(self.preview_zoom))
2519                    .child(TextView::new(&self.plain))
2520                    .child(TextView::new(&self.code))
2521            }
2522        }
2523
2524        let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2525        app.update(|cx| {
2526            crate::init(cx);
2527            crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2528        });
2529        for scale_factor in [1.6, 2.] {
2530            for preview_zoom in [1., 1.25] {
2531                let mut window = app.open_window(|_, cx| LineRoot {
2532                    plain: cx.new(|cx| TextViewState::markdown("plain body words", cx)),
2533                    code: cx.new(|cx| TextViewState::markdown("plain `code` words", cx)),
2534                    preview_zoom,
2535                });
2536                window.simulate_scale_factor_change(scale_factor);
2537                window.draw();
2538                app.run_until_parked();
2539                window.draw();
2540                let (plain, code) = window.read(|root, cx| {
2541                    (
2542                        root.plain.read(cx).bounds().size.height,
2543                        root.code.read(cx).bounds().size.height,
2544                    )
2545                });
2546                assert_eq!(code, plain, "scale {scale_factor}, zoom {preview_zoom}");
2547            }
2548        }
2549    }
2550
2551    /// The code-bearing list item takes `InlineFlow`; the plain item takes the
2552    /// ordinary text path. Their first body glyphs must begin at the same row
2553    /// position relative to their own TextView origins.
2554    #[test]
2555    fn inline_code_list_body_paint_origin_matches_plain_list_item() {
2556        use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2557        use gpui::{TestApp, rems};
2558
2559        struct ListRoot {
2560            plain: Entity<TextViewState>,
2561            code: Entity<TextViewState>,
2562            preview_zoom: f32,
2563        }
2564
2565        impl Render for ListRoot {
2566            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2567                div()
2568                    .w(px(600.))
2569                    .text_size(rems(self.preview_zoom))
2570                    .child(crate::TextSelectionLayer)
2571                    .child(TextView::new(&self.plain).selectable(true))
2572                    .child(TextView::new(&self.code).selectable(true))
2573            }
2574        }
2575
2576        fn draw(app: &mut TestApp, preview_zoom: f32) -> (Pixels, Pixels) {
2577            let mut window = app.open_window(|_, cx| ListRoot {
2578                plain: cx.new(|cx| TextViewState::markdown("- plain body words", cx)),
2579                code: cx.new(|cx| TextViewState::markdown("- plain `code` words", cx)),
2580                preview_zoom,
2581            });
2582            window.draw();
2583            app.run_until_parked();
2584            window.draw();
2585            window.read(|root, cx| {
2586                let first_body_line_top = |view: &Entity<TextViewState>| {
2587                    let view = view.read(cx);
2588                    let painted_line = view
2589                        .selection_adapter
2590                        .text_bounds()
2591                        .into_iter()
2592                        .next()
2593                        .expect("list item should paint its first body line");
2594                    painted_line.top() - view.bounds().top()
2595                };
2596                (
2597                    first_body_line_top(&root.plain),
2598                    first_body_line_top(&root.code),
2599                )
2600            })
2601        }
2602
2603        let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2604        app.update(|cx| {
2605            crate::init(cx);
2606            crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2607        });
2608        for (case, preview_zoom) in [("16px", 1.), ("zoom-1.25", 1.25), ("zoom-1.5", 1.5)] {
2609            let (plain_origin, code_origin) = draw(&mut app, preview_zoom);
2610            assert_eq!(
2611                code_origin, plain_origin,
2612                "{case}: code list body paint origin {code_origin:?} must match plain {plain_origin:?}"
2613            );
2614        }
2615    }
2616
2617    #[test]
2618    fn inline_code_fragment_does_not_paint_past_its_reserved_row() {
2619        use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2620        use gpui::TestApp;
2621
2622        const TEXT_BACKGROUND: u32 = 0x20f0b0;
2623
2624        struct MarkdownRoot {
2625            text_view: Entity<TextViewState>,
2626        }
2627
2628        impl Render for MarkdownRoot {
2629            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2630                div()
2631                    .w(px(640.))
2632                    .text_size(px(17.9))
2633                    .text_bg(gpui::rgb(TEXT_BACKGROUND))
2634                    .child(TextView::new(&self.text_view))
2635            }
2636        }
2637
2638        let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2639        app.update(|cx| {
2640            crate::init(cx);
2641            crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2642        });
2643        let mut window = app.open_window(|_, cx| MarkdownRoot {
2644            text_view: cx.new(|cx| TextViewState::markdown("`main` starts the paragraph", cx)),
2645        });
2646        window.draw();
2647        app.run_until_parked();
2648        window.draw();
2649
2650        let (view_bounds, painted) = window.update(|root, window, cx| {
2651            let view_bounds = root
2652                .text_view
2653                .read(cx)
2654                .bounds()
2655                .scale(window.scale_factor());
2656            let text_background: gpui::Background = gpui::rgb(TEXT_BACKGROUND).into();
2657            let painted = window
2658                .painted_quads()
2659                .into_iter()
2660                .filter(|quad| quad.background == text_background)
2661                .map(|quad| quad.bounds)
2662                .collect::<Vec<_>>();
2663            (view_bounds, painted)
2664        });
2665
2666        assert!(
2667            !painted.is_empty(),
2668            "the inherited text background must make actual text paint observable"
2669        );
2670        assert!(
2671            painted
2672                .iter()
2673                .all(|bounds| bounds.bottom() <= view_bounds.bottom()),
2674            "an inline-code fragment wrapped a second time after InlineFlow reserved one row; \
2675             text background quads={painted:?}, reserved TextView bounds={view_bounds:?}"
2676        );
2677    }
2678
2679    #[test]
2680    fn table_row_backgrounds_follow_the_frame_corner_radius() {
2681        use gpui::TestApp;
2682
2683        const HEAD_BACKGROUND: u32 = 0x11aa77;
2684        const RADIUS: f32 = 8.;
2685
2686        struct RoundedTableRoot {
2687            text_view: Entity<TextViewState>,
2688            scroll: bool,
2689        }
2690
2691        impl Render for RoundedTableRoot {
2692            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2693                let mut table = StyleRefinement::default();
2694                table.corner_radii.top_left = Some(px(RADIUS).into());
2695                table.corner_radii.top_right = Some(px(RADIUS).into());
2696                table.corner_radii.bottom_left = Some(px(RADIUS).into());
2697                table.corner_radii.bottom_right = Some(px(RADIUS).into());
2698                if self.scroll {
2699                    table.overflow.x = Some(gpui::Overflow::Scroll);
2700                }
2701                let style = TextViewStyle::default()
2702                    .with_code_background(gpui::rgb(HEAD_BACKGROUND).into())
2703                    .with_table(table);
2704                div()
2705                    .w(px(400.))
2706                    .child(TextView::new(&self.text_view).style(style))
2707            }
2708        }
2709
2710        for scroll in [false, true] {
2711            let mode = if scroll { "scroll" } else { "wrap" };
2712            let mut app = TestApp::new();
2713            app.update(crate::init);
2714            let mut window = app.open_window(|_, cx| RoundedTableRoot {
2715                text_view: cx
2716                    .new(|cx| TextViewState::markdown("| a | b |\n| --- | --- |\n| 1 | 2 |", cx)),
2717                scroll,
2718            });
2719            window.draw();
2720            app.run_until_parked();
2721            window.draw();
2722
2723            let (quad, scale) = window.update(|_, window, _| {
2724                let head: gpui::Background = gpui::rgb(HEAD_BACKGROUND).into();
2725                let quad = window
2726                    .painted_quads()
2727                    .into_iter()
2728                    .find(|quad| quad.background == head)
2729                    .unwrap_or_else(|| panic!("{mode}: the header background quad is painted"));
2730                (quad, window.scale_factor())
2731            });
2732
2733            // GPUI clips children with a rectangular mask, so the header fill
2734            // must carry the frame radius itself, inset by the 1px border.
2735            let expected = px(RADIUS - 1.).scale(scale);
2736            assert_eq!(quad.corner_radii.top_left, expected, "{mode}: top left");
2737            assert_eq!(quad.corner_radii.top_right, expected, "{mode}: top right");
2738            let square = px(0.).scale(scale);
2739            assert_eq!(quad.corner_radii.bottom_left, square, "{mode}: bottom left");
2740            assert_eq!(
2741                quad.corner_radii.bottom_right, square,
2742                "{mode}: bottom right"
2743            );
2744        }
2745    }
2746
2747    /// A table wider than its frame shrinks its widest column. A narrower
2748    /// column keeps its content width rather than shrinking by the same
2749    /// ratio and wrapping the end of its text onto a line of its own, in the
2750    /// header and the body alike, and with the cell and frame refinements
2751    /// the text renders with.
2752    #[test]
2753    fn table_keeps_narrow_columns_on_one_line_while_wide_ones_wrap() {
2754        use crate::text::inline::test_fonts::WideMonoTextSystem;
2755        use gpui::TestApp;
2756        use std::sync::Arc;
2757
2758        const CELL_BACKGROUND: u32 = 0x11aa77;
2759
2760        struct TableRoot {
2761            text_view: Entity<TextViewState>,
2762            width: Pixels,
2763            style: TextViewStyle,
2764        }
2765
2766        impl Render for TableRoot {
2767            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2768                div()
2769                    .w(self.width)
2770                    .child(TextView::new(&self.text_view).style(self.style.clone()))
2771            }
2772        }
2773
2774        let short = "Operating revenue (USD)";
2775        let source = format!(
2776            "| {short} | Note |\n| --- | --- |\n| {short} | x |\n| 1 | {} |",
2777            "word ".repeat(30)
2778        );
2779        // Heights of the first column's cells, top to bottom.
2780        let first_column = |width: f32, style: TextViewStyle| {
2781            let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2782            app.update(crate::init);
2783            let cell = style.table_cell().clone().bg(gpui::rgb(CELL_BACKGROUND));
2784            let style = style.with_table_cell(cell);
2785            let mut window = app.open_window(|_, cx| TableRoot {
2786                text_view: cx.new(|cx| TextViewState::markdown(&source, cx)),
2787                width: px(width),
2788                style,
2789            });
2790            window.draw();
2791            app.run_until_parked();
2792            window.draw();
2793            window.update(|_, window, _| {
2794                let background: gpui::Background = gpui::rgb(CELL_BACKGROUND).into();
2795                let cells: Vec<_> = window
2796                    .painted_quads()
2797                    .into_iter()
2798                    .filter(|quad| quad.background == background)
2799                    .map(|quad| quad.bounds)
2800                    .collect();
2801                let left = cells.iter().map(|bounds| bounds.origin.x).min().unwrap();
2802                let mut column: Vec<_> = cells
2803                    .into_iter()
2804                    .filter(|bounds| bounds.origin.x == left)
2805                    .collect();
2806                column.sort_by_key(|bounds| bounds.origin.y);
2807                column
2808                    .iter()
2809                    .map(|bounds| bounds.size.height)
2810                    .collect::<Vec<_>>()
2811            })
2812        };
2813        let scroll = || {
2814            let mut table = StyleRefinement::default();
2815            table.overflow.x = Some(gpui::Overflow::Scroll);
2816            TextViewStyle::default().with_table(table)
2817        };
2818
2819        for (layout, style) in [("wrap", TextViewStyle::default()), ("scroll", scroll())] {
2820            let fits = first_column(4000., style.clone());
2821            let narrow = first_column(700., style);
2822            assert_eq!(fits.len(), 3, "{layout}: three rows");
2823            assert_eq!(fits[0], fits[2], "{layout}: the header fits on one line");
2824            assert_eq!(narrow[..2], fits[..2], "{layout}: the short column wraps");
2825        }
2826
2827        let single_line = first_column(4000., TextViewStyle::default())[0];
2828        let padded = TextViewStyle::default().with_table_cell(StyleRefinement::default().px_4());
2829        assert_eq!(
2830            first_column(4000., padded)[0],
2831            single_line,
2832            "the cell padding refinement leaves the text its measured width"
2833        );
2834        let bold = TextViewStyle::default()
2835            .with_table(StyleRefinement::default().font_weight(gpui::FontWeight::BOLD));
2836        assert_eq!(
2837            first_column(4000., bold)[0],
2838            single_line,
2839            "the table text refinement is measured"
2840        );
2841    }
2842
2843    #[gpui::test]
2844    fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
2845        cx.update(crate::init);
2846        let (_, cx) =
2847            cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2848        let cx: &mut VisualTestContext = cx;
2849
2850        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2851
2852        assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2853    }
2854
2855    #[gpui::test]
2856    fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
2857        cx.update(crate::init);
2858        let (_, cx) =
2859            cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2860        let cx: &mut VisualTestContext = cx;
2861
2862        cx.simulate_mouse_down(
2863            point(px(10.), px(10.)),
2864            MouseButton::Right,
2865            Modifiers::default(),
2866        );
2867        cx.simulate_mouse_up(
2868            point(px(10.), px(10.)),
2869            MouseButton::Right,
2870            Modifiers::default(),
2871        );
2872
2873        assert_eq!(cx.opened_url(), None);
2874    }
2875
2876    #[gpui::test]
2877    fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
2878        use std::sync::{Arc, Mutex};
2879
2880        struct LinkRoot {
2881            text_view: Entity<TextViewState>,
2882            clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
2883        }
2884
2885        impl Render for LinkRoot {
2886            fn render(
2887                &mut self,
2888                _window: &mut Window,
2889                _cx: &mut Context<Self>,
2890            ) -> impl IntoElement {
2891                let clicks = self.clicks.clone();
2892                div()
2893                    .w(px(240.))
2894                    .child(
2895                        TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
2896                            clicks.lock().unwrap().push((url.clone(), event.clone()));
2897                        }),
2898                    )
2899            }
2900        }
2901
2902        cx.update(crate::init);
2903        let clicks = Arc::new(Mutex::new(Vec::new()));
2904        let captured = clicks.clone();
2905        let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
2906            text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
2907            clicks,
2908        });
2909        let cx: &mut VisualTestContext = cx;
2910
2911        let mut modifiers = Modifiers::default();
2912        modifiers.control = true;
2913        cx.simulate_click(point(px(10.), px(10.)), modifiers);
2914        cx.simulate_mouse_down(
2915            point(px(10.), px(10.)),
2916            MouseButton::Middle,
2917            Modifiers::default(),
2918        );
2919        cx.simulate_mouse_up(
2920            point(px(10.), px(10.)),
2921            MouseButton::Middle,
2922            Modifiers::default(),
2923        );
2924        cx.simulate_mouse_down(
2925            point(px(10.), px(10.)),
2926            MouseButton::Right,
2927            Modifiers::default(),
2928        );
2929        cx.simulate_mouse_up(
2930            point(px(10.), px(10.)),
2931            MouseButton::Right,
2932            Modifiers::default(),
2933        );
2934
2935        let clicks = captured.lock().unwrap();
2936        assert_eq!(clicks.len(), 3);
2937        assert_eq!(clicks[0].0, "https://example.com");
2938        assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
2939        assert!(clicks[0].1.modifiers().control);
2940        assert!(clicks[1].1.is_middle_click());
2941        assert!(clicks[2].1.is_right_click());
2942        assert_eq!(cx.opened_url(), None);
2943    }
2944
2945    #[gpui::test]
2946    fn inline_object_inherits_bold_and_link_clicks_without_opening_on_drag(
2947        cx: &mut TestAppContext,
2948    ) {
2949        use std::sync::{Arc, Mutex};
2950        struct Root {
2951            state: Entity<TextViewState>,
2952            clicks: Arc<Mutex<Vec<SharedString>>>,
2953        }
2954        impl Render for Root {
2955            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2956                let clicks = self.clicks.clone();
2957                let extensions = crate::text::MarkdownExtensions::default().plugin(
2958                    crate::text::markdown_ext::TestInlinePlugin::new("math")
2959                        .parse_with(|node, _| {
2960                            matches!(node, markdown::mdast::Node::InlineMath(_)).then(|| {
2961                                super::super::MarkdownNode::new("math", ()).text("formula")
2962                            })
2963                        })
2964                        .render_with(|_, context, _, _| {
2965                            assert_eq!(context.text_style().font_weight, gpui::FontWeight::BOLD);
2966                            Some(super::super::InlineElement::new(div().child("formula")))
2967                        }),
2968                );
2969                div().w(px(300.)).child(crate::TextSelectionLayer).child(
2970                    TextView::new(&self.state)
2971                        .markdown_extensions(extensions)
2972                        .on_link_click(move |url, _, _, _| {
2973                            clicks.lock().unwrap().push(url.clone())
2974                        }),
2975                )
2976            }
2977        }
2978        cx.update(crate::init);
2979        let clicks = Arc::new(Mutex::new(Vec::new()));
2980        let captured = clicks.clone();
2981        let (root, cx) = cx.add_window_view(move |_, cx| Root {
2982            state: cx.new(|cx| TextViewState::markdown("**[$x$](https://example.com)**", cx)),
2983            clicks,
2984        });
2985        let cx: &mut VisualTestContext = cx;
2986        cx.run_until_parked();
2987        cx.update(|window, cx| {
2988            let _ = window.draw(cx);
2989        });
2990        let bounds = root.read_with(cx, |root, cx| {
2991            root.state.read(cx).selection_adapter.text_bounds()[0]
2992        });
2993        for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
2994            cx.simulate_mouse_down(bounds.center(), button, Modifiers::default());
2995            cx.simulate_mouse_up(bounds.center(), button, Modifiers::default());
2996        }
2997        assert_eq!(captured.lock().unwrap().len(), 3);
2998        let start = point(bounds.left() + px(1.), bounds.center().y);
2999        let end = point(bounds.right() - px(1.), bounds.center().y);
3000        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
3001        cx.update(|window, cx| {
3002            let _ = window.draw(cx);
3003        });
3004        cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
3005        cx.update(|window, cx| {
3006            let _ = window.draw(cx);
3007        });
3008        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
3009        assert_eq!(captured.lock().unwrap().len(), 3);
3010    }
3011
3012    #[gpui::test]
3013    fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
3014        use std::sync::{Arc, Mutex};
3015
3016        struct LinkedImageRoot {
3017            text_view: Entity<TextViewState>,
3018            clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
3019        }
3020
3021        impl Render for LinkedImageRoot {
3022            fn render(
3023                &mut self,
3024                _window: &mut Window,
3025                _cx: &mut Context<Self>,
3026            ) -> impl IntoElement {
3027                let clicks = self.clicks.clone();
3028                div().w(px(160.)).child(
3029                    TextView::new(&self.text_view)
3030                        .selectable(true)
3031                        .on_link_click(move |url, event, _, _| {
3032                            clicks.lock().unwrap().push((url.clone(), event.clone()));
3033                        }),
3034                )
3035            }
3036        }
3037
3038        cx.update(crate::init);
3039        let clicks = Arc::new(Mutex::new(Vec::new()));
3040        let captured = clicks.clone();
3041        let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
3042                text_view: cx.new(|cx| {
3043                    TextViewState::markdown(
3044                        r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
3045                        cx,
3046                    )
3047                }),
3048                clicks,
3049            }
3050        );
3051        let cx: &mut VisualTestContext = cx;
3052        cx.run_until_parked();
3053        cx.update(|window, cx| {
3054            let _ = window.draw(cx);
3055        });
3056
3057        let inline_bounds = content.read_with(cx, |content, cx| {
3058            content.text_view.read(cx).selection_adapter.text_bounds()
3059        });
3060        assert!(
3061            inline_bounds.len() >= 2,
3062            "linked image needs text bounds on both sides: {inline_bounds:?}"
3063        );
3064        assert!(
3065            inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
3066            "linked image did not reserve the expected click target: {inline_bounds:?}"
3067        );
3068        let position = point(
3069            inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
3070            inline_bounds[0].top() + px(8.),
3071        );
3072        for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
3073            cx.simulate_mouse_down(position, button, Modifiers::default());
3074            cx.simulate_mouse_up(position, button, Modifiers::default());
3075        }
3076
3077        let clicks = captured.lock().unwrap();
3078        assert_eq!(clicks.len(), 3);
3079        assert!(
3080            clicks
3081                .iter()
3082                .all(|(url, _)| url == "https://example.com/image-link")
3083        );
3084        assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
3085        assert!(clicks[1].1.is_middle_click());
3086        assert!(clicks[2].1.is_right_click());
3087        assert_eq!(cx.opened_url(), None);
3088    }
3089
3090    #[gpui::test]
3091    fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
3092        cx.update(crate::init);
3093        let (view, cx) = cx
3094            .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
3095        let cx: &mut VisualTestContext = cx;
3096
3097        cx.simulate_mouse_down(
3098            point(px(10.), px(34.)),
3099            MouseButton::Left,
3100            Modifiers::default(),
3101        );
3102        cx.simulate_mouse_move(
3103            point(px(90.), px(34.)),
3104            Some(MouseButton::Left),
3105            Modifiers::default(),
3106        );
3107        cx.simulate_mouse_up(
3108            point(px(90.), px(34.)),
3109            MouseButton::Left,
3110            Modifiers::default(),
3111        );
3112
3113        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3114        assert!(
3115            selected_text.is_empty(),
3116            "unexpected selection: {selected_text:?}"
3117        );
3118    }
3119
3120    /// A tall selectable TextView clipped by a short `overflow_hidden` viewport,
3121    /// with a large blank footer below so a drag can extend the selection band
3122    /// past the bottom of the clip while still proxy-anchoring to the view.
3123    struct ClippedTallTextViewTestRoot {
3124        text_view: Entity<TextViewState>,
3125    }
3126
3127    impl ClippedTallTextViewTestRoot {
3128        fn new(cx: &mut Context<Self>) -> Self {
3129            // Four separate blocks; only the first (and maybe part of the
3130            // second) fit inside the 40px clip. "charlie"/"delta" render well
3131            // below it.
3132            let text_view =
3133                cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
3134            Self { text_view }
3135        }
3136    }
3137
3138    impl Render for ClippedTallTextViewTestRoot {
3139        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3140            div()
3141                .w(px(200.))
3142                .child(crate::TextSelectionLayer)
3143                .child(
3144                    div()
3145                        .h(px(40.))
3146                        .overflow_hidden()
3147                        .child(TextView::new(&self.text_view).selectable(true)),
3148                )
3149                // A tall blank footer so a drag can reach a y below the clipped
3150                // text; a press there proxy-anchors to the TextView above.
3151                .child(div().h(px(160.)))
3152        }
3153    }
3154
3155    /// Regression for copying a selection taller than the visible viewport.
3156    ///
3157    /// The selection band runs from visible text at the top down to a point
3158    /// far below the clip. Every glyph of the painted TextView is laid out even
3159    /// though the lower ones are clipped away, so the copied text must include
3160    /// the clipped-out "charlie"/"delta" — not just what is on screen. This
3161    /// guards against re-adding a `content_mask` gate in
3162    /// `Inline::layout_selections`.
3163    #[gpui::test]
3164    fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
3165        cx.update(crate::init);
3166        let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
3167        let cx: &mut VisualTestContext = cx;
3168
3169        cx.run_until_parked();
3170        cx.update(|window, cx| {
3171            let _ = window.draw(cx);
3172        });
3173
3174        // Anchor on visible text near the top, then drag to a point well below
3175        // the 40px clip (into the blank footer) and to the far right so the
3176        // last line is fully covered.
3177        cx.simulate_mouse_down(
3178            point(px(2.), px(8.)),
3179            MouseButton::Left,
3180            Modifiers::default(),
3181        );
3182        cx.update(|window, cx| {
3183            let _ = window.draw(cx);
3184        });
3185        cx.simulate_mouse_move(
3186            point(px(180.), px(150.)),
3187            Some(MouseButton::Left),
3188            Modifiers::default(),
3189        );
3190        cx.update(|window, cx| {
3191            let _ = window.draw(cx);
3192        });
3193        cx.simulate_mouse_up(
3194            point(px(180.), px(150.)),
3195            MouseButton::Left,
3196            Modifiers::default(),
3197        );
3198        cx.update(|window, cx| {
3199            let _ = window.draw(cx);
3200        });
3201
3202        let selected_text =
3203            content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3204        assert!(
3205            selected_text.contains("delta"),
3206            "clipped-out text was not copied: {selected_text:?}"
3207        );
3208        assert!(
3209            selected_text.contains("charlie"),
3210            "clipped-out text was not copied: {selected_text:?}"
3211        );
3212    }
3213
3214    #[gpui::test]
3215    fn double_click_selects_word(cx: &mut TestAppContext) {
3216        cx.update(crate::init);
3217        let (view, cx) =
3218            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3219
3220        let cx: &mut VisualTestContext = cx;
3221        cx.run_until_parked();
3222        cx.update(|window, cx| {
3223            let _ = window.draw(cx);
3224        });
3225        let position = point(px(10.), px(16.));
3226        cx.simulate_event(MouseDownEvent {
3227            position,
3228            modifiers: Modifiers::default(),
3229            button: MouseButton::Left,
3230            click_count: 2,
3231            first_mouse: false,
3232        });
3233        cx.simulate_event(MouseUpEvent {
3234            position,
3235            modifiers: Modifiers::default(),
3236            button: MouseButton::Left,
3237            click_count: 2,
3238        });
3239        cx.update(|window, cx| {
3240            let _ = window.draw(cx);
3241        });
3242
3243        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3244        assert_eq!(selected_text.trim(), "quick");
3245    }
3246
3247    #[gpui::test]
3248    fn long_press_selects_word_then_drag_extends_selection(cx: &mut TestAppContext) {
3249        struct TouchRoot {
3250            text_view: Entity<TextViewState>,
3251        }
3252        impl Render for TouchRoot {
3253            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3254                div()
3255                    .w(px(300.))
3256                    .child(crate::TextSelectionLayer)
3257                    .child(TextView::new(&self.text_view).selectable(true))
3258            }
3259        }
3260        cx.update(crate::init);
3261        let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3262            text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3263        });
3264        cx.run_until_parked();
3265        cx.update(|window, cx| {
3266            let _ = window.draw(cx);
3267        });
3268        let start_position = point(px(10.), px(16.));
3269        cx.simulate_event(gpui::LongPressEvent {
3270            phase: gpui::TouchPhase::Started,
3271            start_position,
3272            position: start_position,
3273        });
3274        cx.update(|window, cx| {
3275            let _ = window.draw(cx);
3276        });
3277        assert_eq!(
3278            view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3279                .trim(),
3280            "quick"
3281        );
3282        for phase in [gpui::TouchPhase::Moved, gpui::TouchPhase::Ended] {
3283            cx.simulate_event(gpui::LongPressEvent {
3284                phase,
3285                start_position,
3286                position: point(px(220.), px(16.)),
3287            });
3288        }
3289        cx.update(|window, cx| {
3290            let _ = window.draw(cx);
3291        });
3292        assert_eq!(
3293            view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3294                .trim(),
3295            "quick select value"
3296        );
3297    }
3298
3299    #[gpui::test]
3300    fn long_press_release_keeps_handles_which_drag_the_selection(cx: &mut TestAppContext) {
3301        use crate::{SelectionEdge, TextSelection};
3302
3303        struct TouchRoot {
3304            text_view: Entity<TextViewState>,
3305        }
3306        impl Render for TouchRoot {
3307            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3308                div()
3309                    .w(px(300.))
3310                    .child(crate::TextSelectionLayer)
3311                    .child(TextView::new(&self.text_view).selectable(true))
3312            }
3313        }
3314        cx.update(crate::init);
3315        let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3316            text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3317        });
3318        let draw = |cx: &mut VisualTestContext| {
3319            cx.update(|window, cx| {
3320                let _ = window.draw(cx);
3321            });
3322        };
3323        let selected = |cx: &mut VisualTestContext| {
3324            view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3325                .trim()
3326                .to_string()
3327        };
3328        cx.run_until_parked();
3329        draw(cx);
3330
3331        let start_position = point(px(70.), px(16.));
3332        for phase in [gpui::TouchPhase::Started, gpui::TouchPhase::Ended] {
3333            cx.simulate_event(gpui::LongPressEvent {
3334                phase,
3335                start_position,
3336                position: start_position,
3337            });
3338            draw(cx);
3339        }
3340        assert_eq!(selected(cx), "select");
3341        let snapshot = cx
3342            .update(|window, cx| TextSelection::touch_selection(window, cx))
3343            .expect("a released long press keeps its handles");
3344        assert!(snapshot.is_menu_open());
3345        assert!(!snapshot.is_empty());
3346        assert!(snapshot.start().left() < snapshot.end().left());
3347
3348        // Drag the end handle to the end of the line. The finger holds the
3349        // knob below the line, the selection follows along the line.
3350        let end = snapshot.end();
3351        let finger = point(end.left(), end.bottom() + px(20.));
3352        cx.update(|window, cx| {
3353            TextSelection::begin_edge_drag(SelectionEdge::End, finger, window, cx);
3354        });
3355        draw(cx);
3356        let snapshot = cx
3357            .update(|window, cx| TextSelection::touch_selection(window, cx))
3358            .unwrap();
3359        assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
3360        assert!(!snapshot.is_menu_open());
3361        cx.update(|window, cx| {
3362            TextSelection::update_edge_drag(point(px(290.), finger.y), window, cx);
3363        });
3364        draw(cx);
3365        assert_eq!(selected(cx), "select value");
3366        cx.update(|window, cx| TextSelection::end_edge_drag(window, cx));
3367        draw(cx);
3368        let snapshot = cx
3369            .update(|window, cx| TextSelection::touch_selection(window, cx))
3370            .unwrap();
3371        assert!(snapshot.is_menu_open());
3372        assert_eq!(snapshot.dragging(), None);
3373        let select_start = snapshot.start().left();
3374
3375        // Select All from the menu is a view-local selection; its handles
3376        // still drag, turning it back into a point selection.
3377        cx.update(|_, cx| {
3378            view.update(cx, |root, cx| {
3379                root.text_view.update(cx, |state, cx| state.select_all(cx));
3380            });
3381        });
3382        draw(cx);
3383        assert_eq!(selected(cx), "quick select value");
3384        let snapshot = cx
3385            .update(|window, cx| TextSelection::touch_selection(window, cx))
3386            .expect("select all keeps the touch selection");
3387        let start = snapshot.start();
3388        cx.update(|window, cx| {
3389            TextSelection::begin_edge_drag(SelectionEdge::Start, start.origin, window, cx);
3390            TextSelection::update_edge_drag(point(select_start, start.origin.y), window, cx);
3391            TextSelection::end_edge_drag(window, cx);
3392        });
3393        draw(cx);
3394        assert_eq!(selected(cx), "select value");
3395
3396        // A press on the menu leaves the selection alone; one on the text
3397        // clears it.
3398        let menu = gpui::Bounds::new(point(px(0.), px(200.)), gpui::size(px(120.), px(32.)));
3399        cx.update(|window, cx| TextSelection::register_touch_ui(menu, window, cx));
3400        cx.simulate_event(MouseDownEvent {
3401            position: point(px(10.), px(210.)),
3402            modifiers: Modifiers::default(),
3403            button: MouseButton::Left,
3404            click_count: 1,
3405            first_mouse: false,
3406        });
3407        assert_eq!(selected(cx), "select value");
3408        cx.simulate_event(MouseDownEvent {
3409            position: point(px(10.), px(16.)),
3410            modifiers: Modifiers::default(),
3411            button: MouseButton::Left,
3412            click_count: 1,
3413            first_mouse: false,
3414        });
3415        cx.simulate_event(MouseUpEvent {
3416            position: point(px(10.), px(16.)),
3417            modifiers: Modifiers::default(),
3418            button: MouseButton::Left,
3419            click_count: 1,
3420        });
3421        draw(cx);
3422        assert!(
3423            cx.update(|window, cx| TextSelection::touch_selection(window, cx))
3424                .is_none()
3425        );
3426    }
3427
3428    #[gpui::test]
3429    fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
3430        cx.update(crate::init);
3431        let (view, cx) =
3432            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3433
3434        let cx: &mut VisualTestContext = cx;
3435        cx.run_until_parked();
3436        cx.update(|window, cx| {
3437            let _ = window.draw(cx);
3438        });
3439
3440        let position = point(px(10.), px(10.));
3441        cx.simulate_event(MouseDownEvent {
3442            position,
3443            modifiers: Modifiers::default(),
3444            button: MouseButton::Left,
3445            click_count: 3,
3446            first_mouse: false,
3447        });
3448        cx.simulate_event(MouseUpEvent {
3449            position,
3450            modifiers: Modifiers::default(),
3451            button: MouseButton::Left,
3452            click_count: 3,
3453        });
3454        cx.update(|window, cx| {
3455            let _ = window.draw(cx);
3456        });
3457
3458        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3459        assert_eq!(selected_text.trim(), "quick select value");
3460    }
3461
3462    // Regression: markdown `TextView` items inside an outer `gpui::list` with
3463    // `measure_all` must keep a stable total content height while scrolling.
3464    // Before synchronous full-replace parsing, off-screen markdown views were
3465    // first measured with empty content and the scrollbar thumb jittered as the
3466    // total height grew during scrolling.
3467    #[gpui::test]
3468    fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
3469        use gpui::{ListAlignment, ListState, list};
3470
3471        const ITEMS: &[&str] = &[
3472            "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
3473            "Short.",
3474            "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
3475            "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
3476            "Only one line.",
3477            "**Bold**: medium length text with `code` mixed with regular words.",
3478            "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
3479            "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
3480        ];
3481        let n = 40usize;
3482
3483        struct ListRoot {
3484            state: ListState,
3485        }
3486        impl Render for ListRoot {
3487            fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3488                div().w(px(360.)).h(px(500.)).child(
3489                    list(self.state.clone(), |ix, _w, _cx| {
3490                        div()
3491                            .w_full()
3492                            .child(TextView::markdown(
3493                                ("md", ix as u64),
3494                                ITEMS[ix % ITEMS.len()],
3495                            ))
3496                            .into_any_element()
3497                    })
3498                    .size_full(),
3499                )
3500            }
3501        }
3502
3503        cx.update(crate::init);
3504        let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
3505        let probe = state.clone();
3506        let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
3507        let cx: &mut VisualTestContext = cx;
3508
3509        cx.run_until_parked();
3510        cx.update(|w, cx| {
3511            let _ = w.draw(cx);
3512        });
3513        cx.run_until_parked();
3514        cx.update(|w, cx| {
3515            let _ = w.draw(cx);
3516        });
3517
3518        let total = |p: &ListState| {
3519            f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
3520        };
3521        let mut totals = vec![total(&probe)];
3522        for _ in 0..20 {
3523            probe.scroll_by(px(150.));
3524            cx.update(|w, cx| {
3525                let _ = w.draw(cx);
3526            });
3527            cx.run_until_parked();
3528            totals.push(total(&probe));
3529        }
3530        let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
3531        let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
3532        println!(
3533            "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
3534            max - min
3535        );
3536        assert!(
3537            (max - min) < 2.0,
3538            "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
3539        );
3540    }
3541}