diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs index a2c3eb2..2031fe7 100644 --- a/ui/dr-ui/src/collections_ui.rs +++ b/ui/dr-ui/src/collections_ui.rs @@ -157,6 +157,16 @@ 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, + /// 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>, + touch_press: std::cell::Cell, /// TRACES: FR-UI-2 | FR-UI-4 /// Whether a tap in the grid selects rather than opens. /// @@ -333,6 +343,38 @@ 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 { 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. 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. /// @@ -1501,13 +1559,17 @@ pub fn wire( 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| { + window.on_library_cell_pressed(move |row, ctrl_held, shift_held, touch| { 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 @@ -2739,6 +2801,52 @@ 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 diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index ae6844d..248809c 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -4411,6 +4411,13 @@ pub fn wire( 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 diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index f28dcc5..fd5c1a5 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -557,7 +557,7 @@ export component AppWindow inherits Window { callback library-drag-finished(); in property library-selected-count: 0; - callback library-cell-pressed(int, bool, bool); + callback library-cell-pressed(int, bool, bool, bool); callback library-remove-from-collection(); // The keyboard cursor: where the arrows are in the library, as an image @@ -1467,8 +1467,8 @@ in property panel-visible: true; open-settings() => { root.settings-open(); } open-people() => { root.identity-open(); } - cell-pressed(i, ctrl, shift) => { - root.library-cell-pressed(i, ctrl, shift); + cell-pressed(i, ctrl, shift, touch) => { + root.library-cell-pressed(i, ctrl, shift, touch); } cell-press-ended() => { root.library-cell-press-ended(); } cell-double-clicked(i) => { root.library-cell-double-clicked(i); } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 2be8151..dd714db 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -1198,7 +1198,15 @@ export component LibraryGrid inherits Rectangle { // also why there is no badge position to compute here any more. /// Modifier state at press time, so Rust can decide replace / add / extend /// 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 /// 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 @@ -2990,6 +2998,10 @@ export component LibraryGrid inherits Rectangle { i, ev.modifiers.control || root.select-mode, 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 // and the second has landed; left armed, it