Move the library grid's cells and selection onto their own Slint global

AppWindow carried the grid's loaded window of cells, the keyboard cursor,
drag and drop, the held-row long-press state, columns and cell size, the
scroll and viewport bookkeeping, the photo roll's pick and centre-request,
and the local-only/reorder/collection-filing gestures that act on a
selection, as properties and callbacks on the root component. That state now
lives in the `Library` global declared in library.slint, next to the structs
(LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other
components already share; Rust reaches it through
window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the
root, the same change collections.slint's `Collections` global made for the
sidebar.

library-visible stays on AppWindow: it is computed from active-page and
active-view, the shell's own routing state, which a global cannot read.
Everything else still prefixed library- — the timeline, the filter bar,
ratings and flags, keywording, and the routes and status lines — stays on
the window for now and moves in the commits that follow.
This commit is contained in:
2026-09-20 21:42:48 +02:00
parent 3bfa73d1e1
commit 402dcdc24c
18 changed files with 992 additions and 900 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ use dr_catalog::Catalog;
use dr_types::CollectionId;
use slint::ComponentHandle;
use crate::AppWindow;
use crate::{AppWindow, Library};
use super::controller::CollectionsController;
use super::tree_sync::refresh_tree;
@@ -189,11 +189,11 @@ pub(super) fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>, row:
// This half happens whether or not selection mode was already on —
// it is the half a *drag* needs, and a drag out of a selection of
// forty starts in a mode that is already on.
w.set_library_held_row(row);
w.global::<Library>().set_library_held_row(row);
if !ctl_cb.select_mode.get() {
ctl_cb.select_mode.set(true);
w.set_library_select_mode(true);
w.global::<Library>().set_library_select_mode(true);
}
// The release that follows this hold must not also open the image:
+3 -2
View File
@@ -6,8 +6,9 @@ use std::collections::BTreeSet;
use std::rc::Rc;
use dr_types::ImageId;
use slint::ComponentHandle as _;
use crate::AppWindow;
use crate::{AppWindow, Library};
use super::controller::CollectionsController;
use super::tree_sync::sync_selection;
@@ -292,7 +293,7 @@ pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[
// has to put it back down, or the ring stays open around a cell nobody is
// touching and the grid stays frozen with it.
ctl.pending_toggle.set(None);
window.set_library_held_row(-1);
window.global::<Library>().set_library_held_row(-1);
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
return;
+8 -6
View File
@@ -13,7 +13,7 @@ use dr_catalog::Catalog;
use dr_types::{CollectionId, ImageId};
use slint::{ComponentHandle as _, Model as _};
use crate::{AppWindow, CollectionRow, Collections};
use crate::{AppWindow, CollectionRow, Collections, Library};
use super::controller::CollectionsController;
@@ -195,7 +195,7 @@ fn offline_state(
/// Separate from [`sync_selection`] because the two differ: the selection
/// persists after the drag, the lift lasts only while it is in flight.
pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) {
let model = window.get_library_cells();
let model = window.global::<Library>().get_library_cells();
for (row, id) in ids.iter().enumerate() {
let want = lifted.contains(id);
if let Some(mut cell) = model.row_data(row) {
@@ -213,7 +213,7 @@ pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) {
/// this is what keeps the two in step after a scrub, a drop, or a click.
pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
let selection = ctl.selection.borrow();
let model = window.get_library_cells();
let model = window.global::<Library>().get_library_cells();
// The anchor is deliberately **not** pushed to the grid.
//
@@ -238,7 +238,9 @@ pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids:
}
}
window.set_library_selected_count(selection.len() as i32);
window
.global::<Library>()
.set_library_selected_count(selection.len() as i32);
}
/// TRACES: FR-CAT-7
@@ -261,7 +263,7 @@ pub(super) fn sync_reorderable(window: &AppWindow) {
.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);
window.global::<Library>().set_library_reorderable(manual);
}
/// Refresh the per-cell "in this many collections" badges.
@@ -300,7 +302,7 @@ pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
}
}
let model = window.get_library_cells();
let model = window.global::<Library>().get_library_cells();
for (row, id) in ids.iter().enumerate() {
let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0);
if let Some(mut cell) = model.row_data(row) {
+345 -325
View File
@@ -16,7 +16,7 @@ use dr_types::{CollectionId, ImageId};
use slint::{ComponentHandle, Model as _};
use crate::library;
use crate::{AppWindow, Collections};
use crate::{AppWindow, Collections, Library};
use super::controller::{decide_drop, CollectionsController, Drop};
use super::drag::{arm_hold, arm_spring, collapse_spring_opened, compose_drag_image};
@@ -99,35 +99,37 @@ fn wire_selection(
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_cell_pressed(move |row, ctrl_held, shift_held| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
window
.global::<Library>()
.on_library_cell_pressed(move |row, ctrl_held, shift_held| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
// Consulted by the click that follows: a modified press is building
// a selection and must not also navigate to develop.
ctl.modified_press.set(ctrl_held || shift_held);
// Consulted by the click that follows: a modified press is building
// a selection and must not also navigate to develop.
ctl.modified_press.set(ctrl_held || shift_held);
// Where the window starts, so the press is recorded as the ordinal
// it is rather than as a row that stops meaning this photograph on
// the next scroll.
let offset = w.get_library_offset().max(0) as usize;
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
// Where the window starts, so the press is recorded as the ordinal
// it is rather than as a row that stops meaning this photograph on
// the next scroll.
let offset = w.get_library_offset().max(0) as usize;
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
// And start counting, in case this press is a hold. The press has
// already selected this one cell; what the hold adds is the *mode*,
// so the taps that follow go on selecting instead of opening the
// next photograph the user touches — and the *pick-up*, which is
// what makes the drag reliable.
//
// Armed even when the mode is already on, which it did not used to
// be: there was nothing left for the hold to switch on, so it was
// skipped. But the mode being on is exactly the state a
// multi-image drag starts from, and skipping the hold left that
// drag with no pick-up and no cue — the one gesture that most
// needed both. See `arm_hold`.
arm_hold(&w, &ctl, row);
});
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
// And start counting, in case this press is a hold. The press has
// already selected this one cell; what the hold adds is the *mode*,
// so the taps that follow go on selecting instead of opening the
// next photograph the user touches — and the *pick-up*, which is
// what makes the drag reliable.
//
// Armed even when the mode is already on, which it did not used to
// be: there was nothing left for the hold to switch on, so it was
// skipped. But the mode being on is exactly the state a
// multi-image drag starts from, and skipping the hold left that
// drag with no pick-up and no cue — the one gesture that most
// needed both. See `arm_hold`.
arm_hold(&w, &ctl, row);
});
}
// TRACES: FR-UI-2 | FR-UI-4
@@ -135,9 +137,11 @@ fn wire_selection(
// travelled. Either way the hold is off.
{
let ctl = ctl.clone();
window.on_library_cell_press_ended(move || {
*ctl.hold_timer.borrow_mut() = None;
});
window
.global::<Library>()
.on_library_cell_press_ended(move || {
*ctl.hold_timer.borrow_mut() = None;
});
}
// TRACES: FR-CAT-5
@@ -152,11 +156,13 @@ fn wire_selection(
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_clear_selection(move || {
let Some(w) = weak.upgrade() else { return };
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
});
window
.global::<Library>()
.on_library_clear_selection(move || {
let Some(w) = weak.upgrade() else { return };
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
});
}
// TRACES: FR-CAT-7
@@ -179,75 +185,77 @@ fn wire_selection(
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.global::<Collections>().set_collection_error(
format!("reading the collection's order: {e}").into(),
);
window
.global::<Library>()
.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.global::<Library>().get_library_reorderable() {
return;
}
};
let wanted = library::reordered(&current, &moving, target, after);
let Some(scope) = *ctl.scope.borrow() else {
return;
};
match coll::set_order(cat.connection(), scope, &wanted) {
Ok(()) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{} photograph{} moved",
moving.len(),
if moving.len() == 1 { "" } else { "s" }
)
.into(),
);
drop(borrow);
let moving = ctl.selected();
if moving.is_empty() {
return;
}
// 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());
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;
}
Err(e) => {
drop(borrow);
w.global::<Collections>()
.set_collection_error(format!("reordering: {e}").into());
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.global::<Collections>().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.global::<Collections>()
.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.global::<Collections>()
.set_collection_error(format!("reordering: {e}").into());
}
}
}
});
});
}
// TRACES: FR-CAT-5 | FR-UI-4
@@ -267,10 +275,10 @@ fn wire_selection(
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_select_all(move || {
window.global::<Library>().on_library_select_all(move || {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
let total = w.get_library_total().max(0) as usize;
let total = w.global::<Library>().get_library_total().max(0) as usize;
if total == 0 {
return;
}
@@ -334,69 +342,71 @@ fn wire_selection_filing(
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_collection_from_selection(move |name| {
let Some(w) = weak.upgrade() else { return };
let images = ctl.selected();
if images.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.global::<Collections>()
.set_collection_error("Open a library first.".into());
return;
};
// The sheet refuses an empty name in two places, so this is
// belt-and-braces rather than a path the UI can reach — but a
// collection with no name at all is unfindable in the tree, and
// falling back to the placeholder is recoverable where an empty
// row is not.
let name = match name.trim() {
"" => unique_name(cat.connection(), None),
typed => typed.to_string(),
};
// The id is not carried out. It used to be, to open the rename
// field on the row it names; now the name is already right, and
// `refresh_tree` redraws the tree from the catalog either way.
let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual)
.and_then(|id| coll::add_images(cat.connection(), id, &images));
match made {
Ok(added) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{added} photograph{} in {name}",
if added == 1 { "" } else { "s" }
)
.into(),
);
refresh_tree(&w, &ctl, cat);
drop(borrow);
// The selection has been filed; holding on to it invites
// the next press acting on photographs the user considers
// dealt with.
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
// No rename opened here. The name was chosen before the
// collection existed, so there is nothing left to correct —
// and opening the sidebar's field is what stranded the
// keyboard on a tablet.
reload();
window
.global::<Library>()
.on_library_collection_from_selection(move |name| {
let Some(w) = weak.upgrade() else { return };
let images = ctl.selected();
if images.is_empty() {
return;
}
Err(e) => {
drop(borrow);
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.global::<Collections>()
.set_collection_error(format!("making a collection: {e}").into());
.set_collection_error("Open a library first.".into());
return;
};
// The sheet refuses an empty name in two places, so this is
// belt-and-braces rather than a path the UI can reach — but a
// collection with no name at all is unfindable in the tree, and
// falling back to the placeholder is recoverable where an empty
// row is not.
let name = match name.trim() {
"" => unique_name(cat.connection(), None),
typed => typed.to_string(),
};
// The id is not carried out. It used to be, to open the rename
// field on the row it names; now the name is already right, and
// `refresh_tree` redraws the tree from the catalog either way.
let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual)
.and_then(|id| coll::add_images(cat.connection(), id, &images));
match made {
Ok(added) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{added} photograph{} in {name}",
if added == 1 { "" } else { "s" }
)
.into(),
);
refresh_tree(&w, &ctl, cat);
drop(borrow);
// The selection has been filed; holding on to it invites
// the next press acting on photographs the user considers
// dealt with.
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
// No rename opened here. The name was chosen before the
// collection existed, so there is nothing left to correct —
// and opening the sidebar's field is what stranded the
// keyboard on a tablet.
reload();
}
Err(e) => {
drop(borrow);
w.global::<Collections>()
.set_collection_error(format!("making a collection: {e}").into());
}
}
}
});
});
}
// TRACES: FR-UI-2 | FR-UI-4
@@ -407,23 +417,25 @@ fn wire_selection_filing(
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_toggle_select_mode(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.select_mode.get();
ctl.select_mode.set(on);
w.set_library_select_mode(on);
*ctl.hold_timer.borrow_mut() = None;
window
.global::<Library>()
.on_library_toggle_select_mode(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.select_mode.get();
ctl.select_mode.set(on);
w.global::<Library>().set_library_select_mode(on);
*ctl.hold_timer.borrow_mut() = None;
// Leaving the mode drops the selection. "Done" reads as finishing
// with these photographs, and a selection that outlived the mode
// would still be acted on by the buttons in the header — which is
// how forty images get exported by a user who thought they had put
// them down.
if !on {
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
}
});
// Leaving the mode drops the selection. "Done" reads as finishing
// with these photographs, and a selection that outlived the mode
// would still be acted on by the buttons in the header — which is
// how forty images get exported by a user who thought they had put
// them down.
if !on {
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
}
});
}
// TRACES: FR-CAT-7 | FR-UI-4
@@ -434,59 +446,61 @@ fn wire_selection_filing(
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_file_in_collection(move |id, moves| {
let Some(w) = weak.upgrade() else { return };
let target = CollectionId(id as u64);
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Moving is only a move when there is somewhere to move *from*.
// Unscoped, the sheet does not offer it; this guards the callback
// anyway, and degrades to filing rather than refusing.
let from = if moves { *ctl.scope.borrow() } else { None };
let result = match from {
Some(from) => coll::move_images(cat.connection(), from, target, &chosen),
None => coll::add_images(cat.connection(), target, &chosen),
};
match result {
Ok(filed) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
// The honest count, as the drop reports it: claiming all
// twelve when nine were already there teaches the user to
// distrust every number the app shows them.
let msg = if from.is_some() {
format!("Moved {filed} of {}", chosen.len())
} else if filed == chosen.len() {
format!("Added {filed} to collection")
} else {
format!(
"Added {filed} of {} — the rest were already there",
chosen.len()
)
};
w.set_library_status(msg.into());
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
// A move changes what the collection on screen holds, so
// the grid has to be reread; filing elsewhere does not, but
// the badge count on every cell just changed and the reread
// is one query.
drop(borrow);
reload();
window
.global::<Library>()
.on_library_file_in_collection(move |id, moves| {
let Some(w) = weak.upgrade() else { return };
let target = CollectionId(id as u64);
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("filing: {e}").into()),
}
});
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Moving is only a move when there is somewhere to move *from*.
// Unscoped, the sheet does not offer it; this guards the callback
// anyway, and degrades to filing rather than refusing.
let from = if moves { *ctl.scope.borrow() } else { None };
let result = match from {
Some(from) => coll::move_images(cat.connection(), from, target, &chosen),
None => coll::add_images(cat.connection(), target, &chosen),
};
match result {
Ok(filed) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
// The honest count, as the drop reports it: claiming all
// twelve when nine were already there teaches the user to
// distrust every number the app shows them.
let msg = if from.is_some() {
format!("Moved {filed} of {}", chosen.len())
} else if filed == chosen.len() {
format!("Added {filed} to collection")
} else {
format!(
"Added {filed} of {} — the rest were already there",
chosen.len()
)
};
w.set_library_status(msg.into());
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
// A move changes what the collection on screen holds, so
// the grid has to be reread; filing elsewhere does not, but
// the badge count on every cell just changed and the reread
// is one query.
drop(borrow);
reload();
}
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("filing: {e}").into()),
}
});
}
}
@@ -526,7 +540,7 @@ fn wire_drag(
// nothing outside the application can interpret it as a paste.
{
let ctl = ctl.clone();
window.on_library_drag_payload(move || {
window.global::<Library>().on_library_drag_payload(move || {
let carried = ctl.dragging.borrow().clone();
let mut data = slint::DataTransfer::default();
data.set_user_data(Rc::new(carried));
@@ -551,67 +565,70 @@ fn wire_drag(
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_drag_started(move |row| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
window
.global::<Library>()
.on_library_drag_started(move |row| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
// TRACES: FR-UI-4
// A drag is not a hold, and the timer must not outlive the press
// that armed it. Grabbing a cell and moving inside 450 ms left the
// hold armed underneath the drag, so it fired mid-gesture and put
// the grid into selection mode the user had not asked for — the
// drag finished into a mode that changed what every later tap
// meant. The pinch already cancels for exactly this reason.
*ctl.hold_timer.borrow_mut() = None;
// TRACES: FR-UI-4
// A drag is not a hold, and the timer must not outlive the press
// that armed it. Grabbing a cell and moving inside 450 ms left the
// hold armed underneath the drag, so it fired mid-gesture and put
// the grid into selection mode the user had not asked for — the
// drag finished into a mode that changed what every later tap
// meant. The pinch already cancels for exactly this reason.
*ctl.hold_timer.borrow_mut() = None;
// TRACES: FR-CAT-7
// And this press was not a tap, so it never gets to take anything
// out of the selection — see [`Press::Deferred`]. Dropped here as
// well as on the next press because the drag reads the selection
// one line below, and a removal still pending would be a
// photograph the user can see is selected and the drop would not
// carry.
ctl.pending_toggle.set(None);
// TRACES: FR-CAT-7
// And this press was not a tap, so it never gets to take anything
// out of the selection — see [`Press::Deferred`]. Dropped here as
// well as on the next press because the drag reads the selection
// one line below, and a removal still pending would be a
// photograph the user can see is selected and the drop would not
// carry.
ctl.pending_toggle.set(None);
// Dragging an *unselected* cell carries only that one, and makes it
// the selection — otherwise the images that travel are not the ones
// the user grabbed. Dragging a selected cell carries the whole
// selection, which is the multi-image gesture.
let carried: Vec<ImageId> = {
let mut selection = ctl.selection.borrow_mut();
match ids.get(row as usize) {
Some(id) if !selection.contains(id) => {
selection.clear();
selection.insert(*id);
vec![*id]
// Dragging an *unselected* cell carries only that one, and makes it
// the selection — otherwise the images that travel are not the ones
// the user grabbed. Dragging a selected cell carries the whole
// selection, which is the multi-image gesture.
let carried: Vec<ImageId> = {
let mut selection = ctl.selection.borrow_mut();
match ids.get(row as usize) {
Some(id) if !selection.contains(id) => {
selection.clear();
selection.insert(*id);
vec![*id]
}
_ => selection.iter().copied().collect(),
}
_ => selection.iter().copied().collect(),
}
};
};
// The bitmap under the cursor, built from the thumbnails already in
// the model — the drag carries what the user can see, and a cell
// whose preview has not landed yet contributes nothing rather than
// a placeholder.
let thumbs: Vec<slint::Image> = {
let model = w.get_library_cells();
carried
.iter()
.filter_map(|id| ids.iter().position(|v| v == id))
.filter_map(|row| model.row_data(row))
.filter(|c| c.has_thumb)
.map(|c| c.thumbnail)
.collect()
};
w.set_library_drag_image(compose_drag_image(&thumbs));
// The bitmap under the cursor, built from the thumbnails already in
// the model — the drag carries what the user can see, and a cell
// whose preview has not landed yet contributes nothing rather than
// a placeholder.
let thumbs: Vec<slint::Image> = {
let model = w.global::<Library>().get_library_cells();
carried
.iter()
.filter_map(|id| ids.iter().position(|v| v == id))
.filter_map(|row| model.row_data(row))
.filter(|c| c.has_thumb)
.map(|c| c.thumbnail)
.collect()
};
w.global::<Library>()
.set_library_drag_image(compose_drag_image(&thumbs));
*ctl.dragging.borrow_mut() = carried.clone();
sync_selection(&w, &ctl, &ids);
// The lift-out: these cells fade and shrink in place, so the grid
// shows where the photographs came from while the cursor shows them
// in full colour.
sync_lifted(&w, &carried, &ids);
});
*ctl.dragging.borrow_mut() = carried.clone();
sync_selection(&w, &ctl, &ids);
// The lift-out: these cells fade and shrink in place, so the grid
// shows where the photographs came from while the cursor shows them
// in full colour.
sync_lifted(&w, &carried, &ids);
});
}
// A drag dwelling over a collapsed collection springs it open, so a nested
@@ -740,52 +757,55 @@ fn wire_drag(
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
let session = session.clone();
window.on_library_drag_finished(move || {
let Some(w) = weak.upgrade() else { return };
window
.global::<Library>()
.on_library_drag_finished(move || {
let Some(w) = weak.upgrade() else { return };
let landed = ctl.dropped_on.borrow_mut().take();
let to_trash = ctl.trash_requested.borrow_mut().take();
ctl.dragging.borrow_mut().clear();
// The grid clears this itself on the cancel that starts a drag;
// this is for the endings that reach no cell — a drop, or a drag
// abandoned over nothing.
w.set_library_held_row(-1);
*ctl.hover_id.borrow_mut() = None;
*ctl.spring_timer.borrow_mut() = None;
let landed = ctl.dropped_on.borrow_mut().take();
let to_trash = ctl.trash_requested.borrow_mut().take();
ctl.dragging.borrow_mut().clear();
// The grid clears this itself on the cancel that starts a drag;
// this is for the endings that reach no cell — a drop, or a drag
// abandoned over nothing.
w.global::<Library>().set_library_held_row(-1);
*ctl.hover_id.borrow_mut() = None;
*ctl.spring_timer.borrow_mut() = None;
// A soft delete, deferred out of the drop handler so the models it
// replaces are no longer being walked.
if let Some(images) = to_trash {
start_trash(&w, &ctl, &catalog, &session, &images, &reload);
}
// The cells settle back into the grid, and the cursor bitmap is
// released — it holds a copy of every thumbnail it composited.
sync_lifted(&w, &[], &visible());
w.set_library_drag_image(slint::Image::default());
if landed.is_some() {
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
// Counts changed on the target and every ancestor, and the
// dropped images now carry one more collection badge.
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
// A soft delete, deferred out of the drop handler so the models it
// replaces are no longer being walked.
if let Some(images) = to_trash {
start_trash(&w, &ctl, &catalog, &session, &images, &reload);
}
}
// Put back whatever the spring opened on the way. The collection
// that received the images — and its ancestors — stay open, since
// that is where the user is now working; on an abandoned drag
// `landed` is `None` and everything closes.
collapse_spring_opened(&w, &ctl, &catalog, landed);
// The cells settle back into the grid, and the cursor bitmap is
// released — it holds a copy of every thumbnail it composited.
sync_lifted(&w, &[], &visible());
w.global::<Library>()
.set_library_drag_image(slint::Image::default());
// A drop into the collection currently being shown changes what that
// collection holds, so the grid has to be reread.
if landed.is_some() && landed == *ctl.scope.borrow() {
reload();
}
});
if landed.is_some() {
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
// Counts changed on the target and every ancestor, and the
// dropped images now carry one more collection badge.
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
}
}
// Put back whatever the spring opened on the way. The collection
// that received the images — and its ancestors — stay open, since
// that is where the user is now working; on an abandoned drag
// `landed` is `None` and everything closes.
collapse_spring_opened(&w, &ctl, &catalog, landed);
// A drop into the collection currently being shown changes what that
// collection holds, so the grid has to be reread.
if landed.is_some() && landed == *ctl.scope.borrow() {
reload();
}
});
}
}
+47 -42
View File
@@ -13,7 +13,7 @@ use dr_catalog::Catalog;
use dr_types::{CollectionId, ImageId};
use slint::{ComponentHandle, Model as _};
use crate::{AppWindow, Collections};
use crate::{AppWindow, Collections, Library};
use super::controller::{
decide_delete, decide_release, CollectionsController, DeleteStep, Release,
@@ -236,41 +236,44 @@ pub(super) fn wire_remove(
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_remove_from_collection(move || {
let Some(w) = weak.upgrade() else { return };
let Some(scope) = *ctl.scope.borrow() else {
// Unscoped, there is no collection to remove from. The button
// is hidden in that state; this guards the callback anyway.
return;
};
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match coll::remove_images(cat.connection(), scope, &chosen) {
Ok(n) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
// Named explicitly as a membership change: the images are
// still in the library, and a user who reads this as a
// delete will not trust the feature again.
w.set_library_status(
format!("Removed {n} from this collection; still in the library").into(),
);
ctl.clear_selection();
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
reload();
window
.global::<Library>()
.on_library_remove_from_collection(move || {
let Some(w) = weak.upgrade() else { return };
let Some(scope) = *ctl.scope.borrow() else {
// Unscoped, there is no collection to remove from. The button
// is hidden in that state; this guards the callback anyway.
return;
};
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("removing: {e}").into()),
}
});
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match coll::remove_images(cat.connection(), scope, &chosen) {
Ok(n) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
// Named explicitly as a membership change: the images are
// still in the library, and a user who reads this as a
// delete will not trust the feature again.
w.set_library_status(
format!("Removed {n} from this collection; still in the library")
.into(),
);
ctl.clear_selection();
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
reload();
}
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("removing: {e}").into()),
}
});
}
}
@@ -688,13 +691,15 @@ pub(super) fn wire_membership(
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_library_open_membership(move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
refresh_membership(&w, &ctl, cat);
w.global::<Collections>().set_membership_open(true);
});
window
.global::<Library>()
.on_library_open_membership(move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
refresh_membership(&w, &ctl, cat);
w.global::<Collections>().set_membership_open(true);
});
}
{