Take the whole run a shift-click names, not the part that happens to be loaded
Shift-clicking two photographs selected only the cells between them that were in the loaded window. The grid is a window of a hundred or so over a library of twenty thousand, and `apply_press` resolved the range against that window — `ids[lo_row..=hi_row]`, clamped to what was there. Everything else in the run had no id anywhere in the UI, so it was silently dropped. The user cannot see that: the selection count is off screen along with the photographs, and the gesture only announces itself when the drop files a dozen images instead of two hundred. The two ends are *ordinals*, and only the catalog knows what lies between them. `read_ids_span` asks it, through the same predicates, the same rating filter and the same ordering the window itself is read with — an ordinal names a photograph only relative to an ordering, so a run taken through any other one is a run through a different library. That ordering is now a constant, `GRID_ORDER`, shared by the window, the trash's own order beside it, and the run: capture time first, with the file name breaking ties and nothing more. A card written by two cameras interleaves names that have nothing to do with each other, and what "everything between these two" means to a photographer is a stretch of an afternoon. The query is reached through a closure handed to `CollectionsController` at wiring time rather than a catalog handle, because the scope and the filter that bound the run belong to the grid's controller. `apply_press` stays a pure function of what it is given, which is what keeps the selection rules testable with no library open — and the tests pass a run that reads a plain slice. Where there is nothing to ask, the loaded window is still used: a poorer answer than the catalog's and a far better one than a gesture that appears to do nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+202
-67
@@ -86,6 +86,13 @@ impl PressUndo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// How a run of grid ordinals is turned into the ids it names.
|
||||||
|
///
|
||||||
|
/// See [`CollectionsController::span_source`] for why this is supplied rather
|
||||||
|
/// than reached for.
|
||||||
|
type SpanIds = Rc<dyn Fn(usize, usize) -> Vec<ImageId>>;
|
||||||
|
|
||||||
/// Selection, drag, and tree state for the running window.
|
/// Selection, drag, and tree state for the running window.
|
||||||
///
|
///
|
||||||
/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and
|
/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and
|
||||||
@@ -233,6 +240,18 @@ pub struct CollectionsController {
|
|||||||
/// the tree, the grid cells — and doing that from inside the `dropped`
|
/// the tree, the grid cells — and doing that from inside the `dropped`
|
||||||
/// handler destroys the elements Slint is still using to deliver the event.
|
/// handler destroys the elements Slint is still using to deliver the event.
|
||||||
dropped_on: RefCell<Option<CollectionId>>,
|
dropped_on: RefCell<Option<CollectionId>>,
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// How a run of grid ordinals is turned into ids.
|
||||||
|
///
|
||||||
|
/// Supplied by [`crate::library_ui`] at wiring time, because the catalog,
|
||||||
|
/// the scope and the rating filter — everything the run depends on — belong
|
||||||
|
/// to the grid's controller, not to this one. Held as a closure rather than
|
||||||
|
/// reached for through a handle so the selection rules stay testable with
|
||||||
|
/// no library open: the tests pass a run that reads a plain slice.
|
||||||
|
///
|
||||||
|
/// `None` before wiring, which the caller reads as "nothing to ask" and
|
||||||
|
/// falls back to the loaded window.
|
||||||
|
span_source: RefCell<Option<SpanIds>>,
|
||||||
/// Where the trash workers report what they are doing.
|
/// Where the trash workers report what they are doing.
|
||||||
///
|
///
|
||||||
/// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in
|
/// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in
|
||||||
@@ -318,6 +337,15 @@ impl CollectionsController {
|
|||||||
self.selection.borrow().iter().copied().collect()
|
self.selection.borrow().iter().copied().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// The ids of grid rows `first..=last`, in the order the grid shows them.
|
||||||
|
///
|
||||||
|
/// Empty when nothing has been wired in — see [`Self::span_source`].
|
||||||
|
fn span(&self, first: usize, last: usize) -> Vec<ImageId> {
|
||||||
|
let source = self.span_source.borrow().clone();
|
||||||
|
source.map(|f| f(first, last)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the selection — after a scrub, or when the scope changes.
|
/// Drop the selection — after a scrub, or when the scope changes.
|
||||||
///
|
///
|
||||||
/// Selection is by id and survives a window change, but a selection the
|
/// Selection is by id and survives a window change, but a selection the
|
||||||
@@ -359,6 +387,11 @@ impl CollectionsController {
|
|||||||
/// `ids` is the **loaded window**, `offset` where it starts in the library and
|
/// `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
|
/// `row` a position within it; the anchor is kept as `offset + row`, an
|
||||||
/// ordinal that still names the same photograph after the window has moved.
|
/// ordinal that still names the same photograph after the window has moved.
|
||||||
|
///
|
||||||
|
/// `span` turns a run of ordinals into the ids it names — the catalog's job,
|
||||||
|
/// since the run is mostly not loaded. Passed in rather than reached for so
|
||||||
|
/// this stays a pure function of what it is given.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn apply_press(
|
pub fn apply_press(
|
||||||
selection: &mut BTreeSet<ImageId>,
|
selection: &mut BTreeSet<ImageId>,
|
||||||
anchor: &mut Option<usize>,
|
anchor: &mut Option<usize>,
|
||||||
@@ -367,6 +400,7 @@ pub fn apply_press(
|
|||||||
row: usize,
|
row: usize,
|
||||||
ctrl: bool,
|
ctrl: bool,
|
||||||
shift: bool,
|
shift: bool,
|
||||||
|
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||||
) {
|
) {
|
||||||
let Some(&id) = ids.get(row) else { return };
|
let Some(&id) = ids.get(row) else { return };
|
||||||
let here = offset + row;
|
let here = offset + row;
|
||||||
@@ -401,22 +435,29 @@ pub fn apply_press(
|
|||||||
(here, from)
|
(here, from)
|
||||||
};
|
};
|
||||||
|
|
||||||
// The range is described in ordinals and applied to the window: only
|
// The run is described in ordinals and resolved by the catalog, which
|
||||||
// ids that are loaded can be selected, because selection is by id and
|
// is the only thing that knows what lies between them. Selection is by
|
||||||
// an image the catalog has not been asked for has no id here. A range
|
// id, and an image outside the loaded window has no id here — so an
|
||||||
// longer than the loaded window is therefore truncated to it rather
|
// earlier version truncated the run to what was on screen, and
|
||||||
// than silently selecting the wrong photographs — which is what
|
// shift-clicking two ends of a morning selected the dozen cells that
|
||||||
// holding the anchor as a window row used to do.
|
// happened to be loaded. The user cannot see that they did not get
|
||||||
//
|
// what they asked for until the drop files a dozen photographs
|
||||||
// The slice is always in range and needs no guard: `ids.get(row)`
|
// instead of two hundred.
|
||||||
// succeeded, so the window is non-empty and `here` is inside it, and
|
let mut run = span(lo, hi);
|
||||||
// `here` is one of the two bounds.
|
if run.is_empty() {
|
||||||
let last = ids.len() - 1;
|
// Nothing to ask — no library open, or a query that failed. The
|
||||||
let lo_row = lo.saturating_sub(offset);
|
// loaded window is a poorer answer than the catalog's and a far
|
||||||
let hi_row = hi.saturating_sub(offset).min(last);
|
// better one than selecting nothing.
|
||||||
for id in &ids[lo_row..=hi_row] {
|
//
|
||||||
selection.insert(*id);
|
// The slice is always in range and needs no guard: `ids.get(row)`
|
||||||
|
// succeeded, so the window is non-empty and `here` is inside it,
|
||||||
|
// and `here` is one of the two bounds.
|
||||||
|
let last = ids.len() - 1;
|
||||||
|
let lo_row = lo.saturating_sub(offset);
|
||||||
|
let hi_row = hi.saturating_sub(offset).min(last);
|
||||||
|
run = ids[lo_row..=hi_row].to_vec();
|
||||||
}
|
}
|
||||||
|
selection.extend(run);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,9 +499,10 @@ pub fn press_remembering_anchor(
|
|||||||
row: usize,
|
row: usize,
|
||||||
ctrl: bool,
|
ctrl: bool,
|
||||||
shift: bool,
|
shift: bool,
|
||||||
|
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||||
) {
|
) {
|
||||||
let before = *anchor;
|
let before = *anchor;
|
||||||
apply_press(selection, anchor, ids, offset, row, ctrl, shift);
|
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span);
|
||||||
// Only a press that *moved* the anchor updates this. The second tap of a
|
// 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
|
// double tap lands on the cell the first tap made the anchor, so it changes
|
||||||
// nothing and the origin survives to be extended from.
|
// nothing and the origin survives to be extended from.
|
||||||
@@ -476,6 +518,7 @@ pub fn press_remembering_anchor(
|
|||||||
/// selection mode, which the user entered deliberately, and a gesture that
|
/// selection mode, which the user entered deliberately, and a gesture that
|
||||||
/// silently discarded the run they gathered a moment ago would make gathering
|
/// silently discarded the run they gathered a moment ago would make gathering
|
||||||
/// two runs impossible.
|
/// two runs impossible.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn extend_to_row(
|
pub fn extend_to_row(
|
||||||
selection: &mut BTreeSet<ImageId>,
|
selection: &mut BTreeSet<ImageId>,
|
||||||
anchor: &mut Option<usize>,
|
anchor: &mut Option<usize>,
|
||||||
@@ -483,11 +526,12 @@ pub fn extend_to_row(
|
|||||||
ids: &[ImageId],
|
ids: &[ImageId],
|
||||||
offset: usize,
|
offset: usize,
|
||||||
row: usize,
|
row: usize,
|
||||||
|
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||||
) {
|
) {
|
||||||
// Falling back to the current anchor makes a double tap with no history
|
// 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.
|
// select just that cell, which is what a double tap already did.
|
||||||
*anchor = previous.or(*anchor);
|
*anchor = previous.or(*anchor);
|
||||||
apply_press(selection, anchor, ids, offset, row, true, true);
|
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
|
/// Apply a press and push the result into the grid — the whole of what a
|
||||||
@@ -525,6 +569,7 @@ pub fn select_row(
|
|||||||
row,
|
row,
|
||||||
ctrl,
|
ctrl,
|
||||||
shift,
|
shift,
|
||||||
|
&|first, last| ctl.span(first, last),
|
||||||
);
|
);
|
||||||
ctl.previous_anchor.set(previous);
|
ctl.previous_anchor.set(previous);
|
||||||
// The cursor follows the press, so an arrow key after a click continues
|
// The cursor follows the press, so an arrow key after a click continues
|
||||||
@@ -1401,16 +1446,19 @@ fn format_bytes(bytes: u64) -> String {
|
|||||||
/// `on_scope_changed` reloads the grid — that lives in [`crate::library_ui`],
|
/// `on_scope_changed` reloads the grid — that lives in [`crate::library_ui`],
|
||||||
/// which owns the window and the thumbnail workers, so it is passed in rather
|
/// which owns the window and the thumbnail workers, so it is passed in rather
|
||||||
/// than reached for.
|
/// than reached for.
|
||||||
pub fn wire<S, R, C>(
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn wire<S, R, P, C>(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
ctl: Rc<CollectionsController>,
|
ctl: Rc<CollectionsController>,
|
||||||
catalog: Rc<RefCell<Option<Catalog>>>,
|
catalog: Rc<RefCell<Option<Catalog>>>,
|
||||||
on_scope_changed: S,
|
on_scope_changed: S,
|
||||||
visible_ids: R,
|
visible_ids: R,
|
||||||
|
span_ids: P,
|
||||||
session: C,
|
session: C,
|
||||||
) where
|
) where
|
||||||
S: Fn() + 'static,
|
S: Fn() + 'static,
|
||||||
R: Fn() -> Vec<ImageId> + 'static,
|
R: Fn() -> Vec<ImageId> + 'static,
|
||||||
|
P: Fn(usize, usize) -> Vec<ImageId> + 'static,
|
||||||
C: Fn() -> Option<(
|
C: Fn() -> Option<(
|
||||||
dr_sync_nextcloud::AppCredentials,
|
dr_sync_nextcloud::AppCredentials,
|
||||||
dr_sync_nextcloud::Session,
|
dr_sync_nextcloud::Session,
|
||||||
@@ -1421,6 +1469,9 @@ pub fn wire<S, R, C>(
|
|||||||
// make each of them a separate instantiation for no gain.
|
// make each of them a separate instantiation for no gain.
|
||||||
let on_scope_changed: Rc<dyn Fn()> = Rc::new(on_scope_changed);
|
let on_scope_changed: Rc<dyn Fn()> = Rc::new(on_scope_changed);
|
||||||
let visible_ids = Rc::new(visible_ids);
|
let visible_ids = Rc::new(visible_ids);
|
||||||
|
// A shift-click asks the catalog what lies between its two ends, and the
|
||||||
|
// catalog belongs to the grid's controller — see `span_source`.
|
||||||
|
*ctl.span_source.borrow_mut() = Some(Rc::new(span_ids));
|
||||||
let session: Rc<
|
let session: Rc<
|
||||||
dyn Fn() -> Option<(
|
dyn Fn() -> Option<(
|
||||||
dr_sync_nextcloud::AppCredentials,
|
dr_sync_nextcloud::AppCredentials,
|
||||||
@@ -1500,6 +1551,7 @@ pub fn wire<S, R, C>(
|
|||||||
&ids,
|
&ids,
|
||||||
offset,
|
offset,
|
||||||
row as usize,
|
row as usize,
|
||||||
|
&|first, last| ctl.span(first, last),
|
||||||
);
|
);
|
||||||
ctl.set_cursor(Some(offset + row as usize));
|
ctl.set_cursor(Some(offset + row as usize));
|
||||||
sync_selection(&w, &ctl, &ids);
|
sync_selection(&w, &ctl, &ids);
|
||||||
@@ -2494,6 +2546,28 @@ mod tests {
|
|||||||
(1..=n).map(ImageId).collect()
|
(1..=n).map(ImageId).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The catalog's answer to "what is between these two ordinals".
|
||||||
|
///
|
||||||
|
/// Stands in for [`crate::library_ui::LibraryController::ids_in_span`],
|
||||||
|
/// which does the same thing with a `LIMIT`/`OFFSET`: the whole library is
|
||||||
|
/// available to the query whatever the grid happens to have loaded, and an
|
||||||
|
/// ordinal indexes it directly.
|
||||||
|
fn library(all: &[ImageId]) -> impl Fn(usize, usize) -> Vec<ImageId> + '_ {
|
||||||
|
move |first, last| {
|
||||||
|
all.iter()
|
||||||
|
.copied()
|
||||||
|
.skip(first)
|
||||||
|
.take((last + 1).saturating_sub(first))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No catalog to ask — what a press sees before a library is open, and if
|
||||||
|
/// the query fails.
|
||||||
|
fn unloaded(_first: usize, _last: usize) -> Vec<ImageId> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dragging_a_collection_onto_another_moves_it() {
|
fn dragging_a_collection_onto_another_moves_it() {
|
||||||
// The gesture the tree rearrangement exists for: no images carried, a
|
// The gesture the tree rearrangement exists for: no images carried, a
|
||||||
@@ -2535,11 +2609,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_plain_press_replaces_the_selection() {
|
fn a_plain_press_replaces_the_selection() {
|
||||||
let all = ids(5);
|
let all = ids(5);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||||
|
|
||||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(3)]);
|
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(3)]);
|
||||||
}
|
}
|
||||||
@@ -2547,26 +2622,28 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ctrl_press_adds_and_then_removes() {
|
fn ctrl_press_adds_and_then_removes() {
|
||||||
let all = ids(5);
|
let all = ids(5);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||||
assert_eq!(sel.len(), 2);
|
assert_eq!(sel.len(), 2);
|
||||||
|
|
||||||
// Toggling: a second ctrl-press on the same cell takes it out again.
|
// Toggling: a second ctrl-press on the same cell takes it out again.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(1)]);
|
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_press_extends_a_contiguous_range() {
|
fn shift_press_extends_a_contiguous_range() {
|
||||||
let all = ids(10);
|
let all = ids(10);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, true);
|
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
|
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
|
||||||
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
|
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
|
||||||
@@ -2575,11 +2652,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn shift_extends_backwards_too() {
|
fn shift_extends_backwards_too() {
|
||||||
let all = ids(10);
|
let all = ids(10);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true);
|
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||||
assert_eq!(sel.len(), 5);
|
assert_eq!(sel.len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2589,15 +2667,16 @@ mod tests {
|
|||||||
// clearing would turn the correction into a union, and the user would
|
// clearing would turn the correction into a union, and the user would
|
||||||
// drag cells they believed they had just deselected.
|
// drag cells they believed they had just deselected.
|
||||||
let all = ids(20);
|
let all = ids(20);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 15, false, true);
|
apply_press(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
|
||||||
assert_eq!(sel.len(), 11, "rows 5..=15");
|
assert_eq!(sel.len(), 11, "rows 5..=15");
|
||||||
|
|
||||||
// Corrected to a shorter range from the same anchor.
|
// Corrected to a shorter range from the same anchor.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 8, false, true);
|
apply_press(&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_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range");
|
||||||
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
|
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
|
||||||
}
|
}
|
||||||
@@ -2607,12 +2686,13 @@ mod tests {
|
|||||||
// If the anchor moved to each shift-click, a range could only ever be
|
// If the anchor moved to each shift-click, a range could only ever be
|
||||||
// grown, never corrected inward.
|
// grown, never corrected inward.
|
||||||
let all = ids(20);
|
let all = ids(20);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 10, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 14, false, true);
|
apply_press(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 12, false, true);
|
apply_press(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(anchor, Some(10));
|
assert_eq!(anchor, Some(10));
|
||||||
assert_eq!(sel.len(), 3, "rows 10..=12");
|
assert_eq!(sel.len(), 3, "rows 10..=12");
|
||||||
@@ -2627,7 +2707,17 @@ mod tests {
|
|||||||
all: &[ImageId],
|
all: &[ImageId],
|
||||||
row: usize,
|
row: usize,
|
||||||
) {
|
) {
|
||||||
press_remembering_anchor(sel, anchor, previous, all, 0, row, true, false);
|
press_remembering_anchor(
|
||||||
|
sel,
|
||||||
|
anchor,
|
||||||
|
previous,
|
||||||
|
all,
|
||||||
|
0,
|
||||||
|
row,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
&library(all),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2638,6 +2728,7 @@ mod tests {
|
|||||||
// it on, the second turns it off — and the double tap that follows has
|
// it on, the second turns it off — and the double tap that follows has
|
||||||
// to select the range anyway.
|
// to select the range anyway.
|
||||||
let all = ids(20);
|
let all = ids(20);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
let mut previous = None;
|
let mut previous = None;
|
||||||
@@ -2653,6 +2744,7 @@ mod tests {
|
|||||||
4,
|
4,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
&span,
|
||||||
);
|
);
|
||||||
|
|
||||||
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
||||||
@@ -2663,7 +2755,7 @@ mod tests {
|
|||||||
job to select the range rather than to add one cell"
|
job to select the range rather than to add one cell"
|
||||||
);
|
);
|
||||||
|
|
||||||
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 11);
|
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 11, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 8, "rows 4..=11");
|
assert_eq!(sel.len(), 8, "rows 4..=11");
|
||||||
assert!(sel.contains(&ImageId(5)) && sel.contains(&ImageId(12)));
|
assert!(sel.contains(&ImageId(5)) && sel.contains(&ImageId(12)));
|
||||||
@@ -2675,6 +2767,7 @@ mod tests {
|
|||||||
// user in selection mode is gathering, and the second gesture must not
|
// user in selection mode is gathering, and the second gesture must not
|
||||||
// throw away the first.
|
// throw away the first.
|
||||||
let all = ids(30);
|
let all = ids(30);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
let mut previous = None;
|
let mut previous = None;
|
||||||
@@ -2688,17 +2781,18 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
&span,
|
||||||
);
|
);
|
||||||
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
|
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
|
||||||
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);
|
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 3, &span);
|
||||||
assert_eq!(sel.len(), 4, "rows 0..=3");
|
assert_eq!(sel.len(), 4, "rows 0..=3");
|
||||||
|
|
||||||
// A second run, begun with a plain tap somewhere else.
|
// 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, 20);
|
||||||
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
|
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
|
||||||
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);
|
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 25, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 10, "rows 0..=3 and 20..=25");
|
assert_eq!(sel.len(), 10, "rows 0..=3 and 20..=25");
|
||||||
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(26)));
|
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(26)));
|
||||||
@@ -2709,11 +2803,12 @@ mod tests {
|
|||||||
// The degenerate case: selection mode entered from the header's button
|
// The degenerate case: selection mode entered from the header's button
|
||||||
// rather than by holding a cell, so nothing has anchored yet.
|
// rather than by holding a cell, so nothing has anchored yet.
|
||||||
let all = ids(10);
|
let all = ids(10);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
let previous = None;
|
let previous = None;
|
||||||
|
|
||||||
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 6);
|
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 6, &span);
|
||||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(7)]);
|
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(7)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2722,16 +2817,17 @@ mod tests {
|
|||||||
// Picking up a second run without losing the first: the one case where
|
// Picking up a second run without losing the first: the one case where
|
||||||
// a shift-click must not clear.
|
// a shift-click must not clear.
|
||||||
let all = ids(20);
|
let all = ids(20);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true);
|
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||||
assert_eq!(sel.len(), 3);
|
assert_eq!(sel.len(), 3);
|
||||||
|
|
||||||
// A new anchor by ctrl-click, then a ctrl+shift range from it.
|
// A new anchor by ctrl-click, then a ctrl+shift range from it.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 10, true, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 12, true, true);
|
apply_press(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
|
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
|
||||||
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
|
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
|
||||||
@@ -2742,16 +2838,17 @@ mod tests {
|
|||||||
// This is what makes a multi-image drag possible: the press that starts
|
// This is what makes a multi-image drag possible: the press that starts
|
||||||
// the drag must not collapse what it is about to carry.
|
// the drag must not collapse what it is about to carry.
|
||||||
let all = ids(5);
|
let all = ids(5);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, true, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, true, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
|
||||||
assert_eq!(sel.len(), 3);
|
assert_eq!(sel.len(), 3);
|
||||||
|
|
||||||
// Pressing one of the three to begin a drag.
|
// Pressing one of the three to begin a drag.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||||
assert_eq!(sel.len(), 3, "the selection survived the press");
|
assert_eq!(sel.len(), 3, "the selection survived the press");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2763,17 +2860,18 @@ mod tests {
|
|||||||
// the selection but left the anchor moved would make the next
|
// the selection but left the anchor moved would make the next
|
||||||
// shift-click select a run from a cell nobody pointed at.
|
// shift-click select a run from a cell nobody pointed at.
|
||||||
let all = ids(6);
|
let all = ids(6);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
// A selection built the ordinary way, and the state it leaves behind.
|
// A selection built the ordinary way, and the state it leaves behind.
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||||
let before = (sel.clone(), anchor);
|
let before = (sel.clone(), anchor);
|
||||||
|
|
||||||
// The finger that opens a pinch.
|
// The finger that opens a pinch.
|
||||||
let undo = PressUndo::capture(&sel, anchor, Some(1), Some(3));
|
let undo = PressUndo::capture(&sel, anchor, Some(1), Some(3));
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
(sel.clone(), anchor),
|
(sel.clone(), anchor),
|
||||||
before,
|
before,
|
||||||
@@ -2791,10 +2889,11 @@ mod tests {
|
|||||||
fn a_press_past_the_end_of_the_window_is_ignored() {
|
fn a_press_past_the_end_of_the_window_is_ignored() {
|
||||||
// The grid is windowed and a stale row index can arrive after a scrub.
|
// The grid is windowed and a stale row index can arrive after a scrub.
|
||||||
let all = ids(3);
|
let all = ids(3);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 99, false, false);
|
apply_press(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
|
||||||
assert!(sel.is_empty());
|
assert!(sel.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2806,6 +2905,7 @@ mod tests {
|
|||||||
// that row — so shift-clicking after scrolling selected a run the user
|
// that row — so shift-clicking after scrolling selected a run the user
|
||||||
// never pointed at, silently and with no way to tell.
|
// never pointed at, silently and with no way to tell.
|
||||||
let all = ids(20);
|
let all = ids(20);
|
||||||
|
let span = library(&all);
|
||||||
let first: Vec<_> = all[0..10].to_vec();
|
let first: Vec<_> = all[0..10].to_vec();
|
||||||
let later: Vec<_> = all[4..14].to_vec();
|
let later: Vec<_> = all[4..14].to_vec();
|
||||||
|
|
||||||
@@ -2813,12 +2913,12 @@ mod tests {
|
|||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
// Anchor on the sixth image, in a window starting at the beginning.
|
// Anchor on the sixth image, in a window starting at the beginning.
|
||||||
apply_press(&mut sel, &mut anchor, &first, 0, 5, false, false);
|
apply_press(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
|
||||||
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
|
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
|
||||||
|
|
||||||
// The user scrolls — the window now starts four images in — and
|
// The user scrolls — the window now starts four images in — and
|
||||||
// shift-clicks the image at ordinal 10.
|
// shift-clicks the image at ordinal 10.
|
||||||
apply_press(&mut sel, &mut anchor, &later, 4, 6, false, true);
|
apply_press(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 6, "ordinals 5..=10");
|
assert_eq!(sel.len(), 6, "ordinals 5..=10");
|
||||||
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
|
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
|
||||||
@@ -2827,51 +2927,86 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_range_reaching_outside_the_window_selects_what_is_loaded() {
|
fn a_range_reaching_outside_the_window_selects_the_whole_run() {
|
||||||
// Selection is by id, so a range can only cover images the window
|
// The bug this exists for: the anchor is far above the loaded window,
|
||||||
// holds. Truncating is the honest answer; the alternative — indexing
|
// so all but four of the photographs in the range are off screen. They
|
||||||
// from the window's start as though it were the library's — selects
|
// are still what the user asked for, and the catalog is what knows
|
||||||
// the wrong photographs and looks like it worked.
|
// their ids — reading the range from the window selected the four that
|
||||||
|
// happened to be loaded and looked as though it had worked.
|
||||||
let all = ids(30);
|
let all = ids(30);
|
||||||
|
let span = library(&all);
|
||||||
let window: Vec<_> = all[10..20].to_vec();
|
let window: Vec<_> = all[10..20].to_vec();
|
||||||
|
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = Some(0);
|
let mut anchor = Some(0);
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &window, 10, 3, false, true);
|
apply_press(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13");
|
assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not");
|
||||||
assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14)));
|
assert!(
|
||||||
|
sel.contains(&ImageId(1)),
|
||||||
|
"the anchored image, never loaded"
|
||||||
|
);
|
||||||
|
assert!(sel.contains(&ImageId(14)), "up to the cell pressed");
|
||||||
|
assert!(!sel.contains(&ImageId(15)), "and no further");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_range_running_off_the_far_end_stops_at_the_window() {
|
fn a_range_running_off_the_far_end_is_whole_too() {
|
||||||
// The mirror of the case above, with the anchor *ahead* of the press
|
// The mirror of the case above, with the anchor *ahead* of the press
|
||||||
// instead of behind it. Both directions truncate to what is loaded,
|
// instead of behind it. Neither direction may stop at the window, and
|
||||||
// and neither may wrap round to the other end of the window.
|
// neither may wrap round to the other end of it.
|
||||||
let all = ids(30);
|
let all = ids(30);
|
||||||
|
let span = library(&all);
|
||||||
let window: Vec<_> = all[0..5].to_vec();
|
let window: Vec<_> = all[0..5].to_vec();
|
||||||
|
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = Some(25);
|
let mut anchor = Some(25);
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &window, 0, 2, false, true);
|
apply_press(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
|
||||||
|
|
||||||
assert_eq!(sel.len(), 3, "ordinals 2..=4, the loaded part of 2..=25");
|
assert_eq!(sel.len(), 24, "ordinals 2..=25");
|
||||||
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(5)));
|
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26)));
|
||||||
assert!(
|
assert!(
|
||||||
!sel.contains(&ImageId(1)),
|
!sel.contains(&ImageId(1)),
|
||||||
"nothing before the pressed cell"
|
"nothing before the pressed cell"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_range_with_no_catalog_to_ask_falls_back_to_the_window() {
|
||||||
|
// Before a library is open, and if the query fails. What is loaded is
|
||||||
|
// a poorer answer than the catalog's and a far better one than a
|
||||||
|
// gesture that appears to do nothing.
|
||||||
|
let all = ids(30);
|
||||||
|
let window: Vec<_> = all[10..20].to_vec();
|
||||||
|
|
||||||
|
let mut sel = BTreeSet::new();
|
||||||
|
let mut anchor = Some(0);
|
||||||
|
|
||||||
|
apply_press(
|
||||||
|
&mut sel,
|
||||||
|
&mut anchor,
|
||||||
|
&window,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
&unloaded,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13");
|
||||||
|
assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_without_an_anchor_selects_just_the_one() {
|
fn shift_without_an_anchor_selects_just_the_one() {
|
||||||
let all = ids(5);
|
let all = ids(5);
|
||||||
|
let span = library(&all);
|
||||||
let mut sel = BTreeSet::new();
|
let mut sel = BTreeSet::new();
|
||||||
let mut anchor = None;
|
let mut anchor = None;
|
||||||
|
|
||||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, false, true);
|
apply_press(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
|
||||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(4)]);
|
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(4)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -869,6 +869,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let lib = library.clone();
|
let lib = library.clone();
|
||||||
let coll = collections.clone();
|
let coll = collections.clone();
|
||||||
let lib_ids = library.clone();
|
let lib_ids = library.clone();
|
||||||
|
let lib_span = library.clone();
|
||||||
let lib_session = library.clone();
|
let lib_session = library.clone();
|
||||||
collections_ui::wire(
|
collections_ui::wire(
|
||||||
&window,
|
&window,
|
||||||
@@ -885,6 +886,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
library_ui::reload(&w, &lib);
|
library_ui::reload(&w, &lib);
|
||||||
},
|
},
|
||||||
move || lib_ids.visible_ids(),
|
move || lib_ids.visible_ids(),
|
||||||
|
// What a shift-click selects: the whole run between its two
|
||||||
|
// ends, read from the catalog rather than from the hundred or
|
||||||
|
// so rows that happen to be loaded.
|
||||||
|
move |first, last| lib_span.ids_in_span(first, last),
|
||||||
// The trash's MOVE and DELETE go to the same account the scan
|
// The trash's MOVE and DELETE go to the same account the scan
|
||||||
// and thumbnail workers use.
|
// and thumbnail workers use.
|
||||||
move || lib_session.session(),
|
move || lib_session.session(),
|
||||||
|
|||||||
+166
-3
@@ -176,6 +176,30 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
|
|||||||
/// restore the same frame twice.
|
/// restore the same frame twice.
|
||||||
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
|
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-4
|
||||||
|
/// The order the grid lists photographs in: when they were taken.
|
||||||
|
///
|
||||||
|
/// The file name breaks ties and nothing more — two frames of one burst, or a
|
||||||
|
/// RAW beside the JPEG the camera wrote with it. What a photographer looks for
|
||||||
|
/// is the afternoon, not what the camera called the file, and a grid ordered by
|
||||||
|
/// name interleaves every camera and every card that ever wrote into the same
|
||||||
|
/// folder.
|
||||||
|
///
|
||||||
|
/// Shared rather than spelled out per query, for the same reason [`VISIBLE`] is:
|
||||||
|
/// the window, the count and the run a shift-click resolves are three answers
|
||||||
|
/// about one list, and an ordering that drifted between them would select
|
||||||
|
/// photographs the user never saw without any of it looking wrong.
|
||||||
|
///
|
||||||
|
/// Undated images sort last in either direction. EXIF is read as thumbnails
|
||||||
|
/// load, so a freshly scanned library would otherwise open on the images it
|
||||||
|
/// knows least about.
|
||||||
|
const GRID_ORDER: &str = "ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC";
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-15
|
||||||
|
/// [`GRID_ORDER`] for the trash, which is ordered by when a thing was deleted —
|
||||||
|
/// see [`read_trashed_cells`] for why that view answers a different question.
|
||||||
|
const TRASH_ORDER: &str = "ORDER BY i.trashed_at DESC, i.source_ref ASC";
|
||||||
|
|
||||||
/// TRACES: FR-CAT-6 | FR-CULL-4
|
/// TRACES: FR-CAT-6 | FR-CULL-4
|
||||||
/// What the grid is narrowed to by the rating filter bar.
|
/// What the grid is narrowed to by the rating filter bar.
|
||||||
///
|
///
|
||||||
@@ -2919,7 +2943,7 @@ pub fn read_cells_scoped(
|
|||||||
WHERE {VISIBLE}{rated}
|
WHERE {VISIBLE}{rated}
|
||||||
AND i.id IN (SELECT image_id FROM collection_members
|
AND i.id IN (SELECT image_id FROM collection_members
|
||||||
WHERE collection_id IN ({placeholders}))
|
WHERE collection_id IN ({placeholders}))
|
||||||
ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC
|
{GRID_ORDER}
|
||||||
LIMIT ? OFFSET ?"
|
LIMIT ? OFFSET ?"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2950,7 +2974,7 @@ fn read_cells_all(
|
|||||||
FROM images i
|
FROM images i
|
||||||
LEFT JOIN remote r ON r.image_id = i.id
|
LEFT JOIN remote r ON r.image_id = i.id
|
||||||
WHERE {VISIBLE}{rated}
|
WHERE {VISIBLE}{rated}
|
||||||
ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC
|
{GRID_ORDER}
|
||||||
LIMIT ?1 OFFSET ?2"
|
LIMIT ?1 OFFSET ?2"
|
||||||
))?;
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
@@ -2982,7 +3006,7 @@ pub fn read_trashed_cells(
|
|||||||
FROM images i
|
FROM images i
|
||||||
LEFT JOIN remote r ON r.image_id = i.id
|
LEFT JOIN remote r ON r.image_id = i.id
|
||||||
WHERE {TRASHED}
|
WHERE {TRASHED}
|
||||||
ORDER BY i.trashed_at DESC, i.source_ref ASC
|
{TRASH_ORDER}
|
||||||
LIMIT ?1 OFFSET ?2"
|
LIMIT ?1 OFFSET ?2"
|
||||||
))?;
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
@@ -3005,6 +3029,68 @@ pub fn total_trashed(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogErro
|
|||||||
Ok(n as usize)
|
Ok(n as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// The ids of grid rows `first..=last`, in the order the grid lists them.
|
||||||
|
///
|
||||||
|
/// What a shift-click actually means. The gesture names two *ordinals* and the
|
||||||
|
/// photographs between them are mostly not loaded — the grid is a window of a
|
||||||
|
/// hundred or so over a library of twenty thousand — so a range answered from
|
||||||
|
/// the window selected the handful on screen and silently dropped the rest.
|
||||||
|
/// The catalog knows the whole run, and with the ordering indexed it is one
|
||||||
|
/// seek rather than a scan.
|
||||||
|
///
|
||||||
|
/// Bounded by the same predicates, the same filter and the same [`GRID_ORDER`]
|
||||||
|
/// the window itself is read with. An ordinal only names a photograph relative
|
||||||
|
/// to an ordering, so a range taken through any other one is a range through a
|
||||||
|
/// different library.
|
||||||
|
///
|
||||||
|
/// `trash` picks the trash view's list, which is the other thing the grid can
|
||||||
|
/// be showing and is ordered by deletion time rather than capture time.
|
||||||
|
///
|
||||||
|
/// An empty result means the run is empty or the query failed; callers treat
|
||||||
|
/// the two the same, because both leave the selection where it was.
|
||||||
|
pub fn read_ids_span(
|
||||||
|
catalog: &Catalog,
|
||||||
|
scope: Option<dr_types::CollectionId>,
|
||||||
|
filter: &RatingFilter,
|
||||||
|
trash: bool,
|
||||||
|
first: usize,
|
||||||
|
last: usize,
|
||||||
|
) -> Result<Vec<dr_types::ImageId>, dr_catalog::CatalogError> {
|
||||||
|
let Some(count) = (last + 1).checked_sub(first) else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sql, mut params) = if trash {
|
||||||
|
(
|
||||||
|
format!("SELECT i.id FROM images i WHERE {TRASHED} {TRASH_ORDER} LIMIT ? OFFSET ?"),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let (clause, params) = scope_clause(catalog, scope)?;
|
||||||
|
let rated = filter.sql();
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
"SELECT i.id FROM images i
|
||||||
|
WHERE {VISIBLE}{rated}{clause}
|
||||||
|
{GRID_ORDER}
|
||||||
|
LIMIT ? OFFSET ?"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
params.push(rusqlite::types::Value::Integer(count as i64));
|
||||||
|
params.push(rusqlite::types::Value::Integer(first as i64));
|
||||||
|
|
||||||
|
let mut stmt = catalog.connection().prepare(&sql)?;
|
||||||
|
let ids = stmt
|
||||||
|
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||||
|
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
|
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
|
||||||
fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
|
fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
|
||||||
let path: String = r.get(1)?;
|
let path: String = r.get(1)?;
|
||||||
@@ -4214,6 +4300,83 @@ mod tests {
|
|||||||
assert!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().is_empty());
|
assert!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_span_reads_the_whole_run_whether_or_not_it_is_loaded() {
|
||||||
|
// The shift-click this exists for. The grid holds a window of five and
|
||||||
|
// the user names a run of twelve, so seven of them have no cell and no
|
||||||
|
// id anywhere in the UI — but they are still what was asked for, and
|
||||||
|
// the catalog is what knows them.
|
||||||
|
let catalog = scanned(12);
|
||||||
|
let filter = RatingFilter::default();
|
||||||
|
|
||||||
|
let loaded = read_cells_all(&catalog, &filter, 0, 5).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 5, "the window is smaller than the run");
|
||||||
|
|
||||||
|
let whole: Vec<_> = read_cells_all(&catalog, &filter, 0, 50)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||||||
|
.collect();
|
||||||
|
let span = read_ids_span(&catalog, None, &filter, false, 0, 11).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(span.len(), 12);
|
||||||
|
assert_eq!(span, whole, "the run is the grid's own list, in its order");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_span_starts_and_ends_where_it_was_asked_to() {
|
||||||
|
// Ordinals index the grid's list, so a run has to be exactly the slice
|
||||||
|
// of it the two ends name — one off at either end selects a
|
||||||
|
// photograph the user did not point at.
|
||||||
|
let catalog = scanned(12);
|
||||||
|
let filter = RatingFilter::default();
|
||||||
|
let whole: Vec<_> = read_cells_all(&catalog, &filter, 0, 50)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let span = read_ids_span(&catalog, None, &filter, false, 4, 6).unwrap();
|
||||||
|
assert_eq!(span, whole[4..=6], "ordinals 4..=6, inclusive at both ends");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_span_is_ordered_by_capture_time_rather_than_by_name() {
|
||||||
|
// A card written by two cameras interleaves names that have nothing to
|
||||||
|
// do with each other. What a photographer means by "everything between
|
||||||
|
// these two" is a stretch of an afternoon, so the run has to be taken
|
||||||
|
// through capture time — the ordering the grid draws them in.
|
||||||
|
let catalog = scanned(3);
|
||||||
|
let conn = catalog.connection();
|
||||||
|
for (n, at) in [(1, 9_000), (2, 5_000), (3, 1_000)] {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1",
|
||||||
|
rusqlite::params![format!("%IMG_000{n}%"), at],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let span = read_ids_span(&catalog, None, &RatingFilter::default(), false, 0, 2).unwrap();
|
||||||
|
let names: Vec<String> = span
|
||||||
|
.iter()
|
||||||
|
.map(|id| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT source_ref FROM images WHERE id = ?1",
|
||||||
|
[id.0 as i64],
|
||||||
|
|r| r.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
names[0].ends_with("IMG_0003.CR2")
|
||||||
|
&& names[1].ends_with("IMG_0002.CR2")
|
||||||
|
&& names[2].ends_with("IMG_0001.CR2"),
|
||||||
|
"earliest first, which here is the reverse of the file names: {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_histogram_ignores_the_range_it_is_used_to_choose() {
|
fn the_histogram_ignores_the_range_it_is_used_to_choose() {
|
||||||
// Drawing the axis through the chosen range would collapse it onto the
|
// Drawing the axis through the chosen range would collapse it onto the
|
||||||
|
|||||||
@@ -590,6 +590,38 @@ impl LibraryController {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// Catalog ids of grid rows `first..=last`, loaded or not.
|
||||||
|
///
|
||||||
|
/// The counterpart to [`Self::visible_ids`], and the reason both exist: a
|
||||||
|
/// shift-click names a run by its two ends, and everything between them is
|
||||||
|
/// usually off screen. Answered from the catalog, through the same scope,
|
||||||
|
/// filter and ordering this controller reads its window with — so the run
|
||||||
|
/// is the run the user can see themselves selecting, continued past the
|
||||||
|
/// edge of what has been loaded.
|
||||||
|
///
|
||||||
|
/// Empty before a library is open, and empty if the query fails: a
|
||||||
|
/// selection gesture is not worth an error dialog, and the caller keeps
|
||||||
|
/// what was already selected.
|
||||||
|
pub fn ids_in_span(&self, first: usize, last: usize) -> Vec<dr_types::ImageId> {
|
||||||
|
let borrow = self.catalog.borrow();
|
||||||
|
let Some(catalog) = borrow.as_ref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
library::read_ids_span(
|
||||||
|
catalog,
|
||||||
|
*self.scope.borrow(),
|
||||||
|
&self.filter.borrow(),
|
||||||
|
self.viewing_trash.get(),
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("reading the selected range: {e}");
|
||||||
|
Vec::new()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-EXP-7
|
/// TRACES: FR-EXP-7
|
||||||
/// Remote paths for a set of selected images, in the order they were given.
|
/// Remote paths for a set of selected images, in the order they were given.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user