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, self_scroll) = {
714                let state = state.read(cx);
715                (
716                    state.selection_adapter.clone(),
717                    state.scroll_offset(),
718                    state.bounds(),
719                    state.scrollable,
720                )
721            };
722            let document_order = GlobalState::global_mut(cx).next_selection_document_order();
723            adapter.register(
724                prepaint.hitbox.clone(),
725                content_bounds,
726                scroll_offset,
727                document_order,
728                self_scroll,
729                window,
730                cx,
731            );
732        }
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use std::sync::{
739        Arc,
740        atomic::{AtomicUsize, Ordering},
741    };
742
743    use super::{TextView, TextViewPlugin};
744    use crate::text::{TableData, TextViewState, TextViewStyle};
745    use gpui::{
746        AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
747        Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
748        Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
749        TestAppContext, VisualTestContext, Window, div, point, px,
750    };
751
752    struct TextViewTestRoot {
753        text_view: Entity<TextViewState>,
754    }
755
756    /// A scrollable viewport, so the list has a bounded height to measure
757    /// against and `max_offset_for_scrollbar` reports a real scroll extent.
758    struct ScrollExtentTestRoot {
759        text_view: Entity<TextViewState>,
760    }
761
762    impl Render for ScrollExtentTestRoot {
763        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
764            div()
765                .w(px(400.))
766                .h(px(200.))
767                .overflow_hidden()
768                .child(TextView::new(&self.text_view).scrollable(true))
769        }
770    }
771
772    /// `count` paragraphs, each `words` words long, so two documents can share
773    /// a block count while differing wildly in height.
774    fn document_of(count: usize, words: usize) -> String {
775        (1..=count)
776            .map(|i| format!("Block {i}: {}", "lorem ipsum dolor ".repeat(words)))
777            .collect::<Vec<_>>()
778            .join("\n\n")
779    }
780
781    /// Replacing a document with one that happens to have the *same* block
782    /// count must still re-measure. `Document::render_root` only resets the
783    /// list when the count changes, so without an explicit re-measure every
784    /// cached height stays with the previous document and the scroll extent
785    /// keeps describing it.
786    #[gpui::test]
787    fn replacing_a_document_with_an_equal_block_count_remeasures(cx: &mut TestAppContext) {
788        cx.update(crate::init);
789
790        const BLOCKS: usize = 24;
791        let short = document_of(BLOCKS, 1);
792        let tall = document_of(BLOCKS, 60);
793
794        let (root, cx) = cx.add_window_view(|_, cx| ScrollExtentTestRoot {
795            text_view: cx.new(|cx| TextViewState::markdown(&short, cx)),
796        });
797        let cx: &mut VisualTestContext = cx;
798
799        // The list is populated and measured during layout, so every
800        // assertion below has to follow a real frame.
801        let settle = |cx: &mut VisualTestContext| {
802            cx.run_until_parked();
803            cx.update(|window, cx| window.draw(cx).clear(cx));
804            cx.run_until_parked();
805        };
806        settle(cx);
807
808        let scroll_extent = |cx: &mut VisualTestContext| {
809            root.read_with(cx, |root, cx| {
810                root.text_view
811                    .read(cx)
812                    .list_state()
813                    .max_offset_for_scrollbar()
814                    .y
815            })
816        };
817
818        let short_extent = scroll_extent(cx);
819
820        root.update(cx, |root, cx| {
821            root.text_view
822                .update(cx, |state, cx| state.set_text(&tall, cx));
823        });
824        settle(cx);
825
826        root.read_with(cx, |root, cx| {
827            assert_eq!(
828                root.text_view.read(cx).list_state().item_count(),
829                BLOCKS,
830                "the replacement must keep the block count, or the list resets and the bug cannot occur"
831            );
832        });
833
834        let tall_extent = scroll_extent(cx);
835        assert!(
836            tall_extent > short_extent * 5.,
837            "a much taller document must grow the scroll extent, but it went from \
838             {short_extent:?} to {tall_extent:?}"
839        );
840    }
841
842    struct StatelessMarkdownRoot {
843        renders: Arc<AtomicUsize>,
844    }
845
846    impl Render for StatelessMarkdownRoot {
847        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
848            self.renders.fetch_add(1, Ordering::Relaxed);
849            div().child(
850                TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
851                    .markdown_block_parser(|_, _| None),
852            )
853        }
854    }
855
856    struct DummyTextViewPlugin;
857
858    impl TextViewPlugin for DummyTextViewPlugin {
859        fn setup(self, mut text_view: TextView) -> TextView {
860            text_view.selectable = true;
861            text_view
862        }
863    }
864
865    #[gpui::test]
866    fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
867        cx.update(crate::init);
868        let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
869
870        assert!(TextView::new(&state).selectable);
871        assert!(TextView::markdown("markdown", "text").selectable);
872        assert!(TextView::html("html", "<p>text</p>").selectable);
873    }
874
875    #[gpui::test]
876    fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
877        cx.update(crate::init);
878        let renders = Arc::new(AtomicUsize::new(0));
879        let (_, cx) = cx.add_window_view({
880            let renders = renders.clone();
881            move |_, _| StatelessMarkdownRoot { renders }
882        });
883        let cx: &mut VisualTestContext = cx;
884
885        cx.run_until_parked();
886        assert!(
887            renders.load(Ordering::Relaxed) <= 2,
888            "an unchanged TextView must settle after its parse, but rendered {} times",
889            renders.load(Ordering::Relaxed),
890        );
891    }
892
893    #[gpui::test]
894    fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
895        cx.update(crate::init);
896        cx.update(|cx| {
897            let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
898            colors.primary = gpui::rgb(0x55aaff).into();
899            colors.selection = gpui::rgb(0x335577).into();
900        });
901        let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
902        let cx: &mut VisualTestContext = cx;
903
904        cx.run_until_parked();
905        root.read_with(cx, |root, cx| {
906            let style = &root.text_view.read(cx).text_view_style;
907            assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
908            assert_eq!(style.selection(), gpui::rgb(0x335577).into());
909        });
910    }
911
912    impl TextViewTestRoot {
913        fn new(text: &str, cx: &mut Context<Self>) -> Self {
914            let text = text.to_string();
915            let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
916            Self { text_view }
917        }
918    }
919
920    impl Render for TextViewTestRoot {
921        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
922            div()
923                .w(px(160.))
924                .child(
925                    div()
926                        .h(px(24.))
927                        .overflow_hidden()
928                        .child(TextView::new(&self.text_view).selectable(true)),
929                )
930                .child(div().h(px(40.)).child("footer"))
931        }
932    }
933
934    struct TableSelectionTestRoot {
935        text_view: Entity<TextViewState>,
936    }
937
938    impl Render for TableSelectionTestRoot {
939        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
940            div()
941                .debug_selector(|| "table-selection-root".into())
942                .w(px(520.))
943                .child(crate::TextSelectionLayer)
944                .child(TextView::new(&self.text_view))
945        }
946    }
947
948    #[gpui::test]
949    fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
950        cx.update(crate::init);
951        let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
952            text_view: cx.new(|cx| {
953                TextViewState::markdown(
954                    "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
955                    cx,
956                )
957            }),
958        });
959        let cx: &mut VisualTestContext = cx;
960
961        cx.run_until_parked();
962        let bounds = cx
963            .debug_bounds("table-selection-root")
964            .expect("table bounds");
965        let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
966        let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
967        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
968        cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
969        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
970
971        assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
972        assert_eq!(
973            cx.update(|window, cx| window.simulate_next_frame(cx)),
974            0,
975            "finished table selection must not continuously request frames"
976        );
977    }
978
979    struct InlineImageTextViewTestRoot {
980        text_view: Entity<TextViewState>,
981    }
982
983    impl InlineImageTextViewTestRoot {
984        fn new(cx: &mut Context<Self>) -> Self {
985            let text_view = cx.new(|cx| {
986                TextViewState::markdown(
987                    "Build Status ![inline image](https://example.com/image.svg) after",
988                    cx,
989                )
990            });
991            Self { text_view }
992        }
993    }
994
995    impl Render for InlineImageTextViewTestRoot {
996        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
997            div()
998                .w(px(420.))
999                .child(TextView::new(&self.text_view).selectable(true))
1000        }
1001    }
1002
1003    #[gpui::test]
1004    fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
1005        cx.update(crate::init);
1006        let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
1007        let cx: &mut VisualTestContext = cx;
1008
1009        cx.run_until_parked();
1010        cx.update(|window, cx| {
1011            let _ = window.draw(cx);
1012        });
1013
1014        let inline_bounds = content.read_with(cx, |content, cx| {
1015            content.text_view.read(cx).selection_adapter.text_bounds()
1016        });
1017
1018        assert_eq!(inline_bounds.len(), 2);
1019        assert_eq!(
1020            inline_bounds[0].top(),
1021            inline_bounds[1].top(),
1022            "text before and after an inline image should share a rendered line"
1023        );
1024        assert!(
1025            inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
1026            "inline image should reserve horizontal space in the text layout"
1027        );
1028        assert!(
1029            inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
1030            "unloaded inline image fallback should stay generic and compact"
1031        );
1032    }
1033
1034    #[gpui::test]
1035    fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
1036        cx.update(crate::init);
1037        let (_, cx) = cx.add_window_view(|_, cx| {
1038            TextViewTestRoot::new(
1039                "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
1040                cx,
1041            )
1042        });
1043        let cx: &mut VisualTestContext = cx;
1044
1045        cx.run_until_parked();
1046        cx.update(|window, cx| {
1047            let _ = window.draw(cx);
1048        });
1049    }
1050
1051    #[gpui::test]
1052    fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
1053        struct ListItemBlockRoot;
1054
1055        impl Render for ListItemBlockRoot {
1056            fn render(
1057                &mut self,
1058                _window: &mut Window,
1059                _cx: &mut Context<Self>,
1060            ) -> impl IntoElement {
1061                div().w(px(840.)).h(px(400.)).child(
1062                    crate::h_resizable("markdown-width-test")
1063                        .child(crate::resizable_panel().child(div()))
1064                        .child(crate::resizable_panel().child(
1065                            TextView::markdown(
1066                                "list-with-code",
1067                                "1. List item\n   ```rust\n   nested code\n   ```\n\n```rust\ntop-level code\n```",
1068                            )
1069                            .code_block_actions(|code_block, _, _| {
1070                                let selector = if code_block.code().contains("nested") {
1071                                    "nested-code-action"
1072                                } else {
1073                                    "top-level-code-action"
1074                                };
1075                                div()
1076                                    .debug_selector(move || selector.into())
1077                                    .child("Copy")
1078                            })
1079                            .scrollable(true)
1080                            .p_5()
1081                            .flex_none(),
1082                        )),
1083                )
1084            }
1085        }
1086
1087        cx.update(crate::init);
1088        let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
1089        let cx: &mut VisualTestContext = cx;
1090
1091        cx.run_until_parked();
1092        cx.update(|window, cx| {
1093            let _ = window.draw(cx);
1094        });
1095
1096        let nested_action = cx.debug_bounds("nested-code-action").unwrap();
1097        let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
1098        assert!(
1099            top_level_action.right() - nested_action.right() < px(32.),
1100            "nested code block should fill the list item's available width"
1101        );
1102    }
1103
1104    /// Draw a Markdown table with a `table_actions` hook installed, and return
1105    /// the painted bounds of the actions element plus the data it received.
1106    /// `scroll` opts into the horizontally scrollable table layout.
1107    fn draw_table_with_actions(
1108        cx: &mut TestAppContext,
1109        scroll: bool,
1110    ) -> (Bounds<Pixels>, TableData) {
1111        use std::sync::{Arc, Mutex};
1112
1113        struct TableRoot {
1114            scroll: bool,
1115            captured: Arc<Mutex<Vec<TableData>>>,
1116        }
1117
1118        impl Render for TableRoot {
1119            fn render(
1120                &mut self,
1121                _window: &mut Window,
1122                _cx: &mut Context<Self>,
1123            ) -> impl IntoElement {
1124                let captured = self.captured.clone();
1125                let mut table_style = StyleRefinement::default();
1126                if self.scroll {
1127                    table_style.overflow.x = Some(Overflow::Scroll);
1128                }
1129
1130                div().w(px(320.)).child(
1131                    TextView::markdown(
1132                        "table-actions",
1133                        "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
1134                    )
1135                    .style(TextViewStyle::default().with_table(table_style))
1136                    .table_actions(move |table, _, _| {
1137                        if let Ok(mut captured) = captured.lock() {
1138                            captured.push(table.clone());
1139                        }
1140                        div().debug_selector(|| "table-action".into()).child("Copy")
1141                    }),
1142                )
1143            }
1144        }
1145
1146        cx.update(crate::init);
1147        let captured = Arc::new(Mutex::new(Vec::new()));
1148        let (_, cx) = cx.add_window_view({
1149            let captured = captured.clone();
1150            move |_, _| TableRoot { scroll, captured }
1151        });
1152        let cx: &mut VisualTestContext = cx;
1153
1154        cx.run_until_parked();
1155        cx.update(|window, cx| {
1156            let _ = window.draw(cx);
1157        });
1158
1159        let bounds = cx
1160            .debug_bounds("table-action")
1161            .expect("table actions should be painted");
1162        let data = captured
1163            .lock()
1164            .expect("captured table data")
1165            .last()
1166            .cloned()
1167            .expect("table actions hook should receive the table");
1168
1169        (bounds, data)
1170    }
1171
1172    #[gpui::test]
1173    fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
1174        for scroll in [false, true] {
1175            let (bounds, data) = draw_table_with_actions(cx, scroll);
1176
1177            // Header plus two data rows are painted above the actions row.
1178            assert!(
1179                bounds.top() > px(40.),
1180                "actions should sit below the table (scroll: {scroll}), got {:?}",
1181                bounds.top()
1182            );
1183            assert_eq!(data.headers, vec!["Name", "Age"]);
1184            assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
1185            assert_eq!(
1186                data.markdown,
1187                "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
1188            );
1189            assert_eq!(data.span, Some(0..52));
1190        }
1191    }
1192
1193    #[test]
1194    fn plugin_accepts_text_view_plugins_beyond_markdown() {
1195        let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
1196
1197        assert!(view.selectable);
1198    }
1199
1200    #[test]
1201    fn syntax_highlighting_is_opt_in() {
1202        let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
1203        assert!(default_view.code_block_highlighter.is_none());
1204
1205        let view = default_view.code_block_highlighter(|block| {
1206            vec![(
1207                0..block.code().len(),
1208                gpui::HighlightStyle {
1209                    color: Some(gpui::rgb(0x3366ff).into()),
1210                    ..Default::default()
1211                },
1212            )]
1213        });
1214        assert!(view.code_block_highlighter.is_some());
1215    }
1216
1217    #[gpui::test]
1218    fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
1219        cx.update(crate::init);
1220        let (_, cx) = cx.add_window_view(|_, cx| {
1221            TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
1222        });
1223        let cx: &mut VisualTestContext = cx;
1224
1225        cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
1226
1227        assert_eq!(cx.opened_url(), None);
1228    }
1229
1230    struct MaxLinesTestRoot {
1231        text_view: Entity<TextViewState>,
1232        max_lines: usize,
1233    }
1234
1235    impl MaxLinesTestRoot {
1236        fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
1237            let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
1238            Self {
1239                text_view,
1240                max_lines,
1241            }
1242        }
1243    }
1244
1245    impl Render for MaxLinesTestRoot {
1246        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1247            div()
1248                .w(px(200.))
1249                .child(TextView::new(&self.text_view).max_lines(self.max_lines))
1250        }
1251    }
1252
1253    #[test]
1254    fn the_clip_only_moves_for_a_straddling_glyph_line() {
1255        use super::line_safe_clip_bottom;
1256        use crate::text::state::LineSpan;
1257
1258        let spans = [
1259            // Lines end at 20 / 40 / 60.
1260            LineSpan {
1261                top: px(0.),
1262                bottom: px(60.),
1263                line_height: px(20.),
1264            },
1265            // A second block after an 8px gap; lines end at 88 / 108 / 128.
1266            LineSpan {
1267                top: px(68.),
1268                bottom: px(128.),
1269                line_height: px(20.),
1270            },
1271        ];
1272
1273        // Content continues well past the box in every case but the last.
1274        let below = px(400.);
1275
1276        // A box ending inside the line 88..108 leaves that line out whole.
1277        assert_eq!(
1278            line_safe_clip_bottom(&spans, px(100.), below),
1279            Some(px(88.))
1280        );
1281
1282        // A box ending on a line boundary has nothing to pull the clip up for.
1283        assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
1284
1285        // A strip below the last line shorter than a line — the border and
1286        // padding a block leads with — is not worth showing.
1287        assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
1288
1289        // One taller than a line is: whatever crosses the edge keeps the part
1290        // that fits rather than leaving the box half empty.
1291        let one_block = [LineSpan {
1292            top: px(0.),
1293            bottom: px(60.),
1294            line_height: px(20.),
1295        }];
1296        assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
1297
1298        // Nothing crosses the edge at all: the space under the last line is
1299        // the box's own, not a scrap of something below.
1300        assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
1301    }
1302
1303    #[test]
1304    fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
1305        use super::line_safe_clip_bottom;
1306        use crate::text::state::LineSpan;
1307
1308        // A heading line of 28px, in a box capped at one 26px body line.
1309        let heading = [LineSpan {
1310            top: px(70.),
1311            bottom: px(98.),
1312            line_height: px(28.),
1313        }];
1314
1315        assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
1316    }
1317
1318    #[test]
1319    fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
1320        use super::line_safe_clip_bottom;
1321        use crate::text::state::LineSpan;
1322
1323        // Two table rows, each one line of text, 9px of border and padding
1324        // between them.
1325        let rows = [
1326            LineSpan {
1327                top: px(100.),
1328                bottom: px(126.),
1329                line_height: px(26.),
1330            },
1331            LineSpan {
1332                top: px(135.),
1333                bottom: px(161.),
1334                line_height: px(26.),
1335            },
1336        ];
1337
1338        // Leaving out the second row's text would strand the 9px it leads
1339        // with, so the clip goes back to the row above it.
1340        assert_eq!(
1341            line_safe_clip_bottom(&rows, px(148.), px(400.)),
1342            Some(px(126.))
1343        );
1344    }
1345
1346    /// A clamped view nested the way an application nests one: inside a card,
1347    /// inside a region that fills a window of a known height. The height an
1348    /// ancestor hands down must not reach the clamped content and hide the
1349    /// overflow the clamp measures — with the content stretched to the capped
1350    /// box, nothing looks clipped and lines get cut in half.
1351    struct ClampedPageRoot {
1352        text_view: Entity<TextViewState>,
1353        max_lines: usize,
1354    }
1355
1356    impl Render for ClampedPageRoot {
1357        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1358            use crate::{h_flex, v_flex};
1359
1360            v_flex()
1361                .size_full()
1362                .p_4()
1363                .gap_4()
1364                .child(h_flex().max_w(px(480.)).gap_3().child("header"))
1365                .child(
1366                    v_flex()
1367                        .flex_1()
1368                        .min_h_0()
1369                        .gap_4()
1370                        .id("clamped-page-scroll")
1371                        .child(
1372                            v_flex()
1373                                .max_w(px(480.))
1374                                .p_3()
1375                                .gap_2()
1376                                .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
1377                        )
1378                        .overflow_y_scroll(),
1379                )
1380        }
1381    }
1382
1383    #[gpui::test]
1384    fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
1385        cx.update(crate::init);
1386        let (root, cx) = cx.add_window_view(|_, cx| {
1387            let text_view = cx.new(|cx| {
1388                TextViewState::markdown(
1389                    "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
1390                    cx,
1391                )
1392            });
1393            ClampedPageRoot {
1394                text_view,
1395                max_lines: 3,
1396            }
1397        });
1398        let cx: &mut VisualTestContext = cx;
1399
1400        assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1401    }
1402
1403    #[gpui::test]
1404    fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
1405        cx.update(crate::init);
1406        let (root, cx) = cx.add_window_view(|_, cx| {
1407            MaxLinesTestRoot::new(
1408                "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
1409                2,
1410                cx,
1411            )
1412        });
1413        let cx: &mut VisualTestContext = cx;
1414
1415        assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1416    }
1417
1418    #[gpui::test]
1419    fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
1420        cx.update(crate::init);
1421        let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
1422        let cx: &mut VisualTestContext = cx;
1423
1424        assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1425    }
1426
1427    #[gpui::test]
1428    fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
1429        cx.update(crate::init);
1430        let (_, cx) = cx.add_window_view(|_, cx| {
1431            MaxLinesTestRoot::new(
1432                "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
1433                2,
1434                cx,
1435            )
1436        });
1437        let cx: &mut VisualTestContext = cx;
1438
1439        // Click far below the clamped box, where the link would sit unclamped.
1440        cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
1441
1442        assert_eq!(cx.opened_url(), None);
1443    }
1444
1445    #[gpui::test]
1446    fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) {
1447        struct SelectionRoot {
1448            text_view: Entity<TextViewState>,
1449            format: crate::text::SelectionFormat,
1450        }
1451        impl Render for SelectionRoot {
1452            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1453                div()
1454                    .w(px(160.))
1455                    .child(crate::TextSelectionLayer)
1456                    .child(TextView::new(&self.text_view).selection_format(self.format))
1457            }
1458        }
1459        cx.update(crate::init);
1460        let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot {
1461            format: crate::text::SelectionFormat::Plain,
1462            text_view: cx
1463                .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)),
1464        });
1465        let cx: &mut VisualTestContext = cx;
1466        cx.run_until_parked();
1467        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1468        assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
1469        cx.simulate_mouse_down(
1470            point(px(3.), px(8.)),
1471            MouseButton::Left,
1472            Modifiers::default(),
1473        );
1474        cx.update(|window, cx| {
1475            let _ = window.draw(cx);
1476        });
1477        cx.simulate_mouse_move(
1478            point(px(155.), px(20.)),
1479            Some(MouseButton::Left),
1480            Modifiers::default(),
1481        );
1482        cx.update(|window, cx| {
1483            let _ = window.draw(cx);
1484        });
1485        cx.simulate_mouse_up(
1486            point(px(155.), px(20.)),
1487            MouseButton::Left,
1488            Modifiers::default(),
1489        );
1490        cx.update(|window, cx| {
1491            let _ = window.draw(cx);
1492        });
1493        let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
1494        assert_eq!(selected.trim(), "code after");
1495        view.update(cx, |view, cx| {
1496            view.format = crate::text::SelectionFormat::Source;
1497            cx.notify();
1498        });
1499        cx.update(|window, cx| {
1500            let _ = window.draw(cx);
1501        });
1502        let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
1503        assert_eq!(selected.trim(), "[`code`](https://example.com) after");
1504    }
1505
1506    #[gpui::test]
1507    fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
1508        cx.update(crate::init);
1509        let (_, cx) =
1510            cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
1511        let cx: &mut VisualTestContext = cx;
1512
1513        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1514
1515        assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
1516    }
1517
1518    #[gpui::test]
1519    fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
1520        cx.update(crate::init);
1521        let (_, cx) =
1522            cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
1523        let cx: &mut VisualTestContext = cx;
1524
1525        cx.simulate_mouse_down(
1526            point(px(10.), px(10.)),
1527            MouseButton::Right,
1528            Modifiers::default(),
1529        );
1530        cx.simulate_mouse_up(
1531            point(px(10.), px(10.)),
1532            MouseButton::Right,
1533            Modifiers::default(),
1534        );
1535
1536        assert_eq!(cx.opened_url(), None);
1537    }
1538
1539    #[gpui::test]
1540    fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
1541        use std::sync::{Arc, Mutex};
1542
1543        struct LinkRoot {
1544            text_view: Entity<TextViewState>,
1545            clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
1546        }
1547
1548        impl Render for LinkRoot {
1549            fn render(
1550                &mut self,
1551                _window: &mut Window,
1552                _cx: &mut Context<Self>,
1553            ) -> impl IntoElement {
1554                let clicks = self.clicks.clone();
1555                div()
1556                    .w(px(240.))
1557                    .child(
1558                        TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
1559                            clicks.lock().unwrap().push((url.clone(), event.clone()));
1560                        }),
1561                    )
1562            }
1563        }
1564
1565        cx.update(crate::init);
1566        let clicks = Arc::new(Mutex::new(Vec::new()));
1567        let captured = clicks.clone();
1568        let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
1569            text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
1570            clicks,
1571        });
1572        let cx: &mut VisualTestContext = cx;
1573
1574        let mut modifiers = Modifiers::default();
1575        modifiers.control = true;
1576        cx.simulate_click(point(px(10.), px(10.)), modifiers);
1577        cx.simulate_mouse_down(
1578            point(px(10.), px(10.)),
1579            MouseButton::Middle,
1580            Modifiers::default(),
1581        );
1582        cx.simulate_mouse_up(
1583            point(px(10.), px(10.)),
1584            MouseButton::Middle,
1585            Modifiers::default(),
1586        );
1587        cx.simulate_mouse_down(
1588            point(px(10.), px(10.)),
1589            MouseButton::Right,
1590            Modifiers::default(),
1591        );
1592        cx.simulate_mouse_up(
1593            point(px(10.), px(10.)),
1594            MouseButton::Right,
1595            Modifiers::default(),
1596        );
1597
1598        let clicks = captured.lock().unwrap();
1599        assert_eq!(clicks.len(), 3);
1600        assert_eq!(clicks[0].0, "https://example.com");
1601        assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
1602        assert!(clicks[0].1.modifiers().control);
1603        assert!(clicks[1].1.is_middle_click());
1604        assert!(clicks[2].1.is_right_click());
1605        assert_eq!(cx.opened_url(), None);
1606    }
1607
1608    #[gpui::test]
1609    fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
1610        use std::sync::{Arc, Mutex};
1611
1612        struct LinkedImageRoot {
1613            text_view: Entity<TextViewState>,
1614            clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
1615        }
1616
1617        impl Render for LinkedImageRoot {
1618            fn render(
1619                &mut self,
1620                _window: &mut Window,
1621                _cx: &mut Context<Self>,
1622            ) -> impl IntoElement {
1623                let clicks = self.clicks.clone();
1624                div().w(px(160.)).child(
1625                    TextView::new(&self.text_view)
1626                        .selectable(true)
1627                        .on_link_click(move |url, event, _, _| {
1628                            clicks.lock().unwrap().push((url.clone(), event.clone()));
1629                        }),
1630                )
1631            }
1632        }
1633
1634        cx.update(crate::init);
1635        let clicks = Arc::new(Mutex::new(Vec::new()));
1636        let captured = clicks.clone();
1637        let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
1638                text_view: cx.new(|cx| {
1639                    TextViewState::markdown(
1640                        r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
1641                        cx,
1642                    )
1643                }),
1644                clicks,
1645            }
1646        );
1647        let cx: &mut VisualTestContext = cx;
1648        cx.run_until_parked();
1649        cx.update(|window, cx| {
1650            let _ = window.draw(cx);
1651        });
1652
1653        let inline_bounds = content.read_with(cx, |content, cx| {
1654            content.text_view.read(cx).selection_adapter.text_bounds()
1655        });
1656        assert!(
1657            inline_bounds.len() >= 2,
1658            "linked image needs text bounds on both sides: {inline_bounds:?}"
1659        );
1660        assert!(
1661            inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
1662            "linked image did not reserve the expected click target: {inline_bounds:?}"
1663        );
1664        let position = point(
1665            inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
1666            inline_bounds[0].top() + px(8.),
1667        );
1668        for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
1669            cx.simulate_mouse_down(position, button, Modifiers::default());
1670            cx.simulate_mouse_up(position, button, Modifiers::default());
1671        }
1672
1673        let clicks = captured.lock().unwrap();
1674        assert_eq!(clicks.len(), 3);
1675        assert!(
1676            clicks
1677                .iter()
1678                .all(|(url, _)| url == "https://example.com/image-link")
1679        );
1680        assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
1681        assert!(clicks[1].1.is_middle_click());
1682        assert!(clicks[2].1.is_right_click());
1683        assert_eq!(cx.opened_url(), None);
1684    }
1685
1686    #[gpui::test]
1687    fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
1688        cx.update(crate::init);
1689        let (view, cx) = cx
1690            .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
1691        let cx: &mut VisualTestContext = cx;
1692
1693        cx.simulate_mouse_down(
1694            point(px(10.), px(34.)),
1695            MouseButton::Left,
1696            Modifiers::default(),
1697        );
1698        cx.simulate_mouse_move(
1699            point(px(90.), px(34.)),
1700            Some(MouseButton::Left),
1701            Modifiers::default(),
1702        );
1703        cx.simulate_mouse_up(
1704            point(px(90.), px(34.)),
1705            MouseButton::Left,
1706            Modifiers::default(),
1707        );
1708
1709        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1710        assert!(
1711            selected_text.is_empty(),
1712            "unexpected selection: {selected_text:?}"
1713        );
1714    }
1715
1716    /// A tall selectable TextView clipped by a short `overflow_hidden` viewport,
1717    /// with a large blank footer below so a drag can extend the selection band
1718    /// past the bottom of the clip while still proxy-anchoring to the view.
1719    struct ClippedTallTextViewTestRoot {
1720        text_view: Entity<TextViewState>,
1721    }
1722
1723    impl ClippedTallTextViewTestRoot {
1724        fn new(cx: &mut Context<Self>) -> Self {
1725            // Four separate blocks; only the first (and maybe part of the
1726            // second) fit inside the 40px clip. "charlie"/"delta" render well
1727            // below it.
1728            let text_view =
1729                cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
1730            Self { text_view }
1731        }
1732    }
1733
1734    impl Render for ClippedTallTextViewTestRoot {
1735        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1736            div()
1737                .w(px(200.))
1738                .child(crate::TextSelectionLayer)
1739                .child(
1740                    div()
1741                        .h(px(40.))
1742                        .overflow_hidden()
1743                        .child(TextView::new(&self.text_view).selectable(true)),
1744                )
1745                // A tall blank footer so a drag can reach a y below the clipped
1746                // text; a press there proxy-anchors to the TextView above.
1747                .child(div().h(px(160.)))
1748        }
1749    }
1750
1751    /// Regression for copying a selection taller than the visible viewport.
1752    ///
1753    /// The selection band runs from visible text at the top down to a point
1754    /// far below the clip. Every glyph of the painted TextView is laid out even
1755    /// though the lower ones are clipped away, so the copied text must include
1756    /// the clipped-out "charlie"/"delta" — not just what is on screen. This
1757    /// guards against re-adding a `content_mask` gate in
1758    /// `Inline::layout_selections`.
1759    #[gpui::test]
1760    fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
1761        cx.update(crate::init);
1762        let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
1763        let cx: &mut VisualTestContext = cx;
1764
1765        cx.run_until_parked();
1766        cx.update(|window, cx| {
1767            let _ = window.draw(cx);
1768        });
1769
1770        // Anchor on visible text near the top, then drag to a point well below
1771        // the 40px clip (into the blank footer) and to the far right so the
1772        // last line is fully covered.
1773        cx.simulate_mouse_down(
1774            point(px(2.), px(8.)),
1775            MouseButton::Left,
1776            Modifiers::default(),
1777        );
1778        cx.update(|window, cx| {
1779            let _ = window.draw(cx);
1780        });
1781        cx.simulate_mouse_move(
1782            point(px(180.), px(150.)),
1783            Some(MouseButton::Left),
1784            Modifiers::default(),
1785        );
1786        cx.update(|window, cx| {
1787            let _ = window.draw(cx);
1788        });
1789        cx.simulate_mouse_up(
1790            point(px(180.), px(150.)),
1791            MouseButton::Left,
1792            Modifiers::default(),
1793        );
1794        cx.update(|window, cx| {
1795            let _ = window.draw(cx);
1796        });
1797
1798        let selected_text =
1799            content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1800        assert!(
1801            selected_text.contains("delta"),
1802            "clipped-out text was not copied: {selected_text:?}"
1803        );
1804        assert!(
1805            selected_text.contains("charlie"),
1806            "clipped-out text was not copied: {selected_text:?}"
1807        );
1808    }
1809
1810    #[gpui::test]
1811    fn double_click_selects_word(cx: &mut TestAppContext) {
1812        cx.update(crate::init);
1813        let (view, cx) =
1814            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
1815
1816        let cx: &mut VisualTestContext = cx;
1817        cx.run_until_parked();
1818        cx.update(|window, cx| {
1819            let _ = window.draw(cx);
1820        });
1821        let position = point(px(10.), px(16.));
1822        cx.simulate_event(MouseDownEvent {
1823            position,
1824            modifiers: Modifiers::default(),
1825            button: MouseButton::Left,
1826            click_count: 2,
1827            first_mouse: false,
1828        });
1829        cx.simulate_event(MouseUpEvent {
1830            position,
1831            modifiers: Modifiers::default(),
1832            button: MouseButton::Left,
1833            click_count: 2,
1834        });
1835        cx.update(|window, cx| {
1836            let _ = window.draw(cx);
1837        });
1838
1839        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1840        assert_eq!(selected_text.trim(), "quick");
1841    }
1842
1843    #[gpui::test]
1844    fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
1845        cx.update(crate::init);
1846        let (view, cx) =
1847            cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
1848
1849        let cx: &mut VisualTestContext = cx;
1850        cx.run_until_parked();
1851        cx.update(|window, cx| {
1852            let _ = window.draw(cx);
1853        });
1854
1855        let position = point(px(10.), px(10.));
1856        cx.simulate_event(MouseDownEvent {
1857            position,
1858            modifiers: Modifiers::default(),
1859            button: MouseButton::Left,
1860            click_count: 3,
1861            first_mouse: false,
1862        });
1863        cx.simulate_event(MouseUpEvent {
1864            position,
1865            modifiers: Modifiers::default(),
1866            button: MouseButton::Left,
1867            click_count: 3,
1868        });
1869        cx.update(|window, cx| {
1870            let _ = window.draw(cx);
1871        });
1872
1873        let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1874        assert_eq!(selected_text.trim(), "quick select value");
1875    }
1876
1877    // Regression: markdown `TextView` items inside an outer `gpui::list` with
1878    // `measure_all` must keep a stable total content height while scrolling.
1879    // Before synchronous full-replace parsing, off-screen markdown views were
1880    // first measured with empty content and the scrollbar thumb jittered as the
1881    // total height grew during scrolling.
1882    #[gpui::test]
1883    fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
1884        use gpui::{ListAlignment, ListState, list};
1885
1886        const ITEMS: &[&str] = &[
1887            "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
1888            "Short.",
1889            "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
1890            "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
1891            "Only one line.",
1892            "**Bold**: medium length text with `code` mixed with regular words.",
1893            "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
1894            "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
1895        ];
1896        let n = 40usize;
1897
1898        struct ListRoot {
1899            state: ListState,
1900        }
1901        impl Render for ListRoot {
1902            fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1903                div().w(px(360.)).h(px(500.)).child(
1904                    list(self.state.clone(), |ix, _w, _cx| {
1905                        div()
1906                            .w_full()
1907                            .child(TextView::markdown(
1908                                ("md", ix as u64),
1909                                ITEMS[ix % ITEMS.len()],
1910                            ))
1911                            .into_any_element()
1912                    })
1913                    .size_full(),
1914                )
1915            }
1916        }
1917
1918        cx.update(crate::init);
1919        let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
1920        let probe = state.clone();
1921        let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
1922        let cx: &mut VisualTestContext = cx;
1923
1924        cx.run_until_parked();
1925        cx.update(|w, cx| {
1926            let _ = w.draw(cx);
1927        });
1928        cx.run_until_parked();
1929        cx.update(|w, cx| {
1930            let _ = w.draw(cx);
1931        });
1932
1933        let total = |p: &ListState| {
1934            f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
1935        };
1936        let mut totals = vec![total(&probe)];
1937        for _ in 0..20 {
1938            probe.scroll_by(px(150.));
1939            cx.update(|w, cx| {
1940                let _ = w.draw(cx);
1941            });
1942            cx.run_until_parked();
1943            totals.push(total(&probe));
1944        }
1945        let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
1946        let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1947        println!(
1948            "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
1949            max - min
1950        );
1951        assert!(
1952            (max - min) < 2.0,
1953            "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
1954        );
1955    }
1956}