Split collections_ui.rs into a module directory by area of behaviour

collections_ui.rs had grown to 4,591 lines covering the sidebar controller,
the click/drag selection policy, tree refresh, the drag gesture, the trash
worker, twelve wiring functions, and the row's rename/create/context menu,
all in one file. Split into collections_ui/ with one module per area, the
way develop/ and library/ were already split on this branch:

- controller.rs: CollectionsController and the pure drop/delete/release
  decisions (decide_drop, decide_delete, decide_release, menu_detail,
  delete_warning) that a test can drive without a window.
- press.rs: PressUndo and the click-and-release selection policy
  (apply_press, select_row, commit_press, cancel_press).
- tree_sync.rs: rebuilding the sidebar from the catalog and pushing
  catalog-derived state into the grid (refresh_tree, offline_state,
  sync_lifted/sync_selection/sync_reorderable/sync_badges,
  refresh_membership, direct_holdings).
- drag.rs: the cursor bitmap (compose_drag_image, blit_scaled) and the
  hold/spring timers (arm_hold, arm_spring, should_spring,
  collapse_spring_opened) plus their delay constants.
- trash.rs: the soft delete (start_trash, start_restore, drain_trash,
  stop_trash, refresh_trash, format_bytes).
- wiring_grid.rs / wiring_tree.rs: the wire() entry point and its twelve
  wire_* functions, split in two because together they were the largest
  single piece (grid-facing selection/drag/trash vs. sidebar-facing
  navigation/create/rename/row-drag/menu/membership).
- rename_menu.rs: creating, naming and renaming collections, and the row's
  context menu (apply_rename, create_child, unique_name, open_row_menu,
  close_row_menu, close_rename).

mod.rs carries the module's own top-level doc comment, the `pub use`
re-exports for the eight items the rest of the crate reaches by
`collections_ui::` path (CollectionsController, wire, refresh_tree,
sync_badges, sync_selection, select_row, commit_press, cancel_press), and a
shared `test_support` for the one fixture (`ids`) more than one file's
tests needed. Every item that only crossed a boundary within this module,
not out of it, was narrowed to `pub(super)` rather than kept at the
crate-wide `pub` a single file gave it for free.

