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