Skip to main content

frust_widgets/
selection_toolbar.rs

1//! The baseline framework-drawn selection toolbar: a horizontal pill of text
2//! buttons — Cut, Copy, Paste, Select all — showing only the verbs
3//! [`SelectionToolbarRequest::actions`](frust_core::SelectionToolbarRequest::actions)
4//! enables.
5//!
6//! [`selection_toolbar`] is the view-fn a text field's own
7//! [`OverlaySlot`](crate::overlay::OverlaySlot) floats under
8//! [`SelectionToolbarPolicy::Framework`](frust_core::SelectionToolbarPolicy::Framework)
9//! (see `frust_core::selection_toolbar`'s module docs for the two-route
10//! seam): it returns an [`AnyView<()>`] — the pod carries no application
11//! state, so the view built here never reads anything but the request it was
12//! handed — and is installed as the process's default builder at bootstrap
13//! (`frust::__install_default_selection_toolbar`), set-if-unset so a design
14//! system that installed its own first keeps it.
15//!
16//! # Shape
17//!
18//! One [`ToolbarButtonWidget`] per enabled verb, laid out left to right with
19//! no gap, wrapped in a single pill: `surface_container` fill (themed;
20//! [`FALLBACK_FILL`] unthemed), a 1px `outline_variant` border
21//! ([`FALLBACK_BORDER`] unthemed), [`RADIUS`] corners. Each item pads its
22//! `on_surface`-colored label (`text`'s own default role — see
23//! [`crate::text`]) by [`PAD_X`]/[`PAD_Y`] and is at least [`MIN_HEIGHT`]
24//! logical px tall; the pill itself is never padded or bordered beyond that —
25//! it hugs its items exactly, since the [`OverlaySlot`](crate::overlay::OverlaySlot)
26//! that hosts it, not this view, decides where it sits (module docs: "sized
27//! to content").
28//!
29//! A tap follows every other interactive baseline widget's fire-on-up-inside
30//! contract ([`crate::button`]/[`crate::icon_button`]'s own module docs): a
31//! primary-button `Down` inside captures the pointer and shows a pressed wash
32//! at [`crate::authoring::PRESSED_OPACITY`]; the verb only fires on an `Up`
33//! that lands back inside. Cut/Copy/Select all ride
34//! [`EventCtx::dispatch_edit_command`](frust_core::EventCtx::dispatch_edit_command)
35//! (the toolbar and the field it acts on are two different widgets connected
36//! only through the overlay portal, not a container path — see that method's
37//! own "why this is not just an `InputEvent::EditCommand`" section); Paste
38//! rides [`EventCtx::request_paste`](frust_core::EventCtx::request_paste)
39//! instead, since only the shell may read the host clipboard.
40//!
41//! # Labels
42//!
43//! English-only ([`LABELS`]): localisation is deferred to the builder seam —
44//! a design system (or an app) installing its own translated builder via
45//! [`frust_core::set_selection_toolbar_builder`]/
46//! [`frust_core::install_selection_toolbar_builder_if_unset`] is how this
47//! baseline is meant to be replaced for a localized app. This baseline never
48//! localises its own four labels.
49
50use frust_core::accesskit::{Action, Role};
51use frust_core::{
52    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EditCommand, EventCtx, EventResult,
53    InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SelectionToolbarActions,
54    SelectionToolbarRequest, SemanticsCtx, View, Widget, any,
55};
56use frust_theme::Theme;
57use kurbo::{Point, RoundedRect, Shape, Size};
58use peniko::{Brush, Color};
59
60use crate::ChildKey;
61use crate::authoring::{PRESSED_OPACITY, presses};
62
63/// Minimum tap-target height for one toolbar item, in logical px — enforced
64/// per item (not just on the pill as a whole) so the pill's own height, which
65/// is driven by its tallest child, always clears it too.
66const MIN_HEIGHT: f64 = 44.0;
67/// Horizontal padding around each item's label, in logical px (mirrors
68/// [`crate::button`]'s identically-valued `PAD_X` — no shared spacing-token
69/// constant exists yet to resolve this from instead; see that module's own
70/// `PAD_X` doc comment).
71const PAD_X: f64 = 12.0;
72/// Vertical padding around each item's label, in logical px (mirrors
73/// [`crate::button`]'s identically-valued `PAD_Y`; see [`PAD_X`]).
74const PAD_Y: f64 = 8.0;
75/// Pill corner radius, in logical px.
76const RADIUS: f64 = 8.0;
77/// Pill border stroke width, in logical px (a hairline, matching
78/// [`crate::button`]/[`crate::container`]'s identical precedent).
79const BORDER_WIDTH: f64 = 1.0;
80/// Flattening tolerance for the border's rounded-rect stroke path — see
81/// [`crate::button`]'s identical precedent/rationale.
82const BORDER_TOLERANCE: f64 = 0.1;
83
84/// Unthemed-fallback pill fill (a light neutral surface; no M3 anchor exists
85/// without a theme to resolve `surface_container` from).
86const FALLBACK_FILL: Color = Color::from_rgb8(0xF0, 0xF0, 0xF0);
87/// Unthemed-fallback pill border (see [`FALLBACK_FILL`]'s no-anchor note).
88const FALLBACK_BORDER: Color = Color::from_rgb8(0xD0, 0xD0, 0xD0);
89/// Unthemed-fallback pressed-wash ink — matches [`crate::text::TextView`]'s
90/// own unthemed default color (`TextStyle::default`'s black), since an
91/// unthemed label paints in that color and the wash is meant to darken it.
92const FALLBACK_INK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
93
94/// English label strings for each selection-toolbar verb, in the pill's fixed
95/// display order (Cut, Copy, Paste, Select all). See the [module docs](self)'
96/// "Labels" section: localisation rides the builder seam, not this table.
97struct ToolbarLabels {
98    cut: &'static str,
99    copy: &'static str,
100    paste: &'static str,
101    select_all: &'static str,
102}
103
104/// The one English label table this baseline ever reads from — see
105/// [`ToolbarLabels`].
106const LABELS: ToolbarLabels = ToolbarLabels {
107    cut: "Cut",
108    copy: "Copy",
109    paste: "Paste",
110    select_all: "Select all",
111};
112
113/// One verb a toolbar item fires — the per-item half of
114/// [`frust_core::SelectionToolbarActions`]' enabled set, plus the label it
115/// renders and the [`EventCtx`] call it makes on an up-inside release.
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117enum ToolbarAction {
118    Cut,
119    Copy,
120    Paste,
121    SelectAll,
122}
123
124/// The pill's resolved chrome colors — themed: `surface_container`/
125/// `outline_variant`; unthemed: [`FALLBACK_FILL`]/[`FALLBACK_BORDER`] exactly.
126/// Mirrors `TextInput`'s `Chrome::resolve` shape
127/// (`crates/frust-widgets/src/textinput.rs`), theme-first with a fallback
128/// constant.
129struct ToolbarChrome {
130    fill: Color,
131    border: Color,
132}
133
134impl ToolbarChrome {
135    fn resolve(theme: Option<&Theme>) -> Self {
136        match theme {
137            Some(theme) => {
138                let s = theme.scheme();
139                ToolbarChrome {
140                    fill: s.surface_container,
141                    border: s.outline_variant,
142                }
143            }
144            None => ToolbarChrome {
145                fill: FALLBACK_FILL,
146                border: FALLBACK_BORDER,
147            },
148        }
149    }
150}
151
152/// One reconcilable pill item: its verb (also its [`ChildKey`], so toggling
153/// which verbs are enabled — a selection shrinking to nothing selectable,
154/// say — relocates a surviving item's live widget instead of rebuilding
155/// whatever now sits at its index) and its erased view.
156struct ToolbarChild {
157    action: ToolbarAction,
158    view: AnyView<()>,
159}
160
161/// The enabled items, in the pill's fixed display order (Cut, Copy, Paste,
162/// Select all — this deliverable's own title and the [module docs](self)).
163fn toolbar_children(actions: SelectionToolbarActions) -> Vec<ToolbarChild> {
164    let mut children = Vec::with_capacity(4);
165    if actions.cut {
166        children.push(ToolbarChild {
167            action: ToolbarAction::Cut,
168            view: item_view(ToolbarAction::Cut, LABELS.cut),
169        });
170    }
171    if actions.copy {
172        children.push(ToolbarChild {
173            action: ToolbarAction::Copy,
174            view: item_view(ToolbarAction::Copy, LABELS.copy),
175        });
176    }
177    if actions.paste {
178        children.push(ToolbarChild {
179            action: ToolbarAction::Paste,
180            view: item_view(ToolbarAction::Paste, LABELS.paste),
181        });
182    }
183    if actions.select_all {
184        children.push(ToolbarChild {
185            action: ToolbarAction::SelectAll,
186            view: item_view(ToolbarAction::SelectAll, LABELS.select_all),
187        });
188    }
189    children
190}
191
192/// Erase one [`ToolbarButtonView`] into an `AnyView<()>`.
193fn item_view(action: ToolbarAction, label: &'static str) -> AnyView<()> {
194    any(ToolbarButtonView { action, label })
195}
196
197/// Build this item's label child view — a plain [`crate::text::text`] run,
198/// left at its default [`crate::text::ThemeTextColor::OnSurface`] role (this
199/// deliverable's own "on_surface labels").
200fn label_view(label: &'static str) -> AnyView<()> {
201    any::<(), _>(crate::text::text(label))
202}
203
204/// The declarative one-item pressable text button. See the [module docs](self).
205struct ToolbarButtonView {
206    action: ToolbarAction,
207    label: &'static str,
208}
209
210impl View<()> for ToolbarButtonView {
211    type Element = ToolbarButtonWidget;
212
213    fn build(&self, ctx: &mut BuildCtx<'_>) -> ToolbarButtonWidget {
214        let view = label_view(self.label);
215        ToolbarButtonWidget {
216            action: self.action,
217            label: self.label,
218            text: crate::authoring::build_child(&view, ctx),
219            pressed: false,
220            captured: false,
221        }
222    }
223
224    fn rebuild(
225        &self,
226        prev: &Self,
227        element: &mut ToolbarButtonWidget,
228        ctx: &mut BuildCtx<'_>,
229    ) -> ChangeFlags {
230        element.action = self.action;
231        element.label = self.label;
232        let prev_view = label_view(prev.label);
233        let next_view = label_view(self.label);
234        crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.text, ctx)
235    }
236
237    fn teardown(&self, element: &mut ToolbarButtonWidget, ctx: &mut BuildCtx<'_>) {
238        let view = label_view(self.label);
239        crate::authoring::teardown_child(&view, &mut element.text, ctx);
240    }
241}
242
243/// The retained per-item widget: a text label child plus the press state
244/// machine every baseline interactive widget in this crate shares (see the
245/// [module docs](self)).
246struct ToolbarButtonWidget {
247    action: ToolbarAction,
248    /// The accessible label (also what [`Widget::semantics`] names the node
249    /// with); kept alongside the rendered [`Self::text`] pod the same way
250    /// [`crate::icon_button::IconButtonWidget`] keeps its own `label` field.
251    label: &'static str,
252    text: ChildPod,
253    /// The pressed *visual* state (the wash paints); purely cosmetic.
254    pressed: bool,
255    /// Armed by a `Down` alongside `capture_pointer`, cleared on `Up`/
256    /// `Cancel` — gates `Move`/`Up` so an uncaptured hover `Move` never
257    /// latches `pressed` or fires (mirrors
258    /// [`crate::icon_button::IconButtonWidget`]'s identical field).
259    captured: bool,
260}
261
262/// Whether widget-local `pos` lies within a `size`-sized box anchored at the
263/// origin (mirrors [`crate::button`]/[`crate::icon_button`]'s
264/// identically-named helper).
265fn inside(pos: Point, size: Size) -> bool {
266    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
267}
268
269/// Return `color` with its alpha channel replaced by `alpha` (mirrors
270/// [`crate::button`]/[`crate::icon_button`]'s identically-named helper).
271fn with_alpha(color: Color, alpha: f32) -> Color {
272    let c = color.components;
273    Color::new([c[0], c[1], c[2], alpha])
274}
275
276impl Widget for ToolbarButtonWidget {
277    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
278        let inset = Size::new(PAD_X * 2.0, PAD_Y * 2.0);
279        let inner_max = Size::new(
280            (bc.max().width - inset.width).max(0.0),
281            (bc.max().height - inset.height).max(0.0),
282        );
283        let label_size = self
284            .text
285            .layout_child(ctx, &BoxConstraints::loose(inner_max));
286        let width = label_size.width + inset.width;
287        let height = (label_size.height + inset.height).max(MIN_HEIGHT);
288        self.text
289            .set_origin(Point::new(PAD_X, (height - label_size.height) / 2.0));
290        bc.constrain(Size::new(width, height))
291    }
292
293    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
294        let theme = Theme::from_paint_ctx(ctx);
295        let ink = match theme {
296            Some(theme) => theme.scheme().on_surface,
297            None => FALLBACK_INK,
298        };
299        if self.pressed {
300            scene.fill_rect(ctx.origin(), ctx.size(), with_alpha(ink, PRESSED_OPACITY));
301        }
302        self.text.paint_child(ctx, scene);
303    }
304
305    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
306        let InputEvent::Pointer(p) = event else {
307            return EventResult::Ignored;
308        };
309        match p.phase {
310            PointerPhase::Down => {
311                if !presses(p) {
312                    return EventResult::Ignored;
313                }
314                self.pressed = true;
315                self.captured = true;
316                ctx.capture_pointer();
317                ctx.request_redraw();
318                EventResult::Handled
319            }
320            PointerPhase::Move => {
321                if !self.captured {
322                    return EventResult::Ignored;
323                }
324                self.pressed = inside(p.position, ctx.size());
325                ctx.request_redraw();
326                EventResult::Handled
327            }
328            PointerPhase::Up => {
329                if !self.captured {
330                    return EventResult::Ignored;
331                }
332                // Fire on up-inside only.
333                if inside(p.position, ctx.size()) {
334                    match self.action {
335                        ToolbarAction::Cut => ctx.dispatch_edit_command(EditCommand::Cut),
336                        ToolbarAction::Copy => ctx.dispatch_edit_command(EditCommand::Copy),
337                        ToolbarAction::SelectAll => {
338                            ctx.dispatch_edit_command(EditCommand::SelectAll)
339                        }
340                        // Only the shell may read the host clipboard, so Paste
341                        // asks for it instead of dispatching a command a field
342                        // could apply directly — see `EventCtx::request_paste`.
343                        ToolbarAction::Paste => ctx.request_paste(),
344                    }
345                }
346                self.pressed = false;
347                self.captured = false;
348                ctx.request_redraw();
349                EventResult::Handled
350            }
351            PointerPhase::Cancel => {
352                if !self.captured {
353                    return EventResult::Ignored;
354                }
355                self.pressed = false;
356                self.captured = false;
357                ctx.request_redraw();
358                EventResult::Handled
359            }
360        }
361    }
362
363    fn semantics(&self, ctx: &mut SemanticsCtx) {
364        // One a11y node per item (Role::Button), named by its own label.
365        ctx.push_node(Role::Button, |node| {
366            node.set_label(self.label);
367            node.add_action(Action::Click);
368        });
369    }
370
371    crate::authoring::visit_children!(text);
372}
373
374/// Build the baseline framework-drawn selection-toolbar view for `request`: a
375/// pill of text buttons for whichever verbs
376/// [`request.actions`](SelectionToolbarRequest::actions) enables. See the
377/// [module docs](self).
378///
379/// The pod is over `()` (see `frust_core::selection_toolbar`'s module docs
380/// for why an overlay pod carries no application state): the returned view
381/// never reads anything but `request` itself, so it is usable from a field
382/// hosted under any app state at all. Sized to its content — the
383/// [`OverlaySlot`](crate::overlay::OverlaySlot) that floats it, via
384/// [`crate::overlay::place`], decides where that content sits.
385///
386/// [`request.present_menu`](SelectionToolbarRequest::present_menu) is ignored
387/// here, and that is not an oversight: a builder is called only for a bar the
388/// field already wants, so on this route the flag says nothing the call itself
389/// has not. It exists for the platform route, where "which verbs apply" is a
390/// level a host reads at any moment and "put a menu up" is an edge.
391pub fn selection_toolbar(request: &SelectionToolbarRequest) -> AnyView<()> {
392    any(SelectionToolbarView {
393        actions: request.actions,
394    })
395}
396
397struct SelectionToolbarView {
398    actions: SelectionToolbarActions,
399}
400
401impl View<()> for SelectionToolbarView {
402    type Element = SelectionToolbarWidget;
403
404    fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionToolbarWidget {
405        let children = toolbar_children(self.actions);
406        let mut items = Vec::with_capacity(children.len());
407        for child in &children {
408            items.push(crate::authoring::build_child(&child.view, ctx));
409        }
410        SelectionToolbarWidget { items }
411    }
412
413    fn rebuild(
414        &self,
415        prev: &Self,
416        element: &mut SelectionToolbarWidget,
417        ctx: &mut BuildCtx<'_>,
418    ) -> ChangeFlags {
419        let prev_children = toolbar_children(prev.actions);
420        let next_children = toolbar_children(self.actions);
421        // Keyed by verb: a selection change that shrinks or grows the enabled
422        // set relocates a surviving item's live widget (and its press state)
423        // rather than rebuilding whatever now sits at its index.
424        crate::authoring::rebuild_children::<(), ToolbarChild>(
425            &prev_children,
426            &next_children,
427            &mut element.items,
428            ctx,
429            |c| &c.view,
430            |c| Some(ChildKey::new(c.action)),
431        )
432    }
433
434    fn teardown(&self, element: &mut SelectionToolbarWidget, ctx: &mut BuildCtx<'_>) {
435        let children = toolbar_children(self.actions);
436        for (child, pod) in children.iter().zip(element.items.iter_mut()) {
437            crate::authoring::teardown_child(&child.view, pod, ctx);
438        }
439    }
440}
441
442/// The retained pill widget: the pill's own chrome (painted here, theme-first
443/// — see [`ToolbarChrome`]) around a left-to-right, no-gap row of item pods.
444struct SelectionToolbarWidget {
445    items: Vec<ChildPod>,
446}
447
448impl Widget for SelectionToolbarWidget {
449    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
450        // Sized to content (module docs): each item gets a loose probe up to
451        // the incoming max (the window, under the overlay pod's own loose
452        // constraint), and the pill hugs their sum/tallest exactly.
453        let item_bc = BoxConstraints::loose(bc.max());
454        let mut x = 0.0;
455        let mut height: f64 = 0.0;
456        for pod in &mut self.items {
457            let size = pod.layout_child(ctx, &item_bc);
458            pod.set_origin(Point::new(x, 0.0));
459            x += size.width;
460            height = height.max(size.height);
461        }
462        bc.constrain(Size::new(x, height))
463    }
464
465    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
466        let theme = Theme::from_paint_ctx(ctx);
467        let chrome = ToolbarChrome::resolve(theme);
468        let origin = ctx.origin();
469        let size = ctx.size();
470        if size.width > 0.0 && size.height > 0.0 {
471            scene.fill_rounded_rect(origin, size, RADIUS, chrome.fill);
472            // Inset by half the stroke width so the hairline paints fully
473            // inside the pill's own bounds (mirrors `crate::button`'s
474            // identical border discipline).
475            let half = BORDER_WIDTH / 2.0;
476            let rr = RoundedRect::new(
477                half,
478                half,
479                size.width - half,
480                size.height - half,
481                (RADIUS - half).max(0.0),
482            );
483            let path = rr.to_path(BORDER_TOLERANCE);
484            scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(chrome.border));
485        }
486        for pod in &mut self.items {
487            pod.paint_child(ctx, scene);
488        }
489    }
490
491    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
492        crate::authoring::route_event(&mut self.items, ctx, event)
493    }
494
495    fn semantics(&self, ctx: &mut SemanticsCtx) {
496        for pod in &self.items {
497            pod.semantics_child(ctx);
498        }
499    }
500
501    crate::authoring::visit_children!(items);
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use std::cell::RefCell;
508    use std::rc::Rc;
509
510    use frust_core::{FrameTime, PointerButton, PointerEvent, RenderRoot};
511    use kurbo::Rect;
512
513    use crate::overlay::{OverlayAnchor, OverlayPlacement, OverlaySlot};
514    use crate::test_support::RecordingScene;
515
516    const WINDOW: Size = Size::new(400.0, 600.0);
517
518    fn request(actions: SelectionToolbarActions) -> SelectionToolbarRequest {
519        SelectionToolbarRequest {
520            anchor: Rect::new(150.0, 100.0, 250.0, 120.0),
521            actions,
522            // A builder only ever runs for a bar that is wanted; the flag is
523            // the platform route's, and this view ignores it.
524            present_menu: true,
525        }
526    }
527
528    // --- pure widget-level tests (no RenderRoot needed) --------------------
529
530    fn build_widget(actions: SelectionToolbarActions) -> SelectionToolbarWidget {
531        let view = SelectionToolbarView { actions };
532        let mut next_id = 0u64;
533        View::<()>::build(&view, &mut BuildCtx::new(&mut next_id))
534    }
535
536    #[test]
537    fn only_enabled_actions_render() {
538        let w = build_widget(SelectionToolbarActions {
539            cut: false,
540            copy: true,
541            paste: true,
542            select_all: false,
543        });
544        assert_eq!(w.items.len(), 2, "only Copy and Paste are enabled");
545    }
546
547    #[test]
548    fn every_action_renders_one_item_each_in_display_order() {
549        let w = build_widget(SelectionToolbarActions {
550            cut: true,
551            copy: true,
552            paste: true,
553            select_all: true,
554        });
555        assert_eq!(w.items.len(), 4);
556    }
557
558    #[test]
559    fn no_enabled_actions_renders_nothing() {
560        let w = build_widget(SelectionToolbarActions::default());
561        assert!(w.items.is_empty());
562    }
563
564    #[test]
565    fn chrome_resolves_theme_first_with_a_fallback_when_unthemed() {
566        let unthemed = ToolbarChrome::resolve(None);
567        assert_eq!(unthemed.fill, FALLBACK_FILL);
568        assert_eq!(unthemed.border, FALLBACK_BORDER);
569
570        let theme = Theme::neutral();
571        let scheme = theme.scheme();
572        let themed = ToolbarChrome::resolve(Some(&theme));
573        assert_eq!(themed.fill, scheme.surface_container);
574        assert_eq!(themed.border, scheme.outline_variant);
575        assert_ne!(
576            themed.fill, unthemed.fill,
577            "fixture sanity: the neutral theme's role must actually differ \
578             from the unthemed fallback, or this test proves nothing"
579        );
580    }
581
582    // --- hosted through a real root, via an OverlaySlot<()> ----------------
583    // Mirrors `crate::overlay`'s own `ToolbarHost`/`unit_harness` test shape:
584    // a small owner that mounts `selection_toolbar`'s output through an
585    // `OverlaySlot<()>`, exactly the way a text field hosts its own pod.
586
587    type Log = Rc<RefCell<Vec<EditCommand>>>;
588
589    struct Host {
590        actions: SelectionToolbarActions,
591        log: Log,
592    }
593
594    struct HostWidget {
595        slot: OverlaySlot<()>,
596        view: Option<AnyView<()>>,
597        actions: SelectionToolbarActions,
598        log: Log,
599    }
600
601    impl Host {
602        fn pod(&self) -> AnyView<()> {
603            selection_toolbar(&request(self.actions))
604        }
605    }
606
607    impl View<()> for Host {
608        type Element = HostWidget;
609        fn build(&self, ctx: &mut BuildCtx<'_>) -> HostWidget {
610            let mut slot = OverlaySlot::new();
611            slot.set_placement(OverlayPlacement::default());
612            slot.set_anchor(OverlayAnchor::Rect(request(self.actions).anchor));
613            let view = self.pod();
614            slot.rebuild(None, Some(&view), ctx);
615            HostWidget {
616                slot,
617                view: Some(view),
618                actions: self.actions,
619                log: Rc::clone(&self.log),
620            }
621        }
622        fn rebuild(
623            &self,
624            _prev: &Self,
625            element: &mut HostWidget,
626            ctx: &mut BuildCtx<'_>,
627        ) -> ChangeFlags {
628            element.actions = self.actions;
629            element.log = Rc::clone(&self.log);
630            let view = self.pod();
631            let flags = element
632                .slot
633                .rebuild(element.view.as_ref(), Some(&view), ctx);
634            element.view = Some(view);
635            flags
636        }
637    }
638
639    impl Widget for HostWidget {
640        fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
641            self.slot.layout(ctx);
642            bc.constrain(Size::new(300.0, 300.0))
643        }
644        fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
645            let size = ctx.size();
646            self.slot.paint(ctx, size);
647        }
648        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
649            let mut pod_state = ();
650            let Some(result) = self.slot.event(ctx, event, &mut pod_state) else {
651                return EventResult::Ignored;
652            };
653            // Drained the instant the forward returns, per
654            // `EventCtx::take_edit_commands`'s own contract.
655            for command in ctx.take_edit_commands() {
656                self.log.borrow_mut().push(command);
657            }
658            result
659        }
660    }
661
662    /// Drive a single-item pill through a real root, laid out and painted
663    /// once. The single anchor `request()` uses (width 100, centered at
664    /// x=150+50=200) keeps every click test below geometry-agnostic: under
665    /// `OverlayAlign::Center` (the default), the placed pill's own horizontal
666    /// center always lands exactly on the anchor's horizontal center,
667    /// regardless of the pill's actual (text-shaping-dependent) width — see
668    /// `crate::overlay::place`'s `align_start` — so a click at that x is
669    /// always inside the pill without this test needing to know its width.
670    fn harness(actions: SelectionToolbarActions) -> (RenderRoot<(), Host>, Log) {
671        let log: Log = Rc::new(RefCell::new(Vec::new()));
672        let mut root: RenderRoot<(), Host> = RenderRoot::new();
673        let captured = Rc::clone(&log);
674        let mut build = move |_: &mut ()| Host {
675            actions,
676            log: Rc::clone(&captured),
677        };
678        let mut state = ();
679        root.rebuild(&mut build, &mut state);
680        let mut tcx = frust_text::TextContext::new();
681        root.layout_with_text(WINDOW, &mut tcx as &mut dyn std::any::Any);
682        root.paint(&mut RecordingScene::default(), FrameTime::from_nanos(0));
683        (root, log)
684    }
685
686    /// The anchor's horizontal center (see `harness`'s doc comment) and a
687    /// small fixed offset below its bottom edge plus the default anchor gap —
688    /// safely inside a pill at least [`MIN_HEIGHT`] tall regardless of its
689    /// (text-shaping-dependent) width.
690    fn click_point() -> Point {
691        Point::new(200.0, 100.0 + 20.0 + crate::overlay::DEFAULT_OFFSET + 6.0)
692    }
693
694    fn pointer(phase: PointerPhase, p: Point) -> InputEvent {
695        InputEvent::Pointer(PointerEvent {
696            phase,
697            position: p,
698            button: PointerButton::Primary,
699        })
700    }
701
702    #[test]
703    fn a_tap_on_copy_enqueues_edit_command_copy() {
704        let (mut root, log) = harness(SelectionToolbarActions {
705            copy: true,
706            ..Default::default()
707        });
708        let mut state = ();
709        let p = click_point();
710        root.event(&mut state, &pointer(PointerPhase::Down, p));
711        root.event(&mut state, &pointer(PointerPhase::Up, p));
712        assert_eq!(
713            log.borrow().clone(),
714            vec![EditCommand::Copy],
715            "an up-inside tap on the sole Copy item must dispatch EditCommand::Copy"
716        );
717    }
718
719    #[test]
720    fn a_tap_on_paste_sets_take_paste_request() {
721        let (mut root, _log) = harness(SelectionToolbarActions {
722            paste: true,
723            ..Default::default()
724        });
725        let mut state = ();
726        assert!(!root.take_paste_request(), "nothing asked yet");
727        let p = click_point();
728        root.event(&mut state, &pointer(PointerPhase::Down, p));
729        root.event(&mut state, &pointer(PointerPhase::Up, p));
730        assert!(
731            root.take_paste_request(),
732            "an up-inside tap on the sole Paste item must raise the paste request"
733        );
734        assert!(
735            !root.take_paste_request(),
736            "draining the request must clear it"
737        );
738    }
739
740    #[test]
741    fn a_tap_that_moves_out_before_release_fires_nothing() {
742        let (mut root, log) = harness(SelectionToolbarActions {
743            copy: true,
744            ..Default::default()
745        });
746        let mut state = ();
747        let p = click_point();
748        root.event(&mut state, &pointer(PointerPhase::Down, p));
749        // Released far outside the pill entirely.
750        root.event(&mut state, &pointer(PointerPhase::Up, Point::new(0.0, 0.0)));
751        assert!(
752            log.borrow().is_empty(),
753            "an up-outside release must not dispatch anything"
754        );
755        assert!(!root.take_paste_request());
756    }
757
758    #[test]
759    fn semantics_exposes_one_button_node_per_enabled_action_with_its_label() {
760        fn logic(_s: &mut ()) -> AnyView<()> {
761            selection_toolbar(&request(SelectionToolbarActions {
762                cut: true,
763                copy: true,
764                paste: false,
765                select_all: true,
766            }))
767        }
768        let mut root: RenderRoot<(), AnyView<()>> = RenderRoot::new();
769        let mut state = ();
770        root.rebuild(&mut logic, &mut state);
771        let mut tcx = frust_text::TextContext::new();
772        root.layout_with_text(WINDOW, &mut tcx as &mut dyn std::any::Any);
773        let update = root.semantics();
774        let buttons: Vec<_> = update
775            .nodes
776            .iter()
777            .filter(|(_, n)| n.role() == Role::Button)
778            .collect();
779        assert_eq!(buttons.len(), 3, "one node per enabled verb, not per glyph");
780        let labels: Vec<_> = buttons.iter().filter_map(|(_, n)| n.label()).collect();
781        assert_eq!(labels, vec!["Cut", "Copy", "Select all"]);
782        for (_, node) in &buttons {
783            assert!(node.supports_action(Action::Click));
784        }
785    }
786}