1use crate::control::{PressReleaseExt as _, composed_button};
4use crate::motion::swap_progress;
5use crate::theme::SemanticStyledExt as _;
6use gpui::{
7 AnyElement, App, AppContext as _, Axis, Bounds, Element, ElementId, Entity, EventEmitter,
8 FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement,
9 KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels,
10 Point, Render, Role, ScrollHandle, SharedString, Size, Stateful,
11 StatefulInteractiveElement as _, Styled, Subscription, Window, div, point,
12 prelude::FluentBuilder as _,
13};
14use gpui_base::InteractiveElementExt as _;
15use gpui_base::StyledExt as _;
16use gpui_base::{Align, Placement, Positioner};
17use gpui_component::{
18 ActiveTheme as _,
19 scroll::ScrollableMask,
20 text::{TextView, TextViewState},
21};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SelectionAction {
26 id: SharedString,
27 label: SharedString,
28}
29
30impl SelectionAction {
31 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
33 Self {
34 id: id.into(),
35 label: label.into(),
36 }
37 }
38
39 pub fn id(&self) -> &SharedString {
41 &self.id
42 }
43
44 pub fn label(&self) -> &SharedString {
46 &self.label
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum SelectionActionsEvent {
53 Invoked {
55 id: SharedString,
57 action_id: SharedString,
59 selected_text: SharedString,
61 },
62}
63
64fn invocation_event(
65 id: impl Into<SharedString>,
66 action: &SelectionAction,
67 selected_text: impl Into<SharedString>,
68) -> SelectionActionsEvent {
69 SelectionActionsEvent::Invoked {
70 id: id.into(),
71 action_id: action.id.clone(),
72 selected_text: selected_text.into(),
73 }
74}
75
76fn settled_selection(text: &str) -> Option<&str> {
77 let selected = text.trim();
78 (!selected.is_empty()).then_some(selected)
79}
80
81fn selection_control(
82 id: impl Into<ElementId>,
83 label: impl Into<SharedString>,
84 window: &mut Window,
85 cx: &mut App,
86) -> gpui_base::Button {
87 let tokens = cx.theme().semantic_tokens();
88 let label = label.into();
89 let id = id.into();
90 composed_button(id.clone(), label.clone())
91 .flex()
92 .items_center()
93 .justify_center()
94 .h(crate::sizing::control_md(cx))
95 .px(tokens.spacing.sm)
96 .border_1()
97 .border_color(tokens.colors.border)
98 .rounded(tokens.radius.sm)
99 .bg(tokens.colors.surface)
100 .text_token(tokens.typography.sm)
101 .text_color(tokens.colors.surface_foreground)
102 .hover(|style| style.bg(cx.theme().button_hover))
103 .active(|style| style.bg(cx.theme().button_active))
104 .focus_visible(|style| style.border_color(tokens.colors.ring))
105 .press_release(ElementId::from((id, "press")), tokens.radius.sm, window, cx)
106 .child(div().child(label))
107}
108
109fn selection_toolbar_frame(
110 id: SharedString,
111 maximum_size: Size<Pixels>,
112 focus_handle: &FocusHandle,
113 scroll_handle: &ScrollHandle,
114 buttons: Vec<gpui_base::Button>,
115 cx: &mut App,
116) -> Stateful<gpui::Div> {
117 let tokens = cx.theme().semantic_tokens();
118 let scroll_id = id.clone();
119 div()
120 .id((ElementId::from(id), "toolbar"))
121 .debug_selector(|| "selection-actions-toolbar".to_owned())
122 .role(Role::Toolbar)
123 .aria_label("Selection actions")
124 .tab_group()
125 .track_focus(focus_handle)
126 .occlude()
127 .max_w(maximum_size.width)
128 .max_h(maximum_size.height)
129 .p(tokens.spacing.xs)
130 .border_1()
131 .border_color(tokens.colors.border)
132 .rounded(tokens.radius.md)
133 .bg(tokens.colors.surface)
134 .shadow(tokens.shadow.md.clone())
135 .child(
136 div()
137 .id((ElementId::from(scroll_id.clone()), "toolbar-scroll"))
138 .max_w_full()
139 .min_w_0()
140 .overflow_x_scroll()
141 .lock_scroll_axis()
142 .track_scroll(scroll_handle)
143 .flex()
144 .items_center()
145 .gap(tokens.spacing.xs)
146 .children(buttons),
147 )
148 .child(
149 ScrollableMask::new(Axis::Horizontal, scroll_handle)
150 .id((ElementId::from(scroll_id), "toolbar-scroll-mask")),
151 )
152}
153
154fn selection_toolbar_positioner(
155 anchor: Point<Pixels>,
156 placement: Placement,
157 toolbar: impl IntoElement,
158 cx: &App,
159) -> Positioner {
160 let spacing = cx.theme().semantic_tokens().spacing;
161 Positioner::side(Bounds::new(anchor, Size::default()))
162 .placement(placement)
163 .align(Align::Center)
164 .offset(spacing.sm)
165 .margin(spacing.sm)
166 .child(toolbar)
167}
168
169fn entrance_travel(anchor_y: Pixels, bounds: Bounds<Pixels>, travel: Pixels) -> Pixels {
171 if bounds.top() >= anchor_y {
172 -travel
173 } else if bounds.bottom() <= anchor_y {
174 travel
175 } else {
176 Pixels::ZERO
177 }
178}
179
180struct ToolbarEntrance {
183 child: AnyElement,
184 anchor_y: Pixels,
185 travel: Pixels,
186}
187
188impl IntoElement for ToolbarEntrance {
189 type Element = Self;
190 fn into_element(self) -> Self {
191 self
192 }
193}
194
195impl Element for ToolbarEntrance {
196 type RequestLayoutState = ();
197 type PrepaintState = ();
198 fn id(&self) -> Option<ElementId> {
199 None
200 }
201 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
202 None
203 }
204 fn request_layout(
205 &mut self,
206 _: Option<&GlobalElementId>,
207 _: Option<&InspectorElementId>,
208 window: &mut Window,
209 cx: &mut App,
210 ) -> (LayoutId, ()) {
211 (self.child.request_layout(window, cx), ())
212 }
213 fn prepaint(
214 &mut self,
215 _: Option<&GlobalElementId>,
216 _: Option<&InspectorElementId>,
217 bounds: Bounds<Pixels>,
218 _: &mut (),
219 window: &mut Window,
220 cx: &mut App,
221 ) {
222 let offset = point(
223 Pixels::ZERO,
224 entrance_travel(self.anchor_y, bounds, self.travel),
225 );
226 window.with_element_offset(offset, |window| self.child.prepaint(window, cx));
227 }
228 fn paint(
229 &mut self,
230 _: Option<&GlobalElementId>,
231 _: Option<&InspectorElementId>,
232 _: Bounds<Pixels>,
233 _: &mut (),
234 _: &mut (),
235 window: &mut Window,
236 cx: &mut App,
237 ) {
238 self.child.paint(window, cx);
239 }
240}
241
242fn defer_selection_settle(
243 entity: Entity<SelectionActions>,
244 pointer: Point<Pixels>,
245 window: &mut Window,
246 cx: &mut App,
247) {
248 window.defer(cx, move |_, cx| {
249 entity.update(cx, |this, cx| this.settle_selection(Some(pointer), cx));
250 });
251}
252
253pub struct SelectionActions {
277 style: gpui::StyleRefinement,
284 id: SharedString,
285 markdown: SharedString,
286 text: Entity<TextViewState>,
287 actions: Vec<SelectionAction>,
288 selected_text: SharedString,
289 drag_active: bool,
290 pointer_anchor: Option<Point<Pixels>>,
291 last_text_pointer: Option<Point<Pixels>>,
292 text_focus_scope: FocusHandle,
293 toolbar_focus: FocusHandle,
294 toolbar_action_focus: FocusHandle,
295 selection_focus: Option<FocusHandle>,
296 toolbar_scroll: ScrollHandle,
297 content_scroll: ScrollHandle,
298 toolbar_pointer_active: bool,
299 toolbar_generation: u64,
302 _subscriptions: Vec<Subscription>,
303}
304
305impl SelectionActions {
306 pub fn new(
308 id: impl Into<SharedString>,
309 markdown: impl Into<SharedString>,
310 _window: &mut Window,
311 cx: &mut gpui::Context<Self>,
312 ) -> Self {
313 let markdown = markdown.into();
314 let text = cx.new(|cx| TextViewState::markdown(markdown.as_ref(), cx).selectable(true));
315 let observation = cx.observe(&text, |this, text, cx| {
316 if this.drag_active {
317 return;
318 }
319 let raw = text.read(cx).selected_text();
320 if settled_selection(&raw).is_some() {
321 this.apply_selection(&raw, None, cx);
322 } else {
323 let selection = cx.weak_entity();
324 cx.defer(move |cx| {
325 let _ = selection.update(cx, |this, cx| {
326 if this.drag_active || this.toolbar_pointer_active {
327 return;
328 }
329 let raw = this.text.read(cx).selected_text();
330 if settled_selection(&raw).is_none() {
331 this.apply_selection(&raw, None, cx);
332 }
333 });
334 });
335 }
336 });
337 Self {
338 style: gpui::StyleRefinement::default(),
339 id: id.into(),
340 markdown,
341 text,
342 actions: Vec::new(),
343 selected_text: SharedString::default(),
344 drag_active: false,
345 pointer_anchor: None,
346 last_text_pointer: None,
347 text_focus_scope: cx.focus_handle(),
348 toolbar_focus: cx.focus_handle(),
349 toolbar_action_focus: cx.focus_handle(),
350 selection_focus: None,
351 toolbar_scroll: ScrollHandle::new(),
352 content_scroll: ScrollHandle::new(),
353 toolbar_pointer_active: false,
354 toolbar_generation: 0,
355 _subscriptions: vec![observation],
356 }
357 }
358
359 pub fn set_markdown(
361 &mut self,
362 markdown: impl Into<SharedString>,
363 cx: &mut gpui::Context<Self>,
364 ) {
365 let markdown = markdown.into();
366 if self.markdown == markdown {
367 return;
368 }
369 self.restore_text_focus(cx);
370 self.markdown = markdown.clone();
371 self.text
372 .update(cx, |text, cx| text.set_text(markdown.as_ref(), cx));
373 self.selected_text = SharedString::default();
374 self.pointer_anchor = None;
375 self.last_text_pointer = None;
376 self.drag_active = false;
377 self.toolbar_pointer_active = false;
378 cx.notify();
379 }
380
381 pub fn set_actions(
383 &mut self,
384 actions: impl IntoIterator<Item = SelectionAction>,
385 cx: &mut gpui::Context<Self>,
386 ) {
387 self.actions = actions.into_iter().collect();
388 cx.notify();
389 }
390
391 pub fn selected_text(&self) -> &SharedString {
393 &self.selected_text
394 }
395
396 pub fn clear_selection(&mut self, cx: &mut gpui::Context<Self>) {
398 self.restore_text_focus(cx);
399 self.text.update(cx, TextViewState::clear_selection);
400 self.selected_text = SharedString::default();
401 self.pointer_anchor = None;
402 self.drag_active = false;
403 self.toolbar_pointer_active = false;
404 cx.notify();
405 }
406
407 fn settle_selection(&mut self, pointer: Option<Point<Pixels>>, cx: &mut gpui::Context<Self>) {
408 self.drag_active = false;
409 let raw = self.text.read(cx).selected_text();
410 self.apply_selection(&raw, pointer, cx);
411 }
412
413 fn capture_text_focus(&self, cx: &mut gpui::Context<Self>) -> Option<FocusHandle> {
414 let entity_id = cx.entity_id();
415 let text_focus_scope = self.text_focus_scope.clone();
416 cx.with_window(entity_id, move |window, cx| {
417 text_focus_scope
418 .contains_focused(window, cx)
419 .then(|| window.focused(cx))
420 .flatten()
421 })
422 .flatten()
423 }
424
425 fn restore_text_focus(&self, cx: &mut gpui::Context<Self>) {
426 let Some(selection_focus) = self.selection_focus.clone() else {
427 return;
428 };
429 let entity_id = cx.entity_id();
430 let toolbar_focus = self.toolbar_focus.clone();
431 let _ = cx.with_window(entity_id, move |window, cx| {
432 if toolbar_focus.contains_focused(window, cx) {
433 selection_focus.focus(window, cx);
434 }
435 });
436 }
437
438 fn apply_selection(
439 &mut self,
440 raw: &str,
441 pointer: Option<Point<Pixels>>,
442 cx: &mut gpui::Context<Self>,
443 ) {
444 let selected = settled_selection(raw).unwrap_or_default();
445 let next: SharedString = selected.to_owned().into();
446 let next_focus = (!next.is_empty())
447 .then(|| self.capture_text_focus(cx))
448 .flatten();
449 if next.is_empty() {
450 self.restore_text_focus(cx);
451 }
452 let changed = self.selected_text != next;
453 if changed && !next.is_empty() {
454 self.toolbar_generation = self.toolbar_generation.wrapping_add(1);
455 }
456 self.selected_text = next;
457 if let Some(next_focus) = next_focus {
458 self.selection_focus = Some(next_focus);
459 }
460 if self.selected_text.is_empty() {
461 self.pointer_anchor = None;
462 } else if let Some(pointer) = pointer {
463 self.pointer_anchor = Some(pointer);
464 }
465 if changed || !self.selected_text.is_empty() {
466 cx.notify();
467 }
468 }
469
470 fn toolbar(&self, window: &mut Window, cx: &mut gpui::Context<Self>) -> Positioner {
471 let root_id = self.id.clone();
472 let selected_text = self.selected_text.clone();
473 let actions = self.actions.clone();
474 let tokens = cx.theme().semantic_tokens();
475 let maximum_size = Size::new(tokens.spacing.xxl * 9., tokens.spacing.xxl * 2.);
476 let maximum_control_width = (maximum_size.width - tokens.spacing.md).max(Pixels::default());
477 let buttons = actions
478 .into_iter()
479 .enumerate()
480 .map(|(action_ix, action)| {
481 let event = invocation_event(root_id.clone(), &action, selected_text.clone());
482 let action_id = action.id.clone();
483 let label = action.label.clone();
484 selection_control(
485 (ElementId::from(root_id.clone()), action_id.clone()),
486 label.clone(),
487 window,
488 cx,
489 )
490 .when(action_ix == 0, |button| {
491 button.track_focus(&self.toolbar_action_focus)
492 })
493 .max_w(maximum_control_width)
494 .overflow_hidden()
495 .debug_selector(move || format!("selection-action-{action_id}"))
496 .on_mouse_down(
497 MouseButton::Left,
498 cx.listener(|this, event: &MouseDownEvent, _, cx| {
499 if event.button == MouseButton::Left {
500 this.toolbar_pointer_active = true;
501 cx.stop_propagation();
502 }
503 }),
504 )
505 .on_click(cx.listener(move |this, _, _, cx| {
506 cx.emit(event.clone());
507 this.toolbar_pointer_active = false;
508 }))
509 })
510 .collect::<Vec<_>>();
511
512 let toolbar = selection_toolbar_frame(
513 self.id.clone(),
514 maximum_size,
515 &self.toolbar_focus,
516 &self.toolbar_scroll,
517 buttons,
518 cx,
519 )
520 .on_mouse_up_out(
521 MouseButton::Left,
522 cx.listener(|this, _, _, _| {
523 this.toolbar_pointer_active = false;
524 }),
525 );
526
527 let entrance = swap_progress(
533 ElementId::Name(SharedString::from(format!(
534 "{}-toolbar-entrance-{}",
535 self.id, self.toolbar_generation
536 ))),
537 window,
538 cx,
539 );
540 let anchor = self.toolbar_anchor(window);
541 let toolbar = ToolbarEntrance {
542 child: div().opacity(entrance).child(toolbar).into_any_element(),
543 anchor_y: anchor.y,
544 travel: tokens.spacing.xxs * (1.0 - entrance) * crate::motion::travel(cx),
545 };
546
547 selection_toolbar_positioner(anchor, Placement::Bottom, toolbar, cx)
548 }
549
550 fn toolbar_anchor(&self, window: &Window) -> Point<Pixels> {
551 self.pointer_anchor
552 .or(self.last_text_pointer)
553 .unwrap_or_else(|| {
554 let viewport = window.viewport_size();
555 point(viewport.width / 2., viewport.height / 2.)
556 })
557 }
558}
559
560impl EventEmitter<SelectionActionsEvent> for SelectionActions {}
561
562impl gpui::Styled for SelectionActions {
563 fn style(&mut self) -> &mut gpui::StyleRefinement {
564 &mut self.style
565 }
566}
567
568impl Render for SelectionActions {
569 fn render(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
570 let tokens = cx.theme().semantic_tokens();
571 let root_id = self.id.clone();
572 let text_surface = div()
573 .id((ElementId::from(root_id.clone()), "text"))
574 .size_full()
575 .track_focus(&self.text_focus_scope)
576 .on_mouse_down(
577 MouseButton::Left,
578 cx.listener(|this, event: &MouseDownEvent, _, cx| {
579 if this.toolbar_pointer_active {
580 return;
581 }
582 this.drag_active = true;
583 this.toolbar_pointer_active = false;
584 this.selected_text = SharedString::default();
585 this.pointer_anchor = None;
586 this.last_text_pointer = Some(event.position);
587 cx.notify();
588 }),
589 )
590 .on_mouse_up(
591 MouseButton::Left,
592 cx.listener(|_, event: &MouseUpEvent, window, cx| {
593 defer_selection_settle(cx.entity().clone(), event.position, window, cx);
594 }),
595 )
596 .on_mouse_up_out(
597 MouseButton::Left,
598 cx.listener(|_, event: &MouseUpEvent, window, cx| {
599 defer_selection_settle(cx.entity().clone(), event.position, window, cx);
600 }),
601 )
602 .on_key_up(cx.listener(|_, _, window, cx| {
603 let entity = cx.entity().clone();
604 window.defer(cx, move |_, cx| {
605 entity.update(cx, |this, cx| this.settle_selection(None, cx));
606 });
607 }))
608 .child(TextView::new(&self.text).selectable(true));
609
610 div()
611 .id(ElementId::from(root_id))
612 .debug_selector(|| "selection-actions-surface".to_owned())
613 .role(Role::Group)
614 .aria_label("Selectable content with actions")
615 .relative()
616 .size_full()
617 .min_h(tokens.spacing.xxl * 3.)
618 .border_1()
619 .border_color(tokens.colors.border)
620 .rounded(tokens.radius.lg)
621 .bg(tokens.colors.background)
622 .on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| {
623 if event.button == MouseButton::Left {
624 this.clear_selection(cx);
625 }
626 }))
627 .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
628 let toolbar_visible =
629 !this.drag_active && !this.selected_text.is_empty() && !this.actions.is_empty();
630 match event.keystroke.key.as_str() {
631 "escape" if toolbar_visible => {
632 this.clear_selection(cx);
633 cx.stop_propagation();
634 }
635 "tab"
636 if toolbar_visible
637 && !event.keystroke.modifiers.shift
638 && this.text_focus_scope.contains_focused(window, cx) =>
639 {
640 this.toolbar_action_focus.focus(window, cx);
641 cx.stop_propagation();
642 }
643 _ => {}
644 }
645 }))
646 .child(
647 div()
648 .id((ElementId::from(self.id.clone()), "content-scroll"))
649 .size_full()
650 .overflow_y_scroll()
651 .track_scroll(&self.content_scroll)
652 .p(tokens.spacing.md)
653 .child(text_surface),
654 )
655 .child(
656 ScrollableMask::new(Axis::Vertical, &self.content_scroll)
657 .id((ElementId::from(self.id.clone()), "content-scroll-mask")),
658 )
659 .when(
660 !self.drag_active && !self.selected_text.is_empty() && !self.actions.is_empty(),
661 |surface| surface.child(self.toolbar(window, cx)),
662 )
663 .refine_style(&self.style)
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use gpui::{
671 Bounds, Modifiers, RenderOnce as _, ScrollDelta, ScrollWheelEvent, Size, TestAppContext,
672 VisualTestContext, accesskit, canvas, point, px, rems, size,
673 };
674 use std::sync::{Arc, Mutex};
675
676 #[test]
677 fn duplicate_labels_keep_stable_action_identity() {
678 let actions = [
679 SelectionAction::new("ask-short", "Ask"),
680 SelectionAction::new("ask-deep", "Ask"),
681 ];
682
683 let event = invocation_event("answer", &actions[1], "selected words");
684
685 assert_eq!(
686 event,
687 SelectionActionsEvent::Invoked {
688 id: "answer".into(),
689 action_id: "ask-deep".into(),
690 selected_text: "selected words".into(),
691 }
692 );
693 }
694
695 #[test]
696 fn whitespace_only_selection_is_suppressed() {
697 assert_eq!(settled_selection(" \n\t "), None);
698 assert_eq!(settled_selection(" useful words "), Some("useful words"));
699 }
700
701 #[derive(Clone, Copy)]
702 enum ProbePopupSize {
703 Pixels(f32, f32),
704 Viewport(f32, f32),
705 Rems(f32, f32),
706 }
707
708 #[derive(Clone, Copy)]
709 struct PositionCase {
710 selector: &'static str,
711 anchor: (f32, f32),
712 placement: gpui_base::Placement,
713 popup_size: ProbePopupSize,
714 }
715
716 struct PositionerProbe {
717 cases: Vec<PositionCase>,
718 }
719
720 impl Render for PositionerProbe {
721 fn render(
722 &mut self,
723 window: &mut Window,
724 cx: &mut gpui::Context<Self>,
725 ) -> impl IntoElement {
726 let viewport = window.viewport_size();
727 div()
728 .relative()
729 .size_full()
730 .children(self.cases.iter().map(|case| {
731 let selector = case.selector;
732 let anchor = point(
733 viewport.width * case.anchor.0,
734 viewport.height * case.anchor.1,
735 );
736 let popup = div().debug_selector(move || selector.to_owned());
737 let popup = match case.popup_size {
738 ProbePopupSize::Pixels(width, height) => popup.w(px(width)).h(px(height)),
739 ProbePopupSize::Viewport(width, height) => {
740 popup.w(viewport.width * width).h(viewport.height * height)
741 }
742 ProbePopupSize::Rems(width, height) => popup.w(rems(width)).h(rems(height)),
743 };
744 selection_toolbar_positioner(anchor, case.placement, popup, cx)
745 }))
746 }
747 }
748
749 fn assert_on_screen(bounds: Bounds<Pixels>, viewport: Size<Pixels>) {
750 assert!(
751 bounds.left() >= Pixels::default(),
752 "{bounds:?} vs {viewport:?}"
753 );
754 assert!(
755 bounds.top() >= Pixels::default(),
756 "{bounds:?} vs {viewport:?}"
757 );
758 assert!(
759 bounds.right() <= viewport.width,
760 "{bounds:?} vs {viewport:?}"
761 );
762 assert!(
763 bounds.bottom() <= viewport.height,
764 "{bounds:?} vs {viewport:?}"
765 );
766 }
767
768 #[gpui::test]
769 fn selection_positioner_resolves_all_window_corners_with_expected_vertical_flip(
770 cx: &mut TestAppContext,
771 ) {
772 cx.update(crate::init);
773 let cases = vec![
774 PositionCase {
775 selector: "selection-position-top-left",
776 anchor: (0.02, 0.02),
777 placement: gpui_base::Placement::Bottom,
778 popup_size: ProbePopupSize::Pixels(120., 48.),
779 },
780 PositionCase {
781 selector: "selection-position-top-right",
782 anchor: (0.98, 0.02),
783 placement: gpui_base::Placement::Bottom,
784 popup_size: ProbePopupSize::Pixels(120., 48.),
785 },
786 PositionCase {
787 selector: "selection-position-bottom-left",
788 anchor: (0.02, 0.98),
789 placement: gpui_base::Placement::Bottom,
790 popup_size: ProbePopupSize::Pixels(120., 48.),
791 },
792 PositionCase {
793 selector: "selection-position-bottom-right",
794 anchor: (0.98, 0.98),
795 placement: gpui_base::Placement::Bottom,
796 popup_size: ProbePopupSize::Pixels(120., 48.),
797 },
798 ];
799 let (_, cx) = cx.add_window_view(move |_, _| PositionerProbe { cases });
800 cx.update(|window, cx| window.draw(cx).clear(cx));
801 let viewport = cx.update(|window, _| window.viewport_size());
802
803 for selector in [
804 "selection-position-top-left",
805 "selection-position-top-right",
806 "selection-position-bottom-left",
807 "selection-position-bottom-right",
808 ] {
809 assert_on_screen(
810 cx.debug_bounds(selector)
811 .expect("positioned selection toolbar should render"),
812 viewport,
813 );
814 }
815
816 let top_anchor = viewport.height * 0.02;
817 let bottom_anchor = viewport.height * 0.98;
818 assert!(
819 cx.debug_bounds("selection-position-top-left")
820 .expect("top-left toolbar should render")
821 .top()
822 >= top_anchor
823 );
824 assert!(
825 cx.debug_bounds("selection-position-top-right")
826 .expect("top-right toolbar should render")
827 .top()
828 >= top_anchor
829 );
830 assert!(
831 cx.debug_bounds("selection-position-bottom-left")
832 .expect("bottom-left toolbar should render")
833 .bottom()
834 <= bottom_anchor
835 );
836 assert!(
837 cx.debug_bounds("selection-position-bottom-right")
838 .expect("bottom-right toolbar should render")
839 .bottom()
840 <= bottom_anchor
841 );
842 }
843
844 #[gpui::test]
845 fn selection_positioner_flips_each_preferred_side_when_space_is_insufficient(
846 cx: &mut TestAppContext,
847 ) {
848 cx.update(crate::init);
849 let cases = vec![
850 PositionCase {
851 selector: "selection-position-flip-top",
852 anchor: (0.5, 0.02),
853 placement: gpui_base::Placement::Top,
854 popup_size: ProbePopupSize::Pixels(120., 48.),
855 },
856 PositionCase {
857 selector: "selection-position-flip-bottom",
858 anchor: (0.5, 0.98),
859 placement: gpui_base::Placement::Bottom,
860 popup_size: ProbePopupSize::Pixels(120., 48.),
861 },
862 PositionCase {
863 selector: "selection-position-flip-left",
864 anchor: (0.02, 0.5),
865 placement: gpui_base::Placement::Left,
866 popup_size: ProbePopupSize::Pixels(120., 48.),
867 },
868 PositionCase {
869 selector: "selection-position-flip-right",
870 anchor: (0.98, 0.5),
871 placement: gpui_base::Placement::Right,
872 popup_size: ProbePopupSize::Pixels(120., 48.),
873 },
874 ];
875 let (_, cx) = cx.add_window_view(move |_, _| PositionerProbe { cases });
876 cx.update(|window, cx| window.draw(cx).clear(cx));
877 let viewport = cx.update(|window, _| window.viewport_size());
878
879 let top_anchor = viewport.height * 0.02;
880 let bottom_anchor = viewport.height * 0.98;
881 let left_anchor = viewport.width * 0.02;
882 let right_anchor = viewport.width * 0.98;
883 assert!(
884 cx.debug_bounds("selection-position-flip-top")
885 .expect("top toolbar should render")
886 .top()
887 >= top_anchor
888 );
889 assert!(
890 cx.debug_bounds("selection-position-flip-bottom")
891 .expect("bottom toolbar should render")
892 .bottom()
893 <= bottom_anchor
894 );
895 assert!(
896 cx.debug_bounds("selection-position-flip-left")
897 .expect("left toolbar should render")
898 .left()
899 >= left_anchor
900 );
901 assert!(
902 cx.debug_bounds("selection-position-flip-right")
903 .expect("right toolbar should render")
904 .right()
905 <= right_anchor
906 );
907 }
908
909 #[gpui::test]
910 fn selection_positioner_uses_the_larger_side_and_clamps_an_oversized_side(
911 cx: &mut TestAppContext,
912 ) {
913 cx.update(crate::init);
914 let cases = vec![PositionCase {
915 selector: "selection-position-larger-side",
916 anchor: (0.5, 0.4),
917 placement: gpui_base::Placement::Bottom,
918 popup_size: ProbePopupSize::Viewport(0.3, 0.7),
919 }];
920 let (_, cx) = cx.add_window_view(move |_, _| PositionerProbe { cases });
921 cx.update(|window, cx| window.draw(cx).clear(cx));
922 let viewport = cx.update(|window, _| window.viewport_size());
923 let bounds = cx
924 .debug_bounds("selection-position-larger-side")
925 .expect("oversized-side toolbar should render");
926
927 assert_on_screen(bounds, viewport);
928 assert!(bounds.top() > Pixels::default(), "{bounds:?}");
929 assert!(bounds.bottom() > viewport.height * 0.95, "{bounds:?}");
930 }
931
932 #[gpui::test]
933 fn selection_positioner_keeps_a_rem_scaled_toolbar_reachable_at_two_hundred_percent(
934 cx: &mut TestAppContext,
935 ) {
936 cx.update(crate::init);
937 let cases = vec![PositionCase {
938 selector: "selection-position-double-rem",
939 anchor: (0.98, 0.98),
940 placement: gpui_base::Placement::Bottom,
941 popup_size: ProbePopupSize::Rems(18., 4.),
942 }];
943 let (_, cx) = cx.add_window_view(move |window, _| {
944 window.set_rem_size(px(32.));
945 PositionerProbe { cases }
946 });
947 cx.update(|window, cx| window.draw(cx).clear(cx));
948 let viewport = cx.update(|window, _| window.viewport_size());
949 let bounds = cx
950 .debug_bounds("selection-position-double-rem")
951 .expect("double-rem toolbar should render");
952
953 assert_on_screen(bounds, viewport);
954 assert_eq!(bounds.size, size(px(576.), px(128.)));
955 }
956
957 #[gpui::test]
958 fn a_replaced_selection_replays_the_entrance_and_a_re_render_does_not(
959 cx: &mut gpui::TestAppContext,
960 ) {
961 cx.update(crate::init);
962 let (probe, cx) = cx.add_window_view(SelectionProbe::new);
963 let cx: &mut gpui::VisualTestContext = cx;
964 let selection = probe.read_with(cx, |probe, _| probe.selection.clone());
965
966 let generation =
967 |cx: &mut gpui::VisualTestContext| selection.read_with(cx, |s, _| s.toolbar_generation);
968 assert_eq!(generation(cx), 0, "no selection has settled yet");
969
970 selection.update(cx, |this, cx| {
971 this.apply_selection("chosen words", Some(point(px(40.), px(40.))), cx)
972 });
973 assert_eq!(generation(cx), 1, "a settled selection enters once");
974
975 selection.update(cx, |this, cx| {
976 this.apply_selection("chosen words", Some(point(px(48.), px(44.))), cx)
977 });
978 assert_eq!(generation(cx), 1, "the same selection must not re-enter");
979
980 selection.update(cx, |this, cx| {
981 this.apply_selection("other words", Some(point(px(60.), px(200.))), cx)
982 });
983 assert_eq!(generation(cx), 2, "a replacement replays the entrance");
984
985 selection.update(cx, |this, cx| this.apply_selection("", None, cx));
986 assert_eq!(generation(cx), 2, "clearing enters nothing");
987 }
988
989 #[test]
990 fn the_entrance_travels_out_of_the_anchors_side() {
991 assert_eq!(
994 entrance_travel(
995 px(100.),
996 Bounds::new(point(px(0.), px(108.)), size(px(100.), px(40.))),
997 px(4.)
998 ),
999 px(-4.)
1000 );
1001 assert_eq!(
1003 entrance_travel(
1004 px(560.),
1005 Bounds::new(point(px(0.), px(512.)), size(px(100.), px(40.))),
1006 px(4.)
1007 ),
1008 px(4.)
1009 );
1010 assert_eq!(
1013 entrance_travel(
1014 px(510.),
1015 Bounds::new(point(px(0.), px(518.)), size(px(100.), px(40.))),
1016 px(4.)
1017 ),
1018 px(-4.)
1019 );
1020 }
1021
1022 struct SelectionProbe {
1023 selection: Entity<SelectionActions>,
1024 host_scroll: ScrollHandle,
1025 }
1026
1027 impl SelectionProbe {
1028 fn new(window: &mut Window, cx: &mut gpui::Context<Self>) -> Self {
1029 let markdown = (0..24)
1030 .map(|ix| format!("Selectable line {ix} for scrolling and dismissal tests."))
1031 .collect::<Vec<_>>()
1032 .join("\n\n");
1033 let selection =
1034 cx.new(|cx| SelectionActions::new("selection-probe", markdown, window, cx));
1035 selection.update(cx, |selection, cx| {
1036 selection.set_actions(
1037 [
1038 SelectionAction::new("ask", "Ask about this selection"),
1039 SelectionAction::new("explain", "Explain this selected passage"),
1040 SelectionAction::new("rewrite", "Rewrite this selected passage clearly"),
1041 SelectionAction::new("compare", "Compare this passage with the source"),
1042 SelectionAction::new("final", "Open the final long selection action"),
1043 ],
1044 cx,
1045 );
1046 });
1047 Self {
1048 selection,
1049 host_scroll: ScrollHandle::new(),
1050 }
1051 }
1052 }
1053
1054 impl Render for SelectionProbe {
1055 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1056 div()
1057 .size_full()
1058 .flex()
1059 .flex_col()
1060 .child(
1061 div()
1062 .id("selection-probe-scroll")
1063 .w(px(184.))
1064 .h(px(144.))
1065 .overflow_y_scroll()
1066 .track_scroll(&self.host_scroll)
1067 .child(
1068 div()
1069 .w(px(184.))
1070 .h(px(620.))
1071 .child(div().w(px(184.)).h(px(144.)).child(self.selection.clone())),
1072 ),
1073 )
1074 .child(
1075 div()
1076 .debug_selector(|| "selection-probe-outside".to_owned())
1077 .flex_1(),
1078 )
1079 }
1080 }
1081
1082 fn select_all_probe_text(probe: &Entity<SelectionProbe>, cx: &mut VisualTestContext) {
1083 let text = probe.read_with(cx, |probe, cx| probe.selection.read(cx).text.clone());
1084 text.update(cx, |text, cx| text.select_all(cx));
1085 cx.run_until_parked();
1086 cx.update(|window, cx| window.draw(cx).clear(cx));
1087 }
1088
1089 #[gpui::test]
1090 fn selection_toolbar_stays_attached_when_content_scrolls(cx: &mut TestAppContext) {
1091 cx.update(crate::init);
1092 let (probe, cx) = cx.add_window_view(SelectionProbe::new);
1093 cx.update(|window, cx| window.draw(cx).clear(cx));
1094 select_all_probe_text(&probe, cx);
1095 let surface = cx
1096 .debug_bounds("selection-actions-surface")
1097 .expect("selection surface should render");
1098 let selection = probe.read_with(cx, |probe, _| probe.selection.clone());
1099 selection.update(cx, |selection, cx| {
1100 selection.pointer_anchor = Some(surface.center());
1101 cx.notify();
1102 });
1103 cx.update(|window, cx| window.draw(cx).clear(cx));
1104 let before = cx
1105 .debug_bounds("selection-actions-toolbar")
1106 .expect("toolbar should render before scrolling");
1107
1108 probe.update(cx, |probe, cx| {
1109 probe
1110 .host_scroll
1111 .set_offset(point(Pixels::default(), px(-48.)));
1112 cx.notify();
1113 });
1114 cx.update(|window, cx| window.draw(cx).clear(cx));
1115 let after = cx
1116 .debug_bounds("selection-actions-toolbar")
1117 .expect("toolbar should remain after scrolling");
1118
1119 assert_eq!(
1120 probe.read_with(cx, |probe, _| probe.host_scroll.offset().y),
1121 px(-48.)
1122 );
1123 assert_eq!(before, after);
1124 }
1125
1126 #[gpui::test]
1127 fn selection_horizontal_overflow_keeps_the_final_action_reachable_in_the_window(
1128 cx: &mut TestAppContext,
1129 ) {
1130 cx.update(crate::init);
1131 let (probe, cx) = cx.add_window_view(SelectionProbe::new);
1132 cx.update(|window, cx| window.draw(cx).clear(cx));
1133 select_all_probe_text(&probe, cx);
1134 let surface = cx
1135 .debug_bounds("selection-actions-surface")
1136 .expect("selection surface should render");
1137 let selection = probe.read_with(cx, |probe, _| probe.selection.clone());
1138 selection.update(cx, |selection, cx| {
1139 selection.pointer_anchor = Some(surface.center());
1140 cx.notify();
1141 });
1142 cx.update(|window, cx| window.draw(cx).clear(cx));
1143 let toolbar = cx
1144 .debug_bounds("selection-actions-toolbar")
1145 .expect("toolbar should render after selection");
1146
1147 for _ in 0..12 {
1148 cx.simulate_event(ScrollWheelEvent {
1149 position: toolbar.center(),
1150 delta: ScrollDelta::Pixels(point(px(-120.), Pixels::default())),
1151 ..Default::default()
1152 });
1153 }
1154 cx.update(|window, cx| window.draw(cx).clear(cx));
1155
1156 let final_action = cx
1157 .debug_bounds("selection-action-final")
1158 .expect("final action should remain rendered after horizontal scrolling");
1159 let viewport = cx.update(|window, _| window.viewport_size());
1160 assert!(
1161 final_action.left() >= toolbar.left() && final_action.right() <= toolbar.right(),
1162 "{final_action:?} vs {toolbar:?}"
1163 );
1164 assert_on_screen(final_action, viewport);
1165 }
1166
1167 #[gpui::test]
1168 fn selection_tab_preserves_selection_and_escape_restores_text_focus(cx: &mut TestAppContext) {
1169 cx.update(crate::init);
1170 let (probe, cx) = cx.add_window_view(SelectionProbe::new);
1171 cx.update(|window, cx| window.draw(cx).clear(cx));
1172 let surface = cx
1173 .debug_bounds("selection-actions-surface")
1174 .expect("selection surface should render");
1175 cx.simulate_click(
1176 point(surface.left() + px(24.), surface.top() + px(24.)),
1177 Modifiers::default(),
1178 );
1179 select_all_probe_text(&probe, cx);
1180 let selection = probe.read_with(cx, |probe, _| probe.selection.clone());
1181
1182 let (text_focused, toolbar_focused) = cx.update(|window, cx| {
1183 let selection = selection.read(cx);
1184 (
1185 selection.text_focus_scope.contains_focused(window, cx),
1186 selection.toolbar_focus.contains_focused(window, cx),
1187 )
1188 });
1189 assert!(text_focused);
1190 assert!(!toolbar_focused);
1191
1192 cx.simulate_keystrokes("tab");
1193 cx.update(|window, cx| window.draw(cx).clear(cx));
1194 assert!(cx.debug_bounds("selection-actions-toolbar").is_some());
1195 assert!(selection.read_with(cx, |selection, _| { !selection.selected_text().is_empty() }));
1196 assert!(cx.update(|window, cx| selection.read(cx).toolbar_action_focus.is_focused(window)));
1197 assert!(cx.update(|window, cx| {
1198 selection
1199 .read(cx)
1200 .toolbar_focus
1201 .contains_focused(window, cx)
1202 }));
1203
1204 cx.simulate_keystrokes("escape");
1205 cx.run_until_parked();
1206 cx.update(|window, cx| window.draw(cx).clear(cx));
1207
1208 assert!(cx.debug_bounds("selection-actions-toolbar").is_none());
1209 assert!(cx.update(|window, cx| {
1210 selection
1211 .read(cx)
1212 .text_focus_scope
1213 .contains_focused(window, cx)
1214 }));
1215 }
1216
1217 #[gpui::test]
1218 fn selection_outside_pointer_down_dismisses_toolbar(cx: &mut TestAppContext) {
1219 cx.update(crate::init);
1220 let (probe, cx) = cx.add_window_view(SelectionProbe::new);
1221 cx.update(|window, cx| window.draw(cx).clear(cx));
1222 select_all_probe_text(&probe, cx);
1223 assert!(cx.debug_bounds("selection-actions-toolbar").is_some());
1224
1225 let outside = cx
1226 .debug_bounds("selection-probe-outside")
1227 .expect("outside target should render");
1228 cx.simulate_mouse_down(outside.center(), MouseButton::Left, Modifiers::default());
1229 cx.run_until_parked();
1230 cx.update(|window, cx| window.draw(cx).clear(cx));
1231
1232 assert!(cx.debug_bounds("selection-actions-toolbar").is_none());
1233 }
1234
1235 #[gpui::test]
1236 fn selection_clear_removes_toolbar_immediately(cx: &mut TestAppContext) {
1237 cx.update(crate::init);
1238 let (probe, cx) = cx.add_window_view(SelectionProbe::new);
1239 cx.update(|window, cx| window.draw(cx).clear(cx));
1240 select_all_probe_text(&probe, cx);
1241 let selection = probe.read_with(cx, |probe, _| probe.selection.clone());
1242 assert!(cx.debug_bounds("selection-actions-toolbar").is_some());
1243
1244 selection.update(cx, SelectionActions::clear_selection);
1245 cx.update(|window, cx| window.draw(cx).clear(cx));
1246
1247 assert!(selection.read_with(cx, |selection, _| { selection.selected_text().is_empty() }));
1248 assert!(cx.debug_bounds("selection-actions-toolbar").is_none());
1249 }
1250
1251 struct ControlProbe {
1252 captured: Arc<Mutex<Option<accesskit::Node>>>,
1253 toolbar: Arc<Mutex<Option<accesskit::Node>>>,
1254 }
1255
1256 impl Render for ControlProbe {
1257 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1258 let captured = self.captured.clone();
1259 let toolbar = self.toolbar.clone();
1260 canvas(
1261 move |_, window, cx| {
1262 let mut node = accesskit::Node::new(Role::Button);
1263 selection_control("ask", "Ask about selection", window, cx)
1264 .on_click(|_, _, _| {})
1265 .render(window, cx)
1266 .into_element()
1267 .write_a11y_info(&mut node);
1268 *captured.lock().expect("capture mutex should be available") = Some(node);
1269 let toolbar_focus = cx.focus_handle();
1270 let toolbar_element = selection_toolbar_frame(
1271 "probe".into(),
1272 size(px(240.), px(64.)),
1273 &toolbar_focus,
1274 &ScrollHandle::new(),
1275 Vec::new(),
1276 cx,
1277 )
1278 .into_element();
1279 let mut toolbar_node =
1280 accesskit::Node::new(toolbar_element.a11y_role().unwrap_or(Role::Unknown));
1281 toolbar_element.write_a11y_info(&mut toolbar_node);
1282 *toolbar.lock().expect("toolbar mutex should be available") =
1283 Some(toolbar_node);
1284 },
1285 |_, _, _, _| {},
1286 )
1287 }
1288 }
1289
1290 #[gpui::test]
1291 fn production_action_control_is_named_and_keyboard_activatable(cx: &mut TestAppContext) {
1292 cx.update(crate::init);
1293 let captured = Arc::new(Mutex::new(None));
1294 let toolbar = Arc::new(Mutex::new(None));
1295 let result = captured.clone();
1296 let toolbar_result = toolbar.clone();
1297 let (_, cx) = cx.add_window_view(move |_, _| ControlProbe { captured, toolbar });
1298 cx.update(|window, cx| window.draw(cx).clear(cx));
1299
1300 let node = result
1301 .lock()
1302 .expect("capture mutex should be available")
1303 .take()
1304 .expect("button node should be captured");
1305 assert_eq!(node.role(), Role::Button);
1306 assert_eq!(node.label(), Some("Ask about selection"));
1307 assert!(node.supports_action(accesskit::Action::Click));
1308
1309 let toolbar = toolbar_result
1310 .lock()
1311 .expect("toolbar mutex should be available")
1312 .take()
1313 .expect("toolbar node should be captured");
1314 assert_eq!(toolbar.role(), Role::Toolbar);
1315 assert_eq!(toolbar.label(), Some("Selection actions"));
1316 }
1317}