Merge master into wave-2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
This commit is contained in:
2026-08-30 00:25:42 +02:00
11 changed files with 1271 additions and 373 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
+130
View File
@@ -0,0 +1,130 @@
// @generated by `cargo run -p traceability -- gestures`. Do not edit.
//
// TRACES: FR-UI-4
//! The gesture vocabulary, as the application states it.
//!
//! Extracted from the tagged comments beside the code that implements each
//! one, so the help sheet cannot describe a gesture the application does not
//! have. Edit the comment beside the implementation, then regenerate with
//! `cargo run -p traceability -- gestures`.
/// One documented interaction, as drawn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Gesture {
pub title: &'static str,
/// The place it applies, and the heading it is grouped under.
pub section: &'static str,
/// Empty where the gesture has no counterpart in that modality.
pub touch: &'static str,
pub pointer: &'static str,
pub keys: &'static str,
}
/// Every gesture, in the order the sections were first seen in the source.
pub const GESTURES: &[Gesture] = &[
Gesture {
title: "Pull a face out of the wrong person",
section: "People",
touch: "Tap the faces that do not belong, then \"Split off\"",
pointer: "Click the faces that do not belong, then \"Split off\"",
keys: "",
},
Gesture {
title: "Rule on a suggested face",
section: "People",
touch: "Tick to confirm it, cross to reject it",
pointer: "Tick to confirm it, cross to reject it",
keys: "",
},
Gesture {
title: "See a person's photographs",
section: "People",
touch: "Choose them in the rail, then \"Show photos\"",
pointer: "Choose them in the rail, then \"Show photos\"",
keys: "",
},
Gesture {
title: "Change how faces are grouped",
section: "People",
touch: "\"Grouping…\", move the dials, then Regroup",
pointer: "\"Grouping…\", move the dials, then Regroup",
keys: "",
},
Gesture {
title: "Start selecting several photographs",
section: "Library grid",
touch: "Press and hold a photograph, or press Select in the header",
pointer: "Ctrl-click, or press Select in the header",
keys: "",
},
Gesture {
title: "Add or remove one photograph",
section: "Library grid",
touch: "While selecting, tap it",
pointer: "Ctrl-click it",
keys: "",
},
Gesture {
title: "Leave selecting",
section: "Library grid",
touch: "Press Done in the header",
pointer: "Press Done in the header",
keys: "Escape",
},
Gesture {
title: "Select a range",
section: "Library grid",
touch: "While selecting, press \"Select to…\", then tap the last photograph of the run",
pointer: "Shift-click the last photograph of the run",
keys: "",
},
Gesture {
title: "Find photographs with two people in them",
section: "Library grid",
touch: "Open the People chip on the filter bar, tap each name, then switch the chip beside them to \"all of them\"",
pointer: "Open the People chip on the filter bar, click each name, then switch the chip beside them to \"all of them\"",
keys: "",
},
Gesture {
title: "Drop the selection but keep selecting",
section: "Library grid",
touch: "Press Clear in the selection strip",
pointer: "Press Clear in the selection strip",
keys: "",
},
Gesture {
title: "Select everything the grid is showing",
section: "Library grid",
touch: "While selecting, press \"Select all\"",
pointer: "While selecting, press \"Select all\"",
keys: "",
},
Gesture {
title: "Resize the thumbnails",
section: "Library grid",
touch: "Pinch the grid with two fingers",
pointer: "Ctrl and the scroll wheel",
keys: "",
},
Gesture {
title: "File photographs in a collection",
section: "Library grid",
touch: "Drag a photograph — or a whole selection — onto a collection in the sidebar. Starting a drag stops the press becoming a hold, so it cannot leave you in selection mode.",
pointer: "Drag a photograph — or a whole selection — onto a collection in the sidebar",
keys: "",
},
Gesture {
title: "Open a photograph",
section: "Library grid",
touch: "Tap it — a single tap, any length",
pointer: "Click it",
keys: "",
},
Gesture {
title: "Rate a photograph without opening it",
section: "Library grid",
touch: "Tap a star on the cell",
pointer: "Hover the cell, then click a star",
keys: "0 to 5 on the selection",
},
];
+1 -7
View File
@@ -4550,13 +4550,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