Say where a range ends in words, instead of hiding it in a double tap

Touch has had a range gesture for as long as selection mode has: hold to
enter it, then double-tap the far cell. Nobody finds it. It is invisible,
it is unreliable on a grid that scrolls under the second tap, and it
extends from the anchor as it was *before* the two taps moved it — a rule
subtle enough to need two paragraphs of Rust to explain itself.

The deeper problem is the timer. A double tap is bounded by the double-tap
interval, so the two ends have to be on screen together. The ranges that
actually hurt on a tablet are longer than a screenful, and those are
exactly the ones it cannot describe — which is also why a drag-to-select
sweep would not have fixed this, and why it is not what went in.

So: a "Select to…" button arms the range, the strip stops reporting and
starts instructing ("Tap the last photograph — scroll first if you need
to"), and a Cancel puts it down. Nothing is timing the two taps, so the
user may scroll as far as they like between them, and the run is resolved
by the catalog rather than by what happens to be loaded.

An armed range reaches Rust as `cell-pressed`'s shift argument, because
that is what it is: `apply_press` already reads ctrl+shift as "add the run
from the anchor to here", and in selection mode ctrl is already set. So
this needs no Rust state and no third selection policy — one copy of the
rules, in the function that had them.

"Select all" goes in beside it, asked of the catalog for the same reason:
the window is a hundred cells over a library of thousands, and a select-all
that quietly meant "the hundred that are loaded" is a lie the user cannot
see until the export runs. It sets the anchor to the first frame, or a
"Select to…" straight afterwards would reach `apply_press` with no anchor,
take its shift branch, and clear everything it had just taken.

The strip is at four buttons and a count now, so "New collection from
selection" loses its tail — the sheet it opens already says "New collection
holding 12 photographs" across the top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 20:31:38 +02:00
co-authored by Claude Opus 5
parent ab49280614
commit 1cd58bcaaa
4 changed files with 189 additions and 25 deletions
+19 -19
View File
File diff suppressed because one or more lines are too long
+51
View File
@@ -1574,6 +1574,57 @@ 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 "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 to be showing.
//
// Replaces rather than adds. "All" is a statement about the result, not an
// increment, and a user who wanted what they already had 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
// open or a query that failed, 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. Without one, "Select to…"
// straight afterwards reaches `apply_press` with no anchor to
// extend from, and its shift branch *clears* the selection before
// starting a new one — so the tap after a select-all would throw
// away everything it had just taken.
*ctl.anchor.borrow_mut() = Some(0);
ctl.set_cursor(Some(0));
sync_selection(&w, &ctl, &ids);
});
}
// TRACES: FR-CAT-5
// A collection holding exactly what is selected.
//
+2
View File
@@ -291,6 +291,7 @@ export component AppWindow inherits Window {
callback library-toggle-date-range();
/// TRACES: FR-CAT-5
callback library-clear-selection();
callback library-select-all();
callback library-collection-from-selection(string);
/// TRACES: FR-CAT-6
in property <string> library-range-from;
@@ -1380,6 +1381,7 @@ in property <bool> panel-visible: true;
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { root.library-clear-selection(); }
select-all() => { root.library-select-all(); }
collection-from-selection(name) => {
root.library-collection-from-selection(name);
}
+117 -6
View File
@@ -1225,6 +1225,52 @@ export component LibraryGrid inherits Rectangle {
/// TRACES: FR-CAT-5
/// Drop the selection without leaving select mode.
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 rather than from the loaded window, for the
/// same reason `cell-pressed`'s shift argument is: what is on screen is a
/// hundred cells out of thousands, and a "select all" that quietly meant
/// "the hundred that happen to be loaded" is a lie the user cannot see
/// until the export runs.
callback select-all();
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether the next cell tap should take everything from the anchor 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 is a gesture only
/// its author can find. It is invisible, it is unreliable on a grid that
/// scrolls under the second tap, and it extends from the anchor as it was
/// *before* the two taps moved it, a rule subtle enough to need two
/// paragraphs of Rust to explain itself.
///
/// Worse, it is bounded by the double-tap interval, which is a fraction of
/// a second. The ranges that actually hurt on a tablet are longer than a
/// screenful, and those are exactly the ones two quick taps cannot reach —
/// as a drag-to-select sweep could not either, for the same reason.
///
/// This is the same operation made visible and unhurried: a button arms it,
/// the strip says what the next tap will do, a Cancel puts it down, and the
/// user may scroll as far as they like in between.
///
/// Local to this file, and one-shot — the next press consumes it. Rust
/// needs no state for it, because it reaches `cell-pressed` as the shift
/// argument and lands in `apply_press` as the shift-click it already is.
property <bool> ranging: false;
// A range armed against nothing has no anchor to extend from, and the strip
// that would say it is armed is only drawn while something is selected. So
// "Done" in the header — which drops the selection from outside this strip
// — would otherwise leave the mode armed and invisible, and the next
// ordinary tap would take a run nobody asked for.
changed selected-count => {
if (root.selected-count == 0) {
root.ranging = false;
}
}
/// TRACES: FR-CAT-5
/// Make a new collection, under the given name, holding exactly what is
/// selected. The name arrives from the sheet below rather than being
@@ -2056,9 +2102,42 @@ export component LibraryGrid inherits Rectangle {
spacing: Theme.gap-sm;
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 whole failing of the double tap this replaces.
if root.ranging: Value {
text: "Tap the last photograph";
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
// The half of the instruction that is easy to miss, and the
// half that makes this better than a sweep: the two taps are
// not one gesture, so nothing is timing them.
if root.ranging: Caption {
text: "scroll first if you need to";
vertical-alignment: center;
overflow: elide;
}
if root.ranging: Rectangle { horizontal-stretch: 1; }
// 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
// act on.
Value {
if !root.ranging: Value {
text: root.selected-count + (root.selected-count == 1
? " photograph selected" : " photographs selected");
modified: true;
@@ -2067,17 +2146,37 @@ export component LibraryGrid inherits Rectangle {
overflow: elide;
}
Rectangle { width: Theme.gap; }
if !root.ranging: Rectangle { width: Theme.gap; }
// The way out that is not "undo every tap". Distinct from
// "Done", which leaves select mode entirely: clearing keeps the
// mode, so the next selection can start straight away.
Button {
if !root.ranging: Button {
text: "Clear";
y: (parent.height - self.height) / 2;
clicked => { root.clear-selection(); }
}
// 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 these three" is a far commoner 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
// took four steps: make a collection, find it, select the
// photographs again, add them. One press instead, which is how
@@ -2085,8 +2184,8 @@ export component LibraryGrid inherits Rectangle {
// No longer "…from selection": the sheet it opens says
// "New collection holding 12 photographs" at the top, so a
// button repeating it is width the strip does not have on a
// tablet in portrait.
Button {
// tablet in portrait, beside three others and a count.
if !root.ranging: Button {
text: "New collection";
primary: true;
y: (parent.height - self.height) / 2;
@@ -2837,11 +2936,23 @@ export component LibraryGrid inherits Rectangle {
// over from the gesture, handed back as a new
// press. See `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
// there stays one copy of the rules.
root.cell-pressed(
i,
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) {