Make the range gesture visible, and offer the whole grid at once

Touch has had a range gesture for as long as selection mode has: double-tap the
far end. It is invisible, it is unreliable on a grid that scrolls under the
second tap, and it extends from the anchor *before* the two taps moved it — a
rule subtle enough that the code needs two paragraphs to explain it to itself.
Nobody who was not told about it has ever used it.

"Select to…" is the same operation with state you can see. Press it, the strip
stops reporting and says "Tap the last photograph", and the next cell taken is
the far end. It reaches Rust as shift on `cell-pressed`, so it lands in
`apply_press` as the ctrl+shift it already is, and there is no third selection
policy to keep in step with the other two.

This is deliberately not the sweep gesture. A drag that paints cells can only
reach what is on screen, and the ranges that hurt on a tablet are longer than a
screenful — between the two taps here the user may scroll as far as they like,
and the run is resolved by the catalog rather than by what happened to be
loaded. A sweep is still worth having for short runs; it is not what this
should have rested on.

"Select all" beside it, asked of the catalog for the same reason: a select-all
that quietly meant "the hundred cells that happen to be loaded" is a lie the
user cannot see until the export runs.

The double-tap stays. It is tested, and an accelerator that costs nothing is
worth keeping for whoever has already learnt it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 23:18:01 +02:00
co-authored by Claude Opus 5
parent d1f3b97245
commit 9d11554c71
4 changed files with 174 additions and 25 deletions
+20 -20
View File
File diff suppressed because one or more lines are too long
+49
View File
@@ -1574,6 +1574,55 @@ pub fn wire<S, R, P, C>(
}); });
} }
// TRACES: FR-CAT-5 | FR-UI-4
// Everything the grid is showing.
//
// Asked of the catalog through `span`, not read off the loaded window, for
// the reason spelled out in `apply_press`: the window is a hundred cells
// over a library of thousands, and a "select all" that quietly meant
// "select the hundred that happen to be loaded" is a lie the user cannot
// see until the export runs. `library_total` is the count the same scope
// and filter produced, so the run is the whole of what the grid claims.
//
// Replaces rather than adds. "All" is a statement about the result, not an
// increment, and a user who wanted the rest kept would not have reached for
// this.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_select_all(move || {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
let total = w.get_library_total().max(0) as usize;
if total == 0 {
return;
}
// The loaded window is the fallback, not the answer — the same
// trade the shift-click path makes. An empty span means no library
// or a failed query, and selecting what is on screen beats
// selecting nothing.
let all = match ctl.span(0, total - 1) {
run if run.is_empty() => ids.clone(),
run => run,
};
{
let mut selection = ctl.selection.borrow_mut();
selection.clear();
selection.extend(all);
}
// The first frame becomes the anchor, so a "Select to…" straight
// afterwards describes a range from the top rather than from
// wherever the last individual tap left it — which, after taking
// everything, is not a place the user is still thinking about.
*ctl.anchor.borrow_mut() = Some(0);
sync_selection(&w, &ctl, &ids);
});
}
// TRACES: FR-CAT-5 // TRACES: FR-CAT-5
// A collection holding exactly what is selected. // A collection holding exactly what is selected.
// //
+2
View File
@@ -309,6 +309,7 @@ export component AppWindow inherits Window {
callback library-toggle-date-range(); callback library-toggle-date-range();
/// TRACES: FR-CAT-5 /// TRACES: FR-CAT-5
callback library-clear-selection(); callback library-clear-selection();
callback library-select-all();
callback library-collection-from-selection(string); callback library-collection-from-selection(string);
/// TRACES: FR-CAT-6 /// TRACES: FR-CAT-6
in property <string> library-range-from; in property <string> library-range-from;
@@ -1440,6 +1441,7 @@ in property <bool> panel-visible: true;
range-active: root.library-range-active; range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); } toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { root.library-clear-selection(); } clear-selection() => { root.library-clear-selection(); }
select-all() => { root.library-select-all(); }
collection-from-selection(name) => { collection-from-selection(name) => {
root.library-collection-from-selection(name); root.library-collection-from-selection(name);
} }
+103 -5
View File
@@ -1242,6 +1242,48 @@ export component LibraryGrid inherits Rectangle {
/// TRACES: FR-CAT-5 /// TRACES: FR-CAT-5
/// Drop the selection without leaving select mode. /// Drop the selection without leaving select mode.
callback clear-selection(); callback clear-selection();
/// TRACES: FR-CAT-5 | FR-UI-4
/// Take everything the grid is currently showing — the whole library, or
/// the whole of whatever it is scoped and filtered to. Answered from the
/// catalog, not from the loaded window, for the reason `cell-pressed`'s
/// shift argument is: what is on screen is a fraction of what is meant.
callback select-all();
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether the next cell tap should take everything from the last cell
/// tapped to it.
///
/// **Why this exists.** Touch has had a range gesture for as long as
/// selection mode has — double-tap the far end — and it was a gesture only
/// its author could find. It is invisible, it is unreliable on a grid that
/// scrolls under the second tap, and it extends from the anchor *before*
/// the two taps moved it, a rule subtle enough to need two paragraphs of
/// Rust to explain to itself.
///
/// This is the same operation made visible: a button that arms it, a strip
/// that says what the next tap will do, and a way out. It also does the one
/// thing a drag-to-select sweep could not — the user may scroll as far as
/// they like between the two taps, and the run is resolved by the catalog
/// rather than by what happens to be on screen. The ranges that hurt on a
/// tablet are longer than a screenful, which is exactly where a sweep runs
/// out.
///
/// Local to this file, and one-shot: the next press consumes it. Rust needs
/// no state for it, because it arrives as `cell-pressed`'s shift argument
/// and lands in `apply_press` as the shift-click it already knows how to
/// apply.
property <bool> ranging: false;
// A range armed against nothing has no anchor to extend from, and the strip
// that says it is armed is only drawn while there is a selection — so
// "Done" in the header, which drops the selection from outside that strip,
// would leave the mode armed and invisible, and the next ordinary tap would
// take a run the user never asked for.
changed selected-count => {
if (root.selected-count == 0) {
root.ranging = false;
}
}
/// TRACES: FR-CAT-5 /// TRACES: FR-CAT-5
/// Make a new collection, under the given name, holding exactly what is /// Make a new collection, under the given name, holding exactly what is
/// selected. The name arrives from the sheet below rather than being /// selected. The name arrives from the sheet below rather than being
@@ -2077,9 +2119,33 @@ export component LibraryGrid inherits Rectangle {
spacing: Theme.gap-sm; spacing: Theme.gap-sm;
alignment: start; alignment: start;
// While a range is armed the strip stops reporting and starts
// instructing. The count is still true, but it is not what the
// user needs to read: they have pressed something that changes
// what the *next* tap means, and a mode with no visible state
// is the double-tap gesture this replaces.
if root.ranging: Value {
text: "Tap the last photograph";
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
if root.ranging: Rectangle { width: Theme.gap; }
// Armed by mistake, or armed and thought better of. Without
// this the only way out is to tap a cell, which takes a run the
// user did not want and then has to be undone by hand.
if root.ranging: Button {
text: "Cancel";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = false; }
}
// State rather than a label: it is what the buttons beside it // State rather than a label: it is what the buttons beside it
// act on. // act on.
Value { if !root.ranging: Value {
text: root.selected-count + (root.selected-count == 1 text: root.selected-count + (root.selected-count == 1
? " photograph selected" : " photographs selected"); ? " photograph selected" : " photographs selected");
modified: true; modified: true;
@@ -2088,17 +2154,37 @@ export component LibraryGrid inherits Rectangle {
overflow: elide; overflow: elide;
} }
Rectangle { width: Theme.gap; } if !root.ranging: Rectangle { width: Theme.gap; }
// The way out that is not "undo every tap". Distinct from // The way out that is not "undo every tap". Distinct from
// "Done", which leaves select mode entirely: clearing keeps the // "Done", which leaves select mode entirely: clearing keeps the
// mode, so the next selection can start straight away. // mode, so the next selection can start straight away.
Button { if !root.ranging: Button {
text: "Clear"; text: "Clear";
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
clicked => { root.clear-selection(); } clicked => { root.clear-selection(); }
} }
// Everything the grid is showing. Cheap to offer and tedious to
// do by hand: a scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those three" is a far
// more common shape than the taps it took to say it.
if !root.ranging: Button {
text: "Select all";
y: (parent.height - self.height) / 2;
clicked => { root.select-all(); }
}
// The visible half of `ranging` — see its declaration for why
// the double-tap it replaces was not good enough. Deliberately
// an unfinished sentence: the ellipsis is the promise that a
// second tap is coming.
if !root.ranging: Button {
text: "Select to…";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = true; }
}
// Filing a selection into a collection that does not exist yet // Filing a selection into a collection that does not exist yet
// took four steps: make a collection, find it, select the // took four steps: make a collection, find it, select the
// photographs again, add them. One press instead, which is how // photographs again, add them. One press instead, which is how
@@ -2107,7 +2193,7 @@ export component LibraryGrid inherits Rectangle {
// "New collection holding 12 photographs" at the top, so a // "New collection holding 12 photographs" at the top, so a
// button repeating it is width the strip does not have on a // button repeating it is width the strip does not have on a
// tablet in portrait. // tablet in portrait.
Button { if !root.ranging: Button {
text: "New collection"; text: "New collection";
primary: true; primary: true;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
@@ -2858,11 +2944,23 @@ export component LibraryGrid inherits Rectangle {
// over from the gesture, handed back as a new // over from the gesture, handed back as a new
// press. See `pinching`. // press. See `pinching`.
if (!root.pinching) { if (!root.pinching) {
// An armed range reaches Rust as shift,
// which is what it is: `apply_press` reads
// ctrl+shift as "add the run from the
// anchor to here", and in selection mode
// ctrl is already set. Sent this way rather
// than as a third selection policy, so the
// rules stay in one place.
root.cell-pressed( root.cell-pressed(
i, i,
ev.modifiers.control || root.select-mode, ev.modifiers.control || root.select-mode,
ev.modifiers.shift, ev.modifiers.shift || root.ranging,
); );
// One shot. The range was between two taps
// and the second has landed; left armed, it
// would turn every tap after it into
// another run.
root.ranging = false;
} }
} }
if (ev.kind == PointerEventKind.up) { if (ev.kind == PointerEventKind.up) {