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 style: StyleRefinement,
160 selectable: bool,
161 selection_format: SelectionFormat,
162 scrollable: bool,
163 max_lines: Option<usize>,
164 code_block_actions: Option<Arc<CodeBlockActionsFn>>,
165 code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
166 table_actions: Option<Arc<TableActionsFn>>,
167 link_click_handler: Option<Arc<LinkClickHandlerFn>>,
168 image_source: Option<Arc<ImageSourceFn>>,
169 reveal_handler: Option<Rc<RevealHandlerFn>>,
170 markdown_extensions: Arc<MarkdownExtensions>,
171 motion: Option<TextViewMotion>,
172}
173
174pub trait TextViewPlugin {
176 fn setup(self, text_view: TextView) -> TextView;
177}
178
179impl<P> TextViewPlugin for P
180where
181 P: MarkdownPlugin,
182{
183 fn setup(self, mut text_view: TextView) -> TextView {
184 let extensions = Arc::make_mut(&mut text_view.markdown_extensions);
185 let current = std::mem::take(extensions);
186 *extensions = current.plugin(self);
187 text_view
188 }
189}
190
191impl Styled for TextView {
192 fn style(&mut self) -> &mut StyleRefinement {
193 &mut self.style
194 }
195}
196
197impl TextView {
198 pub fn new(state: &Entity<TextViewState>) -> Self {
200 Self {
201 id: ElementId::Name(state.entity_id().to_string().into()),
202 state: Some(state.clone()),
203 format: None,
204 text: None,
205 text_view_style: None,
206 style: StyleRefinement::default(),
207 selectable: true,
208 selection_format: SelectionFormat::default(),
209 scrollable: false,
210 max_lines: None,
211 code_block_actions: None,
212 code_block_highlighter: None,
213 table_actions: None,
214 link_click_handler: None,
215 image_source: None,
216 reveal_handler: None,
217 markdown_extensions: Arc::default(),
218 motion: None,
219 }
220 }
221
222 pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
224 Self {
225 id: id.into(),
226 format: Some(TextViewFormat::Markdown),
227 text: Some(markdown.into()),
228 text_view_style: None,
229 style: StyleRefinement::default(),
230 state: None,
231 selectable: true,
232 selection_format: SelectionFormat::default(),
233 scrollable: false,
234 max_lines: None,
235 code_block_actions: None,
236 code_block_highlighter: None,
237 table_actions: None,
238 link_click_handler: None,
239 image_source: None,
240 reveal_handler: None,
241 markdown_extensions: Arc::default(),
242 motion: None,
243 }
244 }
245
246 pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
248 Self {
249 id: id.into(),
250 format: Some(TextViewFormat::Html),
251 text: Some(html.into()),
252 text_view_style: None,
253 style: StyleRefinement::default(),
254 state: None,
255 selectable: true,
256 selection_format: SelectionFormat::default(),
257 scrollable: false,
258 max_lines: None,
259 code_block_actions: None,
260 code_block_highlighter: None,
261 table_actions: None,
262 link_click_handler: None,
263 image_source: None,
264 reveal_handler: None,
265 markdown_extensions: Arc::default(),
266 motion: None,
267 }
268 }
269
270 pub fn image_source<F>(mut self, resolver: F) -> Self
276 where
277 F: Fn(&gpui::SharedUri) -> gpui::ImageSource + Send + Sync + 'static,
278 {
279 self.image_source = Some(Arc::new(resolver));
280 self
281 }
282
283 pub fn style(mut self, style: TextViewStyle) -> Self {
285 self.text_view_style = Some(style);
286 self
287 }
288
289 pub fn selectable(mut self, selectable: bool) -> Self {
291 self.selectable = selectable;
292 self
293 }
294
295 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
300 self.selection_format = selection_format;
301 self
302 }
303
304 pub fn scrollable(mut self, scrollable: bool) -> Self {
317 self.scrollable = scrollable;
318 self
319 }
320
321 pub fn max_lines(mut self, max_lines: usize) -> Self {
340 self.max_lines = Some(max_lines);
341 self
342 }
343
344 pub fn code_block_actions<F, E>(mut self, f: F) -> Self
349 where
350 F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
351 E: IntoElement,
352 {
353 self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
354 f(&code_block, window, cx).into_any_element()
355 }));
356 self
357 }
358
359 pub fn code_block_highlighter<F>(mut self, highlighter: F) -> Self
364 where
365 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
366 {
367 self.code_block_highlighter = Some(Arc::new(highlighter));
368 self
369 }
370
371 #[doc(hidden)]
378 pub fn shared_code_block_highlighter(
379 mut self,
380 highlighter: Arc<
381 dyn Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync,
382 >,
383 ) -> Self {
384 self.code_block_highlighter = Some(highlighter);
385 self
386 }
387
388 pub fn table_actions<F, E>(mut self, f: F) -> Self
393 where
394 F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
395 E: IntoElement,
396 {
397 self.table_actions = Some(Arc::new(move |table, window, cx| {
398 f(table, window, cx).into_any_element()
399 }));
400 self
401 }
402
403 pub fn on_link_click<F>(mut self, handler: F) -> Self
408 where
409 F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
410 {
411 self.link_click_handler = Some(Arc::new(handler));
412 self
413 }
414
415 pub fn on_reveal<F>(mut self, handler: F) -> Self
424 where
425 F: Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
426 {
427 self.reveal_handler = Some(Rc::new(handler));
428 self
429 }
430
431 pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self {
433 self.markdown_extensions = Arc::new(extensions);
434 self
435 }
436
437 pub fn motion(mut self, motion: TextViewMotion) -> Self {
440 self.motion = Some(motion);
441 self
442 }
443
444 pub fn markdown_mdx(mut self) -> Self {
449 let extensions = Arc::make_mut(&mut self.markdown_extensions);
450 *extensions = extensions.clone().mdx();
451 self
452 }
453
454 pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
460 where
461 F: for<'a> Fn(
462 &markdown::mdast::Node,
463 &crate::text::MarkdownParseContext<'a>,
464 ) -> Option<MarkdownNode>
465 + Send
466 + Sync
467 + 'static,
468 {
469 Arc::make_mut(&mut self.markdown_extensions).push_block_parser(parser);
470 self
471 }
472
473 pub fn markdown_block_renderer<F, E>(
475 mut self,
476 name: impl Into<SharedString>,
477 renderer: F,
478 ) -> Self
479 where
480 F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
481 E: IntoElement,
482 {
483 Arc::make_mut(&mut self.markdown_extensions).push_block_renderer(name, renderer);
484 self
485 }
486
487 pub fn plugin<P>(self, plugin: P) -> Self
489 where
490 P: TextViewPlugin,
491 {
492 plugin.setup(self)
493 }
494}
495
496impl IntoElement for TextView {
497 type Element = Self;
498
499 fn into_element(self) -> Self::Element {
500 self
501 }
502}
503
504pub struct TextViewLayoutState {
505 state: Entity<TextViewState>,
506 element: AnyElement,
507}
508
509pub struct TextViewPrepaintState {
510 hitbox: Hitbox,
511 clip_bottom: Option<Pixels>,
515 touch_handles: crate::TouchHandleLayout,
517}
518
519const CLIP_EPSILON: Pixels = px(1.);
522
523fn last_line_bottom_above(spans: &[LineSpan], y: Pixels) -> Option<(Pixels, Pixels)> {
526 let mut last: Option<(Pixels, Pixels)> = None;
527 let mut keep = |bottom: Pixels, line_height: Pixels| {
528 if bottom <= y + CLIP_EPSILON && last.is_none_or(|(last, _)| bottom > last) {
529 last = Some((bottom, line_height));
530 }
531 };
532
533 for span in spans {
534 if span.line_height <= px(0.) {
535 continue;
536 }
537 let mut bottom = span.top + span.line_height;
538 while bottom <= span.bottom + CLIP_EPSILON {
539 keep(bottom, span.line_height);
540 bottom += span.line_height;
541 }
542 keep(span.bottom, span.line_height);
544 }
545
546 last
547}
548
549fn line_safe_clip_bottom(
559 spans: &[LineSpan],
560 box_bottom: Pixels,
561 content_bottom: Pixels,
562) -> Option<Pixels> {
563 let mut clip = box_bottom;
564
565 for span in spans {
566 if span.line_height <= px(0.)
567 || span.top >= box_bottom
568 || span.bottom <= box_bottom + CLIP_EPSILON
569 {
570 continue;
571 }
572 let whole_lines = ((box_bottom - span.top) / span.line_height).floor();
573 let line_top = span.top + span.line_height * whole_lines;
574 if line_top < box_bottom - CLIP_EPSILON {
576 clip = clip.min(line_top);
577 }
578 }
579
580 let Some((last_line_bottom, line_height)) = last_line_bottom_above(spans, clip) else {
581 return None;
586 };
587
588 if content_bottom > box_bottom + CLIP_EPSILON {
592 let strip = clip - last_line_bottom;
593 if strip > CLIP_EPSILON && strip < line_height {
594 clip = last_line_bottom;
595 }
596 }
597
598 (clip < box_bottom - CLIP_EPSILON).then_some(clip)
599}
600
601impl Element for TextView {
602 type RequestLayoutState = TextViewLayoutState;
603 type PrepaintState = TextViewPrepaintState;
604
605 fn id(&self) -> Option<ElementId> {
606 Some(self.id.clone())
607 }
608
609 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
610 None
611 }
612
613 fn request_layout(
614 &mut self,
615 _: Option<&GlobalElementId>,
616 _: Option<&InspectorElementId>,
617 window: &mut Window,
618 cx: &mut App,
619 ) -> (LayoutId, Self::RequestLayoutState) {
620 let state = if let Some(state) = self.state.clone() {
621 state
622 } else {
623 let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
624 let default_text = self.text.clone().unwrap_or_default();
625
626 let state = window.use_keyed_state(
627 SharedString::from(format!("{}/state", self.id)),
628 cx,
629 move |_, cx| {
630 if default_format == TextViewFormat::Markdown {
631 TextViewState::markdown(default_text.as_str(), cx)
632 } else {
633 TextViewState::html(default_text.as_str(), cx)
634 }
635 },
636 );
637 self.state = Some(state.clone());
638 state
639 };
640
641 let max_lines = self.max_lines.filter(|_| !self.scrollable);
644
645 let defaults = cx.try_global::<TextViewDefaults>();
648 let theme_style;
649 let mut text_view_style = match (
650 &self.text_view_style,
651 defaults.and_then(|d| d.style.as_ref()),
652 ) {
653 (Some(style), _) | (None, Some(style)) => style,
654 (None, None) => {
655 theme_style = TextViewStyle::from_theme(&crate::Theme::global(cx));
656 &theme_style
657 }
658 };
659 let surface_style;
662 let mut on_inverted_surface = false;
663 if self.text_view_style.is_none() && defaults.is_some_and(|d| d.inherit_text_color) {
664 let color = self
665 .style
666 .text
667 .color
668 .unwrap_or_else(|| window.text_style().color);
669 if color != text_view_style.foreground() {
670 on_inverted_surface = text_view_style.is_inverted_by(color);
671 surface_style = text_view_style.on_text_color(color);
672 text_view_style = &surface_style;
673 }
674 }
675 let foreground = text_view_style.foreground();
676 let text_view_style = (*state.read(cx).text_view_style != *text_view_style)
677 .then(|| Arc::new(text_view_style.clone()));
678 let code_block_highlighter = self.code_block_highlighter.clone().or_else(|| {
679 defaults
680 .filter(|_| !on_inverted_surface)
681 .and_then(|d| d.code_block_highlighter.clone())
682 });
683
684 state.update(cx, |state, cx| {
685 if text_view_style.is_some()
686 || match (&state.code_block_highlighter, &code_block_highlighter) {
687 (Some(previous), Some(next)) => !Arc::ptr_eq(previous, next),
688 (None, None) => false,
689 _ => true,
690 }
691 {
692 state.preserve_selection_for_reflow();
693 }
694 state.code_block_actions = self.code_block_actions.clone();
695 state.code_block_highlighter = code_block_highlighter;
696 state.table_actions = self.table_actions.clone();
697 state.link_click_handler = self.link_click_handler.clone();
698 state.image_source = self.image_source.clone();
699 state.set_markdown_extensions(self.markdown_extensions.clone(), cx);
700 if let Some(motion) = &self.motion {
701 state.set_motion(motion.clone());
702 }
703 state.selectable = self.selectable;
704 state.selection_format = self.selection_format;
705 state.scrollable = self.scrollable;
706 state.max_lines = max_lines;
707 if let Some(text_view_style) = text_view_style {
708 state.text_view_style = text_view_style;
709 }
710
711 if let Some(text) = &self.text {
712 state.set_element_text(text, cx);
713 }
714 });
715
716 let focus_handle = state.read(cx).focus_handle.clone();
717 let list_state = state.read(cx).list_state.clone();
718 let max_lines_cap = max_lines.map(|max_lines| {
722 let mut text_style = window.text_style();
723 text_style.refine(&self.style.text);
724 text_style.line_height_in_pixels(window.rem_size()) * max_lines as f32
725 });
726
727 let mut el = div()
728 .id(("text-view-scroll", state.entity_id()))
729 .key_context("TextView")
730 .track_focus(&focus_handle)
731 .when(self.scrollable, |this| this.size_full())
732 .when_some(max_lines_cap, |this, cap| this.max_h(cap).overflow_hidden())
733 .relative()
734 .text_color(foreground)
735 .on_action(move |_: &crate::input::Copy, window, cx| {
736 let text = TextSelection::selected_text(window, cx).trim().to_string();
737 if text.is_empty() {
738 cx.propagate();
739 return;
740 }
741 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
742 })
743 .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
744 .map(|this| {
745 if self.scrollable {
746 this.child(crate::ScrollBounce::new(
747 ("text-view-bounce", state.entity_id()),
748 &list_state,
749 state.clone(),
750 ))
751 } else {
752 this.child(state.clone())
753 }
754 })
755 .when(self.scrollable, |this| {
758 this.child(
759 div().absolute().inset_0().child(
760 crate::Scrollbar::vertical(&list_state)
761 .id(("text-view-scrollbar", state.entity_id()))
762 .viewport_from_layout(),
763 ),
764 )
765 })
766 .refine_style(&self.style)
767 .into_any_element();
768 let layout_id = el.request_layout(window, cx);
769 (layout_id, TextViewLayoutState { state, element: el })
770 }
771
772 fn prepaint(
773 &mut self,
774 _: Option<&GlobalElementId>,
775 _: Option<&InspectorElementId>,
776 bounds: Bounds<Pixels>,
777 request_layout: &mut Self::RequestLayoutState,
778 window: &mut Window,
779 cx: &mut App,
780 ) -> Self::PrepaintState {
781 let state = request_layout.state.clone();
782 let max_lines_active = state.read(cx).max_lines.is_some();
783 if max_lines_active {
784 if let Ok(mut line_spans) = state.read(cx).line_spans.lock() {
785 line_spans.clear();
786 }
787 GlobalState::global(cx).push_text_view_state(state.clone());
790 }
791 request_layout.element.prepaint(window, cx);
792 if max_lines_active {
793 GlobalState::global(cx).pop_text_view_state();
794 }
795
796 let mut clip_bottom = None;
797 if max_lines_active {
798 let (line_spans, content_bottom) = {
799 let state = state.read(cx);
800 (
801 state
802 .line_spans
803 .lock()
804 .map(|spans| spans.clone())
805 .unwrap_or_default(),
806 state.bounds().bottom(),
807 )
808 };
809 let clipped = content_bottom > bounds.bottom() + px(1.);
813 if state.read(cx).clamped != clipped {
816 state.update(cx, |state, cx| {
817 state.clamped = clipped;
818 cx.notify();
819 });
820 }
821 if clipped {
822 clip_bottom = line_safe_clip_bottom(&line_spans, bounds.bottom(), content_bottom);
823 }
824 }
825
826 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
827 let touch_handles = if self.selectable {
829 state
830 .read(cx)
831 .selection_adapter
832 .prepaint_touch_handles(window, cx)
833 } else {
834 crate::TouchHandleLayout::default()
835 };
836 TextViewPrepaintState {
837 hitbox,
838 clip_bottom,
839 touch_handles,
840 }
841 }
842
843 fn paint(
844 &mut self,
845 _: Option<&GlobalElementId>,
846 _: Option<&InspectorElementId>,
847 bounds: Bounds<Pixels>,
848 request_layout: &mut Self::RequestLayoutState,
849 prepaint: &mut Self::PrepaintState,
850 window: &mut Window,
851 cx: &mut App,
852 ) {
853 let state = &request_layout.state;
854 if self.selectable {
855 state.read(cx).selection_adapter.begin_frame();
856 }
857
858 GlobalState::global(cx).push_text_view_state(state.clone());
859 if let Some(clip_bottom) = prepaint.clip_bottom {
860 let mask = ContentMask {
863 bounds: Bounds::from_corners(bounds.origin, point(bounds.right(), clip_bottom)),
864 };
865 window.with_content_mask(Some(mask), |window| {
866 request_layout.element.paint(window, cx);
867 });
868 } else {
869 request_layout.element.paint(window, cx);
870 }
871 GlobalState::global(cx).pop_text_view_state();
872
873 if state.read(cx).pending_reveal.is_some() {
876 let progress = state.update(cx, |state, _| {
877 state.pending_reveal.as_mut().map(PendingReveal::progress)
878 });
879 match progress {
880 Some(RevealProgress::Shown) => {
881 state.update(cx, |state, _| state.pending_reveal = None);
882 }
883 Some(RevealProgress::Hidden(line)) => {
884 if let Some(handler) = &self.reveal_handler {
885 handler(line, window, cx);
886 }
887 }
888 Some(RevealProgress::NotLaidOut) | None => {}
889 }
890 }
891
892 if self.selectable {
893 let (adapter, scroll_offset, content_bounds, self_scroll, handle_color) = {
894 let state = state.read(cx);
895 (
896 state.selection_adapter.clone(),
897 state.scroll_offset(),
898 state.bounds(),
899 state.scrollable,
900 state.text_view_style.selection().alpha(1.),
901 )
902 };
903 let document_order = GlobalState::global(cx).next_selection_document_order();
904 adapter.register(
905 prepaint.hitbox.clone(),
906 content_bounds,
907 scroll_offset,
908 document_order,
909 self_scroll,
910 window,
911 cx,
912 );
913 adapter.paint_touch_handles(&prepaint.touch_handles, handle_color, window, cx);
916 }
917 }
918}
919
920#[cfg(test)]
921mod tests {
922 use std::sync::{
923 Arc,
924 atomic::{AtomicUsize, Ordering},
925 };
926
927 use super::{TextView, TextViewPlugin};
928 use crate::text::{TableData, TextViewState, TextViewStyle};
929 use gpui::{
930 AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
931 Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
932 Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
933 TestAppContext, VisualTestContext, Window, div, point, prelude::FluentBuilder as _, px,
934 rems,
935 };
936
937 struct TextViewTestRoot {
938 text_view: Entity<TextViewState>,
939 }
940
941 struct InlineHoverTestRoot {
942 view: Entity<TextViewState>,
943 builds: Arc<AtomicUsize>,
944 format: crate::text::SelectionFormat,
945 }
946
947 struct InlineHoverCard;
948 impl Render for InlineHoverCard {
949 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
950 div()
951 .debug_selector(|| "inline-test-card".into())
952 .w(px(120.))
953 .h(px(40.))
954 .child("Member profile")
955 }
956 }
957
958 impl Render for InlineHoverTestRoot {
959 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
960 let builds = self.builds.clone();
961 div()
962 .pl(px(150.))
963 .w(px(300.))
964 .text_size(px(16.))
965 .child(crate::TextSelectionLayer)
966 .child(
967 TextView::new(&self.view)
968 .selection_format(self.format)
969 .plugin(
970 crate::text::markdown_ext::TestInlinePlugin::new("mention")
971 .parse_with(|node, _| {
972 let markdown::mdast::Node::Link(link) = node else {
973 return None;
974 };
975 let handle = link.url.strip_prefix("mention:")?;
976 Some(
977 crate::text::MarkdownNode::new("mention", ())
978 .text(format!("@{handle}")),
979 )
980 })
981 .render_with(move |_, _, _, _| {
982 let builds = builds.clone();
983 Some(crate::text::InlineElement::new(
984 crate::HoverCard::new("mention-hover")
985 .anchor(gpui::Anchor::TopCenter)
986 .trigger(div().child("@member"))
987 .content(move |_, _, cx| {
988 builds.fetch_add(1, Ordering::Relaxed);
989 div()
990 .id("hover-content")
991 .child(cx.new(|_| InlineHoverCard))
992 }),
993 ))
994 }),
995 ),
996 )
997 }
998 }
999
1000 #[gpui::test]
1001 fn text_view_follows_the_text_color_of_its_container(cx: &mut TestAppContext) {
1002 struct Root {
1003 inherited: Entity<TextViewState>,
1004 explicit: Entity<TextViewState>,
1005 surface_text: gpui::Hsla,
1006 }
1007 impl Render for Root {
1008 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1009 div()
1010 .w(px(300.))
1011 .text_color(crate::ColorTokens::light().foreground)
1012 .child(
1013 div()
1014 .text_color(self.surface_text)
1015 .child(TextView::new(&self.inherited))
1016 .child(TextView::new(&self.explicit).style(TextViewStyle::default())),
1017 )
1018 }
1019 }
1020
1021 let colors = crate::ColorTokens::light();
1022 cx.update(|cx| {
1023 crate::init(cx);
1024 super::TextViewDefaults::new()
1025 .with_style(TextViewStyle::default())
1026 .with_inherit_text_color(true)
1027 .install(cx);
1028 });
1029 let (root, cx) = cx.add_window_view(|_, cx| Root {
1030 inherited: cx.new(|cx| TextViewState::markdown("[link](https://a.b) `code`", cx)),
1031 explicit: cx.new(|cx| TextViewState::markdown("text", cx)),
1032 surface_text: colors.primary_foreground,
1033 });
1034 cx.run_until_parked();
1035 cx.update(|window, cx| {
1036 window.draw(cx).clear(cx);
1037 });
1038
1039 root.read_with(cx, |root, cx| {
1040 let inherited = &root.inherited.read(cx).text_view_style;
1041 assert_eq!(inherited.foreground(), colors.primary_foreground);
1042 assert_eq!(inherited.link(), colors.primary_foreground);
1043 assert!(inherited.is_dark());
1044
1045 let explicit = &root.explicit.read(cx).text_view_style;
1046 assert_eq!(explicit.foreground(), colors.foreground);
1047 assert_eq!(explicit.link(), TextViewStyle::default().link());
1048 });
1049 }
1050
1051 #[gpui::test]
1052 fn inline_plugin_reuses_render_and_preserves_native_child_events(cx: &mut TestAppContext) {
1053 use std::sync::Mutex;
1054 struct ControlPlugin(Arc<Mutex<Vec<String>>>);
1055 impl crate::text::MarkdownPlugin for ControlPlugin {
1056 fn name(&self) -> &str {
1057 "control"
1058 }
1059 fn parse(
1060 &self,
1061 node: &markdown::mdast::Node,
1062 _: &crate::text::MarkdownParseContext<'_>,
1063 ) -> Option<crate::text::MarkdownNode> {
1064 let markdown::mdast::Node::Link(link) = node else {
1065 return None;
1066 };
1067 let label = link.url.strip_prefix("control:")?;
1068 Some(crate::text::MarkdownNode::new("control", ()).text(label.to_string()))
1069 }
1070 fn render(
1071 &self,
1072 node: &crate::text::MarkdownNode,
1073 _: &mut Window,
1074 _: &mut gpui::App,
1075 ) -> impl IntoElement {
1076 let clicks = self.0.clone();
1077 let label = node.as_text().to_string();
1078 div()
1079 .id("same-control-id")
1080 .w(px(60.))
1081 .h(px(24.))
1082 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1083 .on_click(move |_, _, _| clicks.lock().unwrap().push(label.clone()))
1084 .child(node.as_text().to_string())
1085 }
1086 }
1087 struct Root {
1088 view: Entity<TextViewState>,
1089 clicks: Arc<Mutex<Vec<String>>>,
1090 }
1091 impl Render for Root {
1092 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1093 div()
1094 .w(px(300.))
1095 .child(crate::TextSelectionLayer)
1096 .child(TextView::new(&self.view).plugin(ControlPlugin(self.clicks.clone())))
1097 }
1098 }
1099 cx.update(crate::init);
1100 let clicks = Arc::new(Mutex::new(Vec::new()));
1101 let captured = clicks.clone();
1102 let (root, cx) = cx.add_window_view(move |_, cx| Root {
1103 view: cx.new(|cx| TextViewState::markdown("[one](control:one)[two](control:two)", cx)),
1104 clicks,
1105 });
1106 cx.run_until_parked();
1107 cx.update(|window, cx| {
1108 window.draw(cx).clear(cx);
1109 });
1110 let bounds = root.read_with(cx, |root, cx| {
1111 root.view.read(cx).selection_adapter.text_bounds()
1112 });
1113 assert_eq!(bounds.len(), 2);
1114 for bounds in bounds {
1115 cx.simulate_click(bounds.center(), Modifiers::default());
1116 cx.update(|window, cx| {
1117 window.draw(cx).clear(cx);
1118 });
1119 }
1120 assert_eq!(*captured.lock().unwrap(), vec!["one", "two"]);
1121 }
1122
1123 #[gpui::test]
1124 fn inline_hover_card_is_lazy_and_retains_atomic_copy(cx: &mut TestAppContext) {
1125 cx.update(crate::init);
1126 let builds = Arc::new(AtomicUsize::new(0));
1127 let source = "[@member](mention:member)";
1128 let (root, cx) = cx.add_window_view(|_, cx| InlineHoverTestRoot {
1129 view: cx.new(|cx| TextViewState::markdown(source, cx)),
1130 builds: builds.clone(),
1131 format: crate::text::SelectionFormat::Plain,
1132 });
1133 cx.run_until_parked();
1134 cx.update(|window, cx| window.draw(cx).clear(cx));
1135 assert_eq!(builds.load(Ordering::Relaxed), 0);
1136 let view = root.read_with(cx, |root, _| root.view.clone());
1137 let bounds = view.read_with(cx, |view, _| view.selection_adapter.text_bounds()[0]);
1138 cx.update(|window, cx| {
1139 window.simulate_mouse_move(point(bounds.right() - px(1.), bounds.center().y), cx)
1140 });
1141 cx.run_until_parked();
1142 cx.executor()
1143 .advance_clock(std::time::Duration::from_secs(1));
1144 cx.run_until_parked();
1145 cx.update(|window, cx| window.draw(cx).clear(cx));
1146 assert!(
1147 builds.load(Ordering::Relaxed) > 0,
1148 "hover did not build the card"
1149 );
1150 cx.update(|window, cx| window.draw(cx).clear(cx));
1151 let card = cx
1152 .debug_bounds("inline-test-card")
1153 .expect("hover card should be painted");
1154 assert!(
1155 (card.center().x - bounds.center().x).abs() < px(1.),
1156 "card {card:?} must be centered on mention {bounds:?}"
1157 );
1158 assert!(
1159 card.top() >= bounds.bottom(),
1160 "card should be below the mention"
1161 );
1162 for (format, expected) in [
1163 (crate::text::SelectionFormat::Plain, "@member"),
1164 (crate::text::SelectionFormat::Source, source),
1165 ] {
1166 root.update(cx, |root, cx| {
1167 root.format = format;
1168 cx.notify();
1169 });
1170 view.update(cx, |view, cx| {
1171 view.set_selection_format(format, cx);
1172 view.select_all(cx);
1173 });
1174 cx.update(|window, cx| window.draw(cx).clear(cx));
1175 assert_eq!(
1176 view.read_with(cx, |view, _| view.selected_text()).trim(),
1177 expected
1178 );
1179 }
1180 }
1181
1182 struct InlinePluginTestRoot {
1183 text_view: Entity<TextViewState>,
1184 width: Pixels,
1185 font_size: Pixels,
1186 source_format: bool,
1187 prepared_size: Option<Arc<AtomicUsize>>,
1188 }
1189
1190 impl Render for InlinePluginTestRoot {
1191 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1192 let prepared_size = self.prepared_size.clone();
1193 div().w(self.width).text_size(self.font_size).child(crate::TextSelectionLayer).child(
1194 TextView::new(&self.text_view)
1195 .selection_format(if self.source_format { crate::text::SelectionFormat::Source }
1196 else { crate::text::SelectionFormat::Plain })
1197
1198 .plugin(crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(|node, _| {
1199 let markdown::mdast::Node::InlineMath(math) = node else { return None };
1200 Some(crate::text::MarkdownNode::new("math", ()).text(format!("{}²", math.value)))
1201 }).render_with(move |_, context, _, _| {
1202 let value = prepared_size.as_ref()?.load(Ordering::Relaxed) as f32;
1203 let image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Svg,
1204 b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><path d=\"M0 0L40 40\" stroke=\"black\"/></svg>".to_vec()));
1205 let unit = context.font_size() / 16.;
1206 Some(crate::text::InlineElement::new(gpui::img(image).w(unit * value).h(unit * value)).with_baseline(unit * value * 0.75))
1207 })))
1208 }
1209 }
1210
1211 #[gpui::test]
1212 fn inline_plugins_drag_and_copy_across_formulas_in_both_directions(cx: &mut TestAppContext) {
1213 cx.update(crate::init);
1214 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1215 text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1216 width: px(420.),
1217 font_size: px(16.),
1218 source_format: false,
1219 prepared_size: None,
1220 });
1221 let cx: &mut VisualTestContext = cx;
1222 for font_size in [16., 24., 32.] {
1223 for width in [60., 160., 420.] {
1224 for prepared in [None, Some(32)] {
1225 for source_format in [false, true] {
1226 root.update(cx, |root, cx| {
1227 root.source_format = source_format;
1228 root.width = px(width);
1229 root.font_size = px(font_size);
1230 root.prepared_size =
1231 prepared.map(|value| Arc::new(AtomicUsize::new(value)));
1232 cx.notify();
1233 });
1234 cx.run_until_parked();
1235 cx.update(|window, cx| window.draw(cx).clear(cx));
1236 let bounds =
1237 root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1238 let text_bounds = root.read_with(cx, |root, cx| {
1239 root.text_view.read(cx).selection_adapter.text_bounds()
1240 });
1241 let first = text_bounds.first().unwrap();
1242 let last = text_bounds.last().unwrap();
1243 let left =
1244 point(first.left() + px(0.1), first.top() + first.size.height / 2.);
1245 let right =
1246 point(last.right() - px(0.1), last.top() + last.size.height / 2.);
1247 for (start, end) in [(left, right), (right, left)] {
1248 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1249 cx.update(|window, cx| window.draw(cx).clear(cx));
1250 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1251 cx.update(|window, cx| window.draw(cx).clear(cx));
1252 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1253 cx.update(|window, cx| window.draw(cx).clear(cx));
1254 let selected = root
1255 .read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1256 assert_eq!(
1257 selected.trim(),
1258 if source_format {
1259 "中文 $x$ $y$ English"
1260 } else {
1261 "中文 x² y² English"
1262 },
1263 "start={start:?} end={end:?} bounds={bounds:?} width={width} font_size={font_size} prepared={prepared:?}"
1264 );
1265 }
1266 }
1267 }
1268 }
1269 }
1270 }
1271
1272 #[gpui::test]
1273 fn inline_resource_size_update_reflows_without_changing_selection(cx: &mut TestAppContext) {
1274 cx.update(crate::init);
1275 let prepared_size = Arc::new(AtomicUsize::new(16));
1276 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1277 text_view: cx.new(|cx| TextViewState::markdown("中文 $x$ $y$ English", cx)),
1278 width: px(160.),
1279 font_size: px(16.),
1280 source_format: false,
1281 prepared_size: Some(prepared_size.clone()),
1282 });
1283 let cx: &mut VisualTestContext = cx;
1284 cx.run_until_parked();
1285 cx.update(|window, cx| window.draw(cx).clear(cx));
1286 let before = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1287 let regions = root.read_with(cx, |root, cx| {
1288 root.text_view.read(cx).selection_adapter.text_bounds()
1289 });
1290 let first = regions.first().unwrap();
1291 let last = regions.last().unwrap();
1292 let start = point(first.left() + px(0.1), first.top() + px(10.));
1293 let end = point(last.right() - px(0.1), last.top() + px(10.));
1294 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1295 cx.update(|window, cx| window.draw(cx).clear(cx));
1296 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1297 cx.update(|window, cx| window.draw(cx).clear(cx));
1298 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1299 cx.update(|window, cx| window.draw(cx).clear(cx));
1300 assert_eq!(
1301 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1302 .trim(),
1303 "中文 x² y² English"
1304 );
1305 prepared_size.store(100, Ordering::Relaxed);
1306 root.update(cx, |root, cx| {
1307 root.text_view
1308 .update(cx, |state, cx| state.invalidate_inline_layout(cx))
1309 });
1310 cx.run_until_parked();
1311 cx.update(|window, cx| window.draw(cx).clear(cx));
1312 let after = root.read_with(cx, |root, cx| root.text_view.read(cx).bounds());
1313 assert!(after.size.height > before.size.height * 2.);
1314 assert_eq!(
1315 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1316 .trim(),
1317 "中文 x² y² English"
1318 );
1319 }
1320
1321 #[gpui::test]
1322 fn triple_click_on_formula_selects_its_entire_mixed_line(cx: &mut TestAppContext) {
1323 cx.update(crate::init);
1324 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1325 text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1326 width: px(420.),
1327 font_size: px(16.),
1328 source_format: false,
1329 prepared_size: None,
1330 });
1331 let cx: &mut VisualTestContext = cx;
1332 cx.run_until_parked();
1333 cx.update(|window, cx| window.draw(cx).clear(cx));
1334 let regions = root.read_with(cx, |root, cx| {
1335 root.text_view.read(cx).selection_adapter.text_bounds()
1336 });
1337 let formula = regions[1];
1338 let position = formula.center();
1339 cx.simulate_event(MouseDownEvent {
1340 position,
1341 modifiers: Modifiers::default(),
1342 button: MouseButton::Left,
1343 click_count: 3,
1344 first_mouse: false,
1345 });
1346 cx.simulate_event(MouseUpEvent {
1347 position,
1348 modifiers: Modifiers::default(),
1349 button: MouseButton::Left,
1350 click_count: 3,
1351 });
1352 cx.update(|window, cx| window.draw(cx).clear(cx));
1353 assert_eq!(
1354 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1355 .trim(),
1356 "before x² after"
1357 );
1358 root.update(cx, |root, cx| {
1359 root.source_format = true;
1360 cx.notify();
1361 });
1362 cx.update(|window, cx| window.draw(cx).clear(cx));
1363 assert_eq!(
1364 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1365 .trim(),
1366 "before $x$ after"
1367 );
1368 }
1369
1370 #[gpui::test]
1371 fn double_click_on_formula_selects_only_that_object(cx: &mut TestAppContext) {
1372 cx.update(crate::init);
1373 let (root, cx) = cx.add_window_view(|_, cx| InlinePluginTestRoot {
1374 text_view: cx.new(|cx| TextViewState::markdown("before $x$ after", cx)),
1375 width: px(420.),
1376 font_size: px(16.),
1377 source_format: false,
1378 prepared_size: None,
1379 });
1380 let cx: &mut VisualTestContext = cx;
1381 cx.run_until_parked();
1382 cx.update(|window, cx| window.draw(cx).clear(cx));
1383 let regions = root.read_with(cx, |root, cx| {
1384 root.text_view.read(cx).selection_adapter.text_bounds()
1385 });
1386 let position = regions[1].center();
1387 cx.simulate_event(MouseDownEvent {
1388 position,
1389 modifiers: Modifiers::default(),
1390 button: MouseButton::Left,
1391 click_count: 2,
1392 first_mouse: false,
1393 });
1394 cx.simulate_event(MouseUpEvent {
1395 position,
1396 modifiers: Modifiers::default(),
1397 button: MouseButton::Left,
1398 click_count: 2,
1399 });
1400 cx.update(|window, cx| window.draw(cx).clear(cx));
1401 assert_eq!(
1403 root.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
1404 .trim(),
1405 "x²"
1406 );
1407 }
1408
1409 struct ScrollExtentTestRoot {
1412 text_view: Entity<TextViewState>,
1413 }
1414
1415 impl Render for ScrollExtentTestRoot {
1416 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1417 div()
1418 .w(px(400.))
1419 .h(px(200.))
1420 .overflow_hidden()
1421 .child(TextView::new(&self.text_view).scrollable(true))
1422 }
1423 }
1424
1425 fn document_of(count: usize, words: usize) -> String {
1428 (1..=count)
1429 .map(|i| format!("Block {i}: {}", "lorem ipsum dolor ".repeat(words)))
1430 .collect::<Vec<_>>()
1431 .join("\n\n")
1432 }
1433
1434 #[gpui::test]
1440 fn replacing_a_document_with_an_equal_block_count_remeasures(cx: &mut TestAppContext) {
1441 cx.update(crate::init);
1442
1443 const BLOCKS: usize = 24;
1444 let short = document_of(BLOCKS, 1);
1445 let tall = document_of(BLOCKS, 60);
1446
1447 let (root, cx) = cx.add_window_view(|_, cx| ScrollExtentTestRoot {
1448 text_view: cx.new(|cx| TextViewState::markdown(&short, cx)),
1449 });
1450 let cx: &mut VisualTestContext = cx;
1451
1452 let settle = |cx: &mut VisualTestContext| {
1455 cx.run_until_parked();
1456 cx.update(|window, cx| window.draw(cx).clear(cx));
1457 cx.run_until_parked();
1458 };
1459 settle(cx);
1460
1461 let scroll_extent = |cx: &mut VisualTestContext| {
1462 root.read_with(cx, |root, cx| {
1463 root.text_view
1464 .read(cx)
1465 .list_state()
1466 .max_offset_for_scrollbar()
1467 .y
1468 })
1469 };
1470
1471 let short_extent = scroll_extent(cx);
1472
1473 root.update(cx, |root, cx| {
1474 root.text_view
1475 .update(cx, |state, cx| state.set_text(&tall, cx));
1476 });
1477 settle(cx);
1478
1479 root.read_with(cx, |root, cx| {
1480 assert_eq!(
1481 root.text_view.read(cx).list_state().item_count(),
1482 BLOCKS,
1483 "the replacement must keep the block count, or the list resets and the bug cannot occur"
1484 );
1485 });
1486
1487 let tall_extent = scroll_extent(cx);
1488 assert!(
1489 tall_extent > short_extent * 5.,
1490 "a much taller document must grow the scroll extent, but it went from \
1491 {short_extent:?} to {tall_extent:?}"
1492 );
1493 }
1494
1495 struct StatelessMarkdownRoot {
1496 renders: Arc<AtomicUsize>,
1497 }
1498
1499 impl Render for StatelessMarkdownRoot {
1500 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1501 self.renders.fetch_add(1, Ordering::Relaxed);
1502 div().child(
1503 TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
1504 .markdown_block_parser(|_, _| None),
1505 )
1506 }
1507 }
1508
1509 struct DummyTextViewPlugin;
1510
1511 impl TextViewPlugin for DummyTextViewPlugin {
1512 fn setup(self, mut text_view: TextView) -> TextView {
1513 text_view.selectable = true;
1514 text_view
1515 }
1516 }
1517
1518 #[gpui::test]
1519 fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
1520 cx.update(crate::init);
1521 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
1522
1523 assert!(TextView::new(&state).selectable);
1524 assert!(TextView::markdown("markdown", "text").selectable);
1525 assert!(TextView::html("html", "<p>text</p>").selectable);
1526 }
1527
1528 #[gpui::test]
1529 fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
1530 cx.update(crate::init);
1531 let renders = Arc::new(AtomicUsize::new(0));
1532 let (_, cx) = cx.add_window_view({
1533 let renders = renders.clone();
1534 move |_, _| StatelessMarkdownRoot { renders }
1535 });
1536 let cx: &mut VisualTestContext = cx;
1537
1538 cx.run_until_parked();
1539 cx.update(|window, cx| window.draw(cx).clear(cx));
1540 let renders_after_redraw = renders.load(Ordering::Relaxed);
1541 cx.run_until_parked();
1542 assert_eq!(
1543 renders.load(Ordering::Relaxed),
1544 renders_after_redraw,
1545 "an unchanged TextView must not schedule another render after its parser is rebuilt",
1546 );
1547 }
1548
1549 #[gpui::test]
1550 fn markdown_data_url_image_is_decoded_inline(cx: &mut TestAppContext) {
1551 use gpui::{Image, ImageFormat, ImageSource};
1552
1553 const PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
1555
1556 cx.update(crate::init);
1557 let markdown = format!("Inline  image");
1558 let (_, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new(&markdown, cx));
1559 let cx: &mut VisualTestContext = cx;
1560
1561 cx.run_until_parked();
1562 cx.update(|window, cx| window.draw(cx).clear(cx));
1563
1564 let bytes = data_url::DataUrl::process(&format!("data:image/png;base64,{PNG_BASE64}"))
1569 .unwrap()
1570 .decode_to_vec()
1571 .unwrap()
1572 .0;
1573 let image = Arc::new(Image::from_bytes(ImageFormat::Png, bytes));
1574 assert!(
1575 cx.update(|_, cx| ImageSource::Image(image).is_asset_cached(cx)),
1576 "the data URL image must be handed to GPUI as decoded bytes",
1577 );
1578 }
1579
1580 #[gpui::test]
1581 fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
1582 cx.update(crate::init);
1583 cx.update(|cx| {
1584 let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
1585 colors.primary = gpui::rgb(0x55aaff).into();
1586 colors.selection = gpui::rgb(0x335577).into();
1587 });
1588 let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
1589 let cx: &mut VisualTestContext = cx;
1590
1591 cx.run_until_parked();
1592 root.read_with(cx, |root, cx| {
1593 let style = &root.text_view.read(cx).text_view_style;
1594 assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
1595 assert_eq!(style.selection(), gpui::rgb(0x335577).into());
1596 });
1597 }
1598
1599 impl TextViewTestRoot {
1600 fn new(text: &str, cx: &mut Context<Self>) -> Self {
1601 let text = text.to_string();
1602 let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
1603 Self { text_view }
1604 }
1605 }
1606
1607 impl Render for TextViewTestRoot {
1608 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1609 div()
1610 .w(px(160.))
1611 .child(
1612 div()
1613 .h(px(24.))
1614 .overflow_hidden()
1615 .child(TextView::new(&self.text_view).selectable(true)),
1616 )
1617 .child(div().h(px(40.)).child("footer"))
1618 }
1619 }
1620
1621 struct TableSelectionTestRoot {
1622 text_view: Entity<TextViewState>,
1623 }
1624
1625 impl Render for TableSelectionTestRoot {
1626 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1627 div()
1628 .debug_selector(|| "table-selection-root".into())
1629 .w(px(520.))
1630 .child(crate::TextSelectionLayer)
1631 .child(TextView::new(&self.text_view))
1632 }
1633 }
1634
1635 #[gpui::test]
1636 fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
1637 cx.update(crate::init);
1638 let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
1639 text_view: cx.new(|cx| {
1640 TextViewState::markdown(
1641 "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
1642 cx,
1643 )
1644 }),
1645 });
1646 let cx: &mut VisualTestContext = cx;
1647
1648 cx.run_until_parked();
1649 let bounds = cx
1650 .debug_bounds("table-selection-root")
1651 .expect("table bounds");
1652 let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
1653 let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
1654 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1655 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
1656 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1657
1658 assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
1659 assert_eq!(
1660 cx.update(|window, cx| window.simulate_next_frame(cx)),
1661 0,
1662 "finished table selection must not continuously request frames"
1663 );
1664 }
1665
1666 struct InlineImageTextViewTestRoot {
1667 text_view: Entity<TextViewState>,
1668 }
1669
1670 impl InlineImageTextViewTestRoot {
1671 fn new(cx: &mut Context<Self>) -> Self {
1672 let text_view = cx.new(|cx| {
1673 TextViewState::markdown(
1674 "Build Status  after",
1675 cx,
1676 )
1677 });
1678 Self { text_view }
1679 }
1680 }
1681
1682 impl Render for InlineImageTextViewTestRoot {
1683 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1684 div()
1685 .w(px(420.))
1686 .child(TextView::new(&self.text_view).selectable(true))
1687 }
1688 }
1689
1690 #[gpui::test]
1691 fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
1692 cx.update(crate::init);
1693 let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
1694 let cx: &mut VisualTestContext = cx;
1695
1696 cx.run_until_parked();
1697 cx.update(|window, cx| {
1698 let _ = window.draw(cx);
1699 });
1700
1701 let inline_bounds = content.read_with(cx, |content, cx| {
1702 content.text_view.read(cx).selection_adapter.text_bounds()
1703 });
1704
1705 assert_eq!(inline_bounds.len(), 2);
1706 assert_eq!(
1707 inline_bounds[0].top(),
1708 inline_bounds[1].top(),
1709 "text before and after an inline image should share a rendered line"
1710 );
1711 assert!(
1712 inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
1713 "inline image should reserve horizontal space in the text layout"
1714 );
1715 assert!(
1716 inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
1717 "unloaded inline image fallback should stay generic and compact"
1718 );
1719 }
1720
1721 #[gpui::test]
1722 fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
1723 cx.update(crate::init);
1724 let (_, cx) = cx.add_window_view(|_, cx| {
1725 TextViewTestRoot::new(
1726 "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
1727 cx,
1728 )
1729 });
1730 let cx: &mut VisualTestContext = cx;
1731
1732 cx.run_until_parked();
1733 cx.update(|window, cx| {
1734 let _ = window.draw(cx);
1735 });
1736 }
1737
1738 #[gpui::test]
1739 fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
1740 struct ListItemBlockRoot;
1741
1742 impl Render for ListItemBlockRoot {
1743 fn render(
1744 &mut self,
1745 _window: &mut Window,
1746 _cx: &mut Context<Self>,
1747 ) -> impl IntoElement {
1748 div().w(px(840.)).h(px(400.)).child(
1749 crate::h_resizable("markdown-width-test")
1750 .child(crate::resizable_panel().child(div()))
1751 .child(crate::resizable_panel().child(
1752 TextView::markdown(
1753 "list-with-code",
1754 "1. List item\n ```rust\n nested code\n ```\n\n```rust\ntop-level code\n```",
1755 )
1756 .code_block_actions(|code_block, _, _| {
1757 let selector = if code_block.code().contains("nested") {
1758 "nested-code-action"
1759 } else {
1760 "top-level-code-action"
1761 };
1762 div()
1763 .debug_selector(move || selector.into())
1764 .child("Copy")
1765 })
1766 .scrollable(true)
1767 .p_5()
1768 .flex_none(),
1769 )),
1770 )
1771 }
1772 }
1773
1774 cx.update(crate::init);
1775 let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
1776 let cx: &mut VisualTestContext = cx;
1777
1778 cx.run_until_parked();
1779 cx.update(|window, cx| {
1780 let _ = window.draw(cx);
1781 });
1782
1783 let nested_action = cx.debug_bounds("nested-code-action").unwrap();
1784 let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
1785 assert!(
1786 top_level_action.right() - nested_action.right() < px(32.),
1787 "nested code block should fill the list item's available width"
1788 );
1789 }
1790
1791 const CODE_HEIGHT: Pixels = px(60.);
1793
1794 struct ScrollingCodeRoot {
1795 text_view: Entity<TextViewState>,
1796 actions: bool,
1797 }
1798
1799 impl Render for ScrollingCodeRoot {
1800 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1801 let mut code_block = StyleRefinement::default().max_h(CODE_HEIGHT);
1802 code_block.overflow.y = Some(Overflow::Scroll);
1803
1804 div().w(px(320.)).h(px(200.)).child(
1805 TextView::new(&self.text_view)
1806 .style(TextViewStyle::default().with_code_block(code_block))
1807 .when(self.actions, |this| {
1808 this.code_block_actions(|_, _, _| {
1809 div().debug_selector(|| "code-action".into()).child("Copy")
1810 })
1811 })
1812 .scrollable(true),
1813 )
1814 }
1815 }
1816
1817 #[gpui::test]
1818 fn scrolling_code_block_keeps_wheel_from_the_list(cx: &mut TestAppContext) {
1819 cx.update(crate::init);
1820 let code = (0..50).map(|i| format!("line {i}\n")).collect::<String>();
1821 let tail = "Paragraph after the code.\n\n".repeat(20);
1822 let markdown = format!("```\n{code}```\n\n{tail}");
1823
1824 for actions in [false, true] {
1827 let (root, cx) = cx.add_window_view(|_, cx| ScrollingCodeRoot {
1828 text_view: cx.new(|cx| TextViewState::markdown(&markdown, cx)),
1829 actions,
1830 });
1831 let cx: &mut VisualTestContext = cx;
1832 let draw = |cx: &mut VisualTestContext| {
1833 cx.run_until_parked();
1834 cx.update(|window, cx| window.draw(cx).clear(cx));
1835 };
1836 let step = px(20.);
1837 let wheel_at = |cx: &mut VisualTestContext, y: Pixels| {
1838 cx.simulate_event(gpui::ScrollWheelEvent {
1839 position: point(px(20.), y),
1840 delta: gpui::ScrollDelta::Pixels(point(px(0.), -step)),
1841 ..Default::default()
1842 });
1843 };
1844 let edges = |cx: &mut VisualTestContext| {
1848 let lines = root.read_with(cx, |root, cx| {
1849 root.text_view.read(cx).selection_adapter.text_bounds()
1850 });
1851 let paragraph = lines.iter().find(|line| line.top() >= CODE_HEIGHT).unwrap();
1852 (lines[0].bottom(), paragraph.top())
1853 };
1854 draw(cx);
1855 let (code_bottom, paragraph_top) = edges(cx);
1856 let action = cx.debug_bounds("code-action");
1857
1858 wheel_at(cx, CODE_HEIGHT / 2.);
1861 draw(cx);
1862 assert_eq!(
1863 edges(cx),
1864 (code_bottom - step, paragraph_top),
1865 "actions: {actions}"
1866 );
1867 assert_eq!(cx.debug_bounds("code-action"), action);
1868
1869 wheel_at(cx, CODE_HEIGHT * 2.5);
1873 draw(cx);
1874 assert_ne!(edges(cx).1, paragraph_top, "actions: {actions}");
1875 }
1876 }
1877
1878 fn draw_table_with_actions(
1882 cx: &mut TestAppContext,
1883 scroll: bool,
1884 ) -> (Bounds<Pixels>, TableData) {
1885 use std::sync::{Arc, Mutex};
1886
1887 struct TableRoot {
1888 scroll: bool,
1889 captured: Arc<Mutex<Vec<TableData>>>,
1890 }
1891
1892 impl Render for TableRoot {
1893 fn render(
1894 &mut self,
1895 _window: &mut Window,
1896 _cx: &mut Context<Self>,
1897 ) -> impl IntoElement {
1898 let captured = self.captured.clone();
1899 let mut table_style = StyleRefinement::default();
1900 if self.scroll {
1901 table_style.overflow.x = Some(Overflow::Scroll);
1902 }
1903
1904 div().w(px(320.)).child(
1905 TextView::markdown(
1906 "table-actions",
1907 "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
1908 )
1909 .style(TextViewStyle::default().with_table(table_style))
1910 .table_actions(move |table, _, _| {
1911 if let Ok(mut captured) = captured.lock() {
1912 captured.push(table.clone());
1913 }
1914 div().debug_selector(|| "table-action".into()).child("Copy")
1915 }),
1916 )
1917 }
1918 }
1919
1920 cx.update(crate::init);
1921 let captured = Arc::new(Mutex::new(Vec::new()));
1922 let (_, cx) = cx.add_window_view({
1923 let captured = captured.clone();
1924 move |_, _| TableRoot { scroll, captured }
1925 });
1926 let cx: &mut VisualTestContext = cx;
1927
1928 cx.run_until_parked();
1929 cx.update(|window, cx| {
1930 let _ = window.draw(cx);
1931 });
1932
1933 let bounds = cx
1934 .debug_bounds("table-action")
1935 .expect("table actions should be painted");
1936 let data = captured
1937 .lock()
1938 .expect("captured table data")
1939 .last()
1940 .cloned()
1941 .expect("table actions hook should receive the table");
1942
1943 (bounds, data)
1944 }
1945
1946 #[gpui::test]
1947 fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
1948 for scroll in [false, true] {
1949 let (bounds, data) = draw_table_with_actions(cx, scroll);
1950
1951 assert!(
1953 bounds.top() > px(40.),
1954 "actions should sit below the table (scroll: {scroll}), got {:?}",
1955 bounds.top()
1956 );
1957 assert_eq!(data.headers, vec!["Name", "Age"]);
1958 assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
1959 assert_eq!(
1960 data.markdown,
1961 "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
1962 );
1963 assert_eq!(data.span, Some(0..52));
1964 }
1965 }
1966
1967 #[test]
1968 fn plugin_accepts_text_view_plugins_beyond_markdown() {
1969 let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
1970
1971 assert!(view.selectable);
1972 }
1973
1974 #[test]
1975 fn syntax_highlighting_is_opt_in() {
1976 let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
1977 assert!(default_view.code_block_highlighter.is_none());
1978
1979 let view = default_view.code_block_highlighter(|block| {
1980 vec![(
1981 0..block.code().len(),
1982 gpui::HighlightStyle {
1983 color: Some(gpui::rgb(0x3366ff).into()),
1984 ..Default::default()
1985 },
1986 )]
1987 });
1988 assert!(view.code_block_highlighter.is_some());
1989 }
1990
1991 #[gpui::test]
1992 fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
1993 cx.update(crate::init);
1994 let (_, cx) = cx.add_window_view(|_, cx| {
1995 TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
1996 });
1997 let cx: &mut VisualTestContext = cx;
1998
1999 cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
2000
2001 assert_eq!(cx.opened_url(), None);
2002 }
2003
2004 struct MaxLinesTestRoot {
2005 text_view: Entity<TextViewState>,
2006 max_lines: usize,
2007 }
2008
2009 impl MaxLinesTestRoot {
2010 fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
2011 let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
2012 Self {
2013 text_view,
2014 max_lines,
2015 }
2016 }
2017 }
2018
2019 impl Render for MaxLinesTestRoot {
2020 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2021 div()
2022 .w(px(200.))
2023 .child(TextView::new(&self.text_view).max_lines(self.max_lines))
2024 }
2025 }
2026
2027 #[test]
2028 fn the_clip_only_moves_for_a_straddling_glyph_line() {
2029 use super::line_safe_clip_bottom;
2030 use crate::text::state::LineSpan;
2031
2032 let spans = [
2033 LineSpan {
2035 top: px(0.),
2036 bottom: px(60.),
2037 line_height: px(20.),
2038 },
2039 LineSpan {
2041 top: px(68.),
2042 bottom: px(128.),
2043 line_height: px(20.),
2044 },
2045 ];
2046
2047 let below = px(400.);
2049
2050 assert_eq!(
2052 line_safe_clip_bottom(&spans, px(100.), below),
2053 Some(px(88.))
2054 );
2055
2056 assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
2058
2059 assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
2062
2063 let one_block = [LineSpan {
2066 top: px(0.),
2067 bottom: px(60.),
2068 line_height: px(20.),
2069 }];
2070 assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
2071
2072 assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
2075 }
2076
2077 #[test]
2078 fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
2079 use super::line_safe_clip_bottom;
2080 use crate::text::state::LineSpan;
2081
2082 let heading = [LineSpan {
2084 top: px(70.),
2085 bottom: px(98.),
2086 line_height: px(28.),
2087 }];
2088
2089 assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
2090 }
2091
2092 #[test]
2093 fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
2094 use super::line_safe_clip_bottom;
2095 use crate::text::state::LineSpan;
2096
2097 let rows = [
2100 LineSpan {
2101 top: px(100.),
2102 bottom: px(126.),
2103 line_height: px(26.),
2104 },
2105 LineSpan {
2106 top: px(135.),
2107 bottom: px(161.),
2108 line_height: px(26.),
2109 },
2110 ];
2111
2112 assert_eq!(
2115 line_safe_clip_bottom(&rows, px(148.), px(400.)),
2116 Some(px(126.))
2117 );
2118 }
2119
2120 struct ClampedPageRoot {
2126 text_view: Entity<TextViewState>,
2127 max_lines: usize,
2128 }
2129
2130 impl Render for ClampedPageRoot {
2131 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2132 use crate::{h_flex, v_flex};
2133
2134 v_flex()
2135 .size_full()
2136 .p_4()
2137 .gap_4()
2138 .child(h_flex().max_w(px(480.)).gap_3().child("header"))
2139 .child(
2140 v_flex()
2141 .flex_1()
2142 .min_h_0()
2143 .gap_4()
2144 .id("clamped-page-scroll")
2145 .child(
2146 v_flex()
2147 .max_w(px(480.))
2148 .p_3()
2149 .gap_2()
2150 .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
2151 )
2152 .overflow_y_scroll(),
2153 )
2154 }
2155 }
2156
2157 #[gpui::test]
2158 fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
2159 cx.update(crate::init);
2160 let (root, cx) = cx.add_window_view(|_, cx| {
2161 let text_view = cx.new(|cx| {
2162 TextViewState::markdown(
2163 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
2164 cx,
2165 )
2166 });
2167 ClampedPageRoot {
2168 text_view,
2169 max_lines: 3,
2170 }
2171 });
2172 let cx: &mut VisualTestContext = cx;
2173
2174 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2175 }
2176
2177 #[gpui::test]
2178 fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
2179 cx.update(crate::init);
2180 let (root, cx) = cx.add_window_view(|_, cx| {
2181 MaxLinesTestRoot::new(
2182 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
2183 2,
2184 cx,
2185 )
2186 });
2187 let cx: &mut VisualTestContext = cx;
2188
2189 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2190 }
2191
2192 #[gpui::test]
2193 fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
2194 cx.update(crate::init);
2195 let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
2196 let cx: &mut VisualTestContext = cx;
2197
2198 assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
2199 }
2200
2201 #[gpui::test]
2202 fn heading_refinement_changes_rendered_heading_geometry(cx: &mut TestAppContext) {
2203 struct HeadingStyleRoot;
2204
2205 impl Render for HeadingStyleRoot {
2206 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2207 div()
2208 .flex()
2209 .items_start()
2210 .child(
2211 div()
2212 .debug_selector(|| "default-h1".into())
2213 .child(TextView::markdown("default-h1-view", "# Heading")),
2214 )
2215 .child(div().debug_selector(|| "custom-h1".into()).child(
2216 TextView::markdown("custom-heading-view", "# Heading").style(
2217 TextViewStyle::default().with_heading(|level| {
2218 if level == 1 {
2219 StyleRefinement::default().pb(rems(2.))
2220 } else {
2221 StyleRefinement::default()
2222 }
2223 }),
2224 ),
2225 ))
2226 .child(
2227 div()
2228 .debug_selector(|| "default-h2".into())
2229 .child(TextView::markdown("default-h2-view", "## Heading")),
2230 )
2231 .child(div().debug_selector(|| "custom-h2".into()).child(
2232 TextView::markdown("custom-h2-view", "## Heading").style(
2233 TextViewStyle::default().with_heading(|level| {
2234 if level == 1 {
2235 StyleRefinement::default().pb(rems(2.))
2236 } else {
2237 StyleRefinement::default()
2238 }
2239 }),
2240 ),
2241 ))
2242 }
2243 }
2244
2245 cx.update(crate::init);
2246 let (_, cx) = cx.add_window_view(|_, _| HeadingStyleRoot);
2247 cx.update(|window, cx| window.draw(cx).clear(cx));
2248
2249 let default_h1 = cx.debug_bounds("default-h1").unwrap();
2250 let custom_h1 = cx.debug_bounds("custom-h1").unwrap();
2251 let default_h2 = cx.debug_bounds("default-h2").unwrap();
2252 let custom_h2 = cx.debug_bounds("custom-h2").unwrap();
2253 assert!(custom_h1.size.height > default_h1.size.height);
2254 assert_eq!(custom_h2.size.height, default_h2.size.height);
2255 }
2256
2257 #[gpui::test]
2258 fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
2259 cx.update(crate::init);
2260 let (_, cx) = cx.add_window_view(|_, cx| {
2261 MaxLinesTestRoot::new(
2262 "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
2263 2,
2264 cx,
2265 )
2266 });
2267 let cx: &mut VisualTestContext = cx;
2268
2269 cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
2271
2272 assert_eq!(cx.opened_url(), None);
2273 }
2274
2275 #[gpui::test]
2276 fn scaled_inline_code_keeps_links_and_drag_selection(cx: &mut TestAppContext) {
2277 struct SelectionRoot {
2278 text_view: Entity<TextViewState>,
2279 format: crate::text::SelectionFormat,
2280 }
2281 impl Render for SelectionRoot {
2282 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2283 div()
2284 .w(px(160.))
2285 .child(crate::TextSelectionLayer)
2286 .child(TextView::new(&self.text_view).selection_format(self.format))
2287 }
2288 }
2289 cx.update(crate::init);
2290 let (view, cx) = cx.add_window_view(|_, cx| SelectionRoot {
2291 format: crate::text::SelectionFormat::Plain,
2292 text_view: cx
2293 .new(|cx| TextViewState::markdown("[`code`](https://example.com) after", cx)),
2294 });
2295 let cx: &mut VisualTestContext = cx;
2296 cx.run_until_parked();
2297 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2298 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2299 cx.simulate_mouse_down(
2301 point(px(5.), px(8.)),
2302 MouseButton::Left,
2303 Modifiers::default(),
2304 );
2305 cx.update(|window, cx| {
2306 let _ = window.draw(cx);
2307 });
2308 cx.simulate_mouse_move(
2309 point(px(155.), px(20.)),
2310 Some(MouseButton::Left),
2311 Modifiers::default(),
2312 );
2313 cx.update(|window, cx| {
2314 let _ = window.draw(cx);
2315 });
2316 cx.simulate_mouse_up(
2317 point(px(155.), px(20.)),
2318 MouseButton::Left,
2319 Modifiers::default(),
2320 );
2321 cx.update(|window, cx| {
2322 let _ = window.draw(cx);
2323 });
2324 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2325 assert_eq!(selected.trim(), "code after");
2326 view.update(cx, |view, cx| {
2327 view.format = crate::text::SelectionFormat::Source;
2328 cx.notify();
2329 });
2330 cx.update(|window, cx| {
2331 let _ = window.draw(cx);
2332 });
2333 let selected = view.read_with(cx, |view, cx| view.text_view.read(cx).selected_text());
2334 assert_eq!(selected.trim(), "[`code`](https://example.com) after");
2335 }
2336
2337 #[test]
2347 fn inline_code_heading_reserves_painted_wrapped_lines_and_list_baseline() {
2348 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2349 use gpui::{TestApp, rems};
2350
2351 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";
2352
2353 struct MarkdownRoot {
2354 text_view: Entity<TextViewState>,
2355 width: Pixels,
2356 preview_zoom: f32,
2357 }
2358
2359 impl Render for MarkdownRoot {
2360 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2361 div()
2362 .w(self.width)
2363 .text_size(rems(self.preview_zoom))
2366 .child(crate::TextSelectionLayer)
2367 .child(TextView::new(&self.text_view).selectable(true))
2368 }
2369 }
2370
2371 fn draw(
2372 app: &mut TestApp,
2373 width: Pixels,
2374 preview_zoom: f32,
2375 ) -> (Bounds<Pixels>, Vec<Bounds<Pixels>>, String) {
2376 let mut window = app.open_window(|_, cx| MarkdownRoot {
2377 text_view: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
2378 width,
2379 preview_zoom,
2380 });
2381 window.draw();
2382 app.run_until_parked();
2383 window.draw();
2384 window.update(|root, _, cx| {
2385 root.text_view.update(cx, |state, cx| state.select_all(cx));
2386 });
2387 window.draw();
2388 window.read(|root, cx| {
2389 let state = root.text_view.read(cx);
2390 (
2391 state.bounds(),
2392 state.selection_adapter.text_bounds(),
2393 state.selected_text(),
2394 )
2395 })
2396 }
2397
2398 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2399 app.update(|cx| {
2400 crate::init(cx);
2401 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2402 });
2403
2404 for (case, width, preview_zoom) in [
2405 ("default-16px", px(600.), 1.),
2408 ("narrow-16px", px(320.), 1.),
2410 ("zoom-1.25", px(600.), 1.25),
2412 ] {
2413 let (view_bounds, text_bounds, selected_text) = draw(&mut app, width, preview_zoom);
2414 assert!(
2415 text_bounds.len() > 8,
2416 "{case}: expected all markdown fragments"
2417 );
2418 assert_eq!(
2419 selected_text.trim(),
2420 "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",
2421 "{case}: wrapped inline-code text was lost or duplicated"
2422 );
2423 let following = text_bounds.last().expect("following paragraph must paint");
2424 let painted_bottom = text_bounds
2425 .iter()
2426 .map(|bounds| bounds.bottom())
2427 .max()
2428 .expect("markdown must paint text");
2429 assert!(
2435 text_bounds[..text_bounds.len() - 1]
2436 .iter()
2437 .all(|bounds| bounds.bottom() <= following.top()),
2438 "{case}: heading/list text painted through the following paragraph; following={following:?}, text_bounds={text_bounds:?}, view={view_bounds:?}"
2439 );
2440 assert!(
2441 painted_bottom <= view_bounds.bottom(),
2442 "{case}: TextView height did not reserve its painted text; painted_bottom={painted_bottom:?}, view={view_bounds:?}"
2443 );
2444 }
2445 }
2446
2447 #[test]
2448 fn ordered_markdown_list_start_reaches_layout_marker() {
2449 use crate::text::inline::test_fonts::{WideMonoTextSystem, record_shaped_lines};
2450 use gpui::TestApp;
2451
2452 struct MarkdownRoot {
2453 text_view: Entity<TextViewState>,
2454 }
2455
2456 impl Render for MarkdownRoot {
2457 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2458 div().w(px(400.)).child(TextView::new(&self.text_view))
2459 }
2460 }
2461
2462 #[derive(Clone, Copy)]
2463 enum Format {
2464 Markdown,
2465 Html,
2466 }
2467
2468 fn shaped_markers(format: Format, source: &str) -> Vec<String> {
2469 let (_, shaped_lines) = record_shaped_lines(|| {
2470 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2471 app.update(crate::init);
2472 let mut window = app.open_window(|_, cx| MarkdownRoot {
2473 text_view: cx.new(|cx| match format {
2474 Format::Markdown => TextViewState::markdown(source, cx),
2475 Format::Html => TextViewState::html(source, cx),
2476 }),
2477 });
2478 window.draw();
2479 app.run_until_parked();
2480 window.draw();
2481 });
2482
2483 let mut markers = shaped_lines
2484 .into_iter()
2485 .filter(|line| line.len() <= 3 && line.ends_with('.'))
2486 .collect::<Vec<_>>();
2487 markers.dedup();
2488 markers
2489 }
2490
2491 let starts_at_one = "1. one\n2. two";
2492 assert_eq!(
2493 shaped_markers(Format::Markdown, starts_at_one),
2494 ["1.", "2."]
2495 );
2496 assert_eq!(
2497 shaped_markers(Format::Html, "<ol><li>one</li><li>two</li></ol>"),
2498 ["1.", "2."]
2499 );
2500
2501 assert_eq!(
2502 shaped_markers(Format::Markdown, "3. hello\n4. world"),
2503 ["3.", "4."]
2504 );
2505
2506 let nested_starts_at_four = "1. outer\n\n 4. nested\n 5. again";
2507 assert_eq!(
2508 shaped_markers(Format::Markdown, nested_starts_at_four),
2509 ["1.", "D.", "E."]
2510 );
2511
2512 let nested_starts_at_zero = "1. outer\n\n 0. zero";
2513 assert_eq!(
2514 shaped_markers(Format::Markdown, nested_starts_at_zero),
2515 ["1.", "0."]
2516 );
2517 }
2518
2519 #[test]
2524 fn inline_code_line_is_as_tall_as_a_plain_line() {
2525 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2526 use gpui::{TestApp, rems};
2527
2528 struct LineRoot {
2529 plain: Entity<TextViewState>,
2530 code: Entity<TextViewState>,
2531 preview_zoom: f32,
2532 }
2533
2534 impl Render for LineRoot {
2535 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2536 div()
2537 .w(px(600.))
2538 .text_size(rems(self.preview_zoom))
2539 .child(TextView::new(&self.plain))
2540 .child(TextView::new(&self.code))
2541 }
2542 }
2543
2544 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2545 app.update(|cx| {
2546 crate::init(cx);
2547 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2548 });
2549 for scale_factor in [1.6, 2.] {
2550 for preview_zoom in [1., 1.25] {
2551 let mut window = app.open_window(|_, cx| LineRoot {
2552 plain: cx.new(|cx| TextViewState::markdown("plain body words", cx)),
2553 code: cx.new(|cx| TextViewState::markdown("plain `code` words", cx)),
2554 preview_zoom,
2555 });
2556 window.simulate_scale_factor_change(scale_factor);
2557 window.draw();
2558 app.run_until_parked();
2559 window.draw();
2560 let (plain, code) = window.read(|root, cx| {
2561 (
2562 root.plain.read(cx).bounds().size.height,
2563 root.code.read(cx).bounds().size.height,
2564 )
2565 });
2566 assert_eq!(code, plain, "scale {scale_factor}, zoom {preview_zoom}");
2567 }
2568 }
2569 }
2570
2571 #[test]
2575 fn inline_code_list_body_paint_origin_matches_plain_list_item() {
2576 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2577 use gpui::{TestApp, rems};
2578
2579 struct ListRoot {
2580 plain: Entity<TextViewState>,
2581 code: Entity<TextViewState>,
2582 preview_zoom: f32,
2583 }
2584
2585 impl Render for ListRoot {
2586 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2587 div()
2588 .w(px(600.))
2589 .text_size(rems(self.preview_zoom))
2590 .child(crate::TextSelectionLayer)
2591 .child(TextView::new(&self.plain).selectable(true))
2592 .child(TextView::new(&self.code).selectable(true))
2593 }
2594 }
2595
2596 fn draw(app: &mut TestApp, preview_zoom: f32) -> (Pixels, Pixels) {
2597 let mut window = app.open_window(|_, cx| ListRoot {
2598 plain: cx.new(|cx| TextViewState::markdown("- plain body words", cx)),
2599 code: cx.new(|cx| TextViewState::markdown("- plain `code` words", cx)),
2600 preview_zoom,
2601 });
2602 window.draw();
2603 app.run_until_parked();
2604 window.draw();
2605 window.read(|root, cx| {
2606 let first_body_line_top = |view: &Entity<TextViewState>| {
2607 let view = view.read(cx);
2608 let painted_line = view
2609 .selection_adapter
2610 .text_bounds()
2611 .into_iter()
2612 .next()
2613 .expect("list item should paint its first body line");
2614 painted_line.top() - view.bounds().top()
2615 };
2616 (
2617 first_body_line_top(&root.plain),
2618 first_body_line_top(&root.code),
2619 )
2620 })
2621 }
2622
2623 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2624 app.update(|cx| {
2625 crate::init(cx);
2626 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2627 });
2628 for (case, preview_zoom) in [("16px", 1.), ("zoom-1.25", 1.25), ("zoom-1.5", 1.5)] {
2629 let (plain_origin, code_origin) = draw(&mut app, preview_zoom);
2630 assert_eq!(
2631 code_origin, plain_origin,
2632 "{case}: code list body paint origin {code_origin:?} must match plain {plain_origin:?}"
2633 );
2634 }
2635 }
2636
2637 #[test]
2638 fn inline_code_fragment_does_not_paint_past_its_reserved_row() {
2639 use crate::text::inline::test_fonts::{MONO, WideMonoTextSystem};
2640 use gpui::TestApp;
2641
2642 const TEXT_BACKGROUND: u32 = 0x20f0b0;
2643
2644 struct MarkdownRoot {
2645 text_view: Entity<TextViewState>,
2646 }
2647
2648 impl Render for MarkdownRoot {
2649 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2650 div()
2651 .w(px(640.))
2652 .text_size(px(17.9))
2653 .text_bg(gpui::rgb(TEXT_BACKGROUND))
2654 .child(TextView::new(&self.text_view))
2655 }
2656 }
2657
2658 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2659 app.update(|cx| {
2660 crate::init(cx);
2661 crate::Theme::global_mut(cx).tokens.typography.mono = MONO.into();
2662 });
2663 let mut window = app.open_window(|_, cx| MarkdownRoot {
2664 text_view: cx.new(|cx| TextViewState::markdown("`main` starts the paragraph", cx)),
2665 });
2666 window.draw();
2667 app.run_until_parked();
2668 window.draw();
2669
2670 let (view_bounds, painted) = window.update(|root, window, cx| {
2671 let view_bounds = root
2672 .text_view
2673 .read(cx)
2674 .bounds()
2675 .scale(window.scale_factor());
2676 let text_background: gpui::Background = gpui::rgb(TEXT_BACKGROUND).into();
2677 let painted = window
2678 .painted_quads()
2679 .into_iter()
2680 .filter(|quad| quad.background == text_background)
2681 .map(|quad| quad.bounds)
2682 .collect::<Vec<_>>();
2683 (view_bounds, painted)
2684 });
2685
2686 assert!(
2687 !painted.is_empty(),
2688 "the inherited text background must make actual text paint observable"
2689 );
2690 assert!(
2691 painted
2692 .iter()
2693 .all(|bounds| bounds.bottom() <= view_bounds.bottom()),
2694 "an inline-code fragment wrapped a second time after InlineFlow reserved one row; \
2695 text background quads={painted:?}, reserved TextView bounds={view_bounds:?}"
2696 );
2697 }
2698
2699 #[test]
2700 fn table_row_backgrounds_follow_the_frame_corner_radius() {
2701 use gpui::TestApp;
2702
2703 const HEAD_BACKGROUND: u32 = 0x11aa77;
2704 const RADIUS: f32 = 8.;
2705
2706 struct RoundedTableRoot {
2707 text_view: Entity<TextViewState>,
2708 scroll: bool,
2709 }
2710
2711 impl Render for RoundedTableRoot {
2712 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2713 let mut table = StyleRefinement::default();
2714 table.corner_radii.top_left = Some(px(RADIUS).into());
2715 table.corner_radii.top_right = Some(px(RADIUS).into());
2716 table.corner_radii.bottom_left = Some(px(RADIUS).into());
2717 table.corner_radii.bottom_right = Some(px(RADIUS).into());
2718 if self.scroll {
2719 table.overflow.x = Some(gpui::Overflow::Scroll);
2720 }
2721 let style = TextViewStyle::default()
2722 .with_code_background(gpui::rgb(HEAD_BACKGROUND).into())
2723 .with_table(table);
2724 div()
2725 .w(px(400.))
2726 .child(TextView::new(&self.text_view).style(style))
2727 }
2728 }
2729
2730 for scroll in [false, true] {
2731 let mode = if scroll { "scroll" } else { "wrap" };
2732 let mut app = TestApp::new();
2733 app.update(crate::init);
2734 let mut window = app.open_window(|_, cx| RoundedTableRoot {
2735 text_view: cx
2736 .new(|cx| TextViewState::markdown("| a | b |\n| --- | --- |\n| 1 | 2 |", cx)),
2737 scroll,
2738 });
2739 window.draw();
2740 app.run_until_parked();
2741 window.draw();
2742
2743 let (quad, scale) = window.update(|_, window, _| {
2744 let head: gpui::Background = gpui::rgb(HEAD_BACKGROUND).into();
2745 let quad = window
2746 .painted_quads()
2747 .into_iter()
2748 .find(|quad| quad.background == head)
2749 .unwrap_or_else(|| panic!("{mode}: the header background quad is painted"));
2750 (quad, window.scale_factor())
2751 });
2752
2753 let expected = px(RADIUS - 1.).scale(scale);
2756 assert_eq!(quad.corner_radii.top_left, expected, "{mode}: top left");
2757 assert_eq!(quad.corner_radii.top_right, expected, "{mode}: top right");
2758 let square = px(0.).scale(scale);
2759 assert_eq!(quad.corner_radii.bottom_left, square, "{mode}: bottom left");
2760 assert_eq!(
2761 quad.corner_radii.bottom_right, square,
2762 "{mode}: bottom right"
2763 );
2764 }
2765 }
2766
2767 #[test]
2773 fn table_keeps_narrow_columns_on_one_line_while_wide_ones_wrap() {
2774 use crate::text::inline::test_fonts::WideMonoTextSystem;
2775 use gpui::TestApp;
2776 use std::sync::Arc;
2777
2778 const CELL_BACKGROUND: u32 = 0x11aa77;
2779
2780 struct TableRoot {
2781 text_view: Entity<TextViewState>,
2782 width: Pixels,
2783 style: TextViewStyle,
2784 }
2785
2786 impl Render for TableRoot {
2787 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2788 div()
2789 .w(self.width)
2790 .child(TextView::new(&self.text_view).style(self.style.clone()))
2791 }
2792 }
2793
2794 let short = "Operating revenue (USD)";
2795 let source = format!(
2796 "| {short} | Note |\n| --- | --- |\n| {short} | x |\n| 1 | {} |",
2797 "word ".repeat(30)
2798 );
2799 let first_column = |width: f32, style: TextViewStyle| {
2801 let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem));
2802 app.update(crate::init);
2803 let cell = style.table_cell().clone().bg(gpui::rgb(CELL_BACKGROUND));
2804 let style = style.with_table_cell(cell);
2805 let mut window = app.open_window(|_, cx| TableRoot {
2806 text_view: cx.new(|cx| TextViewState::markdown(&source, cx)),
2807 width: px(width),
2808 style,
2809 });
2810 window.draw();
2811 app.run_until_parked();
2812 window.draw();
2813 window.update(|_, window, _| {
2814 let background: gpui::Background = gpui::rgb(CELL_BACKGROUND).into();
2815 let cells: Vec<_> = window
2816 .painted_quads()
2817 .into_iter()
2818 .filter(|quad| quad.background == background)
2819 .map(|quad| quad.bounds)
2820 .collect();
2821 let left = cells.iter().map(|bounds| bounds.origin.x).min().unwrap();
2822 let mut column: Vec<_> = cells
2823 .into_iter()
2824 .filter(|bounds| bounds.origin.x == left)
2825 .collect();
2826 column.sort_by_key(|bounds| bounds.origin.y);
2827 column
2828 .iter()
2829 .map(|bounds| bounds.size.height)
2830 .collect::<Vec<_>>()
2831 })
2832 };
2833 let scroll = || {
2834 let mut table = StyleRefinement::default();
2835 table.overflow.x = Some(gpui::Overflow::Scroll);
2836 TextViewStyle::default().with_table(table)
2837 };
2838
2839 for (layout, style) in [("wrap", TextViewStyle::default()), ("scroll", scroll())] {
2840 let fits = first_column(4000., style.clone());
2841 let narrow = first_column(700., style);
2842 assert_eq!(fits.len(), 3, "{layout}: three rows");
2843 assert_eq!(fits[0], fits[2], "{layout}: the header fits on one line");
2844 assert_eq!(narrow[..2], fits[..2], "{layout}: the short column wraps");
2845 }
2846
2847 let single_line = first_column(4000., TextViewStyle::default())[0];
2848 let padded = TextViewStyle::default().with_table_cell(StyleRefinement::default().px_4());
2849 assert_eq!(
2850 first_column(4000., padded)[0],
2851 single_line,
2852 "the cell padding refinement leaves the text its measured width"
2853 );
2854 let bold = TextViewStyle::default()
2855 .with_table(StyleRefinement::default().font_weight(gpui::FontWeight::BOLD));
2856 assert_eq!(
2857 first_column(4000., bold)[0],
2858 single_line,
2859 "the table text refinement is measured"
2860 );
2861 }
2862
2863 #[gpui::test]
2864 fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
2865 cx.update(crate::init);
2866 let (_, cx) =
2867 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2868 let cx: &mut VisualTestContext = cx;
2869
2870 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
2871
2872 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
2873 }
2874
2875 #[gpui::test]
2876 fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
2877 cx.update(crate::init);
2878 let (_, cx) =
2879 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
2880 let cx: &mut VisualTestContext = cx;
2881
2882 cx.simulate_mouse_down(
2883 point(px(10.), px(10.)),
2884 MouseButton::Right,
2885 Modifiers::default(),
2886 );
2887 cx.simulate_mouse_up(
2888 point(px(10.), px(10.)),
2889 MouseButton::Right,
2890 Modifiers::default(),
2891 );
2892
2893 assert_eq!(cx.opened_url(), None);
2894 }
2895
2896 #[gpui::test]
2897 fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
2898 use std::sync::{Arc, Mutex};
2899
2900 struct LinkRoot {
2901 text_view: Entity<TextViewState>,
2902 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
2903 }
2904
2905 impl Render for LinkRoot {
2906 fn render(
2907 &mut self,
2908 _window: &mut Window,
2909 _cx: &mut Context<Self>,
2910 ) -> impl IntoElement {
2911 let clicks = self.clicks.clone();
2912 div()
2913 .w(px(240.))
2914 .child(
2915 TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
2916 clicks.lock().unwrap().push((url.clone(), event.clone()));
2917 }),
2918 )
2919 }
2920 }
2921
2922 cx.update(crate::init);
2923 let clicks = Arc::new(Mutex::new(Vec::new()));
2924 let captured = clicks.clone();
2925 let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
2926 text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
2927 clicks,
2928 });
2929 let cx: &mut VisualTestContext = cx;
2930
2931 let mut modifiers = Modifiers::default();
2932 modifiers.control = true;
2933 cx.simulate_click(point(px(10.), px(10.)), modifiers);
2934 cx.simulate_mouse_down(
2935 point(px(10.), px(10.)),
2936 MouseButton::Middle,
2937 Modifiers::default(),
2938 );
2939 cx.simulate_mouse_up(
2940 point(px(10.), px(10.)),
2941 MouseButton::Middle,
2942 Modifiers::default(),
2943 );
2944 cx.simulate_mouse_down(
2945 point(px(10.), px(10.)),
2946 MouseButton::Right,
2947 Modifiers::default(),
2948 );
2949 cx.simulate_mouse_up(
2950 point(px(10.), px(10.)),
2951 MouseButton::Right,
2952 Modifiers::default(),
2953 );
2954
2955 let clicks = captured.lock().unwrap();
2956 assert_eq!(clicks.len(), 3);
2957 assert_eq!(clicks[0].0, "https://example.com");
2958 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
2959 assert!(clicks[0].1.modifiers().control);
2960 assert!(clicks[1].1.is_middle_click());
2961 assert!(clicks[2].1.is_right_click());
2962 assert_eq!(cx.opened_url(), None);
2963 }
2964
2965 #[gpui::test]
2966 fn inline_object_inherits_bold_and_link_clicks_without_opening_on_drag(
2967 cx: &mut TestAppContext,
2968 ) {
2969 use std::sync::{Arc, Mutex};
2970 struct Root {
2971 state: Entity<TextViewState>,
2972 clicks: Arc<Mutex<Vec<SharedString>>>,
2973 }
2974 impl Render for Root {
2975 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2976 let clicks = self.clicks.clone();
2977 let extensions = crate::text::MarkdownExtensions::default().plugin(
2978 crate::text::markdown_ext::TestInlinePlugin::new("math")
2979 .parse_with(|node, _| {
2980 matches!(node, markdown::mdast::Node::InlineMath(_)).then(|| {
2981 super::super::MarkdownNode::new("math", ()).text("formula")
2982 })
2983 })
2984 .render_with(|_, context, _, _| {
2985 assert_eq!(context.text_style().font_weight, gpui::FontWeight::BOLD);
2986 Some(super::super::InlineElement::new(div().child("formula")))
2987 }),
2988 );
2989 div().w(px(300.)).child(crate::TextSelectionLayer).child(
2990 TextView::new(&self.state)
2991 .markdown_extensions(extensions)
2992 .on_link_click(move |url, _, _, _| {
2993 clicks.lock().unwrap().push(url.clone())
2994 }),
2995 )
2996 }
2997 }
2998 cx.update(crate::init);
2999 let clicks = Arc::new(Mutex::new(Vec::new()));
3000 let captured = clicks.clone();
3001 let (root, cx) = cx.add_window_view(move |_, cx| Root {
3002 state: cx.new(|cx| TextViewState::markdown("**[$x$](https://example.com)**", cx)),
3003 clicks,
3004 });
3005 let cx: &mut VisualTestContext = cx;
3006 cx.run_until_parked();
3007 cx.update(|window, cx| {
3008 let _ = window.draw(cx);
3009 });
3010 let bounds = root.read_with(cx, |root, cx| {
3011 root.state.read(cx).selection_adapter.text_bounds()[0]
3012 });
3013 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
3014 cx.simulate_mouse_down(bounds.center(), button, Modifiers::default());
3015 cx.simulate_mouse_up(bounds.center(), button, Modifiers::default());
3016 }
3017 assert_eq!(captured.lock().unwrap().len(), 3);
3018 let start = point(bounds.left() + px(1.), bounds.center().y);
3019 let end = point(bounds.right() - px(1.), bounds.center().y);
3020 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
3021 cx.update(|window, cx| {
3022 let _ = window.draw(cx);
3023 });
3024 cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
3025 cx.update(|window, cx| {
3026 let _ = window.draw(cx);
3027 });
3028 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
3029 assert_eq!(captured.lock().unwrap().len(), 3);
3030 }
3031
3032 #[gpui::test]
3033 fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
3034 use std::sync::{Arc, Mutex};
3035
3036 struct LinkedImageRoot {
3037 text_view: Entity<TextViewState>,
3038 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
3039 }
3040
3041 impl Render for LinkedImageRoot {
3042 fn render(
3043 &mut self,
3044 _window: &mut Window,
3045 _cx: &mut Context<Self>,
3046 ) -> impl IntoElement {
3047 let clicks = self.clicks.clone();
3048 div().w(px(160.)).child(
3049 TextView::new(&self.text_view)
3050 .selectable(true)
3051 .on_link_click(move |url, event, _, _| {
3052 clicks.lock().unwrap().push((url.clone(), event.clone()));
3053 }),
3054 )
3055 }
3056 }
3057
3058 cx.update(crate::init);
3059 let clicks = Arc::new(Mutex::new(Vec::new()));
3060 let captured = clicks.clone();
3061 let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
3062 text_view: cx.new(|cx| {
3063 TextViewState::markdown(
3064 r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
3065 cx,
3066 )
3067 }),
3068 clicks,
3069 }
3070 );
3071 let cx: &mut VisualTestContext = cx;
3072 cx.run_until_parked();
3073 cx.update(|window, cx| {
3074 let _ = window.draw(cx);
3075 });
3076
3077 let inline_bounds = content.read_with(cx, |content, cx| {
3078 content.text_view.read(cx).selection_adapter.text_bounds()
3079 });
3080 assert!(
3081 inline_bounds.len() >= 2,
3082 "linked image needs text bounds on both sides: {inline_bounds:?}"
3083 );
3084 assert!(
3085 inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
3086 "linked image did not reserve the expected click target: {inline_bounds:?}"
3087 );
3088 let position = point(
3089 inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
3090 inline_bounds[0].top() + px(8.),
3091 );
3092 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
3093 cx.simulate_mouse_down(position, button, Modifiers::default());
3094 cx.simulate_mouse_up(position, button, Modifiers::default());
3095 }
3096
3097 let clicks = captured.lock().unwrap();
3098 assert_eq!(clicks.len(), 3);
3099 assert!(
3100 clicks
3101 .iter()
3102 .all(|(url, _)| url == "https://example.com/image-link")
3103 );
3104 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
3105 assert!(clicks[1].1.is_middle_click());
3106 assert!(clicks[2].1.is_right_click());
3107 assert_eq!(cx.opened_url(), None);
3108 }
3109
3110 #[gpui::test]
3111 fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
3112 cx.update(crate::init);
3113 let (view, cx) = cx
3114 .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
3115 let cx: &mut VisualTestContext = cx;
3116
3117 cx.simulate_mouse_down(
3118 point(px(10.), px(34.)),
3119 MouseButton::Left,
3120 Modifiers::default(),
3121 );
3122 cx.simulate_mouse_move(
3123 point(px(90.), px(34.)),
3124 Some(MouseButton::Left),
3125 Modifiers::default(),
3126 );
3127 cx.simulate_mouse_up(
3128 point(px(90.), px(34.)),
3129 MouseButton::Left,
3130 Modifiers::default(),
3131 );
3132
3133 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3134 assert!(
3135 selected_text.is_empty(),
3136 "unexpected selection: {selected_text:?}"
3137 );
3138 }
3139
3140 struct ClippedTallTextViewTestRoot {
3144 text_view: Entity<TextViewState>,
3145 }
3146
3147 impl ClippedTallTextViewTestRoot {
3148 fn new(cx: &mut Context<Self>) -> Self {
3149 let text_view =
3153 cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
3154 Self { text_view }
3155 }
3156 }
3157
3158 impl Render for ClippedTallTextViewTestRoot {
3159 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3160 div()
3161 .w(px(200.))
3162 .child(crate::TextSelectionLayer)
3163 .child(
3164 div()
3165 .h(px(40.))
3166 .overflow_hidden()
3167 .child(TextView::new(&self.text_view).selectable(true)),
3168 )
3169 .child(div().h(px(160.)))
3172 }
3173 }
3174
3175 #[gpui::test]
3184 fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
3185 cx.update(crate::init);
3186 let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
3187 let cx: &mut VisualTestContext = cx;
3188
3189 cx.run_until_parked();
3190 cx.update(|window, cx| {
3191 let _ = window.draw(cx);
3192 });
3193
3194 cx.simulate_mouse_down(
3198 point(px(2.), px(8.)),
3199 MouseButton::Left,
3200 Modifiers::default(),
3201 );
3202 cx.update(|window, cx| {
3203 let _ = window.draw(cx);
3204 });
3205 cx.simulate_mouse_move(
3206 point(px(180.), px(150.)),
3207 Some(MouseButton::Left),
3208 Modifiers::default(),
3209 );
3210 cx.update(|window, cx| {
3211 let _ = window.draw(cx);
3212 });
3213 cx.simulate_mouse_up(
3214 point(px(180.), px(150.)),
3215 MouseButton::Left,
3216 Modifiers::default(),
3217 );
3218 cx.update(|window, cx| {
3219 let _ = window.draw(cx);
3220 });
3221
3222 let selected_text =
3223 content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3224 assert!(
3225 selected_text.contains("delta"),
3226 "clipped-out text was not copied: {selected_text:?}"
3227 );
3228 assert!(
3229 selected_text.contains("charlie"),
3230 "clipped-out text was not copied: {selected_text:?}"
3231 );
3232 }
3233
3234 #[gpui::test]
3235 fn double_click_selects_word(cx: &mut TestAppContext) {
3236 cx.update(crate::init);
3237 let (view, cx) =
3238 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3239
3240 let cx: &mut VisualTestContext = cx;
3241 cx.run_until_parked();
3242 cx.update(|window, cx| {
3243 let _ = window.draw(cx);
3244 });
3245 let position = point(px(10.), px(16.));
3246 cx.simulate_event(MouseDownEvent {
3247 position,
3248 modifiers: Modifiers::default(),
3249 button: MouseButton::Left,
3250 click_count: 2,
3251 first_mouse: false,
3252 });
3253 cx.simulate_event(MouseUpEvent {
3254 position,
3255 modifiers: Modifiers::default(),
3256 button: MouseButton::Left,
3257 click_count: 2,
3258 });
3259 cx.update(|window, cx| {
3260 let _ = window.draw(cx);
3261 });
3262
3263 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3264 assert_eq!(selected_text.trim(), "quick");
3265 }
3266
3267 #[gpui::test]
3268 fn long_press_selects_word_then_drag_extends_selection(cx: &mut TestAppContext) {
3269 struct TouchRoot {
3270 text_view: Entity<TextViewState>,
3271 }
3272 impl Render for TouchRoot {
3273 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3274 div()
3275 .w(px(300.))
3276 .child(crate::TextSelectionLayer)
3277 .child(TextView::new(&self.text_view).selectable(true))
3278 }
3279 }
3280 cx.update(crate::init);
3281 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3282 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3283 });
3284 cx.run_until_parked();
3285 cx.update(|window, cx| {
3286 let _ = window.draw(cx);
3287 });
3288 let start_position = point(px(10.), px(16.));
3289 cx.simulate_event(gpui::LongPressEvent {
3290 phase: gpui::TouchPhase::Started,
3291 start_position,
3292 position: start_position,
3293 });
3294 cx.update(|window, cx| {
3295 let _ = window.draw(cx);
3296 });
3297 assert_eq!(
3298 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3299 .trim(),
3300 "quick"
3301 );
3302 for phase in [gpui::TouchPhase::Moved, gpui::TouchPhase::Ended] {
3303 cx.simulate_event(gpui::LongPressEvent {
3304 phase,
3305 start_position,
3306 position: point(px(220.), px(16.)),
3307 });
3308 }
3309 cx.update(|window, cx| {
3310 let _ = window.draw(cx);
3311 });
3312 assert_eq!(
3313 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3314 .trim(),
3315 "quick select value"
3316 );
3317 }
3318
3319 #[gpui::test]
3320 fn long_press_release_keeps_handles_which_drag_the_selection(cx: &mut TestAppContext) {
3321 use crate::{SelectionEdge, TextSelection};
3322
3323 struct TouchRoot {
3324 text_view: Entity<TextViewState>,
3325 }
3326 impl Render for TouchRoot {
3327 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3328 div()
3329 .w(px(300.))
3330 .child(crate::TextSelectionLayer)
3331 .child(TextView::new(&self.text_view).selectable(true))
3332 }
3333 }
3334 cx.update(crate::init);
3335 let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
3336 text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
3337 });
3338 let draw = |cx: &mut VisualTestContext| {
3339 cx.update(|window, cx| {
3340 let _ = window.draw(cx);
3341 });
3342 };
3343 let selected = |cx: &mut VisualTestContext| {
3344 view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
3345 .trim()
3346 .to_string()
3347 };
3348 cx.run_until_parked();
3349 draw(cx);
3350
3351 let start_position = point(px(70.), px(16.));
3352 for phase in [gpui::TouchPhase::Started, gpui::TouchPhase::Ended] {
3353 cx.simulate_event(gpui::LongPressEvent {
3354 phase,
3355 start_position,
3356 position: start_position,
3357 });
3358 draw(cx);
3359 }
3360 assert_eq!(selected(cx), "select");
3361 let snapshot = cx
3362 .update(|window, cx| TextSelection::touch_selection(window, cx))
3363 .expect("a released long press keeps its handles");
3364 assert!(snapshot.is_menu_open());
3365 assert!(!snapshot.is_empty());
3366 assert!(snapshot.start().left() < snapshot.end().left());
3367
3368 let end = snapshot.end();
3371 let finger = point(end.left(), end.bottom() + px(20.));
3372 cx.update(|window, cx| {
3373 TextSelection::begin_edge_drag(SelectionEdge::End, finger, window, cx);
3374 });
3375 draw(cx);
3376 let snapshot = cx
3377 .update(|window, cx| TextSelection::touch_selection(window, cx))
3378 .unwrap();
3379 assert_eq!(snapshot.dragging(), Some(SelectionEdge::End));
3380 assert!(!snapshot.is_menu_open());
3381 cx.update(|window, cx| {
3382 TextSelection::update_edge_drag(point(px(290.), finger.y), window, cx);
3383 });
3384 draw(cx);
3385 assert_eq!(selected(cx), "select value");
3386 cx.update(|window, cx| TextSelection::end_edge_drag(window, cx));
3387 draw(cx);
3388 let snapshot = cx
3389 .update(|window, cx| TextSelection::touch_selection(window, cx))
3390 .unwrap();
3391 assert!(snapshot.is_menu_open());
3392 assert_eq!(snapshot.dragging(), None);
3393 let select_start = snapshot.start().left();
3394
3395 cx.update(|_, cx| {
3398 view.update(cx, |root, cx| {
3399 root.text_view.update(cx, |state, cx| state.select_all(cx));
3400 });
3401 });
3402 draw(cx);
3403 assert_eq!(selected(cx), "quick select value");
3404 let snapshot = cx
3405 .update(|window, cx| TextSelection::touch_selection(window, cx))
3406 .expect("select all keeps the touch selection");
3407 let start = snapshot.start();
3408 cx.update(|window, cx| {
3409 TextSelection::begin_edge_drag(SelectionEdge::Start, start.origin, window, cx);
3410 TextSelection::update_edge_drag(point(select_start, start.origin.y), window, cx);
3411 TextSelection::end_edge_drag(window, cx);
3412 });
3413 draw(cx);
3414 assert_eq!(selected(cx), "select value");
3415
3416 let menu = gpui::Bounds::new(point(px(0.), px(200.)), gpui::size(px(120.), px(32.)));
3419 cx.update(|window, cx| TextSelection::register_touch_ui(menu, window, cx));
3420 cx.simulate_event(MouseDownEvent {
3421 position: point(px(10.), px(210.)),
3422 modifiers: Modifiers::default(),
3423 button: MouseButton::Left,
3424 click_count: 1,
3425 first_mouse: false,
3426 });
3427 assert_eq!(selected(cx), "select value");
3428 cx.simulate_event(MouseDownEvent {
3429 position: point(px(10.), px(16.)),
3430 modifiers: Modifiers::default(),
3431 button: MouseButton::Left,
3432 click_count: 1,
3433 first_mouse: false,
3434 });
3435 cx.simulate_event(MouseUpEvent {
3436 position: point(px(10.), px(16.)),
3437 modifiers: Modifiers::default(),
3438 button: MouseButton::Left,
3439 click_count: 1,
3440 });
3441 draw(cx);
3442 assert!(
3443 cx.update(|window, cx| TextSelection::touch_selection(window, cx))
3444 .is_none()
3445 );
3446 }
3447
3448 #[gpui::test]
3449 fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
3450 cx.update(crate::init);
3451 let (view, cx) =
3452 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
3453
3454 let cx: &mut VisualTestContext = cx;
3455 cx.run_until_parked();
3456 cx.update(|window, cx| {
3457 let _ = window.draw(cx);
3458 });
3459
3460 let position = point(px(10.), px(10.));
3461 cx.simulate_event(MouseDownEvent {
3462 position,
3463 modifiers: Modifiers::default(),
3464 button: MouseButton::Left,
3465 click_count: 3,
3466 first_mouse: false,
3467 });
3468 cx.simulate_event(MouseUpEvent {
3469 position,
3470 modifiers: Modifiers::default(),
3471 button: MouseButton::Left,
3472 click_count: 3,
3473 });
3474 cx.update(|window, cx| {
3475 let _ = window.draw(cx);
3476 });
3477
3478 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
3479 assert_eq!(selected_text.trim(), "quick select value");
3480 }
3481
3482 mod copy_before_paint {
3483 use super::*;
3484 use crate::{TextSelection, text::SelectionFormat};
3485
3486 const MARKDOWN: &str = "中文 **café**\n\nβeta `\u{2009}code\u{2009}` 👩💻\n\n終わり";
3487 const PLAIN: &str = "中文 café\nβeta \u{2009}code\u{2009} 👩💻\n終わり\n";
3488
3489 struct Root {
3490 state: Entity<TextViewState>,
3491 format: SelectionFormat,
3492 width: Pixels,
3493 scrollable: bool,
3494 }
3495
3496 impl Render for Root {
3497 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3498 div()
3499 .w(self.width)
3500 .when(self.scrollable, |el| el.h(px(160.)))
3501 .child(crate::TextSelectionLayer)
3502 .child(
3503 TextView::new(&self.state)
3504 .scrollable(self.scrollable)
3505 .selection_format(self.format),
3506 )
3507 }
3508 }
3509
3510 fn setup(
3511 format: SelectionFormat,
3512 cx: &mut TestAppContext,
3513 ) -> (Entity<TextViewState>, &mut VisualTestContext) {
3514 cx.update(crate::init);
3515 let (root, cx) = cx.add_window_view(|_, cx| Root {
3516 state: cx.new(|cx| TextViewState::markdown(MARKDOWN, cx)),
3517 format,
3518 width: px(520.),
3519 scrollable: false,
3520 });
3521 cx.run_until_parked();
3522 cx.update(|window, cx| window.draw(cx).clear(cx));
3523 (root.read_with(cx, |root, _| root.state.clone()), cx)
3524 }
3525
3526 fn document<'a>(
3527 markdown: &str,
3528 format: SelectionFormat,
3529 scrollable: bool,
3530 cx: &'a mut TestAppContext,
3531 ) -> (Entity<Root>, &'a mut VisualTestContext) {
3532 cx.update(crate::init);
3533 let (root, cx) = cx.add_window_view(|_, cx| Root {
3534 state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
3535 format,
3536 width: px(520.),
3537 scrollable,
3538 });
3539 cx.run_until_parked();
3540 cx.update(|window, cx| window.draw(cx).clear(cx));
3541 (root, cx)
3542 }
3543
3544 fn scroll_to(state: &Entity<TextViewState>, ix: usize, cx: &mut VisualTestContext) {
3545 state.read_with(cx, |state, _| {
3546 state.list_state.scroll_to(gpui::ListOffset {
3547 item_ix: ix,
3548 offset_in_item: px(0.),
3549 })
3550 });
3551 cx.update(|window, cx| window.draw(cx).clear(cx));
3552 }
3553
3554 fn endpoints(
3555 state: &Entity<TextViewState>,
3556 cx: &mut VisualTestContext,
3557 ) -> (
3558 gpui::Point<Pixels>,
3559 gpui::Point<Pixels>,
3560 gpui::Point<Pixels>,
3561 ) {
3562 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
3563 let first = rows.first().unwrap();
3564 let last = rows.last().unwrap();
3565 (
3566 point(first.left() + px(0.1), first.center().y),
3567 point(first.right() - px(0.1), first.center().y),
3568 point(last.right() - px(0.1), last.center().y),
3569 )
3570 }
3571
3572 fn press(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
3575 window.dispatch_event(
3576 gpui::PlatformInput::MouseDown(MouseDownEvent {
3577 position,
3578 button: MouseButton::Left,
3579 click_count: 1,
3580 ..Default::default()
3581 }),
3582 cx,
3583 );
3584 }
3585
3586 fn extend(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
3587 window.dispatch_event(
3588 gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
3589 position,
3590 pressed_button: Some(MouseButton::Left),
3591 ..Default::default()
3592 }),
3593 cx,
3594 );
3595 }
3596
3597 fn release(position: gpui::Point<Pixels>, window: &mut Window, cx: &mut gpui::App) {
3598 window.dispatch_event(
3599 gpui::PlatformInput::MouseUp(MouseUpEvent {
3600 position,
3601 button: MouseButton::Left,
3602 modifiers: Modifiers::default(),
3603 click_count: 1,
3604 }),
3605 cx,
3606 );
3607 }
3608
3609 fn assert_copy(expected: &str, window: &mut Window, cx: &mut gpui::App) {
3610 let focus = window.focused(cx).expect("gesture focuses TextView");
3613 focus.dispatch_action(&crate::input::Copy, window, cx);
3614 assert_eq!(
3615 cx.read_from_clipboard().and_then(|item| item.text()),
3616 Some(expected.trim().to_string())
3617 );
3618 assert!(focus.is_focused(window));
3619 assert_eq!(TextSelection::selected_text(window, cx), expected);
3620 }
3621
3622 #[gpui::test]
3623 fn custom_inline_and_block_copy_project_with_the_text(cx: &mut TestAppContext) {
3624 struct CustomRoot {
3625 state: Entity<TextViewState>,
3626 format: SelectionFormat,
3627 extensions: crate::text::MarkdownExtensions,
3628 }
3629 impl Render for CustomRoot {
3630 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3631 div().w(px(520.)).child(crate::TextSelectionLayer).child(
3632 TextView::new(&self.state)
3633 .selection_format(self.format)
3634 .markdown_extensions(self.extensions.clone()),
3635 )
3636 }
3637 }
3638 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
3639 cx.update(crate::init);
3640 let extensions = crate::text::MarkdownExtensions::default()
3641 .plugin(
3642 crate::text::markdown_ext::TestInlinePlugin::new("math").parse_with(
3643 |node, _| {
3644 let markdown::mdast::Node::InlineMath(math) = node else {
3645 return None;
3646 };
3647 Some(
3648 crate::text::MarkdownNode::new("math", ())
3649 .text(format!("{}²", math.value)),
3650 )
3651 },
3652 ),
3653 )
3654 .block_parser(|node, _| {
3655 matches!(node, markdown::mdast::Node::ThematicBreak(_)).then(|| {
3656 crate::text::MarkdownNode::new("block", ())
3657 .text("BLOCK")
3658 .markdown("---")
3659 })
3660 })
3661 .block_renderer("block", |_, _, _| {
3662 div()
3663 .debug_selector(|| "copy-custom-block".into())
3664 .w(px(520.))
3665 .h(px(40.))
3666 });
3667 let (root, cx) = cx.add_window_view(|_, cx| CustomRoot {
3668 state: cx
3669 .new(|cx| TextViewState::markdown("before $x$ after\n\n---\n\nlast", cx)),
3670 format,
3671 extensions,
3672 });
3673 cx.run_until_parked();
3674 cx.update(|window, cx| window.draw(cx).clear(cx));
3675 let state = root.read_with(cx, |root, _| root.state.clone());
3676 let (start, _, end) = endpoints(&state, cx);
3677 let expected = if format == SelectionFormat::Plain {
3678 "before x² after\nBLOCK\nlast\n"
3679 } else {
3680 "before $x$ after\n\n---\n\nlast"
3681 };
3682 cx.update(|window, cx| {
3683 press(start, window, cx);
3684 extend(end, window, cx);
3685 assert_copy(expected, window, cx);
3686 release(end, window, cx);
3687 });
3688 cx.update(|window, cx| window.draw(cx).clear(cx));
3689 cx.update(|window, cx| assert_copy(expected, window, cx));
3690 let block = cx.debug_bounds("copy-custom-block").unwrap();
3691 cx.update(|window, cx| {
3692 press(block.center(), window, cx);
3693 extend(point(block.center().x, block.bottom() + px(1.)), window, cx);
3694 assert_copy(
3695 if format == SelectionFormat::Plain {
3696 "BLOCK\n"
3697 } else {
3698 "---"
3699 },
3700 window,
3701 cx,
3702 );
3703 });
3704 }
3705 }
3706
3707 #[gpui::test]
3708 fn unicode_180_paragraph_copy_extends_and_retracts_before_paint(cx: &mut TestAppContext) {
3709 let markdown = (0..180)
3710 .map(|ix| format!("p{ix:03} **café** 中文 👩💻 `\u{2009}code\u{2009}`"))
3711 .collect::<Vec<_>>()
3712 .join("\n\n");
3713 let expected = (0..180)
3716 .map(|ix| format!("p{ix:03} café 中文 👩💻 \u{2009}code\u{2009}\n"))
3717 .collect::<String>();
3718 let (root, cx) = document(&markdown, SelectionFormat::Plain, false, cx);
3719 cx.simulate_resize(gpui::size(px(800.), px(12000.)));
3720 cx.update(|window, cx| window.draw(cx).clear(cx));
3721 let state = root.read_with(cx, |root, _| root.state.clone());
3722 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
3723 let first = rows.first().unwrap();
3724 let first_end = rows
3725 .iter()
3726 .take_while(|row| row.top() < first.bottom())
3727 .last()
3728 .unwrap();
3729 let last = rows.last().unwrap();
3730 assert!(last.top() > first.top() + px(3000.));
3731 let start = point(first.left() + px(0.1), first.center().y);
3732 let short_end = point(first_end.right() - px(0.1), first_end.center().y);
3733 let end = point(last.right() - px(0.1), last.center().y);
3734 let short = "p000 café 中文 👩💻 \u{2009}code\u{2009}\n";
3735 cx.update(|window, cx| {
3736 press(start, window, cx);
3737 extend(short_end, window, cx);
3738 assert_copy(short, window, cx);
3739 });
3740 cx.update(|window, cx| window.draw(cx).clear(cx));
3741 cx.update(|window, cx| {
3742 extend(end, window, cx);
3743 assert_copy(&expected, window, cx);
3744 extend(short_end, window, cx);
3745 assert_copy(short, window, cx);
3746 });
3747 }
3748
3749 #[gpui::test]
3750 fn scrolling_before_paint_keeps_the_completed_frame_coordinates(cx: &mut TestAppContext) {
3751 let markdown = format!(
3752 "{}\n\nlast",
3753 "alpha beta gamma delta epsilon zeta ".repeat(30)
3754 );
3755 let (root, cx) = document(&markdown, SelectionFormat::Plain, true, cx);
3756 root.update(cx, |root, cx| {
3757 root.width = px(180.);
3758 cx.notify();
3759 });
3760 cx.update(|window, cx| window.draw(cx).clear(cx));
3761 let state = root.read_with(cx, |root, _| root.state.clone());
3762 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
3763 let first = rows[0];
3764 let (start, end) = state.read_with(cx, |state, _| {
3765 (
3766 state.selection_adapter.first_text_position(0),
3767 state.selection_adapter.first_text_position("alpha".len()),
3768 )
3769 });
3770 cx.update(|window, cx| {
3771 press(start, window, cx);
3772 extend(end, window, cx);
3773 });
3774 cx.update(|window, cx| window.draw(cx).clear(cx));
3775 cx.update(|window, cx| {
3776 state.read(cx).list_state.scroll_by(first.size.height);
3777 assert_copy("alpha\n", window, cx);
3780 });
3781 }
3782
3783 #[gpui::test]
3784 fn offscreen_remeasurement_is_not_a_copy_projection(cx: &mut TestAppContext) {
3785 let markdown = (0..180)
3786 .map(|ix| format!("row {ix:03} café 中文"))
3787 .collect::<Vec<_>>()
3788 .join("\n\n");
3789 let (root, cx) = document(&markdown, SelectionFormat::Plain, true, cx);
3790 let state = root.read_with(cx, |root, _| root.state.clone());
3791 scroll_to(&state, 90, cx);
3792 root.update(cx, |root, cx| {
3793 root.width = px(260.);
3794 cx.notify();
3795 });
3796 cx.update(|window, cx| window.draw(cx).clear(cx));
3797 scroll_to(&state, 90, cx);
3798 let (start, end, _) = endpoints(&state, cx);
3801 cx.update(|window, cx| {
3802 press(start, window, cx);
3803 extend(end, window, cx);
3804 assert_copy("row 090 café 中文\n", window, cx);
3805 });
3806 }
3807
3808 #[gpui::test]
3809 fn virtualized_flow_retains_its_source_owned_partial_anchor(cx: &mut TestAppContext) {
3810 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
3811 let tail = (1..180)
3812 .map(|ix| format!("row {ix:03} café 中文"))
3813 .collect::<Vec<_>>();
3814 let markdown = format!("PREFIX `code` suffix\n\n{}", tail.join("\n\n"));
3815 let expected = if format == SelectionFormat::Plain {
3816 format!("code suffix\n{}\n", tail.join("\n"))
3817 } else {
3818 format!("`code` suffix\n\n{}", tail.join("\n\n"))
3819 };
3820 let (root, cx) = document(&markdown, format, true, cx);
3821 let state = root.read_with(cx, |root, _| root.state.clone());
3822 let rows = state.read_with(cx, |state, _| state.selection_adapter.text_bounds());
3823 let code = rows[1];
3824 let suffix = rows[2];
3825 assert!(code.left() < suffix.left());
3826 cx.update(|window, cx| {
3827 press(point(code.left() + px(0.1), code.center().y), window, cx);
3828 extend(
3829 point(suffix.right() - px(0.1), suffix.center().y),
3830 window,
3831 cx,
3832 );
3833 });
3834 cx.update(|window, cx| window.draw(cx).clear(cx));
3835 scroll_to(&state, 179, cx);
3836 cx.update(|window, cx| window.draw(cx).clear(cx));
3838 let (_, _, end) = endpoints(&state, cx);
3839 cx.update(|window, cx| {
3840 extend(end, window, cx);
3841 assert_copy(&expected, window, cx);
3842 });
3843 }
3844 }
3845
3846 #[gpui::test]
3847 fn a_new_range_copies_before_its_first_selection_paint(cx: &mut TestAppContext) {
3848 let (state, cx) = setup(SelectionFormat::Plain, cx);
3849 let (start, _, end) = endpoints(&state, cx);
3850 cx.update(|window, cx| {
3851 press(start, window, cx);
3852 extend(end, window, cx);
3853 assert_copy(PLAIN, window, cx);
3854 });
3855 }
3856
3857 #[gpui::test]
3858 fn an_established_range_extends_and_retracts_without_paint(cx: &mut TestAppContext) {
3859 let (state, cx) = setup(SelectionFormat::Plain, cx);
3860 let (start, first_end, end) = endpoints(&state, cx);
3861 cx.update(|window, cx| {
3862 press(start, window, cx);
3863 extend(first_end, window, cx);
3864 });
3865 cx.update(|window, cx| window.draw(cx).clear(cx));
3866 cx.update(|window, cx| {
3867 assert_copy("中文 café\n", window, cx);
3868 extend(end, window, cx);
3869 assert_copy(PLAIN, window, cx);
3870 extend(first_end, window, cx);
3871 assert_copy("中文 café\n", window, cx);
3872 });
3873 }
3874
3875 #[gpui::test]
3876 fn source_copy_projects_the_current_range_without_paint(cx: &mut TestAppContext) {
3877 let (state, cx) = setup(SelectionFormat::Source, cx);
3878 let (start, first_end, end) = endpoints(&state, cx);
3879 cx.update(|window, cx| {
3880 press(start, window, cx);
3881 extend(first_end, window, cx);
3882 assert_copy("中文 **café**", window, cx);
3883 extend(end, window, cx);
3884 assert_copy(MARKDOWN, window, cx);
3885 extend(first_end, window, cx);
3886 assert_eq!(TextSelection::selected_text(window, cx), "中文 **café**");
3887 });
3888 }
3889
3890 #[gpui::test]
3891 fn a_blank_gesture_does_not_reuse_the_previous_range(cx: &mut TestAppContext) {
3892 let (state, cx) = setup(SelectionFormat::Plain, cx);
3893 let (start, first_end, end) = endpoints(&state, cx);
3894 cx.update(|window, cx| {
3895 press(start, window, cx);
3896 extend(end, window, cx);
3897 assert_eq!(TextSelection::selected_text(window, cx), PLAIN);
3898 release(end, window, cx);
3899 press(first_end + point(px(20.), px(0.)), window, cx);
3900 extend(first_end + point(px(60.), px(0.)), window, cx);
3901 assert_eq!(TextSelection::selected_text(window, cx), "");
3902 });
3903 }
3904
3905 #[gpui::test]
3906 fn replacing_source_cannot_reproject_old_content(cx: &mut TestAppContext) {
3907 for format in [SelectionFormat::Plain, SelectionFormat::Source] {
3908 for replacement in ["replacement".to_string(), "replacement ".repeat(500)] {
3909 let (state, cx) = setup(format, cx);
3910 let (start, _, end) = endpoints(&state, cx);
3911 cx.update(|window, cx| {
3912 press(start, window, cx);
3913 extend(end, window, cx);
3914 assert_eq!(
3915 TextSelection::selected_text(window, cx),
3916 if format == SelectionFormat::Plain {
3917 PLAIN
3918 } else {
3919 MARKDOWN
3920 }
3921 );
3922 state.update(cx, |state, cx| state.set_text(&replacement, cx));
3925 assert_eq!(TextSelection::selected_text(window, cx), "");
3926 });
3927 }
3928 }
3929 }
3930 }
3931
3932 #[gpui::test]
3938 fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
3939 use gpui::{ListAlignment, ListState, list};
3940
3941 const ITEMS: &[&str] = &[
3942 "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
3943 "Short.",
3944 "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
3945 "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
3946 "Only one line.",
3947 "**Bold**: medium length text with `code` mixed with regular words.",
3948 "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
3949 "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
3950 ];
3951 let n = 40usize;
3952
3953 struct ListRoot {
3954 state: ListState,
3955 }
3956 impl Render for ListRoot {
3957 fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
3958 div().w(px(360.)).h(px(500.)).child(
3959 list(self.state.clone(), |ix, _w, _cx| {
3960 div()
3961 .w_full()
3962 .child(TextView::markdown(
3963 ("md", ix as u64),
3964 ITEMS[ix % ITEMS.len()],
3965 ))
3966 .into_any_element()
3967 })
3968 .size_full(),
3969 )
3970 }
3971 }
3972
3973 cx.update(crate::init);
3974 let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
3975 let probe = state.clone();
3976 let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
3977 let cx: &mut VisualTestContext = cx;
3978
3979 cx.run_until_parked();
3980 cx.update(|w, cx| {
3981 let _ = w.draw(cx);
3982 });
3983 cx.run_until_parked();
3984 cx.update(|w, cx| {
3985 let _ = w.draw(cx);
3986 });
3987
3988 let total = |p: &ListState| {
3989 f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
3990 };
3991 let mut totals = vec![total(&probe)];
3992 for _ in 0..20 {
3993 probe.scroll_by(px(150.));
3994 cx.update(|w, cx| {
3995 let _ = w.draw(cx);
3996 });
3997 cx.run_until_parked();
3998 totals.push(total(&probe));
3999 }
4000 let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
4001 let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
4002 println!(
4003 "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
4004 max - min
4005 );
4006 assert!(
4007 (max - min) < 2.0,
4008 "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
4009 );
4010 }
4011}