Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the catalog since collections were, and nothing above dr-catalog has ever written or read either: `collections::set_order` had no callers, and the grid ordered everything by capture time whatever it was scoped to — dr-ui does not construct a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection was a set with an order nobody could see or change. Three pieces, because it could not be fewer: `grid_order_for` decides the ordering from the scope, and both readers take it from there. That is the load-bearing part. An ordinal only names a photograph relative to an ordering, so the window read and the span read have to agree — a shift-click resolved through a different ORDER BY than the cells were drawn with selects a different run than the one on screen, and the user finds out when the export runs. `read_ids_span` already stated that invariant about `GRID_ORDER`; this widens it to an ordering that depends on the scope. Only a single manual collection has one. A set draws its descendants' images too, and two children's positions are unrelated integers that interleave arbitrarily; a smart collection has no member rows to carry a position at all. Both fall back to capture time and refuse the drop rather than pretending. The drop is on the cell, on whichever half of it the finger landed — the trailing edge is the only way to name the last place in a collection, since there is no cell beyond the last one to drop in front of. `reordered` is pure and the membership is rewritten whole. `set_order` sets the positions it is given and leaves the rest, so a partial write would interleave the moved run with rows nobody touched; and it is read unfiltered, so what the filter is hiding keeps its place relative to what the user can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ use dr_types::{CollectionId, ImageId};
|
||||
use rusqlite::OptionalExtension as _;
|
||||
use slint::{ComponentHandle, Model as _};
|
||||
|
||||
use crate::library;
|
||||
use crate::{AppWindow, CollectionRow};
|
||||
|
||||
/// The selection as it stood before a press, for [`cancel_press`].
|
||||
@@ -678,6 +679,12 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
||||
*ctl.row_smart.borrow_mut() = smart;
|
||||
*ctl.row_has_children.borrow_mut() = has_kids;
|
||||
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
||||
|
||||
// The rows are what `sync_reorderable` reads, so it has to be told they
|
||||
// changed: a collection that gains a child, or one that is deleted out from
|
||||
// under the scope, changes whether the grid can be reordered without the
|
||||
// scope itself moving.
|
||||
sync_reorderable(window);
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||
@@ -935,6 +942,29 @@ pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids:
|
||||
window.set_library_selected_count(selection.len() as i32);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7
|
||||
/// Whether what the grid is showing has an order the user can change.
|
||||
///
|
||||
/// A single manual collection: not the whole library, not a saved filter whose
|
||||
/// membership is a rule, and not a set — a set draws its descendants' images
|
||||
/// too, and two children's `position` columns are unrelated integers that would
|
||||
/// interleave arbitrarily.
|
||||
///
|
||||
/// Read off the tree rows the sidebar already draws rather than asked of the
|
||||
/// catalog. `smart` and `has_children` are the only two facts it needs and both
|
||||
/// are in the model already, so this cannot disagree with what the user is
|
||||
/// looking at, and a scope change costs no extra query.
|
||||
fn sync_reorderable(window: &AppWindow) {
|
||||
let id = window.get_collection_selected();
|
||||
let rows = window.get_collection_rows();
|
||||
let manual = id > 0
|
||||
&& (0..rows.row_count())
|
||||
.filter_map(|i| rows.row_data(i))
|
||||
.find(|r| r.id == id)
|
||||
.is_some_and(|r| !r.smart && !r.has_children);
|
||||
window.set_library_reorderable(manual);
|
||||
}
|
||||
|
||||
/// Refresh the per-cell "in this many collections" badges.
|
||||
///
|
||||
/// One query for the whole window rather than one per cell: 120 cells is 120
|
||||
@@ -1574,6 +1604,93 @@ pub fn wire<S, R, P, C>(
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-7
|
||||
// Drag a photograph, or a whole selection of them, to a new place in the
|
||||
// collection being shown.
|
||||
//
|
||||
// `collection_members.position` and `Sort::CollectionPosition` have been in
|
||||
// the catalog since collections were, and until now nothing above it ever
|
||||
// wrote or read them — the grid ordered by capture time whatever it was
|
||||
// scoped to. `library::grid_order_for` reads them; this writes them.
|
||||
//
|
||||
// The membership is rewritten whole rather than patched. `set_order` sets
|
||||
// the positions it is given and leaves the rest, so a partial write would
|
||||
// interleave the moved run with rows whose positions nobody touched — and
|
||||
// it is read unfiltered for the same reason: the user reorders what they
|
||||
// can see, and what the filter is hiding keeps its place relative to it.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let visible = visible_ids.clone();
|
||||
let reload = on_scope_changed.clone();
|
||||
window.on_library_reorder_to(move |row, after| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Both guards belong here rather than only in `library.slint`: the
|
||||
// scope can change between the drag starting and the drop landing.
|
||||
if !w.get_library_reorderable() {
|
||||
return;
|
||||
}
|
||||
let Some(scope) = *ctl.scope.borrow() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let moving = ctl.selected();
|
||||
if moving.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let ids = visible();
|
||||
let Some(&target) = usize::try_from(row).ok().and_then(|r| ids.get(r)) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Dropped on one of its own. There is no gap between a run and
|
||||
// itself to land in, and rewriting the whole membership to say so
|
||||
// would be a revision bump for no change.
|
||||
if moving.contains(&target) {
|
||||
return;
|
||||
}
|
||||
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else { return };
|
||||
|
||||
let current = match library::read_member_order(cat, scope) {
|
||||
Ok(current) => current,
|
||||
Err(e) => {
|
||||
w.set_collection_error(format!("reading the collection's order: {e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let wanted = library::reordered(¤t, &moving, target, after);
|
||||
|
||||
match coll::set_order(cat.connection(), scope, &wanted) {
|
||||
Ok(()) => {
|
||||
w.set_collection_error(slint::SharedString::new());
|
||||
w.set_library_status(
|
||||
format!(
|
||||
"{} photograph{} moved",
|
||||
moving.len(),
|
||||
if moving.len() == 1 { "" } else { "s" }
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
drop(borrow);
|
||||
|
||||
// The selection survives. It is what was just moved, and
|
||||
// dropping it would make a second nudge — which is how a
|
||||
// drag-to-reorder is usually corrected — start over.
|
||||
reload();
|
||||
sync_selection(&w, &ctl, &visible());
|
||||
}
|
||||
Err(e) => {
|
||||
drop(borrow);
|
||||
w.set_collection_error(format!("reordering: {e}").into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-5 | FR-UI-4
|
||||
// Everything the grid is showing.
|
||||
//
|
||||
@@ -2231,6 +2348,7 @@ pub fn wire<S, R, P, C>(
|
||||
};
|
||||
w.set_collection_selected(id);
|
||||
w.set_collection_scope_label(label.into());
|
||||
sync_reorderable(&w);
|
||||
reload();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user