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