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 #[gpui::test]
2096 fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
2097 struct ListItemBlockRoot;
2098
2099 impl Render for ListItemBlockRoot {
2100 fn render(
2101 &mut self,
2102 _window: &mut Window,
2103 _cx: &mut Context<Self>,
2104 ) -> impl IntoElement {
2105 div().w(px(840.)).h(px(400.)).child(
2106 crate::h_resizable("markdown-width-test")
2107 .child(crate::resizable_panel().child(div()))
2108 .child(crate::resizable_panel().child(
2109 TextView::markdown(
2110 "list-with-code",
2111 "1. List item\n ```rust\n nested code\n ```\n\n```rust\ntop-level code\n```",
2112 )
2113 .code_block_actions(|code_block, _, _| {
2114 let selector = if code_block.code().contains("nested") {
2115 "nested-code-action"
2116 } else {
2117 "top-level-code-action"
2118 };
2119 div()
2120 .debug_selector(move || selector.into())
2121 .child("Copy")
2122 })
2123 .scrollable(true)
2124 .p_5()
2125 .flex_none(),
2126 )),
2127 )
2128 }
2129 }
2130
2131 cx.update(crate::init);
2132 let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
2133 let cx: &mut VisualTestContext = cx;
2134
2135 cx.run_until_parked();
2136 cx.update(|window, cx| {
2137 let _ = window.draw(cx);
2138 });
2139
2140 let nested_action = cx.debug_bounds("nested-code-action").unwrap();
2141 let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
2142 assert!(
2143 top_level_action.right() - nested_action.right() < px(32.),
2144 "nested code block should fill the list item's available width"
2145 );
2146 }
2147
2148 const CODE_HEIGHT: Pixels = px(60.);
2150
2151 struct ScrollingCodeRoot {
2152 text_view: Entity<TextViewState>,
2153 actions: bool,
2154 }
2155
2156 impl Render for ScrollingCodeRoot {
2157 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2158 let mut code_block = StyleRefinement::default().max_h(CODE_HEIGHT);
2159 code_block.overflow.y = Some(Overflow::Scroll);
2160
2161 div().w(px(320.)).h(px(200.)).child(
2162 TextView::new(&self.text_view)
2163 .style(TextViewStyle::default().with_code_block(code_block))
2164 .when(self.actions, |this| {
2165 this.code_block_actions(|_, _, _| {
2166 div().debug_selector(|| "code-action".into()).child("Copy")
2167 })
2168 })
2169 .scrollable(true),
2170 )
2171 }
2172 }
2173
2174 #[gpui::test]
2175 fn scrolling_code_block_keeps_wheel_from_the_list(cx: &mut TestAppContext) {
2176 cx.update(crate::init);
2177 let code = (0..50).map(|i| format!("line {i}\n")).collect::<String>();
2178 let tail = "Paragraph after the code.\n\n".repeat(20);
2179 let markdown = format!("```\n{code}```\n\n{tail}");
2180
2181 for actions in [false, true] {
2184 let (root, cx) = cx.add_window_view(|_, cx| ScrollingCodeRoot {
2185 text_view: cx.new(|cx| TextViewState::markdown(&markdown, cx)),
2186 actions,
2187 });
2188 let cx: &mut VisualTestContext = cx;
2189 let draw = |cx: &mut VisualTestContext| {
2190 cx.run_until_parked();
2191 cx.update(|window, cx| window.draw(cx).clear(cx));
2192 };
2193 let step = px(20.);
2194 let wheel_at = |cx: &mut VisualTestContext, y: Pixels| {
2195 cx.simulate_event(gpui::ScrollEvent {
2196 position: point(px(20.), y),
2197 delta: gpui::ScrollDelta::Pixels(point(px(0.), -step)),
2198 ..Default::default()
2199 });
2200 };
2201 let edges = |cx: &mut VisualTestContext| {
2205 let lines = root.read_with(cx, |root, cx| {
2206 root.text_view.read(cx).selection_adapter.text_bounds()
2207 });
2208 let paragraph = lines.iter().find(|line| line.top() >= CODE_HEIGHT).unwrap();
2209 (lines[0].bottom(), paragraph.top())
2210 };
2211 draw(cx);
2212 let (code_bottom, paragraph_top) = edges(cx);
2213 let action = cx.debug_bounds("code-action");
2214
2215 wheel_at(cx, CODE_HEIGHT / 2.);
2218 draw(cx);
2219 assert_eq!(
2220 edges(cx),
2221 (code_bottom - step, paragraph_top),
2222 "actions: {actions}"
2223 );
2224 assert_eq!(cx.debug_bounds("code-action"), action);
2225
2226 wheel_at(cx, CODE_HEIGHT * 2.5);
2230 draw(cx);
2231 assert_ne!(edges(cx).1, paragraph_top, "actions: {actions}");
2232 }
2233 }
2234
2235 fn draw_table_with_actions(
2239 cx: &mut TestAppContext,
2240 scroll: bool,
2241 ) -> (Bounds<Pixels>, TableData) {
2242 use std::sync::{Arc, Mutex};
2243
2244 struct TableRoot {
2245 scroll: bool,
2246 captured: Arc<Mutex<Vec<TableData>>>,
2247 }
2248
2249 impl Render for TableRoot {
2250 fn render(
2251 &mut self,
2252 _window: &mut Window,
2253 _cx: &mut Context<Self>,
2254 ) -> impl IntoElement {
2255 let captured = self.captured.clone();
2256 let mut table_style = StyleRefinement::default();
2257 if self.scroll {
2258 table_style.overflow.x = Some(Overflow::Scroll);
2259 }
2260
2261 div().w(px(320.)).child(
2262 TextView::markdown(
2263 "table-actions",
2264 "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
2265 )
2266 .style(TextViewStyle::default().with_table(table_style))
2267 .table_actions(move |table, _, _| {
2268 if let Ok(mut captured) = captured.lock() {
2269 captured.push(table.clone());
2270 }
2271 div().debug_selector(|| "table-action".into()).child("Copy")
2272 }),
2273 )
2274 }
2275 }
2276
2277 cx.update(crate::init);
2278 let captured = Arc::new(Mutex::new(Vec::new()));
2279 let (_, cx) = cx.add_window_view({
2280 let captured = captured.clone();
2281 move |_, _| TableRoot { scroll, captured }
2282 });
2283 let cx: &mut VisualTestContext = cx;
2284
2285 cx.run_until_parked();
2286 cx.update(|window, cx| {
2287 let _ = window.draw(cx);
2288 });
2289
2290 let bounds = cx
2291 .debug_bounds("table-action")
2292 .expect("table actions should be painted");
2293 let data = captured
2294 .lock()
2295 .expect("captured table data")
2296 .last()
2297 .cloned()
2298 .expect("table actions hook should receive the table");
2299
2300 (bounds, data)
2301 }
2302
2303 #[gpui::test]
2304 fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
2305 for scroll in [false, true] {
2306 let (bounds, data) = draw_table_with_actions(cx, scroll);
2307
2308 assert!(
2310 bounds.top() > px(40.),
2311 "actions should sit below the table (scroll: {scroll}), got {:?}",
2312 bounds.top()
2313 );
2314 assert_eq!(data.headers, vec!["Name", "Age"]);
2315 assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
2316 assert_eq!(
2317 data.markdown,
2318 "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
2319 );
2320 assert_eq!(data.span, Some(0..52));
2321 }
2322 }
2323
2324 #[test]
2325 fn plugin_accepts_text_view_plugins_beyond_markdown() {
2326 let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
2327
2328 assert!(view.selectable);
2329 }
2330
2331 #[test]
2332 fn syntax_highlighting_is_opt_in() {
2333 let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
2334 assert!(default_view.code_block_highlighter.is_none());
2335
2336 let view = default_view.code_block_highlighter(|block| {
2337 vec![(
2338 0..block.code().len(),
2339 gpui::HighlightStyle {
2340 color: Some(gpui::rgb(0x3366ff).into()),
2341 ..Default::default()
2342 },
2343 )]
2344 });
2345 assert!(view.code_block_highlighter.is_some());
2346 }
2347
2348 #[gpui::test]
2349 fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
2350 cx.update(crate::init);
2351 let (_, cx) = cx.add_window_view(|_, cx| {
2352 TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
2353 });
2354 let cx: &mut VisualTestContext = cx;
2355
2356 cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
2357
2358 assert_eq!(cx.opened_url(), None);
2359 }
2360
2361 struct MaxLinesTestRoot {
2362 text_view: Entity<TextViewState>,
2363 max_lines: usize,
2364 }
2365
2366 impl MaxLinesTestRoot {
2367 fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
2368 let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
2369 Self {
2370 text_view,
2371 max_lines,
2372 }
2373 }
2374 }
2375
2376 impl Render for MaxLinesTestRoot {
2377 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2378 div()
2379 .w(px(200.))
2380 .child(TextView::new(&self.text_view).max_lines(self.max_lines))
2381 }
2382 }
2383
2384 #[test]
2385 fn the_clip_only_moves_for_a_straddling_glyph_line() {
2386 use super::line_safe_clip_bottom;
2387 use crate::text::state::LineSpan;
2388
2389 let spans = [
2390 LineSpan {
2392 top: px(0.),
2393 bottom: px(60.),
2394 line_height: px(20.),
2395 },
2396 LineSpan {
2398 top: px(68.),
2399 bottom: px(128.),
2400 line_height: px(20.),
2401 },
2402 ];
2403
2404 let below = px(400.);
2406
2407 assert_eq!(
2409 line_safe_clip_bottom(&spans, px(100.), below),
2410 Some(px(88.))
2411 );
2412
2413 assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
2415
2416 assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
2419
2420 let one_block = [LineSpan {
2423 top: px(0.),
2424 bottom: px(60.),
2425 line_height: px(20.),
2426 }];
2427 assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
2428
2429 assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
2432 }
2433
2434 #[test]
2435 fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
2436 use super::line_safe_clip_bottom;
2437 use crate::text::state::LineSpan;
2438
2439 let heading = [LineSpan {
2441 top: px(70.),
2442 bottom: px(98.),
2443 line_height: px(28.),
2444 }];
2445
2446 assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
2447 }
2448
2449 #[test]
2450 fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
2451 use super::line_safe_clip_bottom;
2452 use crate::text::state::LineSpan;
2453
2454 let rows = [
2457 LineSpan {
2458 top: px(100.),
2459 bottom: px(126.),
2460 line_height: px(26.),
2461 },
2462 LineSpan {
2463 top: px(135.),
2464 bottom: px(161.),
2465 line_height: px(26.),
2466 },
2467 ];
2468
2469 assert_eq!(
2472 line_safe_clip_bottom(&rows, px(148.), px(400.)),
2473 Some(px(126.))
2474 );
2475 }
2476
2477 struct ClampedPageRoot {
2483 text_view: Entity<TextViewState>,
2484 max_lines: usize,
2485 }
2486
2487 impl Render for ClampedPageRoot {
2488 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2489 use crate::{h_flex, v_flex};
2490
2491 v_flex()
2492 .size_full()
2493 .p_4()
2494 .gap_4()
2495 .child(h_flex().max_w(px(480.)).gap_3().child("header"))
2496 .child(
2497 v_flex()
2498 .flex_1()
2499 .min_h_0()
2500 .gap_4()
2501 .id("clamped-page-scroll")
2502 .child(
2503 v_flex()
2504 .max_w(px(480.))
2505 .p_3()
2506 .gap_2()
2507 .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
2508 )
2509 .overflow_y_scroll(),
2510 )
2511 }
2512 }
2513
2514 #[gpui::test]
2515 fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
2516 cx.update(crate::init);
2517 let (root, cx) = cx.add_window_view(|_, cx| {
2518 let text_view = cx.new(|cx| {
2519 TextViewState::markdown(
2520 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
2521 cx,
2522 )
2523 });
2524 ClampedPageRoot {
2525 text_view,
2526 max_lines: 3,
2527 }
2528 });
2529 let cx: &mut VisualTestContext = cx;
2530
2531 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2532 }
2533
2534 #[gpui::test]
2535 fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
2536 cx.update(crate::init);
2537 let (root, cx) = cx.add_window_view(|_, cx| {
2538 MaxLinesTestRoot::new(
2539 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
2540 2,
2541 cx,
2542 )
2543 });
2544 let cx: &mut VisualTestContext = cx;
2545
2546 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2547 }
2548
2549 #[gpui::test]
2550 fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
2551 cx.update(crate::init);
2552 let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
2553 let cx: &mut VisualTestContext = cx;
2554
2555 assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2556 }
2557
2558 #[gpui::test]
2559 fn heading_refinement_changes_rendered_heading_geometry(cx: &mut TestAppContext) {
2560 struct HeadingStyleRoot;
2561
2562 impl Render for HeadingStyleRoot {
2563 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2564 div()
2565 .flex()
2566 .items_start()
2567 .child(
2568 div()
2569 .debug_selector(|| "default-h1".into())
2570 .child(TextView::markdown("default-h1-view", "# Heading")),
2571 )
2572 .child(div().debug_selector(|| "custom-h1".into()).child(
2573 TextView::markdown("custom-heading-view", "# Heading").style(
2574 TextViewStyle::default().with_heading(|level| {
2575 if level == 1 {
2576 StyleRefinement::default().pb(rems(2.))
2577 } else {
2578 StyleRefinement::default()
2579 }
2580 }),
2581 ),
2582 ))
2583 .child(
2584 div()
2585 .debug_selector(|| "default-h2".into())
2586 .child(TextView::markdown("default-h2-view", "## Heading")),
2587 )
2588 .child(div().debug_selector(|| "custom-h2".into()).child(
2589 TextView::markdown("custom-h2-view", "## Heading").style(
2590 TextViewStyle::default().with_heading(|level| {
2591 if level == 1 {
2592 StyleRefinement::default().pb(rems(2.))
2593 } else {
2594 StyleRefinement::default()
2595 }
2596 }),
2597 ),
2598 ))
2599 }
2600 }
2601
2602 cx.update(crate::init);
2603 let (_, cx) = cx.add_window_view(|_, _| HeadingStyleRoot);
2604 cx.update(|window, cx| window.draw(cx).clear(cx));
2605
2606 let default_h1 = cx.debug_bounds("default-h1").unwrap();
2607 let custom_h1 = cx.debug_bounds("custom-h1").unwrap();
2608 let default_h2 = cx.debug_bounds("default-h2").unwrap();
2609 let custom_h2 = cx.debug_bounds("custom-h2").unwrap();
2610 assert!(custom_h1.size.height > default_h1.size.height);
2611 assert_eq!(custom_h2.size.height, default_h2.size.height);
2612 }
2613
2614 #[gpui::test]
2615 fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
2616 cx.update(crate::init);
2617 let (_, cx) = cx.add_window_view(|_, cx| {
2618 MaxLinesTestRoot::new(
2619 "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
2620 2,
2621 cx,
2622 )
2623 });
2624 let cx: &mut VisualTestContext = cx;
2625
2626 cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
2628
2629 assert_eq!(cx.opened_url(), None);
2630 }
2631
2632 #[gpui::test]
2633 fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) {
2634 struct SelectionRoot {
2635 text_view: Entity<TextViewState>,
2636 format: crate::text::SelectionFormat,
2637 }
2638 impl Render for SelectionRoot {
2639 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2640 div()
2641 .w(px(160.))
2642 .child(crate::TextSelectionLayer)
2643 .child(TextView::new(&self.text_view).selection_format(self.format))
2644 }
2645 }
2646 cx.update(crate::init);
2647 let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot {
2648 format: crate::text::SelectionFormat::Plain,
2649 text_view: cx
2650 .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)),
2651 });
2652 let cx: &mut VisualTestContext = cx;
2653 cx.run_until_parked();
2654 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2655 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2656 cx.simulate_mouse_down(
2658 point(px(5.), px(8.)),
2659 MouseButton::Left,
2660 Modifiers::default(),
2661 );
2662 cx.update(|window, cx| {
2663 let _ = window.draw(cx);
2664 });
2665 cx.simulate_mouse_move(
2666 point(px(155.), px(20.)),
2667 Some(MouseButton::Left),
2668 Modifiers::default(),
2669 );
2670 cx.update(|window, cx| {
2671 let _ = window.draw(cx);
2672 });
2673 cx.simulate_mouse_up(
2674 point(px(155.), px(20.)),
2675 MouseButton::Left,
2676 Modifiers::default(),
2677 );
2678 cx.update(|window, cx| {
2679 let _ = window.draw(cx);
2680 });
2681 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2682 assert_eq!(selected.trim(), "code after");
2683 view.update(cx, |view, cx| {
2684 view.format = crate::text::SelectionFormat::Source;
2685 cx.notify();
2686 });
2687 cx.update(|window, cx| {
2688 let _ = window.draw(cx);
2689 });
2690 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2691 assert_eq!(selected.trim(), "[`code`](https://example.com) after");
2692 }
2693
2694 #[test]
2704 fn inline_code_heading_reserves_painted_wrapped_lines_and_list_baseline() {
2705 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2706 use gpui::{TestApp, rems};
2707
2708 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";
2709
2710 struct MarkdownRoot {
2711 text_view: Entity<TextViewState>,
2712 width: Pixels,
2713 preview_zoom: f32,
2714 }
2715
2716 impl Render for MarkdownRoot {
2717 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2718 div()
2719 .w(self.width)
2720 .text_size(rems(self.preview_zoom))
2723 .child(crate::TextSelectionLayer)
2724 .child(TextView::new(&self.text_view).selectable(true))
2725 }
2726 }
2727
2728 fn draw(
2729 app: &mut TestApp,
2730 width: Pixels,
2731 preview_zoom: f32,
2732 ) -> (Bounds<Pixels>, Vec<Bounds<Pixels>>, String) {
2733 let mut window = app.open_window(|_, cx| MarkdownRoot {
2734 text_view: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
2735 width,
2736 preview_zoom,
2737 });
2738 window.draw();
2739 app.run_until_parked();
2740 window.draw();
2741 window.update(|root, _, cx| {
2742 root.text_view.update(cx, |state, cx| state.select_all(cx));
2743 });
2744 window.draw();
2745 window.read(|root, cx| {
2746 let state = root.text_view.read(cx);
2747 (
2748 state.bounds(),
2749 state.selection_adapter.text_bounds(),
2750 state.selected_text(),
2751 )
2752 })
2753 }
2754
2755 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2756 app.update(|cx| {
2757 crate::init(cx);
2758 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2759 });
2760
2761 for (case, width, preview_zoom) in [
2762 ("default-16px", px(600.), 1.),
2765 ("narrow-16px", px(320.), 1.),
2767 ("zoom-1.25", px(600.), 1.25),
2769 ] {
2770 let (view_bounds, text_bounds, selected_text) = draw(&mut app, width, preview_zoom);
2771 assert!(
2772 text_bounds.len() > 8,
2773 "{case}: expected all markdown fragments"
2774 );
2775 assert_eq!(
2776 selected_text.trim(),
2777 "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",
2778 "{case}: wrapped inline-code text was lost or duplicated"
2779 );
2780 let following = text_bounds.last().expect("following paragraph must paint");
2781 let painted_bottom = text_bounds
2782 .iter()
2783 .map(|bounds| bounds.bottom())
2784 .max()
2785 .expect("markdown must paint text");
2786 assert!(
2792 text_bounds[..text_bounds.len() - 1]
2793 .iter()
2794 .all(|bounds| bounds.bottom() <= following.top()),
2795 "{case}: heading/list text painted through the following paragraph; following={following:?}, text_bounds={text_bounds:?}, view={view_bounds:?}"
2796 );
2797 assert!(
2798 painted_bottom <= view_bounds.bottom(),
2799 "{case}: TextView height did not reserve its painted text; painted_bottom={painted_bottom:?}, view={view_bounds:?}"
2800 );
2801 }
2802 }
2803
2804 #[test]
2805 fn ordered_markdown_list_start_reaches_layout_marker() {
2806 use crate::text::inline::test_fonts::{WideMonoTextSystem, record_shaped_lines};
2807 use gpui::TestApp;
2808
2809 struct MarkdownRoot {
2810 text_view: Entity<TextViewState>,
2811 }
2812
2813 impl Render for MarkdownRoot {
2814 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2815 div().w(px(400.)).child(TextView::new(&self.text_view))
2816 }
2817 }
2818
2819 #[derive(Clone, Copy)]
2820 enum Format {
2821 Markdown,
2822 Html,
2823 }
2824
2825 fn shaped_markers(format: Format, source: &str) -> Vec<String> {
2826 let (_, shaped_lines) = record_shaped_lines(|| {
2827 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2828 app.update(crate::init);
2829 let mut window = app.open_window(|_, cx| MarkdownRoot {
2830 text_view: cx.new(|cx| match format {
2831 Format::Markdown => TextViewState::markdown(source, cx),
2832 Format::Html => TextViewState::html(source, cx),
2833 }),
2834 });
2835 window.draw();
2836 app.run_until_parked();
2837 window.draw();
2838 });
2839
2840 let mut markers = shaped_lines
2841 .into_iter()
2842 .filter(|line| line.len() <= 3 && line.ends_with('.'))
2843 .collect::<Vec<_>>();
2844 markers.dedup();
2845 markers
2846 }
2847
2848 let starts_at_one = "1. one\n2. two";
2849 assert_eq!(
2850 shaped_markers(Format::Markdown, starts_at_one),
2851 ["1.", "2."]
2852 );
2853 assert_eq!(
2854 shaped_markers(Format::Html, "<ol><li>one</li><li>two</li></ol>"),
2855 ["1.", "2."]
2856 );
2857
2858 assert_eq!(
2859 shaped_markers(Format::Markdown, "3. hello\n4. world"),
2860 ["3.", "4."]
2861 );
2862
2863 let nested_starts_at_four = "1. outer\n\n 4. nested\n 5. again";
2864 assert_eq!(
2865 shaped_markers(Format::Markdown, nested_starts_at_four),
2866 ["1.", "D.", "E."]
2867 );
2868
2869 let nested_starts_at_zero = "1. outer\n\n 0. zero";
2870 assert_eq!(
2871 shaped_markers(Format::Markdown, nested_starts_at_zero),
2872 ["1.", "0."]
2873 );
2874 }
2875
2876 #[test]
2881 fn inline_code_line_is_as_tall_as_a_plain_line() {
2882 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2883 use gpui::{TestApp, rems};
2884
2885 struct LineRoot {
2886 plain: Entity<TextViewState>,
2887 code: Entity<TextViewState>,
2888 preview_zoom: f32,
2889 }
2890
2891 impl Render for LineRoot {
2892 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2893 div()
2894 .w(px(600.))
2895 .text_size(rems(self.preview_zoom))
2896 .child(TextView::new(&self.plain))
2897 .child(TextView::new(&self.code))
2898 }
2899 }
2900
2901 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2902 app.update(|cx| {
2903 crate::init(cx);
2904 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2905 });
2906 for scale_factor in [1.6, 2.] {
2907 for preview_zoom in [1., 1.25] {
2908 let mut window = app.open_window(|_, cx| LineRoot {
2909 plain: cx.new(|cx| TextViewState::markdown("plain body words", cx)),
2910 code: cx.new(|cx| TextViewState::markdown("plain `code` words", cx)),
2911 preview_zoom,
2912 });
2913 window.simulate_scale_factor_change(scale_factor);
2914 window.draw();
2915 app.run_until_parked();
2916 window.draw();
2917 let (plain, code) = window.read(|root, cx| {
2918 (
2919 root.plain.read(cx).bounds().size.height,
2920 root.code.read(cx).bounds().size.height,
2921 )
2922 });
2923 assert_eq!(code, plain, "scale {scale_factor}, zoom {preview_zoom}");
2924 }
2925 }
2926 }
2927
2928 #[test]
2932 fn inline_code_list_body_paint_origin_matches_plain_list_item() {
2933 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2934 use gpui::{TestApp, rems};
2935
2936 struct ListRoot {
2937 plain: Entity<TextViewState>,
2938 code: Entity<TextViewState>,
2939 preview_zoom: f32,
2940 }
2941
2942 impl Render for ListRoot {
2943 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2944 div()
2945 .w(px(600.))
2946 .text_size(rems(self.preview_zoom))
2947 .child(crate::TextSelectionLayer)
2948 .child(TextView::new(&self.plain).selectable(true))
2949 .child(TextView::new(&self.code).selectable(true))
2950 }
2951 }
2952
2953 fn draw(app: &mut TestApp, preview_zoom: f32) -> (Pixels, Pixels) {
2954 let mut window = app.open_window(|_, cx| ListRoot {
2955 plain: cx.new(|cx| TextViewState::markdown("- plain body words", cx)),
2956 code: cx.new(|cx| TextViewState::markdown("- plain `code` words", cx)),
2957 preview_zoom,
2958 });
2959 window.draw();
2960 app.run_until_parked();
2961 window.draw();
2962 window.read(|root, cx| {
2963 let first_body_line_top = |view: &Entity<TextViewState>| {
2964 let view = view.read(cx);
2965 let painted_line = view
2966 .selection_adapter
2967 .text_bounds()
2968 .into_iter()
2969 .next()
2970 .expect("list item should paint its first body line");
2971 painted_line.top() - view.bounds().top()
2972 };
2973 (
2974 first_body_line_top(&root.plain),
2975 first_body_line_top(&root.code),
2976 )
2977 })
2978 }
2979
2980 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2981 app.update(|cx| {
2982 crate::init(cx);
2983 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2984 });
2985 for (case, preview_zoom) in [("16px", 1.), ("zoom-1.25", 1.25), ("zoom-1.5", 1.5)] {
2986 let (plain_origin, code_origin) = draw(&mut app, preview_zoom);
2987 assert_eq!(
2988 code_origin, plain_origin,
2989 "{case}: code list body paint origin {code_origin:?} must match plain {plain_origin:?}"
2990 );
2991 }
2992 }
2993
2994 #[test]
2995 fn inline_code_fragment_does_not_paint_past_its_reserved_row() {
2996 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2997 use gpui::TestApp;
2998
2999 const TEXT_BACKGROUND: u32 = 0x20f0b0;
3000
3001 struct MarkdownRoot {
3002 text_view: Entity<TextViewState>,
3003 }
3004
3005 impl Render for MarkdownRoot {
3006 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3007 div()
3008 .w(px(640.))
3009 .text_size(px(17.9))
3010 .text_bg(gpui::rgb(TEXT_BACKGROUND))
3011 .child(TextView::new(&self.text_view))
3012 }
3013 }
3014
3015 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
3016 app.update(|cx| {
3017 crate::init(cx);
3018 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
3019 });
3020 let mut window = app.open_window(|_, cx| MarkdownRoot {
3021 text_view: cx.new(|cx| TextViewState::markdown("`main` starts the paragraph", cx)),
3022 });
3023 window.draw();
3024 app.run_until_parked();
3025 window.draw();
3026
3027 let (view_bounds, painted) = window.update(|root, window, cx| {
3028 let view_bounds = root
3029 .text_view
3030 .read(cx)
3031 .bounds()
3032 .scale(window.scale_factor());
3033 let text_background: gpui::Background = gpui::rgb(TEXT_BACKGROUND).into();
3034 let painted = window
3035 .painted_quads()
3036 .into_iter()
3037 .filter(|quad| quad.background == text_background)
3038 .map(|quad| quad.bounds)
3039 .collect::<Vec<_>>();
3040 (view_bounds, painted)
3041 });
3042
3043 assert!(
3044 !painted.is_empty(),
3045 "the inherited text background must make actual text paint observable"
3046 );
3047 assert!(
3048 painted
3049 .iter()
3050 .all(|bounds| bounds.bottom() <= view_bounds.bottom()),
3051 "an inline-code fragment wrapped a second time after InlineFlow reserved one row; \
3052 text background quads={painted:?}, reserved TextView bounds={view_bounds:?}"
3053 );
3054 }
3055
3056 #[test]
3057 fn table_row_backgrounds_follow_the_frame_corner_radius() {
3058 use gpui::TestApp;
3059
3060 const HEAD_BACKGROUND: u32 = 0x11aa77;
3061 const RADIUS: f32 = 8.;
3062
3063 struct RoundedTableRoot {
3064 text_view: Entity<TextViewState>,
3065 scroll: bool,
3066 }
3067
3068 impl Render for RoundedTableRoot {
3069 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3070 let mut table = StyleRefinement::default();
3071 table.corner_radii.top_left = Some(px(RADIUS).into());
3072 table.corner_radii.top_right = Some(px(RADIUS).into());
3073 table.corner_radii.bottom_left = Some(px(RADIUS).into());
3074 table.corner_radii.bottom_right = Some(px(RADIUS).into());
3075 if self.scroll {
3076 table.overflow.x = Some(gpui::Overflow::Scroll);
3077 }
3078 let style = TextViewStyle::default()
3079 .with_code_background(gpui::rgb(HEAD_BACKGROUND).into())
3080 .with_table(table);
3081 div()
3082 .w(px(400.))
3083 .child(TextView::new(&self.text_view).style(style))
3084 }
3085 }
3086
3087 for scroll in [false, true] {
3088 let mode = if scroll { "scroll" } else { "wrap" };
3089 let mut app = TestApp::new();
3090 app.update(crate::init);
3091 let mut window = app.open_window(|_, cx| RoundedTableRoot {
3092 text_view: cx
3093 .new(|cx| TextViewState::markdown("| a | b |\n| --- | --- |\n| 1 | 2 |", cx)),
3094 scroll,
3095 });
3096 window.draw();
3097 app.run_until_parked();
3098 window.draw();
3099
3100 let (quad, scale) = window.update(|_, window, _| {
3101 let head: gpui::Background = gpui::rgb(HEAD_BACKGROUND).into();
3102 let quad = window
3103 .painted_quads()
3104 .into_iter()
3105 .find(|quad| quad.background == head)
3106 .unwrap_or_else(|| panic!("{mode}: the header background quad is painted"));
3107 (quad, window.scale_factor())
3108 });
3109
3110 let expected = px(RADIUS - 1.).scale(scale);
3113 assert_eq!(quad.corner_radii.top_left, expected, "{mode}: top left");
3114 assert_eq!(quad.corner_radii.top_right, expected, "{mode}: top right");
3115 let square = px(0.).scale(scale);
3116 assert_eq!(quad.corner_radii.bottom_left, square, "{mode}: bottom left");
3117 assert_eq!(
3118 quad.corner_radii.bottom_right, square,
3119 "{mode}: bottom right"
3120 );
3121 }
3122 }
3123
3124 #[test]
3130 fn table_keeps_narrow_columns_on_one_line_while_wide_ones_wrap() {
3131 use crate::text::inline::test_fonts::WideMonoTextSystem;
3132 use gpui::TestApp;
3133 use std::sync::Arc;
3134
3135 const CELL_BACKGROUND: u32 = 0x11aa77;
3136
3137 struct TableRoot {
3138 text_view: Entity<TextViewState>,
3139 width: Pixels,
3140 style: TextViewStyle,
3141 }
3142
3143 impl Render for TableRoot {
3144 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3145 div()
3146 .w(self.width)
3147 .child(TextView::new(&self.text_view).style(self.style.clone()))
3148 }
3149 }
3150
3151 let short = "Operating revenue (USD)";
3152 let source = format!(
3153 "| {short} | Note |\n| --- | --- |\n| {short} | x |\n| 1 | {} |",
3154 "word ".repeat(30)
3155 );
3156 let first_column = |width: f32, style: TextViewStyle| {
3158 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
3159 app.update(crate::init);
3160 let cell = style.table_cell().clone().bg(gpui::rgb(CELL_BACKGROUND));
3161 let style = style.with_table_cell(cell);
3162 let mut window = app.open_window(|_, cx| TableRoot {
3163 text_view: cx.new(|cx| TextViewState::markdown(&source, cx)),
3164 width: px(width),
3165 style,
3166 });
3167 window.draw();
3168 app.run_until_parked();
3169 window.draw();
3170 window.update(|_, window, _| {
3171 let background: gpui::Background = gpui::rgb(CELL_BACKGROUND).into();
3172 let cells: Vec<_> = window
3173 .painted_quads()
3174 .into_iter()
3175 .filter(|quad| quad.background == background)
3176 .map(|quad| quad.bounds)
3177 .collect();
3178 let left = cells.iter().map(|bounds| bounds.origin.x).min().unwrap();
3179 let mut column: Vec<_> = cells
3180 .into_iter()
3181 .filter(|bounds| bounds.origin.x == left)
3182 .collect();
3183 column.sort_by_key(|bounds| bounds.origin.y);
3184 column
3185 .iter()
3186 .map(|bounds| bounds.size.height)
3187 .collect::<Vec<_>>()
3188 })
3189 };
3190 let scroll = || {
3191 let mut table = StyleRefinement::default();
3192 table.overflow.x = Some(gpui::Overflow::Scroll);
3193 TextViewStyle::default().with_table(table)
3194 };
3195
3196 for (layout, style) in [("wrap", TextViewStyle::default()), ("scroll", scroll())] {
3197 let fits = first_column(4000., style.clone());
3198 let narrow = first_column(700., style);
3199 assert_eq!(fits.len(), 3, "{layout}: three rows");
3200 assert_eq!(fits[0], fits[2], "{layout}: the header fits on one line");
3201 assert_eq!(narrow[..2], fits[..2], "{layout}: the short column wraps");
3202 }
3203
3204 let single_line = first_column(4000., TextViewStyle::default())[0];
3205 let padded = TextViewStyle::default().with_table_cell(StyleRefinement::default().px_4());
3206 assert_eq!(
3207 first_column(4000., padded)[0],
3208 single_line,
3209 "the cell padding refinement leaves the text its measured width"
3210 );
3211 let bold = TextViewStyle::default()
3212 .with_table(StyleRefinement::default().font_weight(gpui::FontWeight::BOLD));
3213 assert_eq!(
3214 first_column(4000., bold)[0],
3215 single_line,
3216 "the table text refinement is measured"
3217 );
3218 }
3219
3220 #[gpui::test]
3221 fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
3222 cx.update(crate::init);
3223 let (_, cx) =
3224 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
3225 let cx: &mut VisualTestContext = cx;
3226
3227 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
3228
3229 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
3230 }
3231
3232 #[gpui::test]
3233 fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
3234 cx.update(crate::init);
3235 let (_, cx) =
3236 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
3237 let cx: &mut VisualTestContext = cx;
3238
3239 cx.simulate_mouse_down(
3240 point(px(10.), px(10.)),
3241 MouseButton::Right,
3242 Modifiers::default(),
3243 );
3244 cx.simulate_mouse_up(
3245 point(px(10.), px(10.)),
3246 MouseButton::Right,
3247 Modifiers::default(),
3248 );
3249
3250 assert_eq!(cx.opened_url(), None);
3251 }
3252
3253 #[gpui::test]
3254 fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
3255 use std::sync::{Arc, Mutex};
3256
3257 struct LinkRoot {
3258 text_view: Entity<TextViewState>,
3259 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
3260 }
3261
3262 impl Render for LinkRoot {
3263 fn render(
3264 &mut self,
3265 _window: &mut Window,
3266 _cx: &mut Context<Self>,
3267 ) -> impl IntoElement {
3268 let clicks = self.clicks.clone();
3269 div()
3270 .w(px(240.))
3271 .child(
3272 TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
3273 clicks.lock().unwrap().push((url.clone(), event.clone()));
3274 }),
3275 )
3276 }
3277 }
3278
3279 cx.update(crate::init);
3280 let clicks = Arc::new(Mutex::new(Vec::new()));
3281 let captured = clicks.clone();
3282 let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
3283 text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
3284 clicks,
3285 });
3286 let cx: &mut VisualTestContext = cx;
3287
3288 let mut modifiers = Modifiers::default();
3289 modifiers.control = true;
3290 cx.simulate_click(point(px(10.), px(10.)), modifiers);
3291 cx.simulate_mouse_down(
3292 point(px(10.), px(10.)),
3293 MouseButton::Middle,
3294 Modifiers::default(),
3295 );
3296 cx.simulate_mouse_up(
3297 point(px(10.), px(10.)),
3298 MouseButton::Middle,
3299 Modifiers::default(),
3300 );
3301 cx.simulate_mouse_down(
3302 point(px(10.), px(10.)),
3303 MouseButton::Right,
3304 Modifiers::default(),
3305 );
3306 cx.simulate_mouse_up(
3307 point(px(10.), px(10.)),
3308 MouseButton::Right,
3309 Modifiers::default(),
3310 );
3311
3312 let clicks = captured.lock().unwrap();
3313 assert_eq!(clicks.len(), 3);
3314 assert_eq!(clicks[0].0, "https://example.com");
3315 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
3316 assert!(clicks[0].1.modifiers().control);
3317 assert!(clicks[1].1.is_middle_click());
3318 assert!(clicks[2].1.is_right_click());
3319 assert_eq!(cx.opened_url(), None);
3320 }
3321
3322 #[gpui::test]
3323 fn inline_object_inherits_bold_and_link_clicks_without_opening_on_drag(
3324 cx: &mut TestAppContext,
3325 ) {
3326 use std::sync::{Arc, Mutex};
3327 struct Root {
3328 state: Entity<TextViewState>,
3329 clicks: Arc<Mutex<Vec<SharedString>>>,
3330 }
3331 impl Render for Root {
3332 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3333 let clicks = self.clicks.clone();
3334 let extensions = crate::text::MarkdownExtensions::default().plugin(
3335 crate::text::markdown_ext::TestInlinePlugin::new("math")
3336 .parse_with(|node, _| {
3337 matches!(node, markdown::mdast::Node::InlineMath(_)).then(|| {
3338 super::super::MarkdownNode::new("math", ()).text("formula")
3339 })
3340 })
3341 .render_with(|_, context, _, _| {
3342 assert_eq!(context.text_style().font_weight, gpui::FontWeight::BOLD);
3343 Some(super::super::InlineElement::new(div().child("formula")))
3344 }),
3345 );
3346 div().w(px(300.)).child(crate::TextSelectionLayer).child(
3347 TextView::new(&self.state)
3348 .markdown_extensions(extensions)
3349 .on_link_click(move |url, _, _, _| {
3350 clicks.lock().unwrap().push(url.clone())
3351 }),
3352 )
3353 }
3354 }
3355 cx.update(crate::init);
3356 let clicks = Arc::new(Mutex::new(Vec::new()));
3357 let captured = clicks.clone();
3358 let (root, cx) = cx.add_window_view(move |_, cx| Root {
3359 state: cx.new(|cx| TextViewState::markdown("**[$x$](https://example.com)**", cx)),
3360 clicks,
3361 });
3362 let cx: &mut VisualTestContext = cx;
3363 cx.run_until_parked();
3364 cx.update(|window, cx| {
3365 let _ = window.draw(cx);
3366 });
3367 let bounds = root.read_with(cx, |root, cx| {
3368 root.state.read(cx).selection_adapter.text_bounds()[0]
3369 });
3370 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
3371 cx.simulate_mouse_down(bounds.center(), button, Modifiers::default());
3372 cx.simulate_mouse_up(bounds.center(), button, Modifiers::default());
3373 }
3374 assert_eq!(captured.lock().unwrap().len(), 3);
3375 let start = point(bounds.left() + px(1.), bounds.center().y);
3376 let end = point(bounds.right() - px(1.), bounds.center().y);
3377 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
3378 cx.update(|window, cx| {
3379 let _ = window.draw(cx);
3380 });
3381 cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
3382 cx.update(|window, cx| {
3383 let _ = window.draw(cx);
3384 });
3385 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
3386 assert_eq!(captured.lock().unwrap().len(), 3);
3387 }
3388
3389 #[gpui::test]
3390 fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
3391 use std::sync::{Arc, Mutex};
3392
3393 struct LinkedImageRoot {
3394 text_view: Entity<TextViewState>,
3395 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
3396 }
3397
3398 impl Render for LinkedImageRoot {
3399 fn render(
3400 &mut self,
3401 _window: &mut Window,
3402 _cx: &mut Context<Self>,
3403 ) -> impl IntoElement {
3404 let clicks = self.clicks.clone();
3405 div().w(px(160.)).child(
3406 TextView::new(&self.text_view)
3407 .selectable(true)
3408 .on_link_click(move |url, event, _, _| {
3409 clicks.lock().unwrap().push((url.clone(), event.clone()));
3410 }),
3411 )
3412 }
3413 }
3414
3415 cx.update(crate::init);
3416 let clicks = Arc::new(Mutex::new(Vec::new()));
3417 let captured = clicks.clone();
3418 let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
3419 text_view: cx.new(|cx| {
3420 TextViewState::markdown(
3421 r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
3422 cx,
3423 )
3424 }),
3425 clicks,
3426 }
3427 );
3428 let cx: &mut VisualTestContext = cx;
3429 cx.run_until_parked();
3430 cx.update(|window, cx| {
3431 let _ = window.draw(cx);
3432 });
3433
3434 let inline_bounds = content.read_with(cx, |content, cx| {
3435 content.text_view.read(cx).selection_adapter.text_bounds()
3436 });
3437 assert!(
3438 inline_bounds.len() >= 2,
3439 "linked image needs text bounds on both sides: {inline_bounds:?}"
3440 );
3441 assert!(
3442 inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
3443 "linked image did not reserve the expected click target: {inline_bounds:?}"
3444 );
3445 let position = point(
3446 inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
3447 inline_bounds[0].top() + px(8.),
3448 );
3449 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
3450 cx.simulate_mouse_down(position, button, Modifiers::default());
3451 cx.simulate_mouse_up(position, button, Modifiers::default());
3452 }
3453
3454 let clicks = captured.lock().unwrap();
3455 assert_eq!(clicks.len(), 3);
3456 assert!(
3457 clicks
3458 .iter()
3459 .all(|(url, _)| url == "https://example.com/image-link")
3460 );
3461 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
3462 assert!(clicks[1].1.is_middle_click());
3463 assert!(clicks[2].1.is_right_click());
3464 assert_eq!(cx.opened_url(), None);
3465 }
3466
3467 #[gpui::test]
3468 fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
3469 cx.update(crate::init);
3470 let (view, cx) = cx
3471 .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
3472 let cx: &mut VisualTestContext = cx;
3473
3474 cx.simulate_mouse_down(
3475 point(px(10.), px(34.)),
3476 MouseButton::Left,
3477 Modifiers::default(),
3478 );
3479 cx.simulate_mouse_move(
3480 point(px(90.), px(34.)),
3481 Some(MouseButton::Left),
3482 Modifiers::default(),
3483 );
3484 cx.simulate_mouse_up(
3485 point(px(90.), px(34.)),
3486 MouseButton::Left,
3487 Modifiers::default(),
3488 );
3489
3490 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3491 assert!(
3492 selected_text.is_empty(),
3493 "unexpected selection: {selected_text:?}"
3494 );
3495 }
3496
3497 struct ClippedTallTextViewTestRoot {
3501 text_view: Entity<TextViewState>,
3502 }
3503
3504 impl ClippedTallTextViewTestRoot {
3505 fn new(cx: &mut Context<Self>) -> Self {
3506 let text_view =
3510 cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
3511 Self { text_view }
3512 }
3513 }
3514
3515 impl Render for ClippedTallTextViewTestRoot {
3516 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3517 div()
3518 .w(px(200.))
3519 .child(crate::TextSelectionLayer)
3520 .child(
3521 div()
3522 .h(px(40.))
3523 .overflow_hidden()
3524 .child(TextView::new(&self.text_view).selectable(true)),
3525 )
3526 .child(div().h(px(160.)))
3529 }
3530 }
3531
3532 #[gpui::test]
3541 fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
3542 cx.update(crate::init);
3543 let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
3544 let cx: &mut VisualTestContext = cx;
3545
3546 cx.run_until_parked();
3547 cx.update(|window, cx| {
3548 let _ = window.draw(cx);
3549 });
3550
3551 cx.simulate_mouse_down(
3555 point(px(2.), px(8.)),
3556 MouseButton::Left,
3557 Modifiers::default(),
3558 );
3559 cx.update(|window, cx| {
3560 let _ = window.draw(cx);
3561 });
3562 cx.simulate_mouse_move(
3563 point(px(180.), px(150.)),
3564 Some(MouseButton::Left),
3565 Modifiers::default(),
3566 );
3567 cx.update(|window, cx| {
3568 let _ = window.draw(cx);
3569 });
3570 cx.simulate_mouse_up(
3571 point(px(180.), px(150.)),
3572 MouseButton::Left,
3573 Modifiers::default(),
3574 );
3575 cx.update(|window, cx| {
3576 let _ = window.draw(cx);
3577 });
3578
3579 let selected_text =
3580 content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3581 assert!(
3582 selected_text.contains("delta"),
3583 "clipped-out text was not copied: {selected_text:?}"
3584 );
3585 assert!(
3586 selected_text.contains("charlie"),
3587 "clipped-out text was not copied: {selected_text:?}"
3588 );
3589 }
3590
3591 #[gpui::test]
3592 fn double_click_selects_word(cx: &mut TestAppContext) {
3593 cx.update(crate::init);
3594 let (view, cx) =
3595 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3596
3597 let cx: &mut VisualTestContext = cx;
3598 cx.run_until_parked();
3599 cx.update(|window, cx| {
3600 let _ = window.draw(cx);
3601 });
3602 let position = point(px(10.), px(16.));
3603 cx.simulate_event(MouseDownEvent {
3604 position,
3605 modifiers: Modifiers::default(),
3606 button: MouseButton::Left,
3607 click_count: 2,
3608 first_mouse: false,
3609 });
3610 cx.simulate_event(MouseUpEvent {
3611 position,
3612 modifiers: Modifiers::default(),
3613 button: MouseButton::Left,
3614 click_count: 2,
3615 });
3616 cx.update(|window, cx| {
3617 let _ = window.draw(cx);
3618 });
3619
3620 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3621 assert_eq!(selected_text.trim(), "quick");
3622 }
3623
3624 #[gpui::test]
3625 fn long_press_selects_word_then_drag_extends_selection(cx: &mut TestAppContext) {
3626 struct TouchRoot {
3627 text_view: Entity<TextViewState>,
3628 }
3629 impl Render for TouchRoot {
3630 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3631 div()
3632 .w(px(300.))
3633 .child(crate::TextSelectionLayer)
3634 .child(TextView::new(&self.text_view).selectable(true))
3635 }
3636 }
3637 cx.update(crate::init);
3638 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3639 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3640 });
3641 cx.run_until_parked();
3642 cx.update(|window, cx| {
3643 let _ = window.draw(cx);
3644 });
3645 let start_position = point(px(10.), px(16.));
3646 cx.simulate_event(gpui::LongPressEvent {
3647 phase: gpui::TouchPhase::Started,
3648 start_position,
3649 position: start_position,
3650 });
3651 cx.update(|window, cx| {
3652 let _ = window.draw(cx);
3653 });
3654 assert_eq!(
3655 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3656 .trim(),
3657 "quick"
3658 );
3659 for phase in [gpui::TouchPhase::Moved, gpui::TouchPhase::Ended] {
3660 cx.simulate_event(gpui::LongPressEvent {
3661 phase,
3662 start_position,
3663 position: point(px(220.), px(16.)),
3664 });
3665 }
3666 cx.update(|window, cx| {
3667 let _ = window.draw(cx);
3668 });
3669 assert_eq!(
3670 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3671 .trim(),
3672 "quick select value"
3673 );
3674 }
3675
3676 #[gpui::test]
3677 fn long_press_release_keeps_handles_which_drag_the_selection(cx: &mut TestAppContext) {
3678 use crate::{SelectionEdge, TextSelection};
3679
3680 struct TouchRoot {
3681 text_view: Entity<TextViewState>,
3682 }
3683 impl Render for TouchRoot {
3684 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3685 div()
3686 .w(px(300.))
3687 .child(crate::TextSelectionLayer)
3688 .child(TextView::new(&self.text_view).selectable(true))
3689 }
3690 }
3691 cx.update(crate::init);
3692 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3693 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3694 });
3695 let draw = |cx: &mut VisualTestContext| {
3696 cx.update(|window, cx| {
3697 let _ = window.draw(cx);
3698 });
3699 };
3700 let selected = |cx: &mut VisualTestContext| {
3701 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3702 .trim()
3703 .to_string()
3704 };
3705 cx.run_until_parked();
3706 draw(cx);
3707
3708 let start_position = point(px(70.), px(16.));
3709 for phase in [gpui::TouchPhase::Started, gpui::TouchPhase::Ended] {
3710 cx.simulate_event(gpui::LongPressEvent {
3711 phase,
3712 start_position,
3713 position: start_position,
3714 });
3715 draw(cx);
3716 }
3717 assert_eq!(selected(cx), "select");
3718 let snapshot = cx
3719 .update(|window, cx| TextSelection::touch_selection(window, cx))
3720 .expect("a released long press keeps its handles");
3721 assert!(snapshot.is_menu_open());
3722 assert!(!snapshot.is_empty());
3723 assert!(snapshot.start().left() < snapshot.end().left());
3724
3725 let end = snapshot.end();
3728 let finger = point(end.left(), end.bottom() + px(20.));
3729 cx.update(|window, cx| {
3730 TextSelection::begin_edge_drag(SelectionEdge::End, finger, window, cx);
3731 });
3732 draw(cx);
3733 let snapshot = cx
3734 .update(|window, cx| TextSelection::touch_selection(window, cx))
3735 .unwrap();
3736 assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
3737 assert!(!snapshot.is_menu_open());
3738 cx.update(|window, cx| {
3739 TextSelection::update_edge_drag(point(px(290.), finger.y), window, cx);
3740 });
3741 draw(cx);
3742 assert_eq!(selected(cx), "select value");
3743 cx.update(|window, cx| TextSelection::end_edge_drag(window, cx));
3744 draw(cx);
3745 let snapshot = cx
3746 .update(|window, cx| TextSelection::touch_selection(window, cx))
3747 .unwrap();
3748 assert!(snapshot.is_menu_open());
3749 assert_eq!(snapshot.dragging(), None);
3750 let select_start = snapshot.start().left();
3751
3752 cx.update(|_, cx| {
3755 view.update(cx, |root, cx| {
3756 root.text_view.update(cx, |state, cx| state.select_all(cx));
3757 });
3758 });
3759 draw(cx);
3760 assert_eq!(selected(cx), "quick select value");
3761 let snapshot = cx
3762 .update(|window, cx| TextSelection::touch_selection(window, cx))
3763 .expect("select all keeps the touch selection");
3764 let start = snapshot.start();
3765 cx.update(|window, cx| {
3766 TextSelection::begin_edge_drag(SelectionEdge::Start, start.origin, window, cx);
3767 TextSelection::update_edge_drag(point(select_start, start.origin.y), window, cx);
3768 TextSelection::end_edge_drag(window, cx);
3769 });
3770 draw(cx);
3771 assert_eq!(selected(cx), "select value");
3772
3773 let menu = gpui::Bounds::new(point(px(0.), px(200.)), gpui::size(px(120.), px(32.)));
3776 cx.update(|window, cx| TextSelection::register_touch_ui(menu, window, cx));
3777 cx.simulate_event(MouseDownEvent {
3778 position: point(px(10.), px(210.)),
3779 modifiers: Modifiers::default(),
3780 button: MouseButton::Left,
3781 click_count: 1,
3782 first_mouse: false,
3783 });
3784 assert_eq!(selected(cx), "select value");
3785 cx.simulate_event(MouseDownEvent {
3786 position: point(px(10.), px(16.)),
3787 modifiers: Modifiers::default(),
3788 button: MouseButton::Left,
3789 click_count: 1,
3790 first_mouse: false,
3791 });
3792 cx.simulate_event(MouseUpEvent {
3793 position: point(px(10.), px(16.)),
3794 modifiers: Modifiers::default(),
3795 button: MouseButton::Left,
3796 click_count: 1,
3797 });
3798 draw(cx);
3799 assert!(
3800 cx.update(|window, cx| TextSelection::touch_selection(window, cx))
3801 .is_none()
3802 );
3803 }
3804
3805 #[gpui::test]
3806 fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
3807 cx.update(crate::init);
3808 let (view, cx) =
3809 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3810
3811 let cx: &mut VisualTestContext = cx;
3812 cx.run_until_parked();
3813 cx.update(|window, cx| {
3814 let _ = window.draw(cx);
3815 });
3816
3817 let position = point(px(10.), px(10.));
3818 cx.simulate_event(MouseDownEvent {
3819 position,
3820 modifiers: Modifiers::default(),
3821 button: MouseButton::Left,
3822 click_count: 3,
3823 first_mouse: false,
3824 });
3825 cx.simulate_event(MouseUpEvent {
3826 position,
3827 modifiers: Modifiers::default(),
3828 button: MouseButton::Left,
3829 click_count: 3,
3830 });
3831 cx.update(|window, cx| {
3832 let _ = window.draw(cx);
3833 });
3834
3835 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3836 assert_eq!(selected_text.trim(), "quick select value");
3837 }
3838
3839 mod multi_click_drag {
3840 use super::*;
3841
3842 struct Root {
3843 text_view: Entity<TextViewState>,
3844 }
3845
3846 impl Render for Root {
3847 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3848 div()
3849 .w(px(400.))
3850 .child(crate::TextSelectionLayer)
3851 .child(TextView::new(&self.text_view).selectable(true))
3852 }
3853 }
3854
3855 fn setup<'a>(
3856 markdown: &str,
3857 cx: &'a mut TestAppContext,
3858 ) -> (Entity<TextViewState>, &'a mut VisualTestContext) {
3859 cx.update(crate::init);
3860 let (root, cx) = cx.add_window_view(|_, cx| Root {
3861 text_view: cx.new(|cx| TextViewState::markdown(markdown, cx)),
3862 });
3863 cx.run_until_parked();
3864 cx.update(|window, cx| window.draw(cx).clear(cx));
3865 (root.read_with(cx, |root, _| root.text_view.clone()), cx)
3866 }
3867
3868 fn position(
3870 state: &Entity<TextViewState>,
3871 text: &str,
3872 index: usize,
3873 cx: &mut VisualTestContext,
3874 ) -> gpui::Point<Pixels> {
3875 state.read_with(cx, |state, _| {
3876 state.selection_adapter.text_position(text, index)
3877 })
3878 }
3879
3880 fn press(
3881 position: gpui::Point<Pixels>,
3882 click_count: usize,
3883 shift: bool,
3884 cx: &mut VisualTestContext,
3885 ) {
3886 cx.simulate_event(MouseDownEvent {
3887 position,
3888 modifiers: Modifiers {
3889 shift,
3890 ..Default::default()
3891 },
3892 button: MouseButton::Left,
3893 click_count,
3894 first_mouse: false,
3895 });
3896 }
3897
3898 fn drag(position: gpui::Point<Pixels>, cx: &mut VisualTestContext) {
3899 cx.simulate_event(gpui::MouseMoveEvent {
3900 position,
3901 pressed_button: Some(MouseButton::Left),
3902 modifiers: Modifiers::default(),
3903 });
3904 }
3905
3906 fn release(position: gpui::Point<Pixels>, click_count: usize, cx: &mut VisualTestContext) {
3907 cx.simulate_event(MouseUpEvent {
3908 position,
3909 modifiers: Modifiers::default(),
3910 button: MouseButton::Left,
3911 click_count,
3912 });
3913 }
3914
3915 fn selected(state: &Entity<TextViewState>, cx: &mut VisualTestContext) -> String {
3916 cx.update(|window, cx| window.draw(cx).clear(cx));
3917 state.read_with(cx, |state, _| state.selected_text().trim().to_string())
3918 }
3919
3920 #[gpui::test]
3921 fn double_click_drag_extends_the_selection_by_words(cx: &mut TestAppContext) {
3922 let (first, second) = ("alpha beta gamma", "delta epsilon zeta");
3923 let (state, cx) = setup(&format!("{first}\n\n{second}"), cx);
3924
3925 press(position(&state, first, 7, cx), 2, false, cx);
3926 assert_eq!(selected(&state, cx), "beta");
3927 drag(position(&state, first, 8, cx), cx);
3928 assert_eq!(selected(&state, cx), "beta");
3929 drag(position(&state, first, 13, cx), cx);
3930 assert_eq!(selected(&state, cx), "beta gamma");
3931 drag(position(&state, first, 1, cx), cx);
3933 assert_eq!(selected(&state, cx), "alpha beta");
3934 let end = position(&state, second, 8, cx);
3935 drag(end, cx);
3936 assert_eq!(selected(&state, cx), "beta gamma\ndelta epsilon");
3937 release(end, 2, cx);
3938 assert_eq!(selected(&state, cx), "beta gamma\ndelta epsilon");
3939 }
3940
3941 #[gpui::test]
3942 fn triple_click_drag_extends_the_selection_by_paragraphs(cx: &mut TestAppContext) {
3943 let (state, cx) = setup("first one\n\nsecond one\n\nthird one", cx);
3944
3945 press(position(&state, "second one", 2, cx), 3, false, cx);
3946 assert_eq!(selected(&state, cx), "second one");
3947 drag(position(&state, "third one", 2, cx), cx);
3948 assert_eq!(selected(&state, cx), "second one\nthird one");
3949 let end = position(&state, "first one", 2, cx);
3950 drag(end, cx);
3951 assert_eq!(selected(&state, cx), "first one\nsecond one");
3952 release(end, 3, cx);
3953 assert_eq!(selected(&state, cx), "first one\nsecond one");
3954 }
3955
3956 #[gpui::test]
3958 fn triple_click_drag_and_shift_click_keep_hard_break_paragraphs_whole(
3959 cx: &mut TestAppContext,
3960 ) {
3961 let first = "first top\nfirst bottom";
3962 let last = "last top\nlast bottom";
3963 let (state, cx) = setup(
3964 "first top \nfirst bottom\n\nmiddle\n\nlast top \nlast bottom",
3965 cx,
3966 );
3967
3968 press(position(&state, "middle", 2, cx), 3, false, cx);
3969 assert_eq!(selected(&state, cx), "middle");
3970 let after = position(&state, last, 2, cx);
3971 drag(after, cx);
3972 assert_eq!(selected(&state, cx), format!("middle\n{last}"));
3973 let before = position(&state, first, "first top\n".len() + 2, cx);
3974 drag(before, cx);
3975 assert_eq!(selected(&state, cx), format!("{first}\nmiddle"));
3976 release(before, 3, cx);
3977
3978 press(after, 1, true, cx);
3979 release(after, 1, cx);
3980 assert_eq!(selected(&state, cx), format!("middle\n{last}"));
3981 press(before, 1, true, cx);
3982 release(before, 1, cx);
3983 assert_eq!(selected(&state, cx), format!("{first}\nmiddle"));
3984 }
3985
3986 #[gpui::test]
3988 fn multi_click_drag_snaps_across_inline_code(cx: &mut TestAppContext) {
3989 let (state, cx) = setup("plain words\n\nuse `code` in here", cx);
3990
3991 press(position(&state, "plain words", 8, cx), 2, false, cx);
3992 drag(position(&state, " in here", 6, cx), cx);
3993 assert_eq!(selected(&state, cx), "words\nuse code in here");
3994 release(position(&state, " in here", 6, cx), 2, cx);
3995
3996 press(position(&state, "plain words", 2, cx), 3, false, cx);
3997 assert_eq!(selected(&state, cx), "plain words");
3998 drag(position(&state, "use ", 1, cx), cx);
3999 assert_eq!(selected(&state, cx), "plain words\nuse code in here");
4000 }
4001
4002 #[gpui::test]
4003 fn shift_click_after_a_double_click_extends_by_words(cx: &mut TestAppContext) {
4004 let text = "alpha beta gamma";
4005 let (state, cx) = setup(text, cx);
4006
4007 let word = position(&state, text, 7, cx);
4008 press(word, 2, false, cx);
4009 release(word, 2, cx);
4010 let after = position(&state, text, 13, cx);
4011 press(after, 1, true, cx);
4012 release(after, 1, cx);
4013 assert_eq!(selected(&state, cx), "beta gamma");
4014 let before = position(&state, text, 1, cx);
4015 press(before, 1, true, cx);
4016 release(before, 1, cx);
4017 assert_eq!(selected(&state, cx), "alpha beta");
4018 }
4019 }
4020
4021 mod copy_before_paint {
4022 use super::*;
4023 use crate::{TextSelection, text::SelectionFormat};
4024
4025 const MARKDOWN: &str = "中文 **café**\n\nβeta `\u{2009}code\u{2009}` 👩💻\n\n終わり";
4026 const PLAIN: &str = "中文 café\nβeta \u{2009}code\u{2009} 👩💻\n終わり\n";
4027
4028 struct Root {
4029 state: Entity<TextViewState>,
4030 format: SelectionFormat,
4031 width: Pixels,
4032 scrollable: bool,
4033 }
4034
4035 impl Render for Root {
4036 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
4037 div()
4038 .w(self.width)
4039 .when(self.scrollable, |el| el.h(px(160.)))
4040 .child(crate::TextSelectionLayer)
4041 .child(
4042 TextView::new(&self.state)
4043 .scrollable(self.scrollable)
4044 .selection_format(self.format),
4045 )
4046 }
4047 }
4048
4049 fn setup(
4050 format: SelectionFormat,
4051 cx: &mut TestAppContext,
4052 ) -> (Entity<TextViewState>, &mut VisualTestContext) {
4053 cx.update(crate::init);
4054 let (root, cx) = cx.add_window_view(|_, cx| Root {
4055 state: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
4056 format,
4057 width: px(520.),
4058 scrollable: false,
4059 });
4060 cx.run_until_parked();
4061 cx.update(|window, cx| window.draw(cx).clear(cx));
4062 (root.read_with(cx, |root, _| root.state.clone()), cx)
4063 }
4064
4065 fn document<'a>(
4066 markdown: &str,
4067 format: SelectionFormat,
4068 scrollable: bool,
4069 cx: &'a mut TestAppContext,
4070 ) -> (Entity<Root>, &'a mut VisualTestContext) {
4071 cx.update(crate::init);
4072 let (root, cx) = cx.add_window_view(|_, cx| Root {
4073 state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
4074 format,
4075 width: px(520.),
4076 scrollable,
4077 });
4078 cx.run_until_parked();
4079 cx.update(|window, cx| window.draw(cx).clear(cx));
4080 (root, cx)
4081 }
4082
4083 fn scroll_to(state: &Entity<TextViewState>, ix: usize, cx: &mut VisualTestContext) {
4084 state.read_with(cx, |state, _| {
4085 state.list_state.scroll_to(gpui::ListOffset {
4086 item_ix: ix,
4087 offset_in_item: px(0.),
4088 })
4089 });
4090 cx.update(|window, cx| window.draw(cx).clear(cx));
4091 }
4092
4093 fn endpoints(
4094 state: &Entity<TextViewState>,
4095 cx: &mut VisualTestContext,
4096 ) -> (
4097 gpui::Point<Pixels>,
4098 gpui::Point<Pixels>,
4099 gpui::Point<Pixels>,
4100 ) {
4101 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
4102 let first = rows.first().unwrap();
4103 let last = rows.last().unwrap();
4104 (
4105 point(first.left() + px(0.1), first.center().y),
4106 point(first.right() - px(0.1), first.center().y),
4107 point(last.right() - px(0.1), last.center().y),
4108 )
4109 }
4110
4111 fn press(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
4114 window.dispatch_event(
4115 gpui::PlatformInput::MouseDown(MouseDownEvent {
4116 position,
4117 button: MouseButton::Left,
4118 click_count: 1,
4119 ..Default::default()
4120 }),
4121 cx,
4122 );
4123 }
4124
4125 fn extend(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
4126 window.dispatch_event(
4127 gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
4128 position,
4129 pressed_button: Some(MouseButton::Left),
4130 ..Default::default()
4131 }),
4132 cx,
4133 );
4134 }
4135
4136 fn release(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
4137 window.dispatch_event(
4138 gpui::PlatformInput::MouseUp(MouseUpEvent {
4139 position,
4140 button: MouseButton::Left,
4141 modifiers: Modifiers::default(),
4142 click_count: 1,
4143 }),
4144 cx,
4145 );
4146 }
4147
4148 fn assert_copy(expected: &str, window: &mut Window, cx: &mut gpui::App) {
4149 let focus = window.focused(cx).expect("gesture focuses TextView");
4152 focus.dispatch_action(&crate::input::Copy, window, cx);
4153 assert_eq!(
4154 cx.read_from_clipboard().and_then(|item| item.text()),
4155 Some(expected.trim().to_string())
4156 );
4157 assert!(focus.is_focused(window));
4158 assert_eq!(TextSelection::selected_text(window, cx), expected);
4159 }
4160
4161 #[gpui::test]
4162 fn custom_inline_and_block_copy_project_with_the_text(cx: &mut TestAppContext) {
4163 struct CustomRoot {
4164 state: Entity<TextViewState>,
4165 format: SelectionFormat,
4166 extensions: crate::text::MarkdownExtensions,
4167 }
4168 impl Render for CustomRoot {
4169 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
4170 div().w(px(520.)).child(crate::TextSelectionLayer).child(
4171 TextView::new(&self.state)
4172 .selection_format(self.format)
4173 .markdown_extensions(self.extensions.clone()),
4174 )
4175 }
4176 }
4177 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
4178 cx.update(crate::init);
4179 let extensions = crate::text::MarkdownExtensions::default()
4180 .plugin(
4181 crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(
4182 |node, _| {
4183 let markdown::mdast::Node::InlineMath(math) = node else {
4184 return None;
4185 };
4186 Some(
4187 crate::text::MarkdownNode::new("math", ())
4188 .text(format!("{}²", math.value)),
4189 )
4190 },
4191 ),
4192 )
4193 .block_parser(|node, _| {
4194 matches!(node, markdown::mdast::Node::ThematicBreak(_)).then(|| {
4195 crate::text::MarkdownNode::new("block", ())
4196 .text("BLOCK")
4197 .markdown("---")
4198 })
4199 })
4200 .block_renderer("block", |_, _, _| {
4201 div()
4202 .debug_selector(|| "copy-custom-block".into())
4203 .w(px(520.))
4204 .h(px(40.))
4205 });
4206 let (root, cx) = cx.add_window_view(|_, cx| CustomRoot {
4207 state: cx
4208 .new(|cx| TextViewState::markdown("before $x$ after\n\n---\n\nlast", cx)),
4209 format,
4210 extensions,
4211 });
4212 cx.run_until_parked();
4213 cx.update(|window, cx| window.draw(cx).clear(cx));
4214 let state = root.read_with(cx, |root, _| root.state.clone());
4215 let (start, _, end) = endpoints(&state, cx);
4216 let expected = if format == SelectionFormat::Plain {
4217 "before x² after\nBLOCK\nlast\n"
4218 } else {
4219 "before $x$ after\n\n---\n\nlast"
4220 };
4221 cx.update(|window, cx| {
4222 press(start, window, cx);
4223 extend(end, window, cx);
4224 assert_copy(expected, window, cx);
4225 release(end, window, cx);
4226 });
4227 cx.update(|window, cx| window.draw(cx).clear(cx));
4228 cx.update(|window, cx| assert_copy(expected, window, cx));
4229 let block = cx.debug_bounds("copy-custom-block").unwrap();
4230 cx.update(|window, cx| {
4231 press(block.center(), window, cx);
4232 extend(point(block.center().x, block.bottom() + px(1.)), window, cx);
4233 assert_copy(
4234 if format == SelectionFormat::Plain {
4235 "BLOCK\n"
4236 } else {
4237 "---"
4238 },
4239 window,
4240 cx,
4241 );
4242 });
4243 }
4244 }
4245
4246 #[gpui::test]
4247 fn unicode_180_paragraph_copy_extends_and_retracts_before_paint(cx: &mut TestAppContext) {
4248 let markdown = (0..180)
4249 .map(|ix| format!("p{ix:03} **café** 中文 👩💻 `\u{2009}code\u{2009}`"))
4250 .collect::<Vec<_>>()
4251 .join("\n\n");
4252 let expected = (0..180)
4255 .map(|ix| format!("p{ix:03} café 中文 👩💻 \u{2009}code\u{2009}\n"))
4256 .collect::<String>();
4257 let (root, cx) = document(&markdown, SelectionFormat::Plain, false, cx);
4258 cx.simulate_resize(gpui::size(px(800.), px(12000.)));
4259 cx.update(|window, cx| window.draw(cx).clear(cx));
4260 let state = root.read_with(cx, |root, _| root.state.clone());
4261 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
4262 let first = rows.first().unwrap();
4263 let first_end = rows
4264 .iter()
4265 .take_while(|row| row.top() < first.bottom())
4266 .last()
4267 .unwrap();
4268 let last = rows.last().unwrap();
4269 assert!(last.top() > first.top() + px(3000.));
4270 let start = point(first.left() + px(0.1), first.center().y);
4271 let short_end = point(first_end.right() - px(0.1), first_end.center().y);
4272 let end = point(last.right() - px(0.1), last.center().y);
4273 let short = "p000 café 中文 👩💻 \u{2009}code\u{2009}\n";
4274 cx.update(|window, cx| {
4275 press(start, window, cx);
4276 extend(short_end, window, cx);
4277 assert_copy(short, window, cx);
4278 });
4279 cx.update(|window, cx| window.draw(cx).clear(cx));
4280 cx.update(|window, cx| {
4281 extend(end, window, cx);
4282 assert_copy(&expected, window, cx);
4283 extend(short_end, window, cx);
4284 assert_copy(short, window, cx);
4285 });
4286 }
4287
4288 #[gpui::test]
4289 fn scrolling_before_paint_keeps_the_completed_frame_coordinates(cx: &mut TestAppContext) {
4290 let markdown = format!(
4291 "{}\n\nlast",
4292 "alpha beta gamma delta epsilon zeta ".repeat(30)
4293 );
4294 let (root, cx) = document(&markdown, SelectionFormat::Plain, true, cx);
4295 root.update(cx, |root, cx| {
4296 root.width = px(180.);
4297 cx.notify();
4298 });
4299 cx.update(|window, cx| window.draw(cx).clear(cx));
4300 let state = root.read_with(cx, |root, _| root.state.clone());
4301 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
4302 let first = rows[0];
4303 let (start, end) = state.read_with(cx, |state, _| {
4304 (
4305 state.selection_adapter.first_text_position(0),
4306 state.selection_adapter.first_text_position("alpha".len()),
4307 )
4308 });
4309 cx.update(|window, cx| {
4310 press(start, window, cx);
4311 extend(end, window, cx);
4312 });
4313 cx.update(|window, cx| window.draw(cx).clear(cx));
4314 cx.update(|window, cx| {
4315 state.read(cx).list_state.scroll_by(first.size.height);
4316 assert_copy("alpha\n", window, cx);
4319 });
4320 }
4321
4322 #[gpui::test]
4323 fn offscreen_remeasurement_is_not_a_copy_projection(cx: &mut TestAppContext) {
4324 let markdown = (0..180)
4325 .map(|ix| format!("row {ix:03} café 中文"))
4326 .collect::<Vec<_>>()
4327 .join("\n\n");
4328 let (root, cx) = document(&markdown, SelectionFormat::Plain, true, cx);
4329 let state = root.read_with(cx, |root, _| root.state.clone());
4330 scroll_to(&state, 90, cx);
4331 root.update(cx, |root, cx| {
4332 root.width = px(260.);
4333 cx.notify();
4334 });
4335 cx.update(|window, cx| window.draw(cx).clear(cx));
4336 scroll_to(&state, 90, cx);
4337 let (start, end, _) = endpoints(&state, cx);
4340 cx.update(|window, cx| {
4341 press(start, window, cx);
4342 extend(end, window, cx);
4343 assert_copy("row 090 café 中文\n", window, cx);
4344 });
4345 }
4346
4347 #[gpui::test]
4348 fn virtualized_flow_retains_its_source_owned_partial_anchor(cx: &mut TestAppContext) {
4349 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
4350 let tail = (1..180)
4351 .map(|ix| format!("row {ix:03} café 中文"))
4352 .collect::<Vec<_>>();
4353 let markdown = format!("PREFIX `code` suffix\n\n{}", tail.join("\n\n"));
4354 let expected = if format == SelectionFormat::Plain {
4355 format!("code suffix\n{}\n", tail.join("\n"))
4356 } else {
4357 format!("`code` suffix\n\n{}", tail.join("\n\n"))
4358 };
4359 let (root, cx) = document(&markdown, format, true, cx);
4360 let state = root.read_with(cx, |root, _| root.state.clone());
4361 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
4362 let code = rows[1];
4363 let suffix = rows[2];
4364 assert!(code.left() < suffix.left());
4365 cx.update(|window, cx| {
4366 press(point(code.left() + px(0.1), code.center().y), window, cx);
4367 extend(
4368 point(suffix.right() - px(0.1), suffix.center().y),
4369 window,
4370 cx,
4371 );
4372 });
4373 cx.update(|window, cx| window.draw(cx).clear(cx));
4374 scroll_to(&state, 179, cx);
4375 cx.update(|window, cx| window.draw(cx).clear(cx));
4377 let (_, _, end) = endpoints(&state, cx);
4378 cx.update(|window, cx| {
4379 extend(end, window, cx);
4380 assert_copy(&expected, window, cx);
4381 });
4382 }
4383 }
4384
4385 #[gpui::test]
4386 fn a_new_range_copies_before_its_first_selection_paint(cx: &mut TestAppContext) {
4387 let (state, cx) = setup(SelectionFormat::Plain, cx);
4388 let (start, _, end) = endpoints(&state, cx);
4389 cx.update(|window, cx| {
4390 press(start, window, cx);
4391 extend(end, window, cx);
4392 assert_copy(PLAIN, window, cx);
4393 });
4394 }
4395
4396 #[gpui::test]
4397 fn an_established_range_extends_and_retracts_without_paint(cx: &mut TestAppContext) {
4398 let (state, cx) = setup(SelectionFormat::Plain, cx);
4399 let (start, first_end, end) = endpoints(&state, cx);
4400 cx.update(|window, cx| {
4401 press(start, window, cx);
4402 extend(first_end, window, cx);
4403 });
4404 cx.update(|window, cx| window.draw(cx).clear(cx));
4405 cx.update(|window, cx| {
4406 assert_copy("中文 café\n", window, cx);
4407 extend(end, window, cx);
4408 assert_copy(PLAIN, window, cx);
4409 extend(first_end, window, cx);
4410 assert_copy("中文 café\n", window, cx);
4411 });
4412 }
4413
4414 #[gpui::test]
4415 fn source_copy_projects_the_current_range_without_paint(cx: &mut TestAppContext) {
4416 let (state, cx) = setup(SelectionFormat::Source, cx);
4417 let (start, first_end, end) = endpoints(&state, cx);
4418 cx.update(|window, cx| {
4419 press(start, window, cx);
4420 extend(first_end, window, cx);
4421 assert_copy("中文 **café**", window, cx);
4422 extend(end, window, cx);
4423 assert_copy(MARKDOWN, window, cx);
4424 extend(first_end, window, cx);
4425 assert_eq!(TextSelection::selected_text(window, cx), "中文 **café**");
4426 });
4427 }
4428
4429 #[gpui::test]
4430 fn a_blank_gesture_does_not_reuse_the_previous_range(cx: &mut TestAppContext) {
4431 let (state, cx) = setup(SelectionFormat::Plain, cx);
4432 let (start, first_end, end) = endpoints(&state, cx);
4433 cx.update(|window, cx| {
4434 press(start, window, cx);
4435 extend(end, window, cx);
4436 assert_eq!(TextSelection::selected_text(window, cx), PLAIN);
4437 release(end, window, cx);
4438 press(first_end + point(px(20.), px(0.)), window, cx);
4439 extend(first_end + point(px(60.), px(0.)), window, cx);
4440 assert_eq!(TextSelection::selected_text(window, cx), "");
4441 });
4442 }
4443
4444 #[gpui::test]
4445 fn replacing_source_cannot_reproject_old_content(cx: &mut TestAppContext) {
4446 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
4447 for replacement in ["replacement".to_string(), "replacement ".repeat(500)] {
4448 let (state, cx) = setup(format, cx);
4449 let (start, _, end) = endpoints(&state, cx);
4450 cx.update(|window, cx| {
4451 press(start, window, cx);
4452 extend(end, window, cx);
4453 assert_eq!(
4454 TextSelection::selected_text(window, cx),
4455 if format == SelectionFormat::Plain {
4456 PLAIN
4457 } else {
4458 MARKDOWN
4459 }
4460 );
4461 state.update(cx, |state, cx| state.set_text(&replacement, cx));
4464 assert_eq!(TextSelection::selected_text(window, cx), "");
4465 });
4466 }
4467 }
4468 }
4469 }
4470
4471 #[gpui::test]
4477 fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
4478 use gpui::{ListAlignment, ListState, list};
4479
4480 const ITEMS: &[&str] = &[
4481 "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
4482 "Short.",
4483 "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
4484 "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
4485 "Only one line.",
4486 "**Bold**: medium length text with `code` mixed with regular words.",
4487 "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
4488 "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
4489 ];
4490 let n = 40usize;
4491
4492 struct ListRoot {
4493 state: ListState,
4494 }
4495 impl Render for ListRoot {
4496 fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
4497 div().w(px(360.)).h(px(500.)).child(
4498 list(self.state.clone(), |ix, _w, _cx| {
4499 div()
4500 .w_full()
4501 .child(TextView::markdown(
4502 ("md", ix as u64),
4503 ITEMS[ix % ITEMS.len()],
4504 ))
4505 .into_any_element()
4506 })
4507 .size_full(),
4508 )
4509 }
4510 }
4511
4512 cx.update(crate::init);
4513 let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
4514 let probe = state.clone();
4515 let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
4516 let cx: &mut VisualTestContext = cx;
4517
4518 cx.run_until_parked();
4519 cx.update(|w, cx| {
4520 let _ = w.draw(cx);
4521 });
4522 cx.run_until_parked();
4523 cx.update(|w, cx| {
4524 let _ = w.draw(cx);
4525 });
4526
4527 let total = |p: &ListState| {
4528 f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
4529 };
4530 let mut totals = vec![total(&probe)];
4531 for _ in 0..20 {
4532 probe.scroll_by(px(150.));
4533 cx.update(|w, cx| {
4534 let _ = w.draw(cx);
4535 });
4536 cx.run_until_parked();
4537 totals.push(total(&probe));
4538 }
4539 let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
4540 let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
4541 println!(
4542 "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
4543 max - min
4544 );
4545 assert!(
4546 (max - min) < 2.0,
4547 "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
4548 );
4549 }
4550}