Merge: touch selection and drag, from the gallery-selection branch
Verified before merge: fmt clean, clippy -D warnings clean, 563 dr-ui tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md
This commit is contained in:
+311
-153
@@ -50,40 +50,37 @@ use crate::{AppWindow, CollectionRow};
|
||||
struct PressUndo {
|
||||
selection: BTreeSet<ImageId>,
|
||||
anchor: Option<usize>,
|
||||
previous_anchor: Option<usize>,
|
||||
cursor: Option<usize>,
|
||||
}
|
||||
|
||||
impl PressUndo {
|
||||
/// Everything [`press_remembering_anchor`] is about to change.
|
||||
/// Everything [`select_row`] is about to change.
|
||||
///
|
||||
/// The four are the whole of what a press touches, which is the property
|
||||
/// the restore depends on and the reason it is worth a test of its own: an
|
||||
/// press that grew a fifth piece of state would leave that piece behind
|
||||
/// The three are the whole of what a press touches, which is the property
|
||||
/// the restore depends on and the reason it is worth a test of its own: a
|
||||
/// press that grew a fourth piece of state would leave that piece behind
|
||||
/// after a cancel, silently.
|
||||
fn capture(
|
||||
selection: &BTreeSet<ImageId>,
|
||||
anchor: Option<usize>,
|
||||
previous_anchor: Option<usize>,
|
||||
cursor: Option<usize>,
|
||||
) -> Self {
|
||||
Self {
|
||||
selection: selection.clone(),
|
||||
anchor,
|
||||
previous_anchor,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put it all back. Returns the two the caller holds in `Cell`s.
|
||||
/// Put it all back. Returns the one the caller holds in a `Cell`.
|
||||
fn restore(
|
||||
self,
|
||||
selection: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
) -> (Option<usize>, Option<usize>) {
|
||||
) -> Option<usize> {
|
||||
*selection = self.selection;
|
||||
*anchor = self.anchor;
|
||||
(self.previous_anchor, self.cursor)
|
||||
self.cursor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,18 +114,15 @@ pub struct CollectionsController {
|
||||
/// same argument applied to the one index that has to survive a move.
|
||||
anchor: RefCell<Option<usize>>,
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Where the anchor was *before* the press that moved it.
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// A photograph the most recent press asked to take *out* of the
|
||||
/// selection, held until the release says the press was a tap.
|
||||
///
|
||||
/// The touch equivalent of shift-click needs this. A double tap is two
|
||||
/// presses, and both of them move the anchor onto the cell being tapped —
|
||||
/// so by the time the double tap is reported, "the range from the anchor to
|
||||
/// here" describes a single cell. This remembers the cell the user actually
|
||||
/// started from, which is the one they mean.
|
||||
///
|
||||
/// Updated only when a press *moves* the anchor, so the second tap of a
|
||||
/// double tap — which lands on the cell that is already the anchor — leaves
|
||||
/// it pointing where the first tap left it.
|
||||
previous_anchor: std::cell::Cell<Option<usize>>,
|
||||
/// See [`Press::Deferred`] for why removal cannot happen on the press:
|
||||
/// this is the whole of what stops a drag of forty photographs carrying
|
||||
/// one. Overwritten by the next press and dropped by the drag that
|
||||
/// consumes it, so at most one is ever pending.
|
||||
pending_toggle: std::cell::Cell<Option<ImageId>>,
|
||||
/// TRACES: FR-UI-4
|
||||
/// What the selection was immediately before the most recent press, so a
|
||||
/// gesture that turns out not to have been a press can put it back.
|
||||
@@ -368,22 +362,57 @@ impl CollectionsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// What a press did, and what is left for the release to do.
|
||||
///
|
||||
/// Only one press has anything left over, and it is the one every multi-image
|
||||
/// drag depends on — see [`Press::Deferred`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[must_use]
|
||||
pub enum Press {
|
||||
/// The selection is already what this press means. Nothing to finish.
|
||||
Applied,
|
||||
/// **Taking a photograph out of the selection waits for the release.**
|
||||
///
|
||||
/// Putting one *in* has to happen on the press: the drag that may follow
|
||||
/// reads the selection to decide what it carries, and by the time the
|
||||
/// finger lifts it is over the sidebar. Taking one out is the opposite —
|
||||
/// nothing between the press and the release needs the image gone, and one
|
||||
/// thing very much needs it to stay.
|
||||
///
|
||||
/// A plain press has said so since selection was written: pressing an
|
||||
/// already-selected cell leaves the selection alone. Ctrl did not, and on a
|
||||
/// tablet *every* press is a ctrl-press — that is what selection mode is.
|
||||
/// So grabbing one of forty selected photographs deselected it on the way
|
||||
/// down; the drag that followed found the cell under the finger no longer
|
||||
/// in the selection, took that to mean an unselected image was being
|
||||
/// dragged, and carried it alone. Forty photographs became one, and the
|
||||
/// only clue was the grabbed cell's ring blinking out.
|
||||
///
|
||||
/// So the removal is handed back for the *click* to apply, and a click
|
||||
/// fires only for a press that stayed put — never for one that became a
|
||||
/// drag. See `tap-slop` in `library.slint`.
|
||||
Deferred(ImageId),
|
||||
}
|
||||
|
||||
/// Apply a press to the selection.
|
||||
///
|
||||
/// Split from the callback so the policy is testable without a window: this is
|
||||
/// the part a user notices being wrong.
|
||||
///
|
||||
/// - **plain** — replace the selection with this one image
|
||||
/// - **ctrl** — toggle this image, keeping the rest, and move the anchor here
|
||||
/// - **ctrl** — add this image to the selection, keeping the rest, and move the
|
||||
/// anchor here; taking one *out* is [deferred](Press::Deferred) to the release
|
||||
/// - **shift** — select the range from the anchor to here, *replacing* what was
|
||||
/// selected; the anchor stays put, so an overshoot is corrected by
|
||||
/// shift-clicking the right cell rather than starting again
|
||||
/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a
|
||||
/// second run without losing the first
|
||||
///
|
||||
/// A plain press on an image that is *already* selected leaves the selection
|
||||
/// alone. That is what makes dragging a multi-selection possible at all — the
|
||||
/// press that begins the drag would otherwise collapse the selection to one.
|
||||
/// A press on an image that is *already* selected never removes it here —
|
||||
/// plainly or with ctrl. That is what makes dragging a multi-selection possible
|
||||
/// at all: the press that begins the drag would otherwise take the grabbed
|
||||
/// photograph out from under it.
|
||||
///
|
||||
/// `ids` is the **loaded window**, `offset` where it starts in the library and
|
||||
/// `row` a position within it; the anchor is kept as `offset + row`, an
|
||||
@@ -402,8 +431,10 @@ pub fn apply_press(
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||
) {
|
||||
let Some(&id) = ids.get(row) else { return };
|
||||
) -> Press {
|
||||
let Some(&id) = ids.get(row) else {
|
||||
return Press::Applied;
|
||||
};
|
||||
let here = offset + row;
|
||||
|
||||
if shift {
|
||||
@@ -413,7 +444,7 @@ pub fn apply_press(
|
||||
selection.clear();
|
||||
selection.insert(id);
|
||||
*anchor = Some(here);
|
||||
return;
|
||||
return Press::Applied;
|
||||
};
|
||||
|
||||
// The anchor deliberately does **not** move. Shift-clicking again
|
||||
@@ -459,15 +490,19 @@ pub fn apply_press(
|
||||
run = ids[lo_row..=hi_row].to_vec();
|
||||
}
|
||||
selection.extend(run);
|
||||
return;
|
||||
return Press::Applied;
|
||||
}
|
||||
|
||||
if ctrl {
|
||||
if !selection.remove(&id) {
|
||||
selection.insert(id);
|
||||
}
|
||||
*anchor = Some(here);
|
||||
return;
|
||||
|
||||
// Taking one out waits for the release — see [`Press::Deferred`].
|
||||
if selection.contains(&id) {
|
||||
return Press::Deferred(id);
|
||||
}
|
||||
|
||||
selection.insert(id);
|
||||
return Press::Applied;
|
||||
}
|
||||
|
||||
// Plain press on something already selected: leave it. The drag that may
|
||||
@@ -475,41 +510,13 @@ pub fn apply_press(
|
||||
// multi-image drag impossible to start.
|
||||
if selection.contains(&id) {
|
||||
*anchor = Some(here);
|
||||
return;
|
||||
return Press::Applied;
|
||||
}
|
||||
|
||||
selection.clear();
|
||||
selection.insert(id);
|
||||
*anchor = Some(here);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Apply a press, and remember where the anchor was before it moved.
|
||||
///
|
||||
/// The bookkeeping a double tap depends on, split out from the callback so the
|
||||
/// touch sequence — hold one cell, double-tap another, get the run between —
|
||||
/// can be tested without a window. See [`CollectionsController::previous_anchor`]
|
||||
/// for why the *previous* anchor is the one a double tap means.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn press_remembering_anchor(
|
||||
selection: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
previous: &mut Option<usize>,
|
||||
ids: &[ImageId],
|
||||
offset: usize,
|
||||
row: usize,
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||
) {
|
||||
let before = *anchor;
|
||||
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span);
|
||||
// Only a press that *moved* the anchor updates this. The second tap of a
|
||||
// double tap lands on the cell the first tap made the anchor, so it changes
|
||||
// nothing and the origin survives to be extended from.
|
||||
if *anchor != before {
|
||||
*previous = before;
|
||||
}
|
||||
Press::Applied
|
||||
}
|
||||
|
||||
/// Apply a press and push the result into the grid — the whole of what a
|
||||
@@ -533,23 +540,25 @@ pub fn select_row(
|
||||
*ctl.press_undo.borrow_mut() = Some(PressUndo::capture(
|
||||
&ctl.selection.borrow(),
|
||||
*ctl.anchor.borrow(),
|
||||
ctl.previous_anchor.get(),
|
||||
ctl.cursor(),
|
||||
));
|
||||
|
||||
let mut previous = ctl.previous_anchor.get();
|
||||
press_remembering_anchor(
|
||||
// Whatever the last press left pending is answered by this one: two
|
||||
// presses without a click in between means the first never was a tap.
|
||||
ctl.pending_toggle.set(None);
|
||||
|
||||
if let Press::Deferred(id) = apply_press(
|
||||
&mut ctl.selection.borrow_mut(),
|
||||
&mut ctl.anchor.borrow_mut(),
|
||||
&mut previous,
|
||||
ids,
|
||||
offset,
|
||||
row,
|
||||
ctrl,
|
||||
shift,
|
||||
&|first, last| ctl.span(first, last),
|
||||
);
|
||||
ctl.previous_anchor.set(previous);
|
||||
) {
|
||||
ctl.pending_toggle.set(Some(id));
|
||||
}
|
||||
// The cursor follows the press, so an arrow key after a click continues
|
||||
// from the cell that was clicked rather than from wherever the keyboard
|
||||
// was last.
|
||||
@@ -557,6 +566,26 @@ pub fn select_row(
|
||||
sync_selection(window, ctl, ids);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// Finish a press that turned out to be a tap.
|
||||
///
|
||||
/// The other half of [`Press::Deferred`]: a ctrl-press on an already-selected
|
||||
/// photograph leaves it in, because the drag that may follow has to be able to
|
||||
/// carry it, and this takes it out again once the release has proved there was
|
||||
/// no drag.
|
||||
///
|
||||
/// Called from the click, which Slint reports only for a press that stayed
|
||||
/// within `tap-slop` of where it landed — so a press that became a drag never
|
||||
/// reaches here, and a press taken over by the Flickable never reaches here
|
||||
/// either. Both leave the pending removal to be dropped by the next press.
|
||||
pub fn commit_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
||||
let Some(id) = ctl.pending_toggle.take() else {
|
||||
return;
|
||||
};
|
||||
ctl.selection.borrow_mut().remove(&id);
|
||||
sync_selection(window, ctl, ids);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// Put the selection back as it was before the most recent press.
|
||||
///
|
||||
@@ -568,14 +597,22 @@ pub fn select_row(
|
||||
/// Idempotent, and a no-op when there is nothing to undo, so it is safe to
|
||||
/// call on every gesture start rather than only on the ones that need it.
|
||||
pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
||||
// The press is being unmade, so what it left for the release to finish is
|
||||
// unmade with it. Cleared before the early return: a press that changed
|
||||
// nothing to undo can still have deferred a removal — and a finger that
|
||||
// held long enough to pick a photograph up before the second one landed
|
||||
// has to put it back down, or the ring stays open around a cell nobody is
|
||||
// touching and the grid stays frozen with it.
|
||||
ctl.pending_toggle.set(None);
|
||||
window.set_library_held_row(-1);
|
||||
|
||||
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
let (previous_anchor, cursor) = undo.restore(
|
||||
let cursor = undo.restore(
|
||||
&mut ctl.selection.borrow_mut(),
|
||||
&mut ctl.anchor.borrow_mut(),
|
||||
);
|
||||
ctl.previous_anchor.set(previous_anchor);
|
||||
ctl.set_cursor(cursor);
|
||||
sync_selection(window, ctl, ids);
|
||||
}
|
||||
@@ -1022,7 +1059,7 @@ pub(crate) const HOLD_DELAY_MS: u64 = 450;
|
||||
/// press that armed it has *already* selected the cell under the finger, so
|
||||
/// what firing adds is the mode: from here taps toggle rather than open, and
|
||||
/// the header's buttons appear to act on what has been gathered.
|
||||
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
|
||||
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>, row: i32) {
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let ctl_cb = ctl.clone();
|
||||
@@ -1032,8 +1069,22 @@ fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
|
||||
std::time::Duration::from_millis(HOLD_DELAY_MS),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
ctl_cb.select_mode.set(true);
|
||||
w.set_library_select_mode(true);
|
||||
|
||||
// TRACES: FR-CAT-7
|
||||
// The photograph is in the user's hand: the grid draws a ring
|
||||
// opening around it and stops scrolling underneath it, so the drag
|
||||
// that may follow cannot be lost to a flick. See `held-row` in
|
||||
// `library.slint`.
|
||||
//
|
||||
// This half happens whether or not selection mode was already on —
|
||||
// it is the half a *drag* needs, and a drag out of a selection of
|
||||
// forty starts in a mode that is already on.
|
||||
w.set_library_held_row(row);
|
||||
|
||||
if !ctl_cb.select_mode.get() {
|
||||
ctl_cb.select_mode.set(true);
|
||||
w.set_library_select_mode(true);
|
||||
}
|
||||
|
||||
// The release that follows this hold must not also open the image:
|
||||
// the user asked for a selection and would land in develop instead.
|
||||
@@ -1498,17 +1549,20 @@ pub fn wire<S, R, P, C>(
|
||||
let offset = w.get_library_offset().max(0) as usize;
|
||||
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
|
||||
|
||||
// TRACES: FR-UI-2 | FR-UI-4
|
||||
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
|
||||
// And start counting, in case this press is a hold. The press has
|
||||
// already selected this one cell; what the hold adds is the *mode*,
|
||||
// so the taps that follow go on selecting instead of opening the
|
||||
// next photograph the user touches.
|
||||
// next photograph the user touches — and the *pick-up*, which is
|
||||
// what makes the drag reliable.
|
||||
//
|
||||
// Not started when the mode is already on: it is on, and a second
|
||||
// hold would have nothing to do but suppress the tap that ends it.
|
||||
if !ctl.select_mode.get() {
|
||||
arm_hold(&w, &ctl);
|
||||
}
|
||||
// Armed even when the mode is already on, which it did not used to
|
||||
// be: there was nothing left for the hold to switch on, so it was
|
||||
// skipped. But the mode being on is exactly the state a
|
||||
// multi-image drag starts from, and skipping the hold left that
|
||||
// drag with no pick-up and no cue — the one gesture that most
|
||||
// needed both. See `arm_hold`.
|
||||
arm_hold(&w, &ctl, row);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1856,16 +1910,33 @@ pub fn wire<S, R, P, C>(
|
||||
// Slint owns the gesture (see the preamble). What is left here is the
|
||||
// payload — the image ids the drop will act on — and the spring.
|
||||
|
||||
// The payload is built when the drag starts, so it is the selection as it
|
||||
// stands at that moment rather than whatever it becomes mid-flight.
|
||||
// TRACES: FR-CAT-7
|
||||
// **What arms the drag, not what it carries.**
|
||||
//
|
||||
// `DragArea` declines to start a drag while its `data` is empty, and it
|
||||
// tests that on every pointer event that reaches it — the first one
|
||||
// included, which arrives long before any drag. The binding in
|
||||
// `library.slint` calls this callback, and a binding that calls a callback
|
||||
// has nothing Slint can invalidate it on: it is evaluated once, when a
|
||||
// finger first lands on that cell, and cached. `dragging` is empty at that
|
||||
// moment and stays empty until `drag-started` fires.
|
||||
//
|
||||
// So this is answered at the wrong time, and always will be. That is
|
||||
// harmless only because **`set_user_data` is called unconditionally**: an
|
||||
// empty `Vec` is still user data, so the transfer is never `is_empty()` and
|
||||
// the `DragArea` stays armed. Skipping the call for an empty selection —
|
||||
// which looks like an obvious tidy-up — would disarm every cell a finger
|
||||
// had ever touched outside a drag, and dragging would simply stop working
|
||||
// with nothing to see.
|
||||
//
|
||||
// What the drop actually reads is `dragging`, set by `drag-started` and
|
||||
// read back by `dropped-on`. `user_data` rather than plain text so that
|
||||
// nothing outside the application can interpret it as a paste.
|
||||
{
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_drag_payload(move || {
|
||||
let carried = ctl.dragging.borrow().clone();
|
||||
let mut data = slint::DataTransfer::default();
|
||||
// `user_data` rather than plain text: these are catalog ids for our
|
||||
// own drop handler, not something another application should be
|
||||
// able to interpret as a paste.
|
||||
data.set_user_data(Rc::new(carried));
|
||||
data
|
||||
});
|
||||
@@ -1899,6 +1970,15 @@ pub fn wire<S, R, P, C>(
|
||||
// meant. The pinch already cancels for exactly this reason.
|
||||
*ctl.hold_timer.borrow_mut() = None;
|
||||
|
||||
// TRACES: FR-CAT-7
|
||||
// And this press was not a tap, so it never gets to take anything
|
||||
// out of the selection — see [`Press::Deferred`]. Dropped here as
|
||||
// well as on the next press because the drag reads the selection
|
||||
// one line below, and a removal still pending would be a
|
||||
// photograph the user can see is selected and the drop would not
|
||||
// carry.
|
||||
ctl.pending_toggle.set(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
|
||||
@@ -2062,6 +2142,10 @@ pub fn wire<S, R, P, C>(
|
||||
let landed = ctl.dropped_on.borrow_mut().take();
|
||||
let to_trash = ctl.trash_requested.borrow_mut().take();
|
||||
ctl.dragging.borrow_mut().clear();
|
||||
// The grid clears this itself on the cancel that starts a drag;
|
||||
// this is for the endings that reach no cell — a drop, or a drag
|
||||
// abandoned over nothing.
|
||||
w.set_library_held_row(-1);
|
||||
*ctl.hover_id.borrow_mut() = None;
|
||||
*ctl.spring_timer.borrow_mut() = None;
|
||||
|
||||
@@ -2657,6 +2741,30 @@ fn unique_name(conn: &rusqlite::Connection, parent: Option<CollectionId>) -> Str
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// A press and the release that follows it — which is what a click is.
|
||||
///
|
||||
/// These tests are about what a *user* sees, and a user only ever presses
|
||||
/// and lets go. Calling [`apply_press`] alone would drop the half of the
|
||||
/// policy that waits for the release ([`Press::Deferred`]) and quietly
|
||||
/// assert the wrong thing about ctrl.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn click(
|
||||
selection: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
ids: &[ImageId],
|
||||
offset: usize,
|
||||
row: usize,
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||
) {
|
||||
if let Press::Deferred(id) =
|
||||
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span)
|
||||
{
|
||||
selection.remove(&id);
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn ids(n: u64) -> Vec<ImageId> {
|
||||
@@ -2730,8 +2838,8 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||
|
||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(3)]);
|
||||
}
|
||||
@@ -2743,12 +2851,12 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
assert_eq!(sel.len(), 2);
|
||||
|
||||
// Toggling: a second ctrl-press on the same cell takes it out again.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(1)]);
|
||||
}
|
||||
|
||||
@@ -2759,8 +2867,8 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
|
||||
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
|
||||
@@ -2773,8 +2881,8 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||
assert_eq!(sel.len(), 5);
|
||||
}
|
||||
|
||||
@@ -2788,12 +2896,12 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
|
||||
assert_eq!(sel.len(), 11, "rows 5..=15");
|
||||
|
||||
// Corrected to a shorter range from the same anchor.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 8, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 8, false, true, &span);
|
||||
assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range");
|
||||
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
|
||||
}
|
||||
@@ -2807,9 +2915,9 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
|
||||
|
||||
assert_eq!(anchor, Some(10));
|
||||
assert_eq!(sel.len(), 3, "rows 10..=12");
|
||||
@@ -2830,25 +2938,14 @@ mod tests {
|
||||
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,
|
||||
);
|
||||
click(&mut sel, &mut anchor, &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);
|
||||
tap(&mut sel, &mut anchor, &all, 11);
|
||||
tap(&mut sel, &mut anchor, &all, 11);
|
||||
|
||||
assert_eq!(
|
||||
sel.iter().copied().collect::<Vec<_>>(),
|
||||
@@ -2859,24 +2956,86 @@ mod tests {
|
||||
|
||||
/// One tap in selection mode: a press reported as ctrl-held, which is what
|
||||
/// `library.slint` sends while the mode is on.
|
||||
fn tap(
|
||||
sel: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
previous: &mut Option<usize>,
|
||||
all: &[ImageId],
|
||||
row: usize,
|
||||
) {
|
||||
press_remembering_anchor(
|
||||
sel,
|
||||
anchor,
|
||||
previous,
|
||||
all,
|
||||
0,
|
||||
row,
|
||||
true,
|
||||
false,
|
||||
&library(all),
|
||||
fn tap(sel: &mut BTreeSet<ImageId>, anchor: &mut Option<usize>, all: &[ImageId], row: usize) {
|
||||
click(sel, anchor, all, 0, row, true, false, &library(all));
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// The bug that made a forty-image drag file one photograph.
|
||||
///
|
||||
/// In selection mode every press arrives as a ctrl-press, so grabbing one
|
||||
/// of the selected cells to drag them all used to *deselect* the cell being
|
||||
/// grabbed. `drag-started` then saw a press on an image that was not in the
|
||||
/// selection, concluded that was the gesture, and carried it alone.
|
||||
#[test]
|
||||
fn grabbing_a_selected_cell_leaves_the_whole_selection_to_drag() {
|
||||
let all = ids(20);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
// Three photographs picked in selection mode, which reports ctrl.
|
||||
for row in [2, 5, 9] {
|
||||
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
|
||||
}
|
||||
assert_eq!(sel.len(), 3);
|
||||
|
||||
// The press that begins the drag, on one of the three.
|
||||
let outcome = apply_press(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
Press::Deferred(ImageId(6)),
|
||||
"the removal has to be handed back, not performed"
|
||||
);
|
||||
assert_eq!(
|
||||
sel.len(),
|
||||
3,
|
||||
"the drag reads the selection next, and all three have to still be in it"
|
||||
);
|
||||
assert!(sel.contains(&ImageId(6)), "least of all the one being held");
|
||||
}
|
||||
|
||||
/// And the other half: with no drag, the release still takes it out.
|
||||
///
|
||||
/// A tap in selection mode has to toggle, or there is no way to correct a
|
||||
/// mis-tap short of leaving the mode.
|
||||
#[test]
|
||||
fn a_tap_on_a_selected_cell_still_takes_it_out() {
|
||||
let all = ids(20);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
for row in [2, 5, 9] {
|
||||
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
|
||||
}
|
||||
|
||||
click(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
|
||||
|
||||
assert_eq!(
|
||||
sel.iter().copied().collect::<Vec<_>>(),
|
||||
vec![ImageId(3), ImageId(10)],
|
||||
"the tapped photograph is out and the other two stayed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The anchor moves on the press whether or not the removal does.
|
||||
///
|
||||
/// "Select to…" measures from the anchor, and a run taken after tapping a
|
||||
/// selected cell has to start where the user last touched — otherwise the
|
||||
/// gesture sweeps from wherever the anchor happened to be left.
|
||||
#[test]
|
||||
fn a_deferred_press_still_moves_the_anchor() {
|
||||
let all = ids(20);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
let _ = apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
|
||||
assert_eq!(anchor, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2888,13 +3047,13 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||
assert_eq!(sel.len(), 3);
|
||||
|
||||
// A new anchor by ctrl-click, then a ctrl+shift range from it.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
|
||||
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
|
||||
@@ -2909,13 +3068,13 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
|
||||
assert_eq!(sel.len(), 3);
|
||||
|
||||
// Pressing one of the three to begin a drag.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||
assert_eq!(sel.len(), 3, "the selection survived the press");
|
||||
}
|
||||
|
||||
@@ -2932,23 +3091,22 @@ mod tests {
|
||||
let mut anchor = None;
|
||||
|
||||
// A selection built the ordinary way, and the state it leaves behind.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
let before = (sel.clone(), anchor);
|
||||
|
||||
// The finger that opens a pinch.
|
||||
let undo = PressUndo::capture(&sel, anchor, Some(1), Some(3));
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||
let undo = PressUndo::capture(&sel, anchor, Some(3));
|
||||
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||
assert_ne!(
|
||||
(sel.clone(), anchor),
|
||||
before,
|
||||
"the press has to change something, or this proves nothing"
|
||||
);
|
||||
|
||||
let (previous_anchor, cursor) = undo.restore(&mut sel, &mut anchor);
|
||||
let cursor = undo.restore(&mut sel, &mut anchor);
|
||||
|
||||
assert_eq!((sel, anchor), before, "selection and anchor are back");
|
||||
assert_eq!(previous_anchor, Some(1), "and so is the shift-click origin");
|
||||
assert_eq!(cursor, Some(3), "and the keyboard cursor");
|
||||
}
|
||||
|
||||
@@ -2960,7 +3118,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
|
||||
assert!(sel.is_empty());
|
||||
}
|
||||
|
||||
@@ -2980,12 +3138,12 @@ mod tests {
|
||||
let mut anchor = None;
|
||||
|
||||
// Anchor on the sixth image, in a window starting at the beginning.
|
||||
apply_press(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
|
||||
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
|
||||
|
||||
// The user scrolls — the window now starts four images in — and
|
||||
// shift-clicks the image at ordinal 10.
|
||||
apply_press(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 6, "ordinals 5..=10");
|
||||
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
|
||||
@@ -3007,7 +3165,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = Some(0);
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not");
|
||||
assert!(
|
||||
@@ -3030,7 +3188,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = Some(25);
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 24, "ordinals 2..=25");
|
||||
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26)));
|
||||
@@ -3051,7 +3209,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = Some(0);
|
||||
|
||||
apply_press(
|
||||
click(
|
||||
&mut sel,
|
||||
&mut anchor,
|
||||
&window,
|
||||
@@ -3073,7 +3231,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
|
||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(4)]);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,13 @@ pub const GESTURES: &[Gesture] = &[
|
||||
pointer: "Press Done in the header",
|
||||
keys: "Escape",
|
||||
},
|
||||
Gesture {
|
||||
title: "Pick a photograph up to drag it",
|
||||
section: "Library grid",
|
||||
touch: "Press and hold it until a ring opens around it, then drag",
|
||||
pointer: "Drag it",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Select a range",
|
||||
section: "Library grid",
|
||||
|
||||
@@ -73,6 +73,15 @@ pub use develop::DevelopSession;
|
||||
/// opened — see `library::shared_face_models_dir`.
|
||||
pub use library::shared_face_models_dir;
|
||||
|
||||
/// The scene model's three files, wherever this device keeps them.
|
||||
///
|
||||
/// Public for the same reason as the directory above: the pieces that reach for
|
||||
/// a model are not all inside this crate. Exported ahead of the scene tab that
|
||||
/// will consume it so that the packaging and unpacking added alongside it have
|
||||
/// something to be verified against — `assemble-apk.sh` writing files no lookup
|
||||
/// looks for would be a silent mistake for as long as the tab took to arrive.
|
||||
pub use library::scene_model;
|
||||
|
||||
pub mod launch;
|
||||
pub mod launch_ui;
|
||||
|
||||
|
||||
@@ -3846,6 +3846,37 @@ pub fn face_models(account: &Account) -> Option<(PathBuf, PathBuf)> {
|
||||
.or_else(|| system_face_models_dirs().into_iter().find_map(pair))
|
||||
}
|
||||
|
||||
/// The scene model, its vocabulary and its category descriptor, if all three
|
||||
/// are present.
|
||||
///
|
||||
/// All three or none, for the same reason `face_models` insists on its pair:
|
||||
/// the graph alone decodes to 150 anonymous channels, and a descriptor naming
|
||||
/// classes a different model does not have is refused by
|
||||
/// `dr_segment::scene::parse_categories` anyway. Reporting the set missing is
|
||||
/// more useful than starting and failing at the first inference.
|
||||
///
|
||||
/// Searched in the same three places, most specific first — the account's own
|
||||
/// directory, the shared one, then wherever a package installed them. Android
|
||||
/// only ever finds the second, which is where `install_bundled_models` unpacks
|
||||
/// the APK's copy before any store opens.
|
||||
///
|
||||
/// Unlike the face weights this model *is* in the repository, so a desktop
|
||||
/// build from a complete checkout has it. Absent means either a checkout
|
||||
/// without `git lfs pull` or a package that chose not to carry 24 MB, and the
|
||||
/// scene tab reports itself unavailable rather than the app refusing to run.
|
||||
pub fn scene_model(account: &Account) -> Option<(PathBuf, PathBuf, PathBuf)> {
|
||||
fn set(dir: PathBuf) -> Option<(PathBuf, PathBuf, PathBuf)> {
|
||||
let model = dir.join("yolo26s-sem-ade20k.onnx");
|
||||
let classes = dir.join("yolo26s-sem-ade20k.classes.json");
|
||||
let categories = dir.join("categories.txt");
|
||||
(model.is_file() && classes.is_file() && categories.is_file())
|
||||
.then_some((model, classes, categories))
|
||||
}
|
||||
set(face_models_dir(account))
|
||||
.or_else(|| set(shared_face_models_dir()))
|
||||
.or_else(|| system_face_models_dirs().into_iter().find_map(set))
|
||||
}
|
||||
|
||||
/// Where a *package* may have installed the models.
|
||||
///
|
||||
/// `$XDG_DATA_DIRS` rather than a hard-coded `/usr/share`, because that is the
|
||||
|
||||
@@ -4562,6 +4562,12 @@ pub fn wire<F>(
|
||||
let coll_for_click = coll_ctl.clone();
|
||||
let on_open_image = on_open_image.clone();
|
||||
window.on_library_cell_clicked(move |i| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
// The press stayed put, so it was a tap and not a drag: whatever it
|
||||
// held back can be applied now. See `collections_ui::Press`.
|
||||
crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids());
|
||||
|
||||
// A ctrl- or shift-click is a selection gesture. Opening the image
|
||||
// too would throw the user out of the grid mid-selection.
|
||||
if coll_for_click.press_was_modified() {
|
||||
@@ -4572,16 +4578,12 @@ pub fn wire<F>(
|
||||
if let Some(path) = path {
|
||||
// Leave the grid for the develop view. The status bar's
|
||||
// "‹ Library" button comes back here.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.set_show_library(false);
|
||||
// Which cell the develop view is now showing, so the photo
|
||||
// roll opens marking it rather than marking nothing.
|
||||
w.set_library_roll_current(i);
|
||||
}
|
||||
w.set_show_library(false);
|
||||
// Which cell the develop view is now showing, so the photo
|
||||
// roll opens marking it rather than marking nothing.
|
||||
w.set_library_roll_current(i);
|
||||
on_open_image(path);
|
||||
if let Some(w) = weak.upgrade() {
|
||||
report_position(&w, &ctl, i as usize);
|
||||
}
|
||||
report_position(&w, &ctl, i as usize);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -543,6 +543,13 @@ export component AppWindow inherits Window {
|
||||
/// that turns it on. The long press does the same thing without it.
|
||||
in property <bool> library-select-mode: false;
|
||||
callback library-toggle-select-mode();
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// The row a press has held long enough to pick up, or `-1`.
|
||||
///
|
||||
/// Set by the same hold that turns on selection mode and cleared by the
|
||||
/// grid when the press ends — `in-out` because both ends write it. See
|
||||
/// `held-row` in `library.slint` for what it draws and what it stops.
|
||||
in-out property <int> library-held-row: -1;
|
||||
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||||
callback library-cell-press-ended();
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
@@ -1579,6 +1586,7 @@ in property <bool> panel-visible: true;
|
||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||
select-mode: root.library-select-mode;
|
||||
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||||
held-row <=> root.library-held-row;
|
||||
// The sidebar's rows, not a second model: the sheet files
|
||||
// into the same tree the sidebar draws.
|
||||
collections: root.collection-rows;
|
||||
|
||||
+139
-9
@@ -1297,6 +1297,40 @@ export component LibraryGrid inherits Rectangle {
|
||||
// keys: Escape
|
||||
in property <bool> select-mode: false;
|
||||
callback toggle-select-mode();
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// The photograph a press has held long enough to pick up, or `-1`.
|
||||
///
|
||||
/// **The visible half of a gesture that was folklore.** A finger on a cell
|
||||
/// is ambiguous — it may be starting a scroll or taking hold of a
|
||||
/// photograph — and Slint resolves that by giving the `Flickable` the first
|
||||
/// half-second: any press that travels more than a few pixels vertically
|
||||
/// inside it becomes a scroll, and the drag never begins. Only a fast
|
||||
/// sideways flick, or waiting the half-second out, ever picked a
|
||||
/// photograph up, and nothing on the screen said so. The user's account of
|
||||
/// it was that dragging "sometimes works".
|
||||
///
|
||||
/// So the wait is given a mark. The same hold that turns on selection mode
|
||||
/// sets this, a ring opens outward around the cell, and from that moment
|
||||
/// the drag is the only thing the finger can be doing — the grid below is
|
||||
/// no longer `interactive`, so there is no scroll left to lose to. The cue
|
||||
/// can only ever arrive *after* the ambiguity has passed, which is the
|
||||
/// honest direction: once the ring is open, dragging works.
|
||||
///
|
||||
/// A row of the loaded window, like every other row here. It is cleared
|
||||
/// when the press ends and when a drag finishes, and the grid cannot
|
||||
/// scroll while it is set, so it cannot outlive the window it indexes.
|
||||
// GESTURE: Pick a photograph up to drag it
|
||||
// where: Library grid
|
||||
// touch: Press and hold it until a ring opens around it, then drag
|
||||
// pointer: Drag it
|
||||
// why: A finger on a photograph might be starting a scroll, and for
|
||||
// the first half-second the grid assumes it is. Holding says
|
||||
// otherwise, and the ring is the grid saying it heard — from
|
||||
// there the drag cannot be lost to a scroll. A mouse never
|
||||
// waits: the cursor is precise enough that a sideways drag is
|
||||
// unambiguous from the first pixel.
|
||||
in-out property <int> held-row: -1;
|
||||
/// TRACES: FR-CAT-5
|
||||
// --- reordering a manual collection (FR-CAT-7) --------------------------
|
||||
//
|
||||
@@ -1379,15 +1413,29 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// selected. The name arrives from the sheet below rather than being
|
||||
/// invented by Rust and corrected afterwards — see `naming`.
|
||||
callback collection-from-selection(string);
|
||||
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
||||
/// drag starts, so it always reflects the selection as it is at that moment.
|
||||
/// **What arms the drag, not what it carries.**
|
||||
///
|
||||
/// `DragArea` refuses to begin a drag while its `data` is empty, and it
|
||||
/// asks on every pointer event — including the first one, long before
|
||||
/// anything is being dragged. A binding that calls a callback is evaluated
|
||||
/// once and cached, because there is nothing for Slint to invalidate it on,
|
||||
/// so whatever this answers the first time a finger touches a cell is what
|
||||
/// that cell's `DragArea` believes for the rest of its life.
|
||||
///
|
||||
/// It is therefore *not* the payload the drop reads, whatever it looks
|
||||
/// like: what travels is `dragging` in `collections_ui`, recorded by
|
||||
/// `drag-started` and read back by `dropped-on`. See the Rust side for why
|
||||
/// this has to answer something non-empty unconditionally.
|
||||
pure callback drag-payload() -> data-transfer;
|
||||
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
|
||||
/// of them where several are being carried. Composited in Rust, because
|
||||
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
|
||||
in property <image> drag-image;
|
||||
/// A drag began on this cell. Lets Rust promote an unselected cell to the
|
||||
/// selection before the payload is read.
|
||||
/// A drag began on this cell.
|
||||
///
|
||||
/// This, not `drag-payload`, is where what the drag carries is decided: it
|
||||
/// fires with the selection as it stands at the moment the drag starts, and
|
||||
/// lets Rust promote an unselected cell into the selection first.
|
||||
callback drag-started(int);
|
||||
/// The drag ended — dropped or cancelled. Clears the transient UI state.
|
||||
callback drag-finished();
|
||||
@@ -2683,11 +2731,27 @@ export component LibraryGrid inherits Rectangle {
|
||||
|
||||
// --- the grid -----------------------------------------------------
|
||||
//
|
||||
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
|
||||
// properly, so dragging a cell drags the cell and dragging the
|
||||
// background still flicks the grid. (This is the part a hand-rolled
|
||||
// TouchArea gesture could not do — see the drag comments above.)
|
||||
// **Scrolls until a photograph has been picked up, and not after.**
|
||||
//
|
||||
// `DragArea` and `Flickable` do arbitrate, but not evenly: the
|
||||
// Flickable claims any press that travels more than eight pixels
|
||||
// along its own axis within half a second of landing, and it holds
|
||||
// that claim until the finger lifts. That is right for the ordinary
|
||||
// case — a finger that moves is almost always scrolling — and it is
|
||||
// why dragging the background still flicks the grid.
|
||||
//
|
||||
// It is wrong once the user has said otherwise. `held-row` is that
|
||||
// saying: a press that has stayed put long enough to be a pick-up,
|
||||
// marked on the cell so the user can see it. From there this stops
|
||||
// being interactive and the drag has nothing left to lose to.
|
||||
//
|
||||
// Today the hold outlasts the Flickable's window anyway, so this
|
||||
// mostly makes an accident into a guarantee — the arbitration stops
|
||||
// depending on two constants in different crates staying in the
|
||||
// order they happen to be in. The wheel is unaffected: `interactive`
|
||||
// does not gate it.
|
||||
if root.total > 0: grid-scroll := Flickable {
|
||||
interactive: root.held-row < 0;
|
||||
// Ctrl+wheel resizes the cells; a plain wheel is declined and
|
||||
// falls through to the Flickable's own scrolling. Two jobs on
|
||||
// one gesture, distinguished by the modifier — the convention
|
||||
@@ -2903,6 +2967,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
// is what a join table means, and it is why the modifier-free
|
||||
// gesture must not be `move`.
|
||||
allow-copy: true;
|
||||
// Not the payload — the arming. See `drag-payload`.
|
||||
data: root.drag-payload();
|
||||
// What travels under the cursor is the photograph itself — and
|
||||
// where several are being dragged, a stack of them. Composited
|
||||
@@ -2978,7 +3043,19 @@ export component LibraryGrid inherits Rectangle {
|
||||
// photograph, which is exactly the wrong feedback for a
|
||||
// gesture whose whole job is to say "this one". One
|
||||
// treatment, drawn inside, in one place.
|
||||
border-width: cell-touch.has-hover ? 1px : 0px;
|
||||
//
|
||||
// **Not drawn at all once there has been a finger.** A
|
||||
// hand has no hover to give, so on a tablet this ring can
|
||||
// only ever be wrong — and it is wrong in the worst way,
|
||||
// because it is the selection ring's own colour a pixel
|
||||
// thinner. `has-hover` is not reliably cleared on touch:
|
||||
// a release normally brings an `Exit` with it, but the one
|
||||
// that ends a pinch does not, so every pinch to resize the
|
||||
// thumbnails left a ring around whichever cell a finger
|
||||
// happened to have started on. The grid then showed boxes
|
||||
// around photographs that were not selected, with no way
|
||||
// to tell them from ones that were. See `touched`.
|
||||
border-width: cell-touch.has-hover && !root.touched ? 1px : 0px;
|
||||
border-color: Theme.selected-ring;
|
||||
clip: true;
|
||||
|
||||
@@ -3218,6 +3295,8 @@ export component LibraryGrid inherits Rectangle {
|
||||
root.cell-clicked(i);
|
||||
}
|
||||
self.click-pending = false;
|
||||
// Down again, whether or not it was ever up.
|
||||
root.held-row = -1;
|
||||
root.cell-press-ended();
|
||||
}
|
||||
// `cancel` is the important ending: the Flickable
|
||||
@@ -3226,6 +3305,12 @@ export component LibraryGrid inherits Rectangle {
|
||||
// would come to rest as a long press and select it.
|
||||
if (ev.kind == PointerEventKind.cancel) {
|
||||
self.click-pending = false;
|
||||
// Including the cancel a starting drag sends:
|
||||
// by then the `DragArea` has the gesture and
|
||||
// `lifted` is the mark that matters, so there
|
||||
// is no scroll left to lose and nothing to
|
||||
// keep the ring open for.
|
||||
root.held-row = -1;
|
||||
root.cell-press-ended();
|
||||
}
|
||||
}
|
||||
@@ -3399,6 +3484,51 @@ export component LibraryGrid inherits Rectangle {
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
// **The photograph is in your hand now.**
|
||||
//
|
||||
// A ring that opens outward around the cell a press has held
|
||||
// long enough to pick up (see `held-row`), so the wait the
|
||||
// gesture needs has something to end in. Without it the user
|
||||
// is holding a finger on glass with no way to know whether
|
||||
// anything has happened — which is what made dragging feel
|
||||
// like a coin toss.
|
||||
//
|
||||
// **Drawn after the cells, not on one.** Cells are one `for`,
|
||||
// and z-order inside a `for` is the loop order — a cell that
|
||||
// grew past its bounds would stand over its left and top
|
||||
// neighbours and be cut off by its right and bottom ones,
|
||||
// which reads as a rendering fault rather than as a lift. One
|
||||
// element after the loop is above every cell by construction,
|
||||
// and there is only ever one photograph in the hand.
|
||||
//
|
||||
// `visible` rather than `if`, so it has somewhere to animate
|
||||
// *from*: an `if` builds the ring at its final size and it
|
||||
// would appear rather than open.
|
||||
//
|
||||
// `Theme.active` and 3px, deliberately unlike the selection
|
||||
// ring inside the cell — two marks that meant different things
|
||||
// in one colour is the mistake the hover ring made.
|
||||
Rectangle {
|
||||
property <length> pitch: root.cell-size + Theme.gap;
|
||||
property <length> reach: root.held-row >= 0 ? 5px : 0px;
|
||||
|
||||
visible: root.held-row >= 0;
|
||||
x: Theme.gap
|
||||
+ mod(root.held-row + root.offset, root.columns) * self.pitch
|
||||
- self.reach;
|
||||
y: Theme.gap
|
||||
+ floor((root.held-row + root.offset) / root.columns) * self.pitch
|
||||
- self.reach;
|
||||
width: root.cell-size + 2 * self.reach;
|
||||
height: root.cell-size + 2 * self.reach;
|
||||
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||||
|
||||
background: transparent;
|
||||
border-width: 3px;
|
||||
border-color: Theme.active;
|
||||
border-radius: Theme.radius;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user