1use std::{ops::Range, sync::Arc};
2
3use gpui::prelude::FluentBuilder as _;
4use gpui::{
5 AnyElement, App, Bounds, ClickEvent, ContentMask, Element, ElementId, Entity, Global,
6 GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
7 LayoutId, MouseButton, ParentElement, Pixels, Refineable as _, SharedString, StyleRefinement,
8 Styled, Window, div, point, px,
9};
10
11use crate::StyledExt;
12use crate::text::TextViewFormat;
13use crate::text::markdown_ext::{MarkdownExtensions, MarkdownNode, MarkdownPlugin};
14use crate::text::node::{CodeBlock, TableData};
15use crate::text::state::{LineSpan, SelectionFormat, TextViewState};
16use crate::{GlobalState, TextSelection, text::TextViewStyle};
17
18pub(crate) type CodeBlockActionsFn =
20 dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync;
21
22pub(crate) type CodeBlockHighlighterFn =
23 dyn Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync;
24
25#[derive(Clone, Default)]
28pub struct TextViewDefaults {
29 style: Option<TextViewStyle>,
30 code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
31}
32
33impl Global for TextViewDefaults {}
34
35impl TextViewDefaults {
36 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn with_style(mut self, style: TextViewStyle) -> Self {
43 self.style = Some(style);
44 self
45 }
46
47 pub fn with_code_block_highlighter<F>(mut self, highlighter: F) -> Self
49 where
50 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
51 {
52 self.code_block_highlighter = Some(Arc::new(highlighter));
53 self
54 }
55
56 pub fn install(self, cx: &mut App) {
58 cx.set_global(self);
59 }
60
61 pub fn global(cx: &App) -> Self {
63 cx.try_global::<Self>().cloned().unwrap_or_default()
64 }
65
66 pub fn has_code_block_highlighter(&self) -> bool {
68 self.code_block_highlighter.is_some()
69 }
70}
71
72pub(crate) type TableActionsFn =
74 dyn Fn(&TableData, &mut Window, &mut App) -> AnyElement + Send + Sync;
75
76pub(crate) type LinkClickHandlerFn =
77 dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync;
78
79pub(crate) fn handle_link_click(
80 handler: &Option<Arc<LinkClickHandlerFn>>,
81 url: SharedString,
82 event: ClickEvent,
83 window: &mut Window,
84 cx: &mut App,
85) {
86 if let Some(handler) = handler {
87 handler(&url, &event, window, cx);
88 } else if match &event {
89 ClickEvent::Mouse(click) => {
90 matches!(click.up.button, MouseButton::Left | MouseButton::Middle)
91 }
92 ClickEvent::Keyboard(_) => true,
93 ClickEvent::Touch(click) => !click.long_press,
94 } {
95 cx.open_url(&url);
96 }
97}
98
99#[derive(Clone)]
116pub struct TextView {
117 id: ElementId,
118 format: Option<TextViewFormat>,
119 text: Option<SharedString>,
120 pub(crate) state: Option<Entity<TextViewState>>,
121 text_view_style: Option<TextViewStyle>,
122 style: StyleRefinement,
123 selectable: bool,
124 selection_format: SelectionFormat,
125 scrollable: bool,
126 max_lines: Option<usize>,
127 code_block_actions: Option<Arc<CodeBlockActionsFn>>,
128 code_block_highlighter: Option<Arc<CodeBlockHighlighterFn>>,
129 table_actions: Option<Arc<TableActionsFn>>,
130 link_click_handler: Option<Arc<LinkClickHandlerFn>>,
131 markdown_extensions: Arc<MarkdownExtensions>,
132}
133
134pub trait TextViewPlugin {
136 fn setup(self, text_view: TextView) -> TextView;
137}
138
139impl<P> TextViewPlugin for P
140where
141 P: MarkdownPlugin,
142{
143 fn setup(self, mut text_view: TextView) -> TextView {
144 let extensions = Arc::make_mut(&mut text_view.markdown_extensions);
145 let current = std::mem::take(extensions);
146 *extensions = current.plugin(self);
147 text_view
148 }
149}
150
151impl Styled for TextView {
152 fn style(&mut self) -> &mut StyleRefinement {
153 &mut self.style
154 }
155}
156
157impl TextView {
158 pub fn new(state: &Entity<TextViewState>) -> Self {
160 Self {
161 id: ElementId::Name(state.entity_id().to_string().into()),
162 state: Some(state.clone()),
163 format: None,
164 text: None,
165 text_view_style: None,
166 style: StyleRefinement::default(),
167 selectable: true,
168 selection_format: SelectionFormat::default(),
169 scrollable: false,
170 max_lines: None,
171 code_block_actions: None,
172 code_block_highlighter: None,
173 table_actions: None,
174 link_click_handler: None,
175 markdown_extensions: Arc::default(),
176 }
177 }
178
179 pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
181 Self {
182 id: id.into(),
183 format: Some(TextViewFormat::Markdown),
184 text: Some(markdown.into()),
185 text_view_style: None,
186 style: StyleRefinement::default(),
187 state: None,
188 selectable: true,
189 selection_format: SelectionFormat::default(),
190 scrollable: false,
191 max_lines: None,
192 code_block_actions: None,
193 code_block_highlighter: None,
194 table_actions: None,
195 link_click_handler: None,
196 markdown_extensions: Arc::default(),
197 }
198 }
199
200 pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
202 Self {
203 id: id.into(),
204 format: Some(TextViewFormat::Html),
205 text: Some(html.into()),
206 text_view_style: None,
207 style: StyleRefinement::default(),
208 state: None,
209 selectable: true,
210 selection_format: SelectionFormat::default(),
211 scrollable: false,
212 max_lines: None,
213 code_block_actions: None,
214 code_block_highlighter: None,
215 table_actions: None,
216 link_click_handler: None,
217 markdown_extensions: Arc::default(),
218 }
219 }
220
221 pub fn style(mut self, style: TextViewStyle) -> Self {
223 self.text_view_style = Some(style);
224 self
225 }
226
227 pub fn selectable(mut self, selectable: bool) -> Self {
229 self.selectable = selectable;
230 self
231 }
232
233 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
238 self.selection_format = selection_format;
239 self
240 }
241
242 pub fn scrollable(mut self, scrollable: bool) -> Self {
255 self.scrollable = scrollable;
256 self
257 }
258
259 pub fn max_lines(mut self, max_lines: usize) -> Self {
278 self.max_lines = Some(max_lines);
279 self
280 }
281
282 pub fn code_block_actions<F, E>(mut self, f: F) -> Self
287 where
288 F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
289 E: IntoElement,
290 {
291 self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
292 f(&code_block, window, cx).into_any_element()
293 }));
294 self
295 }
296
297 pub fn code_block_highlighter<F>(mut self, highlighter: F) -> Self
302 where
303 F: Fn(&CodeBlock) -> Vec<(Range<usize>, gpui::HighlightStyle)> + Send + Sync + 'static,
304 {
305 self.code_block_highlighter = Some(Arc::new(highlighter));
306 self
307 }
308
309 pub fn table_actions<F, E>(mut self, f: F) -> Self
314 where
315 F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
316 E: IntoElement,
317 {
318 self.table_actions = Some(Arc::new(move |table, window, cx| {
319 f(table, window, cx).into_any_element()
320 }));
321 self
322 }
323
324 pub fn on_link_click<F>(mut self, handler: F) -> Self
329 where
330 F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
331 {
332 self.link_click_handler = Some(Arc::new(handler));
333 self
334 }
335
336 pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self {
338 self.markdown_extensions = Arc::new(extensions);
339 self
340 }
341
342 pub fn markdown_mdx(mut self) -> Self {
347 let extensions = Arc::make_mut(&mut self.markdown_extensions);
348 *extensions = extensions.clone().mdx();
349 self
350 }
351
352 pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
358 where
359 F: for<'a> Fn(
360 &markdown::mdast::Node,
361 &crate::text::MarkdownParseContext<'a>,
362 ) -> Option<MarkdownNode>
363 + Send
364 + Sync
365 + 'static,
366 {
367 Arc::make_mut(&mut self.markdown_extensions).push_block_parser(parser);
368 self
369 }
370
371 pub fn markdown_block_renderer<F, E>(
373 mut self,
374 name: impl Into<SharedString>,
375 renderer: F,
376 ) -> Self
377 where
378 F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
379 E: IntoElement,
380 {
381 Arc::make_mut(&mut self.markdown_extensions).push_block_renderer(name, renderer);
382 self
383 }
384
385 pub fn plugin<P>(self, plugin: P) -> Self
387 where
388 P: TextViewPlugin,
389 {
390 plugin.setup(self)
391 }
392}
393
394impl IntoElement for TextView {
395 type Element = Self;
396
397 fn into_element(self) -> Self::Element {
398 self
399 }
400}
401
402pub struct TextViewLayoutState {
403 state: Entity<TextViewState>,
404 element: AnyElement,
405}
406
407pub struct TextViewPrepaintState {
408 hitbox: Hitbox,
409 clip_bottom: Option<Pixels>,
413}
414
415const CLIP_EPSILON: Pixels = px(1.);
418
419fn last_line_bottom_above(spans: &[LineSpan], y: Pixels) -> Option<(Pixels, Pixels)> {
422 let mut last: Option<(Pixels, Pixels)> = None;
423 let mut keep = |bottom: Pixels, line_height: Pixels| {
424 if bottom <= y + CLIP_EPSILON && last.is_none_or(|(last, _)| bottom > last) {
425 last = Some((bottom, line_height));
426 }
427 };
428
429 for span in spans {
430 if span.line_height <= px(0.) {
431 continue;
432 }
433 let mut bottom = span.top + span.line_height;
434 while bottom <= span.bottom + CLIP_EPSILON {
435 keep(bottom, span.line_height);
436 bottom += span.line_height;
437 }
438 keep(span.bottom, span.line_height);
440 }
441
442 last
443}
444
445fn line_safe_clip_bottom(
455 spans: &[LineSpan],
456 box_bottom: Pixels,
457 content_bottom: Pixels,
458) -> Option<Pixels> {
459 let mut clip = box_bottom;
460
461 for span in spans {
462 if span.line_height <= px(0.)
463 || span.top >= box_bottom
464 || span.bottom <= box_bottom + CLIP_EPSILON
465 {
466 continue;
467 }
468 let whole_lines = ((box_bottom - span.top) / span.line_height).floor();
469 let line_top = span.top + span.line_height * whole_lines;
470 if line_top < box_bottom - CLIP_EPSILON {
472 clip = clip.min(line_top);
473 }
474 }
475
476 let Some((last_line_bottom, line_height)) = last_line_bottom_above(spans, clip) else {
477 return None;
482 };
483
484 if content_bottom > box_bottom + CLIP_EPSILON {
488 let strip = clip - last_line_bottom;
489 if strip > CLIP_EPSILON && strip < line_height {
490 clip = last_line_bottom;
491 }
492 }
493
494 (clip < box_bottom - CLIP_EPSILON).then_some(clip)
495}
496
497impl Element for TextView {
498 type RequestLayoutState = TextViewLayoutState;
499 type PrepaintState = TextViewPrepaintState;
500
501 fn id(&self) -> Option<ElementId> {
502 Some(self.id.clone())
503 }
504
505 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
506 None
507 }
508
509 fn request_layout(
510 &mut self,
511 _: Option<&GlobalElementId>,
512 _: Option<&InspectorElementId>,
513 window: &mut Window,
514 cx: &mut App,
515 ) -> (LayoutId, Self::RequestLayoutState) {
516 let state = if let Some(state) = self.state.clone() {
517 state
518 } else {
519 let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
520 let default_text = self.text.clone().unwrap_or_default();
521
522 let state = window.use_keyed_state(
523 SharedString::from(format!("{}/state", self.id)),
524 cx,
525 move |_, cx| {
526 if default_format == TextViewFormat::Markdown {
527 TextViewState::markdown(default_text.as_str(), cx)
528 } else {
529 TextViewState::html(default_text.as_str(), cx)
530 }
531 },
532 );
533 self.state = Some(state.clone());
534 state
535 };
536
537 let max_lines = self.max_lines.filter(|_| !self.scrollable);
540
541 let defaults = TextViewDefaults::global(cx);
542 let text_view_style = self
543 .text_view_style
544 .clone()
545 .or(defaults.style)
546 .unwrap_or_else(|| TextViewStyle::from_theme(&crate::Theme::global(cx)));
547 let code_block_highlighter = self
548 .code_block_highlighter
549 .clone()
550 .or(defaults.code_block_highlighter);
551
552 state.update(cx, |state, cx| {
553 state.code_block_actions = self.code_block_actions.clone();
554 state.code_block_highlighter = code_block_highlighter.clone();
555 state.table_actions = self.table_actions.clone();
556 state.link_click_handler = self.link_click_handler.clone();
557 state.set_markdown_extensions(self.markdown_extensions.clone(), cx);
558 state.selectable = self.selectable;
559 state.selection_format = self.selection_format;
560 state.scrollable = self.scrollable;
561 state.max_lines = max_lines;
562 if state.text_view_style != text_view_style {
563 state.selection_revision = state.selection_revision.wrapping_add(1);
564 }
565 state.text_view_style = text_view_style.clone();
566
567 if let Some(text) = self.text.clone() {
568 state.set_text(text.as_str(), cx);
569 }
570 });
571
572 let focus_handle = state.read(cx).focus_handle.clone();
573 let list_state = state.read(cx).list_state.clone();
574 let max_lines_cap = max_lines.map(|max_lines| {
578 let mut text_style = window.text_style();
579 text_style.refine(&self.style.text);
580 text_style.line_height_in_pixels(window.rem_size()) * max_lines as f32
581 });
582
583 let mut el = div()
584 .id(("text-view-scroll", state.entity_id()))
585 .key_context("TextView")
586 .track_focus(&focus_handle)
587 .when(self.scrollable, |this| this.size_full())
588 .when_some(max_lines_cap, |this, cap| this.max_h(cap).overflow_hidden())
589 .relative()
590 .text_color(text_view_style.foreground())
591 .on_action(move |_: &crate::input::Copy, window, cx| {
592 let text = TextSelection::selected_text(window, cx).trim().to_string();
593 if text.is_empty() {
594 cx.propagate();
595 return;
596 }
597 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
598 })
599 .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
600 .child(state.clone())
601 .when(self.scrollable, |this| {
604 this.child(
605 div().absolute().inset_0().child(
606 crate::Scrollbar::vertical(&list_state)
607 .id(("text-view-scrollbar", state.entity_id()))
608 .viewport_from_layout(),
609 ),
610 )
611 })
612 .refine_style(&self.style)
613 .into_any_element();
614 let layout_id = el.request_layout(window, cx);
615 (layout_id, TextViewLayoutState { state, element: el })
616 }
617
618 fn prepaint(
619 &mut self,
620 _: Option<&GlobalElementId>,
621 _: Option<&InspectorElementId>,
622 bounds: Bounds<Pixels>,
623 request_layout: &mut Self::RequestLayoutState,
624 window: &mut Window,
625 cx: &mut App,
626 ) -> Self::PrepaintState {
627 let state = request_layout.state.clone();
628 let max_lines_active = state.read(cx).max_lines.is_some();
629 if max_lines_active {
630 if let Ok(mut line_spans) = state.read(cx).line_spans.lock() {
631 line_spans.clear();
632 }
633 GlobalState::global_mut(cx)
636 .text_view_state_stack
637 .push(state.clone());
638 }
639 request_layout.element.prepaint(window, cx);
640 if max_lines_active {
641 GlobalState::global_mut(cx).text_view_state_stack.pop();
642 }
643
644 let mut clip_bottom = None;
645 if max_lines_active {
646 let (line_spans, content_bottom) = {
647 let state = state.read(cx);
648 (
649 state
650 .line_spans
651 .lock()
652 .map(|spans| spans.clone())
653 .unwrap_or_default(),
654 state.bounds().bottom(),
655 )
656 };
657 let clipped = content_bottom > bounds.bottom() + px(1.);
661 if state.read(cx).clamped != clipped {
664 state.update(cx, |state, cx| {
665 state.clamped = clipped;
666 cx.notify();
667 });
668 }
669 if clipped {
670 clip_bottom = line_safe_clip_bottom(&line_spans, bounds.bottom(), content_bottom);
671 }
672 }
673
674 TextViewPrepaintState {
675 hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
676 clip_bottom,
677 }
678 }
679
680 fn paint(
681 &mut self,
682 _: Option<&GlobalElementId>,
683 _: Option<&InspectorElementId>,
684 bounds: Bounds<Pixels>,
685 request_layout: &mut Self::RequestLayoutState,
686 prepaint: &mut Self::PrepaintState,
687 window: &mut Window,
688 cx: &mut App,
689 ) {
690 let state = &request_layout.state;
691 if self.selectable {
692 state.update(cx, |state, _| state.selection_adapter.begin_frame());
693 }
694
695 GlobalState::global_mut(cx)
696 .text_view_state_stack
697 .push(state.clone());
698 if let Some(clip_bottom) = prepaint.clip_bottom {
699 let mask = ContentMask {
702 bounds: Bounds::from_corners(bounds.origin, point(bounds.right(), clip_bottom)),
703 };
704 window.with_content_mask(Some(mask), |window| {
705 request_layout.element.paint(window, cx);
706 });
707 } else {
708 request_layout.element.paint(window, cx);
709 }
710 GlobalState::global_mut(cx).text_view_state_stack.pop();
711
712 if self.selectable {
713 let (adapter, scroll_offset, content_bounds) = {
714 let state = state.read(cx);
715 (
716 state.selection_adapter.clone(),
717 state.scroll_offset(),
718 state.bounds(),
719 )
720 };
721 let document_order = GlobalState::global_mut(cx).next_selection_document_order();
722 adapter.register(
723 prepaint.hitbox.clone(),
724 content_bounds,
725 scroll_offset,
726 document_order,
727 window,
728 cx,
729 );
730 }
731 }
732}
733
734#[cfg(test)]
735mod tests {
736 use std::sync::{
737 Arc,
738 atomic::{AtomicUsize, Ordering},
739 };
740
741 use super::{TextView, TextViewPlugin};
742 use crate::text::{TableData, TextViewState, TextViewStyle};
743 use gpui::{
744 AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, IntoElement,
745 Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Overflow, ParentElement as _, Pixels,
746 Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
747 TestAppContext, VisualTestContext, Window, div, point, px,
748 };
749
750 struct TextViewTestRoot {
751 text_view: Entity<TextViewState>,
752 }
753
754 struct StatelessMarkdownRoot {
755 renders: Arc<AtomicUsize>,
756 }
757
758 impl Render for StatelessMarkdownRoot {
759 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
760 self.renders.fetch_add(1, Ordering::Relaxed);
761 div().child(
762 TextView::markdown("stateless-markdown", include_str!("../../../../README.md"))
763 .markdown_block_parser(|_, _| None),
764 )
765 }
766 }
767
768 struct DummyTextViewPlugin;
769
770 impl TextViewPlugin for DummyTextViewPlugin {
771 fn setup(self, mut text_view: TextView) -> TextView {
772 text_view.selectable = true;
773 text_view
774 }
775 }
776
777 #[gpui::test]
778 fn text_view_constructors_are_selectable_by_default(cx: &mut TestAppContext) {
779 cx.update(crate::init);
780 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("state", cx)));
781
782 assert!(TextView::new(&state).selectable);
783 assert!(TextView::markdown("markdown", "text").selectable);
784 assert!(TextView::html("html", "<p>text</p>").selectable);
785 }
786
787 #[gpui::test]
788 fn stateless_markdown_with_rebuilt_parser_settles(cx: &mut TestAppContext) {
789 cx.update(crate::init);
790 let renders = Arc::new(AtomicUsize::new(0));
791 let (_, cx) = cx.add_window_view({
792 let renders = renders.clone();
793 move |_, _| StatelessMarkdownRoot { renders }
794 });
795 let cx: &mut VisualTestContext = cx;
796
797 cx.run_until_parked();
798 assert!(
799 renders.load(Ordering::Relaxed) <= 2,
800 "an unchanged TextView must settle after its parse, but rendered {} times",
801 renders.load(Ordering::Relaxed),
802 );
803 }
804
805 #[gpui::test]
806 fn unstyled_text_view_uses_base_tokens_for_link_and_input_selection(cx: &mut TestAppContext) {
807 cx.update(crate::init);
808 cx.update(|cx| {
809 let colors = &mut crate::Theme::global_mut(cx).tokens.colors;
810 colors.primary = gpui::rgb(0x55aaff).into();
811 colors.selection = gpui::rgb(0x335577).into();
812 });
813 let (root, cx) = cx.add_window_view(|_, cx| TextViewTestRoot::new("[link](url)", cx));
814 let cx: &mut VisualTestContext = cx;
815
816 cx.run_until_parked();
817 root.read_with(cx, |root, cx| {
818 let style = &root.text_view.read(cx).text_view_style;
819 assert_eq!(style.link(), gpui::rgb(0x55aaff).into());
820 assert_eq!(style.selection(), gpui::rgb(0x335577).into());
821 });
822 }
823
824 impl TextViewTestRoot {
825 fn new(text: &str, cx: &mut Context<Self>) -> Self {
826 let text = text.to_string();
827 let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
828 Self { text_view }
829 }
830 }
831
832 impl Render for TextViewTestRoot {
833 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
834 div()
835 .w(px(160.))
836 .child(
837 div()
838 .h(px(24.))
839 .overflow_hidden()
840 .child(TextView::new(&self.text_view).selectable(true)),
841 )
842 .child(div().h(px(40.)).child("footer"))
843 }
844 }
845
846 struct TableSelectionTestRoot {
847 text_view: Entity<TextViewState>,
848 }
849
850 impl Render for TableSelectionTestRoot {
851 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
852 div()
853 .debug_selector(|| "table-selection-root".into())
854 .w(px(520.))
855 .child(crate::TextSelectionLayer)
856 .child(TextView::new(&self.text_view))
857 }
858 }
859
860 #[gpui::test]
861 fn table_drag_selection_settles_without_requesting_idle_frames(cx: &mut TestAppContext) {
862 cx.update(crate::init);
863 let (_, cx) = cx.add_window_view(|_, cx| TableSelectionTestRoot {
864 text_view: cx.new(|cx| {
865 TextViewState::markdown(
866 "| Header 1 | Header 2 |\n| --- | --- |\n| Cell A | Cell B |\n| Cell C | Cell D |",
867 cx,
868 )
869 }),
870 });
871 let cx: &mut VisualTestContext = cx;
872
873 cx.run_until_parked();
874 let bounds = cx
875 .debug_bounds("table-selection-root")
876 .expect("table bounds");
877 let start = point(bounds.left() + px(24.), bounds.top() + px(16.));
878 let end = point(bounds.right() - px(24.), bounds.bottom() - px(16.));
879 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
880 cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
881 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
882
883 assert!(cx.update(|window, cx| crate::TextSelection::has_selection(window, cx)));
884 assert_eq!(
885 cx.update(|window, cx| window.simulate_next_frame(cx)),
886 0,
887 "finished table selection must not continuously request frames"
888 );
889 }
890
891 struct InlineImageTextViewTestRoot {
892 text_view: Entity<TextViewState>,
893 }
894
895 impl InlineImageTextViewTestRoot {
896 fn new(cx: &mut Context<Self>) -> Self {
897 let text_view = cx.new(|cx| {
898 TextViewState::markdown(
899 "Build Status  after",
900 cx,
901 )
902 });
903 Self { text_view }
904 }
905 }
906
907 impl Render for InlineImageTextViewTestRoot {
908 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
909 div()
910 .w(px(420.))
911 .child(TextView::new(&self.text_view).selectable(true))
912 }
913 }
914
915 #[gpui::test]
916 fn inline_image_keeps_surrounding_text_on_same_line(cx: &mut TestAppContext) {
917 cx.update(crate::init);
918 let (content, cx) = cx.add_window_view(|_, cx| InlineImageTextViewTestRoot::new(cx));
919 let cx: &mut VisualTestContext = cx;
920
921 cx.run_until_parked();
922 cx.update(|window, cx| {
923 let _ = window.draw(cx);
924 });
925
926 let inline_bounds = content.read_with(cx, |content, cx| {
927 content.text_view.read(cx).selection_adapter.text_bounds()
928 });
929
930 assert_eq!(inline_bounds.len(), 2);
931 assert_eq!(
932 inline_bounds[0].top(),
933 inline_bounds[1].top(),
934 "text before and after an inline image should share a rendered line"
935 );
936 assert!(
937 inline_bounds[1].left() - inline_bounds[0].right() > px(8.),
938 "inline image should reserve horizontal space in the text layout"
939 );
940 assert!(
941 inline_bounds[1].left() - inline_bounds[0].right() < px(40.),
942 "unloaded inline image fallback should stay generic and compact"
943 );
944 }
945
946 #[gpui::test]
947 fn inline_html_image_after_newline_does_not_panic(cx: &mut TestAppContext) {
948 cx.update(crate::init);
949 let (_, cx) = cx.add_window_view(|_, cx| {
950 TextViewTestRoot::new(
951 "Hi\n[<img src=\"https://example.com/image.svg\">](https://google.com/)",
952 cx,
953 )
954 });
955 let cx: &mut VisualTestContext = cx;
956
957 cx.run_until_parked();
958 cx.update(|window, cx| {
959 let _ = window.draw(cx);
960 });
961 }
962
963 #[gpui::test]
964 fn list_item_renders_fenced_code_block_at_document_width(cx: &mut TestAppContext) {
965 struct ListItemBlockRoot;
966
967 impl Render for ListItemBlockRoot {
968 fn render(
969 &mut self,
970 _window: &mut Window,
971 _cx: &mut Context<Self>,
972 ) -> impl IntoElement {
973 div().w(px(840.)).h(px(400.)).child(
974 crate::h_resizable("markdown-width-test")
975 .child(crate::resizable_panel().child(div()))
976 .child(crate::resizable_panel().child(
977 TextView::markdown(
978 "list-with-code",
979 "1. List item\n ```rust\n nested code\n ```\n\n```rust\ntop-level code\n```",
980 )
981 .code_block_actions(|code_block, _, _| {
982 let selector = if code_block.code().contains("nested") {
983 "nested-code-action"
984 } else {
985 "top-level-code-action"
986 };
987 div()
988 .debug_selector(move || selector.into())
989 .child("Copy")
990 })
991 .scrollable(true)
992 .p_5()
993 .flex_none(),
994 )),
995 )
996 }
997 }
998
999 cx.update(crate::init);
1000 let (_, cx) = cx.add_window_view(|_, _| ListItemBlockRoot);
1001 let cx: &mut VisualTestContext = cx;
1002
1003 cx.run_until_parked();
1004 cx.update(|window, cx| {
1005 let _ = window.draw(cx);
1006 });
1007
1008 let nested_action = cx.debug_bounds("nested-code-action").unwrap();
1009 let top_level_action = cx.debug_bounds("top-level-code-action").unwrap();
1010 assert!(
1011 top_level_action.right() - nested_action.right() < px(32.),
1012 "nested code block should fill the list item's available width"
1013 );
1014 }
1015
1016 fn draw_table_with_actions(
1020 cx: &mut TestAppContext,
1021 scroll: bool,
1022 ) -> (Bounds<Pixels>, TableData) {
1023 use std::sync::{Arc, Mutex};
1024
1025 struct TableRoot {
1026 scroll: bool,
1027 captured: Arc<Mutex<Vec<TableData>>>,
1028 }
1029
1030 impl Render for TableRoot {
1031 fn render(
1032 &mut self,
1033 _window: &mut Window,
1034 _cx: &mut Context<Self>,
1035 ) -> impl IntoElement {
1036 let captured = self.captured.clone();
1037 let mut table_style = StyleRefinement::default();
1038 if self.scroll {
1039 table_style.overflow.x = Some(Overflow::Scroll);
1040 }
1041
1042 div().w(px(320.)).child(
1043 TextView::markdown(
1044 "table-actions",
1045 "| Name | Age |\n|:--|--:|\n| Alice | 30 |\n| Bob | 41 |",
1046 )
1047 .style(TextViewStyle::default().with_table(table_style))
1048 .table_actions(move |table, _, _| {
1049 if let Ok(mut captured) = captured.lock() {
1050 captured.push(table.clone());
1051 }
1052 div().debug_selector(|| "table-action".into()).child("Copy")
1053 }),
1054 )
1055 }
1056 }
1057
1058 cx.update(crate::init);
1059 let captured = Arc::new(Mutex::new(Vec::new()));
1060 let (_, cx) = cx.add_window_view({
1061 let captured = captured.clone();
1062 move |_, _| TableRoot { scroll, captured }
1063 });
1064 let cx: &mut VisualTestContext = cx;
1065
1066 cx.run_until_parked();
1067 cx.update(|window, cx| {
1068 let _ = window.draw(cx);
1069 });
1070
1071 let bounds = cx
1072 .debug_bounds("table-action")
1073 .expect("table actions should be painted");
1074 let data = captured
1075 .lock()
1076 .expect("captured table data")
1077 .last()
1078 .cloned()
1079 .expect("table actions hook should receive the table");
1080
1081 (bounds, data)
1082 }
1083
1084 #[gpui::test]
1085 fn table_actions_render_below_the_table(cx: &mut TestAppContext) {
1086 for scroll in [false, true] {
1087 let (bounds, data) = draw_table_with_actions(cx, scroll);
1088
1089 assert!(
1091 bounds.top() > px(40.),
1092 "actions should sit below the table (scroll: {scroll}), got {:?}",
1093 bounds.top()
1094 );
1095 assert_eq!(data.headers, vec!["Name", "Age"]);
1096 assert_eq!(data.rows, vec![vec!["Alice", "30"], vec!["Bob", "41"]]);
1097 assert_eq!(
1098 data.markdown,
1099 "| Name | Age |\n| :-- | --: |\n| Alice | 30 |\n| Bob | 41 |"
1100 );
1101 assert_eq!(data.span, Some(0..52));
1102 }
1103 }
1104
1105 #[test]
1106 fn plugin_accepts_text_view_plugins_beyond_markdown() {
1107 let view = TextView::markdown("plugin-test", "").plugin(DummyTextViewPlugin);
1108
1109 assert!(view.selectable);
1110 }
1111
1112 #[test]
1113 fn syntax_highlighting_is_opt_in() {
1114 let default_view = TextView::markdown("default-code", "```rust\nfn main() {}\n```");
1115 assert!(default_view.code_block_highlighter.is_none());
1116
1117 let view = default_view.code_block_highlighter(|block| {
1118 vec![(
1119 0..block.code().len(),
1120 gpui::HighlightStyle {
1121 color: Some(gpui::rgb(0x3366ff).into()),
1122 ..Default::default()
1123 },
1124 )]
1125 });
1126 assert!(view.code_block_highlighter.is_some());
1127 }
1128
1129 #[gpui::test]
1130 fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
1131 cx.update(crate::init);
1132 let (_, cx) = cx.add_window_view(|_, cx| {
1133 TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
1134 });
1135 let cx: &mut VisualTestContext = cx;
1136
1137 cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
1138
1139 assert_eq!(cx.opened_url(), None);
1140 }
1141
1142 struct MaxLinesTestRoot {
1143 text_view: Entity<TextViewState>,
1144 max_lines: usize,
1145 }
1146
1147 impl MaxLinesTestRoot {
1148 fn new(text: &str, max_lines: usize, cx: &mut Context<Self>) -> Self {
1149 let text_view = cx.new(|cx| TextViewState::markdown(text, cx));
1150 Self {
1151 text_view,
1152 max_lines,
1153 }
1154 }
1155 }
1156
1157 impl Render for MaxLinesTestRoot {
1158 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1159 div()
1160 .w(px(200.))
1161 .child(TextView::new(&self.text_view).max_lines(self.max_lines))
1162 }
1163 }
1164
1165 #[test]
1166 fn the_clip_only_moves_for_a_straddling_glyph_line() {
1167 use super::line_safe_clip_bottom;
1168 use crate::text::state::LineSpan;
1169
1170 let spans = [
1171 LineSpan {
1173 top: px(0.),
1174 bottom: px(60.),
1175 line_height: px(20.),
1176 },
1177 LineSpan {
1179 top: px(68.),
1180 bottom: px(128.),
1181 line_height: px(20.),
1182 },
1183 ];
1184
1185 let below = px(400.);
1187
1188 assert_eq!(
1190 line_safe_clip_bottom(&spans, px(100.), below),
1191 Some(px(88.))
1192 );
1193
1194 assert_eq!(line_safe_clip_bottom(&spans, px(88.), below), None);
1196
1197 assert_eq!(line_safe_clip_bottom(&spans, px(64.), below), Some(px(60.)));
1200
1201 let one_block = [LineSpan {
1204 top: px(0.),
1205 bottom: px(60.),
1206 line_height: px(20.),
1207 }];
1208 assert_eq!(line_safe_clip_bottom(&one_block, px(200.), below), None);
1209
1210 assert_eq!(line_safe_clip_bottom(&spans, px(130.), px(128.)), None);
1213 }
1214
1215 #[test]
1216 fn a_line_taller_than_the_budget_keeps_the_part_that_fits() {
1217 use super::line_safe_clip_bottom;
1218 use crate::text::state::LineSpan;
1219
1220 let heading = [LineSpan {
1222 top: px(70.),
1223 bottom: px(98.),
1224 line_height: px(28.),
1225 }];
1226
1227 assert_eq!(line_safe_clip_bottom(&heading, px(96.), px(400.)), None);
1228 }
1229
1230 #[test]
1231 fn the_clip_does_not_stop_on_a_row_of_border_and_padding() {
1232 use super::line_safe_clip_bottom;
1233 use crate::text::state::LineSpan;
1234
1235 let rows = [
1238 LineSpan {
1239 top: px(100.),
1240 bottom: px(126.),
1241 line_height: px(26.),
1242 },
1243 LineSpan {
1244 top: px(135.),
1245 bottom: px(161.),
1246 line_height: px(26.),
1247 },
1248 ];
1249
1250 assert_eq!(
1253 line_safe_clip_bottom(&rows, px(148.), px(400.)),
1254 Some(px(126.))
1255 );
1256 }
1257
1258 struct ClampedPageRoot {
1264 text_view: Entity<TextViewState>,
1265 max_lines: usize,
1266 }
1267
1268 impl Render for ClampedPageRoot {
1269 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1270 use crate::{h_flex, v_flex};
1271
1272 v_flex()
1273 .size_full()
1274 .p_4()
1275 .gap_4()
1276 .child(h_flex().max_w(px(480.)).gap_3().child("header"))
1277 .child(
1278 v_flex()
1279 .flex_1()
1280 .min_h_0()
1281 .gap_4()
1282 .id("clamped-page-scroll")
1283 .child(
1284 v_flex()
1285 .max_w(px(480.))
1286 .p_3()
1287 .gap_2()
1288 .child(TextView::new(&self.text_view).max_lines(self.max_lines)),
1289 )
1290 .overflow_y_scroll(),
1291 )
1292 }
1293 }
1294
1295 #[gpui::test]
1296 fn max_lines_measures_overflow_inside_a_sized_page(cx: &mut TestAppContext) {
1297 cx.update(crate::init);
1298 let (root, cx) = cx.add_window_view(|_, cx| {
1299 let text_view = cx.new(|cx| {
1300 TextViewState::markdown(
1301 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth\n\nseventh",
1302 cx,
1303 )
1304 });
1305 ClampedPageRoot {
1306 text_view,
1307 max_lines: 3,
1308 }
1309 });
1310 let cx: &mut VisualTestContext = cx;
1311
1312 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1313 }
1314
1315 #[gpui::test]
1316 fn max_lines_clamps_overflowing_content(cx: &mut TestAppContext) {
1317 cx.update(crate::init);
1318 let (root, cx) = cx.add_window_view(|_, cx| {
1319 MaxLinesTestRoot::new(
1320 "first\n\nsecond\n\nthird\n\nfourth\n\nfifth\n\nsixth",
1321 2,
1322 cx,
1323 )
1324 });
1325 let cx: &mut VisualTestContext = cx;
1326
1327 assert!(root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1328 }
1329
1330 #[gpui::test]
1331 fn max_lines_leaves_short_content_unclamped(cx: &mut TestAppContext) {
1332 cx.update(crate::init);
1333 let (root, cx) = cx.add_window_view(|_, cx| MaxLinesTestRoot::new("only line", 3, cx));
1334 let cx: &mut VisualTestContext = cx;
1335
1336 assert!(!root.read_with(cx, |root, cx| root.text_view.read(cx).is_clamped()));
1337 }
1338
1339 #[gpui::test]
1340 fn max_lines_disables_links_hidden_by_the_clamp(cx: &mut TestAppContext) {
1341 cx.update(crate::init);
1342 let (_, cx) = cx.add_window_view(|_, cx| {
1343 MaxLinesTestRoot::new(
1344 "first\n\nsecond\n\nthird\n\n[hidden](https://example.com)",
1345 2,
1346 cx,
1347 )
1348 });
1349 let cx: &mut VisualTestContext = cx;
1350
1351 cx.simulate_click(point(px(10.), px(150.)), Modifiers::default());
1353
1354 assert_eq!(cx.opened_url(), None);
1355 }
1356
1357 #[gpui::test]
1358 fn markdown_link_opens_url_without_handler(cx: &mut TestAppContext) {
1359 cx.update(crate::init);
1360 let (_, cx) =
1361 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
1362 let cx: &mut VisualTestContext = cx;
1363
1364 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1365
1366 assert_eq!(cx.opened_url(), Some("https://example.com".to_string()));
1367 }
1368
1369 #[gpui::test]
1370 fn right_click_does_not_open_url_without_handler(cx: &mut TestAppContext) {
1371 cx.update(crate::init);
1372 let (_, cx) =
1373 cx.add_window_view(|_, cx| TextViewTestRoot::new("[example](https://example.com)", cx));
1374 let cx: &mut VisualTestContext = cx;
1375
1376 cx.simulate_mouse_down(
1377 point(px(10.), px(10.)),
1378 MouseButton::Right,
1379 Modifiers::default(),
1380 );
1381 cx.simulate_mouse_up(
1382 point(px(10.), px(10.)),
1383 MouseButton::Right,
1384 Modifiers::default(),
1385 );
1386
1387 assert_eq!(cx.opened_url(), None);
1388 }
1389
1390 #[gpui::test]
1391 fn link_handler_receives_button_and_modifiers(cx: &mut TestAppContext) {
1392 use std::sync::{Arc, Mutex};
1393
1394 struct LinkRoot {
1395 text_view: Entity<TextViewState>,
1396 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
1397 }
1398
1399 impl Render for LinkRoot {
1400 fn render(
1401 &mut self,
1402 _window: &mut Window,
1403 _cx: &mut Context<Self>,
1404 ) -> impl IntoElement {
1405 let clicks = self.clicks.clone();
1406 div()
1407 .w(px(240.))
1408 .child(
1409 TextView::new(&self.text_view).on_link_click(move |url, event, _, _| {
1410 clicks.lock().unwrap().push((url.clone(), event.clone()));
1411 }),
1412 )
1413 }
1414 }
1415
1416 cx.update(crate::init);
1417 let clicks = Arc::new(Mutex::new(Vec::new()));
1418 let captured = clicks.clone();
1419 let (_, cx) = cx.add_window_view(move |_, cx| LinkRoot {
1420 text_view: cx.new(|cx| TextViewState::markdown("[example](https://example.com)", cx)),
1421 clicks,
1422 });
1423 let cx: &mut VisualTestContext = cx;
1424
1425 let mut modifiers = Modifiers::default();
1426 modifiers.control = true;
1427 cx.simulate_click(point(px(10.), px(10.)), modifiers);
1428 cx.simulate_mouse_down(
1429 point(px(10.), px(10.)),
1430 MouseButton::Middle,
1431 Modifiers::default(),
1432 );
1433 cx.simulate_mouse_up(
1434 point(px(10.), px(10.)),
1435 MouseButton::Middle,
1436 Modifiers::default(),
1437 );
1438 cx.simulate_mouse_down(
1439 point(px(10.), px(10.)),
1440 MouseButton::Right,
1441 Modifiers::default(),
1442 );
1443 cx.simulate_mouse_up(
1444 point(px(10.), px(10.)),
1445 MouseButton::Right,
1446 Modifiers::default(),
1447 );
1448
1449 let clicks = captured.lock().unwrap();
1450 assert_eq!(clicks.len(), 3);
1451 assert_eq!(clicks[0].0, "https://example.com");
1452 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
1453 assert!(clicks[0].1.modifiers().control);
1454 assert!(clicks[1].1.is_middle_click());
1455 assert!(clicks[2].1.is_right_click());
1456 assert_eq!(cx.opened_url(), None);
1457 }
1458
1459 #[gpui::test]
1460 fn linked_image_handler_receives_left_middle_and_right_clicks(cx: &mut TestAppContext) {
1461 use std::sync::{Arc, Mutex};
1462
1463 struct LinkedImageRoot {
1464 text_view: Entity<TextViewState>,
1465 clicks: Arc<Mutex<Vec<(SharedString, ClickEvent)>>>,
1466 }
1467
1468 impl Render for LinkedImageRoot {
1469 fn render(
1470 &mut self,
1471 _window: &mut Window,
1472 _cx: &mut Context<Self>,
1473 ) -> impl IntoElement {
1474 let clicks = self.clicks.clone();
1475 div().w(px(160.)).child(
1476 TextView::new(&self.text_view)
1477 .selectable(true)
1478 .on_link_click(move |url, event, _, _| {
1479 clicks.lock().unwrap().push((url.clone(), event.clone()));
1480 }),
1481 )
1482 }
1483 }
1484
1485 cx.update(crate::init);
1486 let clicks = Arc::new(Mutex::new(Vec::new()));
1487 let captured = clicks.clone();
1488 let (content, cx) = cx.add_window_view(move |_, cx| LinkedImageRoot {
1489 text_view: cx.new(|cx| {
1490 TextViewState::markdown(
1491 r#"Before [<img src="https://example.com/image.svg" width="32" height="32">](https://example.com/image-link) after."#,
1492 cx,
1493 )
1494 }),
1495 clicks,
1496 }
1497 );
1498 let cx: &mut VisualTestContext = cx;
1499 cx.run_until_parked();
1500 cx.update(|window, cx| {
1501 let _ = window.draw(cx);
1502 });
1503
1504 let inline_bounds = content.read_with(cx, |content, cx| {
1505 content.text_view.read(cx).selection_adapter.text_bounds()
1506 });
1507 assert!(
1508 inline_bounds.len() >= 2,
1509 "linked image needs text bounds on both sides: {inline_bounds:?}"
1510 );
1511 assert!(
1512 inline_bounds[1].left() - inline_bounds[0].right() >= px(24.),
1513 "linked image did not reserve the expected click target: {inline_bounds:?}"
1514 );
1515 let position = point(
1516 inline_bounds[0].right() + (inline_bounds[1].left() - inline_bounds[0].right()) * 0.5,
1517 inline_bounds[0].top() + px(8.),
1518 );
1519 for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
1520 cx.simulate_mouse_down(position, button, Modifiers::default());
1521 cx.simulate_mouse_up(position, button, Modifiers::default());
1522 }
1523
1524 let clicks = captured.lock().unwrap();
1525 assert_eq!(clicks.len(), 3);
1526 assert!(
1527 clicks
1528 .iter()
1529 .all(|(url, _)| url == "https://example.com/image-link")
1530 );
1531 assert!(!clicks[0].1.is_right_click() && !clicks[0].1.is_middle_click());
1532 assert!(clicks[1].1.is_middle_click());
1533 assert!(clicks[2].1.is_right_click());
1534 assert_eq!(cx.opened_url(), None);
1535 }
1536
1537 #[gpui::test]
1538 fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
1539 cx.update(crate::init);
1540 let (view, cx) = cx
1541 .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
1542 let cx: &mut VisualTestContext = cx;
1543
1544 cx.simulate_mouse_down(
1545 point(px(10.), px(34.)),
1546 MouseButton::Left,
1547 Modifiers::default(),
1548 );
1549 cx.simulate_mouse_move(
1550 point(px(90.), px(34.)),
1551 Some(MouseButton::Left),
1552 Modifiers::default(),
1553 );
1554 cx.simulate_mouse_up(
1555 point(px(90.), px(34.)),
1556 MouseButton::Left,
1557 Modifiers::default(),
1558 );
1559
1560 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1561 assert!(
1562 selected_text.is_empty(),
1563 "unexpected selection: {selected_text:?}"
1564 );
1565 }
1566
1567 struct ClippedTallTextViewTestRoot {
1571 text_view: Entity<TextViewState>,
1572 }
1573
1574 impl ClippedTallTextViewTestRoot {
1575 fn new(cx: &mut Context<Self>) -> Self {
1576 let text_view =
1580 cx.new(|cx| TextViewState::markdown("alpha\n\nbravo\n\ncharlie\n\ndelta", cx));
1581 Self { text_view }
1582 }
1583 }
1584
1585 impl Render for ClippedTallTextViewTestRoot {
1586 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1587 div()
1588 .w(px(200.))
1589 .child(crate::TextSelectionLayer)
1590 .child(
1591 div()
1592 .h(px(40.))
1593 .overflow_hidden()
1594 .child(TextView::new(&self.text_view).selectable(true)),
1595 )
1596 .child(div().h(px(160.)))
1599 }
1600 }
1601
1602 #[gpui::test]
1611 fn selection_band_beyond_clip_copies_offscreen_text(cx: &mut TestAppContext) {
1612 cx.update(crate::init);
1613 let (content, cx) = cx.add_window_view(|_, cx| ClippedTallTextViewTestRoot::new(cx));
1614 let cx: &mut VisualTestContext = cx;
1615
1616 cx.run_until_parked();
1617 cx.update(|window, cx| {
1618 let _ = window.draw(cx);
1619 });
1620
1621 cx.simulate_mouse_down(
1625 point(px(2.), px(8.)),
1626 MouseButton::Left,
1627 Modifiers::default(),
1628 );
1629 cx.update(|window, cx| {
1630 let _ = window.draw(cx);
1631 });
1632 cx.simulate_mouse_move(
1633 point(px(180.), px(150.)),
1634 Some(MouseButton::Left),
1635 Modifiers::default(),
1636 );
1637 cx.update(|window, cx| {
1638 let _ = window.draw(cx);
1639 });
1640 cx.simulate_mouse_up(
1641 point(px(180.), px(150.)),
1642 MouseButton::Left,
1643 Modifiers::default(),
1644 );
1645 cx.update(|window, cx| {
1646 let _ = window.draw(cx);
1647 });
1648
1649 let selected_text =
1650 content.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1651 assert!(
1652 selected_text.contains("delta"),
1653 "clipped-out text was not copied: {selected_text:?}"
1654 );
1655 assert!(
1656 selected_text.contains("charlie"),
1657 "clipped-out text was not copied: {selected_text:?}"
1658 );
1659 }
1660
1661 #[gpui::test]
1662 fn double_click_selects_word(cx: &mut TestAppContext) {
1663 cx.update(crate::init);
1664 let (view, cx) =
1665 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
1666
1667 let cx: &mut VisualTestContext = cx;
1668 cx.run_until_parked();
1669 cx.update(|window, cx| {
1670 let _ = window.draw(cx);
1671 });
1672 let position = point(px(10.), px(16.));
1673 cx.simulate_event(MouseDownEvent {
1674 position,
1675 modifiers: Modifiers::default(),
1676 button: MouseButton::Left,
1677 click_count: 2,
1678 first_mouse: false,
1679 });
1680 cx.simulate_event(MouseUpEvent {
1681 position,
1682 modifiers: Modifiers::default(),
1683 button: MouseButton::Left,
1684 click_count: 2,
1685 });
1686 cx.update(|window, cx| {
1687 let _ = window.draw(cx);
1688 });
1689
1690 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1691 assert_eq!(selected_text.trim(), "quick");
1692 }
1693
1694 #[gpui::test]
1695 fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
1696 cx.update(crate::init);
1697 let (view, cx) =
1698 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
1699
1700 let cx: &mut VisualTestContext = cx;
1701 cx.run_until_parked();
1702 cx.update(|window, cx| {
1703 let _ = window.draw(cx);
1704 });
1705
1706 let position = point(px(10.), px(10.));
1707 cx.simulate_event(MouseDownEvent {
1708 position,
1709 modifiers: Modifiers::default(),
1710 button: MouseButton::Left,
1711 click_count: 3,
1712 first_mouse: false,
1713 });
1714 cx.simulate_event(MouseUpEvent {
1715 position,
1716 modifiers: Modifiers::default(),
1717 button: MouseButton::Left,
1718 click_count: 3,
1719 });
1720 cx.update(|window, cx| {
1721 let _ = window.draw(cx);
1722 });
1723
1724 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
1725 assert_eq!(selected_text.trim(), "quick select value");
1726 }
1727
1728 #[gpui::test]
1734 fn outer_list_content_total_stable_while_scrolling(cx: &mut TestAppContext) {
1735 use gpui::{ListAlignment, ListState, list};
1736
1737 const ITEMS: &[&str] = &[
1738 "# Heading\n\nA paragraph long enough to wrap across several lines and produce a non-trivial height.",
1739 "Short.",
1740 "Paragraph A\n\nParagraph B\n\nParagraph C with more words to increase the height.",
1741 "## Subheading\n\n- One\n- Two\n- Three\n\nClosing paragraph.",
1742 "Only one line.",
1743 "**Bold**: medium length text with `code` mixed with regular words.",
1744 "1. First\n2. Second\n3. Third\n\nA short closing paragraph.",
1745 "A long message with enough words to wrap across multiple lines, create a taller item, and verify that off-screen measurement matches visible measurement.",
1746 ];
1747 let n = 40usize;
1748
1749 struct ListRoot {
1750 state: ListState,
1751 }
1752 impl Render for ListRoot {
1753 fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1754 div().w(px(360.)).h(px(500.)).child(
1755 list(self.state.clone(), |ix, _w, _cx| {
1756 div()
1757 .w_full()
1758 .child(TextView::markdown(
1759 ("md", ix as u64),
1760 ITEMS[ix % ITEMS.len()],
1761 ))
1762 .into_any_element()
1763 })
1764 .size_full(),
1765 )
1766 }
1767 }
1768
1769 cx.update(crate::init);
1770 let state = ListState::new(n, ListAlignment::Top, px(2048.)).measure_all();
1771 let probe = state.clone();
1772 let (_view, cx) = cx.add_window_view(|_w, _cx| ListRoot { state });
1773 let cx: &mut VisualTestContext = cx;
1774
1775 cx.run_until_parked();
1776 cx.update(|w, cx| {
1777 let _ = w.draw(cx);
1778 });
1779 cx.run_until_parked();
1780 cx.update(|w, cx| {
1781 let _ = w.draw(cx);
1782 });
1783
1784 let total = |p: &ListState| {
1785 f32::from(p.max_offset_for_scrollbar().y + p.viewport_bounds().size.height)
1786 };
1787 let mut totals = vec![total(&probe)];
1788 for _ in 0..20 {
1789 probe.scroll_by(px(150.));
1790 cx.update(|w, cx| {
1791 let _ = w.draw(cx);
1792 });
1793 cx.run_until_parked();
1794 totals.push(total(&probe));
1795 }
1796 let min = totals.iter().cloned().fold(f32::INFINITY, f32::min);
1797 let max = totals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1798 println!(
1799 "OUTER_LIST_PROBE min={min:.1} max={max:.1} delta={:.1}",
1800 max - min
1801 );
1802 assert!(
1803 (max - min) < 2.0,
1804 "list content total jittered while scrolling: min={min} max={max} totals={totals:?}"
1805 );
1806 }
1807}