Merge: the library grid on its own Slint global, the last of the screen state off the root
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(¤t, &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(¤t, &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(
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user