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:
+52
-270
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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
|
||||
|
||||
@@ -544,7 +544,6 @@ export component AppWindow inherits Window {
|
||||
callback library-cell-press-ended();
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Two taps on a cell: in selection mode, the far end of a range.
|
||||
callback library-cell-double-clicked(int);
|
||||
/// Renaming started on a row, by id — a double-click, or `F2`.
|
||||
callback collection-rename-start(int);
|
||||
/// A rename was committed: the collection's id and the new name.
|
||||
@@ -594,7 +593,7 @@ export component AppWindow inherits Window {
|
||||
callback library-drag-finished();
|
||||
in property <int> library-selected-count: 0;
|
||||
|
||||
callback library-cell-pressed(int, bool, bool, bool);
|
||||
callback library-cell-pressed(int, bool, bool);
|
||||
callback library-remove-from-collection();
|
||||
|
||||
// The keyboard cursor: where the arrows are in the library, as an image
|
||||
@@ -1568,11 +1567,10 @@ in property <bool> panel-visible: true;
|
||||
open-settings() => { root.settings-open(); }
|
||||
open-people() => { root.identity-open(); }
|
||||
|
||||
cell-pressed(i, ctrl, shift, touch) => {
|
||||
root.library-cell-pressed(i, ctrl, shift, touch);
|
||||
cell-pressed(i, ctrl, shift) => {
|
||||
root.library-cell-pressed(i, ctrl, shift);
|
||||
}
|
||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
|
||||
select-mode: root.library-select-mode;
|
||||
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||||
// The sidebar's rows, not a second model: the sheet files
|
||||
|
||||
@@ -127,6 +127,14 @@ component FaceCell inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// GESTURE: Pull a face out of the wrong person
|
||||
// where: People
|
||||
// touch: Tap the faces that do not belong, then "Split off"
|
||||
// pointer: Click the faces that do not belong, then "Split off"
|
||||
// why: Grouping over-merges on siblings, on parents and
|
||||
// children, and on the same person a decade apart, so
|
||||
// splitting is as prominent as merging. A tool that can
|
||||
// only merge makes its own errors permanent.
|
||||
TouchArea {
|
||||
clicked => { root.toggle-pick(); }
|
||||
}
|
||||
@@ -139,6 +147,14 @@ component FaceCell inherits Rectangle {
|
||||
// Only a suggestion needs ruling on. Once confirmed, the pair
|
||||
// collapses to the label — there is nothing left to decide, and
|
||||
// leaving the buttons would invite an accidental un-confirm.
|
||||
// GESTURE: Rule on a suggested face
|
||||
// where: People
|
||||
// touch: Tick to confirm it, cross to reject it
|
||||
// pointer: Tick to confirm it, cross to reject it
|
||||
// why: A face is either the system's guess or the user's
|
||||
// judgement, and the two are never conflated. A
|
||||
// rejection is remembered, so the face is not suggested
|
||||
// for that person again.
|
||||
if !face.confirmed: IconButton {
|
||||
icon: "check";
|
||||
clicked => { root.confirm(); }
|
||||
@@ -526,6 +542,13 @@ export component IdentityScreen inherits Rectangle {
|
||||
// The way back from a face to the photographs. This is the
|
||||
// point of having identified anybody, and without it the
|
||||
// screen is a filing cabinet with no drawer handles.
|
||||
// GESTURE: See a person's photographs
|
||||
// where: People
|
||||
// touch: Choose them in the rail, then "Show photos"
|
||||
// pointer: Choose them in the rail, then "Show photos"
|
||||
// why: This is the point of having identified anybody.
|
||||
// Without it the screen is a filing cabinet with no
|
||||
// drawer handles.
|
||||
if root.selected-person >= 0: Button {
|
||||
text: "Show photos";
|
||||
clicked => { root.show-photos(root.selected-person, false); }
|
||||
@@ -556,6 +579,15 @@ export component IdentityScreen inherits Rectangle {
|
||||
// next to the button that applies them and the rail that shows
|
||||
// what they did, and a value changed three screens away from
|
||||
// its effect is a value nobody can tune.
|
||||
// GESTURE: Change how faces are grouped
|
||||
// where: People
|
||||
// touch: "Grouping…", move the dials, then Regroup
|
||||
// pointer: "Grouping…", move the dials, then Regroup
|
||||
// why: The right match confidence is a property of your
|
||||
// library, not of the model. "What would this do?"
|
||||
// answers for this library without writing
|
||||
// anything; names, confirmations and the groups you
|
||||
// have set aside are kept whatever the dials say.
|
||||
Button {
|
||||
text: "Grouping…";
|
||||
active: root.grouping-open;
|
||||
|
||||
+137
-25
@@ -1240,15 +1240,7 @@ 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.
|
||||
///
|
||||
/// 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);
|
||||
callback cell-pressed(int, 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
|
||||
@@ -1257,12 +1249,6 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// visible cell.
|
||||
callback cell-press-ended();
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Two taps on the same cell. In selection mode this is the touch form of
|
||||
/// shift-click: everything from where the selection started to here. Rust
|
||||
/// decides — outside that mode a double tap is two ordinary clicks and the
|
||||
/// first has already opened the image.
|
||||
callback cell-double-clicked(int);
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Whether a tap selects rather than opens.
|
||||
///
|
||||
/// Held in Rust beside the selection it modifies, so the long press and the
|
||||
@@ -1270,6 +1256,28 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// can disagree. In this mode a plain tap toggles a cell — exactly what
|
||||
/// ctrl-click does with a pointer, which is why the press below passes it
|
||||
/// as the ctrl flag rather than as a third selection policy.
|
||||
// GESTURE: Start selecting several photographs
|
||||
// where: Library grid
|
||||
// touch: Press and hold a photograph, or press Select in the header
|
||||
// pointer: Ctrl-click, or press Select in the header
|
||||
// why: Touch has no ctrl, so without a mode there is no way to
|
||||
// select a second photograph — the first tap would open it.
|
||||
// The hold is the fast way in and the button is the one that
|
||||
// can be found.
|
||||
//
|
||||
// GESTURE: Add or remove one photograph
|
||||
// where: Library grid
|
||||
// touch: While selecting, tap it
|
||||
// pointer: Ctrl-click it
|
||||
// why: While selecting, a tap never opens. That is the whole point
|
||||
// of the mode: one meaning per gesture at a time. Press Done to
|
||||
// get tap-to-open back.
|
||||
//
|
||||
// GESTURE: Leave selecting
|
||||
// where: Library grid
|
||||
// touch: Press Done in the header
|
||||
// pointer: Press Done in the header
|
||||
// keys: Escape
|
||||
in property <bool> select-mode: false;
|
||||
callback toggle-select-mode();
|
||||
/// TRACES: FR-CAT-5
|
||||
@@ -1326,6 +1334,17 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// no state for it, because it arrives as `cell-pressed`'s shift argument
|
||||
/// and lands in `apply_press` as the shift-click it already knows how to
|
||||
/// apply.
|
||||
// GESTURE: Select a range
|
||||
// where: 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
|
||||
// why: This replaced a double tap, which had no visible state and
|
||||
// could take forty photographs by accident. The run is resolved
|
||||
// by the catalog rather than by what is on screen, so the grid
|
||||
// can scroll between the two taps — the ranges that hurt on a
|
||||
// tablet are longer than a screenful, which is exactly where a
|
||||
// finger sweep runs out.
|
||||
property <bool> ranging: false;
|
||||
|
||||
// A range armed against nothing has no anchor to extend from, and the strip
|
||||
@@ -2054,6 +2073,18 @@ export component LibraryGrid inherits Rectangle {
|
||||
// belongs on the filter bar; this chip is the whole feature's
|
||||
// front door and the tray below is where both terms and the
|
||||
// any/all choice actually are.
|
||||
// GESTURE: Find photographs with two people in them
|
||||
// where: 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"
|
||||
// why: "Any of them" is a union and "all of them" is an
|
||||
// intersection. The tray is where both terms and the
|
||||
// choice between them live, because a filter belongs
|
||||
// on the filter bar.
|
||||
FilterChip {
|
||||
icon: root.people-tray ? "chevron-down" : "chevron-right";
|
||||
label: "People";
|
||||
@@ -2333,6 +2364,13 @@ export component LibraryGrid inherits Rectangle {
|
||||
// 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.
|
||||
// GESTURE: Drop the selection but keep selecting
|
||||
// where: Library grid
|
||||
// touch: Press Clear in the selection strip
|
||||
// pointer: Press Clear in the selection strip
|
||||
// why: Distinct from Done, which leaves the mode
|
||||
// entirely. Clearing keeps it, so the next
|
||||
// selection can start straight away.
|
||||
if !root.ranging: Button {
|
||||
text: "Clear";
|
||||
y: (parent.height - self.height) / 2;
|
||||
@@ -2343,6 +2381,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
// do by hand: a scoped grid of two hundred frames is two hundred
|
||||
// taps otherwise, and "all of them, except those three" is a far
|
||||
// more common shape than the taps it took to say it.
|
||||
// GESTURE: Select everything the grid is showing
|
||||
// where: Library grid
|
||||
// touch: While selecting, press "Select all"
|
||||
// pointer: While selecting, press "Select all"
|
||||
// why: A scoped grid of two hundred frames is two hundred
|
||||
// taps otherwise, and "all of them, except those
|
||||
// three" is a far more common shape than the taps it
|
||||
// took to say it.
|
||||
if !root.ranging: Button {
|
||||
text: "Select all";
|
||||
y: (parent.height - self.height) / 2;
|
||||
@@ -2776,6 +2822,13 @@ export component LibraryGrid inherits Rectangle {
|
||||
// tall sat at the top of a viewport thousands of rows long and
|
||||
// was under the fingers only while the grid had not been
|
||||
// scrolled. Anywhere else the gesture found nothing to land on.
|
||||
// GESTURE: Resize the thumbnails
|
||||
// where: Library grid
|
||||
// touch: Pinch the grid with two fingers
|
||||
// pointer: Ctrl and the scroll wheel
|
||||
// why: There is no wheel on a tablet, so without the
|
||||
// pinch the cell size could only be changed by a
|
||||
// control a finger cannot reach.
|
||||
grid-pinch := ScaleRotateGestureHandler {
|
||||
width: 100%;
|
||||
height: parent.viewport-height;
|
||||
@@ -2918,6 +2971,17 @@ export component LibraryGrid inherits Rectangle {
|
||||
visible: cell.period-heading != "";
|
||||
}
|
||||
|
||||
// GESTURE: File photographs in a collection
|
||||
// where: 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
|
||||
// why: The selection is what the drag carries, which is
|
||||
// why selecting several is worth the mode: forty
|
||||
// photographs file in one gesture.
|
||||
for cell[i] in root.cells: DragArea {
|
||||
// Cells are positioned at their **absolute** place in the
|
||||
// library, not their index in the loaded window: the window
|
||||
@@ -3137,13 +3201,47 @@ export component LibraryGrid inherits Rectangle {
|
||||
// `clicked` fires before the `up` that follows it, so
|
||||
// it can only raise a flag — the decision to open has
|
||||
// to wait for the event that names the finger.
|
||||
// GESTURE: Open a photograph
|
||||
// where: Library grid
|
||||
// touch: Tap it — a single tap, any length
|
||||
// pointer: Click it
|
||||
// why: A tap opens; a tap that *moved* does not.
|
||||
// Travel is what separates a deliberate tap
|
||||
// from a hand brushing past, and it is the
|
||||
// only thing that does: the two are the same
|
||||
// length. An earlier version required the
|
||||
// finger to dwell 120 ms instead, and that
|
||||
// rejected ordinary taps — a real tap is
|
||||
// often quicker than a brush.
|
||||
property <int> down-finger: -1;
|
||||
property <bool> click-pending: false;
|
||||
|
||||
// Where the press landed, so the release can tell how
|
||||
// far the finger travelled.
|
||||
//
|
||||
// The Flickable already cancels a press that becomes a
|
||||
// scroll, but only once it has claimed the gesture,
|
||||
// which takes more travel than a graze has. This
|
||||
// catches the rest: a contact that slid across the cell
|
||||
// and lifted was reaching for something else.
|
||||
property <length> press-x: 0;
|
||||
property <length> press-y: 0;
|
||||
|
||||
/// How far a finger may slide and still be a tap.
|
||||
///
|
||||
/// A thumb's contact patch is wider than this, so no
|
||||
/// stationary tap approaches it; a hand moving across
|
||||
/// the glass passes it within one frame. Below about
|
||||
/// this the number stops describing intent and starts
|
||||
/// describing how steady the user's hand is.
|
||||
property <length> tap-slop: 12px;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.down-finger = ev.touch-finger-id;
|
||||
self.click-pending = false;
|
||||
self.press-x = self.mouse-x;
|
||||
self.press-y = self.mouse-y;
|
||||
// A finger, not a pointer — see `touched`.
|
||||
if (ev.touch-finger-id != 0) {
|
||||
root.touched = true;
|
||||
@@ -3164,10 +3262,6 @@ 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
|
||||
@@ -3187,7 +3281,9 @@ export component LibraryGrid inherits Rectangle {
|
||||
// unmodified.
|
||||
if (self.click-pending
|
||||
&& !root.pinching
|
||||
&& ev.touch-finger-id == self.down-finger) {
|
||||
&& ev.touch-finger-id == self.down-finger
|
||||
&& abs(self.mouse-x - self.press-x) < self.tap-slop
|
||||
&& abs(self.mouse-y - self.press-y) < self.tap-slop) {
|
||||
root.cell-clicked(i);
|
||||
}
|
||||
self.click-pending = false;
|
||||
@@ -3204,12 +3300,28 @@ export component LibraryGrid inherits Rectangle {
|
||||
}
|
||||
|
||||
clicked => { self.click-pending = true; }
|
||||
// The far end of a range, in selection mode. Slint
|
||||
// delivers `clicked` for the first tap as well, which
|
||||
// is why the toggling is idempotent-by-union in Rust
|
||||
// rather than this file trying to swallow one of them.
|
||||
double-clicked => { root.cell-double-clicked(i); }
|
||||
// **No `double-clicked` here, deliberately.** A double
|
||||
// tap used to take a range in selection mode. It was
|
||||
// the only gesture touch had for shift-click, and
|
||||
// "Select to…" replaced it with a control that says
|
||||
// what it is about to do. Leaving both meant two quick
|
||||
// taps on one cell — a thing a hand does by accident —
|
||||
// silently selecting a run of forty photographs, with
|
||||
// no visible state to explain where they came from.
|
||||
//
|
||||
// Now two taps are two toggles and land back where they
|
||||
// started, which is the only thing a user can predict
|
||||
// from what is on screen.
|
||||
|
||||
// GESTURE: Rate a photograph without opening it
|
||||
// where: Library grid
|
||||
// touch: Tap a star on the cell
|
||||
// pointer: Hover the cell, then click a star
|
||||
// keys: 0 to 5 on the selection
|
||||
// why: A star has to take the press without it
|
||||
// also reaching the cell, or every rating
|
||||
// throws the user into develop.
|
||||
//
|
||||
// --- the rating strip, INSIDE the cell's hit area ---
|
||||
//
|
||||
// **A child of `cell-touch`, not a sibling of it.** Two
|
||||
|
||||
Reference in New Issue
Block a user