Give each touch gesture one meaning, and give tap-to-open back

I broke opening a photograph. The dwell added in "Tell a tap on a
photograph from a hand going past" required a finger to stay down 120 ms,
and a deliberate tap is routinely quicker than that — so the grid stopped
opening anything. Duration was the wrong discriminator: a tap and a brush
are the same length.

**Travel is what separates them, and a graze is by definition a moving
contact.** A press now records where it landed and the release compares:
within 12px it is a tap, beyond that the hand was going somewhere else.
No dwell, so no deliberate tap can be refused, and the rule is the same
for a finger and a mouse — one rule instead of two, and the `touch`
argument the dwell needed goes away with it.

Two real conflicts went with it, because a gesture set that overlaps
itself is unlearnable however each half is documented.

**A drag was also a hold.** Grabbing a cell and moving inside 450 ms left
the hold timer armed underneath the drag, so it fired mid-gesture and put
the grid into selection mode nobody asked for — the drag finished into a
mode that changed what every later tap meant. Starting a drag now cancels
it, exactly as a pinch already did.

**A double tap was also a range.** In selection mode two taps on one cell
selected everything back to where selecting began: no visible state, no
warning, from a thing a hand does by accident. "Select to…" does that job
and announces itself first, so the double tap is gone and two taps are
now two toggles that land where they started. `extend_to_row` went with
it — a second range implementation that only the double tap reached,
where every other range goes through `apply_press`.

