Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel was already answered — the Flickable claims the pointer and the press is cancelled — but a contact that neither travels nor lasts reaches a TouchArea as an ordinary press and release, and it was landing the user in develop. So a finger now has to stay down for `TAP_MIN_MS` before letting go counts as opening anything. That is the floor under a tap where the 450 ms `HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above is a hold that starts a selection. One scale, three gestures. Only a finger is held to it. A mouse click is a discrete decision made by a button and is routinely over in thirty milliseconds, so `cell-pressed` now reports whether a finger did it — the same finger-id convention the pinch arbitration beside it already uses — and the dwell applies to touch alone. A graze still *selects* the cell it landed on, because the press already did that. That is the right failure mode: something visible and reversible rather than a silent nothing, and rather than develop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,6 +157,16 @@ pub struct CollectionsController {
|
|||||||
/// develop view on the second ctrl-click. Slint does not report modifiers on
|
/// develop view on the second ctrl-click. Slint does not report modifiers on
|
||||||
/// `clicked`, so the press records them and the click consults this.
|
/// `clicked`, so the press records them and the click consults this.
|
||||||
modified_press: std::cell::Cell<bool>,
|
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
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
/// Whether a tap in the grid selects rather than opens.
|
/// Whether a tap in the grid selects rather than opens.
|
||||||
///
|
///
|
||||||
@@ -333,6 +343,38 @@ impl CollectionsController {
|
|||||||
self.modified_press.get()
|
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.
|
/// Selected image ids, in a stable order.
|
||||||
pub fn selected(&self) -> Vec<ImageId> {
|
pub fn selected(&self) -> Vec<ImageId> {
|
||||||
self.selection.borrow().iter().copied().collect()
|
self.selection.borrow().iter().copied().collect()
|
||||||
@@ -1032,6 +1074,22 @@ const SPRING_DELAY_MS: u64 = 500;
|
|||||||
/// the hand lets go first, having concluded nothing was going to happen.
|
/// the hand lets go first, having concluded nothing was going to happen.
|
||||||
pub(crate) const HOLD_DELAY_MS: u64 = 450;
|
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
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
/// Start the timer that turns a held cell into a selection.
|
/// Start the timer that turns a held cell into a selection.
|
||||||
///
|
///
|
||||||
@@ -1501,13 +1559,17 @@ pub fn wire<S, R, P, C>(
|
|||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
let visible = visible_ids.clone();
|
let visible = visible_ids.clone();
|
||||||
window.on_library_cell_pressed(move |row, ctrl_held, shift_held| {
|
window.on_library_cell_pressed(move |row, ctrl_held, shift_held, touch| {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
let ids = visible();
|
let ids = visible();
|
||||||
|
|
||||||
// Consulted by the click that follows: a modified press is building
|
// Consulted by the click that follows: a modified press is building
|
||||||
// a selection and must not also navigate to develop.
|
// a selection and must not also navigate to develop.
|
||||||
ctl.modified_press.set(ctrl_held || shift_held);
|
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
|
// 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
|
// it is rather than as a row that stops meaning this photograph on
|
||||||
@@ -2739,6 +2801,52 @@ mod tests {
|
|||||||
Vec::new()
|
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]
|
#[test]
|
||||||
fn dragging_a_collection_onto_another_moves_it() {
|
fn dragging_a_collection_onto_another_moves_it() {
|
||||||
// The gesture the tree rearrangement exists for: no images carried, a
|
// The gesture the tree rearrangement exists for: no images carried, a
|
||||||
|
|||||||
@@ -4411,6 +4411,13 @@ pub fn wire<F>(
|
|||||||
if coll_for_click.press_was_modified() {
|
if coll_for_click.press_was_modified() {
|
||||||
return;
|
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();
|
let path = ctl.paths.borrow().get(i as usize).cloned();
|
||||||
if let Some(path) = path {
|
if let Some(path) = path {
|
||||||
// Leave the grid for the develop view. The status bar's
|
// Leave the grid for the develop view. The status bar's
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ export component AppWindow inherits Window {
|
|||||||
callback library-drag-finished();
|
callback library-drag-finished();
|
||||||
in property <int> library-selected-count: 0;
|
in property <int> library-selected-count: 0;
|
||||||
|
|
||||||
callback library-cell-pressed(int, bool, bool);
|
callback library-cell-pressed(int, bool, bool, bool);
|
||||||
callback library-remove-from-collection();
|
callback library-remove-from-collection();
|
||||||
|
|
||||||
// The keyboard cursor: where the arrows are in the library, as an image
|
// The keyboard cursor: where the arrows are in the library, as an image
|
||||||
@@ -1536,8 +1536,8 @@ in property <bool> panel-visible: true;
|
|||||||
open-settings() => { root.settings-open(); }
|
open-settings() => { root.settings-open(); }
|
||||||
open-people() => { root.identity-open(); }
|
open-people() => { root.identity-open(); }
|
||||||
|
|
||||||
cell-pressed(i, ctrl, shift) => {
|
cell-pressed(i, ctrl, shift, touch) => {
|
||||||
root.library-cell-pressed(i, ctrl, shift);
|
root.library-cell-pressed(i, ctrl, shift, touch);
|
||||||
}
|
}
|
||||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||||
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
|
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
|
||||||
|
|||||||
@@ -1215,7 +1215,15 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// also why there is no badge position to compute here any more.
|
// also why there is no badge position to compute here any more.
|
||||||
/// Modifier state at press time, so Rust can decide replace / add / extend
|
/// Modifier state at press time, so Rust can decide replace / add / extend
|
||||||
/// without the .slint file encoding the selection policy.
|
/// without the .slint file encoding the selection policy.
|
||||||
callback cell-pressed(int, bool, bool);
|
///
|
||||||
|
/// The last argument is **whether a finger did it**, and it is here because
|
||||||
|
/// a finger and a pointer need different rules about what counts as a tap.
|
||||||
|
/// A mouse click is over in tens of milliseconds and means it; a hand
|
||||||
|
/// brushing a tablet on its way somewhere else produces exactly the same
|
||||||
|
/// press-and-release, and used to land the user in develop. Rust holds the
|
||||||
|
/// rule (`collections_ui::TAP_MIN_MS`) beside the hold timer it has to sit
|
||||||
|
/// between — this file only reports which kind of contact it was.
|
||||||
|
callback cell-pressed(int, bool, bool, bool);
|
||||||
/// TRACES: FR-UI-2 | FR-UI-4
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
/// The press on a cell ended — lifted, or taken away by the Flickable when
|
/// The press on a cell ended — lifted, or taken away by the Flickable when
|
||||||
/// the finger travelled. Cancels the long-press timer that would otherwise
|
/// the finger travelled. Cancels the long-press timer that would otherwise
|
||||||
@@ -3011,6 +3019,10 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
i,
|
i,
|
||||||
ev.modifiers.control || root.select-mode,
|
ev.modifiers.control || root.select-mode,
|
||||||
ev.modifiers.shift || root.ranging,
|
ev.modifiers.shift || root.ranging,
|
||||||
|
// Finger id 0 is the mouse — the same
|
||||||
|
// convention the pinch arbitration a
|
||||||
|
// few lines up already relies on.
|
||||||
|
ev.touch-finger-id != 0,
|
||||||
);
|
);
|
||||||
// One shot. The range was between two taps
|
// One shot. The range was between two taps
|
||||||
// and the second has landed; left armed, it
|
// and the second has landed; left armed, it
|
||||||
|
|||||||
Reference in New Issue
Block a user