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
18pub(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#[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 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn with_style(mut self, style: TextViewStyle) -> Self {
43 self.style = Some(style);
44 self
45 }
46
47 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 pub fn install(self, cx: &mut App) {
58 cx.set_global(self);
59 }
60
61 pub fn global(cx: &App) -> Self {
63 cx.try_global::<Self>().cloned().unwrap_or_default()
64 }
65
66 pub fn has_code_block_highlighter(&self) -> bool {
68 self.code_block_highlighter.is_some()
69 }
70}
71
72pub(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#[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
134pub 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 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 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 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 pub fn style(mut self, style: TextViewStyle) -> Self {
223 self.text_view_style = Some(style);
224 self
225 }
226
227 pub fn selectable(mut self, selectable: bool) -> Self {
229 self.selectable = selectable;
230 self
231 }
232
233 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
238 self.selection_format = selection_format;
239 self
240 }
241
242 pub fn scrollable(mut self, scrollable: bool) -> Self {
255 self.scrollable = scrollable;
256 self
257 }
258
259 pub fn max_lines(mut self, max_lines: usize) -> Self {
278 self.max_lines = Some(max_lines);
279 self
280 }
281
282 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 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 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 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 pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self {
338 self.markdown_extensions = Arc::new(extensions);
339 self
340 }
341
342 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 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 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 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 clip_bottom: Option<Pixels>,
413}
414
415const CLIP_EPSILON: Pixels = px(1.);
418
419fn 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 keep(span.bottom, span.line_height);
440 }
441
442 last
443}
444
445fn 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 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 return None;
482 };
483
484 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 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 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 .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 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 let clipped = content_bottom > bounds.bottom() + px(1.);
661 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 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 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 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 #[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 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  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 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 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 LineSpan {
1261 top: px(0.),
1262 bottom: px(60.),
1263 line_height: px(20.),
1264 },
1265 LineSpan {
1267 top: px(68.),
1268 bottom: px(128.),
1269 line_height: px(20.),
1270 },
1271 ];
1272
1273 let below = px(400.);
1275
1276 assert_eq!(
1278 line_safe_clip_bottom(&spans, px(100.), below),
1279 Some(px(88.))
1280 );
1281
1282 assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
1284
1285 assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
1288
1289 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 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 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 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 assert_eq!(
1341 line_safe_clip_bottom(&rows, px(148.), px(400.)),
1342 Some(px(126.))
1343 );
1344 }
1345
1346 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 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 struct ClippedTallTextViewTestRoot {
1720 text_view: Entity<TextViewState>,
1721 }
1722
1723 impl ClippedTallTextViewTestRoot {
1724 fn new(cx: &mut Context<Self>) -> Self {
1725 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 .child(div().h(px(160.)))
1748 }
1749 }
1750
1751 #[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 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 #[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}