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