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::text::stream_fade::TextViewMotion;
17use crate::{GlobalState, TextSelection, text::TextViewStyle};
18
19pub(crate) type CodeBlockActionsFn =
21 dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync;
22
23pub(crate) type CodeBlockHighlighterFn =
24 dyn Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync;
25
26#[derive(Clone, Default)]
29pub struct TextViewDefaults {
30 style: Option<TextViewStyle>,
31 code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
32}
33
34impl Global for TextViewDefaults {}
35
36impl TextViewDefaults {
37 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn with_style(mut self, style: TextViewStyle) -> Self {
44 self.style = Some(style);
45 self
46 }
47
48 pub fn with_code_block_highlighter<F>(mut self, highlighter: F) -> Self
50 where
51 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
52 {
53 self.code_block_highlighter = Some(Arc::new(highlighter));
54 self
55 }
56
57 pub fn install(self, cx: &mut App) {
59 cx.set_global(self);
60 }
61
62 pub fn global(cx: &App) -> Self {
64 cx.try_global::<Self>().cloned().unwrap_or_default()
65 }
66
67 pub fn has_code_block_highlighter(&self) -> bool {
69 self.code_block_highlighter.is_some()
70 }
71}
72
73pub(crate) type TableActionsFn =
75 dyn Fn(&TableData, &mut Window, &mut App) -> AnyElement + Send + Sync;
76
77pub(crate) type LinkClickHandlerFn =
78 dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync;
79
80pub(crate) fn handle_link_click(
81 handler: &Option<Arc<LinkClickHandlerFn>>,
82 url: SharedString,
83 event: ClickEvent,
84 window: &mut Window,
85 cx: &mut App,
86) {
87 if let Some(handler) = handler {
88 handler(&url, &event, window, cx);
89 } else if match &event {
90 ClickEvent::Mouse(click) => {
91 matches!(click.up.button, MouseButton::Left | MouseButton::Middle)
92 }
93 ClickEvent::Keyboard(_) => true,
94 ClickEvent::Touch(click) => !click.long_press,
95 } {
96 cx.open_url(&url);
97 }
98}
99
100#[derive(Clone)]
117pub struct TextView {
118 id: ElementId,
119 format: Option<TextViewFormat>,
120 text: Option<SharedString>,
121 pub(crate) state: Option<Entity<TextViewState>>,
122 text_view_style: Option<TextViewStyle>,
123 style: StyleRefinement,
124 selectable: bool,
125 selection_format: SelectionFormat,
126 scrollable: bool,
127 max_lines: Option<usize>,
128 code_block_actions: Option<Arc<CodeBlockActionsFn>>,
129 code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
130 table_actions: Option<Arc<TableActionsFn>>,
131 link_click_handler: Option<Arc<LinkClickHandlerFn>>,
132 markdown_extensions: Arc<MarkdownExtensions>,
133 motion: Option<TextViewMotion>,
134}
135
136pub trait TextViewPlugin {
138 fn setup(self, text_view: TextView) -> TextView;
139}
140
141impl<P> TextViewPlugin for P
142where
143 P: MarkdownPlugin,
144{
145 fn setup(self, mut text_view: TextView) -> TextView {
146 let extensions = Arc::make_mut(&mut text_view.markdown_extensions);
147 let current = std::mem::take(extensions);
148 *extensions = current.plugin(self);
149 text_view
150 }
151}
152
153impl Styled for TextView {
154 fn style(&mut self) -> &mut StyleRefinement {
155 &mut self.style
156 }
157}
158
159impl TextView {
160 pub fn new(state: &Entity<TextViewState>) -> Self {
162 Self {
163 id: ElementId::Name(state.entity_id().to_string().into()),
164 state: Some(state.clone()),
165 format: None,
166 text: None,
167 text_view_style: None,
168 style: StyleRefinement::default(),
169 selectable: true,
170 selection_format: SelectionFormat::default(),
171 scrollable: false,
172 max_lines: None,
173 code_block_actions: None,
174 code_block_highlighter: None,
175 table_actions: None,
176 link_click_handler: None,
177 markdown_extensions: Arc::default(),
178 motion: None,
179 }
180 }
181
182 pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
184 Self {
185 id: id.into(),
186 format: Some(TextViewFormat::Markdown),
187 text: Some(markdown.into()),
188 text_view_style: None,
189 style: StyleRefinement::default(),
190 state: None,
191 selectable: true,
192 selection_format: SelectionFormat::default(),
193 scrollable: false,
194 max_lines: None,
195 code_block_actions: None,
196 code_block_highlighter: None,
197 table_actions: None,
198 link_click_handler: None,
199 markdown_extensions: Arc::default(),
200 motion: None,
201 }
202 }
203
204 pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
206 Self {
207 id: id.into(),
208 format: Some(TextViewFormat::Html),
209 text: Some(html.into()),
210 text_view_style: None,
211 style: StyleRefinement::default(),
212 state: None,
213 selectable: true,
214 selection_format: SelectionFormat::default(),
215 scrollable: false,
216 max_lines: None,
217 code_block_actions: None,
218 code_block_highlighter: None,
219 table_actions: None,
220 link_click_handler: None,
221 markdown_extensions: Arc::default(),
222 motion: None,
223 }
224 }
225
226 pub fn style(mut self, style: TextViewStyle) -> Self {
228 self.text_view_style = Some(style);
229 self
230 }
231
232 pub fn selectable(mut self, selectable: bool) -> Self {
234 self.selectable = selectable;
235 self
236 }
237
238 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
243 self.selection_format = selection_format;
244 self
245 }
246
247 pub fn scrollable(mut self, scrollable: bool) -> Self {
260 self.scrollable = scrollable;
261 self
262 }
263
264 pub fn max_lines(mut self, max_lines: usize) -> Self {
283 self.max_lines = Some(max_lines);
284 self
285 }
286
287 pub fn code_block_actions<F, E>(mut self, f: F) -> Self
292 where
293 F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
294 E: IntoElement,
295 {
296 self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
297 f(&code_block, window, cx).into_any_element()
298 }));
299 self
300 }
301
302 pub fn code_block_highlighter<F>(mut self, highlighter: F) -> Self
307 where
308 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
309 {
310 self.code_block_highlighter = Some(Arc::new(highlighter));
311 self
312 }
313
314 pub fn table_actions<F, E>(mut self, f: F) -> Self
319 where
320 F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
321 E: IntoElement,
322 {
323 self.table_actions = Some(Arc::new(move |table, window, cx| {
324 f(table, window, cx).into_any_element()
325 }));
326 self
327 }
328
329 pub fn on_link_click<F>(mut self, handler: F) -> Self
334 where
335 F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
336 {
337 self.link_click_handler = Some(Arc::new(handler));
338 self
339 }
340
341 pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self {
343 self.markdown_extensions = Arc::new(extensions);
344 self
345 }
346
347 pub fn motion(mut self, motion: TextViewMotion) -> Self {
350 self.motion = Some(motion);
351 self
352 }
353
354 pub fn markdown_mdx(mut self) -> Self {
359 let extensions = Arc::make_mut(&mut self.markdown_extensions);
360 *extensions = extensions.clone().mdx();
361 self
362 }
363
364 pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
370 where
371 F: for<'a> Fn(
372 &markdown::mdast::Node,
373 &crate::text::MarkdownParseContext<'a>,
374 ) -> Option<MarkdownNode>
375 + Send
376 + Sync
377 + 'static,
378 {
379 Arc::make_mut(&mut self.markdown_extensions).push_block_parser(parser);
380 self
381 }
382
383 pub fn markdown_block_renderer<F, E>(
385 mut self,
386 name: impl Into<SharedString>,
387 renderer: F,
388 ) -> Self
389 where
390 F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
391 E: IntoElement,
392 {
393 Arc::make_mut(&mut self.markdown_extensions).push_block_renderer(name, renderer);
394 self
395 }
396
397 pub fn plugin<P>(self, plugin: P) -> Self
399 where
400 P: TextViewPlugin,
401 {
402 plugin.setup(self)
403 }
404}
405
406impl IntoElement for TextView {
407 type Element = Self;
408
409 fn into_element(self) -> Self::Element {
410 self
411 }
412}
413
414pub struct TextViewLayoutState {
415 state: Entity<TextViewState>,
416 element: AnyElement,
417}
418
419pub struct TextViewPrepaintState {
420 hitbox: Hitbox,
421 clip_bottom: Option<Pixels>,
425 touch_handles: crate::TouchHandleLayout,
427}
428
429const CLIP_EPSILON: Pixels = px(1.);
432
433fn last_line_bottom_above(spans: &[LineSpan], y: Pixels) -> Option<(Pixels, Pixels)> {
436 let mut last: Option<(Pixels, Pixels)> = None;
437 let mut keep = |bottom: Pixels, line_height: Pixels| {
438 if bottom <= y + CLIP_EPSILON && last.is_none_or(|(last, _)| bottom > last) {
439 last = Some((bottom, line_height));
440 }
441 };
442
443 for span in spans {
444 if span.line_height <= px(0.) {
445 continue;
446 }
447 let mut bottom = span.top + span.line_height;
448 while bottom <= span.bottom + CLIP_EPSILON {
449 keep(bottom, span.line_height);
450 bottom += span.line_height;
451 }
452 keep(span.bottom, span.line_height);
454 }
455
456 last
457}
458
459fn line_safe_clip_bottom(
469 spans: &[LineSpan],
470 box_bottom: Pixels,
471 content_bottom: Pixels,
472) -> Option<Pixels> {
473 let mut clip = box_bottom;
474
475 for span in spans {
476 if span.line_height <= px(0.)
477 || span.top >= box_bottom
478 || span.bottom <= box_bottom + CLIP_EPSILON
479 {
480 continue;
481 }
482 let whole_lines = ((box_bottom - span.top) / span.line_height).floor();
483 let line_top = span.top + span.line_height * whole_lines;
484 if line_top < box_bottom - CLIP_EPSILON {
486 clip = clip.min(line_top);
487 }
488 }
489
490 let Some((last_line_bottom, line_height)) = last_line_bottom_above(spans, clip) else {
491 return None;
496 };
497
498 if content_bottom > box_bottom + CLIP_EPSILON {
502 let strip = clip - last_line_bottom;
503 if strip > CLIP_EPSILON && strip < line_height {
504 clip = last_line_bottom;
505 }
506 }
507
508 (clip < box_bottom - CLIP_EPSILON).then_some(clip)
509}
510
511impl Element for TextView {
512 type RequestLayoutState = TextViewLayoutState;
513 type PrepaintState = TextViewPrepaintState;
514
515 fn id(&self) -> Option<ElementId> {
516 Some(self.id.clone())
517 }
518
519 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
520 None
521 }
522
523 fn request_layout(
524 &mut self,
525 _: Option<&GlobalElementId>,
526 _: Option<&InspectorElementId>,
527 window: &mut Window,
528 cx: &mut App,
529 ) -> (LayoutId, Self::RequestLayoutState) {
530 let state = if let Some(state) = self.state.clone() {
531 state
532 } else {
533 let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
534 let default_text = self.text.clone().unwrap_or_default();
535
536 let state = window.use_keyed_state(
537 SharedString::from(format!("{}/state", self.id)),
538 cx,
539 move |_, cx| {
540 if default_format == TextViewFormat::Markdown {
541 TextViewState::markdown(default_text.as_str(), cx)
542 } else {
543 TextViewState::html(default_text.as_str(), cx)
544 }
545 },
546 );
547 self.state = Some(state.clone());
548 state
549 };
550
551 let max_lines = self.max_lines.filter(|_| !self.scrollable);
554
555 let defaults = cx.try_global::<TextViewDefaults>();
558 let theme_style;
559 let text_view_style = match (
560 &self.text_view_style,
561 defaults.and_then(|d| d.style.as_ref()),
562 ) {
563 (Some(style), _) | (None, Some(style)) => style,
564 (None, None) => {
565 theme_style = TextViewStyle::from_theme(&crate::Theme::global(cx));
566 &theme_style
567 }
568 };
569 let foreground = text_view_style.foreground();
570 let text_view_style = (*state.read(cx).text_view_style != *text_view_style)
571 .then(|| Arc::new(text_view_style.clone()));
572 let code_block_highlighter = self
573 .code_block_highlighter
574 .clone()
575 .or_else(|| defaults.and_then(|d| d.code_block_highlighter.clone()));
576
577 state.update(cx, |state, cx| {
578 state.code_block_actions = self.code_block_actions.clone();
579 state.code_block_highlighter = code_block_highlighter;
580 state.table_actions = self.table_actions.clone();
581 state.link_click_handler = self.link_click_handler.clone();
582 state.set_markdown_extensions(self.markdown_extensions.clone(), cx);
583 if let Some(motion) = &self.motion {
584 state.set_motion(motion.clone());
585 }
586 state.selectable = self.selectable;
587 state.selection_format = self.selection_format;
588 state.scrollable = self.scrollable;
589 state.max_lines = max_lines;
590 if let Some(text_view_style) = text_view_style {
591 state.selection_revision = state.selection_revision.wrapping_add(1);
592 state.text_view_style = text_view_style;
593 }
594
595 if let Some(text) = &self.text {
596 state.set_element_text(text, cx);
597 }
598 });
599
600 let focus_handle = state.read(cx).focus_handle.clone();
601 let list_state = state.read(cx).list_state.clone();
602 let max_lines_cap = max_lines.map(|max_lines| {
606 let mut text_style = window.text_style();
607 text_style.refine(&self.style.text);
608 text_style.line_height_in_pixels(window.rem_size()) * max_lines as f32
609 });
610
611 let mut el = div()
612 .id(("text-view-scroll", state.entity_id()))
613 .key_context("TextView")
614 .track_focus(&focus_handle)
615 .when(self.scrollable, |this| this.size_full())
616 .when_some(max_lines_cap, |this, cap| this.max_h(cap).overflow_hidden())
617 .relative()
618 .text_color(foreground)
619 .on_action(move |_: &crate::input::Copy, window, cx| {
620 let text = TextSelection::selected_text(window, cx).trim().to_string();
621 if text.is_empty() {
622 cx.propagate();
623 return;
624 }
625 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
626 })
627 .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
628 .child(state.clone())
629 .when(self.scrollable, |this| {
632 this.child(
633 div().absolute().inset_0().child(
634 crate::Scrollbar::vertical(&list_state)
635 .id(("text-view-scrollbar", state.entity_id()))
636 .viewport_from_layout(),
637 ),
638 )
639 })
640 .refine_style(&self.style)
641 .into_any_element();
642 let layout_id = el.request_layout(window, cx);
643 (layout_id, TextViewLayoutState { state, element: el })
644 }
645
646 fn prepaint(
647 &mut self,
648 _: Option<&GlobalElementId>,
649 _: Option<&InspectorElementId>,
650 bounds: Bounds<Pixels>,
651 request_layout: &mut Self::RequestLayoutState,
652 window: &mut Window,
653 cx: &mut App,
654 ) -> Self::PrepaintState {
655 let state = request_layout.state.clone();
656 let max_lines_active = state.read(cx).max_lines.is_some();
657 if max_lines_active {
658 if let Ok(mut line_spans) = state.read(cx).line_spans.lock() {
659 line_spans.clear();
660 }
661 GlobalState::global_mut(cx)
664 .text_view_state_stack
665 .push(state.clone());
666 }
667 request_layout.element.prepaint(window, cx);
668 if max_lines_active {
669 GlobalState::global_mut(cx).text_view_state_stack.pop();
670 }
671
672 let mut clip_bottom = None;
673 if max_lines_active {
674 let (line_spans, content_bottom) = {
675 let state = state.read(cx);
676 (
677 state
678 .line_spans
679 .lock()
680 .map(|spans| spans.clone())
681 .unwrap_or_default(),
682 state.bounds().bottom(),
683 )
684 };
685 let clipped = content_bottom > bounds.bottom() + px(1.);
689 if state.read(cx).clamped != clipped {
692 state.update(cx, |state, cx| {
693 state.clamped = clipped;
694 cx.notify();
695 });
696 }
697 if clipped {
698 clip_bottom = line_safe_clip_bottom(&line_spans, bounds.bottom(), content_bottom);
699 }
700 }
701
702 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
703 let touch_handles = if self.selectable {
705 state
706 .read(cx)
707 .selection_adapter
708 .prepaint_touch_handles(window, cx)
709 } else {
710 crate::TouchHandleLayout::default()
711 };
712 TextViewPrepaintState {
713 hitbox,
714 clip_bottom,
715 touch_handles,
716 }
717 }
718
719 fn paint(
720 &mut self,
721 _: Option<&GlobalElementId>,
722 _: Option<&InspectorElementId>,
723 bounds: Bounds<Pixels>,
724 request_layout: &mut Self::RequestLayoutState,
725 prepaint: &mut Self::PrepaintState,
726 window: &mut Window,
727 cx: &mut App,
728 ) {
729 let state = &request_layout.state;
730 if self.selectable {
731 state.update(cx, |state, _| state.selection_adapter.begin_frame());
732 }
733
734 GlobalState::global_mut(cx)
735 .text_view_state_stack
736 .push(state.clone());
737 if let Some(clip_bottom) = prepaint.clip_bottom {
738 let mask = ContentMask {
741 bounds: Bounds::from_corners(bounds.origin, point(bounds.right(), clip_bottom)),
742 };
743 window.with_content_mask(Some(mask), |window| {
744 request_layout.element.paint(window, cx);
745 });
746 } else {
747 request_layout.element.paint(window, cx);
748 }
749 GlobalState::global_mut(cx).text_view_state_stack.pop();
750
751 if self.selectable {
752 let (adapter, scroll_offset, content_bounds, self_scroll, handle_color) = {
753 let state = state.read(cx);
754 (
755 state.selection_adapter.clone(),
756 state.scroll_offset(),
757 state.bounds(),
758 state.scrollable,
759 state.text_view_style.selection().alpha(1.),
760 )
761 };
762 let document_order = GlobalState::global_mut(cx).next_selection_document_order();
763 adapter.register(
764 prepaint.hitbox.clone(),
765 content_bounds,
766 scroll_offset,
767 document_order,
768 self_scroll,
769 window,
770 cx,
771 );
772 adapter.paint_touch_handles(&prepaint.touch_handles, handle_color, window, cx);
775 }
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use std::sync::{
782 Arc,
783 atomic::{AtomicUsize, Ordering},
784 };
785
786 use super::{TextView, TextViewPlugin};
787 use crate::text::{TableData, TextViewState, TextViewStyle};
788 use gpui::{
789 AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
790 Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
791 Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
792 TestAppContext, VisualTestContext, Window, div, point, px,
793 };
794
795 struct TextViewTestRoot {
796 text_view: Entity<TextViewState>,
797 }
798
799 struct InlineHoverTestRoot {
800 view: Entity<TextViewState>,
801 builds: Arc<AtomicUsize>,
802 format: crate::text::SelectionFormat,
803 }
804
805 struct InlineHoverCard;
806 impl Render for InlineHoverCard {
807 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
808 div()
809 .debug_selector(|| "inline-test-card".into())
810 .w(px(120.))
811 .h(px(40.))
812 .child("Member profile")
813 }
814 }
815
816 impl Render for InlineHoverTestRoot {
817 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
818 let builds = self.builds.clone();
819 div()
820 .pl(px(150.))
821 .w(px(300.))
822 .text_size(px(16.))
823 .child(crate::TextSelectionLayer)
824 .child(
825 TextView::new(&self.view)
826 .selection_format(self.format)
827 .plugin(
828 crate::text::markdown_ext::TestInlinePlugin::new("mention")
829 .parse_with(|node, _| {
830 let markdown::mdast::Node::Link(link) = node else {
831 return None;
832 };
833 let handle = link.url.strip_prefix("mention:")?;
834 Some(
835 crate::text::MarkdownNode::new("mention", ())
836 .text(format!("@{handle}")),
837 )
838 })
839 .render_with(move |_, _, _, _| {
840 let builds = builds.clone();
841 Some(crate::text::InlineElement::new(
842 crate::HoverCard::new("mention-hover")
843 .anchor(gpui::Anchor::TopCenter)
844 .trigger(div().child("@member"))
845 .content(move |_, _, cx| {
846 builds.fetch_add(1, Ordering::Relaxed);
847 div()
848 .id("hover-content")
849 .child(cx.new(|_| InlineHoverCard))
850 }),
851 ))
852 }),
853 ),
854 )
855 }
856 }
857
858 #[gpui::test]
859 fn inline_plugin_reuses_render_and_preserves_native_child_events(cx: &mut TestAppContext) {
860 use std::sync::Mutex;
861 struct ControlPlugin(Arc<Mutex<Vec<String>>>);
862 impl crate::text::MarkdownPlugin for ControlPlugin {
863 fn name(&self) -> &str {
864 "control"
865 }
866 fn parse(
867 &self,
868 node: &markdown::mdast::Node,
869 _: &crate::text::MarkdownParseContext<'_>,
870 ) -> Option<crate::text::MarkdownNode> {
871 let markdown::mdast::Node::Link(link) = node else {
872 return None;
873 };
874 let label = link.url.strip_prefix("control:")?;
875 Some(crate::text::MarkdownNode::new("control", ()).text(label.to_string()))
876 }
877 fn render(
878 &self,
879 node: &crate::text::MarkdownNode,
880 _: &mut Window,
881 _: &mut gpui::App,
882 ) -> impl IntoElement {
883 let clicks = self.0.clone();
884 let label = node.as_text().to_string();
885 div()
886 .id("same-control-id")
887 .w(px(60.))
888 .h(px(24.))
889 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
890 .on_click(move |_, _, _| clicks.lock().unwrap().push(label.clone()))
891 .child(node.as_text().to_string())
892 }
893 }
894 struct Root {
895 view: Entity<TextViewState>,
896 clicks: Arc<Mutex<Vec<String>>>,
897 }
898 impl Render for Root {
899 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
900 div()
901 .w(px(300.))
902 .child(crate::TextSelectionLayer)
903 .child(TextView::new(&self.view).plugin(ControlPlugin(self.clicks.clone())))
904 }
905 }
906 cx.update(crate::init);
907 let clicks = Arc::new(Mutex::new(Vec::new()));
908 let captured = clicks.clone();
909 let (root, cx) = cx.add_window_view(move |_, cx| Root {
910 view: cx.new(|cx| TextViewState::markdown("[one](control:one)[two](control:two)", cx)),
911 clicks,
912 });
913 cx.run_until_parked();
914 cx.update(|window, cx| {
915 window.draw(cx).clear(cx);
916 });
917 let bounds = root.read_with(cx, |root, cx| {
918 root.view.read(cx).selection_adapter.text_bounds()
919 });
920 assert_eq!(bounds.len(), 2);
921 for bounds in bounds {
922 cx.simulate_click(bounds.center(), Modifiers::default());
923 cx.update(|window, cx| {
924 window.draw(cx).clear(cx);
925 });
926 }
927 assert_eq!(*captured.lock().unwrap(), vec!["one", "two"]);
928 }
929
930 #[gpui::test]
931 fn inline_hover_card_is_lazy_and_retains_atomic_copy(cx: &mut TestAppContext) {
932 cx.update(crate::init);
933 let builds = Arc::new(AtomicUsize::new(0));
934 let source = "[@member](mention:member)";
935 let (root, cx) = cx.add_window_view(|_, cx| InlineHoverTestRoot {
936 view: cx.new(|cx| TextViewState::markdown(source, cx)),
937 builds: builds.clone(),
938 format: crate::text::SelectionFormat::Plain,
939 });
940 cx.run_until_parked();
941 cx.update(|window, cx| window.draw(cx).clear(cx));
942 assert_eq!(builds.load(Ordering::Relaxed), 0);
943 let view = root.read_with(cx, |root, _| root.view.clone());
944 let bounds = view.read_with(cx, |view, _| view.selection_adapter.text_bounds()[0]);
945 cx.update(|window, cx| {
946 window.simulate_mouse_move(point(bounds.right() - px(1.), bounds.center().y), cx)
947 });
948 cx.run_until_parked();
949 cx.executor()
950 .advance_clock(std::time::Duration::from_secs(1));
951 cx.run_until_parked();
952 cx.update(|window, cx| window.draw(cx).clear(cx));
953 assert!(
954 builds.load(Ordering::Relaxed) > 0,
955 "hover did not build the card"
956 );
957 cx.update(|window, cx| window.draw(cx).clear(cx));
958 let card = cx
959 .debug_bounds("inline-test-card")
960 .expect("hover card should be painted");
961 assert!(
962 (card.center().x - bounds.center().x).abs() < px(1.),
963 "card {card:?} must be centered on mention {bounds:?}"
964 );
965 assert!(
966 card.top() >= bounds.bottom(),
967 "card should be below the mention"
968 );
969 for (format, expected) in [
970 (crate::text::SelectionFormat::Plain, "@member"),
971 (crate::text::SelectionFormat::Source, source),
972 ] {
973 root.update(cx, |root, cx| {
974 root.format = format;
975 cx.notify();
976 });
977 view.update(cx, |view, cx| {
978 view.set_selection_format(format, cx);
979 view.select_all(cx);
980 });
981 cx.update(|window, cx| window.draw(cx).clear(cx));
982 assert_eq!(
983 view.read_with(cx, |view, _| view.selected_text()).trim(),
984 expected
985 );
986 }
987 }
988
989 struct InlinePluginTestRoot {
990 text_view: Entity<TextViewState>,
991 width: Pixels,
992 font_size: Pixels,
993 source_format: bool,
994 prepared_size: Option<Arc<AtomicUsize>>,
995 }
996
997 impl Render for InlinePluginTestRoot {
998 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
999 let prepared_size = self.prepared_size.clone();
1000 div().w(self.width).text_size(self.font_size).child(crate::TextSelectionLayer).child(
1001 TextView::new(&self.text_view)
1002 .selection_format(if self.source_format { crate::text::SelectionFormat::Source }
1003 else { crate::text::SelectionFormat::Plain })
1004
1005 .plugin(crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(|node, _| {
1006 let markdown::mdast::Node::InlineMath(math) = node else { return None };
1007 Some(crate::text::MarkdownNode::new("math", ()).text(format!("{}²", math.value)))
1008 }).render_with(move |_, context, _, _| {
1009 let value = prepared_size.as_ref()?.load(Ordering::Relaxed) as f32;
1010 let image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Svg,
1011 b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><path d=\"M0 0L40 40\" stroke=\"black\"/></svg>".to_vec()));
1012 let unit = context.font_size() / 16.;
1013 Some(crate::text::InlineElement::new(gpui::img(image).w(unit * value).h(unit * value)).with_baseline(unit * value * 0.75))
1014 })))
1015 }
1016 }
1017
1018 #[gpui::test]
1019 fn inline_plugins_drag_and_copy_across_formulas_in_both_directions(cx: &mut TestAppContext) {
1020 cx.update(crate::init);
1021 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1022 text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1023 width: px(420.),
1024 font_size: px(16.),
1025 source_format: false,
1026 prepared_size: None,
1027 });
1028 let cx: &mut VisualTestContext = cx;
1029 for font_size in [16., 24., 32.] {
1030 for width in [60., 160., 420.] {
1031 for prepared in [None, Some(32)] {
1032 for source_format in [false, true] {
1033 root.update(cx, |root, cx| {
1034 root.source_format = source_format;
1035 root.width = px(width);
1036 root.font_size = px(font_size);
1037 root.prepared_size =
1038 prepared.map(|value| Arc::new(AtomicUsize::new(value)));
1039 cx.notify();
1040 });
1041 cx.run_until_parked();
1042 cx.update(|window, cx| window.draw(cx).clear(cx));
1043 let bounds =
1044 root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1045 let text_bounds = root.read_with(cx, |root, cx| {
1046 root.text_view.read(cx).selection_adapter.text_bounds()
1047 });
1048 let first = text_bounds.first().unwrap();
1049 let last = text_bounds.last().unwrap();
1050 let left =
1051 point(first.left() + px(0.1), first.top() + first.size.height / 2.);
1052 let right =
1053 point(last.right() - px(0.1), last.top() + last.size.height / 2.);
1054 for (start, end) in [(left, right), (right, left)] {
1055 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1056 cx.update(|window, cx| window.draw(cx).clear(cx));
1057 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1058 cx.update(|window, cx| window.draw(cx).clear(cx));
1059 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1060 cx.update(|window, cx| window.draw(cx).clear(cx));
1061 let selected = root
1062 .read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1063 assert_eq!(
1064 selected.trim(),
1065 if source_format {
1066 "中文 $x$ $y$ English"
1067 } else {
1068 "中文 x² y² English"
1069 },
1070 "start={start:?} end={end:?} bounds={bounds:?} width={width} font_size={font_size} prepared={prepared:?}"
1071 );
1072 }
1073 }
1074 }
1075 }
1076 }
1077 }
1078
1079 #[gpui::test]
1080 fn inline_resource_size_update_reflows_without_changing_selection(cx: &mut TestAppContext) {
1081 cx.update(crate::init);
1082 let prepared_size = Arc::new(AtomicUsize::new(16));
1083 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1084 text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1085 width: px(160.),
1086 font_size: px(16.),
1087 source_format: false,
1088 prepared_size: Some(prepared_size.clone()),
1089 });
1090 let cx: &mut VisualTestContext = cx;
1091 cx.run_until_parked();
1092 cx.update(|window, cx| window.draw(cx).clear(cx));
1093 let before = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1094 let regions = root.read_with(cx, |root, cx| {
1095 root.text_view.read(cx).selection_adapter.text_bounds()
1096 });
1097 let first = regions.first().unwrap();
1098 let last = regions.last().unwrap();
1099 let start = point(first.left() + px(0.1), first.top() + px(10.));
1100 let end = point(last.right() - px(0.1), last.top() + px(10.));
1101 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1102 cx.update(|window, cx| window.draw(cx).clear(cx));
1103 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1104 cx.update(|window, cx| window.draw(cx).clear(cx));
1105 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1106 cx.update(|window, cx| window.draw(cx).clear(cx));
1107 assert_eq!(
1108 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1109 .trim(),
1110 "中文 x² y² English"
1111 );
1112 prepared_size.store(100, Ordering::Relaxed);
1113 root.update(cx, |root, cx| {
1114 root.text_view
1115 .update(cx, |state, cx| state.invalidate_inline_layout(cx))
1116 });
1117 cx.run_until_parked();
1118 cx.update(|window, cx| window.draw(cx).clear(cx));
1119 let after = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1120 assert!(after.size.height > before.size.height * 2.);
1121 assert_eq!(
1122 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1123 .trim(),
1124 "中文 x² y² English"
1125 );
1126 }
1127
1128 #[gpui::test]
1129 fn triple_click_on_formula_selects_its_entire_mixed_line(cx: &mut TestAppContext) {
1130 cx.update(crate::init);
1131 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1132 text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1133 width: px(420.),
1134 font_size: px(16.),
1135 source_format: false,
1136 prepared_size: None,
1137 });
1138 let cx: &mut VisualTestContext = cx;
1139 cx.run_until_parked();
1140 cx.update(|window, cx| window.draw(cx).clear(cx));
1141 let regions = root.read_with(cx, |root, cx| {
1142 root.text_view.read(cx).selection_adapter.text_bounds()
1143 });
1144 let formula = regions[1];
1145 let position = formula.center();
1146 cx.simulate_event(MouseDownEvent {
1147 position,
1148 modifiers: Modifiers::default(),
1149 button: MouseButton::Left,
1150 click_count: 3,
1151 first_mouse: false,
1152 });
1153 cx.simulate_event(MouseUpEvent {
1154 position,
1155 modifiers: Modifiers::default(),
1156 button: MouseButton::Left,
1157 click_count: 3,
1158 });
1159 cx.update(|window, cx| window.draw(cx).clear(cx));
1160 assert_eq!(
1161 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1162 .trim(),
1163 "before x² after"
1164 );
1165 root.update(cx, |root, cx| {
1166 root.source_format = true;
1167 cx.notify();
1168 });
1169 cx.update(|window, cx| window.draw(cx).clear(cx));
1170 assert_eq!(
1171 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1172 .trim(),
1173 "before $x$ after"
1174 );
1175 }
1176
1177 #[gpui::test]
1178 fn double_click_on_formula_selects_only_that_object(cx: &mut TestAppContext) {
1179 cx.update(crate::init);
1180 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1181 text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1182 width: px(420.),
1183 font_size: px(16.),
1184 source_format: false,
1185 prepared_size: None,
1186 });
1187 let cx: &mut VisualTestContext = cx;
1188 cx.run_until_parked();
1189 cx.update(|window, cx| window.draw(cx).clear(cx));
1190 let regions = root.read_with(cx, |root, cx| {
1191 root.text_view.read(cx).selection_adapter.text_bounds()
1192 });
1193 let position = regions[1].center();
1194 cx.simulate_event(MouseDownEvent {
1195 position,
1196 modifiers: Modifiers::default(),
1197 button: MouseButton::Left,
1198 click_count: 2,
1199 first_mouse: false,
1200 });
1201 cx.simulate_event(MouseUpEvent {
1202 position,
1203 modifiers: Modifiers::default(),
1204 button: MouseButton::Left,
1205 click_count: 2,
1206 });
1207 cx.update(|window, cx| window.draw(cx).clear(cx));
1208 assert_eq!(
1210 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1211 .trim(),
1212 "x²"
1213 );
1214 }
1215
1216 struct ScrollExtentTestRoot {
1219 text_view: Entity<TextViewState>,
1220 }
1221
1222 impl Render for ScrollExtentTestRoot {
1223 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1224 div()
1225 .w(px(400.))
1226 .h(px(200.))
1227 .overflow_hidden()
1228 .child(TextView::new(&self.text_view).scrollable(true))
1229 }
1230 }
1231
1232 fn document_of(count: usize, words: usize) -> String {
1235 (1..=count)
1236 .map(|i| format!("Block {i}: {}", "lorem ipsum dolor ".repeat(words)))
1237 .collect::<Vec<_>>()
1238 .join("\n\n")
1239 }
1240
1241 #[gpui::test]
1247 fn replacing_a_document_with_an_equal_block_count_remeasures(cx: &mut TestAppContext) {
1248 cx.update(crate::init);
1249
1250 const BLOCKS: usize = 24;
1251 let short = document_of(BLOCKS, 1);
1252 let tall = document_of(BLOCKS, 60);
1253
1254 let (root, cx) = cx.add_window_view(|_, cx| ScrollExtentTestRoot {
1255 text_view: cx.new(|cx| TextViewState::markdown(&short, cx)),
1256 });
1257 let cx: &mut VisualTestContext = cx;
1258
1259 let settle = |cx: &mut VisualTestContext| {
1262 cx.run_until_parked();
1263 cx.update(|window, cx| window.draw(cx).clear(cx));
1264 cx.run_until_parked();
1265 };
1266 settle(cx);
1267
1268 let scroll_extent = |cx: &mut VisualTestContext| {
1269 root.read_with(cx, |root, cx| {
1270 root.text_view
1271 .read(cx)
1272 .list_state()
1273 .max_offset_for_scrollbar()
1274 .y
1275 })
1276 };
1277
1278 let short_extent = scroll_extent(cx);
1279
1280 root.update(cx, |root, cx| {
1281 root.text_view
1282 .update(cx, |state, cx| state.set_text(&tall, cx));
1283 });
1284 settle(cx);
1285
1286 root.read_with(cx, |root, cx| {
1287 assert_eq!(
1288 root.text_view.read(cx).list_state().item_count(),
1289 BLOCKS,
1290 "the replacement must keep the block count, or the list resets and the bug cannot occur"
1291 );
1292 });
1293
1294 let tall_extent = scroll_extent(cx);
1295 assert!(
1296 tall_extent > short_extent * 5.,
1297 "a much taller document must grow the scroll extent, but it went from \
1298 {short_extent:?} to {tall_extent:?}"
1299 );
1300 }
1301
1302 struct StatelessMarkdownRoot {
1303 renders: Arc<AtomicUsize>,
1304 }
1305
1306 impl Render for StatelessMarkdownRoot {
1307 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1308 self.renders.fetch_add(1, Ordering::Relaxed);
1309 div().child(
1310 TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
1311 .markdown_block_parser(|_, _| None),
1312 )
1313 }
1314 }
1315
1316 struct DummyTextViewPlugin;
1317
1318 impl TextViewPlugin for DummyTextViewPlugin {
1319 fn setup(self, mut text_view: TextView) -> TextView {
1320 text_view.selectable = true;
1321 text_view
1322 }
1323 }
1324
1325 #[gpui::test]
1326 fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
1327 cx.update(crate::init);
1328 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
1329
1330 assert!(TextView::new(&state).selectable);
1331 assert!(TextView::markdown("markdown", "text").selectable);
1332 assert!(TextView::html("html", "<p>text</p>").selectable);
1333 }
1334
1335 #[gpui::test]
1336 fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
1337 cx.update(crate::init);
1338 let renders = Arc::new(AtomicUsize::new(0));
1339 let (_, cx) = cx.add_window_view({
1340 let renders = renders.clone();
1341 move |_, _| StatelessMarkdownRoot { renders }
1342 });
1343 let cx: &mut VisualTestContext = cx;
1344
1345 cx.run_until_parked();
1346 cx.update(|window, cx| window.draw(cx).clear(cx));
1347 let renders_after_redraw = renders.load(Ordering::Relaxed);
1348 cx.run_until_parked();
1349 assert_eq!(
1350 renders.load(Ordering::Relaxed),
1351 renders_after_redraw,
1352 "an unchanged TextView must not schedule another render after its parser is rebuilt",
1353 );
1354 }
1355
1356 #[gpui::test]
1357 fn markdown_data_url_image_is_decoded_inline(cx: &mut TestAppContext) {
1358 use gpui::{Image, ImageFormat, ImageSource};
1359
1360 const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
1362
1363 cx.update(crate::init);
1364 let markdown = format!("Inline  image");
1365 let (_, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new(&markdown, cx));
1366 let cx: &mut VisualTestContext = cx;
1367
1368 cx.run_until_parked();
1369 cx.update(|window, cx| window.draw(cx).clear(cx));
1370
1371 let bytes = data_url::DataUrl::process(&format!("data:image/png;base64,{PNG_BASE64}"))
1376 .unwrap()
1377 .decode_to_vec()
1378 .unwrap()
1379 .0;
1380 let image = Arc::new(Image::from_bytes(ImageFormat::Png, bytes));
1381 assert!(
1382 cx.update(|_, cx| ImageSource::Image(image).is_asset_cached(cx)),
1383 "the data URL image must be handed to GPUI as decoded bytes",
1384 );
1385 }
1386
1387 #[gpui::test]
1388 fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
1389 cx.update(crate::init);
1390 cx.update(|cx| {
1391 let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
1392 colors.primary = gpui::rgb(0x55aaff).into();
1393 colors.selection = gpui::rgb(0x335577).into();
1394 });
1395 let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
1396 let cx: &mut VisualTestContext = cx;
1397
1398 cx.run_until_parked();
1399 root.read_with(cx, |root, cx| {
1400 let style = &root.text_view.read(cx).text_view_style;
1401 assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
1402 assert_eq!(style.selection(), gpui::rgb(0x335577).into());
1403 });
1404 }
1405
1406 impl TextViewTestRoot {
1407 fn new(text: &str, cx: &mut Context<Self>) -> Self {
1408 let text = text.to_string();
1409 let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
1410 Self { text_view }
1411 }
1412 }
1413
1414 impl Render for TextViewTestRoot {
1415 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1416 div()
1417 .w(px(160.))
1418 .child(
1419 div()
1420 .h(px(24.))
1421 .overflow_hidden()
1422 .child(TextView::new(&self.text_view).selectable(true)),
1423 )
1424 .child(div().h(px(40.)).child("footer"))
1425 }
1426 }
1427
1428 struct TableSelectionTestRoot {
1429 text_view: Entity<TextViewState>,
1430 }
1431
1432 impl Render for TableSelectionTestRoot {
1433 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1434 div()
1435 .debug_selector(|| "table-selection-root".into())
1436 .w(px(520.))
1437 .child(crate::TextSelectionLayer)
1438 .child(TextView::new(&self.text_view))
1439 }
1440 }
1441
1442 #[gpui::test]
1443 fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
1444 cx.update(crate::init);
1445 let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
1446 text_view: cx.new(|cx| {
1447 TextViewState::markdown(
1448 "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
1449 cx,
1450 )
1451 }),
1452 });
1453 let cx: &mut VisualTestContext = cx;
1454
1455 cx.run_until_parked();
1456 let bounds = cx
1457 .debug_bounds("table-selection-root")
1458 .expect("table bounds");
1459 let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
1460 let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
1461 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1462 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1463 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1464
1465 assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
1466 assert_eq!(
1467 cx.update(|window, cx| window.simulate_next_frame(cx)),
1468 0,
1469 "finished table selection must not continuously request frames"
1470 );
1471 }
1472
1473 struct InlineImageTextViewTestRoot {
1474 text_view: Entity<TextViewState>,
1475 }
1476
1477 impl InlineImageTextViewTestRoot {
1478 fn new(cx: &mut Context<Self>) -> Self {
1479 let text_view = cx.new(|cx| {
1480 TextViewState::markdown(
1481 "Build Status  after",
1482 cx,
1483 )
1484 });
1485 Self { text_view }
1486 }
1487 }
1488
1489 impl Render for InlineImageTextViewTestRoot {
1490 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1491 div()
1492 .w(px(420.))
1493 .child(TextView::new(&self.text_view).selectable(true))
1494 }
1495 }
1496
1497 #[gpui::test]
1498 fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
1499 cx.update(crate::init);
1500 let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
1501 let cx: &mut VisualTestContext = cx;
1502
1503 cx.run_until_parked();
1504 cx.update(|window, cx| {
1505 let _ = window.draw(cx);
1506 });
1507
1508 let inline_bounds = content.read_with(cx, |content, cx| {
1509 content.text_view.read(cx).selection_adapter.text_bounds()
1510 });
1511
1512 assert_eq!(inline_bounds.len(), 2);
1513 assert_eq!(
1514 inline_bounds[0].top(),
1515 inline_bounds[1].top(),
1516 "text before and after an inline image should share a rendered line"
1517 );
1518 assert!(
1519 inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
1520 "inline image should reserve horizontal space in the text layout"
1521 );
1522 assert!(
1523 inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
1524 "unloaded inline image fallback should stay generic and compact"
1525 );
1526 }
1527
1528 #[gpui::test]
1529 fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
1530 cx.update(crate::init);
1531 let (_, cx) = cx.add_window_view(|_, cx| {
1532 TextViewTestRoot::new(
1533 "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
1534 cx,
1535 )
1536 });
1537 let cx: &mut VisualTestContext = cx;
1538
1539 cx.run_until_parked();
1540 cx.update(|window, cx| {
1541 let _ = window.draw(cx);
1542 });
1543 }
1544
1545 #[gpui::test]
1546 fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
1547 struct ListItemBlockRoot;
1548
1549 impl Render for ListItemBlockRoot {
1550 fn render(
1551 &mut self,
1552 _window: &mut Window,
1553 _cx: &mut Context<Self>,
1554 ) -> impl IntoElement {
1555 div().w(px(840.)).h(px(400.)).child(
1556 crate::h_resizable("markdown-width-test")
1557 .child(crate::resizable_panel().child(div()))
1558 .child(crate::resizable_panel().child(
1559 TextView::markdown(
1560 "list-with-code",
1561 "1. List item\n ```rust\n nested code\n ```\n\n```rust\ntop-level code\n```",
1562 )
1563 .code_block_actions(|code_block, _, _| {
1564 let selector = if code_block.code().contains("nested") {
1565 "nested-code-action"
1566 } else {
1567 "top-level-code-action"
1568 };
1569 div()
1570 .debug_selector(move || selector.into())
1571 .child("Copy")
1572 })
1573 .scrollable(true)
1574 .p_5()
1575 .flex_none(),
1576 )),
1577 )
1578 }
1579 }
1580
1581 cx.update(crate::init);
1582 let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
1583 let cx: &mut VisualTestContext = cx;
1584
1585 cx.run_until_parked();
1586 cx.update(|window, cx| {
1587 let _ = window.draw(cx);
1588 });
1589
1590 let nested_action = cx.debug_bounds("nested-code-action").unwrap();
1591 let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
1592 assert!(
1593 top_level_action.right() - nested_action.right() < px(32.),
1594 "nested code block should fill the list item's available width"
1595 );
1596 }
1597
1598 fn draw_table_with_actions(
1602 cx: &mut TestAppContext,
1603 scroll: bool,
1604 ) -> (Bounds<Pixels>, TableData) {
1605 use std::sync::{Arc, Mutex};
1606
1607 struct TableRoot {
1608 scroll: bool,
1609 captured: Arc<Mutex<Vec<TableData>>>,
1610 }
1611
1612 impl Render for TableRoot {
1613 fn render(
1614 &mut self,
1615 _window: &mut Window,
1616 _cx: &mut Context<Self>,
1617 ) -> impl IntoElement {
1618 let captured = self.captured.clone();
1619 let mut table_style = StyleRefinement::default();
1620 if self.scroll {
1621 table_style.overflow.x = Some(Overflow::Scroll);
1622 }
1623
1624 div().w(px(320.)).child(
1625 TextView::markdown(
1626 "table-actions",
1627 "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
1628 )
1629 .style(TextViewStyle::default().with_table(table_style))
1630 .table_actions(move |table, _, _| {
1631 if let Ok(mut captured) = captured.lock() {
1632 captured.push(table.clone());
1633 }
1634 div().debug_selector(|| "table-action".into()).child("Copy")
1635 }),
1636 )
1637 }
1638 }
1639
1640 cx.update(crate::init);
1641 let captured = Arc::new(Mutex::new(Vec::new()));
1642 let (_, cx) = cx.add_window_view({
1643 let captured = captured.clone();
1644 move |_, _| TableRoot { scroll, captured }
1645 });
1646 let cx: &mut VisualTestContext = cx;
1647
1648 cx.run_until_parked();
1649 cx.update(|window, cx| {
1650 let _ = window.draw(cx);
1651 });
1652
1653 let bounds = cx
1654 .debug_bounds("table-action")
1655 .expect("table actions should be painted");
1656 let data = captured
1657 .lock()
1658 .expect("captured table data")
1659 .last()
1660 .cloned()
1661 .expect("table actions hook should receive the table");
1662
1663 (bounds, data)
1664 }
1665
1666 #[gpui::test]
1667 fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
1668 for scroll in [false, true] {
1669 let (bounds, data) = draw_table_with_actions(cx, scroll);
1670
1671 assert!(
1673 bounds.top() > px(40.),
1674 "actions should sit below the table (scroll: {scroll}), got {:?}",
1675 bounds.top()
1676 );
1677 assert_eq!(data.headers, vec!["Name", "Age"]);
1678 assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
1679 assert_eq!(
1680 data.markdown,
1681 "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
1682 );
1683 assert_eq!(data.span, Some(0..52));
1684 }
1685 }
1686
1687 #[test]
1688 fn plugin_accepts_text_view_plugins_beyond_markdown() {
1689 let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
1690
1691 assert!(view.selectable);
1692 }
1693
1694 #[test]
1695 fn syntax_highlighting_is_opt_in() {
1696 let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
1697 assert!(default_view.code_block_highlighter.is_none());
1698
1699 let view = default_view.code_block_highlighter(|block| {
1700 vec![(
1701 0..block.code().len(),
1702 gpui::HighlightStyle {
1703 color: Some(gpui::rgb(0x3366ff).into()),
1704 ..Default::default()
1705 },
1706 )]
1707 });
1708 assert!(view.code_block_highlighter.is_some());
1709 }
1710
1711 #[gpui::test]
1712 fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
1713 cx.update(crate::init);
1714 let (_, cx) = cx.add_window_view(|_, cx| {
1715 TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
1716 });
1717 let cx: &mut VisualTestContext = cx;
1718
1719 cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
1720
1721 assert_eq!(cx.opened_url(), None);
1722 }
1723
1724 struct MaxLinesTestRoot {
1725 text_view: Entity<TextViewState>,
1726 max_lines: usize,
1727 }
1728
1729 impl MaxLinesTestRoot {
1730 fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
1731 let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
1732 Self {
1733 text_view,
1734 max_lines,
1735 }
1736 }
1737 }
1738
1739 impl Render for MaxLinesTestRoot {
1740 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1741 div()
1742 .w(px(200.))
1743 .child(TextView::new(&self.text_view).max_lines(self.max_lines))
1744 }
1745 }
1746
1747 #[test]
1748 fn the_clip_only_moves_for_a_straddling_glyph_line() {
1749 use super::line_safe_clip_bottom;
1750 use crate::text::state::LineSpan;
1751
1752 let spans = [
1753 LineSpan {
1755 top: px(0.),
1756 bottom: px(60.),
1757 line_height: px(20.),
1758 },
1759 LineSpan {
1761 top: px(68.),
1762 bottom: px(128.),
1763 line_height: px(20.),
1764 },
1765 ];
1766
1767 let below = px(400.);
1769
1770 assert_eq!(
1772 line_safe_clip_bottom(&spans, px(100.), below),
1773 Some(px(88.))
1774 );
1775
1776 assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
1778
1779 assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
1782
1783 let one_block = [LineSpan {
1786 top: px(0.),
1787 bottom: px(60.),
1788 line_height: px(20.),
1789 }];
1790 assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
1791
1792 assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
1795 }
1796
1797 #[test]
1798 fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
1799 use super::line_safe_clip_bottom;
1800 use crate::text::state::LineSpan;
1801
1802 let heading = [LineSpan {
1804 top: px(70.),
1805 bottom: px(98.),
1806 line_height: px(28.),
1807 }];
1808
1809 assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
1810 }
1811
1812 #[test]
1813 fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
1814 use super::line_safe_clip_bottom;
1815 use crate::text::state::LineSpan;
1816
1817 let rows = [
1820 LineSpan {
1821 top: px(100.),
1822 bottom: px(126.),
1823 line_height: px(26.),
1824 },
1825 LineSpan {
1826 top: px(135.),
1827 bottom: px(161.),
1828 line_height: px(26.),
1829 },
1830 ];
1831
1832 assert_eq!(
1835 line_safe_clip_bottom(&rows, px(148.), px(400.)),
1836 Some(px(126.))
1837 );
1838 }
1839
1840 struct ClampedPageRoot {
1846 text_view: Entity<TextViewState>,
1847 max_lines: usize,
1848 }
1849
1850 impl Render for ClampedPageRoot {
1851 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1852 use crate::{h_flex, v_flex};
1853
1854 v_flex()
1855 .size_full()
1856 .p_4()
1857 .gap_4()
1858 .child(h_flex().max_w(px(480.)).gap_3().child("header"))
1859 .child(
1860 v_flex()
1861 .flex_1()
1862 .min_h_0()
1863 .gap_4()
1864 .id("clamped-page-scroll")
1865 .child(
1866 v_flex()
1867 .max_w(px(480.))
1868 .p_3()
1869 .gap_2()
1870 .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
1871 )
1872 .overflow_y_scroll(),
1873 )
1874 }
1875 }
1876
1877 #[gpui::test]
1878 fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
1879 cx.update(crate::init);
1880 let (root, cx) = cx.add_window_view(|_, cx| {
1881 let text_view = cx.new(|cx| {
1882 TextViewState::markdown(
1883 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
1884 cx,
1885 )
1886 });
1887 ClampedPageRoot {
1888 text_view,
1889 max_lines: 3,
1890 }
1891 });
1892 let cx: &mut VisualTestContext = cx;
1893
1894 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1895 }
1896
1897 #[gpui::test]
1898 fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
1899 cx.update(crate::init);
1900 let (root, cx) = cx.add_window_view(|_, cx| {
1901 MaxLinesTestRoot::new(
1902 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
1903 2,
1904 cx,
1905 )
1906 });
1907 let cx: &mut VisualTestContext = cx;
1908
1909 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1910 }
1911
1912 #[gpui::test]
1913 fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
1914 cx.update(crate::init);
1915 let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
1916 let cx: &mut VisualTestContext = cx;
1917
1918 assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1919 }
1920
1921 #[gpui::test]
1922 fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
1923 cx.update(crate::init);
1924 let (_, cx) = cx.add_window_view(|_, cx| {
1925 MaxLinesTestRoot::new(
1926 "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
1927 2,
1928 cx,
1929 )
1930 });
1931 let cx: &mut VisualTestContext = cx;
1932
1933 cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
1935
1936 assert_eq!(cx.opened_url(), None);
1937 }
1938
1939 #[gpui::test]
1940 fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) {
1941 struct SelectionRoot {
1942 text_view: Entity<TextViewState>,
1943 format: crate::text::SelectionFormat,
1944 }
1945 impl Render for SelectionRoot {
1946 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1947 div()
1948 .w(px(160.))
1949 .child(crate::TextSelectionLayer)
1950 .child(TextView::new(&self.text_view).selection_format(self.format))
1951 }
1952 }
1953 cx.update(crate::init);
1954 let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot {
1955 format: crate::text::SelectionFormat::Plain,
1956 text_view: cx
1957 .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)),
1958 });
1959 let cx: &mut VisualTestContext = cx;
1960 cx.run_until_parked();
1961 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1962 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
1963 cx.simulate_mouse_down(
1964 point(px(3.), px(8.)),
1965 MouseButton::Left,
1966 Modifiers::default(),
1967 );
1968 cx.update(|window, cx| {
1969 let _ = window.draw(cx);
1970 });
1971 cx.simulate_mouse_move(
1972 point(px(155.), px(20.)),
1973 Some(MouseButton::Left),
1974 Modifiers::default(),
1975 );
1976 cx.update(|window, cx| {
1977 let _ = window.draw(cx);
1978 });
1979 cx.simulate_mouse_up(
1980 point(px(155.), px(20.)),
1981 MouseButton::Left,
1982 Modifiers::default(),
1983 );
1984 cx.update(|window, cx| {
1985 let _ = window.draw(cx);
1986 });
1987 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
1988 assert_eq!(selected.trim(), "code after");
1989 view.update(cx, |view, cx| {
1990 view.format = crate::text::SelectionFormat::Source;
1991 cx.notify();
1992 });
1993 cx.update(|window, cx| {
1994 let _ = window.draw(cx);
1995 });
1996 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
1997 assert_eq!(selected.trim(), "[`code`](https://example.com) after");
1998 }
1999
2000 #[test]
2010 fn inline_code_heading_reserves_painted_wrapped_lines_and_list_baseline() {
2011 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2012 use gpui::{TestApp, rems};
2013
2014 const MARKDOWN: &str = "The same words with inline code.\n\nThe same words with `inline code`.\n\n- The same words with inline code.\n- The same words with `inline code`.\n\n# Heading with inline code\n# Heading with `inline code`\n\nthis is a test";
2015
2016 struct MarkdownRoot {
2017 text_view: Entity<TextViewState>,
2018 width: Pixels,
2019 preview_zoom: f32,
2020 }
2021
2022 impl Render for MarkdownRoot {
2023 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2024 div()
2025 .w(self.width)
2026 .text_size(rems(self.preview_zoom))
2029 .child(crate::TextSelectionLayer)
2030 .child(TextView::new(&self.text_view).selectable(true))
2031 }
2032 }
2033
2034 fn draw(
2035 app: &mut TestApp,
2036 width: Pixels,
2037 preview_zoom: f32,
2038 ) -> (Bounds<Pixels>, Vec<Bounds<Pixels>>, String) {
2039 let mut window = app.open_window(|_, cx| MarkdownRoot {
2040 text_view: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
2041 width,
2042 preview_zoom,
2043 });
2044 window.draw();
2045 app.run_until_parked();
2046 window.draw();
2047 window.update(|root, _, cx| {
2048 root.text_view.update(cx, |state, cx| state.select_all(cx));
2049 });
2050 window.draw();
2051 window.read(|root, cx| {
2052 let state = root.text_view.read(cx);
2053 (
2054 state.bounds(),
2055 state.selection_adapter.text_bounds(),
2056 state.selected_text(),
2057 )
2058 })
2059 }
2060
2061 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2062 app.update(|cx| {
2063 crate::init(cx);
2064 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2065 });
2066
2067 for (case, width, preview_zoom) in [
2068 ("default-16px", px(600.), 1.),
2071 ("narrow-16px", px(320.), 1.),
2073 ("zoom-1.25", px(600.), 1.25),
2075 ] {
2076 let (view_bounds, text_bounds, selected_text) = draw(&mut app, width, preview_zoom);
2077 assert!(
2078 text_bounds.len() > 8,
2079 "{case}: expected all markdown fragments"
2080 );
2081 assert_eq!(
2082 selected_text.trim(),
2083 "The same words with inline code.\nThe same words with inline code.\nThe same words with inline code.\nThe same words with inline code.\nHeading with inline code\nHeading with inline code\nthis is a test",
2084 "{case}: wrapped inline-code text was lost or duplicated"
2085 );
2086 let following = text_bounds.last().expect("following paragraph must paint");
2087 let painted_bottom = text_bounds
2088 .iter()
2089 .map(|bounds| bounds.bottom())
2090 .max()
2091 .expect("markdown must paint text");
2092 assert!(
2098 text_bounds[..text_bounds.len() - 1]
2099 .iter()
2100 .all(|bounds| bounds.bottom() <= following.top()),
2101 "{case}: heading/list text painted through the following paragraph; following={following:?}, text_bounds={text_bounds:?}, view={view_bounds:?}"
2102 );
2103 assert!(
2104 painted_bottom <= view_bounds.bottom(),
2105 "{case}: TextView height did not reserve its painted text; painted_bottom={painted_bottom:?}, view={view_bounds:?}"
2106 );
2107 }
2108 }
2109
2110 #[test]
2114 fn inline_code_list_body_paint_origin_matches_plain_list_item() {
2115 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2116 use gpui::{TestApp, rems};
2117
2118 struct ListRoot {
2119 plain: Entity<TextViewState>,
2120 code: Entity<TextViewState>,
2121 preview_zoom: f32,
2122 }
2123
2124 impl Render for ListRoot {
2125 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2126 div()
2127 .w(px(600.))
2128 .text_size(rems(self.preview_zoom))
2129 .child(crate::TextSelectionLayer)
2130 .child(TextView::new(&self.plain).selectable(true))
2131 .child(TextView::new(&self.code).selectable(true))
2132 }
2133 }
2134
2135 fn draw(app: &mut TestApp, preview_zoom: f32) -> (Pixels, Pixels) {
2136 let mut window = app.open_window(|_, cx| ListRoot {
2137 plain: cx.new(|cx| TextViewState::markdown("- plain body words", cx)),
2138 code: cx.new(|cx| TextViewState::markdown("- plain `code` words", cx)),
2139 preview_zoom,
2140 });
2141 window.draw();
2142 app.run_until_parked();
2143 window.draw();
2144 window.read(|root, cx| {
2145 let first_body_line_top = |view: &Entity<TextViewState>| {
2146 let view = view.read(cx);
2147 let painted_line = view
2148 .selection_adapter
2149 .text_bounds()
2150 .into_iter()
2151 .next()
2152 .expect("list item should paint its first body line");
2153 painted_line.top() - view.bounds().top()
2154 };
2155 (
2156 first_body_line_top(&root.plain),
2157 first_body_line_top(&root.code),
2158 )
2159 })
2160 }
2161
2162 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2163 app.update(|cx| {
2164 crate::init(cx);
2165 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2166 });
2167 for (case, preview_zoom) in [("16px", 1.), ("zoom-1.25", 1.25), ("zoom-1.5", 1.5)] {
2168 let (plain_origin, code_origin) = draw(&mut app, preview_zoom);
2169 assert_eq!(
2170 code_origin, plain_origin,
2171 "{case}: code list body paint origin {code_origin:?} must match plain {plain_origin:?}"
2172 );
2173 }
2174 }
2175
2176 #[test]
2177 fn inline_code_fragment_does_not_paint_past_its_reserved_row() {
2178 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2179 use gpui::TestApp;
2180
2181 const TEXT_BACKGROUND: u32 = 0x20f0b0;
2182
2183 struct MarkdownRoot {
2184 text_view: Entity<TextViewState>,
2185 }
2186
2187 impl Render for MarkdownRoot {
2188 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2189 div()
2190 .w(px(640.))
2191 .text_size(px(17.9))
2192 .text_bg(gpui::rgb(TEXT_BACKGROUND))
2193 .child(TextView::new(&self.text_view))
2194 }
2195 }
2196
2197 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2198 app.update(|cx| {
2199 crate::init(cx);
2200 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2201 });
2202 let mut window = app.open_window(|_, cx| MarkdownRoot {
2203 text_view: cx.new(|cx| TextViewState::markdown("`main` starts the paragraph", cx)),
2204 });
2205 window.draw();
2206 app.run_until_parked();
2207 window.draw();
2208
2209 let (view_bounds, painted) = window.update(|root, window, cx| {
2210 let view_bounds = root
2211 .text_view
2212 .read(cx)
2213 .bounds()
2214 .scale(window.scale_factor());
2215 let text_background: gpui::Background = gpui::rgb(TEXT_BACKGROUND).into();
2216 let painted = window
2217 .painted_quads()
2218 .into_iter()
2219 .filter(|quad| quad.background == text_background)
2220 .map(|quad| quad.bounds)
2221 .collect::<Vec<_>>();
2222 (view_bounds, painted)
2223 });
2224
2225 assert!(
2226 !painted.is_empty(),
2227 "the inherited text background must make actual text paint observable"
2228 );
2229 assert!(
2230 painted
2231 .iter()
2232 .all(|bounds| bounds.bottom() <= view_bounds.bottom()),
2233 "an inline-code fragment wrapped a second time after InlineFlow reserved one row; \
2234 text background quads={painted:?}, reserved TextView bounds={view_bounds:?}"
2235 );
2236 }
2237
2238 #[gpui::test]
2239 fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
2240 cx.update(crate::init);
2241 let (_, cx) =
2242 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2243 let cx: &mut VisualTestContext = cx;
2244
2245 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2246
2247 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2248 }
2249
2250 #[gpui::test]
2251 fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
2252 cx.update(crate::init);
2253 let (_, cx) =
2254 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2255 let cx: &mut VisualTestContext = cx;
2256
2257 cx.simulate_mouse_down(
2258 point(px(10.), px(10.)),
2259 MouseButton::Right,
2260 Modifiers::default(),
2261 );
2262 cx.simulate_mouse_up(
2263 point(px(10.), px(10.)),
2264 MouseButton::Right,
2265 Modifiers::default(),
2266 );
2267
2268 assert_eq!(cx.opened_url(), None);
2269 }
2270
2271 #[gpui::test]
2272 fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
2273 use std::sync::{Arc, Mutex};
2274
2275 struct LinkRoot {
2276 text_view: Entity<TextViewState>,
2277 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
2278 }
2279
2280 impl Render for LinkRoot {
2281 fn render(
2282 &mut self,
2283 _window: &mut Window,
2284 _cx: &mut Context<Self>,
2285 ) -> impl IntoElement {
2286 let clicks = self.clicks.clone();
2287 div()
2288 .w(px(240.))
2289 .child(
2290 TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
2291 clicks.lock().unwrap().push((url.clone(), event.clone()));
2292 }),
2293 )
2294 }
2295 }
2296
2297 cx.update(crate::init);
2298 let clicks = Arc::new(Mutex::new(Vec::new()));
2299 let captured = clicks.clone();
2300 let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
2301 text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
2302 clicks,
2303 });
2304 let cx: &mut VisualTestContext = cx;
2305
2306 let mut modifiers = Modifiers::default();
2307 modifiers.control = true;
2308 cx.simulate_click(point(px(10.), px(10.)), modifiers);
2309 cx.simulate_mouse_down(
2310 point(px(10.), px(10.)),
2311 MouseButton::Middle,
2312 Modifiers::default(),
2313 );
2314 cx.simulate_mouse_up(
2315 point(px(10.), px(10.)),
2316 MouseButton::Middle,
2317 Modifiers::default(),
2318 );
2319 cx.simulate_mouse_down(
2320 point(px(10.), px(10.)),
2321 MouseButton::Right,
2322 Modifiers::default(),
2323 );
2324 cx.simulate_mouse_up(
2325 point(px(10.), px(10.)),
2326 MouseButton::Right,
2327 Modifiers::default(),
2328 );
2329
2330 let clicks = captured.lock().unwrap();
2331 assert_eq!(clicks.len(), 3);
2332 assert_eq!(clicks[0].0, "https://example.com");
2333 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
2334 assert!(clicks[0].1.modifiers().control);
2335 assert!(clicks[1].1.is_middle_click());
2336 assert!(clicks[2].1.is_right_click());
2337 assert_eq!(cx.opened_url(), None);
2338 }
2339
2340 #[gpui::test]
2341 fn inline_object_inherits_bold_and_link_clicks_without_opening_on_drag(
2342 cx: &mut TestAppContext,
2343 ) {
2344 use std::sync::{Arc, Mutex};
2345 struct Root {
2346 state: Entity<TextViewState>,
2347 clicks: Arc<Mutex<Vec<SharedString>>>,
2348 }
2349 impl Render for Root {
2350 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2351 let clicks = self.clicks.clone();
2352 let extensions = crate::text::MarkdownExtensions::default().plugin(
2353 crate::text::markdown_ext::TestInlinePlugin::new("math")
2354 .parse_with(|node, _| {
2355 matches!(node, markdown::mdast::Node::InlineMath(_)).then(|| {
2356 super::super::MarkdownNode::new("math", ()).text("formula")
2357 })
2358 })
2359 .render_with(|_, context, _, _| {
2360 assert_eq!(context.text_style().font_weight, gpui::FontWeight::BOLD);
2361 Some(super::super::InlineElement::new(div().child("formula")))
2362 }),
2363 );
2364 div().w(px(300.)).child(crate::TextSelectionLayer).child(
2365 TextView::new(&self.state)
2366 .markdown_extensions(extensions)
2367 .on_link_click(move |url, _, _, _| {
2368 clicks.lock().unwrap().push(url.clone())
2369 }),
2370 )
2371 }
2372 }
2373 cx.update(crate::init);
2374 let clicks = Arc::new(Mutex::new(Vec::new()));
2375 let captured = clicks.clone();
2376 let (root, cx) = cx.add_window_view(move |_, cx| Root {
2377 state: cx.new(|cx| TextViewState::markdown("**[$x$](https://example.com)**", cx)),
2378 clicks,
2379 });
2380 let cx: &mut VisualTestContext = cx;
2381 cx.run_until_parked();
2382 cx.update(|window, cx| {
2383 let _ = window.draw(cx);
2384 });
2385 let bounds = root.read_with(cx, |root, cx| {
2386 root.state.read(cx).selection_adapter.text_bounds()[0]
2387 });
2388 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
2389 cx.simulate_mouse_down(bounds.center(), button, Modifiers::default());
2390 cx.simulate_mouse_up(bounds.center(), button, Modifiers::default());
2391 }
2392 assert_eq!(captured.lock().unwrap().len(), 3);
2393 let start = point(bounds.left() + px(1.), bounds.center().y);
2394 let end = point(bounds.right() - px(1.), bounds.center().y);
2395 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
2396 cx.update(|window, cx| {
2397 let _ = window.draw(cx);
2398 });
2399 cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
2400 cx.update(|window, cx| {
2401 let _ = window.draw(cx);
2402 });
2403 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
2404 assert_eq!(captured.lock().unwrap().len(), 3);
2405 }
2406
2407 #[gpui::test]
2408 fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
2409 use std::sync::{Arc, Mutex};
2410
2411 struct LinkedImageRoot {
2412 text_view: Entity<TextViewState>,
2413 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
2414 }
2415
2416 impl Render for LinkedImageRoot {
2417 fn render(
2418 &mut self,
2419 _window: &mut Window,
2420 _cx: &mut Context<Self>,
2421 ) -> impl IntoElement {
2422 let clicks = self.clicks.clone();
2423 div().w(px(160.)).child(
2424 TextView::new(&self.text_view)
2425 .selectable(true)
2426 .on_link_click(move |url, event, _, _| {
2427 clicks.lock().unwrap().push((url.clone(), event.clone()));
2428 }),
2429 )
2430 }
2431 }
2432
2433 cx.update(crate::init);
2434 let clicks = Arc::new(Mutex::new(Vec::new()));
2435 let captured = clicks.clone();
2436 let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
2437 text_view: cx.new(|cx| {
2438 TextViewState::markdown(
2439 r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
2440 cx,
2441 )
2442 }),
2443 clicks,
2444 }
2445 );
2446 let cx: &mut VisualTestContext = cx;
2447 cx.run_until_parked();
2448 cx.update(|window, cx| {
2449 let _ = window.draw(cx);
2450 });
2451
2452 let inline_bounds = content.read_with(cx, |content, cx| {
2453 content.text_view.read(cx).selection_adapter.text_bounds()
2454 });
2455 assert!(
2456 inline_bounds.len() >= 2,
2457 "linked image needs text bounds on both sides: {inline_bounds:?}"
2458 );
2459 assert!(
2460 inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
2461 "linked image did not reserve the expected click target: {inline_bounds:?}"
2462 );
2463 let position = point(
2464 inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
2465 inline_bounds[0].top() + px(8.),
2466 );
2467 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
2468 cx.simulate_mouse_down(position, button, Modifiers::default());
2469 cx.simulate_mouse_up(position, button, Modifiers::default());
2470 }
2471
2472 let clicks = captured.lock().unwrap();
2473 assert_eq!(clicks.len(), 3);
2474 assert!(
2475 clicks
2476 .iter()
2477 .all(|(url, _)| url == "https://example.com/image-link")
2478 );
2479 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
2480 assert!(clicks[1].1.is_middle_click());
2481 assert!(clicks[2].1.is_right_click());
2482 assert_eq!(cx.opened_url(), None);
2483 }
2484
2485 #[gpui::test]
2486 fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
2487 cx.update(crate::init);
2488 let (view, cx) = cx
2489 .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
2490 let cx: &mut VisualTestContext = cx;
2491
2492 cx.simulate_mouse_down(
2493 point(px(10.), px(34.)),
2494 MouseButton::Left,
2495 Modifiers::default(),
2496 );
2497 cx.simulate_mouse_move(
2498 point(px(90.), px(34.)),
2499 Some(MouseButton::Left),
2500 Modifiers::default(),
2501 );
2502 cx.simulate_mouse_up(
2503 point(px(90.), px(34.)),
2504 MouseButton::Left,
2505 Modifiers::default(),
2506 );
2507
2508 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2509 assert!(
2510 selected_text.is_empty(),
2511 "unexpected selection: {selected_text:?}"
2512 );
2513 }
2514
2515 struct ClippedTallTextViewTestRoot {
2519 text_view: Entity<TextViewState>,
2520 }
2521
2522 impl ClippedTallTextViewTestRoot {
2523 fn new(cx: &mut Context<Self>) -> Self {
2524 let text_view =
2528 cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
2529 Self { text_view }
2530 }
2531 }
2532
2533 impl Render for ClippedTallTextViewTestRoot {
2534 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2535 div()
2536 .w(px(200.))
2537 .child(crate::TextSelectionLayer)
2538 .child(
2539 div()
2540 .h(px(40.))
2541 .overflow_hidden()
2542 .child(TextView::new(&self.text_view).selectable(true)),
2543 )
2544 .child(div().h(px(160.)))
2547 }
2548 }
2549
2550 #[gpui::test]
2559 fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
2560 cx.update(crate::init);
2561 let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
2562 let cx: &mut VisualTestContext = cx;
2563
2564 cx.run_until_parked();
2565 cx.update(|window, cx| {
2566 let _ = window.draw(cx);
2567 });
2568
2569 cx.simulate_mouse_down(
2573 point(px(2.), px(8.)),
2574 MouseButton::Left,
2575 Modifiers::default(),
2576 );
2577 cx.update(|window, cx| {
2578 let _ = window.draw(cx);
2579 });
2580 cx.simulate_mouse_move(
2581 point(px(180.), px(150.)),
2582 Some(MouseButton::Left),
2583 Modifiers::default(),
2584 );
2585 cx.update(|window, cx| {
2586 let _ = window.draw(cx);
2587 });
2588 cx.simulate_mouse_up(
2589 point(px(180.), px(150.)),
2590 MouseButton::Left,
2591 Modifiers::default(),
2592 );
2593 cx.update(|window, cx| {
2594 let _ = window.draw(cx);
2595 });
2596
2597 let selected_text =
2598 content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2599 assert!(
2600 selected_text.contains("delta"),
2601 "clipped-out text was not copied: {selected_text:?}"
2602 );
2603 assert!(
2604 selected_text.contains("charlie"),
2605 "clipped-out text was not copied: {selected_text:?}"
2606 );
2607 }
2608
2609 #[gpui::test]
2610 fn double_click_selects_word(cx: &mut TestAppContext) {
2611 cx.update(crate::init);
2612 let (view, cx) =
2613 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
2614
2615 let cx: &mut VisualTestContext = cx;
2616 cx.run_until_parked();
2617 cx.update(|window, cx| {
2618 let _ = window.draw(cx);
2619 });
2620 let position = point(px(10.), px(16.));
2621 cx.simulate_event(MouseDownEvent {
2622 position,
2623 modifiers: Modifiers::default(),
2624 button: MouseButton::Left,
2625 click_count: 2,
2626 first_mouse: false,
2627 });
2628 cx.simulate_event(MouseUpEvent {
2629 position,
2630 modifiers: Modifiers::default(),
2631 button: MouseButton::Left,
2632 click_count: 2,
2633 });
2634 cx.update(|window, cx| {
2635 let _ = window.draw(cx);
2636 });
2637
2638 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2639 assert_eq!(selected_text.trim(), "quick");
2640 }
2641
2642 #[gpui::test]
2643 fn long_press_selects_word_then_drag_extends_selection(cx: &mut TestAppContext) {
2644 struct TouchRoot {
2645 text_view: Entity<TextViewState>,
2646 }
2647 impl Render for TouchRoot {
2648 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2649 div()
2650 .w(px(300.))
2651 .child(crate::TextSelectionLayer)
2652 .child(TextView::new(&self.text_view).selectable(true))
2653 }
2654 }
2655 cx.update(crate::init);
2656 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
2657 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
2658 });
2659 cx.run_until_parked();
2660 cx.update(|window, cx| {
2661 let _ = window.draw(cx);
2662 });
2663 let start_position = point(px(10.), px(16.));
2664 cx.simulate_event(gpui::LongPressEvent {
2665 phase: gpui::TouchPhase::Started,
2666 start_position,
2667 position: start_position,
2668 });
2669 cx.update(|window, cx| {
2670 let _ = window.draw(cx);
2671 });
2672 assert_eq!(
2673 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
2674 .trim(),
2675 "quick"
2676 );
2677 for phase in [gpui::TouchPhase::Moved, gpui::TouchPhase::Ended] {
2678 cx.simulate_event(gpui::LongPressEvent {
2679 phase,
2680 start_position,
2681 position: point(px(220.), px(16.)),
2682 });
2683 }
2684 cx.update(|window, cx| {
2685 let _ = window.draw(cx);
2686 });
2687 assert_eq!(
2688 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
2689 .trim(),
2690 "quick select value"
2691 );
2692 }
2693
2694 #[gpui::test]
2695 fn long_press_release_keeps_handles_which_drag_the_selection(cx: &mut TestAppContext) {
2696 use crate::{SelectionEdge, TextSelection};
2697
2698 struct TouchRoot {
2699 text_view: Entity<TextViewState>,
2700 }
2701 impl Render for TouchRoot {
2702 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2703 div()
2704 .w(px(300.))
2705 .child(crate::TextSelectionLayer)
2706 .child(TextView::new(&self.text_view).selectable(true))
2707 }
2708 }
2709 cx.update(crate::init);
2710 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
2711 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
2712 });
2713 let draw = |cx: &mut VisualTestContext| {
2714 cx.update(|window, cx| {
2715 let _ = window.draw(cx);
2716 });
2717 };
2718 let selected = |cx: &mut VisualTestContext| {
2719 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
2720 .trim()
2721 .to_string()
2722 };
2723 cx.run_until_parked();
2724 draw(cx);
2725
2726 let start_position = point(px(70.), px(16.));
2727 for phase in [gpui::TouchPhase::Started, gpui::TouchPhase::Ended] {
2728 cx.simulate_event(gpui::LongPressEvent {
2729 phase,
2730 start_position,
2731 position: start_position,
2732 });
2733 draw(cx);
2734 }
2735 assert_eq!(selected(cx), "select");
2736 let snapshot = cx
2737 .update(|window, cx| TextSelection::touch_selection(window, cx))
2738 .expect("a released long press keeps its handles");
2739 assert!(snapshot.is_menu_open());
2740 assert!(!snapshot.is_empty());
2741 assert!(snapshot.start().left() < snapshot.end().left());
2742
2743 let end = snapshot.end();
2746 let finger = point(end.left(), end.bottom() + px(20.));
2747 cx.update(|window, cx| {
2748 TextSelection::begin_edge_drag(SelectionEdge::End, finger, window, cx);
2749 });
2750 draw(cx);
2751 let snapshot = cx
2752 .update(|window, cx| TextSelection::touch_selection(window, cx))
2753 .unwrap();
2754 assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
2755 assert!(!snapshot.is_menu_open());
2756 cx.update(|window, cx| {
2757 TextSelection::update_edge_drag(point(px(290.), finger.y), window, cx);
2758 });
2759 draw(cx);
2760 assert_eq!(selected(cx), "select value");
2761 cx.update(|window, cx| TextSelection::end_edge_drag(window, cx));
2762 draw(cx);
2763 let snapshot = cx
2764 .update(|window, cx| TextSelection::touch_selection(window, cx))
2765 .unwrap();
2766 assert!(snapshot.is_menu_open());
2767 assert_eq!(snapshot.dragging(), None);
2768 let select_start = snapshot.start().left();
2769
2770 cx.update(|_, cx| {
2773 view.update(cx, |root, cx| {
2774 root.text_view.update(cx, |state, cx| state.select_all(cx));
2775 });
2776 });
2777 draw(cx);
2778 assert_eq!(selected(cx), "quick select value");
2779 let snapshot = cx
2780 .update(|window, cx| TextSelection::touch_selection(window, cx))
2781 .expect("select all keeps the touch selection");
2782 let start = snapshot.start();
2783 cx.update(|window, cx| {
2784 TextSelection::begin_edge_drag(SelectionEdge::Start, start.origin, window, cx);
2785 TextSelection::update_edge_drag(point(select_start, start.origin.y), window, cx);
2786 TextSelection::end_edge_drag(window, cx);
2787 });
2788 draw(cx);
2789 assert_eq!(selected(cx), "select value");
2790
2791 let menu = gpui::Bounds::new(point(px(0.), px(200.)), gpui::size(px(120.), px(32.)));
2794 cx.update(|window, cx| TextSelection::register_touch_ui(menu, window, cx));
2795 cx.simulate_event(MouseDownEvent {
2796 position: point(px(10.), px(210.)),
2797 modifiers: Modifiers::default(),
2798 button: MouseButton::Left,
2799 click_count: 1,
2800 first_mouse: false,
2801 });
2802 assert_eq!(selected(cx), "select value");
2803 cx.simulate_event(MouseDownEvent {
2804 position: point(px(10.), px(16.)),
2805 modifiers: Modifiers::default(),
2806 button: MouseButton::Left,
2807 click_count: 1,
2808 first_mouse: false,
2809 });
2810 cx.simulate_event(MouseUpEvent {
2811 position: point(px(10.), px(16.)),
2812 modifiers: Modifiers::default(),
2813 button: MouseButton::Left,
2814 click_count: 1,
2815 });
2816 draw(cx);
2817 assert!(
2818 cx.update(|window, cx| TextSelection::touch_selection(window, cx))
2819 .is_none()
2820 );
2821 }
2822
2823 #[gpui::test]
2824 fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
2825 cx.update(crate::init);
2826 let (view, cx) =
2827 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
2828
2829 let cx: &mut VisualTestContext = cx;
2830 cx.run_until_parked();
2831 cx.update(|window, cx| {
2832 let _ = window.draw(cx);
2833 });
2834
2835 let position = point(px(10.), px(10.));
2836 cx.simulate_event(MouseDownEvent {
2837 position,
2838 modifiers: Modifiers::default(),
2839 button: MouseButton::Left,
2840 click_count: 3,
2841 first_mouse: false,
2842 });
2843 cx.simulate_event(MouseUpEvent {
2844 position,
2845 modifiers: Modifiers::default(),
2846 button: MouseButton::Left,
2847 click_count: 3,
2848 });
2849 cx.update(|window, cx| {
2850 let _ = window.draw(cx);
2851 });
2852
2853 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2854 assert_eq!(selected_text.trim(), "quick select value");
2855 }
2856
2857 #[gpui::test]
2863 fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
2864 use gpui::{ListAlignment, ListState, list};
2865
2866 const ITEMS: &[&str] = &[
2867 "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
2868 "Short.",
2869 "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
2870 "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
2871 "Only one line.",
2872 "**Bold**: medium length text with `code` mixed with regular words.",
2873 "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
2874 "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
2875 ];
2876 let n = 40usize;
2877
2878 struct ListRoot {
2879 state: ListState,
2880 }
2881 impl Render for ListRoot {
2882 fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2883 div().w(px(360.)).h(px(500.)).child(
2884 list(self.state.clone(), |ix, _w, _cx| {
2885 div()
2886 .w_full()
2887 .child(TextView::markdown(
2888 ("md", ix as u64),
2889 ITEMS[ix % ITEMS.len()],
2890 ))
2891 .into_any_element()
2892 })
2893 .size_full(),
2894 )
2895 }
2896 }
2897
2898 cx.update(crate::init);
2899 let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
2900 let probe = state.clone();
2901 let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
2902 let cx: &mut VisualTestContext = cx;
2903
2904 cx.run_until_parked();
2905 cx.update(|w, cx| {
2906 let _ = w.draw(cx);
2907 });
2908 cx.run_until_parked();
2909 cx.update(|w, cx| {
2910 let _ = w.draw(cx);
2911 });
2912
2913 let total = |p: &ListState| {
2914 f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
2915 };
2916 let mut totals = vec![total(&probe)];
2917 for _ in 0..20 {
2918 probe.scroll_by(px(150.));
2919 cx.update(|w, cx| {
2920 let _ = w.draw(cx);
2921 });
2922 cx.run_until_parked();
2923 totals.push(total(&probe));
2924 }
2925 let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
2926 let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
2927 println!(
2928 "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
2929 max - min
2930 );
2931 assert!(
2932 (max - min) < 2.0,
2933 "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
2934 );
2935 }
2936}