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