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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user