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:
2026-08-29 20:04:17 +02:00
co-authored by Claude Opus 5
parent 6d25d85f18
commit 574107bc39
6 changed files with 574 additions and 27 deletions
+118
View File
@@ -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(&current, &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();
});
}