Merge: the library grid on its own Slint global, the last of the screen state off the root

This commit is contained in:
2026-09-20 22:43:09 +02:00
24 changed files with 2124 additions and 1833 deletions
+3 -3
View File
@@ -45,9 +45,9 @@ use dr_catalog::bursts::{self, Rules, Signature};
use dr_catalog::Catalog;
use dr_thumbs::{ThumbSize, ThumbStore};
use dr_types::ImageId;
use slint::Model as _;
use slint::{ComponentHandle as _, Model as _};
use crate::AppWindow;
use crate::{AppWindow, Library};
/// How many signatures are written per transaction.
///
@@ -364,7 +364,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 (count, expanded, representative) = match found.get(id) {
Some(m) => (m.size as i32, m.expanded, m.representative),
+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;
@@ -280,11 +280,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;
+9 -5
View File
@@ -12,7 +12,7 @@ use dr_catalog::Catalog;
use dr_types::ImageId;
use slint::ComponentHandle;
use crate::{AppWindow, Collections};
use crate::{AppWindow, Collections, Library};
use super::controller::CollectionsController;
use super::tree_sync::refresh_tree;
@@ -56,7 +56,9 @@ pub(super) fn start_trash(
}
log::info!("moving {} image(s) to the trash", moves.len());
window.set_library_status(format!("Moving {} to the trash…", moves.len()).into());
window
.global::<Library>()
.set_library_status(format!("Moving {} to the trash…", moves.len()).into());
// The images are leaving the grid; a selection pointing at them would
// survive as a set of ids the user can no longer see.
ctl.clear_selection();
@@ -131,7 +133,9 @@ pub(super) fn start_restore(
}
log::info!("restoring {} image(s) from the trash", moves.len());
window.set_library_status(format!("Restoring {}…", moves.len()).into());
window
.global::<Library>()
.set_library_status(format!("Restoring {}…", moves.len()).into());
// The images are leaving the trash view, so a selection pointing at them
// would survive as ids the user can no longer see.
ctl.clear_selection();
@@ -214,7 +218,7 @@ pub(super) fn drain_trash(
if failed > 0 {
job.detail(format!("{failed} failed"));
}
w.set_library_status(status.into());
w.global::<Library>().set_library_status(status.into());
}
TrashMessage::Done { moved, failed } => {
// Reported honestly, including the partial case: "38 of
@@ -233,7 +237,7 @@ pub(super) fn drain_trash(
} else {
job.fail(status.clone());
}
w.set_library_status(status.into());
w.global::<Library>().set_library_status(status.into());
if let Some(first) = failed.first() {
w.global::<Collections>()
.set_collection_error(first.as_str().into());
+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) {
+378 -351
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::{
@@ -102,35 +102,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.global::<Library>().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
@@ -138,9 +140,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
@@ -155,11 +159,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
@@ -182,75 +188,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.global::<Library>().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
@@ -270,10 +278,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;
}
@@ -337,69 +345,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.global::<Library>().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
@@ -410,23 +420,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
@@ -437,59 +449,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.global::<Library>().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()),
}
});
}
}
@@ -529,7 +543,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));
@@ -554,67 +568,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(drag_image_via_file(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(drag_image_via_file(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
@@ -676,7 +693,8 @@ fn wire_drag(
Ok(()) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved collection".into());
w.global::<Library>()
.set_library_status("Moved collection".into());
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
@@ -717,7 +735,7 @@ fn wire_drag(
carried.len()
)
};
w.set_library_status(msg.into());
w.global::<Library>().set_library_status(msg.into());
}
Err(e) => w
.global::<Collections>()
@@ -743,53 +761,56 @@ 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());
forget_drag_image_file();
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());
forget_drag_image_file();
// 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();
}
});
}
}
@@ -875,17 +896,19 @@ fn wire_trash_from_grid(
let session = session.clone();
let reload = on_scope_changed.clone();
let visible = visible_ids.clone();
window.on_library_cell_trashed(move |row| {
let Some(w) = weak.upgrade() else { return };
// Resolved through the visible ids rather than the row index alone:
// the grid is a window over the catalog, so a stale index from
// before a scroll would name a different photograph — and here that
// would move the wrong file.
let Some(&id) = visible().get(row as usize) else {
return;
};
start_trash(&w, &ctl, &catalog, &session, &[id], &reload);
});
window
.global::<Library>()
.on_library_cell_trashed(move |row| {
let Some(w) = weak.upgrade() else { return };
// Resolved through the visible ids rather than the row index alone:
// the grid is a window over the catalog, so a stale index from
// before a scroll would name a different photograph — and here that
// would move the wrong file.
let Some(&id) = visible().get(row as usize) else {
return;
};
start_trash(&w, &ctl, &catalog, &session, &[id], &reload);
});
}
// `Delete` on the selection — the bulk gesture.
@@ -895,17 +918,20 @@ fn wire_trash_from_grid(
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_library_trash_selection(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.selected();
if chosen.is_empty() {
// A keystroke that does nothing reads as a broken key, so it
// says why rather than failing silently.
w.set_library_status("Select an image first".into());
return;
}
start_trash(&w, &ctl, &catalog, &session, &chosen, &reload);
});
window
.global::<Library>()
.on_library_trash_selection(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.selected();
if chosen.is_empty() {
// A keystroke that does nothing reads as a broken key, so it
// says why rather than failing silently.
w.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
start_trash(&w, &ctl, &catalog, &session, &chosen, &reload);
});
}
{
@@ -947,7 +973,8 @@ fn wire_trash_from_grid(
let ids: Vec<ImageId> = listed.iter().map(|t| t.image_id).collect();
log::info!("emptying trash: {} image(s)", ids.len());
w.set_library_status(format!("Deleting {} image(s)…", ids.len()).into());
w.global::<Library>()
.set_library_status(format!("Deleting {} image(s)…", ids.len()).into());
let count = ids.len();
let rx = crate::trash::spawn_purge(
+53 -46
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.global::<Library>().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()),
}
});
}
}
@@ -421,7 +424,8 @@ pub(super) fn wire_row_drag(
Ok(()) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved to the top level".into());
w.global::<Library>()
.set_library_status("Moved to the top level".into());
refresh_tree(&w, &ctl, cat);
log::info!("promoted collection {} by drag", source.0);
}
@@ -537,7 +541,8 @@ pub(super) fn wire_row_menu(
Ok(()) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved to the top level".into());
w.global::<Library>()
.set_library_status("Moved to the top level".into());
close_row_menu(&w, &ctl);
refresh_tree(&w, &ctl, cat);
log::info!("promoted collection {} to the top level", id.0);
@@ -626,7 +631,7 @@ pub(super) fn wire_row_menu(
// Named as a membership change, as the removal button is:
// a user who reads this as a delete of their photographs
// will not trust the feature again.
w.set_library_status(if holds > 0 {
w.global::<Library>().set_library_status(if holds > 0 {
format!(
"Deleted the collection; its {holds} photograph(s) stay in the library"
)
@@ -688,13 +693,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);
});
}
{
@@ -724,7 +731,7 @@ pub(super) fn wire_membership(
.set_collection_error(slint::SharedString::new());
// The same wording the scoped button uses, and for the same
// reason: this is a membership change, not a delete.
w.set_library_status(
w.global::<Library>().set_library_status(
format!("Removed {n} from that collection; still in the library")
.into(),
);
+40 -32
View File
@@ -31,8 +31,8 @@ use crate::spots_ui;
use crate::{
apply_crop_aspect, batch_request, drain_outbox, export, groups_in_rail, refresh_export_label,
render_open_frame, sync_crop_aspect, sync_film, sync_framing, sync_rows, Adjustments,
AppWindow, Develop, DevelopSession, Framing, Inspection, ParamRow, Peaking, Render, Steps,
ViewMode,
AppWindow, Develop, DevelopSession, Framing, Inspection, Library, ParamRow, Peaking, Render,
Steps, ViewMode,
};
/// Shared state the develop screen's callbacks capture.
@@ -285,7 +285,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
// buttons share a worker slot, so starting a second run would
// drop the first's drain — leaving a batch still writing files
// with no progress row and a button that never comes back.
if w.get_export_busy() || w.get_library_exporting() {
if w.get_export_busy() || w.global::<Library>().get_library_exporting() {
w.set_export_status("An export is already running".into());
return;
}
@@ -316,42 +316,50 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
let library = library.clone();
let collections = collections.clone();
let start = start.clone();
window.on_library_export_selection(move || {
let Some(w) = weak.upgrade() else { return };
// See the develop button above for why one run excludes the
// other. The button is a cancel by then, so this only catches
// a batch started from develop and left running.
if w.get_library_exporting() || w.get_export_busy() {
w.set_library_status("An export is already running".into());
return;
}
window
.global::<Library>()
.on_library_export_selection(move || {
let Some(w) = weak.upgrade() else { return };
// See the develop button above for why one run excludes the
// other. The button is a cancel by then, so this only catches
// a batch started from develop and left running.
if w.global::<Library>().get_library_exporting() || w.get_export_busy() {
w.global::<Library>()
.set_library_status("An export is already running".into());
return;
}
let sources = library.export_sources(&collections.selected());
if sources.is_empty() {
// Said out loud rather than ignored, matching what a paste
// or a judgement keystroke does with an empty selection.
w.set_library_status("Select an image first".into());
return;
}
let sources = library.export_sources(&collections.selected());
if sources.is_empty() {
// Said out loud rather than ignored, matching what a paste
// or a judgement keystroke does with an empty selection.
w.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
w.set_library_exporting(true);
w.set_library_status(format!("Exporting {} images…", sources.len()).into());
start(&w, sources, export::Reporting::Library);
});
w.global::<Library>().set_library_exporting(true);
w.global::<Library>()
.set_library_status(format!("Exporting {} images…", sources.len()).into());
start(&w, sources, export::Reporting::Library);
});
}
// TRACES: NFR-ARCH-3
{
let weak = window.as_weak();
window.on_library_cancel_export(move || {
cancel.borrow().cancel();
if let Some(w) = weak.upgrade() {
// The worker stops at the next point it is safe to — which
// may be a frame away — so the button says "asked for" and
// not "done". The drain writes the real answer.
w.set_library_status("Cancelling the export…".into());
}
});
window
.global::<Library>()
.on_library_cancel_export(move || {
cancel.borrow().cancel();
if let Some(w) = weak.upgrade() {
// The worker stops at the next point it is safe to — which
// may be a frame away — so the button says "asked for" and
// not "done". The drain writes the real answer.
w.global::<Library>()
.set_library_status("Cancelling the export…".into());
}
});
}
}
+4 -3
View File
@@ -67,8 +67,9 @@ use dr_export::{Encoded, NameContext};
use dr_sync::RemotePath;
use dr_sync::{Account, Connection};
use dr_types::{ExportSettings, ExportTarget};
use slint::ComponentHandle as _;
use crate::AppWindow;
use crate::{AppWindow, Library};
/// Where exports wait for a server that is not there yet.
///
@@ -1131,7 +1132,7 @@ fn summarise(
fn report(window: &AppWindow, reporting: Reporting, text: &str) {
match reporting {
Reporting::Develop => window.set_export_status(text.into()),
Reporting::Library => window.set_library_status(text.into()),
Reporting::Library => window.global::<Library>().set_library_status(text.into()),
}
}
@@ -1140,7 +1141,7 @@ fn settle(window: &AppWindow, reporting: Reporting, text: &str) {
report(window, reporting, text);
match reporting {
Reporting::Develop => window.set_export_busy(false),
Reporting::Library => window.set_library_exporting(false),
Reporting::Library => window.global::<Library>().set_library_exporting(false),
}
}
+4 -3
View File
@@ -72,6 +72,7 @@ use std::rc::Rc;
use anyhow::Result;
use dr_decode::{Metadata, PreviewSize};
use slint::ComponentHandle as _;
pub use develop::DevelopSession;
@@ -1841,7 +1842,7 @@ fn wire_import_and_merge(
// on the next sync pass, then look for it, exactly as an
// import does.
drain_outbox(&library_for_done);
w.invoke_library_rescan();
w.global::<Library>().invoke_library_rescan();
},
);
}
@@ -1878,7 +1879,7 @@ fn wire_import_and_merge(
// the second is what the grid shows, so this asks for the scan
// that finds them rather than inserting rows itself.
if let Some(w) = weak.upgrade() {
w.invoke_library_rescan();
w.global::<Library>().invoke_library_rescan();
}
},
);
@@ -3436,7 +3437,7 @@ fn back_one_step(w: &AppWindow) -> bool {
// Files named on the command line have no grid behind them — the same
// condition the status strip uses to decide whether to offer the way
// back at all.
has_grid: w.get_library_total() > 0,
has_grid: w.global::<Library>().get_library_total() > 0,
scoped: w.get_collection_selected() != 0,
};
+287 -243
View File
@@ -12,7 +12,7 @@ use std::rc::Rc;
use slint::ComponentHandle;
use crate::library;
use crate::{AppWindow, PersonChip, View};
use crate::{AppWindow, Library, PersonChip, View};
use super::controller::LibraryController;
use super::offline::{
@@ -31,42 +31,48 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_min_rating_changed(move |n| {
let Some(w) = weak.upgrade() else { return };
ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8;
// Stars and "unrated" are contradictory terms — asking for four
// stars *and* nothing judged matches nothing at all, which reads
// as a broken filter rather than an impossible question.
if n > 0 {
ctl.filter.borrow_mut().unjudged = false;
}
w.set_library_filter_min_rating(n.clamp(0, 5));
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
refilter(&w, &ctl);
});
window
.global::<Library>()
.on_library_filter_min_rating_changed(move |n| {
let Some(w) = weak.upgrade() else { return };
ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8;
// Stars and "unrated" are contradictory terms — asking for four
// stars *and* nothing judged matches nothing at all, which reads
// as a broken filter rather than an impossible question.
if n > 0 {
ctl.filter.borrow_mut().unjudged = false;
}
w.global::<Library>()
.set_library_filter_min_rating(n.clamp(0, 5));
w.global::<Library>()
.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_unjudged_changed(move |on| {
let Some(w) = weak.upgrade() else { return };
{
let mut f = ctl.filter.borrow_mut();
f.unjudged = on;
// See above: the two cannot both hold.
if on {
f.min_rating = 0;
f.flag = None;
window
.global::<Library>()
.on_library_filter_unjudged_changed(move |on| {
let Some(w) = weak.upgrade() else { return };
{
let mut f = ctl.filter.borrow_mut();
f.unjudged = on;
// See above: the two cannot both hold.
if on {
f.min_rating = 0;
f.flag = None;
}
}
}
w.set_library_filter_unjudged(on);
if on {
w.set_library_filter_min_rating(0);
w.set_library_filter_flag(0);
}
refilter(&w, &ctl);
});
w.global::<Library>().set_library_filter_unjudged(on);
if on {
w.global::<Library>().set_library_filter_min_rating(0);
w.global::<Library>().set_library_filter_flag(0);
}
refilter(&w, &ctl);
});
}
{
@@ -101,99 +107,112 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_person_cleared(move |id| {
let Some(w) = weak.upgrade() else { return };
ctl.filter
.borrow_mut()
.people
.retain(|p| *p != id.max(0) as u64);
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
window
.global::<Library>()
.on_library_filter_person_cleared(move |id| {
let Some(w) = weak.upgrade() else { return };
ctl.filter
.borrow_mut()
.people
.retain(|p| *p != id.max(0) as u64);
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_people_listed(move || {
let Some(w) = weak.upgrade() else { return };
push_people_roster(&w, &ctl);
});
window
.global::<Library>()
.on_library_people_listed(move || {
let Some(w) = weak.upgrade() else { return };
push_people_roster(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_person_toggled(move |id| {
let Some(w) = weak.upgrade() else { return };
let person = id.max(0) as u64;
{
let mut f = ctl.filter.borrow_mut();
if let Some(at) = f.people.iter().position(|p| *p == person) {
f.people.remove(at);
} else {
f.people.push(person);
window
.global::<Library>()
.on_library_filter_person_toggled(move |id| {
let Some(w) = weak.upgrade() else { return };
let person = id.max(0) as u64;
{
let mut f = ctl.filter.borrow_mut();
if let Some(at) = f.people.iter().position(|p| *p == person) {
f.people.remove(at);
} else {
f.people.push(person);
}
}
}
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
drop_eyes_open_without_people(&w, &ctl);
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_eyes_open_toggled(move || {
let Some(w) = weak.upgrade() else { return };
let on = {
let mut f = ctl.filter.borrow_mut();
f.eyes_open = !f.eyes_open;
f.eyes_open
};
w.set_library_filter_eyes_open(on);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_people_mode_toggled(move || {
let Some(w) = weak.upgrade() else { return };
{
let mut f = ctl.filter.borrow_mut();
f.people_mode = match f.people_mode {
library::PeopleMode::Any => library::PeopleMode::All,
library::PeopleMode::All => library::PeopleMode::Any,
window
.global::<Library>()
.on_library_filter_eyes_open_toggled(move || {
let Some(w) = weak.upgrade() else { return };
let on = {
let mut f = ctl.filter.borrow_mut();
f.eyes_open = !f.eyes_open;
f.eyes_open
};
}
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
w.global::<Library>().set_library_filter_eyes_open(on);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_flag_changed(move |f| {
let Some(w) = weak.upgrade() else { return };
{
let mut filter = ctl.filter.borrow_mut();
filter.flag = match f {
1 => Some(dr_types::FlagState::Pick),
2 => Some(dr_types::FlagState::Reject),
_ => None,
};
if f > 0 {
filter.unjudged = false;
window
.global::<Library>()
.on_library_filter_people_mode_toggled(move || {
let Some(w) = weak.upgrade() else { return };
{
let mut f = ctl.filter.borrow_mut();
f.people_mode = match f.people_mode {
library::PeopleMode::Any => library::PeopleMode::All,
library::PeopleMode::All => library::PeopleMode::Any,
};
}
}
w.set_library_filter_flag(f);
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
refilter(&w, &ctl);
});
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_filter_flag_changed(move |f| {
let Some(w) = weak.upgrade() else { return };
{
let mut filter = ctl.filter.borrow_mut();
filter.flag = match f {
1 => Some(dr_types::FlagState::Pick),
2 => Some(dr_types::FlagState::Reject),
_ => None,
};
if f > 0 {
filter.unjudged = false;
}
}
w.global::<Library>().set_library_filter_flag(f);
w.global::<Library>()
.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
refilter(&w, &ctl);
});
}
}
@@ -218,57 +237,59 @@ pub(super) fn wire_filter_dates(window: &AppWindow, ctl: &Rc<LibraryController>)
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_toggle_date_range(move || {
let Some(w) = weak.upgrade() else { return };
// Whether the *controls* are showing, which is not the same as
// whether a range is set — and reading it from the filter was why
// pressing this did nothing. The fields are how a range gets set,
// so requiring one before they appear is a door locked from the
// inside.
let on = !w.get_library_range_active();
if on {
// Seeded from the span the axis is drawn over, which is what
// the user is looking at when they ask for "this range".
//
// Only a seed. If there is no catalog yet, or nothing in it
// carries a capture date — dates are read from EXIF as
// thumbnails load, so a freshly opened library has none — the
// fields simply open empty and wait to be typed into. This
// used to `return` in both cases, which meant the button
// silently did nothing on exactly the libraries where a person
// is most likely to be looking for a date.
let seeded = {
let borrow = ctl.catalog.borrow();
borrow
.as_ref()
.and_then(|catalog| catalog_span(catalog, &ctl))
.map(|span| {
zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
)
})
};
if let Some(span) = seeded {
// Through the same days-inclusive conversion a drag uses,
// so a seeded range and a dragged one are the same kind of
// thing — and so `show_range` reads this one back out as
// the two days it covers.
let (from, to) = dragged_range(span, 0.0, 1.0);
window
.global::<Library>()
.on_library_toggle_date_range(move || {
let Some(w) = weak.upgrade() else { return };
// Whether the *controls* are showing, which is not the same as
// whether a range is set — and reading it from the filter was why
// pressing this did nothing. The fields are how a range gets set,
// so requiring one before they appear is a door locked from the
// inside.
let on = !w.global::<Library>().get_library_range_active();
if on {
// Seeded from the span the axis is drawn over, which is what
// the user is looking at when they ask for "this range".
//
// Only a seed. If there is no catalog yet, or nothing in it
// carries a capture date — dates are read from EXIF as
// thumbnails load, so a freshly opened library has none — the
// fields simply open empty and wait to be typed into. This
// used to `return` in both cases, which meant the button
// silently did nothing on exactly the libraries where a person
// is most likely to be looking for a date.
let seeded = {
let borrow = ctl.catalog.borrow();
borrow
.as_ref()
.and_then(|catalog| catalog_span(catalog, &ctl))
.map(|span| {
zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
)
})
};
if let Some(span) = seeded {
// Through the same days-inclusive conversion a drag uses,
// so a seeded range and a dragged one are the same kind of
// thing — and so `show_range` reads this one back out as
// the two days it covers.
let (from, to) = dragged_range(span, 0.0, 1.0);
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
}
} else {
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
f.captured_from = None;
f.captured_to = None;
}
} else {
let mut f = ctl.filter.borrow_mut();
f.captured_from = None;
f.captured_to = None;
}
w.set_library_range_active(on);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
w.global::<Library>().set_library_range_active(on);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-6 | FR-UI-2
@@ -289,39 +310,41 @@ pub(super) fn wire_filter_dates(window: &AppWindow, ctl: &Rc<LibraryController>)
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_range_changed(move |a, b| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
let span = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
drop(borrow);
window
.global::<Library>()
.on_library_timeline_range_changed(move |a, b| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
let span = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
drop(borrow);
let (from, to) = dragged_range(span, a, b);
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
}
let (from, to) = dragged_range(span, a, b);
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
}
// A dragged range cannot be mistyped, so any standing complaint
// about the fields is now about a range that no longer exists.
w.set_library_range_invalid(false);
w.set_library_range_active(true);
show_range(&w, &ctl);
// Reloads the window, and `load_window` redraws the timeline —
// which is what puts the band back under the finger that dropped
// it, at the whole days it was snapped to.
refilter(&w, &ctl);
});
// A dragged range cannot be mistyped, so any standing complaint
// about the fields is now about a range that no longer exists.
w.global::<Library>().set_library_range_invalid(false);
w.global::<Library>().set_library_range_active(true);
show_range(&w, &ctl);
// Reloads the window, and `load_window` redraws the timeline —
// which is what puts the band back under the finger that dropped
// it, at the whole days it was snapped to.
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-6
@@ -334,39 +357,41 @@ pub(super) fn wire_filter_dates(window: &AppWindow, ctl: &Rc<LibraryController>)
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_range_edited(move |from, to| {
let Some(w) = weak.upgrade() else { return };
window
.global::<Library>()
.on_library_range_edited(move |from, to| {
let Some(w) = weak.upgrade() else { return };
// Both ends, or neither. A range with one end parsed and the other
// silently dropped is a filter nobody asked for, and the grid
// going empty is a poor way to find out a date was mistyped.
let (Some(from), Some(to)) = (
dr_types::parse_date(from.as_str()),
dr_types::parse_date(to.as_str()),
) else {
w.set_library_range_invalid(true);
return;
};
// Both ends, or neither. A range with one end parsed and the other
// silently dropped is a filter nobody asked for, and the grid
// going empty is a poor way to find out a date was mistyped.
let (Some(from), Some(to)) = (
dr_types::parse_date(from.as_str()),
dr_types::parse_date(to.as_str()),
) else {
w.global::<Library>().set_library_range_invalid(true);
return;
};
// Typed the other way round is a slip, not an error: the user has
// said which two days they mean and there is exactly one range
// between them.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
// Typed the other way round is a slip, not an error: the user has
// said which two days they mean and there is exactly one range
// between them.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
// Inclusive of the last day. "To the 5th" means the whole of
// the 5th — `parse_date` returns its midnight, and a range
// ending there would exclude every photograph taken on the day
// the user named. The same rule `dragged_range` applies.
f.captured_to = Some(to + 86_400 - 1);
}
w.set_library_range_invalid(false);
w.set_library_range_active(true);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
// Inclusive of the last day. "To the 5th" means the whole of
// the 5th — `parse_date` returns its midnight, and a range
// ending there would exclude every photograph taken on the day
// the user named. The same rule `dragged_range` applies.
f.captured_to = Some(to + 86_400 - 1);
}
w.global::<Library>().set_library_range_invalid(false);
w.global::<Library>().set_library_range_active(true);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
}
}
@@ -384,14 +409,16 @@ pub(super) fn wire_filter_scope_and_offline(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_toggle_local_only(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.local_only();
ctl.set_local_only(on);
ctl.filter.borrow_mut().local_only = on;
w.set_library_local_only(on);
refilter(&w, &ctl);
});
window
.global::<Library>()
.on_library_toggle_local_only(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.local_only();
ctl.set_local_only(on);
ctl.filter.borrow_mut().local_only = on;
w.global::<Library>().set_library_local_only(on);
refilter(&w, &ctl);
});
}
// TRACES: FR-NC-6a
@@ -403,13 +430,15 @@ pub(super) fn wire_filter_scope_and_offline(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_toggle_pin_scope(move || {
let Some(w) = weak.upgrade() else { return };
let Some(scope) = *ctl.scope.borrow() else {
return;
};
open_offline_prompt(&w, &ctl, scope);
});
window
.global::<Library>()
.on_library_toggle_pin_scope(move || {
let Some(w) = weak.upgrade() else { return };
let Some(scope) = *ctl.scope.borrow() else {
return;
};
open_offline_prompt(&w, &ctl, scope);
});
}
// TRACES: FR-NC-6a | FR-UI-4
@@ -466,14 +495,16 @@ pub(super) fn wire_filter_scope_and_offline(
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_ctl = coll_ctl.clone();
window.on_library_retry_connection(move || {
let Some(w) = weak.upgrade() else { return };
log::info!("retrying the connection at the user's request");
// A rescan is the probe: it is the same request the scan worker
// makes, so a success proves reachability and repopulates the
// catalog in one pass rather than proving it twice.
start_rescan(&w, &ctl, &coll_ctl);
});
window
.global::<Library>()
.on_library_retry_connection(move || {
let Some(w) = weak.upgrade() else { return };
log::info!("retrying the connection at the user's request");
// A rescan is the probe: it is the same request the scan worker
// makes, so a success proves reachability and repopulates the
// catalog in one pass rather than proving it twice.
start_rescan(&w, &ctl, &coll_ctl);
});
}
}
@@ -500,7 +531,9 @@ fn drop_eyes_open_without_people(window: &AppWindow, ctl: &Rc<LibraryController>
let mut f = ctl.filter.borrow_mut();
if f.people.is_empty() && f.eyes_open {
f.eyes_open = false;
window.set_library_filter_eyes_open(false);
window
.global::<Library>()
.set_library_filter_eyes_open(false);
}
}
@@ -508,6 +541,7 @@ pub(super) fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>)
let ids = ctl.filter.borrow().people.clone();
if ids.is_empty() {
window
.global::<Library>()
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
Vec::<PersonChip>::new(),
)));
@@ -552,11 +586,15 @@ pub(super) fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>)
})
.collect();
window.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips)));
window.set_library_filter_people_all(matches!(
ctl.filter.borrow().people_mode,
library::PeopleMode::All
));
window
.global::<Library>()
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips)));
window
.global::<Library>()
.set_library_filter_people_all(matches!(
ctl.filter.borrow().people_mode,
library::PeopleMode::All
));
push_people_roster(window, ctl);
}
@@ -621,7 +659,9 @@ fn push_people_roster(window: &AppWindow, ctl: &Rc<LibraryController>) {
rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces));
let rows: Vec<PersonChip> = rows.into_iter().map(|(_, _, chip)| chip).collect();
window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows)));
window
.global::<Library>()
.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows)));
}
pub(super) fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
@@ -687,10 +727,14 @@ fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
let text = |t: Option<i64>| -> slint::SharedString {
t.map(dr_types::format_date).unwrap_or_default().into()
};
window.set_library_range_from(text(f.captured_from));
window
.global::<Library>()
.set_library_range_from(text(f.captured_from));
// The stored end is the last second of the closing day; naming that day is
// what the user typed and what they should read back.
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
window
.global::<Library>()
.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
}
#[cfg(test)]
+271 -246
View File
@@ -12,7 +12,7 @@ use std::rc::Rc;
use slint::{ComponentHandle, Model as _};
use crate::AppWindow;
use crate::{GestureRow, View};
use crate::{GestureRow, Library, View};
use super::controller::{
LibraryController, OpenImage, MAX_CELL_SIZE, MIN_CELL_SIZE, MIN_WINDOW, SCREENFULS,
@@ -49,7 +49,7 @@ fn move_cursor(
delta: i32,
extend: bool,
) {
let total = window.get_library_total().max(0) as usize;
let total = window.global::<Library>().get_library_total().max(0) as usize;
if total == 0 {
return;
}
@@ -93,11 +93,11 @@ fn place_cursor(
// quarter in, exactly as a scroll does it, so continuing in the same
// direction has loaded cells to move into rather than another reload on
// the very next press.
let loaded = window.get_library_cells().row_count();
let loaded = window.global::<Library>().get_library_cells().row_count();
let offset = *ctl.offset.borrow();
if at < offset || at >= offset + loaded {
let size = *ctl.window.borrow();
let total = window.get_library_total().max(0) as usize;
let total = window.global::<Library>().get_library_total().max(0) as usize;
*ctl.offset.borrow_mut() = window_start(at, size, total);
load_window(window, ctl);
}
@@ -122,7 +122,7 @@ fn place_cursor(
}
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
window.set_library_cursor(at as i32);
window.global::<Library>().set_library_cursor(at as i32);
}
/// Say where in the library the photograph now open sits.
@@ -136,7 +136,7 @@ fn place_cursor(
fn report_position(window: &AppWindow, ctl: &Rc<LibraryController>, row: usize) {
let offset = *ctl.offset.borrow();
window.set_index((offset + row) as i32);
window.set_total(window.get_library_total());
window.set_total(window.global::<Library>().get_library_total());
}
/// Connect the grid's callbacks.
@@ -175,25 +175,29 @@ pub fn wire<F>(
// claims the gesture before the delay it would forward after. Seeding it
// here means the stars are on screen before the first touch rather than
// after it, which is the whole point of showing them.
window.set_library_touched(cfg!(target_os = "android"));
window
.global::<Library>()
.set_library_touched(cfg!(target_os = "android"));
// TRACES: FR-UI-4
// The gesture reference. Pushed once, here, rather than on demand: the
// table is a compiled-in constant, so there is nothing to be fresh about
// and nothing to recompute — a callback to fill it would only be a way for
// it to be empty the first time the sheet opens.
window.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
crate::gestures::rows()
.into_iter()
.map(|r| GestureRow {
heading: r.heading.into(),
title: r.title.into(),
touch: r.touch.into(),
pointer: r.pointer.into(),
keys: r.keys.into(),
})
.collect::<Vec<_>>(),
)));
window
.global::<Library>()
.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
crate::gestures::rows()
.into_iter()
.map(|r| GestureRow {
heading: r.heading.into(),
title: r.title.into(),
touch: r.touch.into(),
pointer: r.pointer.into(),
keys: r.keys.into(),
})
.collect::<Vec<_>>(),
)));
// Shared rather than moved: a click and `Return` both open an image, and
// they are two callbacks.
@@ -209,42 +213,44 @@ pub fn wire<F>(
let ctl = ctl.clone();
let coll_for_click = coll_ctl.clone();
let on_open_image = on_open_image.clone();
window.on_library_cell_clicked(move |i| {
let Some(w) = weak.upgrade() else { return };
window
.global::<Library>()
.on_library_cell_clicked(move |i| {
let Some(w) = weak.upgrade() else { return };
// The press stayed put, so it was a tap and not a drag: whatever it
// held back can be applied now. See `collections_ui::Press`.
crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids());
// The press stayed put, so it was a tap and not a drag: whatever it
// held back can be applied now. See `collections_ui::Press`.
crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids());
// A ctrl- or shift-click is a selection gesture. Opening the image
// too would throw the user out of the grid mid-selection.
if coll_for_click.press_was_modified() {
return;
}
// A ctrl- or shift-click is a selection gesture. Opening the image
// too would throw the user out of the grid mid-selection.
if coll_for_click.press_was_modified() {
return;
}
let path = ctl.paths.borrow().get(i as usize).cloned();
if let Some(path) = path {
// Leave the grid for the develop view. The status bar's
// "‹ Library" button comes back here.
w.set_active_view(View::Develop);
// Which cell the develop view is now showing, so the photo
// roll opens marking it rather than marking nothing.
w.set_library_roll_current(i);
// A develop session begins here, so the roll centres on this
// photograph the first time it settles rather than merely
// scrolling it into view at one edge. Raised here and not in
// `on_library_roll_pick`, which is a step *within* a session —
// see `PhotoRoll::centre-request`.
w.set_library_roll_centre(true);
on_open_image(path);
report_position(&w, &ctl, i as usize);
// TRACES: FR-UI-8
// Written now rather than on a settle: which view you are in is
// the field a debounce is most likely to lose, and quitting
// straight from develop is exactly the case worth getting right.
write_place(&w, &ctl);
}
});
let path = ctl.paths.borrow().get(i as usize).cloned();
if let Some(path) = path {
// Leave the grid for the develop view. The status bar's
// "‹ Library" button comes back here.
w.set_active_view(View::Develop);
// Which cell the develop view is now showing, so the photo
// roll opens marking it rather than marking nothing.
w.global::<Library>().set_library_roll_current(i);
// A develop session begins here, so the roll centres on this
// photograph the first time it settles rather than merely
// scrolling it into view at one edge. Raised here and not in
// `on_library_roll_pick`, which is a step *within* a session —
// see `PhotoRoll::centre-request`.
w.global::<Library>().set_library_roll_centre(true);
on_open_image(path);
report_position(&w, &ctl, i as usize);
// TRACES: FR-UI-8
// Written now rather than on a settle: which view you are in is
// the field a debounce is most likely to lose, and quitting
// straight from develop is exactly the case worth getting right.
write_place(&w, &ctl);
}
});
}
// TRACES: FR-UI-4
@@ -259,12 +265,12 @@ pub fn wire<F>(
let weak = window.as_weak();
let ctl = ctl.clone();
let on_open_image = on_open_image.clone();
window.on_library_roll_pick(move |i| {
window.global::<Library>().on_library_roll_pick(move |i| {
let Some(w) = weak.upgrade() else { return };
let Some(path) = ctl.paths.borrow().get(i as usize).cloned() else {
return;
};
w.set_library_roll_current(i);
w.global::<Library>().set_library_roll_current(i);
on_open_image(path);
report_position(&w, &ctl, i as usize);
// TRACES: FR-UI-8
@@ -307,10 +313,12 @@ fn wire_grid_cursor_and_zoom(
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window.on_library_move_cursor(move |delta, extend| {
let Some(w) = weak.upgrade() else { return };
move_cursor(&w, &ctl, &coll, delta, extend);
});
window
.global::<Library>()
.on_library_move_cursor(move |delta, extend| {
let Some(w) = weak.upgrade() else { return };
move_cursor(&w, &ctl, &coll, delta, extend);
});
}
{
@@ -318,7 +326,7 @@ fn wire_grid_cursor_and_zoom(
let ctl = ctl.clone();
let coll = coll_ctl.clone();
let on_open = on_open_image.clone();
window.on_library_open_cursor(move || {
window.global::<Library>().on_library_open_cursor(move || {
let Some(w) = weak.upgrade() else { return };
// The cursor is a library ordinal and `paths` is the loaded
// window, so the row is the difference. A cursor outside the
@@ -333,8 +341,9 @@ fn wire_grid_cursor_and_zoom(
w.set_active_view(View::Develop);
// As on a click: the roll marks what is open, and centres on it
// because this too begins a session.
w.set_library_roll_current(row.unwrap_or(0) as i32);
w.set_library_roll_centre(true);
w.global::<Library>()
.set_library_roll_current(row.unwrap_or(0) as i32);
w.global::<Library>().set_library_roll_centre(true);
on_open(path);
report_position(&w, &ctl, row.unwrap_or(0));
write_place(&w, &ctl);
@@ -350,33 +359,35 @@ fn wire_grid_cursor_and_zoom(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_zoom_cells(move |delta| {
let Some(w) = weak.upgrade() else { return };
window
.global::<Library>()
.on_library_zoom_cells(move |delta| {
let Some(w) = weak.upgrade() else { return };
let current = w.get_library_cell_size();
let next = if delta > 0 {
current * 1.25
} else {
current / 1.25
}
.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
let current = w.global::<Library>().get_library_cell_size();
let next = if delta > 0 {
current * 1.25
} else {
current / 1.25
}
.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
if (next - current).abs() < 0.5 {
return;
}
w.set_library_cell_size(next);
if (next - current).abs() < 0.5 {
return;
}
w.global::<Library>().set_library_cell_size(next);
// No need to forget anything on a class change: the class is part
// of the request key, so cells that now want the large resolution
// simply miss and ask for it, while the 256px ones they already
// hold stay served.
//
// Deferred, like every other geometry change. This one was still
// reloading inline — a full catalog re-read and model rebuild per
// step, which is what a wheel spun through six steps paid six
// times over.
schedule_reload(&w, &ctl);
});
// No need to forget anything on a class change: the class is part
// of the request key, so cells that now want the large resolution
// simply miss and ask for it, while the 256px ones they already
// hold stay served.
//
// Deferred, like every other geometry change. This one was still
// reloading inline — a full catalog re-read and model rebuild per
// step, which is what a wheel spun through six steps paid six
// times over.
schedule_reload(&w, &ctl);
});
}
// TRACES: FR-UI-4
@@ -391,20 +402,22 @@ fn wire_grid_cursor_and_zoom(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_pinch_cells(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(ratio.is_finite() && ratio > 0.0) {
return;
}
window
.global::<Library>()
.on_library_pinch_cells(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(ratio.is_finite() && ratio > 0.0) {
return;
}
let current = w.get_library_cell_size();
let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
if (next - current).abs() < 0.5 {
return;
}
w.set_library_cell_size(next);
schedule_reload(&w, &ctl);
});
let current = w.global::<Library>().get_library_cell_size();
let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
if (next - current).abs() < 0.5 {
return;
}
w.global::<Library>().set_library_cell_size(next);
schedule_reload(&w, &ctl);
});
}
// TRACES: FR-UI-4
@@ -418,10 +431,12 @@ fn wire_grid_cursor_and_zoom(
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window.on_library_pinch_started(move || {
let Some(w) = weak.upgrade() else { return };
crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids());
});
window
.global::<Library>()
.on_library_pinch_started(move || {
let Some(w) = weak.upgrade() else { return };
crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids());
});
}
}
@@ -433,7 +448,7 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_sync_now(move || {
window.global::<Library>().on_library_sync_now(move || {
if let Some(w) = weak.upgrade() {
start_derived_sync(&w, &ctl);
}
@@ -446,11 +461,13 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_thumbnail_all(move || {
if let Some(w) = weak.upgrade() {
start_thumbnail_sweep(&w, &ctl);
}
});
window
.global::<Library>()
.on_library_thumbnail_all(move || {
if let Some(w) = weak.upgrade() {
start_thumbnail_sweep(&w, &ctl);
}
});
}
// A column-count change moves which cells begin a row, and month headings
@@ -462,25 +479,27 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_columns_changed(move || {
if let Some(w) = weak.upgrade() {
if !w.get_library_visible() {
return;
}
window
.global::<Library>()
.on_library_columns_changed(move || {
if let Some(w) = weak.upgrade() {
if !w.get_library_visible() {
return;
}
// Deferred, and re-anchored when it lands — see
// [`schedule_reload`]. A column change moves every cell in the
// grid, because a cell is drawn at its absolute place in the
// library and the row that resolves to is `index / columns`.
// The viewport does not move with them, so without the
// re-anchor the view is left pointing at rows the loaded window
// no longer covers and the grid draws nothing at all — the
// "gallery randomly goes blank until I scroll" report, whose
// triggers are a resize, the sidebar opening, a zoom step, or
// turning the tablet over.
schedule_reload(&w, &ctl);
}
});
// Deferred, and re-anchored when it lands — see
// [`schedule_reload`]. A column change moves every cell in the
// grid, because a cell is drawn at its absolute place in the
// library and the row that resolves to is `index / columns`.
// The viewport does not move with them, so without the
// re-anchor the view is left pointing at rows the loaded window
// no longer covers and the grid draws nothing at all — the
// "gallery randomly goes blank until I scroll" report, whose
// triggers are a resize, the sidebar opening, a zoom step, or
// turning the tablet over.
schedule_reload(&w, &ctl);
}
});
}
// The viewport changed size, so the window it can usefully hold changed
@@ -489,23 +508,25 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_viewport_cells(move |on_screen| {
let Some(w) = weak.upgrade() else { return };
if !w.get_library_visible() {
return;
}
let on_screen = (on_screen.max(0) as usize).max(1);
let window_size = (on_screen * SCREENFULS).max(MIN_WINDOW);
if window_size == *ctl.window.borrow() && on_screen == ctl.viewport_cells.get() {
return;
}
ctl.viewport_cells.set(on_screen);
*ctl.window.borrow_mut() = window_size;
// Coalesced with the column change that almost always accompanies
// it: resizing the cells alters both, and reloading once per report
// meant two full rebuilds per zoom step.
schedule_reload(&w, &ctl);
});
window
.global::<Library>()
.on_library_viewport_cells(move |on_screen| {
let Some(w) = weak.upgrade() else { return };
if !w.get_library_visible() {
return;
}
let on_screen = (on_screen.max(0) as usize).max(1);
let window_size = (on_screen * SCREENFULS).max(MIN_WINDOW);
if window_size == *ctl.window.borrow() && on_screen == ctl.viewport_cells.get() {
return;
}
ctl.viewport_cells.set(on_screen);
*ctl.window.borrow_mut() = window_size;
// Coalesced with the column change that almost always accompanies
// it: resizing the cells alters both, and reloading once per report
// meant two full rebuilds per zoom step.
schedule_reload(&w, &ctl);
});
}
// Scrolling moves the loaded window through the library.
@@ -515,118 +536,122 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_scrolled(move |first_visible| {
let Some(w) = weak.upgrade() else { return };
let first_visible = first_visible.max(0) as usize;
window
.global::<Library>()
.on_library_scrolled(move |first_visible| {
let Some(w) = weak.upgrade() else { return };
let first_visible = first_visible.max(0) as usize;
// **A report from a grid that is not on screen is not a scroll.**
//
// The grid is gated on an `if`, so leaving it tears the whole
// subtree down — and a Flickable being destroyed passes its viewport
// through zero on the way out, which arrives here indistinguishable
// from the user having flung the grid to the top. Everything below
// then ran on the way *into* develop: the loaded window was reset to
// offset zero, the model was rebuilt against the first rows of the
// library, and a thumbnail batch was issued for photographs nobody
// had asked to see. Those rebuilds landed while the grid was still
// being taken apart, which is what flashed the library over the
// develop view for the first few frames after a click — and on a
// remote library it also spent a burst of requests on the top of the
// catalog every single time an image was opened.
//
// **`library-visible`, not `show-library`, and the difference is the
// whole bug.** `show-library` says "the library rather than develop"
// and stays true while Settings, Import, People or the launch screen
// replaces the window — all four of which take the grid down just as
// opening an image does. So the teardown's scroll-to-zero passed this
// guard, `resume_at` was set to 0, and coming back from any of those
// four screens landed at the top of the library however deep in it the
// user had been. The property is computed once in `app.slint` beside
// the `if` it is spelled from, so the two cannot drift apart again.
//
// The guard used to cover only `resume_at`, for a narrower version
// of the same reason. It belongs over the whole handler.
if !w.get_library_visible() {
return;
}
// **A report from a grid that is not on screen is not a scroll.**
//
// The grid is gated on an `if`, so leaving it tears the whole
// subtree down — and a Flickable being destroyed passes its viewport
// through zero on the way out, which arrives here indistinguishable
// from the user having flung the grid to the top. Everything below
// then ran on the way *into* develop: the loaded window was reset to
// offset zero, the model was rebuilt against the first rows of the
// library, and a thumbnail batch was issued for photographs nobody
// had asked to see. Those rebuilds landed while the grid was still
// being taken apart, which is what flashed the library over the
// develop view for the first few frames after a click — and on a
// remote library it also spent a burst of requests on the top of the
// catalog every single time an image was opened.
//
// **`library-visible`, not `show-library`, and the difference is the
// whole bug.** `show-library` says "the library rather than develop"
// and stays true while Settings, Import, People or the launch screen
// replaces the window — all four of which take the grid down just as
// opening an image does. So the teardown's scroll-to-zero passed this
// guard, `resume_at` was set to 0, and coming back from any of those
// four screens landed at the top of the library however deep in it the
// user had been. The property is computed once in `app.slint` beside
// the `if` it is spelled from, so the two cannot drift apart again.
//
// The guard used to cover only `resume_at`, for a narrower version
// of the same reason. It belongs over the whole handler.
if !w.get_library_visible() {
return;
}
// Remember where the view is, so leaving for the develop view and
// coming back returns here. Latched on every event rather than read
// at departure: by the time the grid is hidden its scroll position
// is only in the Flickable, which is about to be destroyed.
ctl.resume_at.set(first_visible);
// Remember where the view is, so leaving for the develop view and
// coming back returns here. Latched on every event rather than read
// at departure: by the time the grid is hidden its scroll position
// is only in the Flickable, which is about to be destroyed.
ctl.resume_at.set(first_visible);
// TRACES: FR-UI-8
// And where the *next launch* will look for it. Debounced: a flick
// reports several of these per screenful.
note_place(&w, &ctl);
// TRACES: FR-UI-8
// And where the *next launch* will look for it. Debounced: a flick
// reports several of these per screenful.
note_place(&w, &ctl);
// The same position where a rebuilt grid will look for it.
//
// `scroll-to` is read by `seek()`, which runs on a `scroll-token`
// change and on `init` — so writing it here without bumping the
// token cannot move the grid that is on screen, and *is* what the
// next one reads when it is built. That is every route back to the
// grid at once: Settings, Import, People and the launch screen all
// tear the subtree down and rebuild it, and none of them goes
// through `on_back_to_library` to have the position replayed by
// hand. Without this they each rebuilt against whatever `scroll-to`
// was last *set* to — a stale scrub, or zero — and landed there.
w.set_library_scroll_to(first_visible as i32);
// The same position where a rebuilt grid will look for it.
//
// `scroll-to` is read by `seek()`, which runs on a `scroll-token`
// change and on `init` — so writing it here without bumping the
// token cannot move the grid that is on screen, and *is* what the
// next one reads when it is built. That is every route back to the
// grid at once: Settings, Import, People and the launch screen all
// tear the subtree down and rebuild it, and none of them goes
// through `on_back_to_library` to have the position replayed by
// hand. Without this they each rebuilt against whatever `scroll-to`
// was last *set* to — a stale scrub, or zero — and landed there.
w.global::<Library>()
.set_library_scroll_to(first_visible as i32);
// Move the timeline marker with the view. Scrolling the grid is a
// way of moving through time just as scrubbing is, and a marker
// that only ever moved on a scrub sat still while the photographs
// beside it advanced by months — the axis said "when you are" and
// was wrong the moment the user touched the wheel.
//
// This runs before the reload guard below, which fires only a few
// times per screenful; the marker has to follow every event or it
// would advance in visible jerks.
//
// Only the marker is moved, not the whole histogram: rebuilding
// the bars means a `GROUP BY strftime` aggregate over the library,
// far too much for every event of a flick. The bars do not change
// as the grid scrolls anyway — only where the marker sits on them.
//
// Re-running the scrub would be wrong for a second reason: it sets
// `scroll-to`, which would drive the grid from its own scroll.
{
let borrow = ctl.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
if let Some(when) = capture_time_at(&ctl, catalog, first_visible) {
*ctl.current_bucket.borrow_mut() = Some(when);
// Move the timeline marker with the view. Scrolling the grid is a
// way of moving through time just as scrubbing is, and a marker
// that only ever moved on a scrub sat still while the photographs
// beside it advanced by months — the axis said "when you are" and
// was wrong the moment the user touched the wheel.
//
// This runs before the reload guard below, which fires only a few
// times per screenful; the marker has to follow every event or it
// would advance in visible jerks.
//
// Only the marker is moved, not the whole histogram: rebuilding
// the bars means a `GROUP BY strftime` aggregate over the library,
// far too much for every event of a flick. The bars do not change
// as the grid scrolls anyway — only where the marker sits on them.
//
// Re-running the scrub would be wrong for a second reason: it sets
// `scroll-to`, which would drive the grid from its own scroll.
{
let borrow = ctl.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
if let Some(when) = capture_time_at(&ctl, catalog, first_visible) {
*ctl.current_bucket.borrow_mut() = Some(when);
let zoom = *ctl.timeline_zoom.borrow();
let centre = *ctl.timeline_centre.borrow();
if let Some(full) = catalog_span(catalog, &ctl) {
let (from, to) = zoomed_span(full, zoom, centre);
w.set_library_current_bucket(when as i32);
w.set_library_current_fraction(
((when - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0)
as f32,
);
w.set_library_timeline_anchored(true);
let zoom = *ctl.timeline_zoom.borrow();
let centre = *ctl.timeline_centre.borrow();
if let Some(full) = catalog_span(catalog, &ctl) {
let (from, to) = zoomed_span(full, zoom, centre);
w.global::<Library>()
.set_library_current_bucket(when as i32);
w.global::<Library>().set_library_current_fraction(
((when - from) as f64 / (to - from).max(1) as f64)
.clamp(0.0, 1.0) as f32,
);
w.global::<Library>().set_library_timeline_anchored(true);
}
}
}
}
}
// Centre the window on the view, so scrolling either way has
// loaded rows ahead of it rather than only below.
let Some(offset) = window_move(
first_visible,
*ctl.offset.borrow(),
*ctl.window.borrow(),
ctl.viewport_cells.get(),
w.get_library_total().max(0) as usize,
) else {
return;
};
// Centre the window on the view, so scrolling either way has
// loaded rows ahead of it rather than only below.
let Some(offset) = window_move(
first_visible,
*ctl.offset.borrow(),
*ctl.window.borrow(),
ctl.viewport_cells.get(),
w.global::<Library>().get_library_total().max(0) as usize,
) else {
return;
};
*ctl.offset.borrow_mut() = offset;
load_window(&w, &ctl);
});
*ctl.offset.borrow_mut() = offset;
load_window(&w, &ctl);
});
}
}
@@ -643,7 +668,7 @@ fn wire_grid_routes(
// folder could not be changed without clearing stored state by hand.
{
let weak = window.as_weak();
window.on_library_change(move || {
window.global::<Library>().on_library_change(move || {
if let Some(w) = weak.upgrade() {
w.set_active_view(View::Launch);
}
@@ -692,7 +717,7 @@ fn wire_grid_routes(
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_ctl = coll_ctl.clone();
window.on_library_rescan(move || {
window.global::<Library>().on_library_rescan(move || {
let Some(w) = weak.upgrade() else { return };
start_rescan(&w, &ctl, &coll_ctl);
});
+62 -35
View File
@@ -13,7 +13,7 @@ use dr_catalog::Catalog;
use slint::ComponentHandle;
use crate::library;
use crate::AppWindow;
use crate::{AppWindow, Library};
use super::controller::{stop, LibraryController};
@@ -115,7 +115,9 @@ pub(super) fn open_offline_prompt(
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
window
.global::<Library>()
.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
@@ -180,7 +182,7 @@ pub(super) fn open_offline_prompt(
);
window.set_offline_prompt_can_keep(summary.missing() > 0);
window.set_offline_prompt_can_release(summary.held > 0);
window.set_offline_prompt_busy(window.get_library_pin_total() > 0);
window.set_offline_prompt_busy(window.global::<Library>().get_library_pin_total() > 0);
}
/// Close the offline question without answering it.
@@ -208,7 +210,9 @@ pub(super) fn keep_collection_offline(
return;
};
let Some(cache) = ctl.cache() else {
window.set_library_error("No cache directory for this library.".into());
window
.global::<Library>()
.set_library_error("No cache directory for this library.".into());
return;
};
@@ -220,26 +224,34 @@ pub(super) fn keep_collection_offline(
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
window
.global::<Library>()
.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
if images.is_empty() {
window.set_library_error("Nothing in that collection to keep offline.".into());
window
.global::<Library>()
.set_library_error("Nothing in that collection to keep offline.".into());
return;
}
if let Err(e) = cache.pin(catalog.connection(), &images) {
window.set_library_error(format!("pinning: {e}").into());
window
.global::<Library>()
.set_library_error(format!("pinning: {e}").into());
return;
}
images
};
log::info!("pinned {} image(s) for offline use", images.len());
window.set_library_error(slint::SharedString::new());
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
if ctl.scope.borrow().as_ref() == Some(&id) {
window.set_library_scope_pinned(true);
window.global::<Library>().set_library_scope_pinned(true);
}
close_offline_prompt(window, ctl);
start_pin_fetch(window, ctl);
@@ -264,7 +276,9 @@ pub(super) fn release_collection_offline(
return;
};
let Some(cache) = ctl.cache() else {
window.set_library_error("No cache directory for this library.".into());
window
.global::<Library>()
.set_library_error("No cache directory for this library.".into());
return;
};
@@ -276,7 +290,9 @@ pub(super) fn release_collection_offline(
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
window
.global::<Library>()
.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
@@ -284,7 +300,9 @@ pub(super) fn release_collection_offline(
let outcome = match cache.release(catalog.connection(), &images) {
Ok(r) => r,
Err(e) => {
window.set_library_error(format!("removing local copies: {e}").into());
window
.global::<Library>()
.set_library_error(format!("removing local copies: {e}").into());
return;
}
};
@@ -312,8 +330,10 @@ pub(super) fn release_collection_offline(
"released {count} image(s), freeing {}",
crate::activity::describe_bytes(freed)
);
window.set_library_error(slint::SharedString::new());
window.set_library_status(
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
window.global::<Library>().set_library_status(
format!(
"Removed local copies · {} freed",
crate::activity::describe_bytes(freed)
@@ -322,13 +342,13 @@ pub(super) fn release_collection_offline(
.into(),
);
if ctl.scope.borrow().as_ref() == Some(&id) {
window.set_library_scope_pinned(false);
window.global::<Library>().set_library_scope_pinned(false);
}
// A download that was still running for this collection has just had its
// reason withdrawn; the worker checks `pending_pins` per file, so it stops
// finding work rather than being killed.
window.set_library_pin_total(0);
window.set_library_pin_done(0);
window.global::<Library>().set_library_pin_total(0);
window.global::<Library>().set_library_pin_done(0);
close_offline_prompt(window, ctl);
refresh_local_count(window, ctl);
refresh_collection_tree(window, ctl, coll_ctl);
@@ -433,7 +453,7 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_pin_total(0);
w.global::<Library>().set_library_pin_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.pin_timer);
return;
@@ -442,13 +462,13 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
match msg {
library::PinMessage::Planned { total } => {
w.set_library_pin_total(total as i32);
w.set_library_pin_done(0);
w.global::<Library>().set_library_pin_total(total as i32);
w.global::<Library>().set_library_pin_done(0);
job.total(total);
}
library::PinMessage::Stored { done } => {
w.set_library_pin_done(done as i32);
job.progress(done, w.get_library_pin_total() as usize);
w.global::<Library>().set_library_pin_done(done as i32);
job.progress(done, w.global::<Library>().get_library_pin_total() as usize);
// The "On this device" count grows as they land, so
// the chip agrees with the progress line beside it.
refresh_local_count(&w, &ctl_cb);
@@ -462,8 +482,8 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
"{stored} photograph(s) · {}",
crate::activity::describe_bytes(bytes)
));
w.set_library_pin_total(0);
w.set_library_pin_done(0);
w.global::<Library>().set_library_pin_total(0);
w.global::<Library>().set_library_pin_done(0);
refresh_local_count(&w, &ctl_cb);
stop(&ctl_cb.pin_timer);
return;
@@ -471,7 +491,7 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::PinMessage::Failed { message, offline } => {
log::warn!("pin fetch stopped: {message}");
job.fail(message.clone());
w.set_library_pin_total(0);
w.global::<Library>().set_library_pin_total(0);
if offline {
ctl_cb
.reachability
@@ -479,7 +499,8 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
.mark_unreachable(message, std::time::Instant::now());
refresh_offline(&w, &ctl_cb);
} else {
w.set_library_error(format!("keeping offline: {message}").into());
w.global::<Library>()
.set_library_error(format!("keeping offline: {message}").into());
}
refresh_local_count(&w, &ctl_cb);
stop(&ctl_cb.pin_timer);
@@ -499,7 +520,9 @@ fn refresh_local_count(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some(catalog) = borrow.as_ref() else {
return;
};
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
window
.global::<Library>()
.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
}
/// TRACES: FR-NC-6a
@@ -552,12 +575,14 @@ pub(super) fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
let lost = ctl.root_lost.borrow();
let offline = reach.is_offline() || lost.is_some();
window.set_library_offline(offline);
window.set_library_offline_reason(match lost.as_deref() {
Some(why) => why.into(),
None => reach.reason().unwrap_or_default().into(),
});
window.set_library_offline_since(
window.global::<Library>().set_library_offline(offline);
window
.global::<Library>()
.set_library_offline_reason(match lost.as_deref() {
Some(why) => why.into(),
None => reach.reason().unwrap_or_default().into(),
});
window.global::<Library>().set_library_offline_since(
// A duration is what a network outage has and a revoked permission
// does not: "for 4 minutes" invites waiting, and waiting is precisely
// what will not help here.
@@ -575,7 +600,7 @@ pub(super) fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
// A stale scan error under an offline banner reports one problem twice.
if offline {
window.set_library_error("".into());
window.global::<Library>().set_library_error("".into());
}
drop(reach);
@@ -655,7 +680,9 @@ fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
} else if written > 0 {
log::info!("{written} queued sidecar(s) uploaded");
job.finish(format!("{written} queued edit(s) uploaded"));
w.set_library_status(format!("{written} queued edit(s) uploaded").into());
w.global::<Library>().set_library_status(
format!("{written} queued edit(s) uploaded").into(),
);
} else {
job.finish_quietly();
}
+51 -29
View File
@@ -18,7 +18,7 @@ use dr_types::FormatFilter;
use slint::ComponentHandle;
use crate::library::{self, ScanMessage};
use crate::{AppWindow, View};
use crate::{AppWindow, Library, View};
use super::controller::{stop, LibraryController};
use super::offline::refresh_offline;
@@ -57,7 +57,9 @@ pub fn open(
let conn = match store.connection(&account, crate::remote::needs_secret(&account)) {
Ok(c) => c,
Err(e) => {
window.set_library_error(format!("credentials: {e}").into());
window
.global::<Library>()
.set_library_error(format!("credentials: {e}").into());
window.set_active_view(View::Library);
return;
}
@@ -79,19 +81,27 @@ pub fn open(
ctl.place_untouched.set(true);
window.set_active_view(View::Library);
window.set_library_open(true);
window.set_library_scanning(true);
window.set_library_error(slint::SharedString::new());
window.set_library_status("Starting…".into());
window.global::<Library>().set_library_open(true);
window.global::<Library>().set_library_scanning(true);
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
window
.global::<Library>()
.set_library_status("Starting…".into());
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(library_root_label(&conn.account).into());
window
.global::<Library>()
.set_library_root_label(library_root_label(&conn.account).into());
// An empty filter would walk the whole tree and match nothing, which looks
// exactly like a broken scan. Say so instead.
if filter.is_empty() {
window.set_library_scanning(false);
window.set_library_error("No formats selected — tick at least one.".into());
window.global::<Library>().set_library_scanning(false);
window
.global::<Library>()
.set_library_error("No formats selected — tick at least one.".into());
return;
}
@@ -164,8 +174,10 @@ fn open_catalog_soon(
return;
}
window.set_library_opening(true);
window.set_library_status("Reading the catalog on this device…".into());
window.global::<Library>().set_library_opening(true);
window
.global::<Library>()
.set_library_status("Reading the catalog on this device…".into());
let rx = spawn_catalog_open(path.clone());
let weak = window.as_weak();
@@ -190,7 +202,7 @@ fn open_catalog_soon(
}
};
stop(&held.catalog_timer);
w.set_library_opening(false);
w.global::<Library>().set_library_opening(false);
match got {
CatalogOpen::Opened(cat) => {
@@ -305,7 +317,9 @@ fn begin_scan(
filter.iter().count(),
path.display()
);
window.set_library_status("Starting…".into());
window
.global::<Library>()
.set_library_status("Starting…".into());
let rx = library::spawn_scan(
conn.clone(),
@@ -356,9 +370,10 @@ fn drain_scan(
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// A worker that died without sending must not leave
// the screen on "Scanning…" forever.
if w.get_library_scanning() {
w.set_library_scanning(false);
w.set_library_error("scan ended unexpectedly".into());
if w.global::<Library>().get_library_scanning() {
w.global::<Library>().set_library_scanning(false);
w.global::<Library>()
.set_library_error("scan ended unexpectedly".into());
job.fail("ended unexpectedly");
}
stop(&ctl.scan_timer);
@@ -382,7 +397,7 @@ fn drain_scan(
format!("{directories} folders · {images} images")
};
job.detail(status.clone());
w.set_library_status(status.into());
w.global::<Library>().set_library_status(status.into());
}
ScanMessage::Done {
found,
@@ -396,7 +411,7 @@ fn drain_scan(
{pruned} folders unchanged, {judgements} judgements \
taken in) in {elapsed_ms} ms"
);
w.set_library_scanning(false);
w.global::<Library>().set_library_scanning(false);
// A completed scan is the strongest possible evidence
// the server is reachable, so it clears offline mode
@@ -450,7 +465,7 @@ fn drain_scan(
status
};
job.finish(status.clone());
w.set_library_status(status.into());
w.global::<Library>().set_library_status(status.into());
// Usually already open — `show_catalog_now` opened it
// before this scan started, so the grid has been
@@ -485,7 +500,9 @@ fn drain_scan(
// than the part that was scrolled past.
start_sweep(&w, ctl);
}
Err(e) => w.set_library_error(format!("opening catalog: {e}").into()),
Err(e) => w
.global::<Library>()
.set_library_error(format!("opening catalog: {e}").into()),
}
stop(&ctl.scan_timer);
return;
@@ -496,7 +513,7 @@ fn drain_scan(
lost_root,
} => {
log::warn!("scan failed: {message}");
w.set_library_scanning(false);
w.global::<Library>().set_library_scanning(false);
// Recorded as a failure even where it is only the
// connection: the grid's offline banner says the server
// is unreachable, and this says which piece of work
@@ -538,7 +555,7 @@ fn drain_scan(
// for.
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
} else {
w.set_library_error(message.into());
w.global::<Library>().set_library_error(message.into());
}
stop(&ctl.scan_timer);
return;
@@ -566,9 +583,13 @@ pub(crate) fn start_rescan(
return;
};
window.set_library_scanning(true);
window.set_library_error(slint::SharedString::new());
window.set_library_status("Rescanning…".into());
window.global::<Library>().set_library_scanning(true);
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
window
.global::<Library>()
.set_library_status("Rescanning…".into());
let path = library::catalog_path(&conn.account);
let rx = library::spawn_scan(
@@ -614,7 +635,7 @@ fn open_catalog_for_offline(
// No catalog and no server. This is the one genuinely empty case:
// a first run that never reached the server has nothing indexed.
log::warn!("offline with no local catalog: {e}");
window.set_library_error(
window.global::<Library>().set_library_error(
"Offline, and this library has not been scanned on this device yet.".into(),
);
}
@@ -677,11 +698,12 @@ pub(super) fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
// change to `columns` moves every one of them and a viewport left
// where it was would be pointing at rows the window no longer covers.
let window_size = *ctl_cb.window.borrow();
let total = w.get_library_total().max(0) as usize;
let total = w.global::<Library>().get_library_total().max(0) as usize;
*ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total);
load_window(&w, &ctl_cb);
w.set_library_scroll_to(anchor as i32);
w.set_library_scroll_token(w.get_library_scroll_token() + 1);
w.global::<Library>().set_library_scroll_to(anchor as i32);
w.global::<Library>()
.set_library_scroll_token(w.global::<Library>().get_library_scroll_token() + 1);
});
// Replacing the slot drops the previous timer, which is what makes this
+130 -90
View File
@@ -15,7 +15,7 @@ use dr_catalog::Catalog;
use slint::{ComponentHandle, Model as _};
use crate::library;
use crate::{AppWindow, KeywordRow};
use crate::{AppWindow, KeywordRow, Library};
use super::controller::{stop, LibraryController};
use super::window::load_window;
@@ -40,7 +40,7 @@ pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::Imag
}
};
let model = window.get_library_cells();
let model = window.global::<Library>().get_library_cells();
for (row, id) in ids.iter().enumerate() {
// Absent means unrated, which is a real state rather than missing data.
let j = found.get(id).copied().unwrap_or_default();
@@ -64,14 +64,18 @@ pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::Imag
pub(super) fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default();
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
window.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
window
.global::<Library>()
.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
// TRACES: FR-CAT-9
// How many originals are actually here, for the "On this device" chip.
// Shown for the same reason the star counts are: a filter that silently
// empties the grid reads as broken, and this one will legitimately be zero
// on a library nothing has been downloaded from yet.
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
window
.global::<Library>()
.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
}
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
@@ -124,7 +128,9 @@ fn refresh_keywords(window: &AppWindow, ctl: &Rc<LibraryController>, images: &[d
})
.collect();
window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
window
.global::<Library>()
.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
}
/// Put a keyword on the selection, or take it off.
@@ -162,7 +168,9 @@ fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, as
Err(e) => {
// `BadName` carries text written to be read by the user rather than
// by a developer, so it is shown as it is.
window.set_library_error(format!("{e}").into());
window
.global::<Library>()
.set_library_error(format!("{e}").into());
return;
}
};
@@ -171,7 +179,9 @@ fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, as
// in the vocabulary, ready for the photographs it was typed for — so only
// the removal half needs a selection to act on.
if images.is_empty() && !assigning {
window.set_library_status("Select an image first".into());
window
.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
@@ -191,13 +201,19 @@ fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, as
let n = match outcome {
Ok(n) => n,
Err(e) => {
window.set_library_error(format!("{e}").into());
window
.global::<Library>()
.set_library_error(format!("{e}").into());
return;
}
};
window.set_library_error(slint::SharedString::new());
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
window
.global::<Library>()
.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
refresh_keywords(window, ctl, &images);
start_xmp_writes(window, ctl, &images);
@@ -262,7 +278,9 @@ fn apply_judgement(
if images.is_empty() {
// Nothing selected. Said out loud rather than ignored: a keystroke
// that silently does nothing reads as a broken key.
window.set_library_status("Select an image first".into());
window
.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
@@ -281,14 +299,18 @@ fn apply_judgement(
};
if let Err(e) = wrote {
window.set_library_error(format!("recording rating: {e}").into());
window
.global::<Library>()
.set_library_error(format!("recording rating: {e}").into());
return;
}
// Report what happened, in the user's terms rather than as a count of
// rows. A bulk judgement on a selection is easy to trigger by accident
// and the status line is the only confirmation of its extent.
window.set_library_status(judgement_summary(images.len(), rating, flag).into());
window
.global::<Library>()
.set_library_status(judgement_summary(images.len(), rating, flag).into());
// Refresh the grid and the chips from what actually landed, rather
// than assuming the write took: a clamped or coalesced value must show
@@ -357,7 +379,9 @@ pub fn paste_settings_to_selection(
if images.is_empty() {
// Said out loud rather than ignored, matching what a judgement
// keystroke does with an empty selection.
window.set_library_status("Select an image first".into());
window
.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
@@ -377,12 +401,14 @@ pub fn paste_settings_to_selection(
};
if writes.is_empty() {
window.set_library_error("Could not find those images in the catalog.".into());
window
.global::<Library>()
.set_library_error("Could not find those images in the catalog.".into());
return;
}
let count = writes.len();
window.set_library_status(
window.global::<Library>().set_library_status(
format!(
"Applied settings to {count} image{}.",
if count == 1 { "" } else { "s" }
@@ -637,7 +663,7 @@ fn drain_xmp(
}
}
if let Some(w) = weak.upgrade() {
w.set_library_status(status.into());
w.global::<Library>().set_library_status(status.into());
if reload {
// The grid draws what the reload changed, and the
// settings page stops offering what is settled.
@@ -746,7 +772,7 @@ pub(crate) fn start_sidecar_writes(
log::debug!("{queued} sidecar(s) queued for upload");
ctl_cb.outbox_maybe_dirty.set(true);
job.finish_quietly();
w.set_library_status(
w.global::<Library>().set_library_status(
format!("{queued} edit(s) saved · will upload when back online").into(),
);
stop(&ctl_cb.sidecar_timer);
@@ -766,7 +792,7 @@ pub(crate) fn start_sidecar_writes(
// Said plainly, because the consequence is specific:
// the rating is safe in the catalog but will not
// survive deleting it.
w.set_library_status(
w.global::<Library>().set_library_status(
format!(
"{written} saved · {failed} could not be written \
to the library folder"
@@ -822,17 +848,19 @@ pub(super) fn wire_ratings_and_flags(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_cell_rated(move |row, stars| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
window
.global::<Library>()
.on_library_cell_rated(move |row, stars| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None);
});
apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None);
});
}
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
@@ -840,27 +868,29 @@ pub(super) fn wire_ratings_and_flags(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_burst_toggled(move |row| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
window
.global::<Library>()
.on_library_burst_toggled(move |row| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
let changed = {
let borrow = ctl.catalog.borrow();
match borrow.as_ref() {
Some(catalog) => crate::bursts::toggle(catalog, id),
None => false,
let changed = {
let borrow = ctl.catalog.borrow();
match borrow.as_ref() {
Some(catalog) => crate::bursts::toggle(catalog, id),
None => false,
}
};
if changed {
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
}
};
if changed {
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
}
});
});
}
// Name the frame an open burst folds to (FR-CULL-5). The default is the
@@ -876,26 +906,28 @@ pub(super) fn wire_ratings_and_flags(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_burst_representative_chosen(move |row| {
let Some(w) = weak.upgrade() else { return };
let ids: Vec<dr_types::ImageId> = ctl
.image_ids
.borrow()
.iter()
.map(|id| dr_types::ImageId(*id as u64))
.collect();
let Some(id) = ids.get(row as usize).copied() else {
return;
};
window
.global::<Library>()
.on_library_burst_representative_chosen(move |row| {
let Some(w) = weak.upgrade() else { return };
let ids: Vec<dr_types::ImageId> = ctl
.image_ids
.borrow()
.iter()
.map(|id| dr_types::ImageId(*id as u64))
.collect();
let Some(id) = ids.get(row as usize).copied() else {
return;
};
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
if crate::bursts::choose(catalog, id) {
crate::bursts::sync_badges(&w, catalog, &ids);
}
});
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
if crate::bursts::choose(catalog, id) {
crate::bursts::sync_badges(&w, catalog, &ids);
}
});
}
// A rating or flag key. Applies to the whole selection, which is what
@@ -904,18 +936,20 @@ pub(super) fn wire_ratings_and_flags(
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keys = coll_ctl.clone();
window.on_library_judged(move |rating, flag| {
let Some(w) = weak.upgrade() else { return };
let chosen = coll_for_keys.selected();
window
.global::<Library>()
.on_library_judged(move |rating, flag| {
let Some(w) = weak.upgrade() else { return };
let chosen = coll_for_keys.selected();
// Exactly one axis is meant per keystroke; the other arrives as
// -1 so a star press cannot disturb a flag or the reverse.
if rating >= 0 {
apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None);
} else if flag >= 0 {
apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag)));
}
});
// Exactly one axis is meant per keystroke; the other arrives as
// -1 so a star press cannot disturb a flag or the reverse.
if rating >= 0 {
apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None);
} else if flag >= 0 {
apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag)));
}
});
}
}
@@ -934,28 +968,34 @@ pub(super) fn wire_keywords(
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keywords = coll_ctl.clone();
window.on_library_keywords_opened(move || {
let Some(w) = weak.upgrade() else { return };
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
});
window
.global::<Library>()
.on_library_keywords_opened(move || {
let Some(w) = weak.upgrade() else { return };
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_assign_keyword(move |word| {
let Some(w) = weak.upgrade() else { return };
apply_keyword(&w, &ctl, word.as_str(), true);
});
window
.global::<Library>()
.on_library_assign_keyword(move |word| {
let Some(w) = weak.upgrade() else { return };
apply_keyword(&w, &ctl, word.as_str(), true);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_unassign_keyword(move |word| {
let Some(w) = weak.upgrade() else { return };
apply_keyword(&w, &ctl, word.as_str(), false);
});
window
.global::<Library>()
.on_library_unassign_keyword(move |word| {
let Some(w) = weak.upgrade() else { return };
apply_keyword(&w, &ctl, word.as_str(), false);
});
}
}
+33 -22
View File
@@ -12,7 +12,7 @@ use std::rc::Rc;
use slint::ComponentHandle;
use crate::library;
use crate::AppWindow;
use crate::{AppWindow, Library};
use super::controller::{stop, LibraryController};
use super::open::schedule_reload;
@@ -73,7 +73,7 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
};
// Already running: a second pass would race the first over the same
// scratch files.
if ctl.sync_timer.borrow().is_some() && window.get_library_syncing() {
if ctl.sync_timer.borrow().is_some() && window.global::<Library>().get_library_syncing() {
return;
}
@@ -128,7 +128,7 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
}
}
window.set_library_syncing(true);
window.global::<Library>().set_library_syncing(true);
let rx = crate::derived_sync::spawn_sync(
conn.clone(),
conn.account.root.clone(),
@@ -159,7 +159,7 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_syncing(false);
w.global::<Library>().set_library_syncing(false);
job.fail("stopped without finishing");
stop(&ctl_cb.sync_timer);
return;
@@ -169,7 +169,7 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
match msg {
crate::derived_sync::SyncMessage::Status(s) => {
job.detail(s.clone());
w.set_library_status(s.into());
w.global::<Library>().set_library_status(s.into());
}
crate::derived_sync::SyncMessage::Finished(report) => {
log::info!(
@@ -193,7 +193,7 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
String::new()
}
);
w.set_library_syncing(false);
w.global::<Library>().set_library_syncing(false);
let summary = format!(
"{} shard(s) up, {} down",
report.shards_uploaded, report.shards_downloaded
@@ -204,7 +204,8 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
"nothing to exchange".to_string()
});
if report.did_anything() {
w.set_library_status(format!("synced · {summary}").into());
w.global::<Library>()
.set_library_status(format!("synced · {summary}").into());
}
// Adopted thumbnails and merged collections both change
// what the grid should show.
@@ -240,10 +241,11 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
crate::derived_sync::SyncMessage::Failed(e) => {
log::warn!("sync failed: {e}");
job.fail(e.to_string());
w.set_library_syncing(false);
w.global::<Library>().set_library_syncing(false);
// Not an error banner: a failed sync costs nothing —
// everything is still local and the next pass retries.
w.set_library_status(format!("sync failed: {e}").into());
w.global::<Library>()
.set_library_status(format!("sync failed: {e}").into());
stop(&ctl_cb.sync_timer);
return;
}
@@ -302,7 +304,7 @@ pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_sweep_total(0);
w.global::<Library>().set_library_sweep_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.sweep_timer);
return;
@@ -311,13 +313,16 @@ pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
match msg {
library::SweepMessage::Total(n) => {
w.set_library_sweep_total(n as i32);
w.set_library_sweep_done(0);
w.global::<Library>().set_library_sweep_total(n as i32);
w.global::<Library>().set_library_sweep_done(0);
job.total(n);
}
library::SweepMessage::Progress { done, dated } => {
w.set_library_sweep_done(done as i32);
job.progress(done, w.get_library_sweep_total() as usize);
w.global::<Library>().set_library_sweep_done(done as i32);
job.progress(
done,
w.global::<Library>().get_library_sweep_total() as usize,
);
// Rebuild as it goes: the histogram growing while the
// sweep runs is the visible sign it is working.
if dated > 0 {
@@ -330,7 +335,7 @@ pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::SweepMessage::Finished { dated } => {
log::info!("sweep finished: {dated} dated");
job.finish(format!("{dated} dated"));
w.set_library_sweep_total(0);
w.global::<Library>().set_library_sweep_total(0);
{
let borrow = ctl_cb.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
@@ -383,7 +388,9 @@ pub(super) fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryControll
// Already running. A second pass would re-fetch everything the first is
// part way through — the work list is built from what the store lacks, and
// the first pass has not filled it yet.
if ctl.thumb_sweep_timer.borrow().is_some() && window.get_library_thumbnailing() {
if ctl.thumb_sweep_timer.borrow().is_some()
&& window.global::<Library>().get_library_thumbnailing()
{
return;
}
@@ -394,11 +401,13 @@ pub(super) fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryControll
// button that silently does nothing is worse than one that declines.
if ctl.is_offline() {
log::debug!("offline: not starting the thumbnail pass");
window.set_library_status("Offline — thumbnailing needs the server.".into());
window
.global::<Library>()
.set_library_status("Offline — thumbnailing needs the server.".into());
return;
}
window.set_library_thumbnailing(true);
window.global::<Library>().set_library_thumbnailing(true);
let rx = library::spawn_thumbnail_sweep(
conn.clone(),
library::catalog_path(&conn.account),
@@ -433,7 +442,7 @@ pub(super) fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryControll
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_thumbnailing(false);
w.global::<Library>().set_library_thumbnailing(false);
job.fail("stopped without finishing");
stop(&ctl_cb.thumb_sweep_timer);
return;
@@ -444,7 +453,8 @@ pub(super) fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryControll
library::ThumbSweepMessage::Total(n) => {
total.set(n);
job.total(n);
w.set_library_status(format!("Thumbnailing {n} photograph(s)…").into());
w.global::<Library>()
.set_library_status(format!("Thumbnailing {n} photograph(s)…").into());
}
library::ThumbSweepMessage::Progress { done, stored } => {
job.progress(done, total.get());
@@ -466,7 +476,7 @@ pub(super) fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryControll
log::info!(
"thumbnail pass finished: {stored} stored, {failed} without a preview"
);
w.set_library_thumbnailing(false);
w.global::<Library>().set_library_thumbnailing(false);
stop(&ctl_cb.thumb_sweep_timer);
let summary = if offline {
@@ -476,7 +486,8 @@ pub(super) fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryControll
} else {
format!("{stored} stored")
};
w.set_library_status(summary.clone().into());
w.global::<Library>()
.set_library_status(summary.clone().into());
// Finished rather than failed even when the server
// dropped: the pass is resumable and what it did build
// is stored, and a red row would invite the user to
+147 -98
View File
@@ -14,7 +14,7 @@ use dr_catalog::Catalog;
use slint::ComponentHandle;
use crate::library;
use crate::{AppWindow, TimelineBar, View};
use crate::{AppWindow, Library, TimelineBar, View};
use super::controller::LibraryController;
use super::filter_bar::{push_people_chips, refilter};
@@ -95,8 +95,12 @@ pub(super) fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<L
None => {
// No dated images yet. An empty histogram is honest — EXIF is read
// as thumbnails load, so this populates as the user browses.
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
window.set_library_timeline_label(slint::SharedString::new());
window
.global::<Library>()
.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
window
.global::<Library>()
.set_library_timeline_label(slint::SharedString::new());
return;
}
};
@@ -206,14 +210,27 @@ pub(super) fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<L
buckets.get(i).map(|b| b.start)
})
.unwrap_or(0);
window.set_library_current_bucket(lit as i32);
window.set_library_current_fraction(fraction);
window.set_library_range_from_fraction(band_from);
window.set_library_range_to_fraction(band_to);
window.set_library_timeline_anchored(current.is_some());
window
.global::<Library>()
.set_library_current_bucket(lit as i32);
window
.global::<Library>()
.set_library_current_fraction(fraction);
window
.global::<Library>()
.set_library_range_from_fraction(band_from);
window
.global::<Library>()
.set_library_range_to_fraction(band_to);
window
.global::<Library>()
.set_library_timeline_anchored(current.is_some());
window
.global::<Library>()
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
window
.global::<Library>()
.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
}
/// Whether this bucket size is fine enough for month labels to mean anything.
@@ -484,8 +501,12 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
// Move the viewport as well as the window. Cells are drawn at their
// absolute place in the library, so loading rows around image 15,000 while
// the viewport sits at row 0 shows an empty grid until the user scrolls.
window.set_library_scroll_to(position as i32);
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
window
.global::<Library>()
.set_library_scroll_to(position as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
load_window(window, ctl);
}
@@ -506,7 +527,7 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
/// delete — after removing one frame from a wall of twenty thousand, the next
/// one you want is the one that just moved into its place.
pub fn restore_position(window: &AppWindow, ctl: &Rc<LibraryController>) {
let total = window.get_library_total().max(0) as usize;
let total = window.global::<Library>().get_library_total().max(0) as usize;
if total == 0 {
return;
}
@@ -522,8 +543,12 @@ pub fn restore_position(window: &AppWindow, ctl: &Rc<LibraryController>) {
// `resume_at` is what the grid last reported as its first visible image,
// which is the thing the photographer is actually looking at.
let anchor = ctl.resume_at.get().min(total - 1);
window.set_library_scroll_to(anchor as i32);
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
window
.global::<Library>()
.set_library_scroll_to(anchor as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
}
/// Format a bucket start for the histogram's hover label.
@@ -632,7 +657,7 @@ pub(super) fn write_place(window: &AppWindow, ctl: &Rc<LibraryController>) {
fn current_place(window: &AppWindow, ctl: &Rc<LibraryController>) -> Option<dr_types::Place> {
use dr_types::{Place, PlaceScope, Screen};
if window.get_active_view() == View::Launch || !window.get_library_open() {
if window.get_active_view() == View::Launch || !window.global::<Library>().get_library_open() {
return None;
}
@@ -765,7 +790,7 @@ pub(crate) fn apply_place(
// The filter and the scope have both moved the window to the top; this is
// what puts it back, and it has to run after them for that reason.
let total = window.get_library_total().max(0) as usize;
let total = window.global::<Library>().get_library_total().max(0) as usize;
let resolved = resolved.filter(|(at, _)| *at < total);
match resolved {
Some((at, _)) => {
@@ -786,16 +811,18 @@ pub(crate) fn apply_place(
if let Some((at, Found::Photograph)) = resolved {
if let Some(open) = ctl.open_image.borrow().clone() {
window.set_active_view(View::Develop);
window.set_library_roll_centre(true);
window.global::<Library>().set_library_roll_centre(true);
let offset = *ctl.offset.borrow();
if let Some(row) = at.checked_sub(offset) {
window.set_library_roll_current(row as i32);
window
.global::<Library>()
.set_library_roll_current(row as i32);
}
open(place.path.clone());
// After the open, which resets the readout to "1 of 1" on its
// way in — the same ordering `report_position` documents.
window.set_index(at as i32);
window.set_total(window.get_library_total());
window.set_total(window.global::<Library>().get_library_total());
}
}
}
@@ -873,20 +900,30 @@ enum Found {
/// first act would be to try to clear a filter the bar says is off.
fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
let filter = ctl.filter.borrow().clone();
window.set_library_filter_min_rating(filter.min_rating as i32);
window.set_library_filter_unjudged(filter.unjudged);
window
.global::<Library>()
.set_library_filter_min_rating(filter.min_rating as i32);
window
.global::<Library>()
.set_library_filter_unjudged(filter.unjudged);
// The bar's own numbering, from `on_library_filter_flag_changed`. It has no
// code for `Unflagged` because it cannot produce one — "nothing has judged
// this" is the `unjudged` chip, over both stars and flags — so a place can
// never carry one either, and it falls in with "no flag constraint".
window.set_library_filter_flag(match filter.flag {
Some(dr_types::FlagState::Pick) => 1,
Some(dr_types::FlagState::Reject) => 2,
_ => 0,
});
window.set_library_local_only(filter.local_only);
window
.global::<Library>()
.set_library_filter_flag(match filter.flag {
Some(dr_types::FlagState::Pick) => 1,
Some(dr_types::FlagState::Reject) => 2,
_ => 0,
});
window
.global::<Library>()
.set_library_local_only(filter.local_only);
ctl.local_only.set(filter.local_only);
window.set_library_filter_eyes_open(filter.eyes_open);
window
.global::<Library>()
.set_library_filter_eyes_open(filter.eyes_open);
push_people_chips(window, ctl);
}
@@ -925,7 +962,7 @@ pub(super) fn resume_position(
coll: &Rc<crate::collections_ui::CollectionsController>,
focus: Option<usize>,
) {
let total = window.get_library_total().max(0) as usize;
let total = window.global::<Library>().get_library_total().max(0) as usize;
if total == 0 {
// Files named on the command line: there is no grid behind them, and
// an ordinal read off the readout would name a row of nothing.
@@ -955,12 +992,16 @@ pub(super) fn resume_position(
// Before the grid is shown, not after: the markup gates it on an `if`, and
// the rebuilt Flickable reads `scroll-to` in its `init`. Setting these
// afterwards would leave that init to run against the previous position.
window.set_library_scroll_to(anchor as i32);
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
window
.global::<Library>()
.set_library_scroll_to(anchor as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
if let Some(at) = focus {
coll.set_cursor(Some(at));
window.set_library_cursor(at as i32);
window.global::<Library>().set_library_cursor(at as i32);
}
// The view has moved, so the capture-time marker has to move with it.
@@ -997,30 +1038,32 @@ pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_pan(move |fraction| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
window
.global::<Library>()
.on_library_timeline_pan(move |fraction| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
// A fraction of the *visible* span, so dragging half the axis
// moves half a span's worth of time whatever the zoom — and a
// small movement produces a small shift rather than nothing.
let zoom = *ctl.timeline_zoom.borrow();
let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow());
let shift = ((to - from) as f64 * fraction as f64) as i64;
if shift == 0 {
return;
}
// A fraction of the *visible* span, so dragging half the axis
// moves half a span's worth of time whatever the zoom — and a
// small movement produces a small shift rather than nothing.
let zoom = *ctl.timeline_zoom.borrow();
let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow());
let shift = ((to - from) as f64 * fraction as f64) as i64;
if shift == 0 {
return;
}
let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift;
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
refresh_timeline(&w, catalog, &ctl);
});
let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift;
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
refresh_timeline(&w, catalog, &ctl);
});
}
// The wheel zooms the axis: a sidebar is a scale, not a list.
@@ -1030,11 +1073,13 @@ pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_zoom(move |delta| {
if let Some(w) = weak.upgrade() {
apply_zoom(&w, &ctl, delta);
}
});
window
.global::<Library>()
.on_library_timeline_zoom(move |delta| {
if let Some(w) = weak.upgrade() {
apply_zoom(&w, &ctl, delta);
}
});
}
// Dragging the histogram moves the grid through time.
@@ -1045,25 +1090,27 @@ pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_scrub_fraction(move |f| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
window
.global::<Library>()
.on_library_scrub_fraction(move |f| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
let (from, to) = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
let when = instant_at((from, to), f);
drop(borrow);
scrub_to(&w, &ctl, when);
});
let (from, to) = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
let when = instant_at((from, to), f);
drop(borrow);
scrub_to(&w, &ctl, when);
});
}
// Pinch, for tablet: no wheel there, so this is the only way to reach the
@@ -1075,30 +1122,32 @@ pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_pinch(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(0.01..=100.0).contains(&ratio) {
return;
}
window
.global::<Library>()
.on_library_timeline_pinch(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(0.01..=100.0).contains(&ratio) {
return;
}
let mut accum = ctl.pinch_accum.borrow_mut();
*accum *= ratio;
let mut accum = ctl.pinch_accum.borrow_mut();
*accum *= ratio;
// Whole doublings out of the accumulated ratio, remainder carried.
//
// `log2` rather than repeated halving: one update can carry a
// large ratio — a fast spread, or a trackpad reporting coarsely —
// and stepping once per update would turn an 8× pinch into a
// single level instead of three.
let steps = accum.log2().trunc() as i32;
if steps == 0 {
return;
}
*accum /= (2.0f32).powi(steps);
drop(accum);
// Whole doublings out of the accumulated ratio, remainder carried.
//
// `log2` rather than repeated halving: one update can carry a
// large ratio — a fast spread, or a trackpad reporting coarsely —
// and stepping once per update would turn an 8× pinch into a
// single level instead of three.
let steps = accum.log2().trunc() as i32;
if steps == 0 {
return;
}
*accum /= (2.0f32).powi(steps);
drop(accum);
apply_zoom(&w, &ctl, steps);
});
apply_zoom(&w, &ctl, steps);
});
}
}
+30 -22
View File
@@ -14,7 +14,7 @@ use std::sync::mpsc::Receiver;
use slint::{ComponentHandle, Model as _};
use crate::library::{self, ThumbnailMessage};
use crate::{AppWindow, LibraryCell};
use crate::{AppWindow, Library, LibraryCell};
use super::controller::{stop, LibraryController, LibraryFacts};
use super::filter_bar::period_headings;
@@ -234,19 +234,21 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// Three queries deep — descendants, their images, and whether every one of
// them is pinned — and the answer cannot change by scrolling.
if describes_something_new {
window.set_library_scope_pinned(match scope {
Some(id) => dr_catalog::collections::descendants(catalog.connection(), id)
.map(|ids| collection_images(catalog, &ids))
.map(|images| scope_is_pinned(catalog, &images))
.unwrap_or(false),
None => false,
});
window
.global::<Library>()
.set_library_scope_pinned(match scope {
Some(id) => dr_catalog::collections::descendants(catalog.connection(), id)
.map(|ids| collection_images(catalog, &ids))
.map(|images| scope_is_pinned(catalog, &images))
.unwrap_or(false),
None => false,
});
}
// Read before it is overwritten: the property still holds what the library
// was last time this ran, and a library that has become shorter is the
// signal that something was deleted out from under the view.
let was = window.get_library_total().max(0) as usize;
window.set_library_total(total as i32);
let was = window.global::<Library>().get_library_total().max(0) as usize;
window.global::<Library>().set_library_total(total as i32);
let shrank = total < was;
// Clamp so a scrub to the very end still fills the window rather than
@@ -254,7 +256,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
let window_size = *ctl.window.borrow();
let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total)));
*ctl.offset.borrow_mut() = offset;
window.set_library_offset(offset as i32);
window.global::<Library>().set_library_offset(offset as i32);
// The axis the scrubber draws. A `MIN`/`MAX` and a `GROUP BY` over the
// whole scope — 5.7 ms on 24,000 images — and the bars do not change as the
@@ -278,7 +280,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
//
// The trash is never reorderable. It is a view of what was deleted, ordered
// by when, and it is not a collection at all.
window.set_library_reorderable(
window.global::<Library>().set_library_reorderable(
!trash
&& scope.is_some_and(|c| {
dr_catalog::collections::orders_manually(catalog.connection(), c).unwrap_or(false)
@@ -293,7 +295,9 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
let cells = match cells {
Ok(c) => c,
Err(e) => {
window.set_library_error(format!("reading catalog: {e}").into());
window
.global::<Library>()
.set_library_error(format!("reading catalog: {e}").into());
return;
}
};
@@ -308,7 +312,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
Some((lo, hi)) => (lo.min(t), hi.max(t)),
})
});
window.set_library_window_label(
window.global::<Library>().set_library_window_label(
match span {
Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)),
// Nothing here has a date yet: EXIF is read as thumbnails load, so
@@ -321,7 +325,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// Month headings. The grid is ordered by capture time, so without these a
// wall of thumbnails gives no sense of *when* you are looking — the
// sidebar says it, but only if you consult it.
let columns = window.get_library_columns().max(1) as usize;
let columns = window.global::<Library>().get_library_columns().max(1) as usize;
let headings = period_headings(
cells.iter().map(|c| {
c.captured_at.map(|t| {
@@ -335,7 +339,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// What the outgoing model is still holding — see [`hold_thumbnails`].
let held = {
let previous = window.get_library_cells();
let previous = window.global::<Library>().get_library_cells();
let ids = ctl.image_ids.borrow();
let classes = ctl.thumb_class.borrow();
hold_thumbnails(&previous, &ids, &classes)
@@ -391,7 +395,9 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// those fetches would have filled are absent from `requested` above, so the
// batch started below asks for them again.
ctl.generation.set(ctl.generation.get().wrapping_add(1));
window.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows)));
window
.global::<Library>()
.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows)));
// The model is fresh, so the "in this many collections" badges are all zero
// until refilled. One query for the whole window, not one per cell.
@@ -481,7 +487,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
// The drawn cell size decides which class to ask for. Chosen once for the
// batch rather than per row, and carried through to the drain so a cell it
// fills can record what it is now showing.
let cell_pixels = window.get_library_cell_size().max(1.0) as u32;
let cell_pixels = window.global::<Library>().get_library_cell_size().max(1.0) as u32;
let class = dr_thumbs::ThumbSize::for_cell(cell_pixels);
let mut wanted: Vec<library::ThumbnailRequest> = {
@@ -602,7 +608,9 @@ pub fn refresh_thumbnail(
// What the grid is drawing at, so the cell can be corrected on screen
// rather than only on disk.
let drawn = dr_thumbs::ThumbSize::for_cell(window.get_library_cell_size().max(1.0) as u32);
let drawn = dr_thumbs::ThumbSize::for_cell(
window.global::<Library>().get_library_cell_size().max(1.0) as u32,
);
for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] {
let (w, h, rgba) = match render(class.edge()) {
@@ -634,7 +642,7 @@ pub fn refresh_thumbnail(
// Straight into the model, so the edit is on the cell the moment
// the grid comes back rather than after a scroll evicts and
// refetches it.
let model = window.get_library_cells();
let model = window.global::<Library>().get_library_cells();
if let Some(mut cell) = model.row_data(row) {
cell.thumbnail = to_slint_image(w, h, &rgba);
cell.has_thumb = true;
@@ -707,7 +715,7 @@ fn drain_thumbnails(
return;
}
let model = w.get_library_cells();
let model = w.global::<Library>().get_library_cells();
loop {
let msg = match rx.try_recv() {
@@ -753,7 +761,7 @@ fn drain_thumbnails(
if !parts.is_empty() {
let status = parts.join(" · ");
job.detail(status.clone());
w.set_library_status(status.into());
w.global::<Library>().set_library_status(status.into());
}
}
// A header-only date read. Advances the bar; draws nothing.
+108 -101
View File
@@ -22,7 +22,7 @@ use crate::merge::{
self, AlignmentReport, Cancel, Decision, FillSettings, MergeDestination, MergeEvent,
MergeInput, MergeRequest,
};
use crate::{AppWindow, MergeFrameRow, Page};
use crate::{AppWindow, Library, MergeFrameRow, Page};
/// How often the page reads the job's channel.
const DRAIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(120);
@@ -118,107 +118,114 @@ fn wire_start<S, C, F>(
let weak = window.as_weak();
let ctl = ctl.clone();
let on_done = on_done.clone();
window.on_library_merge_selection(move || {
let Some(w) = weak.upgrade() else { return };
if ctl.job.borrow().is_some() {
w.set_library_status("A panorama is already being merged".into());
return;
}
let Some(gpu) = gpu.clone() else {
w.set_library_status("No GPU: a panorama cannot be merged on this device".into());
return;
};
let Some(context) = context() else {
w.set_library_status("Open a library first".into());
return;
};
// The selection, in library order. The catalog's order is the
// grid's, which is capture time — the order a sweep was shot.
let sources: Vec<(String, Option<crate::library::CacheContext>)> = sources()
.into_iter()
.filter_map(|s| match s {
crate::export::Source::Library { path, cache } => Some((path, cache)),
crate::export::Source::Rendered { .. } => None,
})
.collect();
if sources.len() < 2 {
w.set_library_status("Select at least two frames to merge".into());
return;
}
// Where the composite goes: beside its sources, through the
// outbox (FR-MRG-3). The sources' folder, relative to the root.
let root = context.conn.account.root.trim_matches('/').to_string();
let first_dir = std::path::Path::new(&sources[0].0)
.parent()
.map(|p| p.to_string_lossy().trim_matches('/').to_string())
.unwrap_or_default();
let remote_dir = first_dir
.strip_prefix(&root)
.map(|s| s.trim_start_matches('/').to_string())
.unwrap_or(first_dir);
let destination = MergeDestination::Outbox {
outbox: context.outbox,
remote_dir,
};
let names: Vec<String> = sources
.iter()
.map(|(p, _)| {
std::path::Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.clone())
})
.collect();
// The fetch, on the job's own thread before the job: every
// original comes down in turn — sequential so twelve downloads
// do not halve each other's bandwidth — and the job then takes
// bytes, which keeps it free of the library.
let conn = context.conn.clone();
let fetch_names = names.clone();
let fetch = move |tx: &Sender<MergeEvent>,
cancel: &Cancel|
-> Option<Vec<MergeInput>> {
let mut frames = Vec::with_capacity(sources.len());
for (i, (path, cache)) in sources.into_iter().enumerate() {
if cancel.is_cancelled() {
let _ = tx.send(MergeEvent::Cancelled);
return None;
}
let _ = tx.send(MergeEvent::Progress {
stage: "Fetching",
done: i,
total: fetch_names.len(),
});
let rx = crate::library::spawn_full_fetch(conn.clone(), path.clone(), cache);
let bytes = match rx.recv() {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
let _ = tx.send(MergeEvent::Failed(format!(
"{}: {}",
fetch_names[i], e.message
)));
return None;
}
Err(_) => {
let _ = tx.send(MergeEvent::Failed(format!(
"{}: the download ended without answering",
fetch_names[i]
)));
return None;
}
};
frames.push(MergeInput {
name: fetch_names[i].clone(),
bytes: std::sync::Arc::new(bytes),
});
window
.global::<Library>()
.on_library_merge_selection(move || {
let Some(w) = weak.upgrade() else { return };
if ctl.job.borrow().is_some() {
w.global::<Library>()
.set_library_status("A panorama is already being merged".into());
return;
}
Some(frames)
};
start(&w, &ctl, gpu, names, destination, fetch, &on_done);
});
let Some(gpu) = gpu.clone() else {
w.global::<Library>().set_library_status(
"No GPU: a panorama cannot be merged on this device".into(),
);
return;
};
let Some(context) = context() else {
w.global::<Library>()
.set_library_status("Open a library first".into());
return;
};
// The selection, in library order. The catalog's order is the
// grid's, which is capture time — the order a sweep was shot.
let sources: Vec<(String, Option<crate::library::CacheContext>)> = sources()
.into_iter()
.filter_map(|s| match s {
crate::export::Source::Library { path, cache } => Some((path, cache)),
crate::export::Source::Rendered { .. } => None,
})
.collect();
if sources.len() < 2 {
w.global::<Library>()
.set_library_status("Select at least two frames to merge".into());
return;
}
// Where the composite goes: beside its sources, through the
// outbox (FR-MRG-3). The sources' folder, relative to the root.
let root = context.conn.account.root.trim_matches('/').to_string();
let first_dir = std::path::Path::new(&sources[0].0)
.parent()
.map(|p| p.to_string_lossy().trim_matches('/').to_string())
.unwrap_or_default();
let remote_dir = first_dir
.strip_prefix(&root)
.map(|s| s.trim_start_matches('/').to_string())
.unwrap_or(first_dir);
let destination = MergeDestination::Outbox {
outbox: context.outbox,
remote_dir,
};
let names: Vec<String> = sources
.iter()
.map(|(p, _)| {
std::path::Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.clone())
})
.collect();
// The fetch, on the job's own thread before the job: every
// original comes down in turn — sequential so twelve downloads
// do not halve each other's bandwidth — and the job then takes
// bytes, which keeps it free of the library.
let conn = context.conn.clone();
let fetch_names = names.clone();
let fetch =
move |tx: &Sender<MergeEvent>, cancel: &Cancel| -> Option<Vec<MergeInput>> {
let mut frames = Vec::with_capacity(sources.len());
for (i, (path, cache)) in sources.into_iter().enumerate() {
if cancel.is_cancelled() {
let _ = tx.send(MergeEvent::Cancelled);
return None;
}
let _ = tx.send(MergeEvent::Progress {
stage: "Fetching",
done: i,
total: fetch_names.len(),
});
let rx =
crate::library::spawn_full_fetch(conn.clone(), path.clone(), cache);
let bytes = match rx.recv() {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
let _ = tx.send(MergeEvent::Failed(format!(
"{}: {}",
fetch_names[i], e.message
)));
return None;
}
Err(_) => {
let _ = tx.send(MergeEvent::Failed(format!(
"{}: the download ended without answering",
fetch_names[i]
)));
return None;
}
};
frames.push(MergeInput {
name: fetch_names[i].clone(),
bytes: std::sync::Arc::new(bytes),
});
}
Some(frames)
};
start(&w, &ctl, gpu, names, destination, fetch, &on_done);
});
}
// A way to land on the page at startup with local files, for looking
+7 -5
View File
@@ -44,7 +44,7 @@ use dr_catalog::recovery;
use slint::ComponentHandle;
use crate::library_ui::LibraryController;
use crate::AppWindow;
use crate::{AppWindow, Library};
// What the open question is about.
//
@@ -123,7 +123,7 @@ pub(crate) fn offer(window: &AppWindow, catalog: &Path, detail: &str) {
window.set_recovery_busy(false);
// The scan was held back, so the "Scanning…" the grid is showing behind
// this would be a lie the moment the question is dismissed.
window.set_library_scanning(false);
window.global::<Library>().set_library_scanning(false);
PENDING.with(|p| {
*p.borrow_mut() = Some(Pending {
@@ -141,8 +141,8 @@ pub(crate) fn offer(window: &AppWindow, catalog: &Path, detail: &str) {
fn dismiss(window: &AppWindow) {
PENDING.with(|p| *p.borrow_mut() = None);
window.set_recovery_title(slint::SharedString::new());
window.set_library_scanning(false);
window.set_library_error(
window.global::<Library>().set_library_scanning(false);
window.global::<Library>().set_library_error(
"The library index is damaged. Rescan to rebuild it, or restore a backup.".into(),
);
}
@@ -243,7 +243,9 @@ fn answer(
window.set_recovery_busy(false);
window.set_recovery_title(slint::SharedString::new());
window.set_library_error(slint::SharedString::new());
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
if crate::library_ui::show_catalog_now(window, ctl, &pending.catalog, coll_ctl) {
crate::library_ui::start_rescan(window, ctl, coll_ctl);