Skip to main content

gpui_ai/
selection_actions.rs

1//! Selection-anchored actions for readable Markdown content.
2
3use 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/// One application-owned action offered for the current text selection.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SelectionAction {
26    id: SharedString,
27    label: SharedString,
28}
29
30impl SelectionAction {
31    /// Creates an action with stable application identity and a visible label.
32    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    /// Returns the stable application identifier.
40    pub fn id(&self) -> &SharedString {
41        &self.id
42    }
43
44    /// Returns the visible and accessible label.
45    pub fn label(&self) -> &SharedString {
46        &self.label
47    }
48}
49
50/// An interaction emitted by [`SelectionActions`].
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum SelectionActionsEvent {
53    /// The user invoked an action for a settled, non-empty selection.
54    Invoked {
55        /// Stable selection-surface identifier.
56        id: SharedString,
57        /// Stable application action identifier.
58        action_id: SharedString,
59        /// Trimmed selected text at activation time.
60        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
169/// Sample the resolved bounds, not an estimate of the positioner's flip.
170fn 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
180/// Positioner resolves the final layout before this wrapper prepaints. Move
181/// hitboxes and paint together, without copying its private flip algorithm.
182struct 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
253/// Selectable Markdown with application-owned actions anchored to a settled selection.
254///
255/// The entity owns one upstream [`TextViewState`], preserving native GPUI
256/// selection, copy, and select-all behavior. It emits immutable action
257/// requests; applications retain all durable results and asynchronous work.
258///
259/// # Example
260///
261/// ```no_run
262/// # use gpui_ai::prelude::*;
263/// # use gpui::AppContext;
264/// # fn example(window: &mut gpui::Window, cx: &mut gpui::App) {
265/// let selection = cx.new(|cx| {
266///     SelectionActions::new("answer", "Select part of this answer", window, cx)
267/// });
268/// selection.update(cx, |selection, cx| {
269///     selection.set_actions([
270///         SelectionAction::new("ask", "Ask"),
271///         SelectionAction::new("explain", "Explain"),
272///     ], cx);
273/// });
274/// # }
275/// ```
276pub struct SelectionActions {
277    /// Styles the caller put on this component, applied to its own frame.
278    ///
279    /// Last, so a caller outranks the component's defaults - the same rule the
280    /// builder components follow. A wrapper `div` cannot stand in for this:
281    /// a background, a border, or an ink set on a wrapper paints around the
282    /// component rather than on it.
283    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    /// Bumped for each newly settled, distinct selection, so the toolbar's
300    /// entrance replays for a replacement and never for a re-render.
301    toolbar_generation: u64,
302    _subscriptions: Vec<Subscription>,
303}
304
305impl SelectionActions {
306    /// Creates a selectable Markdown surface.
307    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    /// Replaces the Markdown and clears any stale selection and toolbar.
360    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    /// Replaces the application-owned action catalog.
382    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    /// Returns the current settled, trimmed selection.
392    pub fn selected_text(&self) -> &SharedString {
393        &self.selected_text
394    }
395
396    /// Clears native and composite selection state.
397    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        // The toolbar enters from the selection's side: a quick fade with a
528        // few pixels of travel out of the anchor, keyed by the selection
529        // generation so a replaced selection replays the entrance from its
530        // new anchor while a re-render replays nothing. Reduced motion
531        // renders the settled toolbar in one frame.
532        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        // Room below: the toolbar sits under the anchor and drops into
992        // place, so it starts a little above its settled spot.
993        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        // Bottom edge: the positioner flips above, and the entrance rises.
1002        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        // A short toolbar still fits below where a maximum-height estimate
1011        // would have predicted a flip to the top.
1012        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}