Drag a collection onto another to nest it

The tree could be built nested but never rearranged: `set_parent` existed,
with its cycle check and its tests, and nothing in the UI called it. A
collection created in the wrong place stayed there.

Each row is already a drop target, so it becomes a `DragArea` too —
wrapped at the instantiation site the way the grid's cells are, which
keeps the row's own TouchArea nested underneath and a click still
selecting. `allow-move`, not copy: a collection has one parent, unlike a
photograph, which is filed in as many collections as you like.

The drop is handed only the target's id, so the source is remembered from
the press that precedes the drag — Slint builds the payload through a
`pure` binding, which must not have side effects. An image drag always
fills `dragging`, so an empty payload with a remembered row is
unambiguously a rearrangement; the row is taken rather than read, or a
later empty drop would move a collection nobody touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:54:50 +02:00
co-authored by Claude Opus 5
parent edcaf42ded
commit 6acc73a9fa
4 changed files with 88 additions and 1 deletions
+59
View File
@@ -143,6 +143,13 @@ pub struct CollectionsController {
viewing_trash: std::cell::Cell<bool>,
/// The live drag: what it carries. Empty means no drag.
dragging: RefCell<Vec<ImageId>>,
/// The collection being dragged, when the drag is a tree rearrangement
/// rather than a filing of photographs.
///
/// Set when the drag starts and read by the drop, the same way `dragging`
/// works — the drop callback carries only the target's id, so what is being
/// dropped has to be remembered rather than inspected.
dragging_collection: RefCell<Option<CollectionId>>,
/// The collection a drag is currently over, by id.
///
/// Only the spring needs this — the *drop* is hit-tested by Slint and
@@ -188,6 +195,17 @@ impl CollectionsController {
})
}
/// Remember which row was pressed, so a drag that follows knows what it is
/// carrying.
///
/// Recorded on the press rather than at the drag's start because Slint
/// builds the payload through a `pure` binding, which must not have side
/// effects — and the drop callback is handed only the *target's* id, so the
/// source has to be remembered somewhere.
pub fn note_row_press(&self, id: CollectionId) {
*self.dragging_collection.borrow_mut() = Some(id);
}
/// Which collection the grid is scoped to, for [`crate::library_ui`] to
/// build its query from.
pub fn scope(&self) -> Option<CollectionId> {
@@ -1485,6 +1503,17 @@ pub fn wire<S, R, C>(
});
}
// Dragging a collection row. The id travels in the payload for anything
// that cares to read it; the drop itself uses the remembered press, since
// it is handed only the target.
{
window.on_collection_drag_payload(move |id| {
let mut data = slint::DataTransfer::default();
data.set_user_data(Rc::new(CollectionId(id as u64)));
data
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
@@ -1572,7 +1601,37 @@ pub fn wire<S, R, C>(
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
let carried = ctl.dragging.borrow().clone();
// An empty image payload with a remembered source row means the
// tree is being rearranged, not filed into. Checked in this order
// because an image drag always fills `dragging`, so photographs can
// never be mistaken for a reparent.
if carried.is_empty() {
// Taken, not read: a remembered press must be spent by the drop
// it belongs to, or a later empty drop — a file dragged in from
// outside, say — would move a collection nobody touched.
let source = ctl.dragging_collection.borrow_mut().take();
if let Some(source) = source.filter(|s| *s != id) {
let result = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
coll::set_parent(cat.connection(), source, Some(id))
};
match result {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved collection".into());
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
}
}
// The catalog refuses a cycle rather than letting the
// tree walk spin. Saying so is better than a drop that
// silently does nothing.
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
}
}
return;
}
+5
View File
@@ -3747,8 +3747,13 @@ pub fn wire<F>(
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_press = coll_ctl.clone();
window.on_collection_row_press(move |id, down| {
let Some(w) = weak.upgrade() else { return };
// A drag of this row, if one follows, carries this collection.
if down {
coll_for_press.note_row_press(dr_types::CollectionId(id as u64));
}
if !down {
*ctl.row_hold_timer.borrow_mut() = None;
return;
+3
View File
@@ -504,6 +504,8 @@ export component AppWindow inherits Window {
/// Images were dropped on a collection, by id. Slint hit-tests the release,
/// so this is the collection actually under the pointer.
callback collection-dropped(int);
/// Payload for dragging a collection onto another to reparent it.
pure callback collection-drag-payload(int) -> data-transfer;
// --- trash (FR-CAT-15) ---
//
@@ -973,6 +975,7 @@ in property <bool> panel-visible: true;
selected-count: root.library-selected-count;
dropped-on(id) => { root.collection-dropped(id); }
collection-drag-payload(id) => { return root.collection-drag-payload(id); }
dropped-on-trash() => { root.trash-dropped(); }
empty-trash() => { root.trash-empty(); }
restore-selected() => { root.trash-restore(); }
+21 -1
View File
@@ -356,6 +356,10 @@ export component CollectionsPanel inherits Rectangle {
/// release itself, so this is the collection actually under the pointer —
/// not the last one hovered.
callback dropped-on(int);
/// The payload for dragging a *collection*, so the tree can be rearranged.
/// Built when the drag starts, and it is what tells `dropped-on` it is
/// being handed a collection to reparent rather than photographs to file.
pure callback collection-drag-payload(int) -> data-transfer;
/// A drag is dwelling over a collection, or has left it. Drives the
/// spring-loaded expansion, which is timed in Rust.
callback drag-over(int, bool);
@@ -501,9 +505,24 @@ export component CollectionsPanel inherits Rectangle {
vertical-stretch: 1;
viewport-height: root.rows.length * (Theme.row-height + 2px);
for row[i] in root.rows: TreeRow {
// Wrapped rather than made draggable from the inside: the grid's
// cells take the same shape, and a `DragArea` around the whole row
// keeps the row's own TouchArea nested under it, which is what lets
// a click still select while a drag reparents.
for row[i] in root.rows: DragArea {
y: i * (Theme.row-height + 2px);
width: parent.width;
height: Theme.row-height;
// Move, not copy. A collection has one parent, so dragging it
// somewhere takes it out of where it was — unlike a photograph,
// which is filed in as many collections as you like.
allow-move: true;
data: root.collection-drag-payload(row.id);
TreeRow {
width: 100%;
height: 100%;
entry: row;
selected: row.id == root.selected-id;
// A saved filter's membership is computed, so a drop cannot
@@ -522,6 +541,7 @@ export component CollectionsPanel inherits Rectangle {
rename-requested => { root.rename-start(row.id); }
rename-committed(name) => { root.rename-commit(row.id, name); }
rename-cancelled => { root.rename-cancel(); }
}
}
// Empty state. A blank panel gives no hint that collections exist