Let a manual collection be put in the order the photographer wants

`collections::set_order` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever
called either. A manual order existed, could not be seen, and could not be
set. This is the half that was missing.

Three pieces, because it needed all three to be visible at all.

The catalog gains `orders_manually` and `members_in_order`. The first is
the rule about *when* a manual order means anything, kept in one place with
one name: a collection must be manual, and it must have no children. A set
shows its descendants' images, and positions are only ever assigned within
one collection — so two children's positions are unrelated integers, and
ordering by them would sort the grid by a coincidence. The existing comment
on `read_cells_scoped` already argued this; now something enforces it.

`members_in_order` returns the *whole* membership rather than the filtered
view, because `set_order` renumbers exactly what it is handed. Reordering a
filtered list would renumber those and leave every hidden image on a stale
position — two images sharing one, and a grid that rearranges itself the
moment the filter comes off.

The grid reads position where the scope qualifies and capture time
everywhere else. Manual order joins the member row rather than testing
membership with `IN`, which is safe from fanning out rows *because* that
branch is a single collection.

The gesture is a DropArea over the viewport, drawn only where a reorder
means something, with a caret in the gap the photographs would go into —
a line between two images rather than a highlight on one, because lighting
up a cell would say the drop replaces it.

The trap worth naming: `DropEvent.position` is in **window** coordinates.
Slint maps it through `map_to_window` when the drag begins and hands every
target the same event untranslated, so a target inside a Flickable has to
subtract its own `absolute-position`. Getting that wrong is invisible until
the grid is scrolled, because at the top the two frames coincide.

`reordered` is pure and names its destination by the image it goes before
rather than by an index, because the grid can only name a gap in what it is
showing and the ids are what survive a window swap. A drop that changes
nothing returns the order untouched: that counter is what a cross-device
merge resolves by, and spending a revision on a no-op makes this device win
an argument it did not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 20:49:00 +02:00
co-authored by Claude Opus 5
parent 1cd58bcaaa
commit 7ad75dd905
7 changed files with 692 additions and 40 deletions
+158
View File
@@ -376,6 +376,64 @@ pub fn set_order(
touch(conn, id)
}
/// Whether this collection has a manual order worth reading.
///
/// The grid asks before choosing an `ORDER BY`, and the answer is narrower
/// than "is it manual". Two things have to be true.
///
/// It must be **manual**: a saved filter's contents are whatever its selector
/// matches now, in whatever order the query returns them, and there are no
/// member rows to carry a position.
///
/// And it must have **no children**. A parent's contents are the union of its
/// own members and its descendants' ([`descendants`]), and positions are only
/// ever assigned within one collection — so two children's positions are
/// unrelated integers, and interleaving them by value would order the grid by
/// a coincidence. Capture time is the honest answer for a set.
///
/// Kept here rather than assembled at the call site, so the rule has one name
/// and one test, and the grid does not have to know that "no children" is part
/// of it.
pub fn orders_manually(conn: &Connection, id: CollectionId) -> Result<bool, CatalogError> {
if kind_of(conn, id)? != CollectionKind::Manual {
return Ok(false);
}
let has_child: Option<i64> = conn
.query_row(
"SELECT 1 FROM collections
WHERE parent_id = ?1 AND deleted = 0 LIMIT 1",
[id.0 as i64],
|r| r.get(0),
)
.optional()?;
Ok(has_child.is_none())
}
/// Every image in a manual collection, in manual order.
///
/// The whole membership, not the filtered view the grid happens to be showing.
/// [`set_order`] renumbers exactly the images it is handed, so a caller that
/// reordered a filtered list would renumber those and leave every hidden image
/// on a stale position — two images sharing a position, and a grid that
/// rearranges itself when the filter comes off.
///
/// `image_id` breaks ties. Positions are dense in practice, but a merge can
/// deliver two devices' rows at the same position, and an order that is not
/// total is one the grid draws differently each time it reads it.
pub fn members_in_order(conn: &Connection, id: CollectionId) -> Result<Vec<ImageId>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT image_id FROM collection_members
WHERE collection_id = ?1
ORDER BY position, image_id",
)?;
let rows = stmt
.query_map([id.0 as i64], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Save a smart collection's selector.
///
/// Refuses a selector that references this collection, directly or through
@@ -772,6 +830,106 @@ mod tests {
ImageId(i)
}
#[test]
fn a_manual_leaf_orders_manually() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
assert!(orders_manually(c, id).unwrap());
}
#[test]
fn a_saved_filter_has_no_manual_order() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Picks", None, CollectionKind::Smart).unwrap();
assert!(
!orders_manually(c, id).unwrap(),
"a smart collection has no member rows to carry a position"
);
}
#[test]
fn a_parent_has_no_manual_order_of_its_own() {
let cat = seeded();
let c = cat.connection();
let parent = create(c, "2026", None, CollectionKind::Manual).unwrap();
let child = create(c, "Iceland", Some(parent), CollectionKind::Manual).unwrap();
assert!(
!orders_manually(c, parent).unwrap(),
"a set shows its descendants' images, whose positions are unrelated"
);
assert!(
orders_manually(c, child).unwrap(),
"the child is still a leaf and still orders manually"
);
}
#[test]
fn deleting_the_last_child_gives_the_parent_its_order_back() {
let cat = seeded();
let c = cat.connection();
let parent = create(c, "2026", None, CollectionKind::Manual).unwrap();
let child = create(c, "Iceland", Some(parent), CollectionKind::Manual).unwrap();
assert!(!orders_manually(c, parent).unwrap());
delete(c, child).unwrap();
assert!(
orders_manually(c, parent).unwrap(),
"a tombstoned child must not keep counting as a child"
);
}
#[test]
fn members_come_back_in_the_order_they_were_put_in() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(3), img(1), img(2)]).unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(3), img(1), img(2)],
"adding assigns position in the order given, and reading returns it"
);
}
#[test]
fn members_come_back_in_the_order_set_order_wrote() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(1), img(2), img(3)]).unwrap();
set_order(c, id, &[img(3), img(2), img(1)]).unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(3), img(2), img(1)]
);
}
#[test]
fn members_in_order_is_total_even_where_positions_collide() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(1), img(2), img(3)]).unwrap();
// What a merge can deliver: two devices' rows landing on one position.
c.execute(
"UPDATE collection_members SET position = 0 WHERE collection_id = ?1",
[id.0 as i64],
)
.unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(1), img(2), img(3)],
"image_id breaks the tie, so two reads cannot disagree"
);
}
#[test]
fn a_created_collection_appears_in_the_tree() {
let cat = seeded();