Skip to main content

frust_material/
selection.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments)
2// Upstream: https://github.com/paadevelopments/material_3_expressive
3//   lib/components/selection/ (`m3e_selection.dart`'s `M3ESelection`,
4//   `controllers/m3e_selection_controller.dart`'s `M3ESelectionController`,
5//   `components/m3e_selection_app_bar.dart`'s `M3ESelectionAppBar`,
6//   `components/m3e_selection_scope.dart`'s `M3ESelectionScope`,
7//   `components/m3e_selection_leading.dart`'s `M3ESelectionLeading`,
8//   `styles/m3e_selection_theme.dart`'s `M3ESelectionTheme`)
9// This rework restates the reference's `ChangeNotifier`-backed controller as
10// pure functions over an app-owned `BTreeSet<usize>` (this crate's controlled-
11// component convention has no self-mutating retained state to notify from),
12// and merges `M3ESelection` + `M3ESelectionAppBar` + `M3ESelectionScope` into
13// two independent pieces — [`selection_host`] (the item wrapper) and
14// [`selection_app_bar`] (the bar) — rather than one `InheritedWidget`-threaded
15// host, since every other component in this crate takes its collaborators as
16// explicit props. See the module docs' Not ported section for what stays
17// unported: `M3ESelectionLeading`'s 3D flip, the per-leading-tap toggle
18// target, and `M3ESelection`'s `Scaffold` wrapping.
19
20//! The M3E **selection** pattern: an index-set selection mode over a list of
21//! items, entered by a long-press and exited by clearing the selection, plus
22//! the **contextual bar** that swaps in over an idle app bar while it is
23//! active.
24//!
25//! # Controller: pure functions, not an owned controller
26//!
27//! The reference's `M3ESelectionController` is a `ChangeNotifier` an app
28//! mutates in place (`select`/`deselect`/`toggle`/`selectAll`/`clear`) and
29//! listens to. This crate's controlled-component convention has no
30//! self-mutating retained state to notify from (`docs/CODE_STANDARDS.md`'s
31//! Interaction Semantics: "controlled components never self-mutate") — so
32//! this module ports the same five mutations as pure functions over an
33//! app-owned `BTreeSet<usize>`, each returning the *next* set for the app to
34//! adopt into its own signal: [`select`], [`deselect`], [`toggle`],
35//! [`select_all`], [`cleared`]. [`is_selection_mode`] mirrors the reference's
36//! derived `isSelectionMode` getter exactly (`!selected.is_empty()`) — there
37//! is no independent "in selection mode" flag anywhere in this module; mode
38//! is *always* recomputed from the live `selected` set, which is what makes
39//! the controlled semantics below hold. [`is_selected`] and
40//! [`all_selected_for`] port the remaining read-only queries
41//! (`isSelected`/`allSelectedFor`) verbatim.
42//!
43//! # Wrapper: [`selection_host`]
44//!
45//! [`selection_host`] wraps arbitrary [`frust::authoring::AnyView`] item rows
46//! (a `list_item`/`card_list` row, or any other content) as a single
47//! interactive target *per row* — the same "the whole row owns capture"
48//! convention [`mod@super::card_list`] and [`mod@super::list_item`] establish.
49//! **An item passed here should not chain its own `on_press`** (or any other
50//! row-level press handling): the host owns Down/Move/Up/Cancel for the whole
51//! row and never forwards a pointer event into a captured row's children (a
52//! caller wanting a *second*, independently-tappable target inside a row —
53//! the reference's tap-the-leading-avatar-to-toggle affordance — is exactly
54//! what this port does not carry forward; see Not ported below).
55//!
56//! Two outcomes resolve a tap, mutually exclusively, exactly like the
57//! reference's own `_onTap`/`_onLongPress` pairing (see
58//! `example/lib/pages/playground/view/selection_playground.dart`'s
59//! `_SelectionDemoHostState`, the reference's own worked wiring of
60//! `M3ESelectionController` against a plain list):
61//!
62//! - **Tap** (`Up` released inside the same row a `Down` armed): while
63//!   [`is_selection_mode`] reads `true`, fires [`SelectionHostView`]'s
64//!   `on_toggle` with the row's index (`_onTap`'s `selection.toggle(index)`
65//!   branch); otherwise fires `on_activate` (`_onTap`'s `else` branch — the
66//!   row's ordinary action, e.g. opening it).
67//! - **Long-press** (held past [`LONG_PRESS_MS`], 500ms): fires `on_toggle`
68//!   with the row's index **only when it is not already selected** — ported
69//!   verbatim from `_onLongPress`'s own guard (`if (!isSelected(index))
70//!   select(index)`), so a long-press can enter or extend selection mode but
71//!   never *exits* it by deselecting the row it landed on. Movement past
72//!   [`frust::input::TOUCH_SLOP`] from the initiating `Down` cancels the
73//!   long-press candidacy (a drag, not a hold) without cancelling the row's
74//!   own tap/toggle resolution at `Up`.
75//!
76//! `paint` is the only pass carrying a clock in this framework, so the
77//! long-press threshold is latched there and fires on the next pointer event
78//! that arrives after it (a `Move`, or `Up` as the final fallback) — the same
79//! `frust_core::mark_pending_result_flush`-unreachable-from-a-plugin
80//! constraint [`mod@super::toggle_button`]'s own Long-press section documents
81//! at length; this module's [`LongPressState`] is that same shape, ported
82//! fresh here (`docs/PLUGINS_CODE_STANDARDS.md`'s design-system charter: no
83//! `frust-core` dependency in production, so `frust_widgets::gesture`'s
84//! `on_long_press` primitive — which *does* reach that function — is
85//! unreachable from this crate either way).
86//!
87//! `selection_host` paints no chrome of its own (no selection fill, no
88//! pressed/hover overlay): the item view a caller supplies owns every visual
89//! — a selected row's checkmark/fill is the caller's own
90//! `list_item(...).selected(is_selected(&selected, i))` (or a
91//! [`crate::checkbox`] wired as its leading slot), exactly as the reference's
92//! own `_item`/`_leading` builder keys every visual off `_selection.isSelected
93//! (index)` rather than the selection host painting it centrally.
94//!
95//! # Contextual bar: [`selection_app_bar`]
96//!
97//! [`selection_app_bar`] wraps an `idle` app bar (or any other intrinsic-
98//! height content) and swaps to a contextual toolbar — a leading close
99//! affordance, a selected-count label, caller-supplied trailing actions, and
100//! an optional select-all row — whenever [`is_selection_mode`] reads `true`
101//! for the `selected` set it was built with. The swap is a plain structural
102//! rebuild (the idle branch is torn down, the contextual one built, or vice
103//! versa — [`frust::authoring::AnyView`]'s own type-changed `rebuild` already
104//! takes this shape for a single child; here it is two whole sibling sets of
105//! children reconciled the same way), not an animated cross-fade: the
106//! reference's own `AnimatedSize` + `AnimatedSwitcher` (220ms enter / 90ms
107//! exit, `Cubic(0.2, 0, 0, 1)` / `Cubic(0.4, 0, 1, 1)`) motion is out of this
108//! module's v1 scope — see Not ported below.
109//!
110//! **Metrics cited against `appbar.rs`.** The contextual toolbar matches the
111//! idle app bar's own band so the header reads as one continuous surface
112//! across the swap: [`APP_BAR_HEIGHT`]/[`APP_BAR_PAD_X`]/[`APP_BAR_GAP`]
113//! mirror `appbar.rs`'s private `HEIGHT`/`PAD_X`/`GAP` exactly (64dp / 4dp /
114//! 4dp) — duplicated here since those constants are private to that module,
115//! the same citation shape [`mod@super::toggle_button`]'s
116//! `ToggleButtonSize::square_radius` uses for a private sibling-module value.
117//! This bar is its own surface, not a shared base type over the app bar.
118//!
119//! **Window insets: the contextual branch insets like the idle bar.** An idle
120//! [`crate::app_bar`]/[`crate::search_app_bar`] consumes the top, left and
121//! right window insets itself by default (Flutter's `AppBar`). The contextual
122//! branch does the same: it grows by `padding().top`, lays its close button,
123//! count, actions and select-all row out below it and inside the side
124//! insets, and paints its `primary_container` fill edge to edge through the
125//! whole band. So the swap keeps one height and one set of slot offsets in
126//! every composition — unwrapped (both branches consume the inset) or under
127//! a consuming `frust::safe_area(..)` (both see 0). The idle view is an
128//! opaque [`AnyView`], so its own `.safe_area(..)` cannot be read from here:
129//! this bar mirrors the app bar's default, and a caller that opts its idle
130//! bar out with `.safe_area(false)` opts this one out too
131//! ([`SelectionAppBarView::safe_area`]). The contextual branch does not apply
132//! the app bar's window-control `corner_shift`.
133//!
134//! # Controlled semantics
135//!
136//! Neither [`SelectionHostWidget`] nor [`SelectionAppBarWidget`] retains an
137//! independent "in selection mode" flag: both recompute [`is_selection_mode`]
138//! from the `selected` prop on every `rebuild`/`layout`/`paint`. An app that
139//! clears its own `selected` set externally (a system-back handler, a
140//! "done" action elsewhere in the UI) collapses the contextual bar back to
141//! idle and restores tap-to-activate on the very next rebuild — no
142//! `on_clear`/`on_toggle` round trip is needed to leave selection mode, only
143//! to *enter* or *extend* it (mirrors the reference's own `PopScope`
144//! guidance: "system back clears selection", documented on [`M3ESelection`]
145//! → [`selection_host`]/[`selection_app_bar`] here — see this module's own
146//! doc example on [`selection_app_bar`]).
147//!
148//! # Not ported
149//!
150//! * `M3ESelectionLeading`'s 3D Y-axis flip (`Transform`+`Matrix4`, a
151//!   perspective rotation between the unselected and selected leading faces).
152//!   This framework's [`frust::authoring::PaintScene`] takes only 2D affine
153//!   paint calls — no perspective/3D primitive exists to port a `rotateY`
154//!   flip onto. A caller wanting a selected-state leading visual swaps its
155//!   own leading `AnyView` (e.g. an avatar for a [`crate::checkbox`]) keyed
156//!   off [`is_selected`], the same shape `list_item`'s own `leading` slot
157//!   already takes any view.
158//! * The reference's per-leading tap target (`M3ESelectionLeading(onTap: () =>
159//!   controller.toggle(index))`, a *second* independently-tappable zone
160//!   layered over just the leading slot). [`selection_host`] makes the whole
161//!   row one interactive target, the single-target-per-row convention every
162//!   other interactive row in this catalog follows
163//!   ([`mod@super::list_item`], [`mod@super::card_list`]).
164//! * `M3ESelection`'s `Scaffold` wrapping (`scaffold`/`backgroundColor`/
165//!   `resizeToAvoidBottomInset`) — Flutter-`Scaffold`-specific chrome this
166//!   crate has no analogue for; a caller composes [`selection_app_bar`] above
167//!   [`selection_host`] in its own layout, the same way an idle `app_bar` and
168//!   its body are composed today (no scaffold widget exists in this crate).
169//! * `M3ESelectionScope`'s `InheritedWidget` context resolution
170//!   (`M3ESelectionAppBar`'s optional-controller-falls-back-to-scope
171//!   pattern). Every collaborator here is an explicit prop instead, matching
172//!   every other component in this crate.
173//! * The `AnimatedSize`/`AnimatedSwitcher` contextual-bar transition — see
174//!   the Contextual bar section above.
175//!
176//! # Attribution
177//!
178//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright Holders
179//! (Vendored Components)" section and its Module Attribution Header
180//! Convention. `MaterialTokens`-only: no new theme-extension type is
181//! introduced here (unlike the reference's own `M3ESelectionTheme`) — every
182//! color/shape resolution below reads `Theme`/`ColorScheme` directly, the
183//! same pattern `list_item`/`card_list` already establish.
184
185use std::collections::BTreeSet;
186use std::rc::Rc;
187
188use frust::authoring::text::{FontWeight, LineHeight};
189use frust::authoring::{Action, Role};
190use frust::authoring::{
191    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
192    EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
193    ThemeTextColor, ThemeTextType, View, ViewSeq, Widget, any,
194};
195use frust::input::TOUCH_SLOP;
196use frust::{FrameTime, Theme, icon, text};
197use kurbo::{Point, Size};
198use peniko::Color;
199
200use crate::icon_button::icon_button;
201use crate::press::presses;
202
203// ---- Metrics cited against `appbar.rs` (private there; see the module docs'
204// Contextual bar section) --------------------------------------------------
205
206/// Small/center-aligned app bar container height, in logical px — mirrors
207/// `appbar.rs`'s private `HEIGHT`.
208const APP_BAR_HEIGHT: f64 = 64.0;
209/// Horizontal inset from the bar's leading/trailing edges — mirrors
210/// `appbar.rs`'s private `PAD_X`.
211const APP_BAR_PAD_X: f64 = 4.0;
212/// Gap between adjacent slots — mirrors `appbar.rs`'s private `GAP`
213/// (upstream's `theme.appBarTheme.titleGap`).
214const APP_BAR_GAP: f64 = 4.0;
215/// Leading/trailing action slot width — the reference's own literal
216/// (`m3e_selection_app_bar.dart`'s `_actionSlot`, commented there as
217/// "M3EIconButton sm target"), carried over as-is rather than resolved from
218/// this crate's own `IconButtonSize` table (whose `Sm` hit target is 40dp,
219/// not 48 — the reference's own constant, not a token this port re-derives).
220const ACTION_SLOT: f64 = 48.0;
221/// Height of the select-all row (`M3ESelectionTheme.selectAllHeight`).
222const SELECT_ALL_HEIGHT: f64 = 48.0;
223
224/// The count label's M3 `titleLargeEmphasized` type-scale token — mirrors
225/// `appbar.rs`'s private `TITLE_SIZE`/`TITLE_LINE_HEIGHT`/`TITLE_WEIGHT`
226/// (upstream's `theme.typeScale.titleLarge`).
227const COUNT_SIZE: f32 = 22.0;
228const COUNT_LINE_HEIGHT: f32 = 28.0;
229const COUNT_WEIGHT: FontWeight = FontWeight::MEDIUM;
230
231/// Unthemed-fallback contextual bar container fill (a theme resolves this
232/// from `colors.primary_container`).
233const CONTEXTUAL_BACKGROUND: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
234
235/// Accessible label for the contextual bar's close affordance
236/// (`M3ESelectionAppBar`'s `tooltip`/`semanticLabel`, both `'Clear
237/// selection'`).
238const CLEAR_SELECTION_LABEL: &str = "Clear selection";
239
240/// The press duration after which a held row press becomes a long-press
241/// (`frust_widgets::gesture`'s and `toggle_button.rs`'s own
242/// `LONG_PRESS_MS`). **Community-approximate** — Android's
243/// `ViewConfiguration` defaults to ~400-500ms, iOS's
244/// `UILongPressGestureRecognizer.minimumPressDuration` to 0.5s.
245const LONG_PRESS_MS: f64 = 500.0;
246
247// ---- Controller: pure functions over an app-owned `BTreeSet<usize>` ------
248
249/// Whether `selected` puts the surface in selection mode
250/// (`M3ESelectionController.isSelectionMode`).
251pub fn is_selection_mode(selected: &BTreeSet<usize>) -> bool {
252    !selected.is_empty()
253}
254
255/// Whether `index` is selected (`M3ESelectionController.isSelected`).
256pub fn is_selected(selected: &BTreeSet<usize>, index: usize) -> bool {
257    selected.contains(&index)
258}
259
260/// A tristate value for a select-all control given `item_count`
261/// (`M3ESelectionController.allSelectedFor`): `Some(true)` when every index
262/// is selected, `Some(false)` when none are, `None` when some but not all
263/// are.
264pub fn all_selected_for(selected: &BTreeSet<usize>, item_count: usize) -> Option<bool> {
265    if item_count == 0 || selected.is_empty() {
266        return Some(false);
267    }
268    if selected.len() >= item_count {
269        return Some(true);
270    }
271    None
272}
273
274/// The set with `index` selected, if it was not already
275/// (`M3ESelectionController.select`).
276pub fn select(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
277    let mut next = selected.clone();
278    next.insert(index);
279    next
280}
281
282/// The set with `index` deselected, if it was selected
283/// (`M3ESelectionController.deselect`).
284pub fn deselect(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
285    let mut next = selected.clone();
286    next.remove(&index);
287    next
288}
289
290/// The set with `index`'s membership flipped
291/// (`M3ESelectionController.toggle`).
292pub fn toggle(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
293    let mut next = selected.clone();
294    if !next.remove(&index) {
295        next.insert(index);
296    }
297    next
298}
299
300/// Every index in `0..item_count`, selected
301/// (`M3ESelectionController.selectAll`).
302pub fn select_all(item_count: usize) -> BTreeSet<usize> {
303    (0..item_count).collect()
304}
305
306/// The cleared (empty) selection (`M3ESelectionController.clear`).
307pub fn cleared() -> BTreeSet<usize> {
308    BTreeSet::new()
309}
310
311// ---- selection_host: the item wrapper -------------------------------------
312
313type OnIndex<State> = Rc<dyn Fn(&mut State, usize)>;
314
315/// A declarative selection-mode item wrapper. See the [module docs](self).
316pub struct SelectionHostView<State: 'static> {
317    items: Vec<AnyView<State>>,
318    selected: BTreeSet<usize>,
319    enabled: bool,
320    on_toggle: OnIndex<State>,
321    on_activate: Option<OnIndex<State>>,
322}
323
324/// Wrap `items` in the selection gesture router: tap toggles membership while
325/// `selected` is non-empty, otherwise fires `on_activate`
326/// ([`SelectionHostView::on_activate`]); a long-press selects an unselected
327/// row (see the [module docs](self)' Wrapper section). `on_toggle` reports
328/// the row index whose membership should flip.
329pub fn selection_host<State: 'static, F, M>(
330    items: impl ViewSeq<State, M>,
331    selected: &BTreeSet<usize>,
332    on_toggle: F,
333) -> SelectionHostView<State>
334where
335    F: Fn(&mut State, usize) + 'static,
336{
337    let mut erased = Vec::new();
338    items.extend_views(&mut erased);
339    SelectionHostView {
340        items: erased,
341        selected: selected.clone(),
342        enabled: true,
343        on_toggle: Rc::new(on_toggle),
344        on_activate: None,
345    }
346}
347
348impl<State: 'static> SelectionHostView<State> {
349    /// Fire when a row is tapped while `selected` is empty (ordinary row
350    /// activation, e.g. opening it). Absent by default: a tap outside
351    /// selection mode with no `on_activate` set is simply a no-op.
352    pub fn on_activate<F: Fn(&mut State, usize) + 'static>(mut self, on_activate: F) -> Self {
353        self.on_activate = Some(Rc::new(on_activate));
354        self
355    }
356
357    /// Gate the whole router (defaults to `true`): while `false`, no row
358    /// captures a press, arms a long-press, or fires either callback.
359    pub fn enabled(mut self, enabled: bool) -> Self {
360        self.enabled = enabled;
361        self
362    }
363}
364
365/// Latched long-press timing state — mirrors `toggle_button.rs`'s
366/// `LongPressState` (see the [module docs](self)' Wrapper section).
367#[derive(Debug, Default, Clone, Copy)]
368struct LongPressState {
369    /// The frame time the capture started, seeded on the first `paint` after
370    /// a `Down`.
371    press_start: Option<FrameTime>,
372    /// Whether the hold has crossed [`LONG_PRESS_MS`] — latched in `paint`,
373    /// the only pass with a clock.
374    elapsed: bool,
375    /// Whether the callback has already fired for this press (fire-exactly-
376    /// once).
377    fired: bool,
378}
379
380impl LongPressState {
381    fn clear(&mut self) {
382        *self = Self::default();
383    }
384}
385
386/// The retained widget for a [`SelectionHostView`]. See the [module
387/// docs](self).
388pub struct SelectionHostWidget {
389    items: Vec<ChildPod>,
390    selected: BTreeSet<usize>,
391    enabled: bool,
392    /// Each row's `(y, height)` in local space, recorded during layout — the
393    /// hit test in `event` reads this.
394    rows: Vec<(f64, f64)>,
395    width: f64,
396    /// The row index a `Down` armed (alongside `capture_pointer`), cleared on
397    /// `Up`/`Cancel`/loss of enablement.
398    armed: Option<usize>,
399    /// Where the capturing `Down` landed, in local coordinates — the
400    /// long-press slop reference point.
401    down_pos: Point,
402    long_press: LongPressState,
403    on_toggle: ErasedArgCallback<usize>,
404    on_activate: Option<ErasedArgCallback<usize>>,
405}
406
407impl<State: 'static> View<State> for SelectionHostView<State> {
408    type Element = SelectionHostWidget;
409
410    fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionHostWidget {
411        let items = self
412            .items
413            .iter()
414            .map(|view| frust::authoring::build_child(view, ctx))
415            .collect();
416        SelectionHostWidget {
417            items,
418            selected: self.selected.clone(),
419            enabled: self.enabled,
420            rows: Vec::new(),
421            width: 0.0,
422            armed: None,
423            down_pos: Point::ZERO,
424            long_press: LongPressState::default(),
425            on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
426            on_activate: self
427                .on_activate
428                .as_ref()
429                .map(frust::authoring::erase_callback_arg),
430        }
431    }
432
433    fn rebuild(
434        &self,
435        prev: &Self,
436        element: &mut SelectionHostWidget,
437        ctx: &mut BuildCtx<'_>,
438    ) -> ChangeFlags {
439        let mut flags = frust::authoring::rebuild_children(
440            &prev.items,
441            &self.items,
442            &mut element.items,
443            ctx,
444            |view| view,
445            |_| None,
446        );
447
448        if prev.items.len() != self.items.len() {
449            // A structural change invalidates the recorded press: the armed
450            // index may now name a different row (or none at all).
451            element.armed = None;
452            element.long_press.clear();
453            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
454        }
455
456        if element.selected != self.selected {
457            element.selected = self.selected.clone();
458            flags |= ChangeFlags::PAINT;
459        }
460
461        if element.enabled != self.enabled {
462            element.enabled = self.enabled;
463            if !self.enabled {
464                element.armed = None;
465                element.long_press.clear();
466            }
467            flags |= ChangeFlags::PAINT;
468        }
469
470        element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
471        element.on_activate = self
472            .on_activate
473            .as_ref()
474            .map(frust::authoring::erase_callback_arg);
475        flags
476    }
477
478    fn teardown(&self, element: &mut SelectionHostWidget, ctx: &mut BuildCtx<'_>) {
479        for (view, pod) in self.items.iter().zip(element.items.iter_mut()) {
480            frust::authoring::teardown_child(view, pod, ctx);
481        }
482    }
483}
484
485impl SelectionHostWidget {
486    /// The row containing local `pos`, if any — a position in a gap between
487    /// two rows (or outside the stack's width) belongs to neither.
488    fn row_at(&self, pos: Point) -> Option<usize> {
489        if pos.x < 0.0 || pos.x >= self.width {
490            return None;
491        }
492        self.rows
493            .iter()
494            .position(|(y, height)| pos.y >= *y && pos.y < y + height)
495    }
496
497    /// Clear whatever gesture state a press left behind (shared by the
498    /// `Up`/`Cancel` arms).
499    fn clear_press(&mut self) {
500        self.armed = None;
501        self.long_press.clear();
502    }
503
504    /// Fire a long-press: only when `index` is not already selected — ported
505    /// verbatim from the reference's `_onLongPress` guard (see the [module
506    /// docs](self)' Wrapper section) — so a long-press never deselects the
507    /// row it landed on.
508    fn fire_long_press(&mut self, ctx: &mut EventCtx, index: usize) {
509        if !self.selected.contains(&index) {
510            (self.on_toggle)(ctx, index);
511        }
512    }
513
514    /// Fire a tap: toggle while in selection mode, activate otherwise (see
515    /// the [module docs](self)' Wrapper section).
516    fn fire_tap(&mut self, ctx: &mut EventCtx, index: usize) {
517        if is_selection_mode(&self.selected) {
518            (self.on_toggle)(ctx, index);
519        } else if let Some(on_activate) = self.on_activate.as_mut() {
520            on_activate(ctx, index);
521        }
522    }
523}
524
525impl Widget for SelectionHostWidget {
526    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
527        let width = if bc.max().width.is_finite() {
528            bc.max().width
529        } else {
530            0.0
531        };
532        self.width = width;
533        let max_height = bc.max().height;
534        let child_bc = BoxConstraints::new(Size::new(width, 0.0), Size::new(width, max_height));
535
536        self.rows.clear();
537        let mut y = 0.0;
538        for pod in self.items.iter_mut() {
539            let size = pod.layout_child(ctx, &child_bc);
540            pod.set_origin(Point::new(0.0, y));
541            self.rows.push((y, size.height));
542            y += size.height;
543        }
544
545        bc.constrain(Size::new(width, y))
546    }
547
548    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
549        for pod in &mut self.items {
550            pod.paint_child(ctx, scene);
551        }
552
553        // Long-press threshold latch — `paint` is the only pass with a
554        // clock. See the [module docs](self)' Wrapper section.
555        if self.enabled && self.armed.is_some() && !self.long_press.elapsed {
556            let start = *self.long_press.press_start.get_or_insert(ctx.frame_time());
557            let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
558            if elapsed_ms >= LONG_PRESS_MS {
559                self.long_press.elapsed = true;
560            } else {
561                ctx.request_frame();
562            }
563        }
564    }
565
566    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
567        let InputEvent::Pointer(p) = event else {
568            return frust::authoring::route_event(&mut self.items, ctx, event);
569        };
570        if !self.enabled {
571            // A disabled router arms nothing and forwards no pointer event
572            // into its rows either — mirrors `card_list`'s disabled
573            // early-return.
574            return EventResult::Ignored;
575        }
576        match p.phase {
577            PointerPhase::Down => {
578                if !presses(p) {
579                    return EventResult::Ignored;
580                }
581                let Some(index) = self.row_at(p.position) else {
582                    // The gap between two rows is nobody's target.
583                    return EventResult::Ignored;
584                };
585                self.armed = Some(index);
586                self.down_pos = p.position;
587                self.long_press.clear();
588                ctx.capture_pointer();
589                ctx.request_redraw();
590                EventResult::Handled
591            }
592            PointerPhase::Move => {
593                let Some(armed) = self.armed else {
594                    // No host-level hover chrome to claim — see the [module
595                    // docs](self)' Wrapper section (a row's own visuals are
596                    // the caller's responsibility).
597                    return EventResult::Ignored;
598                };
599                // Long-press slop check + fire-on-move-arrival (mirrors
600                // `toggle_button.rs`'s identical pattern).
601                if (p.position - self.down_pos).hypot() > TOUCH_SLOP {
602                    self.long_press.clear();
603                } else if self.long_press.elapsed && !self.long_press.fired {
604                    self.long_press.fired = true;
605                    self.fire_long_press(ctx, armed);
606                    ctx.request_redraw();
607                }
608                EventResult::Handled
609            }
610            PointerPhase::Up => {
611                let Some(armed) = self.armed else {
612                    return EventResult::Ignored;
613                };
614                // A held-past-threshold press resolves as a long-press,
615                // never also as a tap — mutually exclusive outcomes of the
616                // same gesture (the [module docs](self)' Wrapper section).
617                if self.long_press.elapsed {
618                    if !self.long_press.fired {
619                        self.long_press.fired = true;
620                        self.fire_long_press(ctx, armed);
621                    }
622                } else if self.row_at(p.position) == Some(armed) {
623                    self.fire_tap(ctx, armed);
624                }
625                self.clear_press();
626                ctx.request_redraw();
627                EventResult::Handled
628            }
629            PointerPhase::Cancel => {
630                if self.armed.is_none() {
631                    return EventResult::Ignored;
632                }
633                self.clear_press();
634                ctx.request_redraw();
635                EventResult::Handled
636            }
637        }
638    }
639
640    fn semantics(&self, ctx: &mut SemanticsCtx) {
641        ctx.push_container(
642            Role::List,
643            |_node| {},
644            |ctx| {
645                for pod in &self.items {
646                    pod.semantics_child(ctx);
647                }
648            },
649        );
650    }
651
652    frust::authoring::visit_children!(items);
653}
654
655// ---- selection_app_bar: the contextual bar --------------------------------
656
657type OnClear<State> = Rc<dyn Fn(&mut State)>;
658type OnAllSelected<State> = Rc<dyn Fn(&mut State, bool)>;
659
660/// A declarative selection-aware app bar: `idle` content, swapping to a
661/// contextual toolbar while `selected` is non-empty. See the [module
662/// docs](self)' Contextual bar section.
663pub struct SelectionAppBarView<State: 'static> {
664    idle: AnyView<State>,
665    selected: BTreeSet<usize>,
666    item_count: usize,
667    actions: Vec<AnyView<State>>,
668    show_select_all: bool,
669    select_all_label: String,
670    on_clear: OnClear<State>,
671    on_all_selected: Option<OnAllSelected<State>>,
672    safe_area: bool,
673}
674
675/// Create a selection app bar showing `idle` while `selected` is empty, and
676/// the contextual toolbar (count + close + actions) otherwise. `on_clear`
677/// fires when the close affordance is pressed — the app is the source of
678/// truth for `selected`, so this is the *only* way this bar's own close
679/// button ever empties it (see the [module docs](self)' Controlled semantics
680/// section).
681pub fn selection_app_bar<State: 'static, F>(
682    idle: impl View<State>,
683    selected: &BTreeSet<usize>,
684    item_count: usize,
685    on_clear: F,
686) -> SelectionAppBarView<State>
687where
688    F: Fn(&mut State) + 'static,
689{
690    SelectionAppBarView {
691        idle: AnyView::new(idle),
692        selected: selected.clone(),
693        item_count,
694        actions: Vec::new(),
695        show_select_all: true,
696        select_all_label: "Select all".to_string(),
697        on_clear: Rc::new(on_clear),
698        on_all_selected: None,
699        safe_area: true,
700    }
701}
702
703impl<State: 'static> SelectionAppBarView<State> {
704    /// Attach contextual trailing action slots, in reading order
705    /// (`M3ESelectionAppBar.actions`).
706    pub fn actions<M>(mut self, actions: impl ViewSeq<State, M>) -> Self {
707        self.actions.clear();
708        actions.extend_views(&mut self.actions);
709        self
710    }
711
712    /// Whether to show the select-all row in the contextual toolbar.
713    /// Defaults to `true`.
714    pub fn show_select_all(mut self, show: bool) -> Self {
715        self.show_select_all = show;
716        self
717    }
718
719    /// Override the select-all row's label. Defaults to `"Select all"`.
720    pub fn select_all_label(mut self, label: impl Into<String>) -> Self {
721        self.select_all_label = label.into();
722        self
723    }
724
725    /// Fire when the select-all row is toggled — `true` means the app should
726    /// adopt [`select_all`]`(item_count)`, `false` means [`cleared`]`()`
727    /// (`M3ESelectionAppBar.onAllSelected`). Absent by default: toggling
728    /// select-all with no callback set is a no-op.
729    pub fn on_all_selected<F: Fn(&mut State, bool) + 'static>(
730        mut self,
731        on_all_selected: F,
732    ) -> Self {
733        self.on_all_selected = Some(Rc::new(on_all_selected));
734        self
735    }
736
737    /// Whether the contextual branch consumes the top, left and right window
738    /// insets itself (default `true`), exactly as an idle [`crate::app_bar`]
739    /// or [`crate::search_app_bar`] does by default — see the [module
740    /// docs](self)' Contextual bar section. The idle view is opaque here, so
741    /// this setting cannot be read off it: a caller that passes
742    /// `.safe_area(false)` to its idle bar passes it here too, keeping both
743    /// branches at the same height and offsets. It never affects the idle
744    /// branch, which lays out (and insets) on its own terms.
745    pub fn safe_area(mut self, enabled: bool) -> Self {
746        self.safe_area = enabled;
747        self
748    }
749}
750
751impl<State: 'static> SelectionAppBarView<State> {
752    /// The presence-of-slots shape a rebuild's structural check keys off —
753    /// mirrors `list_item.rs`'s `ListItem::shape` pattern.
754    fn shape(&self) -> (usize, bool) {
755        (self.actions.len(), self.show_select_all)
756    }
757}
758
759/// Build the close affordance's view (`icon_button` over
760/// [`crate::icons::CLOSE`], firing `on_clear` directly) — mirrors
761/// `dialog.rs`'s `close_view` helper of the same shape.
762fn close_view<State: 'static>(on_clear: OnClear<State>) -> impl View<State> {
763    icon_button(icon(crate::icons::CLOSE), move |state: &mut State| {
764        (on_clear)(state);
765    })
766    .semantic_label(CLEAR_SELECTION_LABEL)
767}
768
769/// Build the selected-count label's view (`titleLargeEmphasized`,
770/// `on_primary_container`, family from the theme's `titleLargeEmphasized`
771/// role).
772fn count_view<State: 'static>(count: usize) -> AnyView<State> {
773    any::<State, _>(
774        text(count.to_string())
775            .size(COUNT_SIZE)
776            .weight(COUNT_WEIGHT)
777            .line_height(LineHeight::Absolute(COUNT_LINE_HEIGHT))
778            .themed_role(ThemeTextColor::OnPrimaryContainer)
779            .themed_family(ThemeTextType::TitleLargeEmphasized),
780    )
781}
782
783/// Build the select-all checkbox's view. Ports `_toggleSelectAll`'s binary
784/// flip exactly: the checkbox's own tap-cycle report is ignored (`_next`),
785/// and `on_all_selected` instead fires with `!all`, where `all` treats a
786/// partial selection the same as none (matching upstream's `all ?? false`).
787fn select_all_checkbox_view<State: 'static>(
788    selected: BTreeSet<usize>,
789    item_count: usize,
790    on_all_selected: Option<OnAllSelected<State>>,
791) -> impl View<State> {
792    let value = all_selected_for(&selected, item_count);
793    crate::checkbox::tristate_checkbox::<State, _>(
794        value,
795        move |state: &mut State, _next: Option<bool>| {
796            if let Some(cb) = &on_all_selected {
797                cb(state, !matches!(value, Some(true)));
798            }
799        },
800    )
801}
802
803/// Build the select-all row's label view (`bodyLarge` — `text`'s own
804/// default size, and the theme's `bodyLarge` family — `on_primary_container`).
805fn select_all_label_view<State: 'static>(label: String) -> AnyView<State> {
806    any::<State, _>(
807        text(label)
808            .themed_role(ThemeTextColor::OnPrimaryContainer)
809            .themed_family(ThemeTextType::BodyLarge),
810    )
811}
812
813/// The resolved contextual bar container fill. Themed: `colors.
814/// primary_container`. Unthemed: [`CONTEXTUAL_BACKGROUND`] exactly.
815fn resolve_contextual_background(theme: Option<&Theme>) -> Color {
816    match theme {
817        Some(theme) => theme.scheme().primary_container,
818        None => CONTEXTUAL_BACKGROUND,
819    }
820}
821
822/// Index map into [`SelectionAppBarWidget::contextual`] — mirrors
823/// `list_item.rs`'s `Slots` pattern.
824#[derive(Clone, Copy)]
825struct ContextualSlots {
826    close: usize,
827    count_text: usize,
828    actions_start: usize,
829    actions_len: usize,
830    /// `(checkbox index, label index)`, present only while
831    /// [`SelectionAppBarView::show_select_all`] is `true`.
832    select_all: Option<(usize, usize)>,
833}
834
835/// Build the ordered child window `[close, count_text, action.., select_all_
836/// checkbox?, select_all_label?]` and the [`ContextualSlots`] index map.
837fn build_contextual_children<State: 'static>(
838    view: &SelectionAppBarView<State>,
839    ctx: &mut BuildCtx<'_>,
840) -> (Vec<ChildPod>, ContextualSlots) {
841    let mut children = Vec::new();
842
843    let close_v = close_view::<State>(view.on_clear.clone());
844    children.push(frust::authoring::build_child(&close_v, ctx));
845    let close = 0;
846
847    let count_v = count_view::<State>(view.selected.len());
848    children.push(frust::authoring::build_child(&count_v, ctx));
849    let count_text = 1;
850
851    let actions_start = children.len();
852    for action in &view.actions {
853        children.push(frust::authoring::build_child(action, ctx));
854    }
855    let actions_len = view.actions.len();
856
857    let select_all = view.show_select_all.then(|| {
858        let cb = select_all_checkbox_view::<State>(
859            view.selected.clone(),
860            view.item_count,
861            view.on_all_selected.clone(),
862        );
863        let lbl = select_all_label_view::<State>(view.select_all_label.clone());
864        let cb_idx = children.len();
865        children.push(frust::authoring::build_child(&cb, ctx));
866        let lbl_idx = children.len();
867        children.push(frust::authoring::build_child(&lbl, ctx));
868        (cb_idx, lbl_idx)
869    });
870
871    (
872        children,
873        ContextualSlots {
874            close,
875            count_text,
876            actions_start,
877            actions_len,
878            select_all,
879        },
880    )
881}
882
883/// Tear down every contextual child pod through the view it was built from,
884/// per the [`ContextualSlots`] index map — mirrors `list_item.rs`'s
885/// `teardown_children` helper.
886fn teardown_contextual_children<State: 'static>(
887    view: &SelectionAppBarView<State>,
888    slots: ContextualSlots,
889    children: &mut [ChildPod],
890    ctx: &mut BuildCtx<'_>,
891) {
892    let close_v = close_view::<State>(view.on_clear.clone());
893    frust::authoring::teardown_child(&close_v, &mut children[slots.close], ctx);
894
895    let count_v = count_view::<State>(view.selected.len());
896    frust::authoring::teardown_child(&count_v, &mut children[slots.count_text], ctx);
897
898    for i in 0..slots.actions_len {
899        frust::authoring::teardown_child(
900            &view.actions[i],
901            &mut children[slots.actions_start + i],
902            ctx,
903        );
904    }
905
906    if let Some((cb_idx, lbl_idx)) = slots.select_all {
907        let cb = select_all_checkbox_view::<State>(
908            view.selected.clone(),
909            view.item_count,
910            view.on_all_selected.clone(),
911        );
912        frust::authoring::teardown_child(&cb, &mut children[cb_idx], ctx);
913        let lbl = select_all_label_view::<State>(view.select_all_label.clone());
914        frust::authoring::teardown_child(&lbl, &mut children[lbl_idx], ctx);
915    }
916}
917
918/// The retained widget for a [`SelectionAppBarView`]. See the [module
919/// docs](self).
920pub struct SelectionAppBarWidget {
921    idle: ChildPod,
922    /// `[close, count_text, action.., select_all_checkbox?,
923    /// select_all_label?]` — see [`ContextualSlots`].
924    contextual: Vec<ChildPod>,
925    contextual_slots: ContextualSlots,
926    selected: BTreeSet<usize>,
927    item_count: usize,
928    show_select_all: bool,
929    select_all_label: String,
930    /// Whether the contextual branch consumes the top/left/right window
931    /// insets — see [`SelectionAppBarView::safe_area`].
932    safe_area: bool,
933}
934
935impl<State: 'static> View<State> for SelectionAppBarView<State> {
936    type Element = SelectionAppBarWidget;
937
938    fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionAppBarWidget {
939        let (contextual, contextual_slots) = build_contextual_children(self, ctx);
940        SelectionAppBarWidget {
941            idle: frust::authoring::build_child(&self.idle, ctx),
942            contextual,
943            contextual_slots,
944            selected: self.selected.clone(),
945            item_count: self.item_count,
946            show_select_all: self.show_select_all,
947            select_all_label: self.select_all_label.clone(),
948            safe_area: self.safe_area,
949        }
950    }
951
952    fn rebuild(
953        &self,
954        prev: &Self,
955        element: &mut SelectionAppBarWidget,
956        ctx: &mut BuildCtx<'_>,
957    ) -> ChangeFlags {
958        let mut flags =
959            frust::authoring::rebuild_child(&prev.idle, &self.idle, &mut element.idle, ctx);
960
961        if prev.shape() != self.shape() {
962            teardown_contextual_children(
963                prev,
964                element.contextual_slots,
965                &mut element.contextual,
966                ctx,
967            );
968            let (children, slots) = build_contextual_children(self, ctx);
969            element.contextual = children;
970            element.contextual_slots = slots;
971            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
972        } else {
973            let slots = element.contextual_slots;
974
975            let prev_close = close_view::<State>(prev.on_clear.clone());
976            let next_close = close_view::<State>(self.on_clear.clone());
977            flags |= frust::authoring::rebuild_child(
978                &prev_close,
979                &next_close,
980                &mut element.contextual[slots.close],
981                ctx,
982            );
983
984            let prev_count = count_view::<State>(prev.selected.len());
985            let next_count = count_view::<State>(self.selected.len());
986            flags |= frust::authoring::rebuild_child(
987                &prev_count,
988                &next_count,
989                &mut element.contextual[slots.count_text],
990                ctx,
991            );
992
993            for i in 0..slots.actions_len {
994                flags |= frust::authoring::rebuild_child(
995                    &prev.actions[i],
996                    &self.actions[i],
997                    &mut element.contextual[slots.actions_start + i],
998                    ctx,
999                );
1000            }
1001
1002            if let Some((cb_idx, lbl_idx)) = slots.select_all {
1003                let prev_cb = select_all_checkbox_view::<State>(
1004                    prev.selected.clone(),
1005                    prev.item_count,
1006                    prev.on_all_selected.clone(),
1007                );
1008                let next_cb = select_all_checkbox_view::<State>(
1009                    self.selected.clone(),
1010                    self.item_count,
1011                    self.on_all_selected.clone(),
1012                );
1013                flags |= frust::authoring::rebuild_child(
1014                    &prev_cb,
1015                    &next_cb,
1016                    &mut element.contextual[cb_idx],
1017                    ctx,
1018                );
1019
1020                let prev_lbl = select_all_label_view::<State>(prev.select_all_label.clone());
1021                let next_lbl = select_all_label_view::<State>(self.select_all_label.clone());
1022                flags |= frust::authoring::rebuild_child(
1023                    &prev_lbl,
1024                    &next_lbl,
1025                    &mut element.contextual[lbl_idx],
1026                    ctx,
1027                );
1028            }
1029        }
1030
1031        if element.selected != self.selected {
1032            element.selected = self.selected.clone();
1033            // The idle/contextual branch itself may have flipped.
1034            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1035        }
1036        if element.safe_area != self.safe_area {
1037            element.safe_area = self.safe_area;
1038            flags |= ChangeFlags::LAYOUT;
1039        }
1040        element.item_count = self.item_count;
1041        element.show_select_all = self.show_select_all;
1042        element.select_all_label = self.select_all_label.clone();
1043        flags
1044    }
1045
1046    fn teardown(&self, element: &mut SelectionAppBarWidget, ctx: &mut BuildCtx<'_>) {
1047        frust::authoring::teardown_child(&self.idle, &mut element.idle, ctx);
1048        teardown_contextual_children(self, element.contextual_slots, &mut element.contextual, ctx);
1049    }
1050}
1051
1052impl Widget for SelectionAppBarWidget {
1053    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1054        let width = if bc.max().width.is_finite() {
1055            bc.max().width
1056        } else {
1057            0.0
1058        };
1059
1060        if !is_selection_mode(&self.selected) {
1061            let idle_size = self
1062                .idle
1063                .layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)));
1064            self.idle.set_origin(Point::ZERO);
1065            return bc.constrain(Size::new(width, idle_size.height));
1066        }
1067
1068        // Consume the same top/left/right window insets an idle top app bar
1069        // does, so the swap keeps one height and one set of slot offsets in
1070        // every composition (a consuming parent safe area leaves 0 here, for
1071        // both branches alike).
1072        let (pad_l, top, pad_r) = if self.safe_area {
1073            let padding = ctx.window_insets().padding();
1074            (
1075                padding.left.max(0.0),
1076                padding.top.max(0.0),
1077                padding.right.max(0.0),
1078            )
1079        } else {
1080            (0.0, 0.0, 0.0)
1081        };
1082        let leading_edge = pad_l + APP_BAR_PAD_X;
1083        let trailing_edge = width - pad_r - APP_BAR_PAD_X;
1084
1085        let slots = self.contextual_slots;
1086        let slot_bc = BoxConstraints::loose(Size::new(f64::INFINITY, APP_BAR_HEIGHT));
1087
1088        let close_size = self.contextual[slots.close].layout_child(ctx, &slot_bc);
1089        self.contextual[slots.close].set_origin(Point::new(
1090            leading_edge + (ACTION_SLOT - close_size.width).max(0.0) / 2.0,
1091            top + (APP_BAR_HEIGHT - close_size.height) / 2.0,
1092        ));
1093
1094        // Trailing actions, right-to-left from the trailing edge — mirrors
1095        // `appbar.rs`'s own reverse-iteration layout.
1096        let mut right = trailing_edge;
1097        for i in (0..slots.actions_len).rev() {
1098            let idx = slots.actions_start + i;
1099            let size = self.contextual[idx].layout_child(ctx, &slot_bc);
1100            right -= size.width;
1101            self.contextual[idx].set_origin(Point::new(
1102                right,
1103                top + (APP_BAR_HEIGHT - size.height) / 2.0,
1104            ));
1105            right -= APP_BAR_GAP;
1106        }
1107
1108        let count_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
1109        let count_max_width = (right - count_left).max(0.0);
1110        let count_size = self.contextual[slots.count_text].layout_child(
1111            ctx,
1112            &BoxConstraints::loose(Size::new(count_max_width, APP_BAR_HEIGHT)),
1113        );
1114        self.contextual[slots.count_text].set_origin(Point::new(
1115            count_left,
1116            top + (APP_BAR_HEIGHT - count_size.height) / 2.0,
1117        ));
1118
1119        let mut height = top + APP_BAR_HEIGHT;
1120        if let Some((cb_idx, lbl_idx)) = slots.select_all {
1121            let cb_bc = BoxConstraints::loose(Size::new(f64::INFINITY, SELECT_ALL_HEIGHT));
1122            let cb_size = self.contextual[cb_idx].layout_child(ctx, &cb_bc);
1123            self.contextual[cb_idx].set_origin(Point::new(
1124                leading_edge + (ACTION_SLOT - cb_size.width).max(0.0) / 2.0,
1125                height + (SELECT_ALL_HEIGHT - cb_size.height) / 2.0,
1126            ));
1127
1128            let lbl_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
1129            let lbl_max_width = (trailing_edge - lbl_left).max(0.0);
1130            let lbl_size = self.contextual[lbl_idx].layout_child(
1131                ctx,
1132                &BoxConstraints::loose(Size::new(lbl_max_width, SELECT_ALL_HEIGHT)),
1133            );
1134            self.contextual[lbl_idx].set_origin(Point::new(
1135                lbl_left,
1136                height + (SELECT_ALL_HEIGHT - lbl_size.height) / 2.0,
1137            ));
1138            height += SELECT_ALL_HEIGHT;
1139        }
1140
1141        bc.constrain(Size::new(width, height))
1142    }
1143
1144    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1145        if !is_selection_mode(&self.selected) {
1146            self.idle.paint_child(ctx, scene);
1147            return;
1148        }
1149        // Every theme read happens before `ctx` is taken mutably below
1150        // (`paint_child`) — mirrors `card::CardWidget::paint`'s ordering.
1151        let theme = Theme::from_paint_ctx(ctx);
1152        let fill = resolve_contextual_background(theme);
1153        scene.fill_rect(ctx.origin(), ctx.size(), fill);
1154        for pod in &mut self.contextual {
1155            pod.paint_child(ctx, scene);
1156        }
1157    }
1158
1159    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1160        if is_selection_mode(&self.selected) {
1161            frust::authoring::route_event(&mut self.contextual, ctx, event)
1162        } else {
1163            frust::authoring::route_event_single(&mut self.idle, ctx, event)
1164        }
1165    }
1166
1167    fn semantics(&self, ctx: &mut SemanticsCtx) {
1168        if !is_selection_mode(&self.selected) {
1169            self.idle.semantics_child(ctx);
1170            return;
1171        }
1172        let count = self.selected.len();
1173        let label = format!("{count} selected");
1174        let contextual = &self.contextual;
1175        ctx.push_container(
1176            Role::TitleBar,
1177            |node| {
1178                node.set_label(label.as_str());
1179                node.add_action(Action::Click);
1180            },
1181            |ctx| {
1182                for pod in contextual {
1183                    pod.semantics_child(ctx);
1184                }
1185            },
1186        );
1187    }
1188
1189    frust::authoring::visit_children!(contextual, idle);
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use super::*;
1195    use frust::authoring::PointerButton;
1196    use frust::authoring::text::TextContext;
1197    use kurbo::Vec2;
1198    use std::any::Any;
1199
1200    // ---- Controller: pure functions ---------------------------------------
1201
1202    #[test]
1203    fn is_selection_mode_matches_reference_getter() {
1204        assert!(!is_selection_mode(&BTreeSet::new()));
1205        assert!(is_selection_mode(&BTreeSet::from([2])));
1206    }
1207
1208    #[test]
1209    fn is_selected_reads_membership() {
1210        let selected = BTreeSet::from([1, 3]);
1211        assert!(is_selected(&selected, 1));
1212        assert!(!is_selected(&selected, 2));
1213    }
1214
1215    #[test]
1216    fn all_selected_for_matches_reference_tristate_formula() {
1217        assert_eq!(all_selected_for(&BTreeSet::new(), 0), Some(false));
1218        assert_eq!(all_selected_for(&BTreeSet::new(), 3), Some(false));
1219        assert_eq!(all_selected_for(&BTreeSet::from([0, 1, 2]), 3), Some(true));
1220        assert_eq!(
1221            all_selected_for(&BTreeSet::from([0, 1, 2, 5]), 3),
1222            Some(true)
1223        );
1224        assert_eq!(all_selected_for(&BTreeSet::from([0]), 3), None);
1225    }
1226
1227    #[test]
1228    fn select_deselect_toggle_and_select_all_are_pure_next_state_transforms() {
1229        let empty = BTreeSet::new();
1230        assert_eq!(select(&empty, 2), BTreeSet::from([2]));
1231        assert_eq!(empty, BTreeSet::new(), "select must not mutate its input");
1232
1233        let some = BTreeSet::from([1, 2]);
1234        assert_eq!(deselect(&some, 1), BTreeSet::from([2]));
1235        assert_eq!(
1236            deselect(&some, 9),
1237            some,
1238            "deselecting an absent index is a no-op value"
1239        );
1240
1241        assert_eq!(toggle(&some, 1), BTreeSet::from([2]), "toggle off");
1242        assert_eq!(toggle(&some, 5), BTreeSet::from([1, 2, 5]), "toggle on");
1243
1244        assert_eq!(select_all(3), BTreeSet::from([0, 1, 2]));
1245        assert_eq!(select_all(0), BTreeSet::new());
1246        assert_eq!(cleared(), BTreeSet::new());
1247    }
1248
1249    // ---- selection_host -----------------------------------------------------
1250
1251    const ROW_HEIGHT: f64 = 40.0;
1252    const WIDTH: f64 = 200.0;
1253
1254    fn build<S: 'static>(view: &SelectionHostView<S>) -> SelectionHostWidget {
1255        let mut counter = 0u64;
1256        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1257    }
1258
1259    fn layout(w: &mut SelectionHostWidget, height: f64) -> Size {
1260        let mut tcx = TextContext::new();
1261        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1262        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(WIDTH, height)))
1263    }
1264
1265    fn rows<S: 'static>(n: usize) -> Vec<AnyView<S>> {
1266        (0..n)
1267            .map(|_| any::<S, _>(frust::SizedBox(Some(10.0), Some(ROW_HEIGHT))))
1268            .collect()
1269    }
1270
1271    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1272        InputEvent::Pointer(frust::authoring::PointerEvent {
1273            phase,
1274            position: Point::new(x, y),
1275            button: PointerButton::Primary,
1276        })
1277    }
1278
1279    #[derive(Default)]
1280    struct Log {
1281        toggled: Vec<usize>,
1282        activated: Vec<usize>,
1283    }
1284
1285    fn dispatch(w: &mut SelectionHostWidget, state: &mut Log, event: &InputEvent) -> EventResult {
1286        let state_any: &mut dyn Any = state;
1287        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1288        w.event(&mut ctx, event)
1289    }
1290
1291    #[test]
1292    fn layout_stacks_rows_vertically_with_no_gap() {
1293        let selected = BTreeSet::new();
1294        let view: SelectionHostView<Log> =
1295            selection_host(rows(3), &selected, |s: &mut Log, i| s.toggled.push(i));
1296        let mut w = build(&view);
1297        let size = layout(&mut w, 300.0);
1298        assert_eq!(size, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1299        assert_eq!(
1300            w.rows,
1301            vec![
1302                (0.0, ROW_HEIGHT),
1303                (ROW_HEIGHT, ROW_HEIGHT),
1304                (ROW_HEIGHT * 2.0, ROW_HEIGHT)
1305            ]
1306        );
1307    }
1308
1309    #[test]
1310    fn tap_outside_selection_mode_activates_not_toggles() {
1311        let selected = BTreeSet::new();
1312        let view: SelectionHostView<Log> =
1313            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1314                .on_activate(|s: &mut Log, i| s.activated.push(i));
1315        let mut w = build(&view);
1316        layout(&mut w, 300.0);
1317        let mut state = Log::default();
1318        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1319        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1320        assert_eq!(state.activated, vec![0]);
1321        assert!(state.toggled.is_empty());
1322    }
1323
1324    #[test]
1325    fn tap_in_selection_mode_toggles_not_activates() {
1326        let selected = BTreeSet::from([1]);
1327        let view: SelectionHostView<Log> =
1328            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1329                .on_activate(|s: &mut Log, i| s.activated.push(i));
1330        let mut w = build(&view);
1331        layout(&mut w, 300.0);
1332        let mut state = Log::default();
1333        // Tap the first (unselected) row: toggle(0), never activate.
1334        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1335        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1336        assert_eq!(state.toggled, vec![0]);
1337        assert!(state.activated.is_empty());
1338    }
1339
1340    #[test]
1341    fn tap_up_outside_the_armed_row_fires_nothing() {
1342        let selected = BTreeSet::new();
1343        let view: SelectionHostView<Log> =
1344            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1345                .on_activate(|s: &mut Log, i| s.activated.push(i));
1346        let mut w = build(&view);
1347        layout(&mut w, 300.0);
1348        let mut state = Log::default();
1349        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1350        dispatch(
1351            &mut w,
1352            &mut state,
1353            &ev(PointerPhase::Up, 5.0, ROW_HEIGHT + 5.0),
1354        );
1355        assert!(state.activated.is_empty());
1356        assert!(state.toggled.is_empty());
1357    }
1358
1359    #[test]
1360    fn long_press_selects_an_unselected_row_and_suppresses_the_tap() {
1361        let selected = BTreeSet::new();
1362        let view: SelectionHostView<Log> =
1363            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1364                .on_activate(|s: &mut Log, i| s.activated.push(i));
1365        let mut w = build(&view);
1366        layout(&mut w, 300.0);
1367        let mut state = Log::default();
1368
1369        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1370        // Simulate the threshold already crossed (mirrors
1371        // `toggle_button.rs`'s identical test shape).
1372        w.long_press.elapsed = true;
1373        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1374        assert_eq!(state.toggled, vec![0], "long-press fired on move-arrival");
1375        assert!(state.activated.is_empty());
1376
1377        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 6.0, 5.0));
1378        assert_eq!(
1379            state.toggled,
1380            vec![0],
1381            "the resolved long-press is not also a tap"
1382        );
1383    }
1384
1385    #[test]
1386    fn long_press_never_deselects_an_already_selected_row() {
1387        let selected = BTreeSet::from([0]);
1388        let view: SelectionHostView<Log> =
1389            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1390        let mut w = build(&view);
1391        layout(&mut w, 300.0);
1392        let mut state = Log::default();
1393
1394        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1395        w.long_press.elapsed = true;
1396        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1397        assert!(
1398            state.toggled.is_empty(),
1399            "a long-press on an already-selected row must not fire on_toggle"
1400        );
1401    }
1402
1403    #[test]
1404    fn long_press_still_fires_at_up_if_no_move_arrives_first() {
1405        let selected = BTreeSet::new();
1406        let view: SelectionHostView<Log> =
1407            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1408        let mut w = build(&view);
1409        layout(&mut w, 300.0);
1410        let mut state = Log::default();
1411
1412        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1413        w.long_press.elapsed = true;
1414        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1415        assert_eq!(state.toggled, vec![0]);
1416    }
1417
1418    #[test]
1419    fn movement_past_touch_slop_cancels_long_press_candidacy() {
1420        let selected = BTreeSet::new();
1421        let view: SelectionHostView<Log> =
1422            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1423        let mut w = build(&view);
1424        layout(&mut w, 300.0);
1425        let mut state = Log::default();
1426
1427        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1428        w.long_press.elapsed = true; // simulate the threshold already crossed
1429        dispatch(
1430            &mut w,
1431            &mut state,
1432            &ev(PointerPhase::Move, 5.0 + TOUCH_SLOP + 5.0, 5.0),
1433        );
1434        assert!(!w.long_press.elapsed, "a drag cancels the candidacy");
1435        assert!(state.toggled.is_empty());
1436    }
1437
1438    #[test]
1439    fn cancel_clears_state_without_firing() {
1440        let selected = BTreeSet::new();
1441        let view: SelectionHostView<Log> =
1442            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1443        let mut w = build(&view);
1444        layout(&mut w, 300.0);
1445        let mut state = Log::default();
1446
1447        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1448        assert!(w.armed.is_some());
1449        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1450        assert!(w.armed.is_none());
1451        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1452        assert!(state.toggled.is_empty());
1453    }
1454
1455    #[test]
1456    fn disabled_router_arms_nothing() {
1457        let selected = BTreeSet::new();
1458        let view: SelectionHostView<Log> =
1459            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i)).enabled(false);
1460        let mut w = build(&view);
1461        layout(&mut w, 300.0);
1462        let mut state = Log::default();
1463        assert_eq!(
1464            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
1465            EventResult::Ignored
1466        );
1467        assert!(w.armed.is_none());
1468    }
1469
1470    #[test]
1471    fn a_secondary_press_never_arms_a_row() {
1472        let selected = BTreeSet::new();
1473        let view: SelectionHostView<Log> =
1474            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1475        let mut w = build(&view);
1476        layout(&mut w, 300.0);
1477        let mut state = Log::default();
1478        let secondary = InputEvent::Pointer(frust::authoring::PointerEvent {
1479            phase: PointerPhase::Down,
1480            position: Point::new(5.0, 5.0),
1481            button: PointerButton::Secondary,
1482        });
1483        assert_eq!(
1484            dispatch(&mut w, &mut state, &secondary),
1485            EventResult::Ignored
1486        );
1487        assert!(w.armed.is_none());
1488    }
1489
1490    #[test]
1491    fn external_clear_collapses_mode_on_the_next_dispatch() {
1492        // Controlled semantics: rebuilding with an emptied `selected` flips
1493        // tap resolution from toggle back to activate with no separate
1494        // "exit" call (see the module docs' Controlled semantics section).
1495        let mut counter = 0u64;
1496        let prev_selected = BTreeSet::from([0]);
1497        let prev: SelectionHostView<Log> =
1498            selection_host(rows(2), &prev_selected, |s: &mut Log, i| s.toggled.push(i))
1499                .on_activate(|s: &mut Log, i| s.activated.push(i));
1500        let mut w = View::<Log>::build(&prev, &mut BuildCtx::new(&mut counter));
1501        layout(&mut w, 300.0);
1502
1503        let next_selected = BTreeSet::new();
1504        let next: SelectionHostView<Log> =
1505            selection_host(rows(2), &next_selected, |s: &mut Log, i| s.toggled.push(i))
1506                .on_activate(|s: &mut Log, i| s.activated.push(i));
1507        View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1508        assert!(!is_selection_mode(&w.selected));
1509
1510        let mut state = Log::default();
1511        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1512        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1513        assert_eq!(
1514            state.activated,
1515            vec![0],
1516            "collapsed mode resumes tap-to-activate"
1517        );
1518        assert!(state.toggled.is_empty());
1519    }
1520
1521    // ---- selection_app_bar --------------------------------------------------
1522
1523    fn idle_view<S: 'static>() -> AnyView<S> {
1524        any::<S, _>(frust::SizedBox(Some(0.0), Some(30.0)))
1525    }
1526
1527    fn bar_build<S: 'static>(view: &SelectionAppBarView<S>) -> SelectionAppBarWidget {
1528        let mut counter = 0u64;
1529        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1530    }
1531
1532    fn bar_layout(w: &mut SelectionAppBarWidget, width: f64) -> Size {
1533        let mut tcx = TextContext::new();
1534        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1535        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1536    }
1537
1538    #[test]
1539    fn idle_mode_uses_the_idle_contents_own_height() {
1540        let selected = BTreeSet::new();
1541        let view: SelectionAppBarView<()> =
1542            selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1543        let mut w = bar_build(&view);
1544        let size = bar_layout(&mut w, 300.0);
1545        assert_eq!(size, Size::new(300.0, 30.0));
1546    }
1547
1548    #[test]
1549    fn contextual_mode_height_matches_app_bar_height_metric() {
1550        let selected = BTreeSet::from([0]);
1551        let view: SelectionAppBarView<()> =
1552            selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {}).show_select_all(false);
1553        let mut w = bar_build(&view);
1554        let size = bar_layout(&mut w, 300.0);
1555        assert_eq!(
1556            size.height, APP_BAR_HEIGHT,
1557            "the toolbar band matches appbar.rs's own HEIGHT metric"
1558        );
1559    }
1560
1561    // ---- Window insets: the contextual branch insets like the idle bar ----
1562
1563    /// A phone in landscape under a status bar: 47px side bands, 24px top.
1564    fn notched() -> frust::authoring::WindowInsets {
1565        frust::authoring::WindowInsets::new(
1566            frust::authoring::WindowEdgeInsets::new(47.0, 24.0, 47.0, 0.0),
1567            frust::authoring::WindowEdgeInsets::ZERO,
1568        )
1569    }
1570
1571    fn bar_layout_with(
1572        w: &mut SelectionAppBarWidget,
1573        width: f64,
1574        insets: frust::authoring::WindowInsets,
1575    ) -> Size {
1576        let mut tcx = TextContext::new();
1577        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1578        lctx.with_window_insets(insets, |ctx| {
1579            w.layout(ctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1580        })
1581    }
1582
1583    /// A selection bar over a real idle top app bar, with one 48px action on
1584    /// each branch and no select-all row, so both bands are comparable.
1585    fn real_bar(selected: &BTreeSet<usize>, safe_area: bool) -> SelectionAppBarView<()> {
1586        use frust_widgets::test_support::leaf_any;
1587        let idle = crate::app_bar::<()>("Inbox")
1588            .leading(leaf_any(48.0, 48.0))
1589            .actions(vec![leaf_any(48.0, 48.0)])
1590            .safe_area(safe_area);
1591        selection_app_bar(idle, selected, 3, |_: &mut ()| {})
1592            .show_select_all(false)
1593            .actions(vec![leaf_any(48.0, 48.0)])
1594            .safe_area(safe_area)
1595    }
1596
1597    #[test]
1598    fn idle_to_selection_keeps_height_and_slot_offsets_under_window_insets() {
1599        let idle_set = BTreeSet::new();
1600        let selected = BTreeSet::from([0]);
1601
1602        let mut idle = bar_build(&real_bar(&idle_set, true));
1603        let idle_size = bar_layout_with(&mut idle, 800.0, notched());
1604        let mut ctx_w = bar_build(&real_bar(&selected, true));
1605        let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
1606
1607        assert_eq!(idle_size, Size::new(800.0, 24.0 + APP_BAR_HEIGHT));
1608        assert_eq!(ctx_size, idle_size, "the swap keeps the bar's height");
1609
1610        // The contextual slots sit below the top inset and inside the side
1611        // ones, at the idle bar's own leading/trailing rule.
1612        let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
1613        assert_eq!(
1614            close.origin().x,
1615            47.0 + APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
1616        );
1617        assert_eq!(
1618            close.origin().y,
1619            24.0 + (APP_BAR_HEIGHT - close.size().height) / 2.0
1620        );
1621        let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1622        assert_eq!(
1623            action.origin().x + action.size().width,
1624            800.0 - 47.0 - APP_BAR_PAD_X
1625        );
1626        assert_eq!(action.origin().y, 24.0 + (APP_BAR_HEIGHT - 48.0) / 2.0);
1627        let count = &ctx_w.contextual[ctx_w.contextual_slots.count_text];
1628        assert_eq!(
1629            count.origin().x,
1630            47.0 + APP_BAR_PAD_X + ACTION_SLOT + APP_BAR_GAP
1631        );
1632
1633        // The fill covers the whole band, the consumed top inset included.
1634        let mut scene = RectRecorder::default();
1635        let mut pctx = PaintCtx::new(Point::ZERO, ctx_size);
1636        ctx_w.paint(&mut pctx, &mut scene);
1637        assert_eq!(scene.rects[0], (Point::ZERO, ctx_size));
1638    }
1639
1640    #[test]
1641    fn idle_to_selection_stays_continuous_under_a_consuming_safe_area() {
1642        let idle_set = BTreeSet::new();
1643        let selected = BTreeSet::from([0]);
1644        let consumed = notched().consuming(true, true, true, false);
1645
1646        // Inside a consuming scope both branches see zero padding.
1647        let mut idle = bar_build(&real_bar(&idle_set, true));
1648        let idle_size = bar_layout_with(&mut idle, 706.0, consumed);
1649        let mut ctx_w = bar_build(&real_bar(&selected, true));
1650        let ctx_size = bar_layout_with(&mut ctx_w, 706.0, consumed);
1651        assert_eq!(idle_size.height, APP_BAR_HEIGHT);
1652        assert_eq!(ctx_size, idle_size);
1653        let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
1654        assert_eq!(
1655            close.origin().x,
1656            APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
1657        );
1658        let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1659        assert_eq!(
1660            action.origin().x + action.size().width,
1661            706.0 - APP_BAR_PAD_X
1662        );
1663
1664        // Through a real `frust::safe_area`: one 24px pad, never two.
1665        let heights: Vec<f64> = [&idle_set, &selected]
1666            .into_iter()
1667            .map(|set| {
1668                let wrapped = frust::safe_area(real_bar(set, true)).bottom(false);
1669                let mut counter = 0u64;
1670                let mut w = View::<()>::build(&wrapped, &mut BuildCtx::new(&mut counter));
1671                let mut tcx = TextContext::new();
1672                let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1673                lctx.with_window_insets(notched(), |ctx| {
1674                    w.layout(ctx, &BoxConstraints::loose(Size::new(800.0, 400.0)))
1675                })
1676                .height
1677            })
1678            .collect();
1679        assert_eq!(heights, vec![24.0 + APP_BAR_HEIGHT; 2]);
1680    }
1681
1682    #[test]
1683    fn safe_area_false_opts_the_contextual_branch_out_like_the_idle_bar() {
1684        let idle_set = BTreeSet::new();
1685        let selected = BTreeSet::from([0]);
1686        let mut idle = bar_build(&real_bar(&idle_set, false));
1687        let idle_size = bar_layout_with(&mut idle, 800.0, notched());
1688        let mut ctx_w = bar_build(&real_bar(&selected, false));
1689        let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
1690        assert_eq!(idle_size.height, APP_BAR_HEIGHT);
1691        assert_eq!(ctx_size, idle_size);
1692        let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1693        assert_eq!(
1694            action.origin().x + action.size().width,
1695            800.0 - APP_BAR_PAD_X
1696        );
1697
1698        // Flipping the setting is a layout change.
1699        let mut counter = 0u64;
1700        let prev = real_bar(&selected, false);
1701        let next = real_bar(&selected, true);
1702        let flags = View::<()>::rebuild(&next, &prev, &mut ctx_w, &mut BuildCtx::new(&mut counter));
1703        assert!(flags.needs_layout());
1704        assert!(ctx_w.safe_area);
1705    }
1706
1707    #[test]
1708    fn contextual_mode_with_select_all_adds_the_select_all_row_height() {
1709        let selected = BTreeSet::from([0]);
1710        let view: SelectionAppBarView<()> =
1711            selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1712        let mut w = bar_build(&view);
1713        let size = bar_layout(&mut w, 300.0);
1714        assert_eq!(size.height, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT);
1715    }
1716
1717    #[test]
1718    fn unthemed_paint_fills_the_contextual_background_only_in_selection_mode() {
1719        let idle_selected = BTreeSet::new();
1720        let idle: SelectionAppBarView<()> =
1721            selection_app_bar(idle_view(), &idle_selected, 3, |_: &mut ()| {});
1722        let mut idle_w = bar_build(&idle);
1723        bar_layout(&mut idle_w, 300.0);
1724        let mut idle_scene = RectRecorder::default();
1725        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 30.0));
1726        idle_w.paint(&mut ctx, &mut idle_scene);
1727        assert!(
1728            idle_scene.rects.is_empty(),
1729            "idle mode paints no background of its own"
1730        );
1731
1732        let contextual_selected = BTreeSet::from([0]);
1733        let contextual: SelectionAppBarView<()> =
1734            selection_app_bar(idle_view(), &contextual_selected, 3, |_: &mut ()| {});
1735        let mut contextual_w = bar_build(&contextual);
1736        let size = bar_layout(&mut contextual_w, 300.0);
1737        let mut contextual_scene = RectRecorder::default();
1738        let mut ctx = PaintCtx::new(Point::ZERO, size);
1739        contextual_w.paint(&mut ctx, &mut contextual_scene);
1740        // The contextual background fill is always the *first* rect this
1741        // widget itself paints (whatever a child icon button/checkbox
1742        // additionally paints of its own container comes after it).
1743        assert_eq!(contextual_scene.rects[0].1, size);
1744        assert!(!contextual_scene.rects.is_empty());
1745    }
1746
1747    #[derive(Default)]
1748    struct RectRecorder {
1749        rects: Vec<(Point, Size)>,
1750    }
1751    impl PaintScene for RectRecorder {
1752        fn fill_rect(&mut self, o: Point, s: Size, _c: Color) {
1753            self.rects.push((o, s));
1754        }
1755        fn draw_text(&mut self, _o: Point, _t: &str) {}
1756    }
1757
1758    #[test]
1759    fn close_button_press_fires_on_clear() {
1760        #[derive(Default)]
1761        struct S {
1762            cleared: u32,
1763        }
1764        let selected = BTreeSet::from([0]);
1765        let view: SelectionAppBarView<S> =
1766            selection_app_bar(idle_view::<S>(), &selected, 3, |s: &mut S| s.cleared += 1);
1767        let mut w = bar_build(&view);
1768        bar_layout(&mut w, 300.0);
1769
1770        let close_pod = &w.contextual[w.contextual_slots.close];
1771        let center = close_pod.origin()
1772            + Vec2::new(close_pod.size().width / 2.0, close_pod.size().height / 2.0);
1773
1774        let mut state = S::default();
1775        let state_any: &mut dyn Any = &mut state;
1776        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, APP_BAR_HEIGHT));
1777        w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1778        w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1779        assert_eq!(state.cleared, 1);
1780    }
1781
1782    #[test]
1783    fn select_all_reports_binary_flip_treating_partial_as_not_all() {
1784        #[derive(Default)]
1785        struct S {
1786            calls: Vec<bool>,
1787        }
1788
1789        fn bar(selected: &BTreeSet<usize>) -> SelectionAppBarView<S> {
1790            selection_app_bar(idle_view::<S>(), selected, 3, |_: &mut S| {})
1791                .on_all_selected(|s: &mut S, all| s.calls.push(all))
1792        }
1793
1794        fn press_checkbox(w: &mut SelectionAppBarWidget, state: &mut S) {
1795            let (cb_idx, _) = w.contextual_slots.select_all.expect("select-all present");
1796            let pod = &w.contextual[cb_idx];
1797            let center = pod.origin() + Vec2::new(pod.size().width / 2.0, pod.size().height / 2.0);
1798            let state_any: &mut dyn Any = state;
1799            let mut ctx = EventCtx::new(
1800                state_any,
1801                Point::ZERO,
1802                Size::new(300.0, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT),
1803            );
1804            w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1805            w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1806        }
1807
1808        // None selected -> report `true` (select all).
1809        let none = BTreeSet::from([0]); // selection mode needs >=1 to show the bar
1810        let view = bar(&none);
1811        let mut w = bar_build(&view);
1812        bar_layout(&mut w, 300.0);
1813        let mut state = S::default();
1814        press_checkbox(&mut w, &mut state);
1815        assert_eq!(
1816            state.calls,
1817            vec![true],
1818            "a partial selection reports true (not-all)"
1819        );
1820
1821        // All selected -> report `false` (clear).
1822        let all = BTreeSet::from([0, 1, 2]);
1823        let view = bar(&all);
1824        let mut w = bar_build(&view);
1825        bar_layout(&mut w, 300.0);
1826        let mut state = S::default();
1827        press_checkbox(&mut w, &mut state);
1828        assert_eq!(state.calls, vec![false]);
1829    }
1830
1831    #[test]
1832    fn external_clear_collapses_the_bar_back_to_idle() {
1833        // Controlled semantics: no `on_clear` round trip is required to
1834        // leave selection mode — clearing `selected` externally is enough.
1835        let mut counter = 0u64;
1836        let prev_selected = BTreeSet::from([0]);
1837        let prev: SelectionAppBarView<()> =
1838            selection_app_bar(idle_view(), &prev_selected, 3, |_: &mut ()| {});
1839        let mut w = View::<()>::build(&prev, &mut BuildCtx::new(&mut counter));
1840        bar_layout(&mut w, 300.0);
1841        assert!(is_selection_mode(&w.selected));
1842
1843        let next_selected = BTreeSet::new();
1844        let next: SelectionAppBarView<()> =
1845            selection_app_bar(idle_view(), &next_selected, 3, |_: &mut ()| {});
1846        View::<()>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1847        assert!(!is_selection_mode(&w.selected));
1848
1849        let size = bar_layout(&mut w, 300.0);
1850        assert_eq!(
1851            size,
1852            Size::new(300.0, 30.0),
1853            "layout falls back to idle's own height"
1854        );
1855    }
1856
1857    #[test]
1858    fn semantics_reports_count_selected_label_in_contextual_mode() {
1859        fn logic(_s: &mut ()) -> SelectionAppBarView<()> {
1860            let selected = BTreeSet::from([0, 1]);
1861            selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {})
1862        }
1863        let mut root: frust_core::RenderRoot<(), SelectionAppBarView<()>> =
1864            frust_core::RenderRoot::new();
1865        let mut state = ();
1866        root.rebuild(&mut logic, &mut state);
1867        let mut tcx = TextContext::new();
1868        root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
1869        let update = root.semantics();
1870        let (_, node) = update
1871            .nodes
1872            .iter()
1873            .find(|(_, n)| n.role() == Role::TitleBar)
1874            .expect("a TitleBar node is contributed in selection mode");
1875        assert_eq!(node.label(), Some("2 selected"));
1876    }
1877}