Skip to main content

Module selection

Module selection 

Source
Expand description

The M3E selection pattern: an index-set selection mode over a list of items, entered by a long-press and exited by clearing the selection, plus the contextual bar that swaps in over an idle app bar while it is active.

§Controller: pure functions, not an owned controller

The reference’s M3ESelectionController is a ChangeNotifier an app mutates in place (select/deselect/toggle/selectAll/clear) and listens to. This crate’s controlled-component convention has no self-mutating retained state to notify from (docs/CODE_STANDARDS.md’s Interaction Semantics: “controlled components never self-mutate”) — so this module ports the same five mutations as pure functions over an app-owned BTreeSet<usize>, each returning the next set for the app to adopt into its own signal: select, deselect, toggle, select_all, cleared. is_selection_mode mirrors the reference’s derived isSelectionMode getter exactly (!selected.is_empty()) — there is no independent “in selection mode” flag anywhere in this module; mode is always recomputed from the live selected set, which is what makes the controlled semantics below hold. is_selected and all_selected_for port the remaining read-only queries (isSelected/allSelectedFor) verbatim.

§Wrapper: selection_host

selection_host wraps arbitrary frust::authoring::AnyView item rows (a list_item/card_list row, or any other content) as a single interactive target per row — the same “the whole row owns capture” convention super::card_list and super::list_item establish. An item passed here should not chain its own on_press (or any other row-level press handling): the host owns Down/Move/Up/Cancel for the whole row and never forwards a pointer event into a captured row’s children (a caller wanting a second, independently-tappable target inside a row — the reference’s tap-the-leading-avatar-to-toggle affordance — is exactly what this port does not carry forward; see Not ported below).

Two outcomes resolve a tap, mutually exclusively, exactly like the reference’s own _onTap/_onLongPress pairing (see example/lib/pages/playground/view/selection_playground.dart’s _SelectionDemoHostState, the reference’s own worked wiring of M3ESelectionController against a plain list):

  • Tap (Up released inside the same row a Down armed): while is_selection_mode reads true, fires SelectionHostView’s on_toggle with the row’s index (_onTap’s selection.toggle(index) branch); otherwise fires on_activate (_onTap’s else branch — the row’s ordinary action, e.g. opening it).
  • Long-press (held past [LONG_PRESS_MS], 500ms): fires on_toggle with the row’s index only when it is not already selected — ported verbatim from _onLongPress’s own guard (if (!isSelected(index)) select(index)), so a long-press can enter or extend selection mode but never exits it by deselecting the row it landed on. Movement past frust::input::TOUCH_SLOP from the initiating Down cancels the long-press candidacy (a drag, not a hold) without cancelling the row’s own tap/toggle resolution at Up.

paint is the only pass carrying a clock in this framework, so the long-press threshold is latched there and fires on the next pointer event that arrives after it (a Move, or Up as the final fallback) — the same frust_core::mark_pending_result_flush-unreachable-from-a-plugin constraint super::toggle_button’s own Long-press section documents at length; this module’s [LongPressState] is that same shape, ported fresh here (docs/PLUGINS_CODE_STANDARDS.md’s design-system charter: no frust-core dependency in production, so frust_widgets::gesture’s on_long_press primitive — which does reach that function — is unreachable from this crate either way).

selection_host paints no chrome of its own (no selection fill, no pressed/hover overlay): the item view a caller supplies owns every visual — a selected row’s checkmark/fill is the caller’s own list_item(...).selected(is_selected(&selected, i)) (or a [crate::checkbox] wired as its leading slot), exactly as the reference’s own _item/_leading builder keys every visual off _selection.isSelected (index) rather than the selection host painting it centrally.

§Contextual bar: selection_app_bar

selection_app_bar wraps an idle app bar (or any other intrinsic- height content) and swaps to a contextual toolbar — a leading close affordance, a selected-count label, caller-supplied trailing actions, and an optional select-all row — whenever is_selection_mode reads true for the selected set it was built with. The swap is a plain structural rebuild (the idle branch is torn down, the contextual one built, or vice versa — frust::authoring::AnyView’s own type-changed rebuild already takes this shape for a single child; here it is two whole sibling sets of children reconciled the same way), not an animated cross-fade: the reference’s own AnimatedSize + AnimatedSwitcher (220ms enter / 90ms exit, Cubic(0.2, 0, 0, 1) / Cubic(0.4, 0, 1, 1)) motion is out of this module’s v1 scope — see Not ported below.

Metrics cited against appbar.rs. The contextual toolbar matches the idle app bar’s own band so the header reads as one continuous surface across the swap: [APP_BAR_HEIGHT]/[APP_BAR_PAD_X]/[APP_BAR_GAP] mirror appbar.rs’s private HEIGHT/PAD_X/GAP exactly (64dp / 4dp / 4dp) — duplicated here since those constants are private to that module, the same citation shape super::toggle_button’s ToggleButtonSize::square_radius uses for a private sibling-module value. This bar is its own surface, not a shared base type over the app bar.

