diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs index 72b2b5f..75632de 100644 --- a/ui/dr-ui/src/collections_ui.rs +++ b/ui/dr-ui/src/collections_ui.rs @@ -143,6 +143,13 @@ pub struct CollectionsController { viewing_trash: std::cell::Cell, /// The live drag: what it carries. Empty means no drag. dragging: RefCell>, + /// 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>, /// 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 { @@ -1485,6 +1503,17 @@ pub fn wire( }); } + // 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( 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; } diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 468481d..a5ceee3 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -3747,8 +3747,13 @@ pub fn wire( { 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; diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 4fbdd41..ae3277e 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -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 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(); } diff --git a/ui/dr-ui/ui/collections.slint b/ui/dr-ui/ui/collections.slint index 8064b12..70b9ee6 100644 --- a/ui/dr-ui/ui/collections.slint +++ b/ui/dr-ui/ui/collections.slint @@ -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