1use std::{ops::Range, rc::Rc, sync::Arc};
2
3use gpui::prelude::FluentBuilder as _;
4use gpui::{
5 AnyElement, App, Bounds, ClickEvent, ContentMask, Element, ElementId, Entity, Global,
6 GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
7 LayoutId, MouseButton, ParentElement, Pixels, Refineable as _, SharedString, StyleRefinement,
8 Styled, Window, div, point, px,
9};
10
11use crate::StyledExt;
12use crate::text::TextViewFormat;
13use crate::text::markdown_ext::{MarkdownExtensions, MarkdownNode, MarkdownPlugin};
14use crate::text::node::{CodeBlock, TableData};
15use crate::text::range_highlight::{PendingReveal, RevealProgress};
16use crate::text::state::{LineSpan, SelectionFormat, TextViewState};
17use crate::text::stream_fade::TextViewMotion;
18use crate::{GlobalState, TextSelection, text::TextViewStyle};
19
20pub(crate) type CodeBlockActionsFn =
22 dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync;
23
24pub(crate) type CodeBlockHighlighterFn =
25 dyn Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync;
26
27#[derive(Clone, Default)]
30pub struct TextViewDefaults {
31 style: Option<TextViewStyle>,
32 code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
33 inherit_text_color: bool,
34}
35
36impl Global for TextViewDefaults {}
37
38impl TextViewDefaults {
39 pub fn new() -> Self {
41 Self::default()
42 }
43
44 pub fn with_style(mut self, style: TextViewStyle) -> Self {
46 self.style = Some(style);
47 self
48 }
49
50 pub fn with_code_block_highlighter<F>(mut self, highlighter: F) -> Self
52 where
53 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
54 {
55 self.code_block_highlighter = Some(Arc::new(highlighter));
56 self
57 }
58
59 pub fn with_inherit_text_color(mut self, inherit: bool) -> Self {
68 self.inherit_text_color = inherit;
69 self
70 }
71
72 pub fn install(self, cx: &mut App) {
74 cx.set_global(self);
75 }
76
77 pub fn global(cx: &App) -> Self {
79 cx.try_global::<Self>().cloned().unwrap_or_default()
80 }
81
82 pub fn has_code_block_highlighter(&self) -> bool {
84 self.code_block_highlighter.is_some()
85 }
86
87 pub fn inherit_text_color(&self) -> bool {
89 self.inherit_text_color
90 }
91}
92
93pub(crate) type TableActionsFn =
95 dyn Fn(&TableData, &mut Window, &mut App) -> AnyElement + Send + Sync;
96
97pub(crate) type ImageSourceFn = dyn Fn(&gpui::SharedUri) -> gpui::ImageSource + Send + Sync;
98
99pub(crate) type LinkClickHandlerFn =
100 dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync;
101
102pub(crate) type RevealHandlerFn = dyn Fn(Bounds<Pixels>, &mut Window, &mut App);
105
106pub(crate) fn handle_link_click(
107 handler: &Option<Arc<LinkClickHandlerFn>>,
108 url: SharedString,
109 event: ClickEvent,
110 window: &mut Window,
111 cx: &mut App,
112) {
113 if let Some(handler) = handler {
114 handler(&url, &event, window, cx);
115 } else if match &event {
116 ClickEvent::Mouse(click) => {
117 matches!(click.up.button, MouseButton::Left | MouseButton::Middle)
118 }
119 ClickEvent::Keyboard(_) => true,
120 ClickEvent::Touch(click) => !click.long_press,
121 } {
122 cx.open_url(&url);
123 }
124}
125
126#[derive(Clone)]
143pub struct TextView {
144 id: ElementId,
145 format: Option<TextViewFormat>,
146 text: Option<SharedString>,
147 pub(crate) state: Option<Entity<TextViewState>>,
148 text_view_style: Option<TextViewStyle>,
149 style: StyleRefinement,
150 selectable: bool,
151 selection_format: SelectionFormat,
152 scrollable: bool,
153 max_lines: Option<usize>,
154 code_block_actions: Option<Arc<CodeBlockActionsFn>>,
155 code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
156 table_actions: Option<Arc<TableActionsFn>>,
157 link_click_handler: Option<Arc<LinkClickHandlerFn>>,
158 image_source: Option<Arc<ImageSourceFn>>,
159 reveal_handler: Option<Rc<RevealHandlerFn>>,
160 markdown_extensions: Arc<MarkdownExtensions>,
161 motion: Option<TextViewMotion>,
162}
163
164pub trait TextViewPlugin {
166 fn setup(self, text_view: TextView) -> TextView;
167}
168
169impl<P> TextViewPlugin for P
170where
171 P: MarkdownPlugin,
172{
173 fn setup(self, mut text_view: TextView) -> TextView {
174 let extensions = Arc::make_mut(&mut text_view.markdown_extensions);
175 let current = std::mem::take(extensions);
176 *extensions = current.plugin(self);
177 text_view
178 }
179}
180
181impl Styled for TextView {
182 fn style(&mut self) -> &mut StyleRefinement {
183 &mut self.style
184 }
185}
186
187impl TextView {
188 pub fn new(state: &Entity<TextViewState>) -> Self {
190 Self {
191 id: ElementId::Name(state.entity_id().to_string().into()),
192 state: Some(state.clone()),
193 format: None,
194 text: None,
195 text_view_style: None,
196 style: StyleRefinement::default(),
197 selectable: true,
198 selection_format: SelectionFormat::default(),
199 scrollable: false,
200 max_lines: None,
201 code_block_actions: None,
202 code_block_highlighter: None,
203 table_actions: None,
204 link_click_handler: None,
205 image_source: None,
206 reveal_handler: None,
207 markdown_extensions: Arc::default(),
208 motion: None,
209 }
210 }
211
212 pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
214 Self {
215 id: id.into(),
216 format: Some(TextViewFormat::Markdown),
217 text: Some(markdown.into()),
218 text_view_style: None,
219 style: StyleRefinement::default(),
220 state: None,
221 selectable: true,
222 selection_format: SelectionFormat::default(),
223 scrollable: false,
224 max_lines: None,
225 code_block_actions: None,
226 code_block_highlighter: None,
227 table_actions: None,
228 link_click_handler: None,
229 image_source: None,
230 reveal_handler: None,
231 markdown_extensions: Arc::default(),
232 motion: None,
233 }
234 }
235
236 pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
238 Self {
239 id: id.into(),
240 format: Some(TextViewFormat::Html),
241 text: Some(html.into()),
242 text_view_style: None,
243 style: StyleRefinement::default(),
244 state: None,
245 selectable: true,
246 selection_format: SelectionFormat::default(),
247 scrollable: false,
248 max_lines: None,
249 code_block_actions: None,
250 code_block_highlighter: None,
251 table_actions: None,
252 link_click_handler: None,
253 image_source: None,
254 reveal_handler: None,
255 markdown_extensions: Arc::default(),
256 motion: None,
257 }
258 }
259
260 pub fn image_source<F>(mut self, resolver: F) -> Self
266 where
267 F: Fn(&gpui::SharedUri) -> gpui::ImageSource + Send + Sync + 'static,
268 {
269 self.image_source = Some(Arc::new(resolver));
270 self
271 }
272
273 pub fn style(mut self, style: TextViewStyle) -> Self {
275 self.text_view_style = Some(style);
276 self
277 }
278
279 pub fn selectable(mut self, selectable: bool) -> Self {
281 self.selectable = selectable;
282 self
283 }
284
285 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
290 self.selection_format = selection_format;
291 self
292 }
293
294 pub fn scrollable(mut self, scrollable: bool) -> Self {
307 self.scrollable = scrollable;
308 self
309 }
310
311 pub fn max_lines(mut self, max_lines: usize) -> Self {
330 self.max_lines = Some(max_lines);
331 self
332 }
333
334 pub fn code_block_actions<F, E>(mut self, f: F) -> Self
339 where
340 F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
341 E: IntoElement,
342 {
343 self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
344 f(&code_block, window, cx).into_any_element()
345 }));
346 self
347 }
348
349 pub fn code_block_highlighter<F>(mut self, highlighter: F) -> Self
354 where
355 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
356 {
357 self.code_block_highlighter = Some(Arc::new(highlighter));
358 self
359 }
360
361 #[doc(hidden)]
368 pub fn shared_code_block_highlighter(
369 mut self,
370 highlighter: Arc<
371 dyn Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync,
372 >,
373 ) -> Self {
374 self.code_block_highlighter = Some(highlighter);
375 self
376 }
377
378 pub fn table_actions<F, E>(mut self, f: F) -> Self
383 where
384 F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
385 E: IntoElement,
386 {
387 self.table_actions = Some(Arc::new(move |table, window, cx| {
388 f(table, window, cx).into_any_element()
389 }));
390 self
391 }
392
393 pub fn on_link_click<F>(mut self, handler: F) -> Self
398 where
399 F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
400 {
401 self.link_click_handler = Some(Arc::new(handler));
402 self
403 }
404
405 pub fn on_reveal<F>(mut self, handler: F) -> Self
414 where
415 F: Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
416 {
417 self.reveal_handler = Some(Rc::new(handler));
418 self
419 }
420
421 pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self {
423 self.markdown_extensions = Arc::new(extensions);
424 self
425 }
426
427 pub fn motion(mut self, motion: TextViewMotion) -> Self {
430 self.motion = Some(motion);
431 self
432 }
433
434 pub fn markdown_mdx(mut self) -> Self {
439 let extensions = Arc::make_mut(&mut self.markdown_extensions);
440 *extensions = extensions.clone().mdx();
441 self
442 }
443
444 pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
450 where
451 F: for<'a> Fn(
452 &markdown::mdast::Node,
453 &crate::text::MarkdownParseContext<'a>,
454 ) -> Option<MarkdownNode>
455 + Send
456 + Sync
457 + 'static,
458 {
459 Arc::make_mut(&mut self.markdown_extensions).push_block_parser(parser);
460 self
461 }
462
463 pub fn markdown_block_renderer<F, E>(
465 mut self,
466 name: impl Into<SharedString>,
467 renderer: F,
468 ) -> Self
469 where
470 F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
471 E: IntoElement,
472 {
473 Arc::make_mut(&mut self.markdown_extensions).push_block_renderer(name, renderer);
474 self
475 }
476
477 pub fn plugin<P>(self, plugin: P) -> Self
479 where
480 P: TextViewPlugin,
481 {
482 plugin.setup(self)
483 }
484}
485
486impl IntoElement for TextView {
487 type Element = Self;
488
489 fn into_element(self) -> Self::Element {
490 self
491 }
492}
493
494pub struct TextViewLayoutState {
495 state: Entity<TextViewState>,
496 element: AnyElement,
497}
498
499pub struct TextViewPrepaintState {
500 hitbox: Hitbox,
501 clip_bottom: Option<Pixels>,
505 touch_handles: crate::TouchHandleLayout,
507}
508
509const CLIP_EPSILON: Pixels = px(1.);
512
513fn last_line_bottom_above(spans: &[LineSpan], y: Pixels) -> Option<(Pixels, Pixels)> {
516 let mut last: Option<(Pixels, Pixels)> = None;
517 let mut keep = |bottom: Pixels, line_height: Pixels| {
518 if bottom <= y + CLIP_EPSILON && last.is_none_or(|(last, _)| bottom > last) {
519 last = Some((bottom, line_height));
520 }
521 };
522
523 for span in spans {
524 if span.line_height <= px(0.) {
525 continue;
526 }
527 let mut bottom = span.top + span.line_height;
528 while bottom <= span.bottom + CLIP_EPSILON {
529 keep(bottom, span.line_height);
530 bottom += span.line_height;
531 }
532 keep(span.bottom, span.line_height);
534 }
535
536 last
537}
538
539fn line_safe_clip_bottom(
549 spans: &[LineSpan],
550 box_bottom: Pixels,
551 content_bottom: Pixels,
552) -> Option<Pixels> {
553 let mut clip = box_bottom;
554
555 for span in spans {
556 if span.line_height <= px(0.)
557 || span.top >= box_bottom
558 || span.bottom <= box_bottom + CLIP_EPSILON
559 {
560 continue;
561 }
562 let whole_lines = ((box_bottom - span.top) / span.line_height).floor();
563 let line_top = span.top + span.line_height * whole_lines;
564 if line_top < box_bottom - CLIP_EPSILON {
566 clip = clip.min(line_top);
567 }
568 }
569
570 let Some((last_line_bottom, line_height)) = last_line_bottom_above(spans, clip) else {
571 return None;
576 };
577
578 if content_bottom > box_bottom + CLIP_EPSILON {
582 let strip = clip - last_line_bottom;
583 if strip > CLIP_EPSILON && strip < line_height {
584 clip = last_line_bottom;
585 }
586 }
587
588 (clip < box_bottom - CLIP_EPSILON).then_some(clip)
589}
590
591impl Element for TextView {
592 type RequestLayoutState = TextViewLayoutState;
593 type PrepaintState = TextViewPrepaintState;
594
595 fn id(&self) -> Option<ElementId> {
596 Some(self.id.clone())
597 }
598
599 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
600 None
601 }
602
603 fn request_layout(
604 &mut self,
605 _: Option<&GlobalElementId>,
606 _: Option<&InspectorElementId>,
607 window: &mut Window,
608 cx: &mut App,
609 ) -> (LayoutId, Self::RequestLayoutState) {
610 let state = if let Some(state) = self.state.clone() {
611 state
612 } else {
613 let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
614 let default_text = self.text.clone().unwrap_or_default();
615
616 let state = window.use_keyed_state(
617 SharedString::from(format!("{}/state", self.id)),
618 cx,
619 move |_, cx| {
620 if default_format == TextViewFormat::Markdown {
621 TextViewState::markdown(default_text.as_str(), cx)
622 } else {
623 TextViewState::html(default_text.as_str(), cx)
624 }
625 },
626 );
627 self.state = Some(state.clone());
628 state
629 };
630
631 let max_lines = self.max_lines.filter(|_| !self.scrollable);
634
635 let defaults = cx.try_global::<TextViewDefaults>();
638 let theme_style;
639 let mut text_view_style = match (
640 &self.text_view_style,
641 defaults.and_then(|d| d.style.as_ref()),
642 ) {
643 (Some(style), _) | (None, Some(style)) => style,
644 (None, None) => {
645 theme_style = TextViewStyle::from_theme(&crate::Theme::global(cx));
646 &theme_style
647 }
648 };
649 let surface_style;
652 let mut on_inverted_surface = false;
653 if self.text_view_style.is_none() && defaults.is_some_and(|d| d.inherit_text_color) {
654 let color = self
655 .style
656 .text
657 .color
658 .unwrap_or_else(|| window.text_style().color);
659 if color != text_view_style.foreground() {
660 on_inverted_surface = text_view_style.is_inverted_by(color);
661 surface_style = text_view_style.on_text_color(color);
662 text_view_style = &surface_style;
663 }
664 }
665 let foreground = text_view_style.foreground();
666 let text_view_style = (*state.read(cx).text_view_style != *text_view_style)
667 .then(|| Arc::new(text_view_style.clone()));
668 let code_block_highlighter = self.code_block_highlighter.clone().or_else(|| {
669 defaults
670 .filter(|_| !on_inverted_surface)
671 .and_then(|d| d.code_block_highlighter.clone())
672 });
673
674 state.update(cx, |state, cx| {
675 if text_view_style.is_some()
676 || match (&state.code_block_highlighter, &code_block_highlighter) {
677 (Some(previous), Some(next)) => !Arc::ptr_eq(previous, next),
678 (None, None) => false,
679 _ => true,
680 }
681 {
682 state.preserve_selection_for_reflow();
683 }
684 state.code_block_actions = self.code_block_actions.clone();
685 state.code_block_highlighter = code_block_highlighter;
686 state.table_actions = self.table_actions.clone();
687 state.link_click_handler = self.link_click_handler.clone();
688 state.image_source = self.image_source.clone();
689 state.set_markdown_extensions(self.markdown_extensions.clone(), cx);
690 if let Some(motion) = &self.motion {
691 state.set_motion(motion.clone());
692 }
693 state.selectable = self.selectable;
694 state.selection_format = self.selection_format;
695 state.scrollable = self.scrollable;
696 state.max_lines = max_lines;
697 if let Some(text_view_style) = text_view_style {
698 state.text_view_style = text_view_style;
699 }
700
701 if let Some(text) = &self.text {
702 state.set_element_text(text, cx);
703 }
704 });
705
706 let focus_handle = state.read(cx).focus_handle.clone();
707 let list_state = state.read(cx).list_state.clone();
708 let max_lines_cap = max_lines.map(|max_lines| {
712 let mut text_style = window.text_style();
713 text_style.refine(&self.style.text);
714 text_style.line_height_in_pixels(window.rem_size()) * max_lines as f32
715 });
716
717 let mut el = div()
718 .id(("text-view-scroll", state.entity_id()))
719 .key_context("TextView")
720 .track_focus(&focus_handle)
721 .when(self.scrollable, |this| this.size_full())
722 .when_some(max_lines_cap, |this, cap| this.max_h(cap).overflow_hidden())
723 .relative()
724 .text_color(foreground)
725 .on_action(move |_: &crate::input::Copy, window, cx| {
726 let text = TextSelection::selected_text(window, cx).trim().to_string();
727 if text.is_empty() {
728 cx.propagate();
729 return;
730 }
731 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
732 })
733 .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
734 .child(state.clone())
735 .when(self.scrollable, |this| {
738 this.child(
739 div().absolute().inset_0().child(
740 crate::Scrollbar::vertical(&list_state)
741 .id(("text-view-scrollbar", state.entity_id()))
742 .viewport_from_layout(),
743 ),
744 )
745 })
746 .refine_style(&self.style)
747 .into_any_element();
748 let layout_id = el.request_layout(window, cx);
749 (layout_id, TextViewLayoutState { state, element: el })
750 }
751
752 fn prepaint(
753 &mut self,
754 _: Option<&GlobalElementId>,
755 _: Option<&InspectorElementId>,
756 bounds: Bounds<Pixels>,
757 request_layout: &mut Self::RequestLayoutState,
758 window: &mut Window,
759 cx: &mut App,
760 ) -> Self::PrepaintState {
761 let state = request_layout.state.clone();
762 let max_lines_active = state.read(cx).max_lines.is_some();
763 if max_lines_active {
764 if let Ok(mut line_spans) = state.read(cx).line_spans.lock() {
765 line_spans.clear();
766 }
767 GlobalState::global(cx).push_text_view_state(state.clone());
770 }
771 request_layout.element.prepaint(window, cx);
772 if max_lines_active {
773 GlobalState::global(cx).pop_text_view_state();
774 }
775
776 let mut clip_bottom = None;
777 if max_lines_active {
778 let (line_spans, content_bottom) = {
779 let state = state.read(cx);
780 (
781 state
782 .line_spans
783 .lock()
784 .map(|spans| spans.clone())
785 .unwrap_or_default(),
786 state.bounds().bottom(),
787 )
788 };
789 let clipped = content_bottom > bounds.bottom() + px(1.);
793 if state.read(cx).clamped != clipped {
796 state.update(cx, |state, cx| {
797 state.clamped = clipped;
798 cx.notify();
799 });
800 }
801 if clipped {
802 clip_bottom = line_safe_clip_bottom(&line_spans, bounds.bottom(), content_bottom);
803 }
804 }
805
806 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
807 let touch_handles = if self.selectable {
809 state
810 .read(cx)
811 .selection_adapter
812 .prepaint_touch_handles(window, cx)
813 } else {
814 crate::TouchHandleLayout::default()
815 };
816 TextViewPrepaintState {
817 hitbox,
818 clip_bottom,
819 touch_handles,
820 }
821 }
822
823 fn paint(
824 &mut self,
825 _: Option<&GlobalElementId>,
826 _: Option<&InspectorElementId>,
827 bounds: Bounds<Pixels>,
828 request_layout: &mut Self::RequestLayoutState,
829 prepaint: &mut Self::PrepaintState,
830 window: &mut Window,
831 cx: &mut App,
832 ) {
833 let state = &request_layout.state;
834 if self.selectable {
835 state.read(cx).selection_adapter.begin_frame();
836 }
837
838 GlobalState::global(cx).push_text_view_state(state.clone());
839 if let Some(clip_bottom) = prepaint.clip_bottom {
840 let mask = ContentMask {
843 bounds: Bounds::from_corners(bounds.origin, point(bounds.right(), clip_bottom)),
844 };
845 window.with_content_mask(Some(mask), |window| {
846 request_layout.element.paint(window, cx);
847 });
848 } else {
849 request_layout.element.paint(window, cx);
850 }
851 GlobalState::global(cx).pop_text_view_state();
852
853 if state.read(cx).pending_reveal.is_some() {
856 let progress = state.update(cx, |state, _| {
857 state.pending_reveal.as_mut().map(PendingReveal::progress)
858 });
859 match progress {
860 Some(RevealProgress::Shown) => {
861 state.update(cx, |state, _| state.pending_reveal = None);
862 }
863 Some(RevealProgress::Hidden(line)) => {
864 if let Some(handler) = &self.reveal_handler {
865 handler(line, window, cx);
866 }
867 }
868 Some(RevealProgress::NotLaidOut) | None => {}
869 }
870 }
871
872 if self.selectable {
873 let (adapter, scroll_offset, content_bounds, self_scroll, handle_color) = {
874 let state = state.read(cx);
875 (
876 state.selection_adapter.clone(),
877 state.scroll_offset(),
878 state.bounds(),
879 state.scrollable,
880 state.text_view_style.selection().alpha(1.),
881 )
882 };
883 let document_order = GlobalState::global(cx).next_selection_document_order();
884 adapter.register(
885 prepaint.hitbox.clone(),
886 content_bounds,
887 scroll_offset,
888 document_order,
889 self_scroll,
890 window,
891 cx,
892 );
893 adapter.paint_touch_handles(&prepaint.touch_handles, handle_color, window, cx);
896 }
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use std::sync::{
903 Arc,
904 atomic::{AtomicUsize, Ordering},
905 };
906
907 use super::{TextView, TextViewPlugin};
908 use crate::text::{TableData, TextViewState, TextViewStyle};
909 use gpui::{
910 AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
911 Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
912 Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
913 TestAppContext, VisualTestContext, Window, div, point, prelude::FluentBuilder as _, px,
914 rems,
915 };
916
917 struct TextViewTestRoot {
918 text_view: Entity<TextViewState>,
919 }
920
921 struct InlineHoverTestRoot {
922 view: Entity<TextViewState>,
923 builds: Arc<AtomicUsize>,
924 format: crate::text::SelectionFormat,
925 }
926
927 struct InlineHoverCard;
928 impl Render for InlineHoverCard {
929 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
930 div()
931 .debug_selector(|| "inline-test-card".into())
932 .w(px(120.))
933 .h(px(40.))
934 .child("Member profile")
935 }
936 }
937
938 impl Render for InlineHoverTestRoot {
939 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
940 let builds = self.builds.clone();
941 div()
942 .pl(px(150.))
943 .w(px(300.))
944 .text_size(px(16.))
945 .child(crate::TextSelectionLayer)
946 .child(
947 TextView::new(&self.view)
948 .selection_format(self.format)
949 .plugin(
950 crate::text::markdown_ext::TestInlinePlugin::new("mention")
951 .parse_with(|node, _| {
952 let markdown::mdast::Node::Link(link) = node else {
953 return None;
954 };
955 let handle = link.url.strip_prefix("mention:")?;
956 Some(
957 crate::text::MarkdownNode::new("mention", ())
958 .text(format!("@{handle}")),
959 )
960 })
961 .render_with(move |_, _, _, _| {
962 let builds = builds.clone();
963 Some(crate::text::InlineElement::new(
964 crate::HoverCard::new("mention-hover")
965 .anchor(gpui::Anchor::TopCenter)
966 .trigger(div().child("@member"))
967 .content(move |_, _, cx| {
968 builds.fetch_add(1, Ordering::Relaxed);
969 div()
970 .id("hover-content")
971 .child(cx.new(|_| InlineHoverCard))
972 }),
973 ))
974 }),
975 ),
976 )
977 }
978 }
979
980 #[gpui::test]
981 fn text_view_follows_the_text_color_of_its_container(cx: &mut TestAppContext) {
982 struct Root {
983 inherited: Entity<TextViewState>,
984 explicit: Entity<TextViewState>,
985 surface_text: gpui::Hsla,
986 }
987 impl Render for Root {
988 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
989 div()
990 .w(px(300.))
991 .text_color(crate::ColorTokens::light().foreground)
992 .child(
993 div()
994 .text_color(self.surface_text)
995 .child(TextView::new(&self.inherited))
996 .child(TextView::new(&self.explicit).style(TextViewStyle::default())),
997 )
998 }
999 }
1000
1001 let colors = crate::ColorTokens::light();
1002 cx.update(|cx| {
1003 crate::init(cx);
1004 super::TextViewDefaults::new()
1005 .with_style(TextViewStyle::default())
1006 .with_inherit_text_color(true)
1007 .install(cx);
1008 });
1009 let (root, cx) = cx.add_window_view(|_, cx| Root {
1010 inherited: cx.new(|cx| TextViewState::markdown("[link](https://a.b) `code`", cx)),
1011 explicit: cx.new(|cx| TextViewState::markdown("text", cx)),
1012 surface_text: colors.primary_foreground,
1013 });
1014 cx.run_until_parked();
1015 cx.update(|window, cx| {
1016 window.draw(cx).clear(cx);
1017 });
1018
1019 root.read_with(cx, |root, cx| {
1020 let inherited = &root.inherited.read(cx).text_view_style;
1021 assert_eq!(inherited.foreground(), colors.primary_foreground);
1022 assert_eq!(inherited.link(), colors.primary_foreground);
1023 assert!(inherited.is_dark());
1024
1025 let explicit = &root.explicit.read(cx).text_view_style;
1026 assert_eq!(explicit.foreground(), colors.foreground);
1027 assert_eq!(explicit.link(), TextViewStyle::default().link());
1028 });
1029 }
1030
1031 #[gpui::test]
1032 fn inline_plugin_reuses_render_and_preserves_native_child_events(cx: &mut TestAppContext) {
1033 use std::sync::Mutex;
1034 struct ControlPlugin(Arc<Mutex<Vec<String>>>);
1035 impl crate::text::MarkdownPlugin for ControlPlugin {
1036 fn name(&self) -> &str {
1037 "control"
1038 }
1039 fn parse(
1040 &self,
1041 node: &markdown::mdast::Node,
1042 _: &crate::text::MarkdownParseContext<'_>,
1043 ) -> Option<crate::text::MarkdownNode> {
1044 let markdown::mdast::Node::Link(link) = node else {
1045 return None;
1046 };
1047 let label = link.url.strip_prefix("control:")?;
1048 Some(crate::text::MarkdownNode::new("control", ()).text(label.to_string()))
1049 }
1050 fn render(
1051 &self,
1052 node: &crate::text::MarkdownNode,
1053 _: &mut Window,
1054 _: &mut gpui::App,
1055 ) -> impl IntoElement {
1056 let clicks = self.0.clone();
1057 let label = node.as_text().to_string();
1058 div()
1059 .id("same-control-id")
1060 .w(px(60.))
1061 .h(px(24.))
1062 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1063 .on_click(move |_, _, _| clicks.lock().unwrap().push(label.clone()))
1064 .child(node.as_text().to_string())
1065 }
1066 }
1067 struct Root {
1068 view: Entity<TextViewState>,
1069 clicks: Arc<Mutex<Vec<String>>>,
1070 }
1071 impl Render for Root {
1072 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1073 div()
1074 .w(px(300.))
1075 .child(crate::TextSelectionLayer)
1076 .child(TextView::new(&self.view).plugin(ControlPlugin(self.clicks.clone())))
1077 }
1078 }
1079 cx.update(crate::init);
1080 let clicks = Arc::new(Mutex::new(Vec::new()));
1081 let captured = clicks.clone();
1082 let (root, cx) = cx.add_window_view(move |_, cx| Root {
1083 view: cx.new(|cx| TextViewState::markdown("[one](control:one)[two](control:two)", cx)),
1084 clicks,
1085 });
1086 cx.run_until_parked();
1087 cx.update(|window, cx| {
1088 window.draw(cx).clear(cx);
1089 });
1090 let bounds = root.read_with(cx, |root, cx| {
1091 root.view.read(cx).selection_adapter.text_bounds()
1092 });
1093 assert_eq!(bounds.len(), 2);
1094 for bounds in bounds {
1095 cx.simulate_click(bounds.center(), Modifiers::default());
1096 cx.update(|window, cx| {
1097 window.draw(cx).clear(cx);
1098 });
1099 }
1100 assert_eq!(*captured.lock().unwrap(), vec!["one", "two"]);
1101 }
1102
1103 #[gpui::test]
1104 fn inline_hover_card_is_lazy_and_retains_atomic_copy(cx: &mut TestAppContext) {
1105 cx.update(crate::init);
1106 let builds = Arc::new(AtomicUsize::new(0));
1107 let source = "[@member](mention:member)";
1108 let (root, cx) = cx.add_window_view(|_, cx| InlineHoverTestRoot {
1109 view: cx.new(|cx| TextViewState::markdown(source, cx)),
1110 builds: builds.clone(),
1111 format: crate::text::SelectionFormat::Plain,
1112 });
1113 cx.run_until_parked();
1114 cx.update(|window, cx| window.draw(cx).clear(cx));
1115 assert_eq!(builds.load(Ordering::Relaxed), 0);
1116 let view = root.read_with(cx, |root, _| root.view.clone());
1117 let bounds = view.read_with(cx, |view, _| view.selection_adapter.text_bounds()[0]);
1118 cx.update(|window, cx| {
1119 window.simulate_mouse_move(point(bounds.right() - px(1.), bounds.center().y), cx)
1120 });
1121 cx.run_until_parked();
1122 cx.executor()
1123 .advance_clock(std::time::Duration::from_secs(1));
1124 cx.run_until_parked();
1125 cx.update(|window, cx| window.draw(cx).clear(cx));
1126 assert!(
1127 builds.load(Ordering::Relaxed) > 0,
1128 "hover did not build the card"
1129 );
1130 cx.update(|window, cx| window.draw(cx).clear(cx));
1131 let card = cx
1132 .debug_bounds("inline-test-card")
1133 .expect("hover card should be painted");
1134 assert!(
1135 (card.center().x - bounds.center().x).abs() < px(1.),
1136 "card {card:?} must be centered on mention {bounds:?}"
1137 );
1138 assert!(
1139 card.top() >= bounds.bottom(),
1140 "card should be below the mention"
1141 );
1142 for (format, expected) in [
1143 (crate::text::SelectionFormat::Plain, "@member"),
1144 (crate::text::SelectionFormat::Source, source),
1145 ] {
1146 root.update(cx, |root, cx| {
1147 root.format = format;
1148 cx.notify();
1149 });
1150 view.update(cx, |view, cx| {
1151 view.set_selection_format(format, cx);
1152 view.select_all(cx);
1153 });
1154 cx.update(|window, cx| window.draw(cx).clear(cx));
1155 assert_eq!(
1156 view.read_with(cx, |view, _| view.selected_text()).trim(),
1157 expected
1158 );
1159 }
1160 }
1161
1162 struct InlinePluginTestRoot {
1163 text_view: Entity<TextViewState>,
1164 width: Pixels,
1165 font_size: Pixels,
1166 source_format: bool,
1167 prepared_size: Option<Arc<AtomicUsize>>,
1168 }
1169
1170 impl Render for InlinePluginTestRoot {
1171 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1172 let prepared_size = self.prepared_size.clone();
1173 div().w(self.width).text_size(self.font_size).child(crate::TextSelectionLayer).child(
1174 TextView::new(&self.text_view)
1175 .selection_format(if self.source_format { crate::text::SelectionFormat::Source }
1176 else { crate::text::SelectionFormat::Plain })
1177
1178 .plugin(crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(|node, _| {
1179 let markdown::mdast::Node::InlineMath(math) = node else { return None };
1180 Some(crate::text::MarkdownNode::new("math", ()).text(format!("{}²", math.value)))
1181 }).render_with(move |_, context, _, _| {
1182 let value = prepared_size.as_ref()?.load(Ordering::Relaxed) as f32;
1183 let image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Svg,
1184 b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><path d=\"M0 0L40 40\" stroke=\"black\"/></svg>".to_vec()));
1185 let unit = context.font_size() / 16.;
1186 Some(crate::text::InlineElement::new(gpui::img(image).w(unit * value).h(unit * value)).with_baseline(unit * value * 0.75))
1187 })))
1188 }
1189 }
1190
1191 #[gpui::test]
1192 fn inline_plugins_drag_and_copy_across_formulas_in_both_directions(cx: &mut TestAppContext) {
1193 cx.update(crate::init);
1194 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1195 text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1196 width: px(420.),
1197 font_size: px(16.),
1198 source_format: false,
1199 prepared_size: None,
1200 });
1201 let cx: &mut VisualTestContext = cx;
1202 for font_size in [16., 24., 32.] {
1203 for width in [60., 160., 420.] {
1204 for prepared in [None, Some(32)] {
1205 for source_format in [false, true] {
1206 root.update(cx, |root, cx| {
1207 root.source_format = source_format;
1208 root.width = px(width);
1209 root.font_size = px(font_size);
1210 root.prepared_size =
1211 prepared.map(|value| Arc::new(AtomicUsize::new(value)));
1212 cx.notify();
1213 });
1214 cx.run_until_parked();
1215 cx.update(|window, cx| window.draw(cx).clear(cx));
1216 let bounds =
1217 root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1218 let text_bounds = root.read_with(cx, |root, cx| {
1219 root.text_view.read(cx).selection_adapter.text_bounds()
1220 });
1221 let first = text_bounds.first().unwrap();
1222 let last = text_bounds.last().unwrap();
1223 let left =
1224 point(first.left() + px(0.1), first.top() + first.size.height / 2.);
1225 let right =
1226 point(last.right() - px(0.1), last.top() + last.size.height / 2.);
1227 for (start, end) in [(left, right), (right, left)] {
1228 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1229 cx.update(|window, cx| window.draw(cx).clear(cx));
1230 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1231 cx.update(|window, cx| window.draw(cx).clear(cx));
1232 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1233 cx.update(|window, cx| window.draw(cx).clear(cx));
1234 let selected = root
1235 .read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1236 assert_eq!(
1237 selected.trim(),
1238 if source_format {
1239 "中文 $x$ $y$ English"
1240 } else {
1241 "中文 x² y² English"
1242 },
1243 "start={start:?} end={end:?} bounds={bounds:?} width={width} font_size={font_size} prepared={prepared:?}"
1244 );
1245 }
1246 }
1247 }
1248 }
1249 }
1250 }
1251
1252 #[gpui::test]
1253 fn inline_resource_size_update_reflows_without_changing_selection(cx: &mut TestAppContext) {
1254 cx.update(crate::init);
1255 let prepared_size = Arc::new(AtomicUsize::new(16));
1256 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1257 text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1258 width: px(160.),
1259 font_size: px(16.),
1260 source_format: false,
1261 prepared_size: Some(prepared_size.clone()),
1262 });
1263 let cx: &mut VisualTestContext = cx;
1264 cx.run_until_parked();
1265 cx.update(|window, cx| window.draw(cx).clear(cx));
1266 let before = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1267 let regions = root.read_with(cx, |root, cx| {
1268 root.text_view.read(cx).selection_adapter.text_bounds()
1269 });
1270 let first = regions.first().unwrap();
1271 let last = regions.last().unwrap();
1272 let start = point(first.left() + px(0.1), first.top() + px(10.));
1273 let end = point(last.right() - px(0.1), last.top() + px(10.));
1274 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1275 cx.update(|window, cx| window.draw(cx).clear(cx));
1276 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1277 cx.update(|window, cx| window.draw(cx).clear(cx));
1278 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1279 cx.update(|window, cx| window.draw(cx).clear(cx));
1280 assert_eq!(
1281 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1282 .trim(),
1283 "中文 x² y² English"
1284 );
1285 prepared_size.store(100, Ordering::Relaxed);
1286 root.update(cx, |root, cx| {
1287 root.text_view
1288 .update(cx, |state, cx| state.invalidate_inline_layout(cx))
1289 });
1290 cx.run_until_parked();
1291 cx.update(|window, cx| window.draw(cx).clear(cx));
1292 let after = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1293 assert!(after.size.height > before.size.height * 2.);
1294 assert_eq!(
1295 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1296 .trim(),
1297 "中文 x² y² English"
1298 );
1299 }
1300
1301 #[gpui::test]
1302 fn triple_click_on_formula_selects_its_entire_mixed_line(cx: &mut TestAppContext) {
1303 cx.update(crate::init);
1304 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1305 text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1306 width: px(420.),
1307 font_size: px(16.),
1308 source_format: false,
1309 prepared_size: None,
1310 });
1311 let cx: &mut VisualTestContext = cx;
1312 cx.run_until_parked();
1313 cx.update(|window, cx| window.draw(cx).clear(cx));
1314 let regions = root.read_with(cx, |root, cx| {
1315 root.text_view.read(cx).selection_adapter.text_bounds()
1316 });
1317 let formula = regions[1];
1318 let position = formula.center();
1319 cx.simulate_event(MouseDownEvent {
1320 position,
1321 modifiers: Modifiers::default(),
1322 button: MouseButton::Left,
1323 click_count: 3,
1324 first_mouse: false,
1325 });
1326 cx.simulate_event(MouseUpEvent {
1327 position,
1328 modifiers: Modifiers::default(),
1329 button: MouseButton::Left,
1330 click_count: 3,
1331 });
1332 cx.update(|window, cx| window.draw(cx).clear(cx));
1333 assert_eq!(
1334 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1335 .trim(),
1336 "before x² after"
1337 );
1338 root.update(cx, |root, cx| {
1339 root.source_format = true;
1340 cx.notify();
1341 });
1342 cx.update(|window, cx| window.draw(cx).clear(cx));
1343 assert_eq!(
1344 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1345 .trim(),
1346 "before $x$ after"
1347 );
1348 }
1349
1350 #[gpui::test]
1351 fn double_click_on_formula_selects_only_that_object(cx: &mut TestAppContext) {
1352 cx.update(crate::init);
1353 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1354 text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1355 width: px(420.),
1356 font_size: px(16.),
1357 source_format: false,
1358 prepared_size: None,
1359 });
1360 let cx: &mut VisualTestContext = cx;
1361 cx.run_until_parked();
1362 cx.update(|window, cx| window.draw(cx).clear(cx));
1363 let regions = root.read_with(cx, |root, cx| {
1364 root.text_view.read(cx).selection_adapter.text_bounds()
1365 });
1366 let position = regions[1].center();
1367 cx.simulate_event(MouseDownEvent {
1368 position,
1369 modifiers: Modifiers::default(),
1370 button: MouseButton::Left,
1371 click_count: 2,
1372 first_mouse: false,
1373 });
1374 cx.simulate_event(MouseUpEvent {
1375 position,
1376 modifiers: Modifiers::default(),
1377 button: MouseButton::Left,
1378 click_count: 2,
1379 });
1380 cx.update(|window, cx| window.draw(cx).clear(cx));
1381 assert_eq!(
1383 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1384 .trim(),
1385 "x²"
1386 );
1387 }
1388
1389 struct ScrollExtentTestRoot {
1392 text_view: Entity<TextViewState>,
1393 }
1394
1395 impl Render for ScrollExtentTestRoot {
1396 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1397 div()
1398 .w(px(400.))
1399 .h(px(200.))
1400 .overflow_hidden()
1401 .child(TextView::new(&self.text_view).scrollable(true))
1402 }
1403 }
1404
1405 fn document_of(count: usize, words: usize) -> String {
1408 (1..=count)
1409 .map(|i| format!("Block {i}: {}", "lorem ipsum dolor ".repeat(words)))
1410 .collect::<Vec<_>>()
1411 .join("\n\n")
1412 }
1413
1414 #[gpui::test]
1420 fn replacing_a_document_with_an_equal_block_count_remeasures(cx: &mut TestAppContext) {
1421 cx.update(crate::init);
1422
1423 const BLOCKS: usize = 24;
1424 let short = document_of(BLOCKS, 1);
1425 let tall = document_of(BLOCKS, 60);
1426
1427 let (root, cx) = cx.add_window_view(|_, cx| ScrollExtentTestRoot {
1428 text_view: cx.new(|cx| TextViewState::markdown(&short, cx)),
1429 });
1430 let cx: &mut VisualTestContext = cx;
1431
1432 let settle = |cx: &mut VisualTestContext| {
1435 cx.run_until_parked();
1436 cx.update(|window, cx| window.draw(cx).clear(cx));
1437 cx.run_until_parked();
1438 };
1439 settle(cx);
1440
1441 let scroll_extent = |cx: &mut VisualTestContext| {
1442 root.read_with(cx, |root, cx| {
1443 root.text_view
1444 .read(cx)
1445 .list_state()
1446 .max_offset_for_scrollbar()
1447 .y
1448 })
1449 };
1450
1451 let short_extent = scroll_extent(cx);
1452
1453 root.update(cx, |root, cx| {
1454 root.text_view
1455 .update(cx, |state, cx| state.set_text(&tall, cx));
1456 });
1457 settle(cx);
1458
1459 root.read_with(cx, |root, cx| {
1460 assert_eq!(
1461 root.text_view.read(cx).list_state().item_count(),
1462 BLOCKS,
1463 "the replacement must keep the block count, or the list resets and the bug cannot occur"
1464 );
1465 });
1466
1467 let tall_extent = scroll_extent(cx);
1468 assert!(
1469 tall_extent > short_extent * 5.,
1470 "a much taller document must grow the scroll extent, but it went from \
1471 {short_extent:?} to {tall_extent:?}"
1472 );
1473 }
1474
1475 struct StatelessMarkdownRoot {
1476 renders: Arc<AtomicUsize>,
1477 }
1478
1479 impl Render for StatelessMarkdownRoot {
1480 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1481 self.renders.fetch_add(1, Ordering::Relaxed);
1482 div().child(
1483 TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
1484 .markdown_block_parser(|_, _| None),
1485 )
1486 }
1487 }
1488
1489 struct DummyTextViewPlugin;
1490
1491 impl TextViewPlugin for DummyTextViewPlugin {
1492 fn setup(self, mut text_view: TextView) -> TextView {
1493 text_view.selectable = true;
1494 text_view
1495 }
1496 }
1497
1498 #[gpui::test]
1499 fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
1500 cx.update(crate::init);
1501 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
1502
1503 assert!(TextView::new(&state).selectable);
1504 assert!(TextView::markdown("markdown", "text").selectable);
1505 assert!(TextView::html("html", "<p>text</p>").selectable);
1506 }
1507
1508 #[gpui::test]
1509 fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
1510 cx.update(crate::init);
1511 let renders = Arc::new(AtomicUsize::new(0));
1512 let (_, cx) = cx.add_window_view({
1513 let renders = renders.clone();
1514 move |_, _| StatelessMarkdownRoot { renders }
1515 });
1516 let cx: &mut VisualTestContext = cx;
1517
1518 cx.run_until_parked();
1519 cx.update(|window, cx| window.draw(cx).clear(cx));
1520 let renders_after_redraw = renders.load(Ordering::Relaxed);
1521 cx.run_until_parked();
1522 assert_eq!(
1523 renders.load(Ordering::Relaxed),
1524 renders_after_redraw,
1525 "an unchanged TextView must not schedule another render after its parser is rebuilt",
1526 );
1527 }
1528
1529 #[gpui::test]
1530 fn markdown_data_url_image_is_decoded_inline(cx: &mut TestAppContext) {
1531 use gpui::{Image, ImageFormat, ImageSource};
1532
1533 const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
1535
1536 cx.update(crate::init);
1537 let markdown = format!("Inline  image");
1538 let (_, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new(&markdown, cx));
1539 let cx: &mut VisualTestContext = cx;
1540
1541 cx.run_until_parked();
1542 cx.update(|window, cx| window.draw(cx).clear(cx));
1543
1544 let bytes = data_url::DataUrl::process(&format!("data:image/png;base64,{PNG_BASE64}"))
1549 .unwrap()
1550 .decode_to_vec()
1551 .unwrap()
1552 .0;
1553 let image = Arc::new(Image::from_bytes(ImageFormat::Png, bytes));
1554 assert!(
1555 cx.update(|_, cx| ImageSource::Image(image).is_asset_cached(cx)),
1556 "the data URL image must be handed to GPUI as decoded bytes",
1557 );
1558 }
1559
1560 #[gpui::test]
1561 fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
1562 cx.update(crate::init);
1563 cx.update(|cx| {
1564 let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
1565 colors.primary = gpui::rgb(0x55aaff).into();
1566 colors.selection = gpui::rgb(0x335577).into();
1567 });
1568 let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
1569 let cx: &mut VisualTestContext = cx;
1570
1571 cx.run_until_parked();
1572 root.read_with(cx, |root, cx| {
1573 let style = &root.text_view.read(cx).text_view_style;
1574 assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
1575 assert_eq!(style.selection(), gpui::rgb(0x335577).into());
1576 });
1577 }
1578
1579 impl TextViewTestRoot {
1580 fn new(text: &str, cx: &mut Context<Self>) -> Self {
1581 let text = text.to_string();
1582 let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
1583 Self { text_view }
1584 }
1585 }
1586
1587 impl Render for TextViewTestRoot {
1588 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1589 div()
1590 .w(px(160.))
1591 .child(
1592 div()
1593 .h(px(24.))
1594 .overflow_hidden()
1595 .child(TextView::new(&self.text_view).selectable(true)),
1596 )
1597 .child(div().h(px(40.)).child("footer"))
1598 }
1599 }
1600
1601 struct TableSelectionTestRoot {
1602 text_view: Entity<TextViewState>,
1603 }
1604
1605 impl Render for TableSelectionTestRoot {
1606 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1607 div()
1608 .debug_selector(|| "table-selection-root".into())
1609 .w(px(520.))
1610 .child(crate::TextSelectionLayer)
1611 .child(TextView::new(&self.text_view))
1612 }
1613 }
1614
1615 #[gpui::test]
1616 fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
1617 cx.update(crate::init);
1618 let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
1619 text_view: cx.new(|cx| {
1620 TextViewState::markdown(
1621 "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
1622 cx,
1623 )
1624 }),
1625 });
1626 let cx: &mut VisualTestContext = cx;
1627
1628 cx.run_until_parked();
1629 let bounds = cx
1630 .debug_bounds("table-selection-root")
1631 .expect("table bounds");
1632 let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
1633 let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
1634 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1635 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1636 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1637
1638 assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
1639 assert_eq!(
1640 cx.update(|window, cx| window.simulate_next_frame(cx)),
1641 0,
1642 "finished table selection must not continuously request frames"
1643 );
1644 }
1645
1646 struct InlineImageTextViewTestRoot {
1647 text_view: Entity<TextViewState>,
1648 }
1649
1650 impl InlineImageTextViewTestRoot {
1651 fn new(cx: &mut Context<Self>) -> Self {
1652 let text_view = cx.new(|cx| {
1653 TextViewState::markdown(
1654 "Build Status  after",
1655 cx,
1656 )
1657 });
1658 Self { text_view }
1659 }
1660 }
1661
1662 impl Render for InlineImageTextViewTestRoot {
1663 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1664 div()
1665 .w(px(420.))
1666 .child(TextView::new(&self.text_view).selectable(true))
1667 }
1668 }
1669
1670 #[gpui::test]
1671 fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
1672 cx.update(crate::init);
1673 let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
1674 let cx: &mut VisualTestContext = cx;
1675
1676 cx.run_until_parked();
1677 cx.update(|window, cx| {
1678 let _ = window.draw(cx);
1679 });
1680
1681 let inline_bounds = content.read_with(cx, |content, cx| {
1682 content.text_view.read(cx).selection_adapter.text_bounds()
1683 });
1684
1685 assert_eq!(inline_bounds.len(), 2);
1686 assert_eq!(
1687 inline_bounds[0].top(),
1688 inline_bounds[1].top(),
1689 "text before and after an inline image should share a rendered line"
1690 );
1691 assert!(
1692 inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
1693 "inline image should reserve horizontal space in the text layout"
1694 );
1695 assert!(
1696 inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
1697 "unloaded inline image fallback should stay generic and compact"
1698 );
1699 }
1700
1701 #[gpui::test]
1702 fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
1703 cx.update(crate::init);
1704 let (_, cx) = cx.add_window_view(|_, cx| {
1705 TextViewTestRoot::new(
1706 "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
1707 cx,
1708 )
1709 });
1710 let cx: &mut VisualTestContext = cx;
1711
1712 cx.run_until_parked();
1713 cx.update(|window, cx| {
1714 let _ = window.draw(cx);
1715 });
1716 }
1717
1718 #[gpui::test]
1719 fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
1720 struct ListItemBlockRoot;
1721
1722 impl Render for ListItemBlockRoot {
1723 fn render(
1724 &mut self,
1725 _window: &mut Window,
1726 _cx: &mut Context<Self>,
1727 ) -> impl IntoElement {
1728 div().w(px(840.)).h(px(400.)).child(
1729 crate::h_resizable("markdown-width-test")
1730 .child(crate::resizable_panel().child(div()))
1731 .child(crate::resizable_panel().child(
1732 TextView::markdown(
1733 "list-with-code",
1734 "1. List item\n ```rust\n nested code\n ```\n\n```rust\ntop-level code\n```",
1735 )
1736 .code_block_actions(|code_block, _, _| {
1737 let selector = if code_block.code().contains("nested") {
1738 "nested-code-action"
1739 } else {
1740 "top-level-code-action"
1741 };
1742 div()
1743 .debug_selector(move || selector.into())
1744 .child("Copy")
1745 })
1746 .scrollable(true)
1747 .p_5()
1748 .flex_none(),
1749 )),
1750 )
1751 }
1752 }
1753
1754 cx.update(crate::init);
1755 let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
1756 let cx: &mut VisualTestContext = cx;
1757
1758 cx.run_until_parked();
1759 cx.update(|window, cx| {
1760 let _ = window.draw(cx);
1761 });
1762
1763 let nested_action = cx.debug_bounds("nested-code-action").unwrap();
1764 let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
1765 assert!(
1766 top_level_action.right() - nested_action.right() < px(32.),
1767 "nested code block should fill the list item's available width"
1768 );
1769 }
1770
1771 const CODE_HEIGHT: Pixels = px(60.);
1773
1774 struct ScrollingCodeRoot {
1775 text_view: Entity<TextViewState>,
1776 actions: bool,
1777 }
1778
1779 impl Render for ScrollingCodeRoot {
1780 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1781 let mut code_block = StyleRefinement::default().max_h(CODE_HEIGHT);
1782 code_block.overflow.y = Some(Overflow::Scroll);
1783
1784 div().w(px(320.)).h(px(200.)).child(
1785 TextView::new(&self.text_view)
1786 .style(TextViewStyle::default().with_code_block(code_block))
1787 .when(self.actions, |this| {
1788 this.code_block_actions(|_, _, _| {
1789 div().debug_selector(|| "code-action".into()).child("Copy")
1790 })
1791 })
1792 .scrollable(true),
1793 )
1794 }
1795 }
1796
1797 #[gpui::test]
1798 fn scrolling_code_block_keeps_wheel_from_the_list(cx: &mut TestAppContext) {
1799 cx.update(crate::init);
1800 let code = (0..50).map(|i| format!("line {i}\n")).collect::<String>();
1801 let tail = "Paragraph after the code.\n\n".repeat(20);
1802 let markdown = format!("```\n{code}```\n\n{tail}");
1803
1804 for actions in [false, true] {
1807 let (root, cx) = cx.add_window_view(|_, cx| ScrollingCodeRoot {
1808 text_view: cx.new(|cx| TextViewState::markdown(&markdown, cx)),
1809 actions,
1810 });
1811 let cx: &mut VisualTestContext = cx;
1812 let draw = |cx: &mut VisualTestContext| {
1813 cx.run_until_parked();
1814 cx.update(|window, cx| window.draw(cx).clear(cx));
1815 };
1816 let step = px(20.);
1817 let wheel_at = |cx: &mut VisualTestContext, y: Pixels| {
1818 cx.simulate_event(gpui::ScrollWheelEvent {
1819 position: point(px(20.), y),
1820 delta: gpui::ScrollDelta::Pixels(point(px(0.), -step)),
1821 ..Default::default()
1822 });
1823 };
1824 let edges = |cx: &mut VisualTestContext| {
1828 let lines = root.read_with(cx, |root, cx| {
1829 root.text_view.read(cx).selection_adapter.text_bounds()
1830 });
1831 let paragraph = lines.iter().find(|line| line.top() >= CODE_HEIGHT).unwrap();
1832 (lines[0].bottom(), paragraph.top())
1833 };
1834 draw(cx);
1835 let (code_bottom, paragraph_top) = edges(cx);
1836 let action = cx.debug_bounds("code-action");
1837
1838 wheel_at(cx, CODE_HEIGHT / 2.);
1841 draw(cx);
1842 assert_eq!(
1843 edges(cx),
1844 (code_bottom - step, paragraph_top),
1845 "actions: {actions}"
1846 );
1847 assert_eq!(cx.debug_bounds("code-action"), action);
1848
1849 wheel_at(cx, CODE_HEIGHT * 2.5);
1853 draw(cx);
1854 assert_ne!(edges(cx).1, paragraph_top, "actions: {actions}");
1855 }
1856 }
1857
1858 fn draw_table_with_actions(
1862 cx: &mut TestAppContext,
1863 scroll: bool,
1864 ) -> (Bounds<Pixels>, TableData) {
1865 use std::sync::{Arc, Mutex};
1866
1867 struct TableRoot {
1868 scroll: bool,
1869 captured: Arc<Mutex<Vec<TableData>>>,
1870 }
1871
1872 impl Render for TableRoot {
1873 fn render(
1874 &mut self,
1875 _window: &mut Window,
1876 _cx: &mut Context<Self>,
1877 ) -> impl IntoElement {
1878 let captured = self.captured.clone();
1879 let mut table_style = StyleRefinement::default();
1880 if self.scroll {
1881 table_style.overflow.x = Some(Overflow::Scroll);
1882 }
1883
1884 div().w(px(320.)).child(
1885 TextView::markdown(
1886 "table-actions",
1887 "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
1888 )
1889 .style(TextViewStyle::default().with_table(table_style))
1890 .table_actions(move |table, _, _| {
1891 if let Ok(mut captured) = captured.lock() {
1892 captured.push(table.clone());
1893 }
1894 div().debug_selector(|| "table-action".into()).child("Copy")
1895 }),
1896 )
1897 }
1898 }
1899
1900 cx.update(crate::init);
1901 let captured = Arc::new(Mutex::new(Vec::new()));
1902 let (_, cx) = cx.add_window_view({
1903 let captured = captured.clone();
1904 move |_, _| TableRoot { scroll, captured }
1905 });
1906 let cx: &mut VisualTestContext = cx;
1907
1908 cx.run_until_parked();
1909 cx.update(|window, cx| {
1910 let _ = window.draw(cx);
1911 });
1912
1913 let bounds = cx
1914 .debug_bounds("table-action")
1915 .expect("table actions should be painted");
1916 let data = captured
1917 .lock()
1918 .expect("captured table data")
1919 .last()
1920 .cloned()
1921 .expect("table actions hook should receive the table");
1922
1923 (bounds, data)
1924 }
1925
1926 #[gpui::test]
1927 fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
1928 for scroll in [false, true] {
1929 let (bounds, data) = draw_table_with_actions(cx, scroll);
1930
1931 assert!(
1933 bounds.top() > px(40.),
1934 "actions should sit below the table (scroll: {scroll}), got {:?}",
1935 bounds.top()
1936 );
1937 assert_eq!(data.headers, vec!["Name", "Age"]);
1938 assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
1939 assert_eq!(
1940 data.markdown,
1941 "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
1942 );
1943 assert_eq!(data.span, Some(0..52));
1944 }
1945 }
1946
1947 #[test]
1948 fn plugin_accepts_text_view_plugins_beyond_markdown() {
1949 let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
1950
1951 assert!(view.selectable);
1952 }
1953
1954 #[test]
1955 fn syntax_highlighting_is_opt_in() {
1956 let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
1957 assert!(default_view.code_block_highlighter.is_none());
1958
1959 let view = default_view.code_block_highlighter(|block| {
1960 vec![(
1961 0..block.code().len(),
1962 gpui::HighlightStyle {
1963 color: Some(gpui::rgb(0x3366ff).into()),
1964 ..Default::default()
1965 },
1966 )]
1967 });
1968 assert!(view.code_block_highlighter.is_some());
1969 }
1970
1971 #[gpui::test]
1972 fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
1973 cx.update(crate::init);
1974 let (_, cx) = cx.add_window_view(|_, cx| {
1975 TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
1976 });
1977 let cx: &mut VisualTestContext = cx;
1978
1979 cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
1980
1981 assert_eq!(cx.opened_url(), None);
1982 }
1983
1984 struct MaxLinesTestRoot {
1985 text_view: Entity<TextViewState>,
1986 max_lines: usize,
1987 }
1988
1989 impl MaxLinesTestRoot {
1990 fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
1991 let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
1992 Self {
1993 text_view,
1994 max_lines,
1995 }
1996 }
1997 }
1998
1999 impl Render for MaxLinesTestRoot {
2000 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2001 div()
2002 .w(px(200.))
2003 .child(TextView::new(&self.text_view).max_lines(self.max_lines))
2004 }
2005 }
2006
2007 #[test]
2008 fn the_clip_only_moves_for_a_straddling_glyph_line() {
2009 use super::line_safe_clip_bottom;
2010 use crate::text::state::LineSpan;
2011
2012 let spans = [
2013 LineSpan {
2015 top: px(0.),
2016 bottom: px(60.),
2017 line_height: px(20.),
2018 },
2019 LineSpan {
2021 top: px(68.),
2022 bottom: px(128.),
2023 line_height: px(20.),
2024 },
2025 ];
2026
2027 let below = px(400.);
2029
2030 assert_eq!(
2032 line_safe_clip_bottom(&spans, px(100.), below),
2033 Some(px(88.))
2034 );
2035
2036 assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
2038
2039 assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
2042
2043 let one_block = [LineSpan {
2046 top: px(0.),
2047 bottom: px(60.),
2048 line_height: px(20.),
2049 }];
2050 assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
2051
2052 assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
2055 }
2056
2057 #[test]
2058 fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
2059 use super::line_safe_clip_bottom;
2060 use crate::text::state::LineSpan;
2061
2062 let heading = [LineSpan {
2064 top: px(70.),
2065 bottom: px(98.),
2066 line_height: px(28.),
2067 }];
2068
2069 assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
2070 }
2071
2072 #[test]
2073 fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
2074 use super::line_safe_clip_bottom;
2075 use crate::text::state::LineSpan;
2076
2077 let rows = [
2080 LineSpan {
2081 top: px(100.),
2082 bottom: px(126.),
2083 line_height: px(26.),
2084 },
2085 LineSpan {
2086 top: px(135.),
2087 bottom: px(161.),
2088 line_height: px(26.),
2089 },
2090 ];
2091
2092 assert_eq!(
2095 line_safe_clip_bottom(&rows, px(148.), px(400.)),
2096 Some(px(126.))
2097 );
2098 }
2099
2100 struct ClampedPageRoot {
2106 text_view: Entity<TextViewState>,
2107 max_lines: usize,
2108 }
2109
2110 impl Render for ClampedPageRoot {
2111 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2112 use crate::{h_flex, v_flex};
2113
2114 v_flex()
2115 .size_full()
2116 .p_4()
2117 .gap_4()
2118 .child(h_flex().max_w(px(480.)).gap_3().child("header"))
2119 .child(
2120 v_flex()
2121 .flex_1()
2122 .min_h_0()
2123 .gap_4()
2124 .id("clamped-page-scroll")
2125 .child(
2126 v_flex()
2127 .max_w(px(480.))
2128 .p_3()
2129 .gap_2()
2130 .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
2131 )
2132 .overflow_y_scroll(),
2133 )
2134 }
2135 }
2136
2137 #[gpui::test]
2138 fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
2139 cx.update(crate::init);
2140 let (root, cx) = cx.add_window_view(|_, cx| {
2141 let text_view = cx.new(|cx| {
2142 TextViewState::markdown(
2143 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
2144 cx,
2145 )
2146 });
2147 ClampedPageRoot {
2148 text_view,
2149 max_lines: 3,
2150 }
2151 });
2152 let cx: &mut VisualTestContext = cx;
2153
2154 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2155 }
2156
2157 #[gpui::test]
2158 fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
2159 cx.update(crate::init);
2160 let (root, cx) = cx.add_window_view(|_, cx| {
2161 MaxLinesTestRoot::new(
2162 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
2163 2,
2164 cx,
2165 )
2166 });
2167 let cx: &mut VisualTestContext = cx;
2168
2169 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2170 }
2171
2172 #[gpui::test]
2173 fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
2174 cx.update(crate::init);
2175 let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
2176 let cx: &mut VisualTestContext = cx;
2177
2178 assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2179 }
2180
2181 #[gpui::test]
2182 fn heading_refinement_changes_rendered_heading_geometry(cx: &mut TestAppContext) {
2183 struct HeadingStyleRoot;
2184
2185 impl Render for HeadingStyleRoot {
2186 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2187 div()
2188 .flex()
2189 .items_start()
2190 .child(
2191 div()
2192 .debug_selector(|| "default-h1".into())
2193 .child(TextView::markdown("default-h1-view", "# Heading")),
2194 )
2195 .child(div().debug_selector(|| "custom-h1".into()).child(
2196 TextView::markdown("custom-heading-view", "# Heading").style(
2197 TextViewStyle::default().with_heading(|level| {
2198 if level == 1 {
2199 StyleRefinement::default().pb(rems(2.))
2200 } else {
2201 StyleRefinement::default()
2202 }
2203 }),
2204 ),
2205 ))
2206 .child(
2207 div()
2208 .debug_selector(|| "default-h2".into())
2209 .child(TextView::markdown("default-h2-view", "## Heading")),
2210 )
2211 .child(div().debug_selector(|| "custom-h2".into()).child(
2212 TextView::markdown("custom-h2-view", "## Heading").style(
2213 TextViewStyle::default().with_heading(|level| {
2214 if level == 1 {
2215 StyleRefinement::default().pb(rems(2.))
2216 } else {
2217 StyleRefinement::default()
2218 }
2219 }),
2220 ),
2221 ))
2222 }
2223 }
2224
2225 cx.update(crate::init);
2226 let (_, cx) = cx.add_window_view(|_, _| HeadingStyleRoot);
2227 cx.update(|window, cx| window.draw(cx).clear(cx));
2228
2229 let default_h1 = cx.debug_bounds("default-h1").unwrap();
2230 let custom_h1 = cx.debug_bounds("custom-h1").unwrap();
2231 let default_h2 = cx.debug_bounds("default-h2").unwrap();
2232 let custom_h2 = cx.debug_bounds("custom-h2").unwrap();
2233 assert!(custom_h1.size.height > default_h1.size.height);
2234 assert_eq!(custom_h2.size.height, default_h2.size.height);
2235 }
2236
2237 #[gpui::test]
2238 fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
2239 cx.update(crate::init);
2240 let (_, cx) = cx.add_window_view(|_, cx| {
2241 MaxLinesTestRoot::new(
2242 "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
2243 2,
2244 cx,
2245 )
2246 });
2247 let cx: &mut VisualTestContext = cx;
2248
2249 cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
2251
2252 assert_eq!(cx.opened_url(), None);
2253 }
2254
2255 #[gpui::test]
2256 fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) {
2257 struct SelectionRoot {
2258 text_view: Entity<TextViewState>,
2259 format: crate::text::SelectionFormat,
2260 }
2261 impl Render for SelectionRoot {
2262 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2263 div()
2264 .w(px(160.))
2265 .child(crate::TextSelectionLayer)
2266 .child(TextView::new(&self.text_view).selection_format(self.format))
2267 }
2268 }
2269 cx.update(crate::init);
2270 let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot {
2271 format: crate::text::SelectionFormat::Plain,
2272 text_view: cx
2273 .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)),
2274 });
2275 let cx: &mut VisualTestContext = cx;
2276 cx.run_until_parked();
2277 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2278 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2279 cx.simulate_mouse_down(
2281 point(px(5.), px(8.)),
2282 MouseButton::Left,
2283 Modifiers::default(),
2284 );
2285 cx.update(|window, cx| {
2286 let _ = window.draw(cx);
2287 });
2288 cx.simulate_mouse_move(
2289 point(px(155.), px(20.)),
2290 Some(MouseButton::Left),
2291 Modifiers::default(),
2292 );
2293 cx.update(|window, cx| {
2294 let _ = window.draw(cx);
2295 });
2296 cx.simulate_mouse_up(
2297 point(px(155.), px(20.)),
2298 MouseButton::Left,
2299 Modifiers::default(),
2300 );
2301 cx.update(|window, cx| {
2302 let _ = window.draw(cx);
2303 });
2304 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2305 assert_eq!(selected.trim(), "code after");
2306 view.update(cx, |view, cx| {
2307 view.format = crate::text::SelectionFormat::Source;
2308 cx.notify();
2309 });
2310 cx.update(|window, cx| {
2311 let _ = window.draw(cx);
2312 });
2313 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2314 assert_eq!(selected.trim(), "[`code`](https://example.com) after");
2315 }
2316
2317 #[test]
2327 fn inline_code_heading_reserves_painted_wrapped_lines_and_list_baseline() {
2328 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2329 use gpui::{TestApp, rems};
2330
2331 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";
2332
2333 struct MarkdownRoot {
2334 text_view: Entity<TextViewState>,
2335 width: Pixels,
2336 preview_zoom: f32,
2337 }
2338
2339 impl Render for MarkdownRoot {
2340 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2341 div()
2342 .w(self.width)
2343 .text_size(rems(self.preview_zoom))
2346 .child(crate::TextSelectionLayer)
2347 .child(TextView::new(&self.text_view).selectable(true))
2348 }
2349 }
2350
2351 fn draw(
2352 app: &mut TestApp,
2353 width: Pixels,
2354 preview_zoom: f32,
2355 ) -> (Bounds<Pixels>, Vec<Bounds<Pixels>>, String) {
2356 let mut window = app.open_window(|_, cx| MarkdownRoot {
2357 text_view: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
2358 width,
2359 preview_zoom,
2360 });
2361 window.draw();
2362 app.run_until_parked();
2363 window.draw();
2364 window.update(|root, _, cx| {
2365 root.text_view.update(cx, |state, cx| state.select_all(cx));
2366 });
2367 window.draw();
2368 window.read(|root, cx| {
2369 let state = root.text_view.read(cx);
2370 (
2371 state.bounds(),
2372 state.selection_adapter.text_bounds(),
2373 state.selected_text(),
2374 )
2375 })
2376 }
2377
2378 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2379 app.update(|cx| {
2380 crate::init(cx);
2381 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2382 });
2383
2384 for (case, width, preview_zoom) in [
2385 ("default-16px", px(600.), 1.),
2388 ("narrow-16px", px(320.), 1.),
2390 ("zoom-1.25", px(600.), 1.25),
2392 ] {
2393 let (view_bounds, text_bounds, selected_text) = draw(&mut app, width, preview_zoom);
2394 assert!(
2395 text_bounds.len() > 8,
2396 "{case}: expected all markdown fragments"
2397 );
2398 assert_eq!(
2399 selected_text.trim(),
2400 "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",
2401 "{case}: wrapped inline-code text was lost or duplicated"
2402 );
2403 let following = text_bounds.last().expect("following paragraph must paint");
2404 let painted_bottom = text_bounds
2405 .iter()
2406 .map(|bounds| bounds.bottom())
2407 .max()
2408 .expect("markdown must paint text");
2409 assert!(
2415 text_bounds[..text_bounds.len() - 1]
2416 .iter()
2417 .all(|bounds| bounds.bottom() <= following.top()),
2418 "{case}: heading/list text painted through the following paragraph; following={following:?}, text_bounds={text_bounds:?}, view={view_bounds:?}"
2419 );
2420 assert!(
2421 painted_bottom <= view_bounds.bottom(),
2422 "{case}: TextView height did not reserve its painted text; painted_bottom={painted_bottom:?}, view={view_bounds:?}"
2423 );
2424 }
2425 }
2426
2427 #[test]
2428 fn ordered_markdown_list_start_reaches_layout_marker() {
2429 use crate::text::inline::test_fonts::{WideMonoTextSystem, record_shaped_lines};
2430 use gpui::TestApp;
2431
2432 struct MarkdownRoot {
2433 text_view: Entity<TextViewState>,
2434 }
2435
2436 impl Render for MarkdownRoot {
2437 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2438 div().w(px(400.)).child(TextView::new(&self.text_view))
2439 }
2440 }
2441
2442 #[derive(Clone, Copy)]
2443 enum Format {
2444 Markdown,
2445 Html,
2446 }
2447
2448 fn shaped_markers(format: Format, source: &str) -> Vec<String> {
2449 let (_, shaped_lines) = record_shaped_lines(|| {
2450 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2451 app.update(crate::init);
2452 let mut window = app.open_window(|_, cx| MarkdownRoot {
2453 text_view: cx.new(|cx| match format {
2454 Format::Markdown => TextViewState::markdown(source, cx),
2455 Format::Html => TextViewState::html(source, cx),
2456 }),
2457 });
2458 window.draw();
2459 app.run_until_parked();
2460 window.draw();
2461 });
2462
2463 let mut markers = shaped_lines
2464 .into_iter()
2465 .filter(|line| line.len() <= 3 && line.ends_with('.'))
2466 .collect::<Vec<_>>();
2467 markers.dedup();
2468 markers
2469 }
2470
2471 let starts_at_one = "1. one\n2. two";
2472 assert_eq!(
2473 shaped_markers(Format::Markdown, starts_at_one),
2474 ["1.", "2."]
2475 );
2476 assert_eq!(
2477 shaped_markers(Format::Html, "<ol><li>one</li><li>two</li></ol>"),
2478 ["1.", "2."]
2479 );
2480
2481 assert_eq!(
2482 shaped_markers(Format::Markdown, "3. hello\n4. world"),
2483 ["3.", "4."]
2484 );
2485
2486 let nested_starts_at_four = "1. outer\n\n 4. nested\n 5. again";
2487 assert_eq!(
2488 shaped_markers(Format::Markdown, nested_starts_at_four),
2489 ["1.", "D.", "E."]
2490 );
2491
2492 let nested_starts_at_zero = "1. outer\n\n 0. zero";
2493 assert_eq!(
2494 shaped_markers(Format::Markdown, nested_starts_at_zero),
2495 ["1.", "0."]
2496 );
2497 }
2498
2499 #[test]
2504 fn inline_code_line_is_as_tall_as_a_plain_line() {
2505 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2506 use gpui::{TestApp, rems};
2507
2508 struct LineRoot {
2509 plain: Entity<TextViewState>,
2510 code: Entity<TextViewState>,
2511 preview_zoom: f32,
2512 }
2513
2514 impl Render for LineRoot {
2515 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2516 div()
2517 .w(px(600.))
2518 .text_size(rems(self.preview_zoom))
2519 .child(TextView::new(&self.plain))
2520 .child(TextView::new(&self.code))
2521 }
2522 }
2523
2524 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2525 app.update(|cx| {
2526 crate::init(cx);
2527 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2528 });
2529 for scale_factor in [1.6, 2.] {
2530 for preview_zoom in [1., 1.25] {
2531 let mut window = app.open_window(|_, cx| LineRoot {
2532 plain: cx.new(|cx| TextViewState::markdown("plain body words", cx)),
2533 code: cx.new(|cx| TextViewState::markdown("plain `code` words", cx)),
2534 preview_zoom,
2535 });
2536 window.simulate_scale_factor_change(scale_factor);
2537 window.draw();
2538 app.run_until_parked();
2539 window.draw();
2540 let (plain, code) = window.read(|root, cx| {
2541 (
2542 root.plain.read(cx).bounds().size.height,
2543 root.code.read(cx).bounds().size.height,
2544 )
2545 });
2546 assert_eq!(code, plain, "scale {scale_factor}, zoom {preview_zoom}");
2547 }
2548 }
2549 }
2550
2551 #[test]
2555 fn inline_code_list_body_paint_origin_matches_plain_list_item() {
2556 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2557 use gpui::{TestApp, rems};
2558
2559 struct ListRoot {
2560 plain: Entity<TextViewState>,
2561 code: Entity<TextViewState>,
2562 preview_zoom: f32,
2563 }
2564
2565 impl Render for ListRoot {
2566 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2567 div()
2568 .w(px(600.))
2569 .text_size(rems(self.preview_zoom))
2570 .child(crate::TextSelectionLayer)
2571 .child(TextView::new(&self.plain).selectable(true))
2572 .child(TextView::new(&self.code).selectable(true))
2573 }
2574 }
2575
2576 fn draw(app: &mut TestApp, preview_zoom: f32) -> (Pixels, Pixels) {
2577 let mut window = app.open_window(|_, cx| ListRoot {
2578 plain: cx.new(|cx| TextViewState::markdown("- plain body words", cx)),
2579 code: cx.new(|cx| TextViewState::markdown("- plain `code` words", cx)),
2580 preview_zoom,
2581 });
2582 window.draw();
2583 app.run_until_parked();
2584 window.draw();
2585 window.read(|root, cx| {
2586 let first_body_line_top = |view: &Entity<TextViewState>| {
2587 let view = view.read(cx);
2588 let painted_line = view
2589 .selection_adapter
2590 .text_bounds()
2591 .into_iter()
2592 .next()
2593 .expect("list item should paint its first body line");
2594 painted_line.top() - view.bounds().top()
2595 };
2596 (
2597 first_body_line_top(&root.plain),
2598 first_body_line_top(&root.code),
2599 )
2600 })
2601 }
2602
2603 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2604 app.update(|cx| {
2605 crate::init(cx);
2606 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2607 });
2608 for (case, preview_zoom) in [("16px", 1.), ("zoom-1.25", 1.25), ("zoom-1.5", 1.5)] {
2609 let (plain_origin, code_origin) = draw(&mut app, preview_zoom);
2610 assert_eq!(
2611 code_origin, plain_origin,
2612 "{case}: code list body paint origin {code_origin:?} must match plain {plain_origin:?}"
2613 );
2614 }
2615 }
2616
2617 #[test]
2618 fn inline_code_fragment_does_not_paint_past_its_reserved_row() {
2619 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2620 use gpui::TestApp;
2621
2622 const TEXT_BACKGROUND: u32 = 0x20f0b0;
2623
2624 struct MarkdownRoot {
2625 text_view: Entity<TextViewState>,
2626 }
2627
2628 impl Render for MarkdownRoot {
2629 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2630 div()
2631 .w(px(640.))
2632 .text_size(px(17.9))
2633 .text_bg(gpui::rgb(TEXT_BACKGROUND))
2634 .child(TextView::new(&self.text_view))
2635 }
2636 }
2637
2638 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2639 app.update(|cx| {
2640 crate::init(cx);
2641 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2642 });
2643 let mut window = app.open_window(|_, cx| MarkdownRoot {
2644 text_view: cx.new(|cx| TextViewState::markdown("`main` starts the paragraph", cx)),
2645 });
2646 window.draw();
2647 app.run_until_parked();
2648 window.draw();
2649
2650 let (view_bounds, painted) = window.update(|root, window, cx| {
2651 let view_bounds = root
2652 .text_view
2653 .read(cx)
2654 .bounds()
2655 .scale(window.scale_factor());
2656 let text_background: gpui::Background = gpui::rgb(TEXT_BACKGROUND).into();
2657 let painted = window
2658 .painted_quads()
2659 .into_iter()
2660 .filter(|quad| quad.background == text_background)
2661 .map(|quad| quad.bounds)
2662 .collect::<Vec<_>>();
2663 (view_bounds, painted)
2664 });
2665
2666 assert!(
2667 !painted.is_empty(),
2668 "the inherited text background must make actual text paint observable"
2669 );
2670 assert!(
2671 painted
2672 .iter()
2673 .all(|bounds| bounds.bottom() <= view_bounds.bottom()),
2674 "an inline-code fragment wrapped a second time after InlineFlow reserved one row; \
2675 text background quads={painted:?}, reserved TextView bounds={view_bounds:?}"
2676 );
2677 }
2678
2679 #[test]
2680 fn table_row_backgrounds_follow_the_frame_corner_radius() {
2681 use gpui::TestApp;
2682
2683 const HEAD_BACKGROUND: u32 = 0x11aa77;
2684 const RADIUS: f32 = 8.;
2685
2686 struct RoundedTableRoot {
2687 text_view: Entity<TextViewState>,
2688 scroll: bool,
2689 }
2690
2691 impl Render for RoundedTableRoot {
2692 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2693 let mut table = StyleRefinement::default();
2694 table.corner_radii.top_left = Some(px(RADIUS).into());
2695 table.corner_radii.top_right = Some(px(RADIUS).into());
2696 table.corner_radii.bottom_left = Some(px(RADIUS).into());
2697 table.corner_radii.bottom_right = Some(px(RADIUS).into());
2698 if self.scroll {
2699 table.overflow.x = Some(gpui::Overflow::Scroll);
2700 }
2701 let style = TextViewStyle::default()
2702 .with_code_background(gpui::rgb(HEAD_BACKGROUND).into())
2703 .with_table(table);
2704 div()
2705 .w(px(400.))
2706 .child(TextView::new(&self.text_view).style(style))
2707 }
2708 }
2709
2710 for scroll in [false, true] {
2711 let mode = if scroll { "scroll" } else { "wrap" };
2712 let mut app = TestApp::new();
2713 app.update(crate::init);
2714 let mut window = app.open_window(|_, cx| RoundedTableRoot {
2715 text_view: cx
2716 .new(|cx| TextViewState::markdown("| a | b |\n| --- | --- |\n| 1 | 2 |", cx)),
2717 scroll,
2718 });
2719 window.draw();
2720 app.run_until_parked();
2721 window.draw();
2722
2723 let (quad, scale) = window.update(|_, window, _| {
2724 let head: gpui::Background = gpui::rgb(HEAD_BACKGROUND).into();
2725 let quad = window
2726 .painted_quads()
2727 .into_iter()
2728 .find(|quad| quad.background == head)
2729 .unwrap_or_else(|| panic!("{mode}: the header background quad is painted"));
2730 (quad, window.scale_factor())
2731 });
2732
2733 let expected = px(RADIUS - 1.).scale(scale);
2736 assert_eq!(quad.corner_radii.top_left, expected, "{mode}: top left");
2737 assert_eq!(quad.corner_radii.top_right, expected, "{mode}: top right");
2738 let square = px(0.).scale(scale);
2739 assert_eq!(quad.corner_radii.bottom_left, square, "{mode}: bottom left");
2740 assert_eq!(
2741 quad.corner_radii.bottom_right, square,
2742 "{mode}: bottom right"
2743 );
2744 }
2745 }
2746
2747 #[test]
2753 fn table_keeps_narrow_columns_on_one_line_while_wide_ones_wrap() {
2754 use crate::text::inline::test_fonts::WideMonoTextSystem;
2755 use gpui::TestApp;
2756 use std::sync::Arc;
2757
2758 const CELL_BACKGROUND: u32 = 0x11aa77;
2759
2760 struct TableRoot {
2761 text_view: Entity<TextViewState>,
2762 width: Pixels,
2763 style: TextViewStyle,
2764 }
2765
2766 impl Render for TableRoot {
2767 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2768 div()
2769 .w(self.width)
2770 .child(TextView::new(&self.text_view).style(self.style.clone()))
2771 }
2772 }
2773
2774 let short = "Operating revenue (USD)";
2775 let source = format!(
2776 "| {short} | Note |\n| --- | --- |\n| {short} | x |\n| 1 | {} |",
2777 "word ".repeat(30)
2778 );
2779 let first_column = |width: f32, style: TextViewStyle| {
2781 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2782 app.update(crate::init);
2783 let cell = style.table_cell().clone().bg(gpui::rgb(CELL_BACKGROUND));
2784 let style = style.with_table_cell(cell);
2785 let mut window = app.open_window(|_, cx| TableRoot {
2786 text_view: cx.new(|cx| TextViewState::markdown(&source, cx)),
2787 width: px(width),
2788 style,
2789 });
2790 window.draw();
2791 app.run_until_parked();
2792 window.draw();
2793 window.update(|_, window, _| {
2794 let background: gpui::Background = gpui::rgb(CELL_BACKGROUND).into();
2795 let cells: Vec<_> = window
2796 .painted_quads()
2797 .into_iter()
2798 .filter(|quad| quad.background == background)
2799 .map(|quad| quad.bounds)
2800 .collect();
2801 let left = cells.iter().map(|bounds| bounds.origin.x).min().unwrap();
2802 let mut column: Vec<_> = cells
2803 .into_iter()
2804 .filter(|bounds| bounds.origin.x == left)
2805 .collect();
2806 column.sort_by_key(|bounds| bounds.origin.y);
2807 column
2808 .iter()
2809 .map(|bounds| bounds.size.height)
2810 .collect::<Vec<_>>()
2811 })
2812 };
2813 let scroll = || {
2814 let mut table = StyleRefinement::default();
2815 table.overflow.x = Some(gpui::Overflow::Scroll);
2816 TextViewStyle::default().with_table(table)
2817 };
2818
2819 for (layout, style) in [("wrap", TextViewStyle::default()), ("scroll", scroll())] {
2820 let fits = first_column(4000., style.clone());
2821 let narrow = first_column(700., style);
2822 assert_eq!(fits.len(), 3, "{layout}: three rows");
2823 assert_eq!(fits[0], fits[2], "{layout}: the header fits on one line");
2824 assert_eq!(narrow[..2], fits[..2], "{layout}: the short column wraps");
2825 }
2826
2827 let single_line = first_column(4000., TextViewStyle::default())[0];
2828 let padded = TextViewStyle::default().with_table_cell(StyleRefinement::default().px_4());
2829 assert_eq!(
2830 first_column(4000., padded)[0],
2831 single_line,
2832 "the cell padding refinement leaves the text its measured width"
2833 );
2834 let bold = TextViewStyle::default()
2835 .with_table(StyleRefinement::default().font_weight(gpui::FontWeight::BOLD));
2836 assert_eq!(
2837 first_column(4000., bold)[0],
2838 single_line,
2839 "the table text refinement is measured"
2840 );
2841 }
2842
2843 #[gpui::test]
2844 fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
2845 cx.update(crate::init);
2846 let (_, cx) =
2847 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2848 let cx: &mut VisualTestContext = cx;
2849
2850 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2851
2852 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2853 }
2854
2855 #[gpui::test]
2856 fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
2857 cx.update(crate::init);
2858 let (_, cx) =
2859 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2860 let cx: &mut VisualTestContext = cx;
2861
2862 cx.simulate_mouse_down(
2863 point(px(10.), px(10.)),
2864 MouseButton::Right,
2865 Modifiers::default(),
2866 );
2867 cx.simulate_mouse_up(
2868 point(px(10.), px(10.)),
2869 MouseButton::Right,
2870 Modifiers::default(),
2871 );
2872
2873 assert_eq!(cx.opened_url(), None);
2874 }
2875
2876 #[gpui::test]
2877 fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
2878 use std::sync::{Arc, Mutex};
2879
2880 struct LinkRoot {
2881 text_view: Entity<TextViewState>,
2882 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
2883 }
2884
2885 impl Render for LinkRoot {
2886 fn render(
2887 &mut self,
2888 _window: &mut Window,
2889 _cx: &mut Context<Self>,
2890 ) -> impl IntoElement {
2891 let clicks = self.clicks.clone();
2892 div()
2893 .w(px(240.))
2894 .child(
2895 TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
2896 clicks.lock().unwrap().push((url.clone(), event.clone()));
2897 }),
2898 )
2899 }
2900 }
2901
2902 cx.update(crate::init);
2903 let clicks = Arc::new(Mutex::new(Vec::new()));
2904 let captured = clicks.clone();
2905 let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
2906 text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
2907 clicks,
2908 });
2909 let cx: &mut VisualTestContext = cx;
2910
2911 let mut modifiers = Modifiers::default();
2912 modifiers.control = true;
2913 cx.simulate_click(point(px(10.), px(10.)), modifiers);
2914 cx.simulate_mouse_down(
2915 point(px(10.), px(10.)),
2916 MouseButton::Middle,
2917 Modifiers::default(),
2918 );
2919 cx.simulate_mouse_up(
2920 point(px(10.), px(10.)),
2921 MouseButton::Middle,
2922 Modifiers::default(),
2923 );
2924 cx.simulate_mouse_down(
2925 point(px(10.), px(10.)),
2926 MouseButton::Right,
2927 Modifiers::default(),
2928 );
2929 cx.simulate_mouse_up(
2930 point(px(10.), px(10.)),
2931 MouseButton::Right,
2932 Modifiers::default(),
2933 );
2934
2935 let clicks = captured.lock().unwrap();
2936 assert_eq!(clicks.len(), 3);
2937 assert_eq!(clicks[0].0, "https://example.com");
2938 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
2939 assert!(clicks[0].1.modifiers().control);
2940 assert!(clicks[1].1.is_middle_click());
2941 assert!(clicks[2].1.is_right_click());
2942 assert_eq!(cx.opened_url(), None);
2943 }
2944
2945 #[gpui::test]
2946 fn inline_object_inherits_bold_and_link_clicks_without_opening_on_drag(
2947 cx: &mut TestAppContext,
2948 ) {
2949 use std::sync::{Arc, Mutex};
2950 struct Root {
2951 state: Entity<TextViewState>,
2952 clicks: Arc<Mutex<Vec<SharedString>>>,
2953 }
2954 impl Render for Root {
2955 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2956 let clicks = self.clicks.clone();
2957 let extensions = crate::text::MarkdownExtensions::default().plugin(
2958 crate::text::markdown_ext::TestInlinePlugin::new("math")
2959 .parse_with(|node, _| {
2960 matches!(node, markdown::mdast::Node::InlineMath(_)).then(|| {
2961 super::super::MarkdownNode::new("math", ()).text("formula")
2962 })
2963 })
2964 .render_with(|_, context, _, _| {
2965 assert_eq!(context.text_style().font_weight, gpui::FontWeight::BOLD);
2966 Some(super::super::InlineElement::new(div().child("formula")))
2967 }),
2968 );
2969 div().w(px(300.)).child(crate::TextSelectionLayer).child(
2970 TextView::new(&self.state)
2971 .markdown_extensions(extensions)
2972 .on_link_click(move |url, _, _, _| {
2973 clicks.lock().unwrap().push(url.clone())
2974 }),
2975 )
2976 }
2977 }
2978 cx.update(crate::init);
2979 let clicks = Arc::new(Mutex::new(Vec::new()));
2980 let captured = clicks.clone();
2981 let (root, cx) = cx.add_window_view(move |_, cx| Root {
2982 state: cx.new(|cx| TextViewState::markdown("**[$x$](https://example.com)**", cx)),
2983 clicks,
2984 });
2985 let cx: &mut VisualTestContext = cx;
2986 cx.run_until_parked();
2987 cx.update(|window, cx| {
2988 let _ = window.draw(cx);
2989 });
2990 let bounds = root.read_with(cx, |root, cx| {
2991 root.state.read(cx).selection_adapter.text_bounds()[0]
2992 });
2993 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
2994 cx.simulate_mouse_down(bounds.center(), button, Modifiers::default());
2995 cx.simulate_mouse_up(bounds.center(), button, Modifiers::default());
2996 }
2997 assert_eq!(captured.lock().unwrap().len(), 3);
2998 let start = point(bounds.left() + px(1.), bounds.center().y);
2999 let end = point(bounds.right() - px(1.), bounds.center().y);
3000 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
3001 cx.update(|window, cx| {
3002 let _ = window.draw(cx);
3003 });
3004 cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
3005 cx.update(|window, cx| {
3006 let _ = window.draw(cx);
3007 });
3008 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
3009 assert_eq!(captured.lock().unwrap().len(), 3);
3010 }
3011
3012 #[gpui::test]
3013 fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
3014 use std::sync::{Arc, Mutex};
3015
3016 struct LinkedImageRoot {
3017 text_view: Entity<TextViewState>,
3018 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
3019 }
3020
3021 impl Render for LinkedImageRoot {
3022 fn render(
3023 &mut self,
3024 _window: &mut Window,
3025 _cx: &mut Context<Self>,
3026 ) -> impl IntoElement {
3027 let clicks = self.clicks.clone();
3028 div().w(px(160.)).child(
3029 TextView::new(&self.text_view)
3030 .selectable(true)
3031 .on_link_click(move |url, event, _, _| {
3032 clicks.lock().unwrap().push((url.clone(), event.clone()));
3033 }),
3034 )
3035 }
3036 }
3037
3038 cx.update(crate::init);
3039 let clicks = Arc::new(Mutex::new(Vec::new()));
3040 let captured = clicks.clone();
3041 let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
3042 text_view: cx.new(|cx| {
3043 TextViewState::markdown(
3044 r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
3045 cx,
3046 )
3047 }),
3048 clicks,
3049 }
3050 );
3051 let cx: &mut VisualTestContext = cx;
3052 cx.run_until_parked();
3053 cx.update(|window, cx| {
3054 let _ = window.draw(cx);
3055 });
3056
3057 let inline_bounds = content.read_with(cx, |content, cx| {
3058 content.text_view.read(cx).selection_adapter.text_bounds()
3059 });
3060 assert!(
3061 inline_bounds.len() >= 2,
3062 "linked image needs text bounds on both sides: {inline_bounds:?}"
3063 );
3064 assert!(
3065 inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
3066 "linked image did not reserve the expected click target: {inline_bounds:?}"
3067 );
3068 let position = point(
3069 inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
3070 inline_bounds[0].top() + px(8.),
3071 );
3072 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
3073 cx.simulate_mouse_down(position, button, Modifiers::default());
3074 cx.simulate_mouse_up(position, button, Modifiers::default());
3075 }
3076
3077 let clicks = captured.lock().unwrap();
3078 assert_eq!(clicks.len(), 3);
3079 assert!(
3080 clicks
3081 .iter()
3082 .all(|(url, _)| url == "https://example.com/image-link")
3083 );
3084 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
3085 assert!(clicks[1].1.is_middle_click());
3086 assert!(clicks[2].1.is_right_click());
3087 assert_eq!(cx.opened_url(), None);
3088 }
3089
3090 #[gpui::test]
3091 fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
3092 cx.update(crate::init);
3093 let (view, cx) = cx
3094 .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
3095 let cx: &mut VisualTestContext = cx;
3096
3097 cx.simulate_mouse_down(
3098 point(px(10.), px(34.)),
3099 MouseButton::Left,
3100 Modifiers::default(),
3101 );
3102 cx.simulate_mouse_move(
3103 point(px(90.), px(34.)),
3104 Some(MouseButton::Left),
3105 Modifiers::default(),
3106 );
3107 cx.simulate_mouse_up(
3108 point(px(90.), px(34.)),
3109 MouseButton::Left,
3110 Modifiers::default(),
3111 );
3112
3113 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3114 assert!(
3115 selected_text.is_empty(),
3116 "unexpected selection: {selected_text:?}"
3117 );
3118 }
3119
3120 struct ClippedTallTextViewTestRoot {
3124 text_view: Entity<TextViewState>,
3125 }
3126
3127 impl ClippedTallTextViewTestRoot {
3128 fn new(cx: &mut Context<Self>) -> Self {
3129 let text_view =
3133 cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
3134 Self { text_view }
3135 }
3136 }
3137
3138 impl Render for ClippedTallTextViewTestRoot {
3139 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3140 div()
3141 .w(px(200.))
3142 .child(crate::TextSelectionLayer)
3143 .child(
3144 div()
3145 .h(px(40.))
3146 .overflow_hidden()
3147 .child(TextView::new(&self.text_view).selectable(true)),
3148 )
3149 .child(div().h(px(160.)))
3152 }
3153 }
3154
3155 #[gpui::test]
3164 fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
3165 cx.update(crate::init);
3166 let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
3167 let cx: &mut VisualTestContext = cx;
3168
3169 cx.run_until_parked();
3170 cx.update(|window, cx| {
3171 let _ = window.draw(cx);
3172 });
3173
3174 cx.simulate_mouse_down(
3178 point(px(2.), px(8.)),
3179 MouseButton::Left,
3180 Modifiers::default(),
3181 );
3182 cx.update(|window, cx| {
3183 let _ = window.draw(cx);
3184 });
3185 cx.simulate_mouse_move(
3186 point(px(180.), px(150.)),
3187 Some(MouseButton::Left),
3188 Modifiers::default(),
3189 );
3190 cx.update(|window, cx| {
3191 let _ = window.draw(cx);
3192 });
3193 cx.simulate_mouse_up(
3194 point(px(180.), px(150.)),
3195 MouseButton::Left,
3196 Modifiers::default(),
3197 );
3198 cx.update(|window, cx| {
3199 let _ = window.draw(cx);
3200 });
3201
3202 let selected_text =
3203 content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3204 assert!(
3205 selected_text.contains("delta"),
3206 "clipped-out text was not copied: {selected_text:?}"
3207 );
3208 assert!(
3209 selected_text.contains("charlie"),
3210 "clipped-out text was not copied: {selected_text:?}"
3211 );
3212 }
3213
3214 #[gpui::test]
3215 fn double_click_selects_word(cx: &mut TestAppContext) {
3216 cx.update(crate::init);
3217 let (view, cx) =
3218 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3219
3220 let cx: &mut VisualTestContext = cx;
3221 cx.run_until_parked();
3222 cx.update(|window, cx| {
3223 let _ = window.draw(cx);
3224 });
3225 let position = point(px(10.), px(16.));
3226 cx.simulate_event(MouseDownEvent {
3227 position,
3228 modifiers: Modifiers::default(),
3229 button: MouseButton::Left,
3230 click_count: 2,
3231 first_mouse: false,
3232 });
3233 cx.simulate_event(MouseUpEvent {
3234 position,
3235 modifiers: Modifiers::default(),
3236 button: MouseButton::Left,
3237 click_count: 2,
3238 });
3239 cx.update(|window, cx| {
3240 let _ = window.draw(cx);
3241 });
3242
3243 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3244 assert_eq!(selected_text.trim(), "quick");
3245 }
3246
3247 #[gpui::test]
3248 fn long_press_selects_word_then_drag_extends_selection(cx: &mut TestAppContext) {
3249 struct TouchRoot {
3250 text_view: Entity<TextViewState>,
3251 }
3252 impl Render for TouchRoot {
3253 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3254 div()
3255 .w(px(300.))
3256 .child(crate::TextSelectionLayer)
3257 .child(TextView::new(&self.text_view).selectable(true))
3258 }
3259 }
3260 cx.update(crate::init);
3261 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3262 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3263 });
3264 cx.run_until_parked();
3265 cx.update(|window, cx| {
3266 let _ = window.draw(cx);
3267 });
3268 let start_position = point(px(10.), px(16.));
3269 cx.simulate_event(gpui::LongPressEvent {
3270 phase: gpui::TouchPhase::Started,
3271 start_position,
3272 position: start_position,
3273 });
3274 cx.update(|window, cx| {
3275 let _ = window.draw(cx);
3276 });
3277 assert_eq!(
3278 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3279 .trim(),
3280 "quick"
3281 );
3282 for phase in [gpui::TouchPhase::Moved, gpui::TouchPhase::Ended] {
3283 cx.simulate_event(gpui::LongPressEvent {
3284 phase,
3285 start_position,
3286 position: point(px(220.), px(16.)),
3287 });
3288 }
3289 cx.update(|window, cx| {
3290 let _ = window.draw(cx);
3291 });
3292 assert_eq!(
3293 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3294 .trim(),
3295 "quick select value"
3296 );
3297 }
3298
3299 #[gpui::test]
3300 fn long_press_release_keeps_handles_which_drag_the_selection(cx: &mut TestAppContext) {
3301 use crate::{SelectionEdge, TextSelection};
3302
3303 struct TouchRoot {
3304 text_view: Entity<TextViewState>,
3305 }
3306 impl Render for TouchRoot {
3307 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3308 div()
3309 .w(px(300.))
3310 .child(crate::TextSelectionLayer)
3311 .child(TextView::new(&self.text_view).selectable(true))
3312 }
3313 }
3314 cx.update(crate::init);
3315 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3316 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3317 });
3318 let draw = |cx: &mut VisualTestContext| {
3319 cx.update(|window, cx| {
3320 let _ = window.draw(cx);
3321 });
3322 };
3323 let selected = |cx: &mut VisualTestContext| {
3324 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3325 .trim()
3326 .to_string()
3327 };
3328 cx.run_until_parked();
3329 draw(cx);
3330
3331 let start_position = point(px(70.), px(16.));
3332 for phase in [gpui::TouchPhase::Started, gpui::TouchPhase::Ended] {
3333 cx.simulate_event(gpui::LongPressEvent {
3334 phase,
3335 start_position,
3336 position: start_position,
3337 });
3338 draw(cx);
3339 }
3340 assert_eq!(selected(cx), "select");
3341 let snapshot = cx
3342 .update(|window, cx| TextSelection::touch_selection(window, cx))
3343 .expect("a released long press keeps its handles");
3344 assert!(snapshot.is_menu_open());
3345 assert!(!snapshot.is_empty());
3346 assert!(snapshot.start().left() < snapshot.end().left());
3347
3348 let end = snapshot.end();
3351 let finger = point(end.left(), end.bottom() + px(20.));
3352 cx.update(|window, cx| {
3353 TextSelection::begin_edge_drag(SelectionEdge::End, finger, window, cx);
3354 });
3355 draw(cx);
3356 let snapshot = cx
3357 .update(|window, cx| TextSelection::touch_selection(window, cx))
3358 .unwrap();
3359 assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
3360 assert!(!snapshot.is_menu_open());
3361 cx.update(|window, cx| {
3362 TextSelection::update_edge_drag(point(px(290.), finger.y), window, cx);
3363 });
3364 draw(cx);
3365 assert_eq!(selected(cx), "select value");
3366 cx.update(|window, cx| TextSelection::end_edge_drag(window, cx));
3367 draw(cx);
3368 let snapshot = cx
3369 .update(|window, cx| TextSelection::touch_selection(window, cx))
3370 .unwrap();
3371 assert!(snapshot.is_menu_open());
3372 assert_eq!(snapshot.dragging(), None);
3373 let select_start = snapshot.start().left();
3374
3375 cx.update(|_, cx| {
3378 view.update(cx, |root, cx| {
3379 root.text_view.update(cx, |state, cx| state.select_all(cx));
3380 });
3381 });
3382 draw(cx);
3383 assert_eq!(selected(cx), "quick select value");
3384 let snapshot = cx
3385 .update(|window, cx| TextSelection::touch_selection(window, cx))
3386 .expect("select all keeps the touch selection");
3387 let start = snapshot.start();
3388 cx.update(|window, cx| {
3389 TextSelection::begin_edge_drag(SelectionEdge::Start, start.origin, window, cx);
3390 TextSelection::update_edge_drag(point(select_start, start.origin.y), window, cx);
3391 TextSelection::end_edge_drag(window, cx);
3392 });
3393 draw(cx);
3394 assert_eq!(selected(cx), "select value");
3395
3396 let menu = gpui::Bounds::new(point(px(0.), px(200.)), gpui::size(px(120.), px(32.)));
3399 cx.update(|window, cx| TextSelection::register_touch_ui(menu, window, cx));
3400 cx.simulate_event(MouseDownEvent {
3401 position: point(px(10.), px(210.)),
3402 modifiers: Modifiers::default(),
3403 button: MouseButton::Left,
3404 click_count: 1,
3405 first_mouse: false,
3406 });
3407 assert_eq!(selected(cx), "select value");
3408 cx.simulate_event(MouseDownEvent {
3409 position: point(px(10.), px(16.)),
3410 modifiers: Modifiers::default(),
3411 button: MouseButton::Left,
3412 click_count: 1,
3413 first_mouse: false,
3414 });
3415 cx.simulate_event(MouseUpEvent {
3416 position: point(px(10.), px(16.)),
3417 modifiers: Modifiers::default(),
3418 button: MouseButton::Left,
3419 click_count: 1,
3420 });
3421 draw(cx);
3422 assert!(
3423 cx.update(|window, cx| TextSelection::touch_selection(window, cx))
3424 .is_none()
3425 );
3426 }
3427
3428 #[gpui::test]
3429 fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
3430 cx.update(crate::init);
3431 let (view, cx) =
3432 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3433
3434 let cx: &mut VisualTestContext = cx;
3435 cx.run_until_parked();
3436 cx.update(|window, cx| {
3437 let _ = window.draw(cx);
3438 });
3439
3440 let position = point(px(10.), px(10.));
3441 cx.simulate_event(MouseDownEvent {
3442 position,
3443 modifiers: Modifiers::default(),
3444 button: MouseButton::Left,
3445 click_count: 3,
3446 first_mouse: false,
3447 });
3448 cx.simulate_event(MouseUpEvent {
3449 position,
3450 modifiers: Modifiers::default(),
3451 button: MouseButton::Left,
3452 click_count: 3,
3453 });
3454 cx.update(|window, cx| {
3455 let _ = window.draw(cx);
3456 });
3457
3458 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3459 assert_eq!(selected_text.trim(), "quick select value");
3460 }
3461
3462 #[gpui::test]
3468 fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
3469 use gpui::{ListAlignment, ListState, list};
3470
3471 const ITEMS: &[&str] = &[
3472 "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
3473 "Short.",
3474 "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
3475 "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
3476 "Only one line.",
3477 "**Bold**: medium length text with `code` mixed with regular words.",
3478 "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
3479 "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
3480 ];
3481 let n = 40usize;
3482
3483 struct ListRoot {
3484 state: ListState,
3485 }
3486 impl Render for ListRoot {
3487 fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3488 div().w(px(360.)).h(px(500.)).child(
3489 list(self.state.clone(), |ix, _w, _cx| {
3490 div()
3491 .w_full()
3492 .child(TextView::markdown(
3493 ("md", ix as u64),
3494 ITEMS[ix % ITEMS.len()],
3495 ))
3496 .into_any_element()
3497 })
3498 .size_full(),
3499 )
3500 }
3501 }
3502
3503 cx.update(crate::init);
3504 let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
3505 let probe = state.clone();
3506 let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
3507 let cx: &mut VisualTestContext = cx;
3508
3509 cx.run_until_parked();
3510 cx.update(|w, cx| {
3511 let _ = w.draw(cx);
3512 });
3513 cx.run_until_parked();
3514 cx.update(|w, cx| {
3515 let _ = w.draw(cx);
3516 });
3517
3518 let total = |p: &ListState| {
3519 f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
3520 };
3521 let mut totals = vec![total(&probe)];
3522 for _ in 0..20 {
3523 probe.scroll_by(px(150.));
3524 cx.update(|w, cx| {
3525 let _ = w.draw(cx);
3526 });
3527 cx.run_until_parked();
3528 totals.push(total(&probe));
3529 }
3530 let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
3531 let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
3532 println!(
3533 "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
3534 max - min
3535 );
3536 assert!(
3537 (max - min) < 2.0,
3538 "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
3539 );
3540 }
3541}