Extracted with a brace-aware pass that kept each item's own leading doc
comment and attributes attached to it, and tests moved with the code they
exercise; every TRACES/GESTURE comment lands on the same code it did
before. No file outside the new directory changed — lib.rs's `mod
collections_ui;` resolves to the directory automatically, and every
outside caller's `collections_ui::` path still resolves through mod.rs's
re-exports.
This commit is contained in:
2026-09-20 20:29:21 +02:00
parent e86edef47c
commit 9c8f21b754
11 changed files with 4809 additions and 4602 deletions
+567
View File
@@ -0,0 +1,567 @@
//! Selection and tree state (`CollectionsController`), and the pure
//! decisions a drop, a delete, or a hold-release comes down to.
//!
//! `decide_drop`, `decide_delete` and `decide_release` are kept apart from
//! the callbacks that call them for the reason the crate-level doc gives:
//! the gesture cannot be driven from a test, but the decision can.
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::rc::Rc;
use dr_types::{CollectionId, ImageId};
use super::press::PressUndo;
/// TRACES: FR-CAT-5
/// How a run of grid ordinals is turned into the ids it names.
///
/// See [`CollectionsController::span_source`] for why this is supplied rather
/// than reached for.
type SpanIds = Rc<dyn Fn(usize, usize) -> Vec<ImageId>>;
/// Selection, drag, and tree state for the running window.
///
/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and
/// they all run on the one event-loop thread — the same shape
/// [`crate::library_ui::LibraryController`] uses.
#[derive(Default)]
pub struct CollectionsController {
/// Selected images, by catalog id. A `BTreeSet` rather than a `Vec` so
/// membership tests are cheap during a rubber-band and the order a drop
/// applies in is stable across runs.
pub(super) selection: RefCell<BTreeSet<ImageId>>,
/// Where a shift-click extends from. The last cell *clicked*, not the last
/// added — extending from the far end of a previous range is not what the
/// gesture means anywhere else.
///
/// An **image ordinal in the library**, not a row of the loaded window.
/// The grid is a window over the catalog, so row 7 names a different
/// photograph after every scroll: held as a row, an anchor set before a
/// scroll described a range from wherever that row had since drifted to.
/// Selection is by id for the same reason (see the preamble); this is the
/// same argument applied to the one index that has to survive a move.
pub(super) anchor: RefCell<Option<usize>>,
/// TRACES: FR-UI-2 | FR-UI-4
/// TRACES: FR-CAT-7 | FR-UI-4
/// A photograph the most recent press asked to take *out* of the
/// selection, held until the release says the press was a tap.
///
/// See [`Press::Deferred`] for why removal cannot happen on the press:
/// this is the whole of what stops a drag of forty photographs carrying
/// one. Overwritten by the next press and dropped by the drag that
/// consumes it, so at most one is ever pending.
pub(super) pending_toggle: std::cell::Cell<Option<ImageId>>,
/// TRACES: FR-UI-4
/// What the selection was immediately before the most recent press, so a
/// gesture that turns out not to have been a press can put it back.
///
/// A press has to act immediately — the drag that may follow reads the
/// selection to build its payload, so deciding on release is too late.
/// That is right for a drag and wrong for a pinch, which begins as an
/// ordinary one-finger press and only becomes a pinch when the second
/// finger lands. By then a cell has been selected that the user never
/// meant to touch; they were reaching for the grid with two fingers.
///
/// Cheap to keep: a few hundred ids at most, cloned once per press.
pub(super) press_undo: RefCell<Option<PressUndo>>,
/// Where the keyboard is, as an image ordinal.
///
/// Distinct from the anchor, and it has to be: shift+arrow grows a range
/// *from* the anchor *to* the cursor, so the two are the two ends and
/// cannot be one field. `None` until the user has taken hold of the grid,
/// so the first arrow press starts from what is on screen rather than
/// jumping to the top of the library.
pub(super) cursor: std::cell::Cell<Option<usize>>,
/// Whether the press that began the current gesture carried ctrl or shift.
///
/// A modified click is a *selection* gesture and must not also open the
/// image: building a selection would otherwise throw the user into the
/// develop view on the second ctrl-click. Slint does not report modifiers on
/// `clicked`, so the press records them and the click consults this.
pub(super) modified_press: std::cell::Cell<bool>,
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether a tap in the grid selects rather than opens.
///
/// Touch has no ctrl and no shift, so without a mode there is no way to
/// select a second photograph: the first tap would open the first one. In
/// this mode a plain press is reported as a ctrl-press and goes through the
/// same [`apply_press`] as everything else — a separate touch policy would
/// be a second copy of these rules to keep in step.
pub(super) select_mode: std::cell::Cell<bool>,
/// The timer that turns a held cell into a selection.
///
/// Here rather than in `.slint` because Slint has no long-press gesture and
/// a hand-rolled one would need a `Timer` element per visible cell — a
/// hundred timers to answer a question about one finger. Held so that
/// dropping it cancels: a press that ends, or is taken by the Flickable
/// when the finger travels, must not arrive as a selection a moment later.
pub(super) hold_timer: RefCell<Option<slint::Timer>>,
/// Collection ids parallel to the sidebar's rows, so a hovered row index
/// resolves to an id without another query.
pub(super) row_ids: RefCell<Vec<CollectionId>>,
/// Which rows are saved filters, so a drop onto one is refused *before* the
/// release rather than after.
pub(super) row_smart: RefCell<Vec<bool>>,
/// Which rows have children, so the spring knows there is anything to open.
pub(super) row_has_children: RefCell<Vec<bool>>,
/// Collapsed collections, by id. Collapse is a view preference and
/// deliberately not persisted to the catalog — it is not something to sync
/// between devices.
pub(super) collapsed: RefCell<std::collections::HashSet<CollectionId>>,
/// Which collection scopes the grid. `None` is the whole library.
pub(super) scope: RefCell<Option<CollectionId>>,
/// The collection whose name is being edited in the sidebar, if any.
///
/// Held here rather than in Slint because a rename can also be *started*
/// from Rust — creating a collection opens its field — and because a
/// commit that the catalog refuses has to leave the field open on the name
/// the user typed rather than silently closing over a rejected edit.
pub(super) renaming: RefCell<Option<CollectionId>>,
/// Whether the grid is showing the trash rather than the library.
///
/// Separate from `scope` because the trash is not a collection: its contents
/// come from `trashed_at`, not from membership, and every other query in the
/// library *excludes* exactly what this view exists to show. Folding it into
/// `scope` as a sentinel id would put that inversion inside a type that
/// means "a collection".
pub(super) viewing_trash: std::cell::Cell<bool>,
/// The live drag: what it carries. Empty means no drag.
pub(super) dragging: RefCell<Vec<ImageId>>,
/// The collection being dragged, when the drag is a tree rearrangement
/// rather than a filing of photographs.
///
/// Set when the drag starts and read by the drop, the same way `dragging`
/// works — the drop callback carries only the target's id, so what is being
/// dropped has to be remembered rather than inspected.
pub(super) dragging_collection: RefCell<Option<CollectionId>>,
/// TRACES: FR-UI-3 | FR-UI-4
/// The collection a hold has picked up, and the timer that picks it up.
///
/// The tree is inside a Flickable, which claims any drag beginning inside
/// it — so with a finger, a drag on a row is a scroll unless something says
/// otherwise first. The hold is that something. It arms the drag *and*
/// opens the row menu, and which of the two the user gets is decided by
/// whether they then moved: the same fork the grid already uses to tell a
/// hold-to-select from a drag-to-file.
pub(super) lifted: RefCell<Option<CollectionId>>,
/// Whether the lifted row's drag actually began. Reset by the press that
/// arms the next one, so a release can tell a rearrangement from a menu.
pub(super) drag_began: std::cell::Cell<bool>,
/// The hold timer for a sidebar row. One, replaced per press, so a press
/// that became a scroll leaves nothing queued to fire over the list the
/// user is now scrolling.
pub(super) row_hold_timer: RefCell<Option<slint::Timer>>,
/// Whether each visible row has a parent, in `row_ids` order — what decides
/// whether "All photographs" lights up as a drop target. Kept beside the
/// rows it indexes rather than queried per drag: `refresh_tree` already has
/// the parent in hand, and a query would answer for a tree that may have
/// been rebuilt since.
pub(super) row_nested: RefCell<Vec<bool>>,
/// The collection the row menu is open on.
///
/// Held here as well as in the window's title property because the two say
/// different things: the property is what the sheet *draws*, and this is
/// what every action *acts on*. A rename committed from the menu changes
/// the first and must not change the second.
pub(super) menu_for: RefCell<Option<CollectionId>>,
/// Whether the menu's delete has been asked once and is waiting to be
/// confirmed.
///
/// A `Cell` rather than a window property alone so the decision is made in
/// Rust: the sheet must not be able to reach the destructive branch by
/// flipping a bit of its own.
pub(super) menu_confirming: std::cell::Cell<bool>,
/// The collection a drag is currently over, by id.
///
/// Only the spring needs this — the *drop* is hit-tested by Slint and
/// arrives with its own id, so nothing here has to remember where the
/// pointer was.
pub(super) hover_id: RefCell<Option<CollectionId>>,
/// Spring-loaded expansion: the timer that opens a collapsed parent the
/// pointer has been dwelling on mid-drag.
///
/// One timer, restarted per row, so moving on cancels the pending
/// expansion rather than leaving a queue of them to fire later.
pub(super) spring_timer: RefCell<Option<slint::Timer>>,
/// Collections the spring opened during *this* drag, so they can be closed
/// again if the drag ends elsewhere. Without this, dragging across a deep
/// tree leaves every parent it passed over hanging open.
pub(super) spring_opened: RefCell<Vec<CollectionId>>,
/// Images dropped on the trash, recorded by `dropped-on-trash` and acted on
/// in `drag-finished` — the same deferral, for the same reason.
pub(super) trash_requested: RefCell<Option<Vec<ImageId>>>,
/// Drains a trash worker. Held so a second operation replaces the first
/// rather than two timers fighting over the same model.
pub(super) trash_timer: RefCell<Option<slint::Timer>>,
/// Which collection a drop landed on, recorded by `dropped` and acted on in
/// `drag-finished`.
///
/// Deferred because every consequence of a drop replaces a Slint model —
/// the tree, the grid cells — and doing that from inside the `dropped`
/// handler destroys the elements Slint is still using to deliver the event.
pub(super) dropped_on: RefCell<Option<CollectionId>>,
/// TRACES: FR-CAT-5
/// How a run of grid ordinals is turned into ids.
///
/// Supplied by [`crate::library_ui`] at wiring time, because the catalog,
/// the scope and the rating filter — everything the run depends on — belong
/// to the grid's controller, not to this one. Held as a closure rather than
/// reached for through a handle so the selection rules stay testable with
/// no library open: the tests pass a run that reads a plain slice.
///
/// `None` before wiring, which the caller reads as "nothing to ask" and
/// falls back to the loaded window.
pub(super) span_source: RefCell<Option<SpanIds>>,
/// Where the trash workers report what they are doing.
///
/// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in
/// one list, and the user asking what the application is busy with does not
/// care which module started them.
pub(super) activity: Rc<crate::activity::ActivityLog>,
}
/// What a release over a collection row means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Drop {
/// File these photographs in the target.
FileImages(Vec<ImageId>),
/// Move this collection under the target.
Reparent(CollectionId),
/// Nothing to do — an empty drag, or a row dropped on itself.
Nothing,
}
/// Decide what a drop on `target` should do.
///
/// Separated from the callback because the gesture cannot be driven from a
/// test — Slint owns it — while this decision is where it can actually go
/// wrong. The order matters: an image drag always fills `carried`, so
/// photographs can never be mistaken for a rearrangement, and only an empty
/// payload consults the row remembered from the press.
pub fn decide_drop(
carried: &[ImageId],
pressed_row: Option<CollectionId>,
target: CollectionId,
) -> Drop {
if !carried.is_empty() {
return Drop::FileImages(carried.to_vec());
}
match pressed_row {
// A collection cannot go inside itself. Caught here as well as in the
// catalog so the common case is a no-op rather than an error message.
Some(source) if source != target => Drop::Reparent(source),
_ => Drop::Nothing,
}
}
/// TRACES: FR-CAT-7
/// What the row menu's Delete should do on this press.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteStep {
/// Delete it now. Nothing is lost that the user cannot see is nothing.
Now,
/// Say what goes, and wait to be asked again.
Confirm,
}
/// TRACES: FR-CAT-7
/// Whether deleting this collection needs to be confirmed first.
///
/// The gesture this replaced refused outright whenever the collection held
/// anything, which made a full collection undeletable without emptying it by
/// hand — child by child, since a parent counts its descendants. Refusing is
/// not a safety property; it is the absence of one, because the user goes and
/// does the same thing the long way round.
///
/// So: an empty collection goes on the first press, because there is nothing
/// to warn about and a dialogue asking "delete this empty thing?" is the kind
/// that teaches people to dismiss dialogues. Anything else is asked once.
///
/// Split from the handler because this is the whole of the safety rule, and a
/// handler needs a window to run.
pub fn decide_delete(holds: usize, children: usize, confirmed: bool) -> DeleteStep {
if confirmed || (holds == 0 && children == 0) {
DeleteStep::Now
} else {
DeleteStep::Confirm
}
}
/// TRACES: FR-CAT-7
/// The line under the menu's title: what this collection directly holds.
///
/// Direct members and direct children, not the sidebar's deep count, because
/// this line sits above a Delete — and delete drops *this* collection's member
/// rows while promoting its children rather than taking them. A number here
/// that counted descendants' photographs would be describing something the
/// button below it does not do.
pub fn menu_detail(holds: usize, children: usize) -> String {
let photos = match holds {
0 => "no photographs".to_string(),
1 => "1 photograph".to_string(),
n => format!("{n} photographs"),
};
match children {
0 => photos,
1 => format!("{photos} · 1 collection inside"),
n => format!("{photos} · {n} collections inside"),
}
}
/// TRACES: FR-CAT-7
/// What deleting this collection actually does, in full.
///
/// Every clause here is one a user has got wrong about a collections feature
/// before: that deleting a collection deletes the photographs (it does not —
/// membership is a join table), and that deleting a parent takes the
/// collections nested in it (it does not — [`dr_catalog::collections::delete`]
/// promotes them, because losing a subtree because its container was tidied
/// away is not recoverable).
pub fn delete_warning(name: &str, holds: usize, children: usize) -> String {
let mut out = format!("Deleting “{name}” ");
match (holds, children) {
(0, _) => out.push_str("removes it from the sidebar."),
(1, _) => out.push_str("takes 1 photograph out of it."),
(n, _) => out.push_str(&format!("takes {n} photographs out of it.")),
}
if holds > 0 {
out.push_str(" They stay in your library and in every other collection they are in.");
}
match children {
0 => {}
1 => {
out.push_str(" The collection inside it moves up one level rather than going with it.")
}
n => out.push_str(&format!(
" The {n} collections inside it move up one level rather than going with it."
)),
}
out
}
/// TRACES: FR-UI-3 | FR-UI-4
/// What letting go of a held collection row means.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Release {
/// The hold fired and nothing moved: the user is asking what can be done
/// to this collection.
OpenMenu(CollectionId),
/// Either the hold never fired — an ordinary tap, which selects — or it
/// did and the row was then dragged, in which case the drop has already
/// done the work.
Nothing,
}
/// TRACES: FR-UI-3 | FR-UI-4
/// Decide what a release does, from what the press turned into.
///
/// The whole of the fork that lets one gesture mean two things, and the reason
/// it is worth a test: get it wrong in the direction of `OpenMenu` and every
/// rearrangement ends with a sheet over the tree the user just tidied; get it
/// wrong the other way and the menu is unreachable with a finger.
pub fn decide_release(held: Option<CollectionId>, dragged: bool) -> Release {
match held {
Some(id) if !dragged => Release::OpenMenu(id),
_ => Release::Nothing,
}
}
impl CollectionsController {
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
Rc::new(Self {
activity,
..Default::default()
})
}
/// Remember which row was pressed, so a drag that follows knows what it is
/// carrying.
///
/// Recorded on the press rather than at the drag's start because Slint
/// builds the payload through a `pure` binding, which must not have side
/// effects — and the drop callback is handed only the *target's* id, so the
/// source has to be remembered somewhere.
pub fn note_row_press(&self, id: CollectionId) {
*self.dragging_collection.borrow_mut() = Some(id);
}
/// Which collection the grid is scoped to, for [`crate::library_ui`] to
/// build its query from.
pub fn scope(&self) -> Option<CollectionId> {
*self.scope.borrow()
}
/// Whether the grid should be showing the trash.
pub fn viewing_trash(&self) -> bool {
self.viewing_trash.get()
}
/// Whether the gesture in progress began with ctrl or shift held.
///
/// A modified click selects and nothing more — opening the image as well
/// would eject the user from the grid they are selecting in.
pub fn press_was_modified(&self) -> bool {
self.modified_press.get()
}
/// Selected image ids, in a stable order.
pub fn selected(&self) -> Vec<ImageId> {
self.selection.borrow().iter().copied().collect()
}
/// TRACES: FR-CAT-5
/// The ids of grid rows `first..=last`, in the order the grid shows them.
///
/// Empty when nothing has been wired in — see [`Self::span_source`].
pub(super) fn span(&self, first: usize, last: usize) -> Vec<ImageId> {
let source = self.span_source.borrow().clone();
source.map(|f| f(first, last)).unwrap_or_default()
}
/// Drop the selection — after a scrub, or when the scope changes.
///
/// Selection is by id and survives a window change, but a selection the
/// user cannot see is a selection they will act on by accident. Clearing on
/// a deliberate navigation is the safer of the two behaviours.
pub fn clear_selection(&self) {
self.selection.borrow_mut().clear();
*self.anchor.borrow_mut() = None;
self.cursor.set(None);
}
/// Where the keyboard cursor is, as an image ordinal.
pub fn cursor(&self) -> Option<usize> {
self.cursor.get()
}
pub fn set_cursor(&self, at: Option<usize>) {
self.cursor.set(at);
}
}
#[cfg(test)]
mod tests {
use crate::collections_ui::test_support::*;
use super::*;
#[test]
fn dragging_a_collection_onto_another_moves_it() {
// The gesture the tree rearrangement exists for: no images carried, a
// row remembered from the press, dropped somewhere else.
assert_eq!(
decide_drop(&[], Some(CollectionId(7)), CollectionId(9)),
Drop::Reparent(CollectionId(7))
);
}
#[test]
fn photographs_are_filed_even_when_a_row_was_pressed_first() {
// Clicking a collection and then dragging photographs into another must
// file them, not move the collection that happens to be remembered.
// This is the ordering the whole discrimination rests on.
assert_eq!(
decide_drop(&ids(3), Some(CollectionId(7)), CollectionId(9)),
Drop::FileImages(ids(3))
);
}
#[test]
fn a_collection_dropped_on_itself_does_nothing() {
// The catalog would refuse it as a cycle; catching it here makes the
// commonest slip a no-op rather than an error the user has to read.
assert_eq!(
decide_drop(&[], Some(CollectionId(7)), CollectionId(7)),
Drop::Nothing
);
}
#[test]
fn an_empty_drag_with_nothing_remembered_moves_nothing() {
// A file dragged in from another application lands here with no images
// and no pressed row. It must not disturb the tree.
assert_eq!(decide_drop(&[], None, CollectionId(9)), Drop::Nothing);
}
#[test]
fn an_empty_collection_is_deleted_without_being_asked_about() {
// There is nothing to warn about, and a dialogue asking "delete this
// empty thing?" is the kind that teaches people to dismiss dialogues
// without reading them — including the one that mattered.
assert_eq!(decide_delete(0, 0, false), DeleteStep::Now);
}
#[test]
fn a_collection_holding_anything_is_asked_about_once() {
assert_eq!(decide_delete(12, 0, false), DeleteStep::Confirm);
assert_eq!(decide_delete(0, 1, false), DeleteStep::Confirm);
// And once asked, it goes.
assert_eq!(decide_delete(12, 3, true), DeleteStep::Now);
}
#[test]
fn a_collection_holding_only_children_still_asks() {
// The old gesture refused on `deep_count > 0`, which counted
// descendants' photographs — so an empty parent of empty children was
// deletable in one press and an empty parent of a *full* child was not
// deletable at all. Both are now the same question, asked once.
assert_eq!(decide_delete(0, 2, false), DeleteStep::Confirm);
}
#[test]
fn the_menu_line_counts_what_delete_acts_on() {
assert_eq!(menu_detail(0, 0), "no photographs");
assert_eq!(menu_detail(1, 0), "1 photograph");
assert_eq!(menu_detail(12, 1), "12 photographs · 1 collection inside");
assert_eq!(menu_detail(0, 3), "no photographs · 3 collections inside");
}
#[test]
fn the_warning_says_the_photographs_survive() {
// The single most likely misreading of "Delete" here. If this clause
// ever goes, a user deletes a collection expecting to lose the
// pictures — and either panics, or does not do it at all.
let w = delete_warning("Iceland", 12, 0);
assert!(w.contains("12 photographs"), "{w}");
assert!(w.contains("stay in your library"), "{w}");
}
#[test]
fn the_warning_says_nested_collections_are_promoted_not_taken() {
// `dr_catalog::collections::delete` promotes children to the deleted
// collection's parent rather than cascading. A warning that did not
// say so would describe a data loss that does not happen.
let w = delete_warning("Trips", 0, 2);
assert!(w.contains("2 collections inside it move up"), "{w}");
// And nothing about photographs surviving, because none were lost.
assert!(!w.contains("stay in your library"), "{w}");
}
#[test]
fn a_hold_that_did_not_move_opens_the_menu() {
assert_eq!(
decide_release(Some(CollectionId(4)), false),
Release::OpenMenu(CollectionId(4))
);
}
#[test]
fn a_hold_that_became_a_drag_opens_nothing() {
// The drop has already done the work. A menu here would put a sheet
// over the tree the user has just finished rearranging — and over the
// row they would have to look at to see whether it worked.
assert_eq!(
decide_release(Some(CollectionId(4)), true),
Release::Nothing
);
}
#[test]
fn an_ordinary_tap_opens_nothing() {
// Nothing was ever picked up, so this is the press that selects a
// collection and scopes the grid. A menu on every tap would make the
// sidebar unusable.
assert_eq!(decide_release(None, false), Release::Nothing);
}
}