Window insets: the contextual branch insets like the idle bar. An idle crate::app_bar/crate::search_app_bar consumes the top, left and right window insets itself by default (Flutter’s AppBar). The contextual branch does the same: it grows by padding().top, lays its close button, count, actions and select-all row out below it and inside the side insets, and paints its primary_container fill edge to edge through the whole band. So the swap keeps one height and one set of slot offsets in every composition — unwrapped (both branches consume the inset) or under a consuming frust::safe_area(..) (both see 0). The idle view is an opaque AnyView, so its own .safe_area(..) cannot be read from here: this bar mirrors the app bar’s default, and a caller that opts its idle bar out with .safe_area(false) opts this one out too (SelectionAppBarView::safe_area). The contextual branch does not apply the app bar’s window-control corner_shift.

§Controlled semantics

Neither SelectionHostWidget nor SelectionAppBarWidget retains an independent “in selection mode” flag: both recompute is_selection_mode from the selected prop on every rebuild/layout/paint. An app that clears its own selected set externally (a system-back handler, a “done” action elsewhere in the UI) collapses the contextual bar back to idle and restores tap-to-activate on the very next rebuild — no on_clear/on_toggle round trip is needed to leave selection mode, only to enter or extend it (mirrors the reference’s own PopScope guidance: “system back clears selection”, documented on [M3ESelection] → selection_host/selection_app_bar here — see this module’s own doc example on selection_app_bar).

§Not ported

  • M3ESelectionLeading’s 3D Y-axis flip (Transform+Matrix4, a perspective rotation between the unselected and selected leading faces). This framework’s frust::authoring::PaintScene takes only 2D affine paint calls — no perspective/3D primitive exists to port a rotateY flip onto. A caller wanting a selected-state leading visual swaps its own leading AnyView (e.g. an avatar for a [crate::checkbox]) keyed off is_selected, the same shape list_item’s own leading slot already takes any view.
  • The reference’s per-leading tap target (M3ESelectionLeading(onTap: () => controller.toggle(index)), a second independently-tappable zone layered over just the leading slot). selection_host makes the whole row one interactive target, the single-target-per-row convention every other interactive row in this catalog follows (super::list_item, super::card_list).
  • M3ESelection’s Scaffold wrapping (scaffold/backgroundColor/ resizeToAvoidBottomInset) — Flutter-Scaffold-specific chrome this crate has no analogue for; a caller composes selection_app_bar above selection_host in its own layout, the same way an idle app_bar and its body are composed today (no scaffold widget exists in this crate).
  • M3ESelectionScope’s InheritedWidget context resolution (M3ESelectionAppBar’s optional-controller-falls-back-to-scope pattern). Every collaborator here is an explicit prop instead, matching every other component in this crate.
  • The AnimatedSize/AnimatedSwitcher contextual-bar transition — see the Contextual bar section above.

§Attribution

See plugins/material/NOTICE’s “MIT License — Additional Copyright Holders (Vendored Components)” section and its Module Attribution Header Convention. MaterialTokens-only: no new theme-extension type is introduced here (unlike the reference’s own M3ESelectionTheme) — every color/shape resolution below reads Theme/ColorScheme directly, the same pattern list_item/card_list already establish.

Structs§

SelectionAppBarView
A declarative selection-aware app bar: idle content, swapping to a contextual toolbar while selected is non-empty. See the module docs’ Contextual bar section.
SelectionAppBarWidget
The retained widget for a SelectionAppBarView. See the module docs.
SelectionHostView
A declarative selection-mode item wrapper. See the module docs.
SelectionHostWidget
The retained widget for a SelectionHostView. See the module docs.

Functions§

all_selected_for
A tristate value for a select-all control given item_count (M3ESelectionController.allSelectedFor): Some(true) when every index is selected, Some(false) when none are, None when some but not all are.
cleared
The cleared (empty) selection (M3ESelectionController.clear).
deselect
The set with index deselected, if it was selected (M3ESelectionController.deselect).
is_selected
Whether index is selected (M3ESelectionController.isSelected).
is_selection_mode
Whether selected puts the surface in selection mode (M3ESelectionController.isSelectionMode).
select
The set with index selected, if it was not already (M3ESelectionController.select).
select_all
Every index in 0..item_count, selected (M3ESelectionController.selectAll).
selection_app_bar
Create a selection app bar showing idle while selected is empty, and the contextual toolbar (count + close + actions) otherwise. on_clear fires when the close affordance is pressed — the app is the source of truth for selected, so this is the only way this bar’s own close button ever empties it (see the module docs’ Controlled semantics section).
selection_host
Wrap items in the selection gesture router: tap toggles membership while selected is non-empty, otherwise fires on_activate (SelectionHostView::on_activate); a long-press selects an unselected row (see the module docs’ Wrapper section). on_toggle reports the row index whose membership should flip.
toggle
The set with index’s membership flipped (M3ESelectionController.toggle).