Skip to main content

gpui_base/text/
text_view.rs

1use std::{ops::Range, sync::Arc};
2
3use gpui::prelude::FluentBuilder as _;
4use gpui::{
5    AnyElement, App, Bounds, ClickEvent, ContentMask, Element, ElementId, Entity, Global,
6    GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
7    LayoutId, MouseButton, ParentElement, Pixels, Refineable as _, SharedString, StyleRefinement,
8    Styled, Window, div, point, px,
9};
10
11use crate::StyledExt;
12use crate::text::TextViewFormat;
13use crate::text::markdown_ext::{MarkdownExtensions, MarkdownNode, MarkdownPlugin};
14use crate::text::node::{CodeBlock, TableData};
15use crate::text::state::{LineSpan, SelectionFormat, TextViewState};
16use crate::{GlobalState, TextSelection, text::TextViewStyle};
17
18/// Type for code block actions generator function.
19pub(crate) type CodeBlockActionsFn =
20    dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync;
21
22pub(crate) type CodeBlockHighlighterFn =
23    dyn Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync;
24
25/// Application-wide defaults for TextViews that do not provide explicit
26/// presentation or syntax-highlighting overrides.
27#[derive(Clone, Default)]
28pub struct TextViewDefaults {
29    style: Option<TextViewStyle>,
30    code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
31}
32
33impl Global for TextViewDefaults {}
34
35impl TextViewDefaults {
36    /// Creates defaults that leave every text view as Base renders it.
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Sets the style every text view starts from.
42    pub fn with_style(mut self, style: TextViewStyle) -> Self {
43        self.style = Some(style);
44        self
45    }
46
47    /// Sets the syntax highlighter used for fenced code blocks.
48    pub fn with_code_block_highlighter<F>(mut self, highlighter: F) -> Self
49    where
50        F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
51    {
52        self.code_block_highlighter = Some(Arc::new(highlighter));
53        self
54    }
55
56    /// Installs these defaults for the whole application.
57    pub fn install(self, cx: &mut App) {
58        cx.set_global(self);
59    }
60
61    /// Returns the installed defaults, or the Base ones when none were.
62    pub fn global(cx: &App) -> Self {
63        cx.try_global::<Self>().cloned().unwrap_or_default()
64    }
65
66    /// Whether a syntax highlighter was installed.
67    pub fn has_code_block_highlighter(&self) -> bool {
68        self.code_block_highlighter.is_some()
69    }
70}
71
72/// Type for the table actions generator function.
73pub(crate) type TableActionsFn =
74    dyn Fn(&TableData, &mut Window, &mut App) -> AnyElement + Send + Sync;
75
76pub(crate) type LinkClickHandlerFn =
77    dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync;
78
79pub(crate) fn handle_link_click(
80    handler: &Option<Arc<LinkClickHandlerFn>>,
81    url: SharedString,
82    event: ClickEvent,
83    window: &mut Window,
84    cx: &mut App,
85) {
86    if let Some(handler) = handler {
87        handler(&url, &event, window, cx);
88    } else if match &event {
89        ClickEvent::Mouse(click) => {
90            matches!(click.up.button, MouseButton::Left | MouseButton::Middle)
91        }
92        ClickEvent::Keyboard(_) => true,
93        ClickEvent::Touch(click) => !click.long_press,
94    } {
95        cx.open_url(&url);
96    }
97}
98
99/// A text view that can render Markdown or HTML.
100///
101/// ## Goals
102///
103/// - Provide a rich text rendering component for such as Markdown or HTML,
104/// used to display rich text in GPUI application (e.g., Help messages, Release notes)
105/// - Support Markdown GFM and HTML (Simple HTML like Safari Reader Mode) for showing most common used markups.
106/// - Support Heading, Paragraph, Bold, Italic, StrikeThrough, Code, Link, Image, Blockquote, List, Table, HorizontalRule, CodeBlock ...
107///
108/// ## Not Goals
109///
110/// - Customization of the complex style (some simple styles will be supported)
111/// - As a Markdown editor or viewer (If you want to like this, you must fork your version).
112/// - As a HTML viewer, we not support CSS, we only support basic HTML tags for used to as a content reader.
113///
114/// See also [`MarkdownElement`], [`HtmlElement`]
115#[derive(Clone)]
116pub struct TextView {
117    id: ElementId,
118    format: Option<TextViewFormat>,
119    text: Option<SharedString>,
120    pub(crate) state: Option<Entity<TextViewState>>,
121    text_view_style: Option<TextViewStyle>,
122    style: StyleRefinement,
123    selectable: bool,
124    selection_format: SelectionFormat,
125    scrollable: bool,
126    max_lines: Option<usize>,
127    code_block_actions: Option<Arc<CodeBlockActionsFn>>,
128    code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
129    table_actions: Option<Arc<TableActionsFn>>,
130    link_click_handler: Option<Arc<LinkClickHandlerFn>>,
131    markdown_extensions: Arc<MarkdownExtensions>,
132}
133
134/// A plugin that can configure a [`TextView`].
135pub trait TextViewPlugin {
136    fn setup(self, text_view: TextView) -> TextView;
137}
138
139impl<P> TextViewPlugin for P
140where
141    P: MarkdownPlugin,
142{
143    fn setup(self, mut text_view: TextView) -> TextView {
144        let extensions = Arc::make_mut(&mut text_view.markdown_extensions);
145        let current = std::mem::take(extensions);
146        *extensions = current.plugin(self);
147        text_view
148    }
149}
150
151impl Styled for TextView {
152    fn style(&mut self) -> &mut StyleRefinement {
153        &mut self.style
154    }
155}
156
157impl TextView {
158    /// Create new TextView with managed state.
159    pub fn new(state: &Entity<TextViewState>) -> Self {
160        Self {
161            id: ElementId::Name(state.entity_id().to_string().into()),
162            state: Some(state.clone()),
163            format: None,
164            text: None,
165            text_view_style: None,
166            style: StyleRefinement::default(),
167            selectable: true,
168            selection_format: SelectionFormat::default(),
169            scrollable: false,
170            max_lines: None,
171            code_block_actions: None,
172            code_block_highlighter: None,
173            table_actions: None,
174            link_click_handler: None,
175            markdown_extensions: Arc::default(),
176        }
177    }
178
179    /// Create a new markdown text view.
180    pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
181        Self {
182            id: id.into(),
183            format: Some(TextViewFormat::Markdown),
184            text: Some(markdown.into()),
185            text_view_style: None,
186            style: StyleRefinement::default(),
187            state: None,
188            selectable: true,
189            selection_format: SelectionFormat::default(),
190            scrollable: false,
191            max_lines: None,
192            code_block_actions: None,
193            code_block_highlighter: None,
194            table_actions: None,
195            link_click_handler: None,
196            markdown_extensions: Arc::default(),
197        }
198    }
199
200    /// Create a new html text view.
201    pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
202        Self {
203            id: id.into(),
204            format: Some(TextViewFormat::Html),
205            text: Some(html.into()),
206            text_view_style: None,
207            style: StyleRefinement::default(),
208            state: None,
209            selectable: true,
210            selection_format: SelectionFormat::default(),
211            scrollable: false,
212            max_lines: None,
213            code_block_actions: None,
214            code_block_highlighter: None,
215            table_actions: None,
216            link_click_handler: None,
217            markdown_extensions: Arc::default(),
218        }
219    }
220
221    /// Set [`TextViewStyle`].
222    pub fn style(mut self, style: TextViewStyle) -> Self {
223        self.text_view_style = Some(style);
224        self
225    }
226
227    /// Set whether the text view is selectable, default is true.
228    pub fn selectable(mut self, selectable: bool) -> Self {
229        self.selectable = selectable;
230        self
231    }
232
233    /// Set the [`SelectionFormat`], default is [`SelectionFormat::Plain`].
234    ///
235    /// With [`SelectionFormat::Source`], selecting inside `**bold**` yields
236    /// `**bold**` (the Markdown source) rather than `bold`.
237    pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
238        self.selection_format = selection_format;
239        self
240    }
241
242    /// Set the text view to be scrollable, default is false.
243    ///
244    /// ## If true for `scrollable`
245    ///
246    /// The `scrollable` mode used for large content,
247    /// will show scrollbar, but requires the parent to have a fixed height,
248    /// and use [`gpui::list`] to render the content in a virtualized way.
249    ///
250    /// ## If false to fit content
251    ///
252    /// The TextView will expand to fit all content, no scrollbar.
253    /// This mode is suitable for small content, such as a few lines of text, a label, etc.
254    pub fn scrollable(mut self, scrollable: bool) -> Self {
255        self.scrollable = scrollable;
256        self
257    }
258
259    /// Clamp the rendered content to at most `n` lines of body text.
260    ///
261    /// The view's height is capped at `n` × the base line height, and a line
262    /// of glyphs is never cut in half: a line that would straddle the bottom
263    /// of the box is left out whole, across paragraphs, lists, headings, code
264    /// blocks and tables. Nothing is shown with less than a line of itself to
265    /// show, so the border and padding a table row leads with never strands at
266    /// the bottom; whatever has more than that is cut on the box edge and keeps
267    /// the part that fits, rather than disappearing and leaving blank space
268    /// behind.
269    ///
270    /// Check [`TextViewState::is_clamped`] (which answers for the frame that
271    /// was last painted) to decide whether to show an "expand" affordance.
272    ///
273    /// `n` counts lines of body text, so paragraph spacing and taller lines
274    /// mean fewer of them fit inside the capped height. A line taller than the
275    /// whole budget keeps the part that fits rather than emptying the box.
276    /// Ignored when [`Self::scrollable`] is set.
277    pub fn max_lines(mut self, max_lines: usize) -> Self {
278        self.max_lines = Some(max_lines);
279        self
280    }
281
282    /// Set custom block actions for code blocks.
283    ///
284    /// The closure receives the [`CodeBlock`],
285    /// and returns an element to display.
286    pub fn code_block_actions<F, E>(mut self, f: F) -> Self
287    where
288        F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
289        E: IntoElement,
290    {
291        self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
292            f(&code_block, window, cx).into_any_element()
293        }));
294        self
295    }
296
297    /// Adds opt-in syntax highlighting for fenced code blocks.
298    ///
299    /// Returned byte ranges are relative to [`CodeBlock::code`]. Invalid
300    /// ranges are discarded. Without this callback, code is unhighlighted.
301    pub fn code_block_highlighter<F>(mut self, highlighter: F) -> Self
302    where
303        F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
304    {
305        self.code_block_highlighter = Some(Arc::new(highlighter));
306        self
307    }
308
309    /// Set custom actions to be rendered below each Markdown table.
310    ///
311    /// The closure receives the [`TableData`],
312    /// and returns an element to display.
313    pub fn table_actions<F, E>(mut self, f: F) -> Self
314    where
315        F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
316        E: IntoElement,
317    {
318        self.table_actions = Some(Arc::new(move |table, window, cx| {
319            f(table, window, cx).into_any_element()
320        }));
321        self
322    }
323
324    /// Handle pointer events on rendered links.
325    ///
326    /// The handler receives the resolved URL and the original GPUI click event.
327    /// Without a handler, links open through App::open_url.
328    pub fn on_link_click<F>(mut self, handler: F) -> Self
329    where
330        F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
331    {
332        self.link_click_handler = Some(Arc::new(handler));
333        self
334    }
335
336    /// Replace the Markdown extension registry.
337    pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self {
338        self.markdown_extensions = Arc::new(extensions);
339        self
340    }
341
342    /// Enable MDX JSX/expression parsing.
343    ///
344    /// This disables raw HTML parsing because `markdown-rs` gives HTML
345    /// priority over MDX when both are enabled.
346    pub fn markdown_mdx(mut self) -> Self {
347        let extensions = Arc::make_mut(&mut self.markdown_extensions);
348        *extensions = extensions.clone().mdx();
349        self
350    }
351
352    /// Register a custom block-level Markdown parser.
353    ///
354    /// The parser runs during Markdown AST conversion and must be independent
355    /// of [`Window`] / [`App`]. Store any parsed data in [`MarkdownNode`] and
356    /// render it later with [`Self::markdown_block_renderer`].
357    pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
358    where
359        F: for<'a> Fn(
360                &markdown::mdast::Node,
361                &crate::text::MarkdownParseContext<'a>,
362            ) -> Option<MarkdownNode>
363            + Send
364            + Sync
365            + 'static,
366    {
367        Arc::make_mut(&mut self.markdown_extensions).push_block_parser(parser);
368        self
369    }
370
371    /// Register a renderer for a custom block-level Markdown node name.
372    pub fn markdown_block_renderer<F, E>(
373        mut self,
374        name: impl Into<SharedString>,
375        renderer: F,
376    ) -> Self
377    where
378        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
379        E: IntoElement,
380    {
381        Arc::make_mut(&mut self.markdown_extensions).push_block_renderer(name, renderer);
382        self
383    }
384
385    /// Apply a reusable text view plugin.
386    pub fn plugin<P>(self, plugin: P) -> Self
387    where
388        P: TextViewPlugin,
389    {
390        plugin.setup(self)
391    }
392}
393
394impl IntoElement for TextView {
395    type Element = Self;
396
397    fn into_element(self) -> Self::Element {
398        self
399    }
400}
401
402pub struct TextViewLayoutState {
403    state: Entity<TextViewState>,
404    element: AnyElement,
405}
406
407pub struct TextViewPrepaintState {
408    hitbox: Hitbox,
409    /// Where paint has to pull the `max_lines` clip up to, because a glyph line
410    /// straddles the bottom of the box. `None` leaves the clip at the box edge,
411    /// where the container's hidden overflow already applies it.
412    clip_bottom: Option<Pixels>,
413}
414
415/// Absorbs sub-pixel layout jitter: a line ending within a pixel of the box
416/// bottom counts as fitting inside it.
417const CLIP_EPSILON: Pixels = px(1.);
418
419/// The bottom of the last whole line at or above `y`, with the height of a line
420/// where it sits.
421fn last_line_bottom_above(spans: &[LineSpan], y: Pixels) -> Option<(Pixels, Pixels)> {
422    let mut last: Option<(Pixels, Pixels)> = None;
423    let mut keep = |bottom: Pixels, line_height: Pixels| {
424        if bottom <= y + CLIP_EPSILON && last.is_none_or(|(last, _)| bottom > last) {
425            last = Some((bottom, line_height));
426        }
427    };
428
429    for span in spans {
430        if span.line_height <= px(0.) {
431            continue;
432        }
433        let mut bottom = span.top + span.line_height;
434        while bottom <= span.bottom + CLIP_EPSILON {
435            keep(bottom, span.line_height);
436            bottom += span.line_height;
437        }
438        // The span's own bottom covers a last line taller than the rest.
439        keep(span.bottom, span.line_height);
440    }
441
442    last
443}
444
445/// Where to clip, given the lines a descendant `Inline` reported. `None` leaves
446/// the clip on the box edge.
447///
448/// Two things are never shown: half a line of glyphs, and anything with less
449/// than a line of itself to show. A line straddling `box_bottom` is left out
450/// whole, and so is the strip between it and the line before — the border and
451/// padding a table row leads with reads as a rendering fault rather than as a
452/// row. Whatever has more than a line to show is cut on the edge and keeps the
453/// part that fits, so the box holds no blank space it could have filled.
454fn line_safe_clip_bottom(
455    spans: &[LineSpan],
456    box_bottom: Pixels,
457    content_bottom: Pixels,
458) -> Option<Pixels> {
459    let mut clip = box_bottom;
460
461    for span in spans {
462        if span.line_height <= px(0.)
463            || span.top >= box_bottom
464            || span.bottom <= box_bottom + CLIP_EPSILON
465        {
466            continue;
467        }
468        let whole_lines = ((box_bottom - span.top) / span.line_height).floor();
469        let line_top = span.top + span.line_height * whole_lines;
470        // A line starting on the box edge is not straddling it.
471        if line_top < box_bottom - CLIP_EPSILON {
472            clip = clip.min(line_top);
473        }
474    }
475
476    let Some((last_line_bottom, line_height)) = last_line_bottom_above(spans, clip) else {
477        // Leaving the straddling line out would leave nothing at all — a first
478        // line taller than the whole budget, a heading in a one-line box. It
479        // keeps the part that fits instead, because an empty clamp reads as
480        // broken where a cut one reads as more to come.
481        return None;
482    };
483
484    // Snap away a scrap. Only content that continues past the box can leave
485    // one: the space under the last line of a document that fits is the box's
486    // own, not a piece of something below.
487    if content_bottom > box_bottom + CLIP_EPSILON {
488        let strip = clip - last_line_bottom;
489        if strip > CLIP_EPSILON && strip < line_height {
490            clip = last_line_bottom;
491        }
492    }
493
494    (clip < box_bottom - CLIP_EPSILON).then_some(clip)
495}
496
497impl Element for TextView {
498    type RequestLayoutState = TextViewLayoutState;
499    type PrepaintState = TextViewPrepaintState;
500
501    fn id(&self) -> Option<ElementId> {
502        Some(self.id.clone())
503    }
504
505    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
506        None
507    }
508
509    fn request_layout(
510        &mut self,
511        _: Option<&GlobalElementId>,
512        _: Option<&InspectorElementId>,
513        window: &mut Window,
514        cx: &mut App,
515    ) -> (LayoutId, Self::RequestLayoutState) {
516        let state = if let Some(state) = self.state.clone() {
517            state
518        } else {
519            let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
520            let default_text = self.text.clone().unwrap_or_default();
521
522            let state = window.use_keyed_state(
523                SharedString::from(format!("{}/state", self.id)),
524                cx,
525                move |_, cx| {
526                    if default_format == TextViewFormat::Markdown {
527                        TextViewState::markdown(default_text.as_str(), cx)
528                    } else {
529                        TextViewState::html(default_text.as_str(), cx)
530                    }
531                },
532            );
533            self.state = Some(state.clone());
534            state
535        };
536
537        // `max_lines` needs the whole document laid out to snap the clip to a
538        // whole line, so it only applies to the fit-content mode.
539        let max_lines = self.max_lines.filter(|_| !self.scrollable);
540
541        let defaults = TextViewDefaults::global(cx);
542        let text_view_style = self
543            .text_view_style
544            .clone()
545            .or(defaults.style)
546            .unwrap_or_else(|| TextViewStyle::from_theme(&crate::Theme::global(cx)));
547        let code_block_highlighter = self
548            .code_block_highlighter
549            .clone()
550            .or(defaults.code_block_highlighter);
551
552        state.update(cx, |state, cx| {
553            state.code_block_actions = self.code_block_actions.clone();
554            state.code_block_highlighter = code_block_highlighter.clone();
555            state.table_actions = self.table_actions.clone();
556            state.link_click_handler = self.link_click_handler.clone();
557            state.set_markdown_extensions(self.markdown_extensions.clone(), cx);
558            state.selectable = self.selectable;
559            state.selection_format = self.selection_format;
560            state.scrollable = self.scrollable;
561            state.max_lines = max_lines;
562            if state.text_view_style != text_view_style {
563                state.selection_revision = state.selection_revision.wrapping_add(1);
564            }
565            state.text_view_style = text_view_style.clone();
566
567            if let Some(text) = self.text.clone() {
568                state.set_text(text.as_str(), cx);
569            }
570        });
571
572        let focus_handle = state.read(cx).focus_handle.clone();
573        let list_state = state.read(cx).list_state.clone();
574        // Cap the box at `n` body-text lines (the effective text style may be
575        // refined by this view's own style, e.g. `.text_sm()`); hidden
576        // overflow also clips descendant hitboxes to the box during prepaint.
577        let max_lines_cap = max_lines.map(|max_lines| {
578            let mut text_style = window.text_style();
579            text_style.refine(&self.style.text);
580            text_style.line_height_in_pixels(window.rem_size()) * max_lines as f32
581        });
582
583        let mut el = div()
584            .id(("text-view-scroll", state.entity_id()))
585            .key_context("TextView")
586            .track_focus(&focus_handle)
587            .when(self.scrollable, |this| this.size_full())
588            .when_some(max_lines_cap, |this, cap| this.max_h(cap).overflow_hidden())
589            .relative()
590            .text_color(text_view_style.foreground())
591            .on_action(move |_: &crate::input::Copy, window, cx| {
592                let text = TextSelection::selected_text(window, cx).trim().to_string();
593                if text.is_empty() {
594                    cx.propagate();
595                    return;
596                }
597                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
598            })
599            .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
600            .child(state.clone())
601            // Overlay controls must paint after the document, otherwise rich
602            // content and selection backgrounds cover the thumb and hitbox.
603            .when(self.scrollable, |this| {
604                this.child(
605                    div().absolute().inset_0().child(
606                        crate::Scrollbar::vertical(&list_state)
607                            .id(("text-view-scrollbar", state.entity_id()))
608                            .viewport_from_layout(),
609                    ),
610                )
611            })
612            .refine_style(&self.style)
613            .into_any_element();
614        let layout_id = el.request_layout(window, cx);
615        (layout_id, TextViewLayoutState { state, element: el })
616    }
617
618    fn prepaint(
619        &mut self,
620        _: Option<&GlobalElementId>,
621        _: Option<&InspectorElementId>,
622        bounds: Bounds<Pixels>,
623        request_layout: &mut Self::RequestLayoutState,
624        window: &mut Window,
625        cx: &mut App,
626    ) -> Self::PrepaintState {
627        let state = request_layout.state.clone();
628        let max_lines_active = state.read(cx).max_lines.is_some();
629        if max_lines_active {
630            if let Ok(mut line_spans) = state.read(cx).line_spans.lock() {
631                line_spans.clear();
632            }
633            // Descendant `Inline`s report their line spans through the state
634            // stack during prepaint (in addition to the paint-time push below).
635            GlobalState::global_mut(cx)
636                .text_view_state_stack
637                .push(state.clone());
638        }
639        request_layout.element.prepaint(window, cx);
640        if max_lines_active {
641            GlobalState::global_mut(cx).text_view_state_stack.pop();
642        }
643
644        let mut clip_bottom = None;
645        if max_lines_active {
646            let (line_spans, content_bottom) = {
647                let state = state.read(cx);
648                (
649                    state
650                        .line_spans
651                        .lock()
652                        .map(|spans| spans.clone())
653                        .unwrap_or_default(),
654                    state.bounds().bottom(),
655                )
656            };
657            // The content keeps its natural height inside the capped box, so
658            // this sees everything the box cannot show — including a tall image
659            // that reports no lines of its own.
660            let clipped = content_bottom > bounds.bottom() + px(1.);
661            // Notify on change so observers (e.g. an "expand" button gated on
662            // `is_clamped`) re-render once the flag flips.
663            if state.read(cx).clamped != clipped {
664                state.update(cx, |state, cx| {
665                    state.clamped = clipped;
666                    cx.notify();
667                });
668            }
669            if clipped {
670                clip_bottom = line_safe_clip_bottom(&line_spans, bounds.bottom(), content_bottom);
671            }
672        }
673
674        TextViewPrepaintState {
675            hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
676            clip_bottom,
677        }
678    }
679
680    fn paint(
681        &mut self,
682        _: Option<&GlobalElementId>,
683        _: Option<&InspectorElementId>,
684        bounds: Bounds<Pixels>,
685        request_layout: &mut Self::RequestLayoutState,
686        prepaint: &mut Self::PrepaintState,
687        window: &mut Window,
688        cx: &mut App,
689    ) {
690        let state = &request_layout.state;
691        if self.selectable {
692            state.update(cx, |state, _| state.selection_adapter.begin_frame());
693        }
694
695        GlobalState::global_mut(cx)
696            .text_view_state_stack
697            .push(state.clone());
698        if let Some(clip_bottom) = prepaint.clip_bottom {
699            // Snap the `max_lines` clip to the last whole line that fits, so a
700            // line of glyphs is never cut in half.
701            let mask = ContentMask {
702                bounds: Bounds::from_corners(bounds.origin, point(bounds.right(), clip_bottom)),
703            };
704            window.with_content_mask(Some(mask), |window| {
705                request_layout.element.paint(window, cx);
706            });
707        } else {
708            request_layout.element.paint(window, cx);
709        }
710        GlobalState::global_mut(cx).text_view_state_stack.pop();
711
712        if self.selectable {
713            let (adapter, scroll_offset, content_bounds) = {
714                let state = state.read(cx);
715                (
716                    state.selection_adapter.clone(),
717                    state.scroll_offset(),
718                    state.bounds(),
719                )
720            };
721            let document_order = GlobalState::global_mut(cx).next_selection_document_order();
722            adapter.register(
723                prepaint.hitbox.clone(),
724                content_bounds,
725                scroll_offset,
726                document_order,
727                window,
728                cx,
729            );
730        }
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use std::sync::{
737        Arc,
738        atomic::{AtomicUsize, Ordering},
739    };
740
741    use super::{TextView, TextViewPlugin};
742    use crate::text::{TableData, TextViewState, TextViewStyle};
743    use gpui::{
744        AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
745        Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
746        Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
747        TestAppContext, VisualTestContext, Window, div, point, px,
748    };
749
750    struct TextViewTestRoot {
751        text_view: Entity<TextViewState>,
752    }
753
754    struct StatelessMarkdownRoot {
755        renders: Arc<AtomicUsize>,
756    }
757
758    impl Render for StatelessMarkdownRoot {
759        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
760            self.renders.fetch_add(1, Ordering::Relaxed);
761            div().child(
762                TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
763                    .markdown_block_parser(|_, _| None),
764            )
765        }
766    }
767
768    struct DummyTextViewPlugin;
769
770    impl TextViewPlugin for DummyTextViewPlugin {
771        fn setup(self, mut text_view: TextView) -> TextView {
772            text_view.selectable = true;
773            text_view
774        }
775    }
776
777    #[gpui::test]
778    fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
779        cx.update(crate::init);
780        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
781
782        assert!(TextView::new(&state).selectable);
783        assert!(TextView::markdown("markdown", "text").selectable);
784        assert!(TextView::html("html", "<p>text</p>").selectable);
785    }
786
787    #[gpui::test]
788    fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
789        cx.update(crate::init);
790        let renders = Arc::new(AtomicUsize::new(0));
791        let (_, cx) = cx.add_window_view({
792            let renders = renders.clone();
793            move |_, _| StatelessMarkdownRoot { renders }
794        });
795        let cx: &mut VisualTestContext = cx;
796
797        cx.run_until_parked();
798        assert!(
799            renders.load(Ordering::Relaxed) <= 2,
800            "an unchanged TextView must settle after its parse, but rendered {} times",
801            renders.load(Ordering::Relaxed),
802        );
803    }
804
805    #[gpui::test]
806    fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
807        cx.update(crate::init);
808        cx.update(|cx| {
809            let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
810            colors.primary = gpui::rgb(0x55aaff).into();
811            colors.selection = gpui::rgb(0x335577).into();
812        });
813        let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
814        let cx: &mut VisualTestContext = cx;
815
816        cx.run_until_parked();
817        root.read_with(cx, |root, cx| {
818            let style = &root.text_view.read(cx).text_view_style;
819            assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
820            assert_eq!(style.selection(), gpui::rgb(0x335577).into());
821        });
822    }
823
824    impl TextViewTestRoot {
825        fn new(text: &str, cx: &mut Context<Self>) -> Self {
826            let text = text.to_string();
827            let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
828            Self { text_view }
829        }
830    }
831
832    impl Render for TextViewTestRoot {
833        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
834            div()
835                .w(px(160.))
836                .child(
837                    div()
838                        .h(px(24.))
839                        .overflow_hidden()
840                        .child(TextView::new(&self.text_view).selectable(true)),
841                )
842                .child(div().h(px(40.)).child("footer"))
843        }
844    }
845
846    struct TableSelectionTestRoot {
847        text_view: Entity<TextViewState>,
848    }
849
850    impl Render for TableSelectionTestRoot {
851        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
852            div()
853                .debug_selector(|| "table-selection-root".into())
854                .w(px(520.))
855                .child(crate::TextSelectionLayer)
856                .child(TextView::new(&self.text_view))
857        }
858    }
859
860    #[gpui::test]
861    fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
862        cx.update(crate::init);
863        let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
864            text_view: cx.new(|cx| {
865                TextViewState::markdown(
866                    "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
867                    cx,
868                )
869            }),
870        });
871        let cx: &mut VisualTestContext = cx;
872
873        cx.run_until_parked();
874        let bounds = cx
875            .debug_bounds("table-selection-root")
876            .expect("table bounds");
877        let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
878        let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
879        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
880        cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
881        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
882
883        assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
884        assert_eq!(
885            cx.update(|window, cx| window.simulate_next_frame(cx)),
886            0,
887            "finished table selection must not continuously request frames"
888        );
889    }
890
891    struct InlineImageTextViewTestRoot {
892        text_view: Entity<TextViewState>,
893    }
894
895    impl InlineImageTextViewTestRoot {
896        fn new(cx: &mut Context<Self>) -> Self {
897            let text_view = cx.new(|cx| {
898                TextViewState::markdown(
899                    "Build Status ![inline image](https://example.com/image.svg) after",
900                    cx,
901                )
902            });
903            Self { text_view }
904        }
905    }
906
907    impl Render for InlineImageTextViewTestRoot {
908        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
909            div()
910                .w(px(420.))
911                .child(TextView::new(&self.text_view).selectable(true))
912        }
913    }
914
915    #[gpui::test]
916    fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
917        cx.update(crate::init);
918        let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
919        let cx: &mut VisualTestContext = cx;
920
921        cx.run_until_parked();
922        cx.update(|window, cx| {
923            let _ = window.draw(cx);
924        });
925
926        let inline_bounds = content.read_with(cx, |content, cx| {
927            content.text_view.read(cx).selection_adapter.text_bounds()
928        });
929
930        assert_eq!(inline_bounds.len(), 2);
931        assert_eq!(
932            inline_bounds[0].top(),
933            inline_bounds[1].top(),
934            "text before and after an inline image should share a rendered line"
935        );
936        assert!(
937            inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
938            "inline image should reserve horizontal space in the text layout"
939        );
940        assert!(
941            inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
942            "unloaded inline image fallback should stay generic and compact"
943        );
944    }
945
946    #[gpui::test]
947    fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
948        cx.update(crate::init);
949        let (_, cx) = cx.add_window_view(|_, cx| {
950            TextViewTestRoot::new(
951                "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
952                cx,
953            )
954        });
955        let cx: &mut VisualTestContext = cx;
956
957        cx.run_until_parked();
958        cx.update(|window, cx| {
959            let _ = window.draw(cx);
960        });
961    }
962
963    #[gpui::test]
964    fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
965        struct ListItemBlockRoot;
966
967        impl Render for ListItemBlockRoot {
968            fn render(
969                &mut self,
970                _window: &mut Window,
971                _cx: &mut Context<Self>,
972            ) -> impl IntoElement {
973                div().w(px(840.)).h(px(400.)).child(
974                    crate::h_resizable("markdown-width-test")
975                        .child(crate::resizable_panel().child(div()))
976                        .child(crate::resizable_panel().child(
977                            TextView::markdown(
978                                "list-with-code",
979                                "1. List item\n   ```rust\n   nested code\n   ```\n\n```rust\ntop-level code\n```",
980                            )
981                            .code_block_actions(|code_block, _, _| {
982                                let selector = if code_block.code().contains("nested") {
983                                    "nested-code-action"
984                                } else {
985                                    "top-level-code-action"
986                                };
987                                div()
988                                    .debug_selector(move || selector.into())
989                                    .child("Copy")
990                            })
991                            .scrollable(true)
992                            .p_5()
993                            .flex_none(),
994                        )),
995                )
996            }
997        }
998
999        cx.update(crate::init);
1000        let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
1001        let cx: &mut VisualTestContext = cx;
1002
1003        cx.run_until_parked();
1004        cx.update(|window, cx| {
1005            let _ = window.draw(cx);
1006        });
1007
1008        let nested_action = cx.debug_bounds("nested-code-action").unwrap();
1009        let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
1010        assert!(
1011            top_level_action.right() - nested_action.right() < px(32.),
1012            "nested code block should fill the list item's available width"
1013        );
1014    }
1015
1016    /// Draw a Markdown table with a `table_actions` hook installed, and return
1017    /// the painted bounds of the actions element plus the data it received.
1018    /// `scroll` opts into the horizontally scrollable table layout.
1019    fn draw_table_with_actions(
1020        cx: &mut TestAppContext,
1021        scroll: bool,
1022    ) -> (Bounds<Pixels>, TableData) {
1023        use std::sync::{Arc, Mutex};
1024
1025        struct TableRoot {
1026            scroll: bool,
1027            captured: Arc<Mutex<Vec<TableData>>>,
1028        }
1029
1030        impl Render for TableRoot {
1031            fn render(
1032                &mut self,
1033                _window: &mut Window,
1034                _cx: &mut Context<Self>,
1035            ) -> impl IntoElement {
1036                let captured = self.captured.clone();
1037                let mut table_style = StyleRefinement::default();
1038                if self.scroll {
1039                    table_style.overflow.x = Some(Overflow::Scroll);
1040                }
1041
1042                div().w(px(320.)).child(
1043                    TextView::markdown(
1044                        "table-actions",
1045                        "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
1046                    )
1047                    .style(TextViewStyle::default().with_table(table_style))
1048                    .table_actions(move |table, _, _| {
1049                        if let Ok(mut captured) = captured.lock() {
1050                            captured.push(table.clone());
1051                        }
1052                        div().debug_selector(|| "table-action".into()).child("Copy")
1053                    }),
1054                )
1055            }
1056        }
1057
1058        cx.update(crate::init);
1059        let captured = Arc::new(Mutex::new(Vec::new()));
1060        let (_, cx) = cx.add_window_view({
1061            let captured = captured.clone();
1062            move |_, _| TableRoot { scroll, captured }
1063        });
1064        let cx: &mut VisualTestContext = cx;
1065
1066        cx.run_until_parked();
1067        cx.update(|window, cx| {
1068            let _ = window.draw(cx);
1069        });
1070
1071        let bounds = cx
1072            .debug_bounds("table-action")
1073            .expect("table actions should be painted");
1074        let data = captured
1075            .lock()
1076            .expect("captured table data")
1077            .last()
1078            .cloned()
1079            .expect("table actions hook should receive the table");
1080
1081        (bounds, data)
1082    }
1083
1084    #[gpui::test]
1085    fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
1086        for scroll in [false, true] {
1087            let (bounds, data) = draw_table_with_actions(cx, scroll);
1088
1089            // Header plus two data rows are painted above the actions row.
1090            assert!(
1091                bounds.top() > px(40.),
1092                "actions should sit below the table (scroll: {scroll}), got {:?}",
1093                bounds.top()
1094            );
1095            assert_eq!(data.headers, vec!["Name", "Age"]);
1096            assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
1097            assert_eq!(
1098                data.markdown,
1099                "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
1100            );
1101            assert_eq!(data.span, Some(0..52));
1102        }
1103    }
1104
1105    #[test]
1106    fn plugin_accepts_text_view_plugins_beyond_markdown() {
1107        let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
1108
1109        assert!(view.selectable);
1110    }
1111
1112    #[test]
1113    fn syntax_highlighting_is_opt_in() {
1114        let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
1115        assert!(default_view.code_block_highlighter.is_none());
1116
1117        let view = default_view.code_block_highlighter(|block| {
1118            vec![(
1119                0..block.code().len(),
1120                gpui::HighlightStyle {
1121                    color: Some(gpui::rgb(0x3366ff).into()),
1122                    ..Default::default()
1123                },
1124            )]
1125        });
1126        assert!(view.code_block_highlighter.is_some());
1127    }
1128
1129    #[gpui::test]
1130    fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
1131        cx.update(crate::init);
1132        let (_, cx) = cx.add_window_view(|_, cx| {
1133            TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
1134        });
1135        let cx: &mut VisualTestContext = cx;
1136
1137        cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
1138
1139        assert_eq!(cx.opened_url(), None);
1140    }
1141
1142    struct MaxLinesTestRoot {
1143        text_view: Entity<TextViewState>,
1144        max_lines: usize,
1145    }
1146
1147    impl MaxLinesTestRoot {
1148        fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
1149            let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
1150            Self {
1151                text_view,
1152                max_lines,
1153            }
1154        }
1155    }
1156
1157    impl Render for MaxLinesTestRoot {
1158        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1159            div()
1160                .w(px(200.))
1161                .child(TextView::new(&self.text_view).max_lines(self.max_lines))
1162        }
1163    }
1164
1165    #[test]
1166    fn the_clip_only_moves_for_a_straddling_glyph_line() {
1167        use super::line_safe_clip_bottom;
1168        use crate::text::state::LineSpan;
1169
1170        let spans = [
1171            // Lines end at 20 / 40 / 60.
1172            LineSpan {
1173                top: px(0.),
1174                bottom: px(60.),
1175                line_height: px(20.),
1176            },
1177            // A second block after an 8px gap; lines end at 88 / 108 / 128.
1178            LineSpan {
1179                top: px(68.),
1180                bottom: px(128.),
1181                line_height: px(20.),
1182            },
1183        ];
1184
1185        // Content continues well past the box in every case but the last.
1186        let below = px(400.);
1187
1188        // A box ending inside the line 88..108 leaves that line out whole.
1189        assert_eq!(
1190            line_safe_clip_bottom(&spans, px(100.), below),
1191            Some(px(88.))
1192        );
1193
1194        // A box ending on a line boundary has nothing to pull the clip up for.
1195        assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
1196
1197        // A strip below the last line shorter than a line — the border and
1198        // padding a block leads with — is not worth showing.
1199        assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
1200
1201        // One taller than a line is: whatever crosses the edge keeps the part
1202        // that fits rather than leaving the box half empty.
1203        let one_block = [LineSpan {
1204            top: px(0.),
1205            bottom: px(60.),
1206            line_height: px(20.),
1207        }];
1208        assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
1209
1210        // Nothing crosses the edge at all: the space under the last line is
1211        // the box's own, not a scrap of something below.
1212        assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
1213    }
1214
1215    #[test]
1216    fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
1217        use super::line_safe_clip_bottom;
1218        use crate::text::state::LineSpan;
1219
1220        // A heading line of 28px, in a box capped at one 26px body line.
1221        let heading = [LineSpan {
1222            top: px(70.),
1223            bottom: px(98.),
1224            line_height: px(28.),
1225        }];
1226
1227        assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
1228    }
1229
1230    #[test]
1231    fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
1232        use super::line_safe_clip_bottom;
1233        use crate::text::state::LineSpan;
1234
1235        // Two table rows, each one line of text, 9px of border and padding
1236        // between them.
1237        let rows = [
1238            LineSpan {
1239                top: px(100.),
1240                bottom: px(126.),
1241                line_height: px(26.),
1242            },
1243            LineSpan {
1244                top: px(135.),
1245                bottom: px(161.),
1246                line_height: px(26.),
1247            },
1248        ];
1249
1250        // Leaving out the second row's text would strand the 9px it leads
1251        // with, so the clip goes back to the row above it.
1252        assert_eq!(
1253            line_safe_clip_bottom(&rows, px(148.), px(400.)),
1254            Some(px(126.))
1255        );
1256    }
1257
1258    /// A clamped view nested the way an application nests one: inside a card,
1259    /// inside a region that fills a window of a known height. The height an
1260    /// ancestor hands down must not reach the clamped content and hide the
1261    /// overflow the clamp measures — with the content stretched to the capped
1262    /// box, nothing looks clipped and lines get cut in half.
1263    struct ClampedPageRoot {
1264        text_view: Entity<TextViewState>,
1265        max_lines: usize,
1266    }
1267
1268    impl Render for ClampedPageRoot {
1269        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1270            use crate::{h_flex, v_flex};
1271
1272            v_flex()
1273                .size_full()
1274                .p_4()
1275                .gap_4()
1276                .child(h_flex().max_w(px(480.)).gap_3().child("header"))
1277                .child(
1278                    v_flex()
1279                        .flex_1()
1280                        .min_h_0()
1281                        .gap_4()
1282                        .id("clamped-page-scroll")
1283                        .child(
1284                            v_flex()
1285                                .max_w(px(480.))
1286                                .p_3()
1287                                .gap_2()
1288                                .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
1289                        )
1290                        .overflow_y_scroll(),
1291                )
1292        }
1293    }
1294
1295    #[gpui::test]
1296    fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
1297        cx.update(crate::init);
1298        let (root, cx) = cx.add_window_view(|_, cx| {
1299            let text_view = cx.new(|cx| {
1300                TextViewState::markdown(
1301                    "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
1302                    cx,
1303                )
1304            });
1305            ClampedPageRoot {
1306                text_view,
1307                max_lines: 3,
1308            }
1309        });
1310        let cx: &mut VisualTestContext = cx;
1311
1312        assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1313    }
1314
1315    #[gpui::test]
1316    fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
1317        cx.update(crate::init);
1318        let (root, cx) = cx.add_window_view(|_, cx| {
1319            MaxLinesTestRoot::new(
1320                "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
1321                2,
1322                cx,
1323            )
1324        });
1325        let cx: &mut VisualTestContext = cx;
1326
1327        assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1328    }
1329
1330    #[gpui::test]
1331    fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
1332        cx.update(crate::init);
1333        let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
1334        let cx: &mut VisualTestContext = cx;
1335
1336        assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1337    }
1338
1339    #[gpui::test]
1340    fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
1341        cx.update(crate::init);
1342        let (_, cx) = cx.add_window_view(|_, cx| {
1343            MaxLinesTestRoot::new(
1344                "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
1345                2,
1346                cx,
1347            )
1348        });
1349        let cx: &mut VisualTestContext = cx;
1350
1351        // Click far below the clamped box, where the link would sit unclamped.
1352        cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
1353
1354        assert_eq!(cx.opened_url(), None);
1355    }
1356
1357    #[gpui::test]
1358    fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
1359        cx.update(crate::init);
1360        let (_, cx) =
1361            cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
1362        let cx: &mut VisualTestContext = cx;
1363
1364        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1365
1366        assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
1367    }
1368
1369    #[gpui::test]
1370    fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
1371        cx.update(crate::init);
1372        let (_, cx) =
1373            cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
1374        let cx: &mut VisualTestContext = cx;
1375
1376        cx.simulate_mouse_down(
1377            point(px(10.), px(10.)),
1378            MouseButton::Right,
1379            Modifiers::default(),
1380        );
1381        cx.simulate_mouse_up(
1382            point(px(10.), px(10.)),
1383            MouseButton::Right,
1384            Modifiers::default(),
1385        );
1386
1387        assert_eq!(cx.opened_url(), None);
1388    }
1389
1390    #[gpui::test]
1391    fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
1392        use std::sync::{Arc, Mutex};
1393
1394        struct LinkRoot {
1395            text_view: Entity<TextViewState>,
1396            clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
1397        }
1398
1399        impl Render for LinkRoot {
1400            fn render(
1401                &mut self,
1402                _window: &mut Window,
1403                _cx: &mut Context<Self>,
1404            ) -> impl IntoElement {
1405                let clicks = self.clicks.clone();
1406                div()
1407                    .w(px(240.))
1408                    .child(
1409                        TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
1410                            clicks.lock().unwrap().push((url.clone(), event.clone()));
1411                        }),
1412                    )
1413            }
1414        }
1415
1416        cx.update(crate::init);
1417        let clicks = Arc::new(Mutex::new(Vec::new()));
1418        let captured = clicks.clone();
1419        let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
1420            text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
1421            clicks,
1422        });
1423        let cx: &mut VisualTestContext = cx;
1424
1425        let mut modifiers = Modifiers::default();
1426        modifiers.control = true;
1427        cx.simulate_click(point(px(10.), px(10.)), modifiers);
1428        cx.simulate_mouse_down(
1429            point(px(10.), px(10.)),
1430            MouseButton::Middle,
1431            Modifiers::default(),
1432        );
1433        cx.simulate_mouse_up(
1434            point(px(10.), px(10.)),
1435            MouseButton::Middle,
1436            Modifiers::default(),
1437        );
1438        cx.simulate_mouse_down(
1439            point(px(10.), px(10.)),
1440            MouseButton::Right,
1441            Modifiers::default(),
1442        );
1443        cx.simulate_mouse_up(
1444            point(px(10.), px(10.)),
1445            MouseButton::Right,
1446            Modifiers::default(),
1447        );
1448
1449        let clicks = captured.lock().unwrap();
1450        assert_eq!(clicks.len(), 3);
1451        assert_eq!(clicks[0].0, "https://example.com");
1452        assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
1453        assert!(clicks[0].1.modifiers().control);
1454        assert!(clicks[1].1.is_middle_click());
1455        assert!(clicks[2].1.is_right_click());
1456        assert_eq!(cx.opened_url(), None);
1457    }
1458
1459    #[gpui::test]
1460    fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
1461        use std::sync::{Arc, Mutex};
1462
1463        struct LinkedImageRoot {
1464            text_view: Entity<TextViewState>,
1465            clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
1466        }
1467
1468        impl Render for LinkedImageRoot {
1469            fn render(
1470                &mut self,
1471                _window: &mut Window,
1472                _cx: &mut Context<Self>,
1473            ) -> impl IntoElement {
1474                let clicks = self.clicks.clone();
1475                div().w(px(160.)).child(
1476                    TextView::new(&self.text_view)
1477                        .selectable(true)
1478                        .on_link_click(move |url, event, _, _| {
1479                            clicks.lock().unwrap().push((url.clone(), event.clone()));
1480                        }),
1481                )
1482            }
1483        }
1484
1485        cx.update(crate::init);
1486        let clicks = Arc::new(Mutex::new(Vec::new()));
1487        let captured = clicks.clone();
1488        let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
1489                text_view: cx.new(|cx| {
1490                    TextViewState::markdown(
1491                        r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
1492                        cx,
1493                    )
1494                }),
1495                clicks,
1496            }
1497        );
1498        let cx: &mut VisualTestContext = cx;
1499        cx.run_until_parked();
1500        cx.update(|window, cx| {
1501            let _ = window.draw(cx);
1502        });
1503
1504        let inline_bounds = content.read_with(cx, |content, cx| {
1505            content.text_view.read(cx).selection_adapter.text_bounds()
1506        });
1507        assert!(
1508            inline_bounds.len() >= 2,
1509            "linked image needs text bounds on both sides: {inline_bounds:?}"
1510        );
1511        assert!(
1512            inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
1513            "linked image did not reserve the expected click target: {inline_bounds:?}"
1514        );
1515        let position = point(
1516            inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
1517            inline_bounds[0].top() + px(8.),
1518        );
1519        for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
1520            cx.simulate_mouse_down(position, button, Modifiers::default());
1521            cx.simulate_mouse_up(position, button, Modifiers::default());
1522        }
1523
1524        let clicks = captured.lock().unwrap();
1525        assert_eq!(clicks.len(), 3);
1526        assert!(
1527            clicks
1528                .iter()
1529                .all(|(url, _)| url == "https://example.com/image-link")
1530        );
1531        assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
1532        assert!(clicks[1].1.is_middle_click());
1533        assert!(clicks[2].1.is_right_click());
1534        assert_eq!(cx.opened_url(), None);
1535    }
1536
1537    #[gpui::test]
1538    fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
1539        cx.update(crate::init);
1540        let (view, cx) = cx
1541            .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
1542        let cx: &mut VisualTestContext = cx;
1543
1544        cx.simulate_mouse_down(
1545            point(px(10.), px(34.)),
1546            MouseButton::Left,
1547            Modifiers::default(),
1548        );
1549        cx.simulate_mouse_move(
1550            point(px(90.), px(34.)),
1551            Some(MouseButton::Left),
1552            Modifiers::default(),
1553        );
1554        cx.simulate_mouse_up(
1555            point(px(90.), px(34.)),
1556            MouseButton::Left,
1557            Modifiers::default(),
1558        );
1559
1560        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1561        assert!(
1562            selected_text.is_empty(),
1563            "unexpected selection: {selected_text:?}"
1564        );
1565    }
1566
1567    /// A tall selectable TextView clipped by a short `overflow_hidden` viewport,
1568    /// with a large blank footer below so a drag can extend the selection band
1569    /// past the bottom of the clip while still proxy-anchoring to the view.
1570    struct ClippedTallTextViewTestRoot {
1571        text_view: Entity<TextViewState>,
1572    }
1573
1574    impl ClippedTallTextViewTestRoot {
1575        fn new(cx: &mut Context<Self>) -> Self {
1576            // Four separate blocks; only the first (and maybe part of the
1577            // second) fit inside the 40px clip. "charlie"/"delta" render well
1578            // below it.
1579            let text_view =
1580                cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
1581            Self { text_view }
1582        }
1583    }
1584
1585    impl Render for ClippedTallTextViewTestRoot {
1586        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1587            div()
1588                .w(px(200.))
1589                .child(crate::TextSelectionLayer)
1590                .child(
1591                    div()
1592                        .h(px(40.))
1593                        .overflow_hidden()
1594                        .child(TextView::new(&self.text_view).selectable(true)),
1595                )
1596                // A tall blank footer so a drag can reach a y below the clipped
1597                // text; a press there proxy-anchors to the TextView above.
1598                .child(div().h(px(160.)))
1599        }
1600    }
1601
1602    /// Regression for copying a selection taller than the visible viewport.
1603    ///
1604    /// The selection band runs from visible text at the top down to a point
1605    /// far below the clip. Every glyph of the painted TextView is laid out even
1606    /// though the lower ones are clipped away, so the copied text must include
1607    /// the clipped-out "charlie"/"delta" — not just what is on screen. This
1608    /// guards against re-adding a `content_mask` gate in
1609    /// `Inline::layout_selections`.
1610    #[gpui::test]
1611    fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
1612        cx.update(crate::init);
1613        let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
1614        let cx: &mut VisualTestContext = cx;
1615
1616        cx.run_until_parked();
1617        cx.update(|window, cx| {
1618            let _ = window.draw(cx);
1619        });
1620
1621        // Anchor on visible text near the top, then drag to a point well below
1622        // the 40px clip (into the blank footer) and to the far right so the
1623        // last line is fully covered.
1624        cx.simulate_mouse_down(
1625            point(px(2.), px(8.)),
1626            MouseButton::Left,
1627            Modifiers::default(),
1628        );
1629        cx.update(|window, cx| {
1630            let _ = window.draw(cx);
1631        });
1632        cx.simulate_mouse_move(
1633            point(px(180.), px(150.)),
1634            Some(MouseButton::Left),
1635            Modifiers::default(),
1636        );
1637        cx.update(|window, cx| {
1638            let _ = window.draw(cx);
1639        });
1640        cx.simulate_mouse_up(
1641            point(px(180.), px(150.)),
1642            MouseButton::Left,
1643            Modifiers::default(),
1644        );
1645        cx.update(|window, cx| {
1646            let _ = window.draw(cx);
1647        });
1648
1649        let selected_text =
1650            content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1651        assert!(
1652            selected_text.contains("delta"),
1653            "clipped-out text was not copied: {selected_text:?}"
1654        );
1655        assert!(
1656            selected_text.contains("charlie"),
1657            "clipped-out text was not copied: {selected_text:?}"
1658        );
1659    }
1660
1661    #[gpui::test]
1662    fn double_click_selects_word(cx: &mut TestAppContext) {
1663        cx.update(crate::init);
1664        let (view, cx) =
1665            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
1666
1667        let cx: &mut VisualTestContext = cx;
1668        cx.run_until_parked();
1669        cx.update(|window, cx| {
1670            let _ = window.draw(cx);
1671        });
1672        let position = point(px(10.), px(16.));
1673        cx.simulate_event(MouseDownEvent {
1674            position,
1675            modifiers: Modifiers::default(),
1676            button: MouseButton::Left,
1677            click_count: 2,
1678            first_mouse: false,
1679        });
1680        cx.simulate_event(MouseUpEvent {
1681            position,
1682            modifiers: Modifiers::default(),
1683            button: MouseButton::Left,
1684            click_count: 2,
1685        });
1686        cx.update(|window, cx| {
1687            let _ = window.draw(cx);
1688        });
1689
1690        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1691        assert_eq!(selected_text.trim(), "quick");
1692    }
1693
1694    #[gpui::test]
1695    fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
1696        cx.update(crate::init);
1697        let (view, cx) =
1698            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
1699
1700        let cx: &mut VisualTestContext = cx;
1701        cx.run_until_parked();
1702        cx.update(|window, cx| {
1703            let _ = window.draw(cx);
1704        });
1705
1706        let position = point(px(10.), px(10.));
1707        cx.simulate_event(MouseDownEvent {
1708            position,
1709            modifiers: Modifiers::default(),
1710            button: MouseButton::Left,
1711            click_count: 3,
1712            first_mouse: false,
1713        });
1714        cx.simulate_event(MouseUpEvent {
1715            position,
1716            modifiers: Modifiers::default(),
1717            button: MouseButton::Left,
1718            click_count: 3,
1719        });
1720        cx.update(|window, cx| {
1721            let _ = window.draw(cx);
1722        });
1723
1724        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1725        assert_eq!(selected_text.trim(), "quick select value");
1726    }
1727
1728    // Regression: markdown `TextView` items inside an outer `gpui::list` with
1729    // `measure_all` must keep a stable total content height while scrolling.
1730    // Before synchronous full-replace parsing, off-screen markdown views were
1731    // first measured with empty content and the scrollbar thumb jittered as the
1732    // total height grew during scrolling.
1733    #[gpui::test]
1734    fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
1735        use gpui::{ListAlignment, ListState, list};
1736
1737        const ITEMS: &[&str] = &[
1738            "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
1739            "Short.",
1740            "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
1741            "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
1742            "Only one line.",
1743            "**Bold**: medium length text with `code` mixed with regular words.",
1744            "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
1745            "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
1746        ];
1747        let n = 40usize;
1748
1749        struct ListRoot {
1750            state: ListState,
1751        }
1752        impl Render for ListRoot {
1753            fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1754                div().w(px(360.)).h(px(500.)).child(
1755                    list(self.state.clone(), |ix, _w, _cx| {
1756                        div()
1757                            .w_full()
1758                            .child(TextView::markdown(
1759                                ("md", ix as u64),
1760                                ITEMS[ix % ITEMS.len()],
1761                            ))
1762                            .into_any_element()
1763                    })
1764                    .size_full(),
1765                )
1766            }
1767        }
1768
1769        cx.update(crate::init);
1770        let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
1771        let probe = state.clone();
1772        let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
1773        let cx: &mut VisualTestContext = cx;
1774
1775        cx.run_until_parked();
1776        cx.update(|w, cx| {
1777            let _ = w.draw(cx);
1778        });
1779        cx.run_until_parked();
1780        cx.update(|w, cx| {
1781            let _ = w.draw(cx);
1782        });
1783
1784        let total = |p: &ListState| {
1785            f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
1786        };
1787        let mut totals = vec![total(&probe)];
1788        for _ in 0..20 {
1789            probe.scroll_by(px(150.));
1790            cx.update(|w, cx| {
1791                let _ = w.draw(cx);
1792            });
1793            cx.run_until_parked();
1794            totals.push(total(&probe));
1795        }
1796        let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
1797        let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1798        println!(
1799            "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
1800            max - min
1801        );
1802        assert!(
1803            (max - min) < 2.0,
1804            "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
1805        );
1806    }
1807}