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