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(crate) fn style(&self) -> Option<&TextViewStyle> {
52 self.style.as_ref()
53 }
54
55 pub(crate) fn code_block_highlighter(&self) -> Option<Arc<CodeBlockHighlighterFn>> {
57 self.code_block_highlighter.clone()
58 }
59
60 pub fn with_code_block_highlighter<F>(mut self, highlighter: F) -> Self
62 where
63 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
64 {
65 self.code_block_highlighter = Some(Arc::new(highlighter));
66 self
67 }
68
69 pub fn with_inherit_text_color(mut self, inherit: bool) -> Self {
78 self.inherit_text_color = inherit;
79 self
80 }
81
82 pub fn install(self, cx: &mut App) {
84 cx.set_global(self);
85 }
86
87 pub fn global(cx: &App) -> Self {
89 cx.try_global::<Self>().cloned().unwrap_or_default()
90 }
91
92 pub fn has_code_block_highlighter(&self) -> bool {
94 self.code_block_highlighter.is_some()
95 }
96
97 pub fn inherit_text_color(&self) -> bool {
99 self.inherit_text_color
100 }
101}
102
103pub(crate) type TableActionsFn =
105 dyn Fn(&TableData, &mut Window, &mut App) -> AnyElement + Send + Sync;
106
107pub(crate) type ImageSourceFn = dyn Fn(&gpui::SharedUri) -> gpui::ImageSource + Send + Sync;
108
109pub(crate) type LinkClickHandlerFn =
110 dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync;
111
112pub(crate) type RevealHandlerFn = dyn Fn(Bounds<Pixels>, &mut Window, &mut App);
115
116pub(crate) fn handle_link_click(
117 handler: &Option<Arc<LinkClickHandlerFn>>,
118 url: SharedString,
119 event: ClickEvent,
120 window: &mut Window,
121 cx: &mut App,
122) {
123 if let Some(handler) = handler {
124 handler(&url, &event, window, cx);
125 } else if match &event {
126 ClickEvent::Mouse(click) => {
127 matches!(click.up.button, MouseButton::Left | MouseButton::Middle)
128 }
129 ClickEvent::Keyboard(_) => true,
130 ClickEvent::Touch(click) => !click.long_press,
131 } {
132 cx.open_url(&url);
133 }
134}
135
136#[derive(Clone)]
153pub struct TextView {
154 id: ElementId,
155 format: Option<TextViewFormat>,
156 text: Option<SharedString>,
157 pub(crate) state: Option<Entity<TextViewState>>,
158 text_view_style: Option<TextViewStyle>,
159 inline_code_style: Option<StyleRefinement>,
160 list_style: Option<StyleRefinement>,
161 blockquote_style: Option<StyleRefinement>,
162 style: StyleRefinement,
163 selectable: bool,
164 selection_format: SelectionFormat,
165 scrollable: bool,
166 max_lines: Option<usize>,
167 code_block_actions: Option<Arc<CodeBlockActionsFn>>,
168 code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
169 table_actions: Option<Arc<TableActionsFn>>,
170 link_click_handler: Option<Arc<LinkClickHandlerFn>>,
171 image_source: Option<Arc<ImageSourceFn>>,
172 reveal_handler: Option<Rc<RevealHandlerFn>>,
173 markdown_extensions: Arc<MarkdownExtensions>,
174 motion: Option<TextViewMotion>,
175}
176
177pub trait TextViewPlugin {
179 fn setup(self, text_view: TextView) -> TextView;
180}
181
182impl<P> TextViewPlugin for P
183where
184 P: MarkdownPlugin,
185{
186 fn setup(self, mut text_view: TextView) -> TextView {
187 let extensions = Arc::make_mut(&mut text_view.markdown_extensions);
188 let current = std::mem::take(extensions);
189 *extensions = current.plugin(self);
190 text_view
191 }
192}
193
194impl Styled for TextView {
195 fn style(&mut self) -> &mut StyleRefinement {
196 &mut self.style
197 }
198}
199
200impl TextView {
201 pub fn new(state: &Entity<TextViewState>) -> Self {
203 Self {
204 id: ElementId::Name(state.entity_id().to_string().into()),
205 state: Some(state.clone()),
206 format: None,
207 text: None,
208 text_view_style: None,
209 inline_code_style: None,
210 list_style: None,
211 blockquote_style: None,
212 style: StyleRefinement::default(),
213 selectable: true,
214 selection_format: SelectionFormat::default(),
215 scrollable: false,
216 max_lines: None,
217 code_block_actions: None,
218 code_block_highlighter: None,
219 table_actions: None,
220 link_click_handler: None,
221 image_source: None,
222 reveal_handler: None,
223 markdown_extensions: Arc::default(),
224 motion: None,
225 }
226 }
227
228 pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
230 Self {
231 id: id.into(),
232 format: Some(TextViewFormat::Markdown),
233 text: Some(markdown.into()),
234 text_view_style: None,
235 inline_code_style: None,
236 list_style: None,
237 blockquote_style: None,
238 style: StyleRefinement::default(),
239 state: None,
240 selectable: true,
241 selection_format: SelectionFormat::default(),
242 scrollable: false,
243 max_lines: None,
244 code_block_actions: None,
245 code_block_highlighter: None,
246 table_actions: None,
247 link_click_handler: None,
248 image_source: None,
249 reveal_handler: None,
250 markdown_extensions: Arc::default(),
251 motion: None,
252 }
253 }
254
255 pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
257 Self {
258 id: id.into(),
259 format: Some(TextViewFormat::Html),
260 text: Some(html.into()),
261 text_view_style: None,
262 inline_code_style: None,
263 list_style: None,
264 blockquote_style: None,
265 style: StyleRefinement::default(),
266 state: None,
267 selectable: true,
268 selection_format: SelectionFormat::default(),
269 scrollable: false,
270 max_lines: None,
271 code_block_actions: None,
272 code_block_highlighter: None,
273 table_actions: None,
274 link_click_handler: None,
275 image_source: None,
276 reveal_handler: None,
277 markdown_extensions: Arc::default(),
278 motion: None,
279 }
280 }
281
282 pub fn image_source<F>(mut self, resolver: F) -> Self
288 where
289 F: Fn(&gpui::SharedUri) -> gpui::ImageSource + Send + Sync + 'static,
290 {
291 self.image_source = Some(Arc::new(resolver));
292 self
293 }
294
295 pub fn inline_code_style(mut self, style: StyleRefinement) -> Self {
299 self.inline_code_style = Some(super::style::normalize_inline_code_style(style));
300 self
301 }
302
303 pub fn list_style(mut self, style: StyleRefinement) -> Self {
307 self.list_style = Some(style);
308 self
309 }
310
311 pub fn blockquote_style(mut self, style: StyleRefinement) -> Self {
315 self.blockquote_style = Some(style);
316 self
317 }
318
319 pub fn style(mut self, style: TextViewStyle) -> Self {
321 self.text_view_style = Some(style);
322 self
323 }
324
325 pub fn selectable(mut self, selectable: bool) -> Self {
327 self.selectable = selectable;
328 self
329 }
330
331 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
336 self.selection_format = selection_format;
337 self
338 }
339
340 pub fn scrollable(mut self, scrollable: bool) -> Self {
353 self.scrollable = scrollable;
354 self
355 }
356
357 pub fn max_lines(mut self, max_lines: usize) -> Self {
376 self.max_lines = Some(max_lines);
377 self
378 }
379
380 pub fn code_block_actions<F, E>(mut self, f: F) -> Self
385 where
386 F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
387 E: IntoElement,
388 {
389 self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
390 f(&code_block, window, cx).into_any_element()
391 }));
392 self
393 }
394
395 pub fn code_block_highlighter<F>(mut self, highlighter: F) -> Self
400 where
401 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
402 {
403 self.code_block_highlighter = Some(Arc::new(highlighter));
404 self
405 }
406
407 #[doc(hidden)]
414 pub fn shared_code_block_highlighter(
415 mut self,
416 highlighter: Arc<
417 dyn Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync,
418 >,
419 ) -> Self {
420 self.code_block_highlighter = Some(highlighter);
421 self
422 }
423
424 pub fn table_actions<F, E>(mut self, f: F) -> Self
429 where
430 F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
431 E: IntoElement,
432 {
433 self.table_actions = Some(Arc::new(move |table, window, cx| {
434 f(table, window, cx).into_any_element()
435 }));
436 self
437 }
438
439 pub fn on_link_click<F>(mut self, handler: F) -> Self
444 where
445 F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
446 {
447 self.link_click_handler = Some(Arc::new(handler));
448 self
449 }
450
451 pub fn on_reveal<F>(mut self, handler: F) -> Self
460 where
461 F: Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
462 {
463 self.reveal_handler = Some(Rc::new(handler));
464 self
465 }
466
467 pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self {
469 self.markdown_extensions = Arc::new(extensions);
470 self
471 }
472
473 pub fn motion(mut self, motion: TextViewMotion) -> Self {
476 self.motion = Some(motion);
477 self
478 }
479
480 pub fn markdown_mdx(mut self) -> Self {
485 let extensions = Arc::make_mut(&mut self.markdown_extensions);
486 *extensions = extensions.clone().mdx();
487 self
488 }
489
490 pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
496 where
497 F: for<'a> Fn(
498 &markdown::mdast::Node,
499 &crate::text::MarkdownParseContext<'a>,
500 ) -> Option<MarkdownNode>
501 + Send
502 + Sync
503 + 'static,
504 {
505 Arc::make_mut(&mut self.markdown_extensions).push_block_parser(parser);
506 self
507 }
508
509 pub fn markdown_block_renderer<F, E>(
511 mut self,
512 name: impl Into<SharedString>,
513 renderer: F,
514 ) -> Self
515 where
516 F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
517 E: IntoElement,
518 {
519 Arc::make_mut(&mut self.markdown_extensions).push_block_renderer(name, renderer);
520 self
521 }
522
523 pub fn plugin<P>(self, plugin: P) -> Self
525 where
526 P: TextViewPlugin,
527 {
528 plugin.setup(self)
529 }
530}
531
532impl IntoElement for TextView {
533 type Element = Self;
534
535 fn into_element(self) -> Self::Element {
536 self
537 }
538}
539
540pub struct TextViewLayoutState {
541 state: Entity<TextViewState>,
542 element: AnyElement,
543}
544
545pub struct TextViewPrepaintState {
546 hitbox: Hitbox,
547 clip_bottom: Option<Pixels>,
551 touch_handles: crate::TouchHandleLayout,
553}
554
555const CLIP_EPSILON: Pixels = px(1.);
558
559fn last_line_bottom_above(spans: &[LineSpan], y: Pixels) -> Option<(Pixels, Pixels)> {
562 let mut last: Option<(Pixels, Pixels)> = None;
563 let mut keep = |bottom: Pixels, line_height: Pixels| {
564 if bottom <= y + CLIP_EPSILON && last.is_none_or(|(last, _)| bottom > last) {
565 last = Some((bottom, line_height));
566 }
567 };
568
569 for span in spans {
570 if span.line_height <= px(0.) {
571 continue;
572 }
573 let mut bottom = span.top + span.line_height;
574 while bottom <= span.bottom + CLIP_EPSILON {
575 keep(bottom, span.line_height);
576 bottom += span.line_height;
577 }
578 keep(span.bottom, span.line_height);
580 }
581
582 last
583}
584
585fn line_safe_clip_bottom(
595 spans: &[LineSpan],
596 box_bottom: Pixels,
597 content_bottom: Pixels,
598) -> Option<Pixels> {
599 let mut clip = box_bottom;
600
601 for span in spans {
602 if span.line_height <= px(0.)
603 || span.top >= box_bottom
604 || span.bottom <= box_bottom + CLIP_EPSILON
605 {
606 continue;
607 }
608 let whole_lines = ((box_bottom - span.top) / span.line_height).floor();
609 let line_top = span.top + span.line_height * whole_lines;
610 if line_top < box_bottom - CLIP_EPSILON {
612 clip = clip.min(line_top);
613 }
614 }
615
616 let Some((last_line_bottom, line_height)) = last_line_bottom_above(spans, clip) else {
617 return None;
622 };
623
624 if content_bottom > box_bottom + CLIP_EPSILON {
628 let strip = clip - last_line_bottom;
629 if strip > CLIP_EPSILON && strip < line_height {
630 clip = last_line_bottom;
631 }
632 }
633
634 (clip < box_bottom - CLIP_EPSILON).then_some(clip)
635}
636
637impl Element for TextView {
638 type RequestLayoutState = TextViewLayoutState;
639 type PrepaintState = TextViewPrepaintState;
640
641 fn id(&self) -> Option<ElementId> {
642 Some(self.id.clone())
643 }
644
645 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
646 None
647 }
648
649 fn request_layout(
650 &mut self,
651 _: Option<&GlobalElementId>,
652 _: Option<&InspectorElementId>,
653 window: &mut Window,
654 cx: &mut App,
655 ) -> (LayoutId, Self::RequestLayoutState) {
656 let state = if let Some(state) = self.state.clone() {
657 state
658 } else {
659 let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
660 let default_text = self.text.clone().unwrap_or_default();
661
662 let state = window.use_keyed_state(
663 SharedString::from(format!("{}/state", self.id)),
664 cx,
665 move |_, cx| {
666 if default_format == TextViewFormat::Markdown {
667 TextViewState::markdown(default_text.as_str(), cx)
668 } else {
669 TextViewState::html(default_text.as_str(), cx)
670 }
671 },
672 );
673 self.state = Some(state.clone());
674 state
675 };
676
677 let max_lines = self.max_lines.filter(|_| !self.scrollable);
680
681 let defaults = cx.try_global::<TextViewDefaults>();
684 let theme_style;
685 let mut text_view_style = match (
686 &self.text_view_style,
687 defaults.and_then(|d| d.style.as_ref()),
688 ) {
689 (Some(style), _) | (None, Some(style)) => style,
690 (None, None) => {
691 theme_style = TextViewStyle::from_theme(&crate::Theme::global(cx));
692 &theme_style
693 }
694 };
695 let surface_style;
698 let mut on_inverted_surface = false;
699 if self.text_view_style.is_none() && defaults.is_some_and(|d| d.inherit_text_color) {
700 let color = self
701 .style
702 .text
703 .color
704 .unwrap_or_else(|| window.text_style().color);
705 if color != text_view_style.foreground() {
706 on_inverted_surface = text_view_style.is_inverted_by(color);
707 surface_style = text_view_style.on_text_color(color);
708 text_view_style = &surface_style;
709 }
710 }
711 let inline_style;
712 if let Some(refinement) = &self.inline_code_style {
713 let mut style = text_view_style.inline_code_style().clone();
714 style.refine(refinement);
715 inline_style = text_view_style.clone().with_inline_code_style(style);
716 text_view_style = &inline_style;
717 }
718 let block_style;
719 if self.list_style.is_some() || self.blockquote_style.is_some() {
720 let mut style = text_view_style.clone();
721 if let Some(refinement) = &self.list_style {
722 let mut marker = style.list_style().clone();
723 marker.refine(refinement);
724 style = style.with_list_style(marker);
725 }
726 if let Some(refinement) = &self.blockquote_style {
727 let mut quote = style.blockquote_style().clone();
728 quote.refine(refinement);
729 style = style.with_blockquote_style(quote);
730 }
731 block_style = style;
732 text_view_style = &block_style;
733 }
734 let foreground = text_view_style.foreground();
735 let text_view_style = (*state.read(cx).text_view_style != *text_view_style)
736 .then(|| Arc::new(text_view_style.clone()));
737 let code_block_highlighter = self.code_block_highlighter.clone().or_else(|| {
738 defaults
739 .filter(|_| !on_inverted_surface)
740 .and_then(|d| d.code_block_highlighter.clone())
741 });
742
743 state.update(cx, |state, cx| {
744 if text_view_style.is_some()
745 || match (&state.code_block_highlighter, &code_block_highlighter) {
746 (Some(previous), Some(next)) => !Arc::ptr_eq(previous, next),
747 (None, None) => false,
748 _ => true,
749 }
750 {
751 state.preserve_selection_for_reflow();
752 }
753 state.code_block_actions = self.code_block_actions.clone();
754 state.code_block_highlighter = code_block_highlighter;
755 state.table_actions = self.table_actions.clone();
756 state.link_click_handler = self.link_click_handler.clone();
757 state.image_source = self.image_source.clone();
758 state.set_markdown_extensions(self.markdown_extensions.clone(), cx);
759 if let Some(motion) = &self.motion {
760 state.set_motion(motion.clone());
761 }
762 state.selectable = self.selectable;
763 state.selection_format = self.selection_format;
764 state.scrollable = self.scrollable;
765 state.max_lines = max_lines;
766 if let Some(text_view_style) = text_view_style {
767 state.text_view_style = text_view_style;
768 }
769
770 if let Some(text) = &self.text {
771 state.set_element_text(text, cx);
772 }
773 });
774
775 let focus_handle = state.read(cx).focus_handle.clone();
776 let list_state = state.read(cx).list_state.clone();
777 let max_lines_cap = max_lines.map(|max_lines| {
781 let mut text_style = window.text_style();
782 text_style.refine(&self.style.text);
783 text_style.line_height_in_pixels(window.rem_size()) * max_lines as f32
784 });
785
786 let mut el = div()
787 .id(("text-view-scroll", state.entity_id()))
788 .key_context("TextView")
789 .track_focus(&focus_handle)
790 .when(self.scrollable, |this| this.size_full())
791 .when_some(max_lines_cap, |this, cap| this.max_h(cap).overflow_hidden())
792 .relative()
793 .text_color(foreground)
794 .on_action(move |_: &crate::input::Copy, window, cx| {
795 let text = TextSelection::selected_text(window, cx).trim().to_string();
796 if text.is_empty() {
797 cx.propagate();
798 return;
799 }
800 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
801 })
802 .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
803 .map(|this| {
804 if self.scrollable {
805 this.child(crate::ScrollBounce::new(
806 ("text-view-bounce", state.entity_id()),
807 &list_state,
808 state.clone(),
809 ))
810 } else {
811 this.child(state.clone())
812 }
813 })
814 .when(self.scrollable, |this| {
817 this.child(
818 div().absolute().inset_0().child(
819 crate::Scrollbar::vertical(&list_state)
820 .id(("text-view-scrollbar", state.entity_id()))
821 .viewport_from_layout(),
822 ),
823 )
824 })
825 .refine_style(&self.style)
826 .into_any_element();
827 let layout_id = el.request_layout(window, cx);
828 (layout_id, TextViewLayoutState { state, element: el })
829 }
830
831 fn prepaint(
832 &mut self,
833 _: Option<&GlobalElementId>,
834 _: Option<&InspectorElementId>,
835 bounds: Bounds<Pixels>,
836 request_layout: &mut Self::RequestLayoutState,
837 window: &mut Window,
838 cx: &mut App,
839 ) -> Self::PrepaintState {
840 let state = request_layout.state.clone();
841 let max_lines_active = state.read(cx).max_lines.is_some();
842 if max_lines_active {
843 if let Ok(mut line_spans) = state.read(cx).line_spans.lock() {
844 line_spans.clear();
845 }
846 }
847 GlobalState::global(cx).push_text_view_state(state.clone());
852 request_layout.element.prepaint(window, cx);
853 GlobalState::global(cx).pop_text_view_state();
854
855 let mut clip_bottom = None;
856 if max_lines_active {
857 let (line_spans, content_bottom) = {
858 let state = state.read(cx);
859 (
860 state
861 .line_spans
862 .lock()
863 .map(|spans| spans.clone())
864 .unwrap_or_default(),
865 state.bounds().bottom(),
866 )
867 };
868 let clipped = content_bottom > bounds.bottom() + px(1.);
872 if state.read(cx).clamped != clipped {
875 state.update(cx, |state, cx| {
876 state.clamped = clipped;
877 cx.notify();
878 });
879 }
880 if clipped {
881 clip_bottom = line_safe_clip_bottom(&line_spans, bounds.bottom(), content_bottom);
882 }
883 }
884
885 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
886 let touch_handles = if self.selectable {
888 state
889 .read(cx)
890 .selection_adapter
891 .prepaint_touch_handles(window, cx)
892 } else {
893 crate::TouchHandleLayout::default()
894 };
895 TextViewPrepaintState {
896 hitbox,
897 clip_bottom,
898 touch_handles,
899 }
900 }
901
902 fn paint(
903 &mut self,
904 _: Option<&GlobalElementId>,
905 _: Option<&InspectorElementId>,
906 bounds: Bounds<Pixels>,
907 request_layout: &mut Self::RequestLayoutState,
908 prepaint: &mut Self::PrepaintState,
909 window: &mut Window,
910 cx: &mut App,
911 ) {
912 let state = &request_layout.state;
913 if self.selectable {
914 state.read(cx).selection_adapter.begin_frame();
915 }
916
917 GlobalState::global(cx).push_text_view_state(state.clone());
918 if let Some(clip_bottom) = prepaint.clip_bottom {
919 let mask = ContentMask {
922 bounds: Bounds::from_corners(bounds.origin, point(bounds.right(), clip_bottom)),
923 };
924 window.with_content_mask(Some(mask), |window| {
925 request_layout.element.paint(window, cx);
926 });
927 } else {
928 request_layout.element.paint(window, cx);
929 }
930 GlobalState::global(cx).pop_text_view_state();
931
932 if state.read(cx).pending_reveal.is_some() {
935 let progress = state.update(cx, |state, _| {
936 state.pending_reveal.as_mut().map(PendingReveal::progress)
937 });
938 match progress {
939 Some(RevealProgress::Shown) => {
940 state.update(cx, |state, _| state.pending_reveal = None);
941 }
942 Some(RevealProgress::Hidden(line)) => {
943 if let Some(handler) = &self.reveal_handler {
944 handler(line, window, cx);
945 }
946 }
947 Some(RevealProgress::NotLaidOut) | None => {}
948 }
949 }
950
951 if self.selectable {
952 let (adapter, scroll_offset, content_bounds, self_scroll, handle_color) = {
953 let state = state.read(cx);
954 (
955 state.selection_adapter.clone(),
956 state.scroll_offset(),
957 state.bounds(),
958 state.scrollable,
959 state.text_view_style.selection().alpha(1.),
960 )
961 };
962 adapter.register(
963 prepaint.hitbox.clone(),
964 content_bounds,
965 scroll_offset,
966 self_scroll,
967 window,
968 cx,
969 );
970 adapter.paint_touch_handles(&prepaint.touch_handles, handle_color, window, cx);
973 }
974 }
975}
976
977#[cfg(test)]
978mod tests {
979 use std::sync::{
980 Arc,
981 atomic::{AtomicUsize, Ordering},
982 };
983
984 use super::{TextView, TextViewPlugin};
985 use crate::text::{SelectionFormat, TableData, TextViewState, TextViewStyle};
986 use gpui::{
987 AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
988 Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
989 Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
990 TestAppContext, VisualTestContext, Window, div, point, prelude::FluentBuilder as _, px,
991 rems,
992 };
993
994 struct TextViewTestRoot {
995 text_view: Entity<TextViewState>,
996 }
997
998 struct InlineHoverTestRoot {
999 view: Entity<TextViewState>,
1000 builds: Arc<AtomicUsize>,
1001 format: crate::text::SelectionFormat,
1002 }
1003
1004 struct InlineHoverCard;
1005 impl Render for InlineHoverCard {
1006 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1007 div()
1008 .debug_selector(|| "inline-test-card".into())
1009 .w(px(120.))
1010 .h(px(40.))
1011 .child("Member profile")
1012 }
1013 }
1014
1015 impl Render for InlineHoverTestRoot {
1016 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1017 let builds = self.builds.clone();
1018 div()
1019 .pl(px(150.))
1020 .w(px(300.))
1021 .text_size(px(16.))
1022 .child(crate::TextSelectionLayer)
1023 .child(
1024 TextView::new(&self.view)
1025 .selection_format(self.format)
1026 .plugin(
1027 crate::text::markdown_ext::TestInlinePlugin::new("mention")
1028 .parse_with(|node, _| {
1029 let markdown::mdast::Node::Link(link) = node else {
1030 return None;
1031 };
1032 let handle = link.url.strip_prefix("mention:")?;
1033 Some(
1034 crate::text::MarkdownNode::new("mention", ())
1035 .text(format!("@{handle}")),
1036 )
1037 })
1038 .render_with(move |_, _, _, _| {
1039 let builds = builds.clone();
1040 Some(crate::text::InlineElement::new(
1041 crate::HoverCard::new("mention-hover")
1042 .anchor(gpui::Anchor::TopCenter)
1043 .trigger(div().child("@member"))
1044 .content(move |_, _, cx| {
1045 builds.fetch_add(1, Ordering::Relaxed);
1046 div()
1047 .id("hover-content")
1048 .child(cx.new(|_| InlineHoverCard))
1049 }),
1050 ))
1051 }),
1052 ),
1053 )
1054 }
1055 }
1056
1057 #[gpui::test]
1058 fn text_view_follows_the_text_color_of_its_container(cx: &mut TestAppContext) {
1059 struct Root {
1060 inherited: Entity<TextViewState>,
1061 explicit: Entity<TextViewState>,
1062 surface_text: gpui::Hsla,
1063 }
1064 impl Render for Root {
1065 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1066 div()
1067 .w(px(300.))
1068 .text_color(crate::ColorTokens::light().foreground)
1069 .child(
1070 div()
1071 .text_color(self.surface_text)
1072 .child(TextView::new(&self.inherited))
1073 .child(TextView::new(&self.explicit).style(TextViewStyle::default())),
1074 )
1075 }
1076 }
1077
1078 let colors = crate::ColorTokens::light();
1079 cx.update(|cx| {
1080 crate::init(cx);
1081 super::TextViewDefaults::new()
1082 .with_style(TextViewStyle::default())
1083 .with_inherit_text_color(true)
1084 .install(cx);
1085 });
1086 let (root, cx) = cx.add_window_view(|_, cx| Root {
1087 inherited: cx.new(|cx| TextViewState::markdown("[link](https://a.b) `code`", cx)),
1088 explicit: cx.new(|cx| TextViewState::markdown("text", cx)),
1089 surface_text: colors.primary_foreground,
1090 });
1091 cx.run_until_parked();
1092 cx.update(|window, cx| {
1093 window.draw(cx).clear(cx);
1094 });
1095
1096 root.read_with(cx, |root, cx| {
1097 let inherited = &root.inherited.read(cx).text_view_style;
1098 assert_eq!(inherited.foreground(), colors.primary_foreground);
1099 assert_eq!(inherited.link(), colors.primary_foreground);
1100 assert!(inherited.is_dark());
1101
1102 let explicit = &root.explicit.read(cx).text_view_style;
1103 assert_eq!(explicit.foreground(), colors.foreground);
1104 assert_eq!(explicit.link(), TextViewStyle::default().link());
1105 });
1106 }
1107
1108 #[gpui::test]
1109 fn inline_plugin_reuses_render_and_preserves_native_child_events(cx: &mut TestAppContext) {
1110 use std::sync::Mutex;
1111 struct ControlPlugin(Arc<Mutex<Vec<String>>>);
1112 impl crate::text::MarkdownPlugin for ControlPlugin {
1113 fn name(&self) -> &str {
1114 "control"
1115 }
1116 fn parse(
1117 &self,
1118 node: &markdown::mdast::Node,
1119 _: &crate::text::MarkdownParseContext<'_>,
1120 ) -> Option<crate::text::MarkdownNode> {
1121 let markdown::mdast::Node::Link(link) = node else {
1122 return None;
1123 };
1124 let label = link.url.strip_prefix("control:")?;
1125 Some(crate::text::MarkdownNode::new("control", ()).text(label.to_string()))
1126 }
1127 fn render(
1128 &self,
1129 node: &crate::text::MarkdownNode,
1130 _: &mut Window,
1131 _: &mut gpui::App,
1132 ) -> impl IntoElement {
1133 let clicks = self.0.clone();
1134 let label = node.as_text().to_string();
1135 div()
1136 .id("same-control-id")
1137 .w(px(60.))
1138 .h(px(24.))
1139 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1140 .on_click(move |_, _, _| clicks.lock().unwrap().push(label.clone()))
1141 .child(node.as_text().to_string())
1142 }
1143 }
1144 struct Root {
1145 view: Entity<TextViewState>,
1146 clicks: Arc<Mutex<Vec<String>>>,
1147 }
1148 impl Render for Root {
1149 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1150 div()
1151 .w(px(300.))
1152 .child(crate::TextSelectionLayer)
1153 .child(TextView::new(&self.view).plugin(ControlPlugin(self.clicks.clone())))
1154 }
1155 }
1156 cx.update(crate::init);
1157 let clicks = Arc::new(Mutex::new(Vec::new()));
1158 let captured = clicks.clone();
1159 let (root, cx) = cx.add_window_view(move |_, cx| Root {
1160 view: cx.new(|cx| TextViewState::markdown("[one](control:one)[two](control:two)", cx)),
1161 clicks,
1162 });
1163 cx.run_until_parked();
1164 cx.update(|window, cx| {
1165 window.draw(cx).clear(cx);
1166 });
1167 let bounds = root.read_with(cx, |root, cx| {
1168 root.view.read(cx).selection_adapter.text_bounds()
1169 });
1170 assert_eq!(bounds.len(), 2);
1171 for bounds in bounds {
1172 cx.simulate_click(bounds.center(), Modifiers::default());
1173 cx.update(|window, cx| {
1174 window.draw(cx).clear(cx);
1175 });
1176 }
1177 assert_eq!(*captured.lock().unwrap(), vec!["one", "two"]);
1178 }
1179
1180 #[gpui::test]
1181 fn inline_hover_card_is_lazy_and_retains_atomic_copy(cx: &mut TestAppContext) {
1182 cx.update(crate::init);
1183 let builds = Arc::new(AtomicUsize::new(0));
1184 let source = "[@member](mention:member)";
1185 let (root, cx) = cx.add_window_view(|_, cx| InlineHoverTestRoot {
1186 view: cx.new(|cx| TextViewState::markdown(source, cx)),
1187 builds: builds.clone(),
1188 format: crate::text::SelectionFormat::Plain,
1189 });
1190 cx.run_until_parked();
1191 cx.update(|window, cx| window.draw(cx).clear(cx));
1192 assert_eq!(builds.load(Ordering::Relaxed), 0);
1193 let view = root.read_with(cx, |root, _| root.view.clone());
1194 let bounds = view.read_with(cx, |view, _| view.selection_adapter.text_bounds()[0]);
1195 cx.update(|window, cx| {
1196 window.simulate_mouse_move(point(bounds.right() - px(1.), bounds.center().y), cx)
1197 });
1198 cx.run_until_parked();
1199 cx.executor()
1200 .advance_clock(std::time::Duration::from_secs(1));
1201 cx.run_until_parked();
1202 cx.update(|window, cx| window.draw(cx).clear(cx));
1203 assert!(
1204 builds.load(Ordering::Relaxed) > 0,
1205 "hover did not build the card"
1206 );
1207 cx.update(|window, cx| window.draw(cx).clear(cx));
1208 let card = cx
1209 .debug_bounds("inline-test-card")
1210 .expect("hover card should be painted");
1211 assert!(
1212 (card.center().x - bounds.center().x).abs() < px(1.),
1213 "card {card:?} must be centered on mention {bounds:?}"
1214 );
1215 assert!(
1216 card.top() >= bounds.bottom(),
1217 "card should be below the mention"
1218 );
1219 for (format, expected) in [
1220 (crate::text::SelectionFormat::Plain, "@member"),
1221 (crate::text::SelectionFormat::Source, source),
1222 ] {
1223 root.update(cx, |root, cx| {
1224 root.format = format;
1225 cx.notify();
1226 });
1227 view.update(cx, |view, cx| {
1228 view.set_selection_format(format, cx);
1229 view.select_all(cx);
1230 });
1231 cx.update(|window, cx| window.draw(cx).clear(cx));
1232 assert_eq!(
1233 view.read_with(cx, |view, _| view.selected_text()).trim(),
1234 expected
1235 );
1236 }
1237 }
1238
1239 struct InlinePluginTestRoot {
1240 text_view: Entity<TextViewState>,
1241 width: Pixels,
1242 font_size: Pixels,
1243 source_format: bool,
1244 prepared_size: Option<Arc<AtomicUsize>>,
1245 }
1246
1247 impl Render for InlinePluginTestRoot {
1248 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1249 let prepared_size = self.prepared_size.clone();
1250 div().w(self.width).text_size(self.font_size).child(crate::TextSelectionLayer).child(
1251 TextView::new(&self.text_view)
1252 .selection_format(if self.source_format { crate::text::SelectionFormat::Source }
1253 else { crate::text::SelectionFormat::Plain })
1254
1255 .plugin(crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(|node, _| {
1256 let markdown::mdast::Node::InlineMath(math) = node else { return None };
1257 Some(crate::text::MarkdownNode::new("math", ()).text(format!("{}²", math.value)))
1258 }).render_with(move |_, context, _, _| {
1259 let value = prepared_size.as_ref()?.load(Ordering::Relaxed) as f32;
1260 let image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Svg,
1261 b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><path d=\"M0 0L40 40\" stroke=\"black\"/></svg>".to_vec()));
1262 let unit = context.font_size() / 16.;
1263 Some(crate::text::InlineElement::new(gpui::img(image).w(unit * value).h(unit * value)).with_baseline(unit * value * 0.75))
1264 })))
1265 }
1266 }
1267
1268 #[gpui::test]
1269 fn inline_plugins_drag_and_copy_across_formulas_in_both_directions(cx: &mut TestAppContext) {
1270 cx.update(crate::init);
1271 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1272 text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1273 width: px(420.),
1274 font_size: px(16.),
1275 source_format: false,
1276 prepared_size: None,
1277 });
1278 let cx: &mut VisualTestContext = cx;
1279 for font_size in [16., 24., 32.] {
1280 for width in [60., 160., 420.] {
1281 for prepared in [None, Some(32)] {
1282 for source_format in [false, true] {
1283 root.update(cx, |root, cx| {
1284 root.source_format = source_format;
1285 root.width = px(width);
1286 root.font_size = px(font_size);
1287 root.prepared_size =
1288 prepared.map(|value| Arc::new(AtomicUsize::new(value)));
1289 cx.notify();
1290 });
1291 cx.run_until_parked();
1292 cx.update(|window, cx| window.draw(cx).clear(cx));
1293 let bounds =
1294 root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1295 let text_bounds = root.read_with(cx, |root, cx| {
1296 root.text_view.read(cx).selection_adapter.text_bounds()
1297 });
1298 let first = text_bounds.first().unwrap();
1299 let last = text_bounds.last().unwrap();
1300 let left =
1301 point(first.left() + px(0.1), first.top() + first.size.height / 2.);
1302 let right =
1303 point(last.right() - px(0.1), last.top() + last.size.height / 2.);
1304 for (start, end) in [(left, right), (right, left)] {
1305 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1306 cx.update(|window, cx| window.draw(cx).clear(cx));
1307 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1308 cx.update(|window, cx| window.draw(cx).clear(cx));
1309 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1310 cx.update(|window, cx| window.draw(cx).clear(cx));
1311 let selected = root
1312 .read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1313 assert_eq!(
1314 selected.trim(),
1315 if source_format {
1316 "中文 $x$ $y$ English"
1317 } else {
1318 "中文 x² y² English"
1319 },
1320 "start={start:?} end={end:?} bounds={bounds:?} width={width} font_size={font_size} prepared={prepared:?}"
1321 );
1322 }
1323 }
1324 }
1325 }
1326 }
1327 }
1328
1329 #[gpui::test]
1330 fn inline_resource_size_update_reflows_without_changing_selection(cx: &mut TestAppContext) {
1331 cx.update(crate::init);
1332 let prepared_size = Arc::new(AtomicUsize::new(16));
1333 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1334 text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1335 width: px(160.),
1336 font_size: px(16.),
1337 source_format: false,
1338 prepared_size: Some(prepared_size.clone()),
1339 });
1340 let cx: &mut VisualTestContext = cx;
1341 cx.run_until_parked();
1342 cx.update(|window, cx| window.draw(cx).clear(cx));
1343 let before = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1344 let regions = root.read_with(cx, |root, cx| {
1345 root.text_view.read(cx).selection_adapter.text_bounds()
1346 });
1347 let first = regions.first().unwrap();
1348 let last = regions.last().unwrap();
1349 let start = point(first.left() + px(0.1), first.top() + px(10.));
1350 let end = point(last.right() - px(0.1), last.top() + px(10.));
1351 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1352 cx.update(|window, cx| window.draw(cx).clear(cx));
1353 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1354 cx.update(|window, cx| window.draw(cx).clear(cx));
1355 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1356 cx.update(|window, cx| window.draw(cx).clear(cx));
1357 assert_eq!(
1358 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1359 .trim(),
1360 "中文 x² y² English"
1361 );
1362 prepared_size.store(100, Ordering::Relaxed);
1363 root.update(cx, |root, cx| {
1364 root.text_view
1365 .update(cx, |state, cx| state.invalidate_inline_layout(cx))
1366 });
1367 cx.run_until_parked();
1368 cx.update(|window, cx| window.draw(cx).clear(cx));
1369 let after = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1370 assert!(after.size.height > before.size.height * 2.);
1371 assert_eq!(
1372 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1373 .trim(),
1374 "中文 x² y² English"
1375 );
1376 }
1377
1378 #[gpui::test]
1379 fn triple_click_on_formula_selects_its_entire_mixed_line(cx: &mut TestAppContext) {
1380 cx.update(crate::init);
1381 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1382 text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1383 width: px(420.),
1384 font_size: px(16.),
1385 source_format: false,
1386 prepared_size: None,
1387 });
1388 let cx: &mut VisualTestContext = cx;
1389 cx.run_until_parked();
1390 cx.update(|window, cx| window.draw(cx).clear(cx));
1391 let regions = root.read_with(cx, |root, cx| {
1392 root.text_view.read(cx).selection_adapter.text_bounds()
1393 });
1394 let formula = regions[1];
1395 let position = formula.center();
1396 cx.simulate_event(MouseDownEvent {
1397 position,
1398 modifiers: Modifiers::default(),
1399 button: MouseButton::Left,
1400 click_count: 3,
1401 first_mouse: false,
1402 });
1403 cx.simulate_event(MouseUpEvent {
1404 position,
1405 modifiers: Modifiers::default(),
1406 button: MouseButton::Left,
1407 click_count: 3,
1408 });
1409 cx.update(|window, cx| window.draw(cx).clear(cx));
1410 assert_eq!(
1411 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1412 .trim(),
1413 "before x² after"
1414 );
1415 root.update(cx, |root, cx| {
1416 root.source_format = true;
1417 cx.notify();
1418 });
1419 cx.update(|window, cx| window.draw(cx).clear(cx));
1420 assert_eq!(
1421 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1422 .trim(),
1423 "before $x$ after"
1424 );
1425 }
1426
1427 #[gpui::test]
1428 fn double_click_on_formula_selects_only_that_object(cx: &mut TestAppContext) {
1429 cx.update(crate::init);
1430 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1431 text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1432 width: px(420.),
1433 font_size: px(16.),
1434 source_format: false,
1435 prepared_size: None,
1436 });
1437 let cx: &mut VisualTestContext = cx;
1438 cx.run_until_parked();
1439 cx.update(|window, cx| window.draw(cx).clear(cx));
1440 let regions = root.read_with(cx, |root, cx| {
1441 root.text_view.read(cx).selection_adapter.text_bounds()
1442 });
1443 let position = regions[1].center();
1444 cx.simulate_event(MouseDownEvent {
1445 position,
1446 modifiers: Modifiers::default(),
1447 button: MouseButton::Left,
1448 click_count: 2,
1449 first_mouse: false,
1450 });
1451 cx.simulate_event(MouseUpEvent {
1452 position,
1453 modifiers: Modifiers::default(),
1454 button: MouseButton::Left,
1455 click_count: 2,
1456 });
1457 cx.update(|window, cx| window.draw(cx).clear(cx));
1458 assert_eq!(
1460 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1461 .trim(),
1462 "x²"
1463 );
1464 }
1465
1466 struct ScrollExtentTestRoot {
1469 text_view: Entity<TextViewState>,
1470 }
1471
1472 impl Render for ScrollExtentTestRoot {
1473 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1474 div()
1475 .w(px(400.))
1476 .h(px(200.))
1477 .overflow_hidden()
1478 .child(TextView::new(&self.text_view).scrollable(true))
1479 }
1480 }
1481
1482 fn document_of(count: usize, words: usize) -> String {
1485 (1..=count)
1486 .map(|i| format!("Block {i}: {}", "lorem ipsum dolor ".repeat(words)))
1487 .collect::<Vec<_>>()
1488 .join("\n\n")
1489 }
1490
1491 #[gpui::test]
1497 fn replacing_a_document_with_an_equal_block_count_remeasures(cx: &mut TestAppContext) {
1498 cx.update(crate::init);
1499
1500 const BLOCKS: usize = 24;
1501 let short = document_of(BLOCKS, 1);
1502 let tall = document_of(BLOCKS, 60);
1503
1504 let (root, cx) = cx.add_window_view(|_, cx| ScrollExtentTestRoot {
1505 text_view: cx.new(|cx| TextViewState::markdown(&short, cx)),
1506 });
1507 let cx: &mut VisualTestContext = cx;
1508
1509 let settle = |cx: &mut VisualTestContext| {
1512 cx.run_until_parked();
1513 cx.update(|window, cx| window.draw(cx).clear(cx));
1514 cx.run_until_parked();
1515 };
1516 settle(cx);
1517
1518 let scroll_extent = |cx: &mut VisualTestContext| {
1519 root.read_with(cx, |root, cx| {
1520 root.text_view
1521 .read(cx)
1522 .list_state()
1523 .max_offset_for_scrollbar()
1524 .y
1525 })
1526 };
1527
1528 let short_extent = scroll_extent(cx);
1529
1530 root.update(cx, |root, cx| {
1531 root.text_view
1532 .update(cx, |state, cx| state.set_text(&tall, cx));
1533 });
1534 settle(cx);
1535
1536 root.read_with(cx, |root, cx| {
1537 assert_eq!(
1538 root.text_view.read(cx).list_state().item_count(),
1539 BLOCKS,
1540 "the replacement must keep the block count, or the list resets and the bug cannot occur"
1541 );
1542 });
1543
1544 let tall_extent = scroll_extent(cx);
1545 assert!(
1546 tall_extent > short_extent * 5.,
1547 "a much taller document must grow the scroll extent, but it went from \
1548 {short_extent:?} to {tall_extent:?}"
1549 );
1550 }
1551
1552 struct StatelessMarkdownRoot {
1553 renders: Arc<AtomicUsize>,
1554 }
1555
1556 impl Render for StatelessMarkdownRoot {
1557 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1558 self.renders.fetch_add(1, Ordering::Relaxed);
1559 div().child(
1560 TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
1561 .markdown_block_parser(|_, _| None),
1562 )
1563 }
1564 }
1565
1566 struct DummyTextViewPlugin;
1567
1568 impl TextViewPlugin for DummyTextViewPlugin {
1569 fn setup(self, mut text_view: TextView) -> TextView {
1570 text_view.selectable = true;
1571 text_view
1572 }
1573 }
1574
1575 #[gpui::test]
1576 fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
1577 cx.update(crate::init);
1578 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
1579
1580 assert!(TextView::new(&state).selectable);
1581 assert!(TextView::markdown("markdown", "text").selectable);
1582 assert!(TextView::html("html", "<p>text</p>").selectable);
1583 }
1584
1585 #[gpui::test]
1586 fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
1587 cx.update(crate::init);
1588 let renders = Arc::new(AtomicUsize::new(0));
1589 let (_, cx) = cx.add_window_view({
1590 let renders = renders.clone();
1591 move |_, _| StatelessMarkdownRoot { renders }
1592 });
1593 let cx: &mut VisualTestContext = cx;
1594
1595 cx.run_until_parked();
1596 cx.update(|window, cx| window.draw(cx).clear(cx));
1597 let renders_after_redraw = renders.load(Ordering::Relaxed);
1598 cx.run_until_parked();
1599 assert_eq!(
1600 renders.load(Ordering::Relaxed),
1601 renders_after_redraw,
1602 "an unchanged TextView must not schedule another render after its parser is rebuilt",
1603 );
1604 }
1605
1606 #[gpui::test]
1607 fn markdown_data_url_image_is_decoded_inline(cx: &mut TestAppContext) {
1608 use gpui::{Image, ImageFormat, ImageSource};
1609
1610 const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
1612
1613 cx.update(crate::init);
1614 let markdown = format!("Inline  image");
1615 let (_, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new(&markdown, cx));
1616 let cx: &mut VisualTestContext = cx;
1617
1618 cx.run_until_parked();
1619 cx.update(|window, cx| window.draw(cx).clear(cx));
1620
1621 let bytes = data_url::DataUrl::process(&format!("data:image/png;base64,{PNG_BASE64}"))
1626 .unwrap()
1627 .decode_to_vec()
1628 .unwrap()
1629 .0;
1630 let image = Arc::new(Image::from_bytes(ImageFormat::Png, bytes));
1631 assert!(
1632 cx.update(|_, cx| ImageSource::Image(image).is_asset_cached(cx)),
1633 "the data URL image must be handed to GPUI as decoded bytes",
1634 );
1635 }
1636
1637 #[gpui::test]
1638 fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
1639 cx.update(crate::init);
1640 cx.update(|cx| {
1641 let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
1642 colors.primary = gpui::rgb(0x55aaff).into();
1643 colors.selection = gpui::rgb(0x335577).into();
1644 });
1645 let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
1646 let cx: &mut VisualTestContext = cx;
1647
1648 cx.run_until_parked();
1649 root.read_with(cx, |root, cx| {
1650 let style = &root.text_view.read(cx).text_view_style;
1651 assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
1652 assert_eq!(style.selection(), gpui::rgb(0x335577).into());
1653 });
1654 }
1655
1656 #[gpui::test]
1657 fn inline_code_view_refinement_keeps_unspecified_style_properties(cx: &mut TestAppContext) {
1658 use super::{TextViewDefaults, TextViewStyle};
1659 struct StyledCode(Entity<TextViewState>);
1660 impl Render for StyledCode {
1661 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1662 TextView::new(&self.0)
1663 .inline_code_style(
1664 StyleRefinement::default()
1665 .border_1()
1666 .border_color(gpui::blue()),
1667 )
1668 .into_any_element()
1669 }
1670 }
1671 cx.update(crate::init);
1672 cx.update(|cx| {
1673 cx.set_global(
1674 TextViewDefaults::default().with_style(
1675 TextViewStyle::default().with_inline_code_style(
1676 StyleRefinement::default()
1677 .text_size(rems(1.))
1678 .bg(gpui::red()),
1679 ),
1680 ),
1681 )
1682 });
1683 let (root, cx) = cx.add_window_view(|_, cx| {
1684 StyledCode(cx.new(|cx| TextViewState::markdown("before `code` after", cx)))
1685 });
1686 cx.run_until_parked();
1687 cx.update(|window, cx| window.draw(cx).clear(cx));
1688 root.read_with(cx, |root, cx| {
1689 let state = root.0.read(cx);
1690 let style = state.text_view_style.inline_code_style();
1691 assert_eq!(style.text.font_size, Some(rems(1.).into()));
1692 assert_eq!(style.background, Some(gpui::red().into()));
1693 assert_eq!(style.border_color, Some(gpui::blue()));
1694 assert_eq!(style.border_widths.left, Some(px(1.).into()));
1695 });
1696 }
1697
1698 #[gpui::test]
1699 fn list_and_blockquote_view_refinements_keep_unspecified_style_properties(
1700 cx: &mut TestAppContext,
1701 ) {
1702 use super::{TextViewDefaults, TextViewStyle};
1703 struct StyledBlocks(Entity<TextViewState>);
1704 impl Render for StyledBlocks {
1705 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1706 TextView::new(&self.0)
1707 .list_style(StyleRefinement::default().text_color(gpui::blue()))
1708 .blockquote_style(StyleRefinement::default().border_color(gpui::blue()))
1709 .into_any_element()
1710 }
1711 }
1712 cx.update(crate::init);
1713 cx.update(|cx| {
1714 cx.set_global(
1715 TextViewDefaults::default().with_style(
1716 TextViewStyle::default()
1717 .with_list_style(
1718 StyleRefinement::default().font_weight(gpui::FontWeight::BOLD),
1719 )
1720 .with_blockquote_style(StyleRefinement::default().border_l_4()),
1721 ),
1722 )
1723 });
1724 let (root, cx) = cx.add_window_view(|_, cx| {
1725 StyledBlocks(cx.new(|cx| TextViewState::markdown("- item\n\n> quote", cx)))
1726 });
1727 cx.run_until_parked();
1728 cx.update(|window, cx| window.draw(cx).clear(cx));
1729 root.read_with(cx, |root, cx| {
1730 let style = &root.0.read(cx).text_view_style;
1731 let marker = style.list_style();
1732 assert_eq!(marker.text.color, Some(gpui::blue()));
1733 assert_eq!(marker.text.font_weight, Some(gpui::FontWeight::BOLD));
1734 let quote = style.blockquote_style();
1735 assert_eq!(quote.border_color, Some(gpui::blue()));
1736 assert_eq!(quote.border_widths.left, Some(px(4.).into()));
1737 });
1738 }
1739
1740 impl TextViewTestRoot {
1741 fn new(text: &str, cx: &mut Context<Self>) -> Self {
1742 let text = text.to_string();
1743 let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
1744 Self { text_view }
1745 }
1746 }
1747
1748 impl Render for TextViewTestRoot {
1749 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1750 div()
1751 .w(px(160.))
1752 .child(
1753 div()
1754 .h(px(24.))
1755 .overflow_hidden()
1756 .child(TextView::new(&self.text_view).selectable(true)),
1757 )
1758 .child(div().h(px(40.)).child("footer"))
1759 }
1760 }
1761
1762 struct TableSelectionTestRoot {
1763 text_view: Entity<TextViewState>,
1764 }
1765
1766 impl Render for TableSelectionTestRoot {
1767 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1768 div()
1769 .debug_selector(|| "table-selection-root".into())
1770 .w(px(520.))
1771 .child(crate::TextSelectionLayer)
1772 .child(TextView::new(&self.text_view))
1773 }
1774 }
1775
1776 #[gpui::test]
1777 fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
1778 cx.update(crate::init);
1779 let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
1780 text_view: cx.new(|cx| {
1781 TextViewState::markdown(
1782 "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
1783 cx,
1784 )
1785 }),
1786 });
1787 let cx: &mut VisualTestContext = cx;
1788
1789 cx.run_until_parked();
1790 let bounds = cx
1791 .debug_bounds("table-selection-root")
1792 .expect("table bounds");
1793 let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
1794 let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
1795 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1796 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1797 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1798
1799 assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
1800 assert_eq!(
1801 cx.update(|window, cx| window.simulate_next_frame(cx)),
1802 0,
1803 "finished table selection must not continuously request frames"
1804 );
1805 }
1806
1807 #[gpui::test]
1811 fn table_drag_from_a_cell_stays_in_that_cell(cx: &mut TestAppContext) {
1812 cx.update(crate::init);
1813 let (root, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
1814 text_view: cx.new(|cx| {
1815 TextViewState::markdown(
1816 "Intro\n\n| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
1817 cx,
1818 )
1819 }),
1820 });
1821 let cx: &mut VisualTestContext = cx;
1822 cx.run_until_parked();
1823 cx.update(|window, cx| window.draw(cx).clear(cx));
1824
1825 let text_bounds = root.read_with(cx, |root, cx| {
1826 root.text_view.read(cx).selection_adapter.text_bounds()
1827 });
1828 assert_eq!(text_bounds.len(), 7, "{text_bounds:?}");
1829 let [intro, _, _, cell_a, cell_b, _, cell_d] = text_bounds[..] else {
1830 unreachable!()
1831 };
1832 let table = cx
1833 .debug_bounds("table-selection-root")
1834 .expect("table bounds");
1835 let start_of = |bounds: Bounds<Pixels>| point(bounds.left() + px(0.1), bounds.center().y);
1836 let end_of = |bounds: Bounds<Pixels>| point(bounds.right() - px(0.1), bounds.center().y);
1837
1838 let mut drag = |start, end| {
1839 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1840 cx.update(|window, cx| window.draw(cx).clear(cx));
1841 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1842 cx.update(|window, cx| window.draw(cx).clear(cx));
1843 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1844 cx.update(|window, cx| window.draw(cx).clear(cx));
1845 let selected = root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1846 let copied = cx.update(|window, cx| crate::TextSelection::selected_text(window, cx));
1847 (selected.trim().to_string(), copied.trim().to_string())
1848 };
1849
1850 let below = point(table.right() - px(2.), table.bottom() - px(1.));
1852 let expected = ("Cell A".to_string(), "Cell A".to_string());
1853 assert_eq!(drag(start_of(cell_a), below), expected);
1854
1855 let expected = ("Cell B".to_string(), "Cell B".to_string());
1858 let left = point(cell_a.center().x, cell_b.center().y);
1859 assert_eq!(drag(end_of(cell_b), left), expected);
1860 let above = point(table.left() + px(2.), intro.top());
1861 assert_eq!(drag(end_of(cell_b), above), expected);
1862
1863 let (selected, copied) = drag(start_of(intro), end_of(cell_d));
1865 for text in ["Intro", "Header 1", "Cell A", "Cell B", "Cell C", "Cell D"] {
1866 assert!(selected.contains(text), "{text} not in {selected:?}");
1867 assert!(copied.contains(text), "{text} not in {copied:?}");
1868 }
1869
1870 cx.simulate_mouse_down(start_of(cell_a), MouseButton::Left, Modifiers::default());
1873 cx.update(|window, cx| window.draw(cx).clear(cx));
1874 cx.simulate_mouse_move(end_of(cell_a), MouseButton::Left, Modifiers::default());
1875 cx.update(|window, cx| window.draw(cx).clear(cx));
1876 cx.simulate_mouse_move(below, MouseButton::Left, Modifiers::default());
1877 cx.simulate_mouse_up(below, MouseButton::Left, Modifiers::default());
1878 let copied = cx.update(|window, cx| crate::TextSelection::selected_text(window, cx));
1879 assert_eq!(copied.trim(), "Cell A");
1880 }
1881
1882 struct CellSelectionTestRoot {
1883 text_view: Entity<TextViewState>,
1884 scrollable: bool,
1885 }
1886
1887 impl Render for CellSelectionTestRoot {
1888 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1889 div()
1890 .w(px(520.))
1891 .h(px(400.))
1892 .child(crate::TextSelectionLayer)
1893 .child(TextView::new(&self.text_view).scrollable(self.scrollable))
1894 }
1895 }
1896
1897 #[gpui::test]
1898 fn table_cell_copy_excludes_following_blocks_in_a_scrollable_view(cx: &mut TestAppContext) {
1899 cx.update(crate::init);
1900 let (root, cx) = cx.add_window_view(|_, cx| CellSelectionTestRoot {
1901 text_view: cx.new(|cx| TextViewState::markdown(
1902 "| Header | Other |\n| --- | --- |\n| Cell A | Cell B |\n\nMiddle paragraph\n\nLast paragraph", cx)),
1903 scrollable: true,
1904 });
1905 cx.run_until_parked();
1906 cx.update(|window, cx| window.draw(cx).clear(cx));
1907 let rows = root.read_with(cx, |root, cx| {
1908 root.text_view.read(cx).selection_adapter.text_bounds()
1909 });
1910 assert_eq!(rows.len(), 6, "{rows:?}");
1911 let start = point(rows[2].left() + px(0.1), rows[2].center().y);
1912 let inside = point(rows[2].right() - px(0.1), rows[2].center().y);
1913 let end = point(rows[5].right() - px(0.1), rows[5].center().y);
1914 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1915 cx.simulate_mouse_move(inside, MouseButton::Left, Modifiers::default());
1916 cx.update(|window, cx| window.draw(cx).clear(cx));
1917 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1919 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1920 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
1921 root.update(cx, |root, cx| {
1922 root.text_view
1923 .update(cx, |state, cx| state.set_selection_format(format, cx))
1924 });
1925 let copied = cx.update(|window, cx| crate::TextSelection::selected_text(window, cx));
1926 assert_eq!(copied.trim(), "Cell A", "{format:?}");
1927 }
1928 cx.update(|window, cx| window.draw(cx).clear(cx));
1929 let selected = root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1930 assert_eq!(selected.trim(), "Cell A");
1931 }
1932
1933 #[gpui::test]
1934 fn table_cell_touch_select_all_includes_every_cell(cx: &mut TestAppContext) {
1935 cx.update(crate::init);
1936 let (root, cx) = cx.add_window_view(|_, cx| CellSelectionTestRoot {
1937 text_view: cx.new(|cx| {
1938 TextViewState::markdown(
1939 "| Header | Other |\n| --- | --- |\n| Cell A | Cell B |",
1940 cx,
1941 )
1942 }),
1943 scrollable: false,
1944 });
1945 cx.run_until_parked();
1946 cx.update(|window, cx| window.draw(cx).clear(cx));
1947 let rows = root.read_with(cx, |root, cx| {
1948 root.text_view.read(cx).selection_adapter.text_bounds()
1949 });
1950 let position = rows[2].center();
1951 for phase in [gpui::TouchPhase::Started, gpui::TouchPhase::Ended] {
1952 cx.simulate_event(gpui::LongPressEvent {
1953 phase,
1954 start_position: position,
1955 position,
1956 });
1957 }
1958 cx.update(|window, cx| {
1959 window.draw(cx).clear(cx);
1960 crate::TextSelection::select_all(window, cx);
1961 window.draw(cx).clear(cx);
1962 });
1963 let copied = cx.update(|window, cx| crate::TextSelection::selected_text(window, cx));
1964 let selected = root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1965 for text in ["Header", "Other", "Cell A", "Cell B"] {
1966 assert!(selected.contains(text), "{selected:?}");
1967 assert!(copied.contains(text), "{copied:?}");
1968 }
1969 }
1970
1971 #[gpui::test]
1972 fn table_cell_selection_does_not_leak_into_another_window(cx: &mut TestAppContext) {
1973 cx.update(crate::init);
1974 let start = {
1975 let (root, cx) = cx.add_window_view(|_, cx| CellSelectionTestRoot {
1976 text_view: cx.new(|cx| {
1977 TextViewState::markdown(
1978 "| Header | Other |\n| --- | --- |\n| Cell A | Cell B |",
1979 cx,
1980 )
1981 }),
1982 scrollable: false,
1983 });
1984 cx.run_until_parked();
1985 cx.update(|window, cx| window.draw(cx).clear(cx));
1986 let rows = root.read_with(cx, |root, cx| {
1987 root.text_view.read(cx).selection_adapter.text_bounds()
1988 });
1989 let start = point(rows[0].left() + px(0.1), rows[0].center().y);
1990 let end = point(rows[0].right() - px(0.1), rows[0].center().y);
1991 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1992 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1993 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1994 cx.update(|window, cx| window.draw(cx).clear(cx));
1995 assert_eq!(
1996 cx.update(|window, cx| crate::TextSelection::selected_text(window, cx))
1997 .trim(),
1998 "Header"
1999 );
2000 start
2001 };
2002 let (root, cx) = cx.add_window_view(|_, cx| CellSelectionTestRoot {
2003 text_view: cx.new(|cx| TextViewState::markdown("ordinary text in another window", cx)),
2004 scrollable: false,
2005 });
2006 cx.run_until_parked();
2007 cx.update(|window, cx| window.draw(cx).clear(cx));
2008 let rows = root.read_with(cx, |root, cx| {
2009 root.text_view.read(cx).selection_adapter.text_bounds()
2010 });
2011 assert!(rows[0].contains(&start), "{start:?}, {rows:?}");
2012 let end = point(rows[0].right() - px(0.1), rows[0].center().y);
2013 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
2014 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
2015 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
2016 cx.update(|window, cx| window.draw(cx).clear(cx));
2017 let selected = root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
2018 let copied = cx.update(|window, cx| crate::TextSelection::selected_text(window, cx));
2019 assert!(!selected.trim().is_empty());
2020 assert_eq!(copied, selected);
2021 }
2022
2023 struct InlineImageTextViewTestRoot {
2024 text_view: Entity<TextViewState>,
2025 }
2026
2027 impl InlineImageTextViewTestRoot {
2028 fn new(cx: &mut Context<Self>) -> Self {
2029 let text_view = cx.new(|cx| {
2030 TextViewState::markdown(
2031 "Build Status  after",
2032 cx,
2033 )
2034 });
2035 Self { text_view }
2036 }
2037 }
2038
2039 impl Render for InlineImageTextViewTestRoot {
2040 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2041 div()
2042 .w(px(420.))
2043 .child(TextView::new(&self.text_view).selectable(true))
2044 }
2045 }
2046
2047 #[gpui::test]
2048 fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
2049 cx.update(crate::init);
2050 let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
2051 let cx: &mut VisualTestContext = cx;
2052
2053 cx.run_until_parked();
2054 cx.update(|window, cx| {
2055 let _ = window.draw(cx);
2056 });
2057
2058 let inline_bounds = content.read_with(cx, |content, cx| {
2059 content.text_view.read(cx).selection_adapter.text_bounds()
2060 });
2061
2062 assert_eq!(inline_bounds.len(), 2);
2063 assert_eq!(
2064 inline_bounds[0].top(),
2065 inline_bounds[1].top(),
2066 "text before and after an inline image should share a rendered line"
2067 );
2068 assert!(
2069 inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
2070 "inline image should reserve horizontal space in the text layout"
2071 );
2072 assert!(
2073 inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
2074 "unloaded inline image fallback should stay generic and compact"
2075 );
2076 }
2077
2078 #[gpui::test]
2079 fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
2080 cx.update(crate::init);
2081 let (_, cx) = cx.add_window_view(|_, cx| {
2082 TextViewTestRoot::new(
2083 "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
2084 cx,
2085 )
2086 });
2087 let cx: &mut VisualTestContext = cx;
2088
2089 cx.run_until_parked();
2090 cx.update(|window, cx| {
2091 let _ = window.draw(cx);
2092 });
2093 }
2094
2095 struct FittedListTestRoot {
2096 text_view: Entity<TextViewState>,
2097 width: Pixels,
2098 }
2099
2100 impl Render for FittedListTestRoot {
2101 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2102 div()
2103 .w(self.width)
2104 .flex()
2105 .flex_col()
2106 .items_end()
2107 .text_size(px(16.))
2108 .child(crate::TextSelectionLayer)
2109 .child(
2110 div()
2111 .debug_selector(|| "fitted-list".into())
2112 .flex()
2113 .flex_col()
2114 .min_w_0()
2115 .max_w_full()
2116 .p_4()
2117 .child(TextView::new(&self.text_view).selectable(true)),
2118 )
2119 }
2120 }
2121
2122 fn fitted_list_bounds(
2123 source: &str,
2124 width: f32,
2125 cx: &mut TestAppContext,
2126 ) -> (Bounds<Pixels>, Vec<Bounds<Pixels>>) {
2127 let (root, cx) = cx.add_window_view(|_, cx| FittedListTestRoot {
2128 text_view: cx.new(|cx| TextViewState::markdown(source, cx)),
2129 width: px(width),
2130 });
2131 cx.run_until_parked();
2132 cx.update(|window, cx| window.draw(cx).clear(cx));
2133 let bubble = cx.debug_bounds("fitted-list").unwrap();
2134 let rows = root.read_with(cx, |root, cx| {
2135 root.text_view.read(cx).selection_adapter.text_bounds()
2136 });
2137 assert!(!rows.is_empty(), "list text must be painted: {source:?}");
2138 for row in &rows {
2139 assert!(
2140 row.left() >= bubble.left() && row.right() <= bubble.right(),
2141 "text must remain visible inside the fitted container: {source:?}, {bubble:?}, {row:?}"
2142 );
2143 }
2144 (bubble, rows)
2145 }
2146
2147 #[gpui::test]
2148 fn fitted_lists_include_their_body_width(cx: &mut TestAppContext) {
2149 cx.update(crate::init);
2150 let (plain, _) = fitted_list_bounds("Hello world", 600., cx);
2151 for source in [
2152 "3. Hello world",
2153 "- Hello world",
2154 "- [ ] Hello world",
2155 "- [x] Hello world",
2156 "Intro\n\n- Hello world",
2157 "- Outer\n - Hello world",
2158 ] {
2159 let (bubble, _) = fitted_list_bounds(source, 600., cx);
2160 assert!(
2161 bubble.size.width > plain.size.width,
2162 "the marker must leave room for the full body: {source:?}, {bubble:?}, {plain:?}"
2163 );
2164 }
2165 }
2166
2167 #[gpui::test]
2168 fn fitted_lists_wrap_their_body_within_the_available_width(cx: &mut TestAppContext) {
2169 cx.update(crate::init);
2170 let body = "Visible text that wraps without being clipped. ".repeat(12);
2171 for width in [600., 180.] {
2172 for prefix in ["3. ", "- ", "- [ ] ", "- [x] ", "- Outer\n - "] {
2173 let (bubble, rows) = fitted_list_bounds(&format!("{prefix}{body}"), width, cx);
2174 assert!(bubble.size.width <= px(width));
2175 assert!(rows.len() > 1, "the body must wrap: {prefix:?}, {width}");
2176 }
2177 }
2178 }
2179
2180 #[gpui::test]
2181 fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
2182 struct ListItemBlockRoot;
2183
2184 impl Render for ListItemBlockRoot {
2185 fn render(
2186 &mut self,
2187 _window: &mut Window,
2188 _cx: &mut Context<Self>,
2189 ) -> impl IntoElement {
2190 div().w(px(840.)).h(px(400.)).child(
2191 crate::h_resizable("markdown-width-test")
2192 .child(crate::resizable_panel().child(div()))
2193 .child(crate::resizable_panel().child(
2194 TextView::markdown(
2195 "list-with-code",
2196 "1. List item\n ```rust\n nested code\n ```\n\n```rust\ntop-level code\n```",
2197 )
2198 .code_block_actions(|code_block, _, _| {
2199 let selector = if code_block.code().contains("nested") {
2200 "nested-code-action"
2201 } else {
2202 "top-level-code-action"
2203 };
2204 div()
2205 .debug_selector(move || selector.into())
2206 .child("Copy")
2207 })
2208 .scrollable(true)
2209 .p_5()
2210 .flex_none(),
2211 )),
2212 )
2213 }
2214 }
2215
2216 cx.update(crate::init);
2217 let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
2218 let cx: &mut VisualTestContext = cx;
2219
2220 cx.run_until_parked();
2221 cx.update(|window, cx| {
2222 let _ = window.draw(cx);
2223 });
2224
2225 let nested_action = cx.debug_bounds("nested-code-action").unwrap();
2226 let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
2227 assert!(
2228 top_level_action.right() - nested_action.right() < px(32.),
2229 "nested code block should fill the list item's available width"
2230 );
2231 }
2232
2233 const CODE_HEIGHT: Pixels = px(60.);
2235
2236 struct ScrollingCodeRoot {
2237 text_view: Entity<TextViewState>,
2238 actions: bool,
2239 }
2240
2241 impl Render for ScrollingCodeRoot {
2242 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2243 let mut code_block = StyleRefinement::default().max_h(CODE_HEIGHT);
2244 code_block.overflow.y = Some(Overflow::Scroll);
2245
2246 div().w(px(320.)).h(px(200.)).child(
2247 TextView::new(&self.text_view)
2248 .style(TextViewStyle::default().with_code_block(code_block))
2249 .when(self.actions, |this| {
2250 this.code_block_actions(|_, _, _| {
2251 div().debug_selector(|| "code-action".into()).child("Copy")
2252 })
2253 })
2254 .scrollable(true),
2255 )
2256 }
2257 }
2258
2259 #[gpui::test]
2260 fn scrolling_code_block_keeps_wheel_from_the_list(cx: &mut TestAppContext) {
2261 cx.update(crate::init);
2262 let code = (0..50).map(|i| format!("line {i}\n")).collect::<String>();
2263 let tail = "Paragraph after the code.\n\n".repeat(20);
2264 let markdown = format!("```\n{code}```\n\n{tail}");
2265
2266 for actions in [false, true] {
2269 let (root, cx) = cx.add_window_view(|_, cx| ScrollingCodeRoot {
2270 text_view: cx.new(|cx| TextViewState::markdown(&markdown, cx)),
2271 actions,
2272 });
2273 let cx: &mut VisualTestContext = cx;
2274 let draw = |cx: &mut VisualTestContext| {
2275 cx.run_until_parked();
2276 cx.update(|window, cx| window.draw(cx).clear(cx));
2277 };
2278 let step = px(20.);
2279 let wheel_at = |cx: &mut VisualTestContext, y: Pixels| {
2280 cx.simulate_event(gpui::ScrollEvent {
2281 position: point(px(20.), y),
2282 delta: gpui::ScrollDelta::Pixels(point(px(0.), -step)),
2283 ..Default::default()
2284 });
2285 };
2286 let edges = |cx: &mut VisualTestContext| {
2290 let lines = root.read_with(cx, |root, cx| {
2291 root.text_view.read(cx).selection_adapter.text_bounds()
2292 });
2293 let paragraph = lines.iter().find(|line| line.top() >= CODE_HEIGHT).unwrap();
2294 (lines[0].bottom(), paragraph.top())
2295 };
2296 draw(cx);
2297 let (code_bottom, paragraph_top) = edges(cx);
2298 let action = cx.debug_bounds("code-action");
2299
2300 wheel_at(cx, CODE_HEIGHT / 2.);
2303 draw(cx);
2304 assert_eq!(
2305 edges(cx),
2306 (code_bottom - step, paragraph_top),
2307 "actions: {actions}"
2308 );
2309 assert_eq!(cx.debug_bounds("code-action"), action);
2310
2311 wheel_at(cx, CODE_HEIGHT * 2.5);
2315 draw(cx);
2316 assert_ne!(edges(cx).1, paragraph_top, "actions: {actions}");
2317 }
2318 }
2319
2320 fn draw_table_with_actions(
2324 cx: &mut TestAppContext,
2325 scroll: bool,
2326 ) -> (Bounds<Pixels>, TableData) {
2327 use std::sync::{Arc, Mutex};
2328
2329 struct TableRoot {
2330 scroll: bool,
2331 captured: Arc<Mutex<Vec<TableData>>>,
2332 }
2333
2334 impl Render for TableRoot {
2335 fn render(
2336 &mut self,
2337 _window: &mut Window,
2338 _cx: &mut Context<Self>,
2339 ) -> impl IntoElement {
2340 let captured = self.captured.clone();
2341 let mut table_style = StyleRefinement::default();
2342 if self.scroll {
2343 table_style.overflow.x = Some(Overflow::Scroll);
2344 }
2345
2346 div().w(px(320.)).child(
2347 TextView::markdown(
2348 "table-actions",
2349 "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
2350 )
2351 .style(TextViewStyle::default().with_table(table_style))
2352 .table_actions(move |table, _, _| {
2353 if let Ok(mut captured) = captured.lock() {
2354 captured.push(table.clone());
2355 }
2356 div().debug_selector(|| "table-action".into()).child("Copy")
2357 }),
2358 )
2359 }
2360 }
2361
2362 cx.update(crate::init);
2363 let captured = Arc::new(Mutex::new(Vec::new()));
2364 let (_, cx) = cx.add_window_view({
2365 let captured = captured.clone();
2366 move |_, _| TableRoot { scroll, captured }
2367 });
2368 let cx: &mut VisualTestContext = cx;
2369
2370 cx.run_until_parked();
2371 cx.update(|window, cx| {
2372 let _ = window.draw(cx);
2373 });
2374
2375 let bounds = cx
2376 .debug_bounds("table-action")
2377 .expect("table actions should be painted");
2378 let data = captured
2379 .lock()
2380 .expect("captured table data")
2381 .last()
2382 .cloned()
2383 .expect("table actions hook should receive the table");
2384
2385 (bounds, data)
2386 }
2387
2388 #[gpui::test]
2389 fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
2390 for scroll in [false, true] {
2391 let (bounds, data) = draw_table_with_actions(cx, scroll);
2392
2393 assert!(
2395 bounds.top() > px(40.),
2396 "actions should sit below the table (scroll: {scroll}), got {:?}",
2397 bounds.top()
2398 );
2399 assert_eq!(data.headers, vec!["Name", "Age"]);
2400 assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
2401 assert_eq!(
2402 data.markdown,
2403 "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
2404 );
2405 assert_eq!(data.span, Some(0..52));
2406 }
2407 }
2408
2409 #[test]
2410 fn plugin_accepts_text_view_plugins_beyond_markdown() {
2411 let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
2412
2413 assert!(view.selectable);
2414 }
2415
2416 #[test]
2417 fn syntax_highlighting_is_opt_in() {
2418 let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
2419 assert!(default_view.code_block_highlighter.is_none());
2420
2421 let view = default_view.code_block_highlighter(|block| {
2422 vec![(
2423 0..block.code().len(),
2424 gpui::HighlightStyle {
2425 color: Some(gpui::rgb(0x3366ff).into()),
2426 ..Default::default()
2427 },
2428 )]
2429 });
2430 assert!(view.code_block_highlighter.is_some());
2431 }
2432
2433 #[gpui::test]
2434 fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
2435 cx.update(crate::init);
2436 let (_, cx) = cx.add_window_view(|_, cx| {
2437 TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
2438 });
2439 let cx: &mut VisualTestContext = cx;
2440
2441 cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
2442
2443 assert_eq!(cx.opened_url(), None);
2444 }
2445
2446 struct MaxLinesTestRoot {
2447 text_view: Entity<TextViewState>,
2448 max_lines: usize,
2449 }
2450
2451 impl MaxLinesTestRoot {
2452 fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
2453 let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
2454 Self {
2455 text_view,
2456 max_lines,
2457 }
2458 }
2459 }
2460
2461 impl Render for MaxLinesTestRoot {
2462 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2463 div()
2464 .w(px(200.))
2465 .child(TextView::new(&self.text_view).max_lines(self.max_lines))
2466 }
2467 }
2468
2469 #[test]
2470 fn the_clip_only_moves_for_a_straddling_glyph_line() {
2471 use super::line_safe_clip_bottom;
2472 use crate::text::state::LineSpan;
2473
2474 let spans = [
2475 LineSpan {
2477 top: px(0.),
2478 bottom: px(60.),
2479 line_height: px(20.),
2480 },
2481 LineSpan {
2483 top: px(68.),
2484 bottom: px(128.),
2485 line_height: px(20.),
2486 },
2487 ];
2488
2489 let below = px(400.);
2491
2492 assert_eq!(
2494 line_safe_clip_bottom(&spans, px(100.), below),
2495 Some(px(88.))
2496 );
2497
2498 assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
2500
2501 assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
2504
2505 let one_block = [LineSpan {
2508 top: px(0.),
2509 bottom: px(60.),
2510 line_height: px(20.),
2511 }];
2512 assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
2513
2514 assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
2517 }
2518
2519 #[test]
2520 fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
2521 use super::line_safe_clip_bottom;
2522 use crate::text::state::LineSpan;
2523
2524 let heading = [LineSpan {
2526 top: px(70.),
2527 bottom: px(98.),
2528 line_height: px(28.),
2529 }];
2530
2531 assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
2532 }
2533
2534 #[test]
2535 fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
2536 use super::line_safe_clip_bottom;
2537 use crate::text::state::LineSpan;
2538
2539 let rows = [
2542 LineSpan {
2543 top: px(100.),
2544 bottom: px(126.),
2545 line_height: px(26.),
2546 },
2547 LineSpan {
2548 top: px(135.),
2549 bottom: px(161.),
2550 line_height: px(26.),
2551 },
2552 ];
2553
2554 assert_eq!(
2557 line_safe_clip_bottom(&rows, px(148.), px(400.)),
2558 Some(px(126.))
2559 );
2560 }
2561
2562 struct ClampedPageRoot {
2568 text_view: Entity<TextViewState>,
2569 max_lines: usize,
2570 }
2571
2572 impl Render for ClampedPageRoot {
2573 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2574 use crate::{h_flex, v_flex};
2575
2576 v_flex()
2577 .size_full()
2578 .p_4()
2579 .gap_4()
2580 .child(h_flex().max_w(px(480.)).gap_3().child("header"))
2581 .child(
2582 v_flex()
2583 .flex_1()
2584 .min_h_0()
2585 .gap_4()
2586 .id("clamped-page-scroll")
2587 .child(
2588 v_flex()
2589 .max_w(px(480.))
2590 .p_3()
2591 .gap_2()
2592 .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
2593 )
2594 .overflow_y_scroll(),
2595 )
2596 }
2597 }
2598
2599 #[gpui::test]
2600 fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
2601 cx.update(crate::init);
2602 let (root, cx) = cx.add_window_view(|_, cx| {
2603 let text_view = cx.new(|cx| {
2604 TextViewState::markdown(
2605 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
2606 cx,
2607 )
2608 });
2609 ClampedPageRoot {
2610 text_view,
2611 max_lines: 3,
2612 }
2613 });
2614 let cx: &mut VisualTestContext = cx;
2615
2616 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2617 }
2618
2619 #[gpui::test]
2620 fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
2621 cx.update(crate::init);
2622 let (root, cx) = cx.add_window_view(|_, cx| {
2623 MaxLinesTestRoot::new(
2624 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
2625 2,
2626 cx,
2627 )
2628 });
2629 let cx: &mut VisualTestContext = cx;
2630
2631 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2632 }
2633
2634 #[gpui::test]
2635 fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
2636 cx.update(crate::init);
2637 let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
2638 let cx: &mut VisualTestContext = cx;
2639
2640 assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2641 }
2642
2643 #[gpui::test]
2644 fn heading_refinement_changes_rendered_heading_geometry(cx: &mut TestAppContext) {
2645 struct HeadingStyleRoot;
2646
2647 impl Render for HeadingStyleRoot {
2648 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2649 div()
2650 .flex()
2651 .items_start()
2652 .child(
2653 div()
2654 .debug_selector(|| "default-h1".into())
2655 .child(TextView::markdown("default-h1-view", "# Heading")),
2656 )
2657 .child(div().debug_selector(|| "custom-h1".into()).child(
2658 TextView::markdown("custom-heading-view", "# Heading").style(
2659 TextViewStyle::default().with_heading(|level| {
2660 if level == 1 {
2661 StyleRefinement::default().pb(rems(2.))
2662 } else {
2663 StyleRefinement::default()
2664 }
2665 }),
2666 ),
2667 ))
2668 .child(
2669 div()
2670 .debug_selector(|| "default-h2".into())
2671 .child(TextView::markdown("default-h2-view", "## Heading")),
2672 )
2673 .child(div().debug_selector(|| "custom-h2".into()).child(
2674 TextView::markdown("custom-h2-view", "## Heading").style(
2675 TextViewStyle::default().with_heading(|level| {
2676 if level == 1 {
2677 StyleRefinement::default().pb(rems(2.))
2678 } else {
2679 StyleRefinement::default()
2680 }
2681 }),
2682 ),
2683 ))
2684 }
2685 }
2686
2687 cx.update(crate::init);
2688 let (_, cx) = cx.add_window_view(|_, _| HeadingStyleRoot);
2689 cx.update(|window, cx| window.draw(cx).clear(cx));
2690
2691 let default_h1 = cx.debug_bounds("default-h1").unwrap();
2692 let custom_h1 = cx.debug_bounds("custom-h1").unwrap();
2693 let default_h2 = cx.debug_bounds("default-h2").unwrap();
2694 let custom_h2 = cx.debug_bounds("custom-h2").unwrap();
2695 assert!(custom_h1.size.height > default_h1.size.height);
2696 assert_eq!(custom_h2.size.height, default_h2.size.height);
2697 }
2698
2699 #[gpui::test]
2700 fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
2701 cx.update(crate::init);
2702 let (_, cx) = cx.add_window_view(|_, cx| {
2703 MaxLinesTestRoot::new(
2704 "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
2705 2,
2706 cx,
2707 )
2708 });
2709 let cx: &mut VisualTestContext = cx;
2710
2711 cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
2713
2714 assert_eq!(cx.opened_url(), None);
2715 }
2716
2717 #[gpui::test]
2718 fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) {
2719 struct SelectionRoot {
2720 text_view: Entity<TextViewState>,
2721 format: crate::text::SelectionFormat,
2722 }
2723 impl Render for SelectionRoot {
2724 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2725 div()
2726 .w(px(160.))
2727 .child(crate::TextSelectionLayer)
2728 .child(TextView::new(&self.text_view).selection_format(self.format))
2729 }
2730 }
2731 cx.update(crate::init);
2732 let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot {
2733 format: crate::text::SelectionFormat::Plain,
2734 text_view: cx
2735 .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)),
2736 });
2737 let cx: &mut VisualTestContext = cx;
2738 cx.run_until_parked();
2739 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2740 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2741 cx.simulate_mouse_down(
2743 point(px(5.), px(8.)),
2744 MouseButton::Left,
2745 Modifiers::default(),
2746 );
2747 cx.update(|window, cx| {
2748 let _ = window.draw(cx);
2749 });
2750 cx.simulate_mouse_move(
2751 point(px(155.), px(20.)),
2752 Some(MouseButton::Left),
2753 Modifiers::default(),
2754 );
2755 cx.update(|window, cx| {
2756 let _ = window.draw(cx);
2757 });
2758 cx.simulate_mouse_up(
2759 point(px(155.), px(20.)),
2760 MouseButton::Left,
2761 Modifiers::default(),
2762 );
2763 cx.update(|window, cx| {
2764 let _ = window.draw(cx);
2765 });
2766 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2767 assert_eq!(selected.trim(), "code after");
2768 view.update(cx, |view, cx| {
2769 view.format = crate::text::SelectionFormat::Source;
2770 cx.notify();
2771 });
2772 cx.update(|window, cx| {
2773 let _ = window.draw(cx);
2774 });
2775 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2776 assert_eq!(selected.trim(), "[`code`](https://example.com) after");
2777 }
2778
2779 #[test]
2789 fn inline_code_heading_reserves_painted_wrapped_lines_and_list_baseline() {
2790 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2791 use gpui::{TestApp, rems};
2792
2793 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";
2794
2795 struct MarkdownRoot {
2796 text_view: Entity<TextViewState>,
2797 width: Pixels,
2798 preview_zoom: f32,
2799 }
2800
2801 impl Render for MarkdownRoot {
2802 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2803 div()
2804 .w(self.width)
2805 .text_size(rems(self.preview_zoom))
2808 .child(crate::TextSelectionLayer)
2809 .child(TextView::new(&self.text_view).selectable(true))
2810 }
2811 }
2812
2813 fn draw(
2814 app: &mut TestApp,
2815 width: Pixels,
2816 preview_zoom: f32,
2817 ) -> (Bounds<Pixels>, Vec<Bounds<Pixels>>, String) {
2818 let mut window = app.open_window(|_, cx| MarkdownRoot {
2819 text_view: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
2820 width,
2821 preview_zoom,
2822 });
2823 window.draw();
2824 app.run_until_parked();
2825 window.draw();
2826 window.update(|root, _, cx| {
2827 root.text_view.update(cx, |state, cx| state.select_all(cx));
2828 });
2829 window.draw();
2830 window.read(|root, cx| {
2831 let state = root.text_view.read(cx);
2832 (
2833 state.bounds(),
2834 state.selection_adapter.text_bounds(),
2835 state.selected_text(),
2836 )
2837 })
2838 }
2839
2840 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2841 app.update(|cx| {
2842 crate::init(cx);
2843 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2844 });
2845
2846 for (case, width, preview_zoom) in [
2847 ("default-16px", px(600.), 1.),
2850 ("narrow-16px", px(320.), 1.),
2852 ("zoom-1.25", px(600.), 1.25),
2854 ] {
2855 let (view_bounds, text_bounds, selected_text) = draw(&mut app, width, preview_zoom);
2856 assert!(
2857 text_bounds.len() > 8,
2858 "{case}: expected all markdown fragments"
2859 );
2860 assert_eq!(
2861 selected_text.trim(),
2862 "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",
2863 "{case}: wrapped inline-code text was lost or duplicated"
2864 );
2865 let following = text_bounds.last().expect("following paragraph must paint");
2866 let painted_bottom = text_bounds
2867 .iter()
2868 .map(|bounds| bounds.bottom())
2869 .max()
2870 .expect("markdown must paint text");
2871 assert!(
2877 text_bounds[..text_bounds.len() - 1]
2878 .iter()
2879 .all(|bounds| bounds.bottom() <= following.top()),
2880 "{case}: heading/list text painted through the following paragraph; following={following:?}, text_bounds={text_bounds:?}, view={view_bounds:?}"
2881 );
2882 assert!(
2883 painted_bottom <= view_bounds.bottom(),
2884 "{case}: TextView height did not reserve its painted text; painted_bottom={painted_bottom:?}, view={view_bounds:?}"
2885 );
2886 }
2887 }
2888
2889 #[test]
2890 fn ordered_markdown_list_start_reaches_layout_marker() {
2891 use crate::text::inline::test_fonts::{WideMonoTextSystem, record_shaped_lines};
2892 use gpui::TestApp;
2893
2894 struct MarkdownRoot {
2895 text_view: Entity<TextViewState>,
2896 }
2897
2898 impl Render for MarkdownRoot {
2899 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2900 div().w(px(400.)).child(TextView::new(&self.text_view))
2901 }
2902 }
2903
2904 #[derive(Clone, Copy)]
2905 enum Format {
2906 Markdown,
2907 Html,
2908 }
2909
2910 fn shaped_markers(format: Format, source: &str) -> Vec<String> {
2911 let (_, shaped_lines) = record_shaped_lines(|| {
2912 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2913 app.update(crate::init);
2914 let mut window = app.open_window(|_, cx| MarkdownRoot {
2915 text_view: cx.new(|cx| match format {
2916 Format::Markdown => TextViewState::markdown(source, cx),
2917 Format::Html => TextViewState::html(source, cx),
2918 }),
2919 });
2920 window.draw();
2921 app.run_until_parked();
2922 window.draw();
2923 });
2924
2925 let mut markers = shaped_lines
2926 .into_iter()
2927 .filter(|line| line.len() <= 3 && line.ends_with('.'))
2928 .collect::<Vec<_>>();
2929 markers.dedup();
2930 markers
2931 }
2932
2933 let starts_at_one = "1. one\n2. two";
2934 assert_eq!(
2935 shaped_markers(Format::Markdown, starts_at_one),
2936 ["1.", "2."]
2937 );
2938 assert_eq!(
2939 shaped_markers(Format::Html, "<ol><li>one</li><li>two</li></ol>"),
2940 ["1.", "2."]
2941 );
2942
2943 assert_eq!(
2944 shaped_markers(Format::Markdown, "3. hello\n4. world"),
2945 ["3.", "4."]
2946 );
2947
2948 let nested_starts_at_four = "1. outer\n\n 4. nested\n 5. again";
2949 assert_eq!(
2950 shaped_markers(Format::Markdown, nested_starts_at_four),
2951 ["1.", "D.", "E."]
2952 );
2953
2954 let nested_starts_at_zero = "1. outer\n\n 0. zero";
2955 assert_eq!(
2956 shaped_markers(Format::Markdown, nested_starts_at_zero),
2957 ["1.", "0."]
2958 );
2959 }
2960
2961 #[test]
2966 fn inline_code_line_is_as_tall_as_a_plain_line() {
2967 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2968 use gpui::{TestApp, rems};
2969
2970 struct LineRoot {
2971 plain: Entity<TextViewState>,
2972 code: Entity<TextViewState>,
2973 preview_zoom: f32,
2974 }
2975
2976 impl Render for LineRoot {
2977 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2978 div()
2979 .w(px(600.))
2980 .text_size(rems(self.preview_zoom))
2981 .child(TextView::new(&self.plain))
2982 .child(TextView::new(&self.code))
2983 }
2984 }
2985
2986 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2987 app.update(|cx| {
2988 crate::init(cx);
2989 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2990 });
2991 for scale_factor in [1.6, 2.] {
2992 for preview_zoom in [1., 1.25] {
2993 let mut window = app.open_window(|_, cx| LineRoot {
2994 plain: cx.new(|cx| TextViewState::markdown("plain body words", cx)),
2995 code: cx.new(|cx| TextViewState::markdown("plain `code` words", cx)),
2996 preview_zoom,
2997 });
2998 window.simulate_scale_factor_change(scale_factor);
2999 window.draw();
3000 app.run_until_parked();
3001 window.draw();
3002 let (plain, code) = window.read(|root, cx| {
3003 (
3004 root.plain.read(cx).bounds().size.height,
3005 root.code.read(cx).bounds().size.height,
3006 )
3007 });
3008 assert_eq!(code, plain, "scale {scale_factor}, zoom {preview_zoom}");
3009 }
3010 }
3011 }
3012
3013 #[test]
3017 fn inline_code_list_body_paint_origin_matches_plain_list_item() {
3018 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
3019 use gpui::{TestApp, rems};
3020
3021 struct ListRoot {
3022 plain: Entity<TextViewState>,
3023 code: Entity<TextViewState>,
3024 preview_zoom: f32,
3025 }
3026
3027 impl Render for ListRoot {
3028 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3029 div()
3030 .w(px(600.))
3031 .text_size(rems(self.preview_zoom))
3032 .child(crate::TextSelectionLayer)
3033 .child(TextView::new(&self.plain).selectable(true))
3034 .child(TextView::new(&self.code).selectable(true))
3035 }
3036 }
3037
3038 fn draw(app: &mut TestApp, preview_zoom: f32) -> (Pixels, Pixels) {
3039 let mut window = app.open_window(|_, cx| ListRoot {
3040 plain: cx.new(|cx| TextViewState::markdown("- plain body words", cx)),
3041 code: cx.new(|cx| TextViewState::markdown("- plain `code` words", cx)),
3042 preview_zoom,
3043 });
3044 window.draw();
3045 app.run_until_parked();
3046 window.draw();
3047 window.read(|root, cx| {
3048 let first_body_line_top = |view: &Entity<TextViewState>| {
3049 let view = view.read(cx);
3050 let painted_line = view
3051 .selection_adapter
3052 .text_bounds()
3053 .into_iter()
3054 .next()
3055 .expect("list item should paint its first body line");
3056 painted_line.top() - view.bounds().top()
3057 };
3058 (
3059 first_body_line_top(&root.plain),
3060 first_body_line_top(&root.code),
3061 )
3062 })
3063 }
3064
3065 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
3066 app.update(|cx| {
3067 crate::init(cx);
3068 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
3069 });
3070 for (case, preview_zoom) in [("16px", 1.), ("zoom-1.25", 1.25), ("zoom-1.5", 1.5)] {
3071 let (plain_origin, code_origin) = draw(&mut app, preview_zoom);
3072 assert_eq!(
3073 code_origin, plain_origin,
3074 "{case}: code list body paint origin {code_origin:?} must match plain {plain_origin:?}"
3075 );
3076 }
3077 }
3078
3079 #[test]
3080 fn inline_code_fragment_does_not_paint_past_its_reserved_row() {
3081 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
3082 use gpui::TestApp;
3083
3084 const TEXT_BACKGROUND: u32 = 0x20f0b0;
3085
3086 struct MarkdownRoot {
3087 text_view: Entity<TextViewState>,
3088 }
3089
3090 impl Render for MarkdownRoot {
3091 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3092 div()
3093 .w(px(640.))
3094 .text_size(px(17.9))
3095 .text_bg(gpui::rgb(TEXT_BACKGROUND))
3096 .child(TextView::new(&self.text_view))
3097 }
3098 }
3099
3100 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
3101 app.update(|cx| {
3102 crate::init(cx);
3103 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
3104 });
3105 let mut window = app.open_window(|_, cx| MarkdownRoot {
3106 text_view: cx.new(|cx| TextViewState::markdown("`main` starts the paragraph", cx)),
3107 });
3108 window.draw();
3109 app.run_until_parked();
3110 window.draw();
3111
3112 let (view_bounds, painted) = window.update(|root, window, cx| {
3113 let view_bounds = root
3114 .text_view
3115 .read(cx)
3116 .bounds()
3117 .scale(window.scale_factor());
3118 let text_background: gpui::Background = gpui::rgb(TEXT_BACKGROUND).into();
3119 let painted = window
3120 .painted_quads()
3121 .into_iter()
3122 .filter(|quad| quad.background == text_background)
3123 .map(|quad| quad.bounds)
3124 .collect::<Vec<_>>();
3125 (view_bounds, painted)
3126 });
3127
3128 assert!(
3129 !painted.is_empty(),
3130 "the inherited text background must make actual text paint observable"
3131 );
3132 assert!(
3133 painted
3134 .iter()
3135 .all(|bounds| bounds.bottom() <= view_bounds.bottom()),
3136 "an inline-code fragment wrapped a second time after InlineFlow reserved one row; \
3137 text background quads={painted:?}, reserved TextView bounds={view_bounds:?}"
3138 );
3139 }
3140
3141 #[test]
3142 fn table_row_backgrounds_follow_the_frame_corner_radius() {
3143 use gpui::TestApp;
3144
3145 const HEAD_BACKGROUND: u32 = 0x11aa77;
3146 const RADIUS: f32 = 8.;
3147
3148 struct RoundedTableRoot {
3149 text_view: Entity<TextViewState>,
3150 scroll: bool,
3151 }
3152
3153 impl Render for RoundedTableRoot {
3154 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3155 let mut table = StyleRefinement::default();
3156 table.corner_radii.top_left = Some(px(RADIUS).into());
3157 table.corner_radii.top_right = Some(px(RADIUS).into());
3158 table.corner_radii.bottom_left = Some(px(RADIUS).into());
3159 table.corner_radii.bottom_right = Some(px(RADIUS).into());
3160 if self.scroll {
3161 table.overflow.x = Some(gpui::Overflow::Scroll);
3162 }
3163 let style = TextViewStyle::default()
3164 .with_code_background(gpui::rgb(HEAD_BACKGROUND).into())
3165 .with_table(table);
3166 div()
3167 .w(px(400.))
3168 .child(TextView::new(&self.text_view).style(style))
3169 }
3170 }
3171
3172 for scroll in [false, true] {
3173 let mode = if scroll { "scroll" } else { "wrap" };
3174 let mut app = TestApp::new();
3175 app.update(crate::init);
3176 let mut window = app.open_window(|_, cx| RoundedTableRoot {
3177 text_view: cx
3178 .new(|cx| TextViewState::markdown("| a | b |\n| --- | --- |\n| 1 | 2 |", cx)),
3179 scroll,
3180 });
3181 window.draw();
3182 app.run_until_parked();
3183 window.draw();
3184
3185 let (quad, scale) = window.update(|_, window, _| {
3186 let head: gpui::Background = gpui::rgb(HEAD_BACKGROUND).into();
3187 let quad = window
3188 .painted_quads()
3189 .into_iter()
3190 .find(|quad| quad.background == head)
3191 .unwrap_or_else(|| panic!("{mode}: the header background quad is painted"));
3192 (quad, window.scale_factor())
3193 });
3194
3195 let expected = px(RADIUS - 1.).scale(scale);
3198 assert_eq!(quad.corner_radii.top_left, expected, "{mode}: top left");
3199 assert_eq!(quad.corner_radii.top_right, expected, "{mode}: top right");
3200 let square = px(0.).scale(scale);
3201 assert_eq!(quad.corner_radii.bottom_left, square, "{mode}: bottom left");
3202 assert_eq!(
3203 quad.corner_radii.bottom_right, square,
3204 "{mode}: bottom right"
3205 );
3206 }
3207 }
3208
3209 #[test]
3215 fn table_keeps_narrow_columns_on_one_line_while_wide_ones_wrap() {
3216 use crate::text::inline::test_fonts::WideMonoTextSystem;
3217 use gpui::TestApp;
3218 use std::sync::Arc;
3219
3220 const CELL_BACKGROUND: u32 = 0x11aa77;
3221
3222 struct TableRoot {
3223 text_view: Entity<TextViewState>,
3224 width: Pixels,
3225 style: TextViewStyle,
3226 }
3227
3228 impl Render for TableRoot {
3229 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3230 div()
3231 .w(self.width)
3232 .child(TextView::new(&self.text_view).style(self.style.clone()))
3233 }
3234 }
3235
3236 let short = "Operating revenue (USD)";
3237 let source = format!(
3238 "| {short} | Note |\n| --- | --- |\n| {short} | x |\n| 1 | {} |",
3239 "word ".repeat(30)
3240 );
3241 let first_column = |width: f32, style: TextViewStyle| {
3243 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
3244 app.update(crate::init);
3245 let cell = style.table_cell().clone().bg(gpui::rgb(CELL_BACKGROUND));
3246 let style = style.with_table_cell(cell);
3247 let mut window = app.open_window(|_, cx| TableRoot {
3248 text_view: cx.new(|cx| TextViewState::markdown(&source, cx)),
3249 width: px(width),
3250 style,
3251 });
3252 window.draw();
3253 app.run_until_parked();
3254 window.draw();
3255 window.update(|_, window, _| {
3256 let background: gpui::Background = gpui::rgb(CELL_BACKGROUND).into();
3257 let cells: Vec<_> = window
3258 .painted_quads()
3259 .into_iter()
3260 .filter(|quad| quad.background == background)
3261 .map(|quad| quad.bounds)
3262 .collect();
3263 let left = cells.iter().map(|bounds| bounds.origin.x).min().unwrap();
3264 let mut column: Vec<_> = cells
3265 .into_iter()
3266 .filter(|bounds| bounds.origin.x == left)
3267 .collect();
3268 column.sort_by_key(|bounds| bounds.origin.y);
3269 column
3270 .iter()
3271 .map(|bounds| bounds.size.height)
3272 .collect::<Vec<_>>()
3273 })
3274 };
3275 let scroll = || {
3276 let mut table = StyleRefinement::default();
3277 table.overflow.x = Some(gpui::Overflow::Scroll);
3278 TextViewStyle::default().with_table(table)
3279 };
3280
3281 for (layout, style) in [("wrap", TextViewStyle::default()), ("scroll", scroll())] {
3282 let fits = first_column(4000., style.clone());
3283 let narrow = first_column(700., style);
3284 assert_eq!(fits.len(), 3, "{layout}: three rows");
3285 assert_eq!(fits[0], fits[2], "{layout}: the header fits on one line");
3286 assert_eq!(narrow[..2], fits[..2], "{layout}: the short column wraps");
3287 }
3288
3289 let single_line = first_column(4000., TextViewStyle::default())[0];
3290 let padded = TextViewStyle::default().with_table_cell(StyleRefinement::default().px_4());
3291 assert_eq!(
3292 first_column(4000., padded)[0],
3293 single_line,
3294 "the cell padding refinement leaves the text its measured width"
3295 );
3296 let bold = TextViewStyle::default()
3297 .with_table(StyleRefinement::default().font_weight(gpui::FontWeight::BOLD));
3298 assert_eq!(
3299 first_column(4000., bold)[0],
3300 single_line,
3301 "the table text refinement is measured"
3302 );
3303 }
3304
3305 #[gpui::test]
3306 fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
3307 cx.update(crate::init);
3308 let (_, cx) =
3309 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
3310 let cx: &mut VisualTestContext = cx;
3311
3312 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
3313
3314 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
3315 }
3316
3317 #[gpui::test]
3318 fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
3319 cx.update(crate::init);
3320 let (_, cx) =
3321 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
3322 let cx: &mut VisualTestContext = cx;
3323
3324 cx.simulate_mouse_down(
3325 point(px(10.), px(10.)),
3326 MouseButton::Right,
3327 Modifiers::default(),
3328 );
3329 cx.simulate_mouse_up(
3330 point(px(10.), px(10.)),
3331 MouseButton::Right,
3332 Modifiers::default(),
3333 );
3334
3335 assert_eq!(cx.opened_url(), None);
3336 }
3337
3338 #[gpui::test]
3339 fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
3340 use std::sync::{Arc, Mutex};
3341
3342 struct LinkRoot {
3343 text_view: Entity<TextViewState>,
3344 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
3345 }
3346
3347 impl Render for LinkRoot {
3348 fn render(
3349 &mut self,
3350 _window: &mut Window,
3351 _cx: &mut Context<Self>,
3352 ) -> impl IntoElement {
3353 let clicks = self.clicks.clone();
3354 div()
3355 .w(px(240.))
3356 .child(
3357 TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
3358 clicks.lock().unwrap().push((url.clone(), event.clone()));
3359 }),
3360 )
3361 }
3362 }
3363
3364 cx.update(crate::init);
3365 let clicks = Arc::new(Mutex::new(Vec::new()));
3366 let captured = clicks.clone();
3367 let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
3368 text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
3369 clicks,
3370 });
3371 let cx: &mut VisualTestContext = cx;
3372
3373 let mut modifiers = Modifiers::default();
3374 modifiers.control = true;
3375 cx.simulate_click(point(px(10.), px(10.)), modifiers);
3376 cx.simulate_mouse_down(
3377 point(px(10.), px(10.)),
3378 MouseButton::Middle,
3379 Modifiers::default(),
3380 );
3381 cx.simulate_mouse_up(
3382 point(px(10.), px(10.)),
3383 MouseButton::Middle,
3384 Modifiers::default(),
3385 );
3386 cx.simulate_mouse_down(
3387 point(px(10.), px(10.)),
3388 MouseButton::Right,
3389 Modifiers::default(),
3390 );
3391 cx.simulate_mouse_up(
3392 point(px(10.), px(10.)),
3393 MouseButton::Right,
3394 Modifiers::default(),
3395 );
3396
3397 let clicks = captured.lock().unwrap();
3398 assert_eq!(clicks.len(), 3);
3399 assert_eq!(clicks[0].0, "https://example.com");
3400 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
3401 assert!(clicks[0].1.modifiers().control);
3402 assert!(clicks[1].1.is_middle_click());
3403 assert!(clicks[2].1.is_right_click());
3404 assert_eq!(cx.opened_url(), None);
3405 }
3406
3407 #[gpui::test]
3408 fn inline_object_inherits_bold_and_link_clicks_without_opening_on_drag(
3409 cx: &mut TestAppContext,
3410 ) {
3411 use std::sync::{Arc, Mutex};
3412 struct Root {
3413 state: Entity<TextViewState>,
3414 clicks: Arc<Mutex<Vec<SharedString>>>,
3415 }
3416 impl Render for Root {
3417 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3418 let clicks = self.clicks.clone();
3419 let extensions = crate::text::MarkdownExtensions::default().plugin(
3420 crate::text::markdown_ext::TestInlinePlugin::new("math")
3421 .parse_with(|node, _| {
3422 matches!(node, markdown::mdast::Node::InlineMath(_)).then(|| {
3423 super::super::MarkdownNode::new("math", ()).text("formula")
3424 })
3425 })
3426 .render_with(|_, context, _, _| {
3427 assert_eq!(context.text_style().font_weight, gpui::FontWeight::BOLD);
3428 Some(super::super::InlineElement::new(div().child("formula")))
3429 }),
3430 );
3431 div().w(px(300.)).child(crate::TextSelectionLayer).child(
3432 TextView::new(&self.state)
3433 .markdown_extensions(extensions)
3434 .on_link_click(move |url, _, _, _| {
3435 clicks.lock().unwrap().push(url.clone())
3436 }),
3437 )
3438 }
3439 }
3440 cx.update(crate::init);
3441 let clicks = Arc::new(Mutex::new(Vec::new()));
3442 let captured = clicks.clone();
3443 let (root, cx) = cx.add_window_view(move |_, cx| Root {
3444 state: cx.new(|cx| TextViewState::markdown("**[$x$](https://example.com)**", cx)),
3445 clicks,
3446 });
3447 let cx: &mut VisualTestContext = cx;
3448 cx.run_until_parked();
3449 cx.update(|window, cx| {
3450 let _ = window.draw(cx);
3451 });
3452 let bounds = root.read_with(cx, |root, cx| {
3453 root.state.read(cx).selection_adapter.text_bounds()[0]
3454 });
3455 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
3456 cx.simulate_mouse_down(bounds.center(), button, Modifiers::default());
3457 cx.simulate_mouse_up(bounds.center(), button, Modifiers::default());
3458 }
3459 assert_eq!(captured.lock().unwrap().len(), 3);
3460 let start = point(bounds.left() + px(1.), bounds.center().y);
3461 let end = point(bounds.right() - px(1.), bounds.center().y);
3462 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
3463 cx.update(|window, cx| {
3464 let _ = window.draw(cx);
3465 });
3466 cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
3467 cx.update(|window, cx| {
3468 let _ = window.draw(cx);
3469 });
3470 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
3471 assert_eq!(captured.lock().unwrap().len(), 3);
3472 }
3473
3474 #[gpui::test]
3475 fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
3476 use std::sync::{Arc, Mutex};
3477
3478 struct LinkedImageRoot {
3479 text_view: Entity<TextViewState>,
3480 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
3481 }
3482
3483 impl Render for LinkedImageRoot {
3484 fn render(
3485 &mut self,
3486 _window: &mut Window,
3487 _cx: &mut Context<Self>,
3488 ) -> impl IntoElement {
3489 let clicks = self.clicks.clone();
3490 div().w(px(160.)).child(
3491 TextView::new(&self.text_view)
3492 .selectable(true)
3493 .on_link_click(move |url, event, _, _| {
3494 clicks.lock().unwrap().push((url.clone(), event.clone()));
3495 }),
3496 )
3497 }
3498 }
3499
3500 cx.update(crate::init);
3501 let clicks = Arc::new(Mutex::new(Vec::new()));
3502 let captured = clicks.clone();
3503 let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
3504 text_view: cx.new(|cx| {
3505 TextViewState::markdown(
3506 r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
3507 cx,
3508 )
3509 }),
3510 clicks,
3511 }
3512 );
3513 let cx: &mut VisualTestContext = cx;
3514 cx.run_until_parked();
3515 cx.update(|window, cx| {
3516 let _ = window.draw(cx);
3517 });
3518
3519 let inline_bounds = content.read_with(cx, |content, cx| {
3520 content.text_view.read(cx).selection_adapter.text_bounds()
3521 });
3522 assert!(
3523 inline_bounds.len() >= 2,
3524 "linked image needs text bounds on both sides: {inline_bounds:?}"
3525 );
3526 assert!(
3527 inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
3528 "linked image did not reserve the expected click target: {inline_bounds:?}"
3529 );
3530 let position = point(
3531 inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
3532 inline_bounds[0].top() + px(8.),
3533 );
3534 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
3535 cx.simulate_mouse_down(position, button, Modifiers::default());
3536 cx.simulate_mouse_up(position, button, Modifiers::default());
3537 }
3538
3539 let clicks = captured.lock().unwrap();
3540 assert_eq!(clicks.len(), 3);
3541 assert!(
3542 clicks
3543 .iter()
3544 .all(|(url, _)| url == "https://example.com/image-link")
3545 );
3546 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
3547 assert!(clicks[1].1.is_middle_click());
3548 assert!(clicks[2].1.is_right_click());
3549 assert_eq!(cx.opened_url(), None);
3550 }
3551
3552 #[gpui::test]
3553 fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
3554 cx.update(crate::init);
3555 let (view, cx) = cx
3556 .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
3557 let cx: &mut VisualTestContext = cx;
3558
3559 cx.simulate_mouse_down(
3560 point(px(10.), px(34.)),
3561 MouseButton::Left,
3562 Modifiers::default(),
3563 );
3564 cx.simulate_mouse_move(
3565 point(px(90.), px(34.)),
3566 Some(MouseButton::Left),
3567 Modifiers::default(),
3568 );
3569 cx.simulate_mouse_up(
3570 point(px(90.), px(34.)),
3571 MouseButton::Left,
3572 Modifiers::default(),
3573 );
3574
3575 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3576 assert!(
3577 selected_text.is_empty(),
3578 "unexpected selection: {selected_text:?}"
3579 );
3580 }
3581
3582 struct ClippedTallTextViewTestRoot {
3586 text_view: Entity<TextViewState>,
3587 }
3588
3589 impl ClippedTallTextViewTestRoot {
3590 fn new(cx: &mut Context<Self>) -> Self {
3591 let text_view =
3595 cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
3596 Self { text_view }
3597 }
3598 }
3599
3600 impl Render for ClippedTallTextViewTestRoot {
3601 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3602 div()
3603 .w(px(200.))
3604 .child(crate::TextSelectionLayer)
3605 .child(
3606 div()
3607 .h(px(40.))
3608 .overflow_hidden()
3609 .child(TextView::new(&self.text_view).selectable(true)),
3610 )
3611 .child(div().h(px(160.)))
3614 }
3615 }
3616
3617 #[gpui::test]
3626 fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
3627 cx.update(crate::init);
3628 let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
3629 let cx: &mut VisualTestContext = cx;
3630
3631 cx.run_until_parked();
3632 cx.update(|window, cx| {
3633 let _ = window.draw(cx);
3634 });
3635
3636 cx.simulate_mouse_down(
3640 point(px(2.), px(8.)),
3641 MouseButton::Left,
3642 Modifiers::default(),
3643 );
3644 cx.update(|window, cx| {
3645 let _ = window.draw(cx);
3646 });
3647 cx.simulate_mouse_move(
3648 point(px(180.), px(150.)),
3649 Some(MouseButton::Left),
3650 Modifiers::default(),
3651 );
3652 cx.update(|window, cx| {
3653 let _ = window.draw(cx);
3654 });
3655 cx.simulate_mouse_up(
3656 point(px(180.), px(150.)),
3657 MouseButton::Left,
3658 Modifiers::default(),
3659 );
3660 cx.update(|window, cx| {
3661 let _ = window.draw(cx);
3662 });
3663
3664 let selected_text =
3665 content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3666 assert!(
3667 selected_text.contains("delta"),
3668 "clipped-out text was not copied: {selected_text:?}"
3669 );
3670 assert!(
3671 selected_text.contains("charlie"),
3672 "clipped-out text was not copied: {selected_text:?}"
3673 );
3674 }
3675
3676 #[gpui::test]
3677 fn double_click_selects_word(cx: &mut TestAppContext) {
3678 cx.update(crate::init);
3679 let (view, cx) =
3680 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3681
3682 let cx: &mut VisualTestContext = cx;
3683 cx.run_until_parked();
3684 cx.update(|window, cx| {
3685 let _ = window.draw(cx);
3686 });
3687 let position = point(px(10.), px(16.));
3688 cx.simulate_event(MouseDownEvent {
3689 position,
3690 modifiers: Modifiers::default(),
3691 button: MouseButton::Left,
3692 click_count: 2,
3693 first_mouse: false,
3694 });
3695 cx.simulate_event(MouseUpEvent {
3696 position,
3697 modifiers: Modifiers::default(),
3698 button: MouseButton::Left,
3699 click_count: 2,
3700 });
3701 cx.update(|window, cx| {
3702 let _ = window.draw(cx);
3703 });
3704
3705 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3706 assert_eq!(selected_text.trim(), "quick");
3707 }
3708
3709 #[gpui::test]
3710 fn long_press_selects_word_then_drag_extends_selection(cx: &mut TestAppContext) {
3711 struct TouchRoot {
3712 text_view: Entity<TextViewState>,
3713 }
3714 impl Render for TouchRoot {
3715 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3716 div()
3717 .w(px(300.))
3718 .child(crate::TextSelectionLayer)
3719 .child(TextView::new(&self.text_view).selectable(true))
3720 }
3721 }
3722 cx.update(crate::init);
3723 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3724 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3725 });
3726 cx.run_until_parked();
3727 cx.update(|window, cx| {
3728 let _ = window.draw(cx);
3729 });
3730 let start_position = point(px(10.), px(16.));
3731 cx.simulate_event(gpui::LongPressEvent {
3732 phase: gpui::TouchPhase::Started,
3733 start_position,
3734 position: start_position,
3735 });
3736 cx.update(|window, cx| {
3737 let _ = window.draw(cx);
3738 });
3739 assert_eq!(
3740 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3741 .trim(),
3742 "quick"
3743 );
3744 for phase in [gpui::TouchPhase::Moved, gpui::TouchPhase::Ended] {
3745 cx.simulate_event(gpui::LongPressEvent {
3746 phase,
3747 start_position,
3748 position: point(px(220.), px(16.)),
3749 });
3750 }
3751 cx.update(|window, cx| {
3752 let _ = window.draw(cx);
3753 });
3754 assert_eq!(
3755 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3756 .trim(),
3757 "quick select value"
3758 );
3759 }
3760
3761 #[gpui::test]
3762 fn long_press_release_keeps_handles_which_drag_the_selection(cx: &mut TestAppContext) {
3763 use crate::{SelectionEdge, TextSelection};
3764
3765 struct TouchRoot {
3766 text_view: Entity<TextViewState>,
3767 }
3768 impl Render for TouchRoot {
3769 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3770 div()
3771 .w(px(300.))
3772 .child(crate::TextSelectionLayer)
3773 .child(TextView::new(&self.text_view).selectable(true))
3774 }
3775 }
3776 cx.update(crate::init);
3777 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3778 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3779 });
3780 let draw = |cx: &mut VisualTestContext| {
3781 cx.update(|window, cx| {
3782 let _ = window.draw(cx);
3783 });
3784 };
3785 let selected = |cx: &mut VisualTestContext| {
3786 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3787 .trim()
3788 .to_string()
3789 };
3790 cx.run_until_parked();
3791 draw(cx);
3792
3793 let start_position = point(px(70.), px(16.));
3794 for phase in [gpui::TouchPhase::Started, gpui::TouchPhase::Ended] {
3795 cx.simulate_event(gpui::LongPressEvent {
3796 phase,
3797 start_position,
3798 position: start_position,
3799 });
3800 draw(cx);
3801 }
3802 assert_eq!(selected(cx), "select");
3803 let snapshot = cx
3804 .update(|window, cx| TextSelection::touch_selection(window, cx))
3805 .expect("a released long press keeps its handles");
3806 assert!(snapshot.is_menu_open());
3807 assert!(!snapshot.is_empty());
3808 assert!(snapshot.start().left() < snapshot.end().left());
3809
3810 let end = snapshot.end();
3813 let finger = point(end.left(), end.bottom() + px(20.));
3814 cx.update(|window, cx| {
3815 TextSelection::begin_edge_drag(SelectionEdge::End, finger, window, cx);
3816 });
3817 draw(cx);
3818 let snapshot = cx
3819 .update(|window, cx| TextSelection::touch_selection(window, cx))
3820 .unwrap();
3821 assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
3822 assert!(!snapshot.is_menu_open());
3823 cx.update(|window, cx| {
3824 TextSelection::update_edge_drag(point(px(290.), finger.y), window, cx);
3825 });
3826 draw(cx);
3827 assert_eq!(selected(cx), "select value");
3828 cx.update(|window, cx| TextSelection::end_edge_drag(window, cx));
3829 draw(cx);
3830 let snapshot = cx
3831 .update(|window, cx| TextSelection::touch_selection(window, cx))
3832 .unwrap();
3833 assert!(snapshot.is_menu_open());
3834 assert_eq!(snapshot.dragging(), None);
3835 let select_start = snapshot.start().left();
3836
3837 cx.update(|_, cx| {
3840 view.update(cx, |root, cx| {
3841 root.text_view.update(cx, |state, cx| state.select_all(cx));
3842 });
3843 });
3844 draw(cx);
3845 assert_eq!(selected(cx), "quick select value");
3846 let snapshot = cx
3847 .update(|window, cx| TextSelection::touch_selection(window, cx))
3848 .expect("select all keeps the touch selection");
3849 let start = snapshot.start();
3850 cx.update(|window, cx| {
3851 TextSelection::begin_edge_drag(SelectionEdge::Start, start.origin, window, cx);
3852 TextSelection::update_edge_drag(point(select_start, start.origin.y), window, cx);
3853 TextSelection::end_edge_drag(window, cx);
3854 });
3855 draw(cx);
3856 assert_eq!(selected(cx), "select value");
3857
3858 let menu = gpui::Bounds::new(point(px(0.), px(200.)), gpui::size(px(120.), px(32.)));
3861 cx.update(|window, cx| TextSelection::register_touch_ui(menu, window, cx));
3862 cx.simulate_event(MouseDownEvent {
3863 position: point(px(10.), px(210.)),
3864 modifiers: Modifiers::default(),
3865 button: MouseButton::Left,
3866 click_count: 1,
3867 first_mouse: false,
3868 });
3869 assert_eq!(selected(cx), "select value");
3870 cx.simulate_event(MouseDownEvent {
3871 position: point(px(10.), px(16.)),
3872 modifiers: Modifiers::default(),
3873 button: MouseButton::Left,
3874 click_count: 1,
3875 first_mouse: false,
3876 });
3877 cx.simulate_event(MouseUpEvent {
3878 position: point(px(10.), px(16.)),
3879 modifiers: Modifiers::default(),
3880 button: MouseButton::Left,
3881 click_count: 1,
3882 });
3883 draw(cx);
3884 assert!(
3885 cx.update(|window, cx| TextSelection::touch_selection(window, cx))
3886 .is_none()
3887 );
3888 }
3889
3890 #[gpui::test]
3891 fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
3892 cx.update(crate::init);
3893 let (view, cx) =
3894 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3895
3896 let cx: &mut VisualTestContext = cx;
3897 cx.run_until_parked();
3898 cx.update(|window, cx| {
3899 let _ = window.draw(cx);
3900 });
3901
3902 let position = point(px(10.), px(10.));
3903 cx.simulate_event(MouseDownEvent {
3904 position,
3905 modifiers: Modifiers::default(),
3906 button: MouseButton::Left,
3907 click_count: 3,
3908 first_mouse: false,
3909 });
3910 cx.simulate_event(MouseUpEvent {
3911 position,
3912 modifiers: Modifiers::default(),
3913 button: MouseButton::Left,
3914 click_count: 3,
3915 });
3916 cx.update(|window, cx| {
3917 let _ = window.draw(cx);
3918 });
3919
3920 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3921 assert_eq!(selected_text.trim(), "quick select value");
3922 }
3923
3924 mod multi_click_drag {
3925 use super::*;
3926
3927 struct Root {
3928 text_view: Entity<TextViewState>,
3929 }
3930
3931 impl Render for Root {
3932 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3933 div()
3934 .w(px(400.))
3935 .child(crate::TextSelectionLayer)
3936 .child(TextView::new(&self.text_view).selectable(true))
3937 }
3938 }
3939
3940 fn setup<'a>(
3941 markdown: &str,
3942 cx: &'a mut TestAppContext,
3943 ) -> (Entity<TextViewState>, &'a mut VisualTestContext) {
3944 cx.update(crate::init);
3945 let (root, cx) = cx.add_window_view(|_, cx| Root {
3946 text_view: cx.new(|cx| TextViewState::markdown(markdown, cx)),
3947 });
3948 cx.run_until_parked();
3949 cx.update(|window, cx| window.draw(cx).clear(cx));
3950 (root.read_with(cx, |root, _| root.text_view.clone()), cx)
3951 }
3952
3953 fn position(
3955 state: &Entity<TextViewState>,
3956 text: &str,
3957 index: usize,
3958 cx: &mut VisualTestContext,
3959 ) -> gpui::Point<Pixels> {
3960 state.read_with(cx, |state, _| {
3961 state.selection_adapter.text_position(text, index)
3962 })
3963 }
3964
3965 fn press(
3966 position: gpui::Point<Pixels>,
3967 click_count: usize,
3968 shift: bool,
3969 cx: &mut VisualTestContext,
3970 ) {
3971 cx.simulate_event(MouseDownEvent {
3972 position,
3973 modifiers: Modifiers {
3974 shift,
3975 ..Default::default()
3976 },
3977 button: MouseButton::Left,
3978 click_count,
3979 first_mouse: false,
3980 });
3981 }
3982
3983 fn drag(position: gpui::Point<Pixels>, cx: &mut VisualTestContext) {
3984 cx.simulate_event(gpui::MouseMoveEvent {
3985 position,
3986 pressed_button: Some(MouseButton::Left),
3987 modifiers: Modifiers::default(),
3988 });
3989 }
3990
3991 fn release(position: gpui::Point<Pixels>, click_count: usize, cx: &mut VisualTestContext) {
3992 cx.simulate_event(MouseUpEvent {
3993 position,
3994 modifiers: Modifiers::default(),
3995 button: MouseButton::Left,
3996 click_count,
3997 });
3998 }
3999
4000 fn selected(state: &Entity<TextViewState>, cx: &mut VisualTestContext) -> String {
4001 cx.update(|window, cx| window.draw(cx).clear(cx));
4002 state.read_with(cx, |state, _| state.selected_text().trim().to_string())
4003 }
4004
4005 #[gpui::test]
4006 fn double_click_drag_extends_the_selection_by_words(cx: &mut TestAppContext) {
4007 let (first, second) = ("alpha beta gamma", "delta epsilon zeta");
4008 let (state, cx) = setup(&format!("{first}\n\n{second}"), cx);
4009
4010 press(position(&state, first, 7, cx), 2, false, cx);
4011 assert_eq!(selected(&state, cx), "beta");
4012 drag(position(&state, first, 8, cx), cx);
4013 assert_eq!(selected(&state, cx), "beta");
4014 drag(position(&state, first, 13, cx), cx);
4015 assert_eq!(selected(&state, cx), "beta gamma");
4016 drag(position(&state, first, 1, cx), cx);
4018 assert_eq!(selected(&state, cx), "alpha beta");
4019 let end = position(&state, second, 8, cx);
4020 drag(end, cx);
4021 assert_eq!(selected(&state, cx), "beta gamma\ndelta epsilon");
4022 release(end, 2, cx);
4023 assert_eq!(selected(&state, cx), "beta gamma\ndelta epsilon");
4024 }
4025
4026 #[gpui::test]
4027 fn triple_click_drag_extends_the_selection_by_paragraphs(cx: &mut TestAppContext) {
4028 let (state, cx) = setup("first one\n\nsecond one\n\nthird one", cx);
4029
4030 press(position(&state, "second one", 2, cx), 3, false, cx);
4031 assert_eq!(selected(&state, cx), "second one");
4032 drag(position(&state, "third one", 2, cx), cx);
4033 assert_eq!(selected(&state, cx), "second one\nthird one");
4034 let end = position(&state, "first one", 2, cx);
4035 drag(end, cx);
4036 assert_eq!(selected(&state, cx), "first one\nsecond one");
4037 release(end, 3, cx);
4038 assert_eq!(selected(&state, cx), "first one\nsecond one");
4039 }
4040
4041 #[gpui::test]
4043 fn triple_click_drag_and_shift_click_keep_hard_break_paragraphs_whole(
4044 cx: &mut TestAppContext,
4045 ) {
4046 let first = "first top\nfirst bottom";
4047 let last = "last top\nlast bottom";
4048 let (state, cx) = setup(
4049 "first top \nfirst bottom\n\nmiddle\n\nlast top \nlast bottom",
4050 cx,
4051 );
4052
4053 press(position(&state, "middle", 2, cx), 3, false, cx);
4054 assert_eq!(selected(&state, cx), "middle");
4055 let after = position(&state, last, 2, cx);
4056 drag(after, cx);
4057 assert_eq!(selected(&state, cx), format!("middle\n{last}"));
4058 let before = position(&state, first, "first top\n".len() + 2, cx);
4059 drag(before, cx);
4060 assert_eq!(selected(&state, cx), format!("{first}\nmiddle"));
4061 release(before, 3, cx);
4062
4063 press(after, 1, true, cx);
4064 release(after, 1, cx);
4065 assert_eq!(selected(&state, cx), format!("middle\n{last}"));
4066 press(before, 1, true, cx);
4067 release(before, 1, cx);
4068 assert_eq!(selected(&state, cx), format!("{first}\nmiddle"));
4069 }
4070
4071 #[gpui::test]
4073 fn multi_click_drag_snaps_across_inline_code(cx: &mut TestAppContext) {
4074 let (state, cx) = setup("plain words\n\nuse `code` in here", cx);
4075
4076 press(position(&state, "plain words", 8, cx), 2, false, cx);
4077 drag(position(&state, " in here", 6, cx), cx);
4078 assert_eq!(selected(&state, cx), "words\nuse code in here");
4079 release(position(&state, " in here", 6, cx), 2, cx);
4080
4081 press(position(&state, "plain words", 2, cx), 3, false, cx);
4082 assert_eq!(selected(&state, cx), "plain words");
4083 drag(position(&state, "use ", 1, cx), cx);
4084 assert_eq!(selected(&state, cx), "plain words\nuse code in here");
4085 }
4086
4087 #[gpui::test]
4088 fn shift_click_after_a_double_click_extends_by_words(cx: &mut TestAppContext) {
4089 let text = "alpha beta gamma";
4090 let (state, cx) = setup(text, cx);
4091
4092 let word = position(&state, text, 7, cx);
4093 press(word, 2, false, cx);
4094 release(word, 2, cx);
4095 let after = position(&state, text, 13, cx);
4096 press(after, 1, true, cx);
4097 release(after, 1, cx);
4098 assert_eq!(selected(&state, cx), "beta gamma");
4099 let before = position(&state, text, 1, cx);
4100 press(before, 1, true, cx);
4101 release(before, 1, cx);
4102 assert_eq!(selected(&state, cx), "alpha beta");
4103 }
4104 }
4105
4106 mod copy_before_paint {
4107 use super::*;
4108 use crate::{TextSelection, text::SelectionFormat};
4109
4110 const MARKDOWN: &str = "中文 **café**\n\nβeta `\u{2009}code\u{2009}` 👩💻\n\n終わり";
4111 const PLAIN: &str = "中文 café\nβeta \u{2009}code\u{2009} 👩💻\n終わり\n";
4112
4113 struct Root {
4114 state: Entity<TextViewState>,
4115 format: SelectionFormat,
4116 width: Pixels,
4117 scrollable: bool,
4118 }
4119
4120 impl Render for Root {
4121 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
4122 div()
4123 .w(self.width)
4124 .when(self.scrollable, |el| el.h(px(160.)))
4125 .child(crate::TextSelectionLayer)
4126 .child(
4127 TextView::new(&self.state)
4128 .scrollable(self.scrollable)
4129 .selection_format(self.format),
4130 )
4131 }
4132 }
4133
4134 fn setup(
4135 format: SelectionFormat,
4136 cx: &mut TestAppContext,
4137 ) -> (Entity<TextViewState>, &mut VisualTestContext) {
4138 cx.update(crate::init);
4139 let (root, cx) = cx.add_window_view(|_, cx| Root {
4140 state: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
4141 format,
4142 width: px(520.),
4143 scrollable: false,
4144 });
4145 cx.run_until_parked();
4146 cx.update(|window, cx| window.draw(cx).clear(cx));
4147 (root.read_with(cx, |root, _| root.state.clone()), cx)
4148 }
4149
4150 fn document<'a>(
4151 markdown: &str,
4152 format: SelectionFormat,
4153 scrollable: bool,
4154 cx: &'a mut TestAppContext,
4155 ) -> (Entity<Root>, &'a mut VisualTestContext) {
4156 cx.update(crate::init);
4157 let (root, cx) = cx.add_window_view(|_, cx| Root {
4158 state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
4159 format,
4160 width: px(520.),
4161 scrollable,
4162 });
4163 cx.run_until_parked();
4164 cx.update(|window, cx| window.draw(cx).clear(cx));
4165 (root, cx)
4166 }
4167
4168 fn scroll_to(state: &Entity<TextViewState>, ix: usize, cx: &mut VisualTestContext) {
4169 state.read_with(cx, |state, _| {
4170 state.list_state.scroll_to(gpui::ListOffset {
4171 item_ix: ix,
4172 offset_in_item: px(0.),
4173 })
4174 });
4175 cx.update(|window, cx| window.draw(cx).clear(cx));
4176 }
4177
4178 fn endpoints(
4179 state: &Entity<TextViewState>,
4180 cx: &mut VisualTestContext,
4181 ) -> (
4182 gpui::Point<Pixels>,
4183 gpui::Point<Pixels>,
4184 gpui::Point<Pixels>,
4185 ) {
4186 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
4187 let first = rows.first().unwrap();
4188 let last = rows.last().unwrap();
4189 (
4190 point(first.left() + px(0.1), first.center().y),
4191 point(first.right() - px(0.1), first.center().y),
4192 point(last.right() - px(0.1), last.center().y),
4193 )
4194 }
4195
4196 fn press(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
4199 window.dispatch_event(
4200 gpui::PlatformInput::MouseDown(MouseDownEvent {
4201 position,
4202 button: MouseButton::Left,
4203 click_count: 1,
4204 ..Default::default()
4205 }),
4206 cx,
4207 );
4208 }
4209
4210 fn extend(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
4211 window.dispatch_event(
4212 gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
4213 position,
4214 pressed_button: Some(MouseButton::Left),
4215 ..Default::default()
4216 }),
4217 cx,
4218 );
4219 }
4220
4221 fn release(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
4222 window.dispatch_event(
4223 gpui::PlatformInput::MouseUp(MouseUpEvent {
4224 position,
4225 button: MouseButton::Left,
4226 modifiers: Modifiers::default(),
4227 click_count: 1,
4228 }),
4229 cx,
4230 );
4231 }
4232
4233 fn assert_copy(expected: &str, window: &mut Window, cx: &mut gpui::App) {
4234 let focus = window.focused(cx).expect("gesture focuses TextView");
4237 focus.dispatch_action(&crate::input::Copy, window, cx);
4238 assert_eq!(
4239 cx.read_from_clipboard().and_then(|item| item.text()),
4240 Some(expected.trim().to_string())
4241 );
4242 assert!(focus.is_focused(window));
4243 assert_eq!(TextSelection::selected_text(window, cx), expected);
4244 }
4245
4246 #[gpui::test]
4247 fn custom_inline_and_block_copy_project_with_the_text(cx: &mut TestAppContext) {
4248 struct CustomRoot {
4249 state: Entity<TextViewState>,
4250 format: SelectionFormat,
4251 extensions: crate::text::MarkdownExtensions,
4252 }
4253 impl Render for CustomRoot {
4254 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
4255 div().w(px(520.)).child(crate::TextSelectionLayer).child(
4256 TextView::new(&self.state)
4257 .selection_format(self.format)
4258 .markdown_extensions(self.extensions.clone()),
4259 )
4260 }
4261 }
4262 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
4263 cx.update(crate::init);
4264 let extensions = crate::text::MarkdownExtensions::default()
4265 .plugin(
4266 crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(
4267 |node, _| {
4268 let markdown::mdast::Node::InlineMath(math) = node else {
4269 return None;
4270 };
4271 Some(
4272 crate::text::MarkdownNode::new("math", ())
4273 .text(format!("{}²", math.value)),
4274 )
4275 },
4276 ),
4277 )
4278 .block_parser(|node, _| {
4279 matches!(node, markdown::mdast::Node::ThematicBreak(_)).then(|| {
4280 crate::text::MarkdownNode::new("block", ())
4281 .text("BLOCK")
4282 .markdown("---")
4283 })
4284 })
4285 .block_renderer("block", |_, _, _| {
4286 div()
4287 .debug_selector(|| "copy-custom-block".into())
4288 .w(px(520.))
4289 .h(px(40.))
4290 });
4291 let (root, cx) = cx.add_window_view(|_, cx| CustomRoot {
4292 state: cx
4293 .new(|cx| TextViewState::markdown("before $x$ after\n\n---\n\nlast", cx)),
4294 format,
4295 extensions,
4296 });
4297 cx.run_until_parked();
4298 cx.update(|window, cx| window.draw(cx).clear(cx));
4299 let state = root.read_with(cx, |root, _| root.state.clone());
4300 let (start, _, end) = endpoints(&state, cx);
4301 let expected = if format == SelectionFormat::Plain {
4302 "before x² after\nBLOCK\nlast\n"
4303 } else {
4304 "before $x$ after\n\n---\n\nlast"
4305 };
4306 cx.update(|window, cx| {
4307 press(start, window, cx);
4308 extend(end, window, cx);
4309 assert_copy(expected, window, cx);
4310 release(end, window, cx);
4311 });
4312 cx.update(|window, cx| window.draw(cx).clear(cx));
4313 cx.update(|window, cx| assert_copy(expected, window, cx));
4314 let block = cx.debug_bounds("copy-custom-block").unwrap();
4315 cx.update(|window, cx| {
4316 press(block.center(), window, cx);
4317 extend(point(block.center().x, block.bottom() + px(1.)), window, cx);
4318 assert_copy(
4319 if format == SelectionFormat::Plain {
4320 "BLOCK\n"
4321 } else {
4322 "---"
4323 },
4324 window,
4325 cx,
4326 );
4327 });
4328 }
4329 }
4330
4331 #[gpui::test]
4332 fn unicode_180_paragraph_copy_extends_and_retracts_before_paint(cx: &mut TestAppContext) {
4333 let markdown = (0..180)
4334 .map(|ix| format!("p{ix:03} **café** 中文 👩💻 `\u{2009}code\u{2009}`"))
4335 .collect::<Vec<_>>()
4336 .join("\n\n");
4337 let expected = (0..180)
4340 .map(|ix| format!("p{ix:03} café 中文 👩💻 \u{2009}code\u{2009}\n"))
4341 .collect::<String>();
4342 let (root, cx) = document(&markdown, SelectionFormat::Plain, false, cx);
4343 cx.simulate_resize(gpui::size(px(800.), px(12000.)));
4344 cx.update(|window, cx| window.draw(cx).clear(cx));
4345 let state = root.read_with(cx, |root, _| root.state.clone());
4346 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
4347 let first = rows.first().unwrap();
4348 let first_end = rows
4349 .iter()
4350 .take_while(|row| row.top() < first.bottom())
4351 .last()
4352 .unwrap();
4353 let last = rows.last().unwrap();
4354 assert!(last.top() > first.top() + px(3000.));
4355 let start = point(first.left() + px(0.1), first.center().y);
4356 let short_end = point(first_end.right() - px(0.1), first_end.center().y);
4357 let end = point(last.right() - px(0.1), last.center().y);
4358 let short = "p000 café 中文 👩💻 \u{2009}code\u{2009}\n";
4359 cx.update(|window, cx| {
4360 press(start, window, cx);
4361 extend(short_end, window, cx);
4362 assert_copy(short, window, cx);
4363 });
4364 cx.update(|window, cx| window.draw(cx).clear(cx));
4365 cx.update(|window, cx| {
4366 extend(end, window, cx);
4367 assert_copy(&expected, window, cx);
4368 extend(short_end, window, cx);
4369 assert_copy(short, window, cx);
4370 });
4371 }
4372
4373 #[gpui::test]
4374 fn scrolling_before_paint_keeps_the_completed_frame_coordinates(cx: &mut TestAppContext) {
4375 let markdown = format!(
4376 "{}\n\nlast",
4377 "alpha beta gamma delta epsilon zeta ".repeat(30)
4378 );
4379 let (root, cx) = document(&markdown, SelectionFormat::Plain, true, cx);
4380 root.update(cx, |root, cx| {
4381 root.width = px(180.);
4382 cx.notify();
4383 });
4384 cx.update(|window, cx| window.draw(cx).clear(cx));
4385 let state = root.read_with(cx, |root, _| root.state.clone());
4386 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
4387 let first = rows[0];
4388 let (start, end) = state.read_with(cx, |state, _| {
4389 (
4390 state.selection_adapter.first_text_position(0),
4391 state.selection_adapter.first_text_position("alpha".len()),
4392 )
4393 });
4394 cx.update(|window, cx| {
4395 press(start, window, cx);
4396 extend(end, window, cx);
4397 });
4398 cx.update(|window, cx| window.draw(cx).clear(cx));
4399 cx.update(|window, cx| {
4400 state.read(cx).list_state.scroll_by(first.size.height);
4401 assert_copy("alpha\n", window, cx);
4404 });
4405 }
4406
4407 #[gpui::test]
4408 fn offscreen_remeasurement_is_not_a_copy_projection(cx: &mut TestAppContext) {
4409 let markdown = (0..180)
4410 .map(|ix| format!("row {ix:03} café 中文"))
4411 .collect::<Vec<_>>()
4412 .join("\n\n");
4413 let (root, cx) = document(&markdown, SelectionFormat::Plain, true, cx);
4414 let state = root.read_with(cx, |root, _| root.state.clone());
4415 scroll_to(&state, 90, cx);
4416 root.update(cx, |root, cx| {
4417 root.width = px(260.);
4418 cx.notify();
4419 });
4420 cx.update(|window, cx| window.draw(cx).clear(cx));
4421 scroll_to(&state, 90, cx);
4422 let (start, end, _) = endpoints(&state, cx);
4425 cx.update(|window, cx| {
4426 press(start, window, cx);
4427 extend(end, window, cx);
4428 assert_copy("row 090 café 中文\n", window, cx);
4429 });
4430 }
4431
4432 #[gpui::test]
4433 fn virtualized_flow_retains_its_source_owned_partial_anchor(cx: &mut TestAppContext) {
4434 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
4435 let tail = (1..180)
4436 .map(|ix| format!("row {ix:03} café 中文"))
4437 .collect::<Vec<_>>();
4438 let markdown = format!("PREFIX `code` suffix\n\n{}", tail.join("\n\n"));
4439 let expected = if format == SelectionFormat::Plain {
4440 format!("code suffix\n{}\n", tail.join("\n"))
4441 } else {
4442 format!("`code` suffix\n\n{}", tail.join("\n\n"))
4443 };
4444 let (root, cx) = document(&markdown, format, true, cx);
4445 let state = root.read_with(cx, |root, _| root.state.clone());
4446 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
4447 let code = rows[1];
4448 let suffix = rows[2];
4449 assert!(code.left() < suffix.left());
4450 cx.update(|window, cx| {
4451 press(point(code.left() + px(0.1), code.center().y), window, cx);
4452 extend(
4453 point(suffix.right() - px(0.1), suffix.center().y),
4454 window,
4455 cx,
4456 );
4457 });
4458 cx.update(|window, cx| window.draw(cx).clear(cx));
4459 scroll_to(&state, 179, cx);
4460 cx.update(|window, cx| window.draw(cx).clear(cx));
4462 let (_, _, end) = endpoints(&state, cx);
4463 cx.update(|window, cx| {
4464 extend(end, window, cx);
4465 assert_copy(&expected, window, cx);
4466 });
4467 }
4468 }
4469
4470 #[gpui::test]
4471 fn a_new_range_copies_before_its_first_selection_paint(cx: &mut TestAppContext) {
4472 let (state, cx) = setup(SelectionFormat::Plain, cx);
4473 let (start, _, end) = endpoints(&state, cx);
4474 cx.update(|window, cx| {
4475 press(start, window, cx);
4476 extend(end, window, cx);
4477 assert_copy(PLAIN, window, cx);
4478 });
4479 }
4480
4481 #[gpui::test]
4482 fn an_established_range_extends_and_retracts_without_paint(cx: &mut TestAppContext) {
4483 let (state, cx) = setup(SelectionFormat::Plain, cx);
4484 let (start, first_end, end) = endpoints(&state, cx);
4485 cx.update(|window, cx| {
4486 press(start, window, cx);
4487 extend(first_end, window, cx);
4488 });
4489 cx.update(|window, cx| window.draw(cx).clear(cx));
4490 cx.update(|window, cx| {
4491 assert_copy("中文 café\n", window, cx);
4492 extend(end, window, cx);
4493 assert_copy(PLAIN, window, cx);
4494 extend(first_end, window, cx);
4495 assert_copy("中文 café\n", window, cx);
4496 });
4497 }
4498
4499 #[gpui::test]
4500 fn source_copy_projects_the_current_range_without_paint(cx: &mut TestAppContext) {
4501 let (state, cx) = setup(SelectionFormat::Source, cx);
4502 let (start, first_end, end) = endpoints(&state, cx);
4503 cx.update(|window, cx| {
4504 press(start, window, cx);
4505 extend(first_end, window, cx);
4506 assert_copy("中文 **café**", window, cx);
4507 extend(end, window, cx);
4508 assert_copy(MARKDOWN, window, cx);
4509 extend(first_end, window, cx);
4510 assert_eq!(TextSelection::selected_text(window, cx), "中文 **café**");
4511 });
4512 }
4513
4514 #[gpui::test]
4515 fn a_blank_gesture_does_not_reuse_the_previous_range(cx: &mut TestAppContext) {
4516 let (state, cx) = setup(SelectionFormat::Plain, cx);
4517 let (start, first_end, end) = endpoints(&state, cx);
4518 cx.update(|window, cx| {
4519 press(start, window, cx);
4520 extend(end, window, cx);
4521 assert_eq!(TextSelection::selected_text(window, cx), PLAIN);
4522 release(end, window, cx);
4523 press(first_end + point(px(20.), px(0.)), window, cx);
4524 extend(first_end + point(px(60.), px(0.)), window, cx);
4525 assert_eq!(TextSelection::selected_text(window, cx), "");
4526 });
4527 }
4528
4529 #[gpui::test]
4530 fn replacing_source_cannot_reproject_old_content(cx: &mut TestAppContext) {
4531 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
4532 for replacement in ["replacement".to_string(), "replacement ".repeat(500)] {
4533 let (state, cx) = setup(format, cx);
4534 let (start, _, end) = endpoints(&state, cx);
4535 cx.update(|window, cx| {
4536 press(start, window, cx);
4537 extend(end, window, cx);
4538 assert_eq!(
4539 TextSelection::selected_text(window, cx),
4540 if format == SelectionFormat::Plain {
4541 PLAIN
4542 } else {
4543 MARKDOWN
4544 }
4545 );
4546 state.update(cx, |state, cx| state.set_text(&replacement, cx));
4549 assert_eq!(TextSelection::selected_text(window, cx), "");
4550 });
4551 }
4552 }
4553 }
4554 }
4555
4556 #[gpui::test]
4562 fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
4563 use gpui::{ListAlignment, ListState, list};
4564
4565 const ITEMS: &[&str] = &[
4566 "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
4567 "Short.",
4568 "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
4569 "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
4570 "Only one line.",
4571 "**Bold**: medium length text with `code` mixed with regular words.",
4572 "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
4573 "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
4574 ];
4575 let n = 40usize;
4576
4577 struct ListRoot {
4578 state: ListState,
4579 }
4580 impl Render for ListRoot {
4581 fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
4582 div().w(px(360.)).h(px(500.)).child(
4583 list(self.state.clone(), |ix, _w, _cx| {
4584 div()
4585 .w_full()
4586 .child(TextView::markdown(
4587 ("md", ix as u64),
4588 ITEMS[ix % ITEMS.len()],
4589 ))
4590 .into_any_element()
4591 })
4592 .size_full(),
4593 )
4594 }
4595 }
4596
4597 cx.update(crate::init);
4598 let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
4599 let probe = state.clone();
4600 let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
4601 let cx: &mut VisualTestContext = cx;
4602
4603 cx.run_until_parked();
4604 cx.update(|w, cx| {
4605 let _ = w.draw(cx);
4606 });
4607 cx.run_until_parked();
4608 cx.update(|w, cx| {
4609 let _ = w.draw(cx);
4610 });
4611
4612 let total = |p: &ListState| {
4613 f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
4614 };
4615 let mut totals = vec![total(&probe)];
4616 for _ in 0..20 {
4617 probe.scroll_by(px(150.));
4618 cx.update(|w, cx| {
4619 let _ = w.draw(cx);
4620 });
4621 cx.run_until_parked();
4622 totals.push(total(&probe));
4623 }
4624 let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
4625 let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
4626 println!(
4627 "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
4628 max - min
4629 );
4630 assert!(
4631 (max - min) < 2.0,
4632 "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
4633 );
4634 }
4635}