The resulting vocabulary, one meaning each: tap opens, tap-and-slide does
nothing, hold starts selecting, drag files, two fingers resize, and while
selecting a tap only ever toggles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:09:37 +02:00
co-authored by Claude Opus 5
parent d70f5c14e0
commit 20c368d3fc
5 changed files with 223 additions and 337 deletions
+52 -270
View File
@@ -157,16 +157,6 @@ pub struct CollectionsController {
/// develop view on the second ctrl-click. Slint does not report modifiers on
/// `clicked`, so the press records them and the click consults this.
modified_press: std::cell::Cell<bool>,
/// TRACES: FR-UI-4
/// When the press that began the current gesture landed, and whether a
/// finger did it.
///
/// Read by the click that follows, to tell a tap from a graze — see
/// [`CollectionsController::press_was_a_graze`]. `None` between gestures,
/// which a click with no press before it is treated as: it cannot have been
/// deliberate if nothing pressed.
pressed_at: std::cell::Cell<Option<std::time::Instant>>,
touch_press: std::cell::Cell<bool>,
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether a tap in the grid selects rather than opens.
///
@@ -343,38 +333,6 @@ impl CollectionsController {
self.modified_press.get()
}
/// TRACES: FR-UI-4
/// Whether the contact that is ending was too brief to have meant anything.
///
/// A hand crossing a tablet on its way to the scroll it intended produces a
/// press and a release a few tens of milliseconds apart, in the same place —
/// indistinguishable, to a `TouchArea`, from a tap, and it was opening
/// whichever photograph happened to be under the knuckle. Travel is already
/// answered (the Flickable claims the pointer and the press is cancelled);
/// what was left is the contact that does not travel and does not last.
///
/// **Only a finger is held to this.** A mouse click is a discrete decision
/// made by a button and is routinely over in thirty milliseconds; applying
/// a dwell to it would make the desktop feel broken to fix a problem the
/// desktop does not have.
///
/// And it only withholds the *open*. The press has already selected the
/// cell under the finger, which is the right failure mode: a graze leaves
/// something visible and reversible on screen rather than silently doing
/// nothing, and rather than throwing the user into develop.
pub fn press_was_a_graze(&self) -> bool {
if !self.touch_press.get() {
return false;
}
match self.pressed_at.get() {
Some(at) => at.elapsed() < std::time::Duration::from_millis(TAP_MIN_MS),
// A click with no press recorded before it. Not a graze — there is
// nothing to say it was one — and refusing it would be the one way
// this rule could make a photograph unopenable.
None => false,
}
}
/// Selected image ids, in a stable order.
pub fn selected(&self) -> Vec<ImageId> {
self.selection.borrow().iter().copied().collect()
@@ -554,29 +512,6 @@ pub fn press_remembering_anchor(
}
}
/// TRACES: FR-UI-2 | FR-UI-4
/// Extend the selection to `row` — the touch form of a shift-click.
///
/// Added to the selection rather than replacing it: this is reached only from
/// selection mode, which the user entered deliberately, and a gesture that
/// silently discarded the run they gathered a moment ago would make gathering
/// two runs impossible.
#[allow(clippy::too_many_arguments)]
pub fn extend_to_row(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
previous: Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
) {
// Falling back to the current anchor makes a double tap with no history
// select just that cell, which is what a double tap already did.
*anchor = previous.or(*anchor);
apply_press(selection, anchor, ids, offset, row, true, true, span);
}
/// Apply a press and push the result into the grid — the whole of what a
/// click, or an arrow key, does to the selection.
///
@@ -1074,22 +1009,6 @@ const SPRING_DELAY_MS: u64 = 500;
/// the hand lets go first, having concluded nothing was going to happen.
pub(crate) const HOLD_DELAY_MS: u64 = 450;
/// TRACES: FR-UI-4
/// How long a finger must stay down before letting go counts as opening a
/// photograph.
///
/// **The floor under a tap, where `HOLD_DELAY_MS` is the ceiling.** Between the
/// two is a tap; below is a graze that only selects; above is a hold that
/// starts a selection. The three have to be one scale or the gesture set stops
/// being learnable.
///
/// 120 ms is a tenth of the hold and about twice a brush. It costs nothing in
/// felt latency because it does not *delay* anything — the open still happens
/// on release, and this only decides whether that release counted — so the
/// error it can make is one-sided: an unusually quick deliberate tap selects
/// instead of opening, and the photograph is one further tap away.
pub(crate) const TAP_MIN_MS: u64 = 120;
/// TRACES: FR-UI-2 | FR-UI-4
/// Start the timer that turns a held cell into a selection.
///
@@ -1559,17 +1478,13 @@ pub fn wire<S, R, P, C>(
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_cell_pressed(move |row, ctrl_held, shift_held, touch| {
window.on_library_cell_pressed(move |row, ctrl_held, shift_held| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
// Consulted by the click that follows: a modified press is building
// a selection and must not also navigate to develop.
ctl.modified_press.set(ctrl_held || shift_held);
// And so is this: how long the contact lasts is what separates a
// tap from a graze, and only the press knows when it started.
ctl.pressed_at.set(Some(std::time::Instant::now()));
ctl.touch_press.set(touch);
// Where the window starts, so the press is recorded as the ordinal
// it is rather than as a row that stops meaning this photograph on
@@ -1591,52 +1506,6 @@ pub fn wire<S, R, P, C>(
});
}
// TRACES: FR-UI-2 | FR-UI-4
// A double tap in selection mode: take everything between the cell the
// selection started from and this one.
//
// This is shift-click, reached by the one gesture touch has left. Hold to
// start selecting, double-tap the far end, and a run of forty photographs
// is three touches — then the whole selection drags onto a collection as
// one, which is the thing this sequence exists to make possible.
//
// The range is *added*, not replaced, so a second run can be picked up
// without losing the first — ctrl+shift's behaviour, and the right one
// here: a mode the user entered deliberately should accumulate rather than
// throw away what they have already gathered.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_cell_double_clicked(move |row| {
let Some(w) = weak.upgrade() else { return };
// Outside selection mode a double tap is two ordinary clicks, and
// the first has already opened the image. Nothing to do.
if !ctl.select_mode.get() {
return;
}
// The hold that would have fired mid-double-tap.
*ctl.hold_timer.borrow_mut() = None;
let ids = visible();
let offset = w.get_library_offset().max(0) as usize;
// Extended from the cell the user started at, not from the one the
// two taps just moved the anchor onto.
extend_to_row(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
ctl.previous_anchor.get(),
&ids,
offset,
row as usize,
&|first, last| ctl.span(first, last),
);
ctl.set_cursor(Some(offset + row as usize));
sync_selection(&w, &ctl, &ids);
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The press ended — lifted, or taken by the Flickable when the finger
// travelled. Either way the hold is off.
@@ -2015,6 +1884,15 @@ pub fn wire<S, R, P, C>(
let Some(w) = weak.upgrade() else { return };
let ids = visible();
// TRACES: FR-UI-4
// A drag is not a hold, and the timer must not outlive the press
// that armed it. Grabbing a cell and moving inside 450 ms left the
// hold armed underneath the drag, so it fired mid-gesture and put
// the grid into selection mode the user had not asked for — the
// drag finished into a mode that changed what every later tap
// meant. The pinch already cancels for exactly this reason.
*ctl.hold_timer.borrow_mut() = None;
// Dragging an *unselected* cell carries only that one, and makes it
// the selection — otherwise the images that travel are not the ones
// the user grabbed. Dragging a selected cell carries the whole
@@ -2801,52 +2679,6 @@ mod tests {
Vec::new()
}
/// TRACES: FR-UI-4
/// A hand brushing the tablet on its way to a scroll must not open a
/// photograph. It still *selects* one — the press did that, and something
/// visible and reversible is the right thing to be left with.
#[test]
fn a_graze_does_not_open_a_photograph() {
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
ctl.touch_press.set(true);
ctl.pressed_at.set(Some(std::time::Instant::now()));
assert!(
ctl.press_was_a_graze(),
"a release in the same instant as the press is not a tap"
);
}
/// And a finger that stayed down is a tap, not a graze.
#[test]
fn a_deliberate_tap_opens_a_photograph() {
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
ctl.touch_press.set(true);
ctl.pressed_at.set(Some(
std::time::Instant::now() - std::time::Duration::from_millis(TAP_MIN_MS + 10),
));
assert!(!ctl.press_was_a_graze());
}
/// The desktop is not held to the dwell. A mouse button is a discrete
/// decision and is routinely down for thirty milliseconds.
#[test]
fn a_mouse_click_is_never_a_graze() {
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
ctl.touch_press.set(false);
ctl.pressed_at.set(Some(std::time::Instant::now()));
assert!(!ctl.press_was_a_graze());
}
/// The floor has to sit under the ceiling, or there is no tap between them:
/// every press would be either a graze or a hold.
#[test]
fn a_tap_has_room_between_a_graze_and_a_hold() {
// A `const` block, so this is a compile error rather than a test
// failure: the two numbers are constants, and a gesture set with no
// room for a tap in it should not get as far as being run.
const { assert!(TAP_MIN_MS < HOLD_DELAY_MS) };
}
#[test]
fn dragging_a_collection_onto_another_moves_it() {
// The gesture the tree rearrangement exists for: no images carried, a
@@ -2977,6 +2809,48 @@ mod tests {
assert_eq!(sel.len(), 3, "rows 10..=12");
}
/// TRACES: FR-UI-2 | FR-UI-4
/// Two taps on one cell put it back where it started, and take nothing else.
///
/// This is the behaviour that replaced the double-tap range. That gesture
/// selected everything between the cell and wherever the selection began —
/// silently, with no visible state, from a thing a hand does by accident.
/// "Select to…" does the same job and says so first, so a double tap is now
/// two toggles and nothing more: the only outcome a user can predict from
/// what is on the screen.
#[test]
fn two_taps_on_one_cell_cancel_out_and_take_no_range() {
let all = ids(30);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
let mut previous = None;
// Selecting began somewhere else, so a range gesture would have had an
// anchor to sweep from.
press_remembering_anchor(
&mut sel,
&mut anchor,
&mut previous,
&all,
0,
4,
false,
false,
&span,
);
assert_eq!(sel.len(), 1);
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
assert_eq!(
sel.iter().copied().collect::<Vec<_>>(),
vec![ImageId(5)],
"a double tap took a run instead of toggling one cell twice"
);
}
/// One tap in selection mode: a press reported as ctrl-held, which is what
/// `library.slint` sends while the mode is on.
fn tap(
@@ -2999,98 +2873,6 @@ mod tests {
);
}
#[test]
fn a_double_tap_takes_the_run_between_it_and_where_selecting_began() {
// The whole touch gesture, in the order a finger performs it: hold one
// photograph to start selecting, then double-tap the far end of the run.
// Both taps of that double tap land on the same cell — the first turns
// it on, the second turns it off — and the double tap that follows has
// to select the range anyway.
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
let mut previous = None;
// The long press: an ordinary plain press, which is what the cell got
// before the hold timer fired.
press_remembering_anchor(
&mut sel,
&mut anchor,
&mut previous,
&all,
0,
4,
false,
false,
&span,
);
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
assert!(
!sel.contains(&ImageId(12)),
"the two taps cancelled out, which is what makes the double tap's \
job to select the range rather than to add one cell"
);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 11, &span);
assert_eq!(sel.len(), 8, "rows 4..=11");
assert!(sel.contains(&ImageId(5)) && sel.contains(&ImageId(12)));
}
#[test]
fn a_double_tap_keeps_a_run_gathered_earlier() {
// Two runs, which is why the extension unions rather than replaces: a
// user in selection mode is gathering, and the second gesture must not
// throw away the first.
let all = ids(30);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
let mut previous = None;
press_remembering_anchor(
&mut sel,
&mut anchor,
&mut previous,
&all,
0,
0,
false,
false,
&span,
);
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 3, &span);
assert_eq!(sel.len(), 4, "rows 0..=3");
// A second run, begun with a plain tap somewhere else.
tap(&mut sel, &mut anchor, &mut previous, &all, 20);
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 25, &span);
assert_eq!(sel.len(), 10, "rows 0..=3 and 20..=25");
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(26)));
}
#[test]
fn a_double_tap_with_nothing_to_extend_from_selects_only_that_cell() {
// The degenerate case: selection mode entered from the header's button
// rather than by holding a cell, so nothing has anchored yet.
let all = ids(10);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
let previous = None;
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 6, &span);
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(7)]);
}
#[test]
fn ctrl_shift_adds_a_second_range_to_the_selection() {
// Picking up a second run without losing the first: the one case where
+1 -7
View File
@@ -4527,13 +4527,7 @@ pub fn wire<F>(
if coll_for_click.press_was_modified() {
return;
}
// TRACES: FR-UI-4
// And a graze is not a tap. The press has already selected the cell,
// so the user sees what they touched; what they do not get is a
// photograph opened by a hand on its way past.
if coll_for_click.press_was_a_graze() {
return;
}
let path = ctl.paths.borrow().get(i as usize).cloned();
if let Some(path) = path {
// Leave the grid for the develop view. The status bar's