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, 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>(
330    items: impl IntoIterator<Item = AnyView<State>>,
331    selected: &BTreeSet<usize>,
332    on_toggle: F,
333) -> SelectionHostView<State>
334where
335    F: Fn(&mut State, usize) + 'static,
336{
337    SelectionHostView {
338        items: items.into_iter().collect(),
339        selected: selected.clone(),
340        enabled: true,
341        on_toggle: Rc::new(on_toggle),
342        on_activate: None,
343    }
344}
345
346impl<State: 'static> SelectionHostView<State> {
347    /// Fire when a row is tapped while `selected` is empty (ordinary row
348    /// activation, e.g. opening it). Absent by default: a tap outside
349    /// selection mode with no `on_activate` set is simply a no-op.
350    pub fn on_activate<F: Fn(&mut State, usize) + 'static>(mut self, on_activate: F) -> Self {
351        self.on_activate = Some(Rc::new(on_activate));
352        self
353    }
354
355    /// Gate the whole router (defaults to `true`): while `false`, no row
356    /// captures a press, arms a long-press, or fires either callback.
357    pub fn enabled(mut self, enabled: bool) -> Self {
358        self.enabled = enabled;
359        self
360    }
361}
362
363/// Latched long-press timing state — mirrors `toggle_button.rs`'s
364/// `LongPressState` (see the [module docs](self)' Wrapper section).
365#[derive(Debug, Default, Clone, Copy)]
366struct LongPressState {
367    /// The frame time the capture started, seeded on the first `paint` after
368    /// a `Down`.
369    press_start: Option<FrameTime>,
370    /// Whether the hold has crossed [`LONG_PRESS_MS`] — latched in `paint`,
371    /// the only pass with a clock.
372    elapsed: bool,
373    /// Whether the callback has already fired for this press (fire-exactly-
374    /// once).
375    fired: bool,
376}
377
378impl LongPressState {
379    fn clear(&mut self) {
380        *self = Self::default();
381    }
382}
383
384/// The retained widget for a [`SelectionHostView`]. See the [module
385/// docs](self).
386pub struct SelectionHostWidget {
387    items: Vec<ChildPod>,
388    selected: BTreeSet<usize>,
389    enabled: bool,
390    /// Each row's `(y, height)` in local space, recorded during layout — the
391    /// hit test in `event` reads this.
392    rows: Vec<(f64, f64)>,
393    width: f64,
394    /// The row index a `Down` armed (alongside `capture_pointer`), cleared on
395    /// `Up`/`Cancel`/loss of enablement.
396    armed: Option<usize>,
397    /// Where the capturing `Down` landed, in local coordinates — the
398    /// long-press slop reference point.
399    down_pos: Point,
400    long_press: LongPressState,
401    on_toggle: ErasedArgCallback<usize>,
402    on_activate: Option<ErasedArgCallback<usize>>,
403}
404
405impl<State: 'static> View<State> for SelectionHostView<State> {
406    type Element = SelectionHostWidget;
407
408    fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionHostWidget {
409        let items = self
410            .items
411            .iter()
412            .map(|view| frust::authoring::build_child(view, ctx))
413            .collect();
414        SelectionHostWidget {
415            items,
416            selected: self.selected.clone(),
417            enabled: self.enabled,
418            rows: Vec::new(),
419            width: 0.0,
420            armed: None,
421            down_pos: Point::ZERO,
422            long_press: LongPressState::default(),
423            on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
424            on_activate: self
425                .on_activate
426                .as_ref()
427                .map(frust::authoring::erase_callback_arg),
428        }
429    }
430
431    fn rebuild(
432        &self,
433        prev: &Self,
434        element: &mut SelectionHostWidget,
435        ctx: &mut BuildCtx<'_>,
436    ) -> ChangeFlags {
437        let mut flags = frust::authoring::rebuild_children(
438            &prev.items,
439            &self.items,
440            &mut element.items,
441            ctx,
442            |view| view,
443            |_| None,
444        );
445
446        if prev.items.len() != self.items.len() {
447            // A structural change invalidates the recorded press: the armed
448            // index may now name a different row (or none at all).
449            element.armed = None;
450            element.long_press.clear();
451            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
452        }
453
454        if element.selected != self.selected {
455            element.selected = self.selected.clone();
456            flags |= ChangeFlags::PAINT;
457        }
458
459        if element.enabled != self.enabled {
460            element.enabled = self.enabled;
461            if !self.enabled {
462                element.armed = None;
463                element.long_press.clear();
464            }
465            flags |= ChangeFlags::PAINT;
466        }
467
468        element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
469        element.on_activate = self
470            .on_activate
471            .as_ref()
472            .map(frust::authoring::erase_callback_arg);
473        flags
474    }
475
476    fn teardown(&self, element: &mut SelectionHostWidget, ctx: &mut BuildCtx<'_>) {
477        for (view, pod) in self.items.iter().zip(element.items.iter_mut()) {
478            frust::authoring::teardown_child(view, pod, ctx);
479        }
480    }
481}
482
483impl SelectionHostWidget {
484    /// The row containing local `pos`, if any — a position in a gap between
485    /// two rows (or outside the stack's width) belongs to neither.
486    fn row_at(&self, pos: Point) -> Option<usize> {
487        if pos.x < 0.0 || pos.x >= self.width {
488            return None;
489        }
490        self.rows
491            .iter()
492            .position(|(y, height)| pos.y >= *y && pos.y < y + height)
493    }
494
495    /// Clear whatever gesture state a press left behind (shared by the
496    /// `Up`/`Cancel` arms).
497    fn clear_press(&mut self) {
498        self.armed = None;
499        self.long_press.clear();
500    }
501
502    /// Fire a long-press: only when `index` is not already selected — ported
503    /// verbatim from the reference's `_onLongPress` guard (see the [module
504    /// docs](self)' Wrapper section) — so a long-press never deselects the
505    /// row it landed on.
506    fn fire_long_press(&mut self, ctx: &mut EventCtx, index: usize) {
507        if !self.selected.contains(&index) {
508            (self.on_toggle)(ctx, index);
509        }
510    }
511
512    /// Fire a tap: toggle while in selection mode, activate otherwise (see
513    /// the [module docs](self)' Wrapper section).
514    fn fire_tap(&mut self, ctx: &mut EventCtx, index: usize) {
515        if is_selection_mode(&self.selected) {
516            (self.on_toggle)(ctx, index);
517        } else if let Some(on_activate) = self.on_activate.as_mut() {
518            on_activate(ctx, index);
519        }
520    }
521}
522
523impl Widget for SelectionHostWidget {
524    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
525        let width = if bc.max().width.is_finite() {
526            bc.max().width
527        } else {
528            0.0
529        };
530        self.width = width;
531        let max_height = bc.max().height;
532        let child_bc = BoxConstraints::new(Size::new(width, 0.0), Size::new(width, max_height));
533
534        self.rows.clear();
535        let mut y = 0.0;
536        for pod in self.items.iter_mut() {
537            let size = pod.layout_child(ctx, &child_bc);
538            pod.set_origin(Point::new(0.0, y));
539            self.rows.push((y, size.height));
540            y += size.height;
541        }
542
543        bc.constrain(Size::new(width, y))
544    }
545
546    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
547        for pod in &mut self.items {
548            pod.paint_child(ctx, scene);
549        }
550
551        // Long-press threshold latch — `paint` is the only pass with a
552        // clock. See the [module docs](self)' Wrapper section.
553        if self.enabled && self.armed.is_some() && !self.long_press.elapsed {
554            let start = *self.long_press.press_start.get_or_insert(ctx.frame_time());
555            let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
556            if elapsed_ms >= LONG_PRESS_MS {
557                self.long_press.elapsed = true;
558            } else {
559                ctx.request_frame();
560            }
561        }
562    }
563
564    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
565        let InputEvent::Pointer(p) = event else {
566            return frust::authoring::route_event(&mut self.items, ctx, event);
567        };
568        if !self.enabled {
569            // A disabled router arms nothing and forwards no pointer event
570            // into its rows either — mirrors `card_list`'s disabled
571            // early-return.
572            return EventResult::Ignored;
573        }
574        match p.phase {
575            PointerPhase::Down => {
576                if !presses(p) {
577                    return EventResult::Ignored;
578                }
579                let Some(index) = self.row_at(p.position) else {
580                    // The gap between two rows is nobody's target.
581                    return EventResult::Ignored;
582                };
583                self.armed = Some(index);
584                self.down_pos = p.position;
585                self.long_press.clear();
586                ctx.capture_pointer();
587                ctx.request_redraw();
588                EventResult::Handled
589            }
590            PointerPhase::Move => {
591                let Some(armed) = self.armed else {
592                    // No host-level hover chrome to claim — see the [module
593                    // docs](self)' Wrapper section (a row's own visuals are
594                    // the caller's responsibility).
595                    return EventResult::Ignored;
596                };
597                // Long-press slop check + fire-on-move-arrival (mirrors
598                // `toggle_button.rs`'s identical pattern).
599                if (p.position - self.down_pos).hypot() > TOUCH_SLOP {
600                    self.long_press.clear();
601                } else if self.long_press.elapsed && !self.long_press.fired {
602                    self.long_press.fired = true;
603                    self.fire_long_press(ctx, armed);
604                    ctx.request_redraw();
605                }
606                EventResult::Handled
607            }
608            PointerPhase::Up => {
609                let Some(armed) = self.armed else {
610                    return EventResult::Ignored;
611                };
612                // A held-past-threshold press resolves as a long-press,
613                // never also as a tap — mutually exclusive outcomes of the
614                // same gesture (the [module docs](self)' Wrapper section).
615                if self.long_press.elapsed {
616                    if !self.long_press.fired {
617                        self.long_press.fired = true;
618                        self.fire_long_press(ctx, armed);
619                    }
620                } else if self.row_at(p.position) == Some(armed) {
621                    self.fire_tap(ctx, armed);
622                }
623                self.clear_press();
624                ctx.request_redraw();
625                EventResult::Handled
626            }
627            PointerPhase::Cancel => {
628                if self.armed.is_none() {
629                    return EventResult::Ignored;
630                }
631                self.clear_press();
632                ctx.request_redraw();
633                EventResult::Handled
634            }
635        }
636    }
637
638    fn semantics(&self, ctx: &mut SemanticsCtx) {
639        ctx.push_container(
640            Role::List,
641            |_node| {},
642            |ctx| {
643                for pod in &self.items {
644                    pod.semantics_child(ctx);
645                }
646            },
647        );
648    }
649
650    frust::authoring::visit_children!(items);
651}
652
653// ---- selection_app_bar: the contextual bar --------------------------------
654
655type OnClear<State> = Rc<dyn Fn(&mut State)>;
656type OnAllSelected<State> = Rc<dyn Fn(&mut State, bool)>;
657
658/// A declarative selection-aware app bar: `idle` content, swapping to a
659/// contextual toolbar while `selected` is non-empty. See the [module
660/// docs](self)' Contextual bar section.
661pub struct SelectionAppBarView<State: 'static> {
662    idle: AnyView<State>,
663    selected: BTreeSet<usize>,
664    item_count: usize,
665    actions: Vec<AnyView<State>>,
666    show_select_all: bool,
667    select_all_label: String,
668    on_clear: OnClear<State>,
669    on_all_selected: Option<OnAllSelected<State>>,
670    safe_area: bool,
671}
672
673/// Create a selection app bar showing `idle` while `selected` is empty, and
674/// the contextual toolbar (count + close + actions) otherwise. `on_clear`
675/// fires when the close affordance is pressed — the app is the source of
676/// truth for `selected`, so this is the *only* way this bar's own close
677/// button ever empties it (see the [module docs](self)' Controlled semantics
678/// section).
679pub fn selection_app_bar<State: 'static, F>(
680    idle: AnyView<State>,
681    selected: &BTreeSet<usize>,
682    item_count: usize,
683    on_clear: F,
684) -> SelectionAppBarView<State>
685where
686    F: Fn(&mut State) + 'static,
687{
688    SelectionAppBarView {
689        idle,
690        selected: selected.clone(),
691        item_count,
692        actions: Vec::new(),
693        show_select_all: true,
694        select_all_label: "Select all".to_string(),
695        on_clear: Rc::new(on_clear),
696        on_all_selected: None,
697        safe_area: true,
698    }
699}
700
701impl<State: 'static> SelectionAppBarView<State> {
702    /// Attach contextual trailing action slots, in reading order
703    /// (`M3ESelectionAppBar.actions`).
704    pub fn actions(mut self, actions: Vec<AnyView<State>>) -> Self {
705        self.actions = actions;
706        self
707    }
708
709    /// Whether to show the select-all row in the contextual toolbar.
710    /// Defaults to `true`.
711    pub fn show_select_all(mut self, show: bool) -> Self {
712        self.show_select_all = show;
713        self
714    }
715
716    /// Override the select-all row's label. Defaults to `"Select all"`.
717    pub fn select_all_label(mut self, label: impl Into<String>) -> Self {
718        self.select_all_label = label.into();
719        self
720    }
721
722    /// Fire when the select-all row is toggled — `true` means the app should
723    /// adopt [`select_all`]`(item_count)`, `false` means [`cleared`]`()`
724    /// (`M3ESelectionAppBar.onAllSelected`). Absent by default: toggling
725    /// select-all with no callback set is a no-op.
726    pub fn on_all_selected<F: Fn(&mut State, bool) + 'static>(
727        mut self,
728        on_all_selected: F,
729    ) -> Self {
730        self.on_all_selected = Some(Rc::new(on_all_selected));
731        self
732    }
733
734    /// Whether the contextual branch consumes the top, left and right window
735    /// insets itself (default `true`), exactly as an idle [`crate::app_bar`]
736    /// or [`crate::search_app_bar`] does by default — see the [module
737    /// docs](self)' Contextual bar section. The idle view is opaque here, so
738    /// this setting cannot be read off it: a caller that passes
739    /// `.safe_area(false)` to its idle bar passes it here too, keeping both
740    /// branches at the same height and offsets. It never affects the idle
741    /// branch, which lays out (and insets) on its own terms.
742    pub fn safe_area(mut self, enabled: bool) -> Self {
743        self.safe_area = enabled;
744        self
745    }
746}
747
748impl<State: 'static> SelectionAppBarView<State> {
749    /// The presence-of-slots shape a rebuild's structural check keys off —
750    /// mirrors `list_item.rs`'s `ListItem::shape` pattern.
751    fn shape(&self) -> (usize, bool) {
752        (self.actions.len(), self.show_select_all)
753    }
754}
755
756/// Build the close affordance's view (`icon_button` over
757/// [`crate::icons::CLOSE`], firing `on_clear` directly) — mirrors
758/// `dialog.rs`'s `close_view` helper of the same shape.
759fn close_view<State: 'static>(on_clear: OnClear<State>) -> AnyView<State> {
760    any(
761        icon_button(any(icon(crate::icons::CLOSE)), move |state: &mut State| {
762            (on_clear)(state);
763        })
764        .semantic_label(CLEAR_SELECTION_LABEL),
765    )
766}
767
768/// Build the selected-count label's view (`titleLargeEmphasized`,
769/// `on_primary_container`, family from the theme's `titleLargeEmphasized`
770/// role).
771fn count_view<State: 'static>(count: usize) -> AnyView<State> {
772    any::<State, _>(
773        text(count.to_string())
774            .size(COUNT_SIZE)
775            .weight(COUNT_WEIGHT)
776            .line_height(LineHeight::Absolute(COUNT_LINE_HEIGHT))
777            .themed_role(ThemeTextColor::OnPrimaryContainer)
778            .themed_family(ThemeTextType::TitleLargeEmphasized),
779    )
780}
781
782/// Build the select-all checkbox's view. Ports `_toggleSelectAll`'s binary
783/// flip exactly: the checkbox's own tap-cycle report is ignored (`_next`),
784/// and `on_all_selected` instead fires with `!all`, where `all` treats a
785/// partial selection the same as none (matching upstream's `all ?? false`).
786fn select_all_checkbox_view<State: 'static>(
787    selected: BTreeSet<usize>,
788    item_count: usize,
789    on_all_selected: Option<OnAllSelected<State>>,
790) -> AnyView<State> {
791    let value = all_selected_for(&selected, item_count);
792    any(crate::checkbox::tristate_checkbox::<State, _>(
793        value,
794        move |state: &mut State, _next: Option<bool>| {
795            if let Some(cb) = &on_all_selected {
796                cb(state, !matches!(value, Some(true)));
797            }
798        },
799    ))
800}
801
802/// Build the select-all row's label view (`bodyLarge` — `text`'s own
803/// default size, and the theme's `bodyLarge` family — `on_primary_container`).
804fn select_all_label_view<State: 'static>(label: String) -> AnyView<State> {
805    any::<State, _>(
806        text(label)
807            .themed_role(ThemeTextColor::OnPrimaryContainer)
808            .themed_family(ThemeTextType::BodyLarge),
809    )
810}
811
812/// The resolved contextual bar container fill. Themed: `colors.
813/// primary_container`. Unthemed: [`CONTEXTUAL_BACKGROUND`] exactly.
814fn resolve_contextual_background(theme: Option<&Theme>) -> Color {
815    match theme {
816        Some(theme) => theme.scheme().primary_container,
817        None => CONTEXTUAL_BACKGROUND,
818    }
819}
820
821/// Index map into [`SelectionAppBarWidget::contextual`] — mirrors
822/// `list_item.rs`'s `Slots` pattern.
823#[derive(Clone, Copy)]
824struct ContextualSlots {
825    close: usize,
826    count_text: usize,
827    actions_start: usize,
828    actions_len: usize,
829    /// `(checkbox index, label index)`, present only while
830    /// [`SelectionAppBarView::show_select_all`] is `true`.
831    select_all: Option<(usize, usize)>,
832}
833
834/// Build the ordered child window `[close, count_text, action.., select_all_
835/// checkbox?, select_all_label?]` and the [`ContextualSlots`] index map.
836fn build_contextual_children<State: 'static>(
837    view: &SelectionAppBarView<State>,
838    ctx: &mut BuildCtx<'_>,
839) -> (Vec<ChildPod>, ContextualSlots) {
840    let mut children = Vec::new();
841
842    let close_v = close_view::<State>(view.on_clear.clone());
843    children.push(frust::authoring::build_child(&close_v, ctx));
844    let close = 0;
845
846    let count_v = count_view::<State>(view.selected.len());
847    children.push(frust::authoring::build_child(&count_v, ctx));
848    let count_text = 1;
849
850    let actions_start = children.len();
851    for action in &view.actions {
852        children.push(frust::authoring::build_child(action, ctx));
853    }
854    let actions_len = view.actions.len();
855
856    let select_all = view.show_select_all.then(|| {
857        let cb = select_all_checkbox_view::<State>(
858            view.selected.clone(),
859            view.item_count,
860            view.on_all_selected.clone(),
861        );
862        let lbl = select_all_label_view::<State>(view.select_all_label.clone());
863        let cb_idx = children.len();
864        children.push(frust::authoring::build_child(&cb, ctx));
865        let lbl_idx = children.len();
866        children.push(frust::authoring::build_child(&lbl, ctx));
867        (cb_idx, lbl_idx)
868    });
869
870    (
871        children,
872        ContextualSlots {
873            close,
874            count_text,
875            actions_start,
876            actions_len,
877            select_all,
878        },
879    )
880}
881
882/// Tear down every contextual child pod through the view it was built from,
883/// per the [`ContextualSlots`] index map — mirrors `list_item.rs`'s
884/// `teardown_children` helper.
885fn teardown_contextual_children<State: 'static>(
886    view: &SelectionAppBarView<State>,
887    slots: ContextualSlots,
888    children: &mut [ChildPod],
889    ctx: &mut BuildCtx<'_>,
890) {
891    let close_v = close_view::<State>(view.on_clear.clone());
892    frust::authoring::teardown_child(&close_v, &mut children[slots.close], ctx);
893
894    let count_v = count_view::<State>(view.selected.len());
895    frust::authoring::teardown_child(&count_v, &mut children[slots.count_text], ctx);
896
897    for i in 0..slots.actions_len {
898        frust::authoring::teardown_child(
899            &view.actions[i],
900            &mut children[slots.actions_start + i],
901            ctx,
902        );
903    }
904
905    if let Some((cb_idx, lbl_idx)) = slots.select_all {
906        let cb = select_all_checkbox_view::<State>(
907            view.selected.clone(),
908            view.item_count,
909            view.on_all_selected.clone(),
910        );
911        frust::authoring::teardown_child(&cb, &mut children[cb_idx], ctx);
912        let lbl = select_all_label_view::<State>(view.select_all_label.clone());
913        frust::authoring::teardown_child(&lbl, &mut children[lbl_idx], ctx);
914    }
915}
916
917/// The retained widget for a [`SelectionAppBarView`]. See the [module
918/// docs](self).
919pub struct SelectionAppBarWidget {
920    idle: ChildPod,
921    /// `[close, count_text, action.., select_all_checkbox?,
922    /// select_all_label?]` — see [`ContextualSlots`].
923    contextual: Vec<ChildPod>,
924    contextual_slots: ContextualSlots,
925    selected: BTreeSet<usize>,
926    item_count: usize,
927    show_select_all: bool,
928    select_all_label: String,
929    /// Whether the contextual branch consumes the top/left/right window
930    /// insets — see [`SelectionAppBarView::safe_area`].
931    safe_area: bool,
932}
933
934impl<State: 'static> View<State> for SelectionAppBarView<State> {
935    type Element = SelectionAppBarWidget;
936
937    fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionAppBarWidget {
938        let (contextual, contextual_slots) = build_contextual_children(self, ctx);
939        SelectionAppBarWidget {
940            idle: frust::authoring::build_child(&self.idle, ctx),
941            contextual,
942            contextual_slots,
943            selected: self.selected.clone(),
944            item_count: self.item_count,
945            show_select_all: self.show_select_all,
946            select_all_label: self.select_all_label.clone(),
947            safe_area: self.safe_area,
948        }
949    }
950
951    fn rebuild(
952        &self,
953        prev: &Self,
954        element: &mut SelectionAppBarWidget,
955        ctx: &mut BuildCtx<'_>,
956    ) -> ChangeFlags {
957        let mut flags =
958            frust::authoring::rebuild_child(&prev.idle, &self.idle, &mut element.idle, ctx);
959
960        if prev.shape() != self.shape() {
961            teardown_contextual_children(
962                prev,
963                element.contextual_slots,
964                &mut element.contextual,
965                ctx,
966            );
967            let (children, slots) = build_contextual_children(self, ctx);
968            element.contextual = children;
969            element.contextual_slots = slots;
970            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
971        } else {
972            let slots = element.contextual_slots;
973
974            let prev_close = close_view::<State>(prev.on_clear.clone());
975            let next_close = close_view::<State>(self.on_clear.clone());
976            flags |= frust::authoring::rebuild_child(
977                &prev_close,
978                &next_close,
979                &mut element.contextual[slots.close],
980                ctx,
981            );
982
983            let prev_count = count_view::<State>(prev.selected.len());
984            let next_count = count_view::<State>(self.selected.len());
985            flags |= frust::authoring::rebuild_child(
986                &prev_count,
987                &next_count,
988                &mut element.contextual[slots.count_text],
989                ctx,
990            );
991
992            for i in 0..slots.actions_len {
993                flags |= frust::authoring::rebuild_child(
994                    &prev.actions[i],
995                    &self.actions[i],
996                    &mut element.contextual[slots.actions_start + i],
997                    ctx,
998                );
999            }
1000
1001            if let Some((cb_idx, lbl_idx)) = slots.select_all {
1002                let prev_cb = select_all_checkbox_view::<State>(
1003                    prev.selected.clone(),
1004                    prev.item_count,
1005                    prev.on_all_selected.clone(),
1006                );
1007                let next_cb = select_all_checkbox_view::<State>(
1008                    self.selected.clone(),
1009                    self.item_count,
1010                    self.on_all_selected.clone(),
1011                );
1012                flags |= frust::authoring::rebuild_child(
1013                    &prev_cb,
1014                    &next_cb,
1015                    &mut element.contextual[cb_idx],
1016                    ctx,
1017                );
1018
1019                let prev_lbl = select_all_label_view::<State>(prev.select_all_label.clone());
1020                let next_lbl = select_all_label_view::<State>(self.select_all_label.clone());
1021                flags |= frust::authoring::rebuild_child(
1022                    &prev_lbl,
1023                    &next_lbl,
1024                    &mut element.contextual[lbl_idx],
1025                    ctx,
1026                );
1027            }
1028        }
1029
1030        if element.selected != self.selected {
1031            element.selected = self.selected.clone();
1032            // The idle/contextual branch itself may have flipped.
1033            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1034        }
1035        if element.safe_area != self.safe_area {
1036            element.safe_area = self.safe_area;
1037            flags |= ChangeFlags::LAYOUT;
1038        }
1039        element.item_count = self.item_count;
1040        element.show_select_all = self.show_select_all;
1041        element.select_all_label = self.select_all_label.clone();
1042        flags
1043    }
1044
1045    fn teardown(&self, element: &mut SelectionAppBarWidget, ctx: &mut BuildCtx<'_>) {
1046        frust::authoring::teardown_child(&self.idle, &mut element.idle, ctx);
1047        teardown_contextual_children(self, element.contextual_slots, &mut element.contextual, ctx);
1048    }
1049}
1050
1051impl Widget for SelectionAppBarWidget {
1052    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1053        let width = if bc.max().width.is_finite() {
1054            bc.max().width
1055        } else {
1056            0.0
1057        };
1058
1059        if !is_selection_mode(&self.selected) {
1060            let idle_size = self
1061                .idle
1062                .layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)));
1063            self.idle.set_origin(Point::ZERO);
1064            return bc.constrain(Size::new(width, idle_size.height));
1065        }
1066
1067        // Consume the same top/left/right window insets an idle top app bar
1068        // does, so the swap keeps one height and one set of slot offsets in
1069        // every composition (a consuming parent safe area leaves 0 here, for
1070        // both branches alike).
1071        let (pad_l, top, pad_r) = if self.safe_area {
1072            let padding = ctx.window_insets().padding();
1073            (
1074                padding.left.max(0.0),
1075                padding.top.max(0.0),
1076                padding.right.max(0.0),
1077            )
1078        } else {
1079            (0.0, 0.0, 0.0)
1080        };
1081        let leading_edge = pad_l + APP_BAR_PAD_X;
1082        let trailing_edge = width - pad_r - APP_BAR_PAD_X;
1083
1084        let slots = self.contextual_slots;
1085        let slot_bc = BoxConstraints::loose(Size::new(f64::INFINITY, APP_BAR_HEIGHT));
1086
1087        let close_size = self.contextual[slots.close].layout_child(ctx, &slot_bc);
1088        self.contextual[slots.close].set_origin(Point::new(
1089            leading_edge + (ACTION_SLOT - close_size.width).max(0.0) / 2.0,
1090            top + (APP_BAR_HEIGHT - close_size.height) / 2.0,
1091        ));
1092
1093        // Trailing actions, right-to-left from the trailing edge — mirrors
1094        // `appbar.rs`'s own reverse-iteration layout.
1095        let mut right = trailing_edge;
1096        for i in (0..slots.actions_len).rev() {
1097            let idx = slots.actions_start + i;
1098            let size = self.contextual[idx].layout_child(ctx, &slot_bc);
1099            right -= size.width;
1100            self.contextual[idx].set_origin(Point::new(
1101                right,
1102                top + (APP_BAR_HEIGHT - size.height) / 2.0,
1103            ));
1104            right -= APP_BAR_GAP;
1105        }
1106
1107        let count_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
1108        let count_max_width = (right - count_left).max(0.0);
1109        let count_size = self.contextual[slots.count_text].layout_child(
1110            ctx,
1111            &BoxConstraints::loose(Size::new(count_max_width, APP_BAR_HEIGHT)),
1112        );
1113        self.contextual[slots.count_text].set_origin(Point::new(
1114            count_left,
1115            top + (APP_BAR_HEIGHT - count_size.height) / 2.0,
1116        ));
1117
1118        let mut height = top + APP_BAR_HEIGHT;
1119        if let Some((cb_idx, lbl_idx)) = slots.select_all {
1120            let cb_bc = BoxConstraints::loose(Size::new(f64::INFINITY, SELECT_ALL_HEIGHT));
1121            let cb_size = self.contextual[cb_idx].layout_child(ctx, &cb_bc);
1122            self.contextual[cb_idx].set_origin(Point::new(
1123                leading_edge + (ACTION_SLOT - cb_size.width).max(0.0) / 2.0,
1124                height + (SELECT_ALL_HEIGHT - cb_size.height) / 2.0,
1125            ));
1126
1127            let lbl_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
1128            let lbl_max_width = (trailing_edge - lbl_left).max(0.0);
1129            let lbl_size = self.contextual[lbl_idx].layout_child(
1130                ctx,
1131                &BoxConstraints::loose(Size::new(lbl_max_width, SELECT_ALL_HEIGHT)),
1132            );
1133            self.contextual[lbl_idx].set_origin(Point::new(
1134                lbl_left,
1135                height + (SELECT_ALL_HEIGHT - lbl_size.height) / 2.0,
1136            ));
1137            height += SELECT_ALL_HEIGHT;
1138        }
1139
1140        bc.constrain(Size::new(width, height))
1141    }
1142
1143    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1144        if !is_selection_mode(&self.selected) {
1145            self.idle.paint_child(ctx, scene);
1146            return;
1147        }
1148        // Every theme read happens before `ctx` is taken mutably below
1149        // (`paint_child`) — mirrors `card::CardWidget::paint`'s ordering.
1150        let theme = Theme::from_paint_ctx(ctx);
1151        let fill = resolve_contextual_background(theme);
1152        scene.fill_rect(ctx.origin(), ctx.size(), fill);
1153        for pod in &mut self.contextual {
1154            pod.paint_child(ctx, scene);
1155        }
1156    }
1157
1158    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1159        if is_selection_mode(&self.selected) {
1160            frust::authoring::route_event(&mut self.contextual, ctx, event)
1161        } else {
1162            frust::authoring::route_event_single(&mut self.idle, ctx, event)
1163        }
1164    }
1165
1166    fn semantics(&self, ctx: &mut SemanticsCtx) {
1167        if !is_selection_mode(&self.selected) {
1168            self.idle.semantics_child(ctx);
1169            return;
1170        }
1171        let count = self.selected.len();
1172        let label = format!("{count} selected");
1173        let contextual = &self.contextual;
1174        ctx.push_container(
1175            Role::TitleBar,
1176            |node| {
1177                node.set_label(label.as_str());
1178                node.add_action(Action::Click);
1179            },
1180            |ctx| {
1181                for pod in contextual {
1182                    pod.semantics_child(ctx);
1183                }
1184            },
1185        );
1186    }
1187
1188    frust::authoring::visit_children!(contextual, idle);
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194    use frust::authoring::PointerButton;
1195    use frust::authoring::text::TextContext;
1196    use kurbo::Vec2;
1197    use std::any::Any;
1198
1199    // ---- Controller: pure functions ---------------------------------------
1200
1201    #[test]
1202    fn is_selection_mode_matches_reference_getter() {
1203        assert!(!is_selection_mode(&BTreeSet::new()));
1204        assert!(is_selection_mode(&BTreeSet::from([2])));
1205    }
1206
1207    #[test]
1208    fn is_selected_reads_membership() {
1209        let selected = BTreeSet::from([1, 3]);
1210        assert!(is_selected(&selected, 1));
1211        assert!(!is_selected(&selected, 2));
1212    }
1213
1214    #[test]
1215    fn all_selected_for_matches_reference_tristate_formula() {
1216        assert_eq!(all_selected_for(&BTreeSet::new(), 0), Some(false));
1217        assert_eq!(all_selected_for(&BTreeSet::new(), 3), Some(false));
1218        assert_eq!(all_selected_for(&BTreeSet::from([0, 1, 2]), 3), Some(true));
1219        assert_eq!(
1220            all_selected_for(&BTreeSet::from([0, 1, 2, 5]), 3),
1221            Some(true)
1222        );
1223        assert_eq!(all_selected_for(&BTreeSet::from([0]), 3), None);
1224    }
1225
1226    #[test]
1227    fn select_deselect_toggle_and_select_all_are_pure_next_state_transforms() {
1228        let empty = BTreeSet::new();
1229        assert_eq!(select(&empty, 2), BTreeSet::from([2]));
1230        assert_eq!(empty, BTreeSet::new(), "select must not mutate its input");
1231
1232        let some = BTreeSet::from([1, 2]);
1233        assert_eq!(deselect(&some, 1), BTreeSet::from([2]));
1234        assert_eq!(
1235            deselect(&some, 9),
1236            some,
1237            "deselecting an absent index is a no-op value"
1238        );
1239
1240        assert_eq!(toggle(&some, 1), BTreeSet::from([2]), "toggle off");
1241        assert_eq!(toggle(&some, 5), BTreeSet::from([1, 2, 5]), "toggle on");
1242
1243        assert_eq!(select_all(3), BTreeSet::from([0, 1, 2]));
1244        assert_eq!(select_all(0), BTreeSet::new());
1245        assert_eq!(cleared(), BTreeSet::new());
1246    }
1247
1248    // ---- selection_host -----------------------------------------------------
1249
1250    const ROW_HEIGHT: f64 = 40.0;
1251    const WIDTH: f64 = 200.0;
1252
1253    fn build<S: 'static>(view: &SelectionHostView<S>) -> SelectionHostWidget {
1254        let mut counter = 0u64;
1255        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1256    }
1257
1258    fn layout(w: &mut SelectionHostWidget, height: f64) -> Size {
1259        let mut tcx = TextContext::new();
1260        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1261        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(WIDTH, height)))
1262    }
1263
1264    fn rows<S: 'static>(n: usize) -> Vec<AnyView<S>> {
1265        (0..n)
1266            .map(|_| any::<S, _>(frust::SizedBox(Some(10.0), Some(ROW_HEIGHT))))
1267            .collect()
1268    }
1269
1270    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1271        InputEvent::Pointer(frust::authoring::PointerEvent {
1272            phase,
1273            position: Point::new(x, y),
1274            button: PointerButton::Primary,
1275        })
1276    }
1277
1278    #[derive(Default)]
1279    struct Log {
1280        toggled: Vec<usize>,
1281        activated: Vec<usize>,
1282    }
1283
1284    fn dispatch(w: &mut SelectionHostWidget, state: &mut Log, event: &InputEvent) -> EventResult {
1285        let state_any: &mut dyn Any = state;
1286        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1287        w.event(&mut ctx, event)
1288    }
1289
1290    #[test]
1291    fn layout_stacks_rows_vertically_with_no_gap() {
1292        let selected = BTreeSet::new();
1293        let view: SelectionHostView<Log> =
1294            selection_host(rows(3), &selected, |s: &mut Log, i| s.toggled.push(i));
1295        let mut w = build(&view);
1296        let size = layout(&mut w, 300.0);
1297        assert_eq!(size, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1298        assert_eq!(
1299            w.rows,
1300            vec![
1301                (0.0, ROW_HEIGHT),
1302                (ROW_HEIGHT, ROW_HEIGHT),
1303                (ROW_HEIGHT * 2.0, ROW_HEIGHT)
1304            ]
1305        );
1306    }
1307
1308    #[test]
1309    fn tap_outside_selection_mode_activates_not_toggles() {
1310        let selected = BTreeSet::new();
1311        let view: SelectionHostView<Log> =
1312            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1313                .on_activate(|s: &mut Log, i| s.activated.push(i));
1314        let mut w = build(&view);
1315        layout(&mut w, 300.0);
1316        let mut state = Log::default();
1317        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1318        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1319        assert_eq!(state.activated, vec![0]);
1320        assert!(state.toggled.is_empty());
1321    }
1322
1323    #[test]
1324    fn tap_in_selection_mode_toggles_not_activates() {
1325        let selected = BTreeSet::from([1]);
1326        let view: SelectionHostView<Log> =
1327            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1328                .on_activate(|s: &mut Log, i| s.activated.push(i));
1329        let mut w = build(&view);
1330        layout(&mut w, 300.0);
1331        let mut state = Log::default();
1332        // Tap the first (unselected) row: toggle(0), never activate.
1333        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1334        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1335        assert_eq!(state.toggled, vec![0]);
1336        assert!(state.activated.is_empty());
1337    }
1338
1339    #[test]
1340    fn tap_up_outside_the_armed_row_fires_nothing() {
1341        let selected = BTreeSet::new();
1342        let view: SelectionHostView<Log> =
1343            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1344                .on_activate(|s: &mut Log, i| s.activated.push(i));
1345        let mut w = build(&view);
1346        layout(&mut w, 300.0);
1347        let mut state = Log::default();
1348        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1349        dispatch(
1350            &mut w,
1351            &mut state,
1352            &ev(PointerPhase::Up, 5.0, ROW_HEIGHT + 5.0),
1353        );
1354        assert!(state.activated.is_empty());
1355        assert!(state.toggled.is_empty());
1356    }
1357
1358    #[test]
1359    fn long_press_selects_an_unselected_row_and_suppresses_the_tap() {
1360        let selected = BTreeSet::new();
1361        let view: SelectionHostView<Log> =
1362            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1363                .on_activate(|s: &mut Log, i| s.activated.push(i));
1364        let mut w = build(&view);
1365        layout(&mut w, 300.0);
1366        let mut state = Log::default();
1367
1368        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1369        // Simulate the threshold already crossed (mirrors
1370        // `toggle_button.rs`'s identical test shape).
1371        w.long_press.elapsed = true;
1372        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1373        assert_eq!(state.toggled, vec![0], "long-press fired on move-arrival");
1374        assert!(state.activated.is_empty());
1375
1376        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 6.0, 5.0));
1377        assert_eq!(
1378            state.toggled,
1379            vec![0],
1380            "the resolved long-press is not also a tap"
1381        );
1382    }
1383
1384    #[test]
1385    fn long_press_never_deselects_an_already_selected_row() {
1386        let selected = BTreeSet::from([0]);
1387        let view: SelectionHostView<Log> =
1388            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1389        let mut w = build(&view);
1390        layout(&mut w, 300.0);
1391        let mut state = Log::default();
1392
1393        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1394        w.long_press.elapsed = true;
1395        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1396        assert!(
1397            state.toggled.is_empty(),
1398            "a long-press on an already-selected row must not fire on_toggle"
1399        );
1400    }
1401
1402    #[test]
1403    fn long_press_still_fires_at_up_if_no_move_arrives_first() {
1404        let selected = BTreeSet::new();
1405        let view: SelectionHostView<Log> =
1406            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1407        let mut w = build(&view);
1408        layout(&mut w, 300.0);
1409        let mut state = Log::default();
1410
1411        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1412        w.long_press.elapsed = true;
1413        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1414        assert_eq!(state.toggled, vec![0]);
1415    }
1416
1417    #[test]
1418    fn movement_past_touch_slop_cancels_long_press_candidacy() {
1419        let selected = BTreeSet::new();
1420        let view: SelectionHostView<Log> =
1421            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1422        let mut w = build(&view);
1423        layout(&mut w, 300.0);
1424        let mut state = Log::default();
1425
1426        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1427        w.long_press.elapsed = true; // simulate the threshold already crossed
1428        dispatch(
1429            &mut w,
1430            &mut state,
1431            &ev(PointerPhase::Move, 5.0 + TOUCH_SLOP + 5.0, 5.0),
1432        );
1433        assert!(!w.long_press.elapsed, "a drag cancels the candidacy");
1434        assert!(state.toggled.is_empty());
1435    }
1436
1437    #[test]
1438    fn cancel_clears_state_without_firing() {
1439        let selected = BTreeSet::new();
1440        let view: SelectionHostView<Log> =
1441            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1442        let mut w = build(&view);
1443        layout(&mut w, 300.0);
1444        let mut state = Log::default();
1445
1446        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1447        assert!(w.armed.is_some());
1448        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1449        assert!(w.armed.is_none());
1450        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1451        assert!(state.toggled.is_empty());
1452    }
1453
1454    #[test]
1455    fn disabled_router_arms_nothing() {
1456        let selected = BTreeSet::new();
1457        let view: SelectionHostView<Log> =
1458            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i)).enabled(false);
1459        let mut w = build(&view);
1460        layout(&mut w, 300.0);
1461        let mut state = Log::default();
1462        assert_eq!(
1463            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
1464            EventResult::Ignored
1465        );
1466        assert!(w.armed.is_none());
1467    }
1468
1469    #[test]
1470    fn a_secondary_press_never_arms_a_row() {
1471        let selected = BTreeSet::new();
1472        let view: SelectionHostView<Log> =
1473            selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1474        let mut w = build(&view);
1475        layout(&mut w, 300.0);
1476        let mut state = Log::default();
1477        let secondary = InputEvent::Pointer(frust::authoring::PointerEvent {
1478            phase: PointerPhase::Down,
1479            position: Point::new(5.0, 5.0),
1480            button: PointerButton::Secondary,
1481        });
1482        assert_eq!(
1483            dispatch(&mut w, &mut state, &secondary),
1484            EventResult::Ignored
1485        );
1486        assert!(w.armed.is_none());
1487    }
1488
1489    #[test]
1490    fn external_clear_collapses_mode_on_the_next_dispatch() {
1491        // Controlled semantics: rebuilding with an emptied `selected` flips
1492        // tap resolution from toggle back to activate with no separate
1493        // "exit" call (see the module docs' Controlled semantics section).
1494        let mut counter = 0u64;
1495        let prev_selected = BTreeSet::from([0]);
1496        let prev: SelectionHostView<Log> =
1497            selection_host(rows(2), &prev_selected, |s: &mut Log, i| s.toggled.push(i))
1498                .on_activate(|s: &mut Log, i| s.activated.push(i));
1499        let mut w = View::<Log>::build(&prev, &mut BuildCtx::new(&mut counter));
1500        layout(&mut w, 300.0);
1501
1502        let next_selected = BTreeSet::new();
1503        let next: SelectionHostView<Log> =
1504            selection_host(rows(2), &next_selected, |s: &mut Log, i| s.toggled.push(i))
1505                .on_activate(|s: &mut Log, i| s.activated.push(i));
1506        View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1507        assert!(!is_selection_mode(&w.selected));
1508
1509        let mut state = Log::default();
1510        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1511        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1512        assert_eq!(
1513            state.activated,
1514            vec![0],
1515            "collapsed mode resumes tap-to-activate"
1516        );
1517        assert!(state.toggled.is_empty());
1518    }
1519
1520    // ---- selection_app_bar --------------------------------------------------
1521
1522    fn idle_view<S: 'static>() -> AnyView<S> {
1523        any::<S, _>(frust::SizedBox(Some(0.0), Some(30.0)))
1524    }
1525
1526    fn bar_build<S: 'static>(view: &SelectionAppBarView<S>) -> SelectionAppBarWidget {
1527        let mut counter = 0u64;
1528        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1529    }
1530
1531    fn bar_layout(w: &mut SelectionAppBarWidget, width: f64) -> Size {
1532        let mut tcx = TextContext::new();
1533        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1534        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1535    }
1536
1537    #[test]
1538    fn idle_mode_uses_the_idle_contents_own_height() {
1539        let selected = BTreeSet::new();
1540        let view: SelectionAppBarView<()> =
1541            selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1542        let mut w = bar_build(&view);
1543        let size = bar_layout(&mut w, 300.0);
1544        assert_eq!(size, Size::new(300.0, 30.0));
1545    }
1546
1547    #[test]
1548    fn contextual_mode_height_matches_app_bar_height_metric() {
1549        let selected = BTreeSet::from([0]);
1550        let view: SelectionAppBarView<()> =
1551            selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {}).show_select_all(false);
1552        let mut w = bar_build(&view);
1553        let size = bar_layout(&mut w, 300.0);
1554        assert_eq!(
1555            size.height, APP_BAR_HEIGHT,
1556            "the toolbar band matches appbar.rs's own HEIGHT metric"
1557        );
1558    }
1559
1560    // ---- Window insets: the contextual branch insets like the idle bar ----
1561
1562    /// A phone in landscape under a status bar: 47px side bands, 24px top.
1563    fn notched() -> frust::authoring::WindowInsets {
1564        frust::authoring::WindowInsets::new(
1565            frust::authoring::WindowEdgeInsets::new(47.0, 24.0, 47.0, 0.0),
1566            frust::authoring::WindowEdgeInsets::ZERO,
1567        )
1568    }
1569
1570    fn bar_layout_with(
1571        w: &mut SelectionAppBarWidget,
1572        width: f64,
1573        insets: frust::authoring::WindowInsets,
1574    ) -> Size {
1575        let mut tcx = TextContext::new();
1576        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1577        lctx.with_window_insets(insets, |ctx| {
1578            w.layout(ctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1579        })
1580    }
1581
1582    /// A selection bar over a real idle top app bar, with one 48px action on
1583    /// each branch and no select-all row, so both bands are comparable.
1584    fn real_bar(selected: &BTreeSet<usize>, safe_area: bool) -> SelectionAppBarView<()> {
1585        use frust_widgets::test_support::leaf_any;
1586        let idle = crate::app_bar::<()>("Inbox")
1587            .leading(leaf_any(48.0, 48.0))
1588            .actions(vec![leaf_any(48.0, 48.0)])
1589            .safe_area(safe_area);
1590        selection_app_bar(any(idle), selected, 3, |_: &mut ()| {})
1591            .show_select_all(false)
1592            .actions(vec![leaf_any(48.0, 48.0)])
1593            .safe_area(safe_area)
1594    }
1595
1596    #[test]
1597    fn idle_to_selection_keeps_height_and_slot_offsets_under_window_insets() {
1598        let idle_set = BTreeSet::new();
1599        let selected = BTreeSet::from([0]);
1600
1601        let mut idle = bar_build(&real_bar(&idle_set, true));
1602        let idle_size = bar_layout_with(&mut idle, 800.0, notched());
1603        let mut ctx_w = bar_build(&real_bar(&selected, true));
1604        let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
1605
1606        assert_eq!(idle_size, Size::new(800.0, 24.0 + APP_BAR_HEIGHT));
1607        assert_eq!(ctx_size, idle_size, "the swap keeps the bar's height");
1608
1609        // The contextual slots sit below the top inset and inside the side
1610        // ones, at the idle bar's own leading/trailing rule.
1611        let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
1612        assert_eq!(
1613            close.origin().x,
1614            47.0 + APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
1615        );
1616        assert_eq!(
1617            close.origin().y,
1618            24.0 + (APP_BAR_HEIGHT - close.size().height) / 2.0
1619        );
1620        let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1621        assert_eq!(
1622            action.origin().x + action.size().width,
1623            800.0 - 47.0 - APP_BAR_PAD_X
1624        );
1625        assert_eq!(action.origin().y, 24.0 + (APP_BAR_HEIGHT - 48.0) / 2.0);
1626        let count = &ctx_w.contextual[ctx_w.contextual_slots.count_text];
1627        assert_eq!(
1628            count.origin().x,
1629            47.0 + APP_BAR_PAD_X + ACTION_SLOT + APP_BAR_GAP
1630        );
1631
1632        // The fill covers the whole band, the consumed top inset included.
1633        let mut scene = RectRecorder::default();
1634        let mut pctx = PaintCtx::new(Point::ZERO, ctx_size);
1635        ctx_w.paint(&mut pctx, &mut scene);
1636        assert_eq!(scene.rects[0], (Point::ZERO, ctx_size));
1637    }
1638
1639    #[test]
1640    fn idle_to_selection_stays_continuous_under_a_consuming_safe_area() {
1641        let idle_set = BTreeSet::new();
1642        let selected = BTreeSet::from([0]);
1643        let consumed = notched().consuming(true, true, true, false);
1644
1645        // Inside a consuming scope both branches see zero padding.
1646        let mut idle = bar_build(&real_bar(&idle_set, true));
1647        let idle_size = bar_layout_with(&mut idle, 706.0, consumed);
1648        let mut ctx_w = bar_build(&real_bar(&selected, true));
1649        let ctx_size = bar_layout_with(&mut ctx_w, 706.0, consumed);
1650        assert_eq!(idle_size.height, APP_BAR_HEIGHT);
1651        assert_eq!(ctx_size, idle_size);
1652        let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
1653        assert_eq!(
1654            close.origin().x,
1655            APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
1656        );
1657        let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1658        assert_eq!(
1659            action.origin().x + action.size().width,
1660            706.0 - APP_BAR_PAD_X
1661        );
1662
1663        // Through a real `frust::safe_area`: one 24px pad, never two.
1664        let heights: Vec<f64> = [&idle_set, &selected]
1665            .into_iter()
1666            .map(|set| {
1667                let wrapped = frust::safe_area(real_bar(set, true)).bottom(false);
1668                let mut counter = 0u64;
1669                let mut w = View::<()>::build(&wrapped, &mut BuildCtx::new(&mut counter));
1670                let mut tcx = TextContext::new();
1671                let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1672                lctx.with_window_insets(notched(), |ctx| {
1673                    w.layout(ctx, &BoxConstraints::loose(Size::new(800.0, 400.0)))
1674                })
1675                .height
1676            })
1677            .collect();
1678        assert_eq!(heights, vec![24.0 + APP_BAR_HEIGHT; 2]);
1679    }
1680
1681    #[test]
1682    fn safe_area_false_opts_the_contextual_branch_out_like_the_idle_bar() {
1683        let idle_set = BTreeSet::new();
1684        let selected = BTreeSet::from([0]);
1685        let mut idle = bar_build(&real_bar(&idle_set, false));
1686        let idle_size = bar_layout_with(&mut idle, 800.0, notched());
1687        let mut ctx_w = bar_build(&real_bar(&selected, false));
1688        let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
1689        assert_eq!(idle_size.height, APP_BAR_HEIGHT);
1690        assert_eq!(ctx_size, idle_size);
1691        let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1692        assert_eq!(
1693            action.origin().x + action.size().width,
1694            800.0 - APP_BAR_PAD_X
1695        );
1696
1697        // Flipping the setting is a layout change.
1698        let mut counter = 0u64;
1699        let prev = real_bar(&selected, false);
1700        let next = real_bar(&selected, true);
1701        let flags = View::<()>::rebuild(&next, &prev, &mut ctx_w, &mut BuildCtx::new(&mut counter));
1702        assert!(flags.needs_layout());
1703        assert!(ctx_w.safe_area);
1704    }
1705
1706    #[test]
1707    fn contextual_mode_with_select_all_adds_the_select_all_row_height() {
1708        let selected = BTreeSet::from([0]);
1709        let view: SelectionAppBarView<()> =
1710            selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1711        let mut w = bar_build(&view);
1712        let size = bar_layout(&mut w, 300.0);
1713        assert_eq!(size.height, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT);
1714    }
1715
1716    #[test]
1717    fn unthemed_paint_fills_the_contextual_background_only_in_selection_mode() {
1718        let idle_selected = BTreeSet::new();
1719        let idle: SelectionAppBarView<()> =
1720            selection_app_bar(idle_view(), &idle_selected, 3, |_: &mut ()| {});
1721        let mut idle_w = bar_build(&idle);
1722        bar_layout(&mut idle_w, 300.0);
1723        let mut idle_scene = RectRecorder::default();
1724        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 30.0));
1725        idle_w.paint(&mut ctx, &mut idle_scene);
1726        assert!(
1727            idle_scene.rects.is_empty(),
1728            "idle mode paints no background of its own"
1729        );
1730
1731        let contextual_selected = BTreeSet::from([0]);
1732        let contextual: SelectionAppBarView<()> =
1733            selection_app_bar(idle_view(), &contextual_selected, 3, |_: &mut ()| {});
1734        let mut contextual_w = bar_build(&contextual);
1735        let size = bar_layout(&mut contextual_w, 300.0);
1736        let mut contextual_scene = RectRecorder::default();
1737        let mut ctx = PaintCtx::new(Point::ZERO, size);
1738        contextual_w.paint(&mut ctx, &mut contextual_scene);
1739        // The contextual background fill is always the *first* rect this
1740        // widget itself paints (whatever a child icon button/checkbox
1741        // additionally paints of its own container comes after it).
1742        assert_eq!(contextual_scene.rects[0].1, size);
1743        assert!(!contextual_scene.rects.is_empty());
1744    }
1745
1746    #[derive(Default)]
1747    struct RectRecorder {
1748        rects: Vec<(Point, Size)>,
1749    }
1750    impl PaintScene for RectRecorder {
1751        fn fill_rect(&mut self, o: Point, s: Size, _c: Color) {
1752            self.rects.push((o, s));
1753        }
1754        fn draw_text(&mut self, _o: Point, _t: &str) {}
1755    }
1756
1757    #[test]
1758    fn close_button_press_fires_on_clear() {
1759        #[derive(Default)]
1760        struct S {
1761            cleared: u32,
1762        }
1763        let selected = BTreeSet::from([0]);
1764        let view: SelectionAppBarView<S> =
1765            selection_app_bar(idle_view::<S>(), &selected, 3, |s: &mut S| s.cleared += 1);
1766        let mut w = bar_build(&view);
1767        bar_layout(&mut w, 300.0);
1768
1769        let close_pod = &w.contextual[w.contextual_slots.close];
1770        let center = close_pod.origin()
1771            + Vec2::new(close_pod.size().width / 2.0, close_pod.size().height / 2.0);
1772
1773        let mut state = S::default();
1774        let state_any: &mut dyn Any = &mut state;
1775        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, APP_BAR_HEIGHT));
1776        w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1777        w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1778        assert_eq!(state.cleared, 1);
1779    }
1780
1781    #[test]
1782    fn select_all_reports_binary_flip_treating_partial_as_not_all() {
1783        #[derive(Default)]
1784        struct S {
1785            calls: Vec<bool>,
1786        }
1787
1788        fn bar(selected: &BTreeSet<usize>) -> SelectionAppBarView<S> {
1789            selection_app_bar(idle_view::<S>(), selected, 3, |_: &mut S| {})
1790                .on_all_selected(|s: &mut S, all| s.calls.push(all))
1791        }
1792
1793        fn press_checkbox(w: &mut SelectionAppBarWidget, state: &mut S) {
1794            let (cb_idx, _) = w.contextual_slots.select_all.expect("select-all present");
1795            let pod = &w.contextual[cb_idx];
1796            let center = pod.origin() + Vec2::new(pod.size().width / 2.0, pod.size().height / 2.0);
1797            let state_any: &mut dyn Any = state;
1798            let mut ctx = EventCtx::new(
1799                state_any,
1800                Point::ZERO,
1801                Size::new(300.0, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT),
1802            );
1803            w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1804            w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1805        }
1806
1807        // None selected -> report `true` (select all).
1808        let none = BTreeSet::from([0]); // selection mode needs >=1 to show the bar
1809        let view = bar(&none);
1810        let mut w = bar_build(&view);
1811        bar_layout(&mut w, 300.0);
1812        let mut state = S::default();
1813        press_checkbox(&mut w, &mut state);
1814        assert_eq!(
1815            state.calls,
1816            vec![true],
1817            "a partial selection reports true (not-all)"
1818        );
1819
1820        // All selected -> report `false` (clear).
1821        let all = BTreeSet::from([0, 1, 2]);
1822        let view = bar(&all);
1823        let mut w = bar_build(&view);
1824        bar_layout(&mut w, 300.0);
1825        let mut state = S::default();
1826        press_checkbox(&mut w, &mut state);
1827        assert_eq!(state.calls, vec![false]);
1828    }
1829
1830    #[test]
1831    fn external_clear_collapses_the_bar_back_to_idle() {
1832        // Controlled semantics: no `on_clear` round trip is required to
1833        // leave selection mode — clearing `selected` externally is enough.
1834        let mut counter = 0u64;
1835        let prev_selected = BTreeSet::from([0]);
1836        let prev: SelectionAppBarView<()> =
1837            selection_app_bar(idle_view(), &prev_selected, 3, |_: &mut ()| {});
1838        let mut w = View::<()>::build(&prev, &mut BuildCtx::new(&mut counter));
1839        bar_layout(&mut w, 300.0);
1840        assert!(is_selection_mode(&w.selected));
1841
1842        let next_selected = BTreeSet::new();
1843        let next: SelectionAppBarView<()> =
1844            selection_app_bar(idle_view(), &next_selected, 3, |_: &mut ()| {});
1845        View::<()>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1846        assert!(!is_selection_mode(&w.selected));
1847
1848        let size = bar_layout(&mut w, 300.0);
1849        assert_eq!(
1850            size,
1851            Size::new(300.0, 30.0),
1852            "layout falls back to idle's own height"
1853        );
1854    }
1855
1856    #[test]
1857    fn semantics_reports_count_selected_label_in_contextual_mode() {
1858        fn logic(_s: &mut ()) -> SelectionAppBarView<()> {
1859            let selected = BTreeSet::from([0, 1]);
1860            selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {})
1861        }
1862        let mut root: frust_core::RenderRoot<(), SelectionAppBarView<()>> =
1863            frust_core::RenderRoot::new();
1864        let mut state = ();
1865        root.rebuild(&mut logic, &mut state);
1866        let mut tcx = TextContext::new();
1867        root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
1868        let update = root.semantics();
1869        let (_, node) = update
1870            .nodes
1871            .iter()
1872            .find(|(_, n)| n.role() == Role::TitleBar)
1873            .expect("a TitleBar node is contributed in selection mode");
1874        assert_eq!(node.label(), Some("2 selected"));
1875    }
1876}