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);
|
||||
|
||||
+126
-391
@@ -7,7 +7,7 @@ import { CropOverlay } from "crop.slint";
|
||||
import { HistoryPanel, HistoryRow, Steps } from "history.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip, Library } from "library.slint";
|
||||
import { GestureRow } from "gestures.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, CollectionMenu,
|
||||
@@ -32,6 +32,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
||||
// panels take their wiring this way and why there is one global per family.
|
||||
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
|
||||
export { Collections }
|
||||
export { Library }
|
||||
|
||||
/// Which top-level screen is on display. Exactly one at a time — replacing
|
||||
/// the `show-launch` / `show-library` / `show-identity` booleans, with
|
||||
@@ -288,8 +289,6 @@ export component AppWindow inherits Window {
|
||||
/// the two can drift apart again.
|
||||
out property <bool> library-visible:
|
||||
root.active-page == Page.none && root.active-view == View.library;
|
||||
in-out property <[LibraryCell]> library-cells;
|
||||
in property <int> library-total: 0;
|
||||
// --- background activity (FR-CAT-1, FR-NC-6c) ---
|
||||
//
|
||||
// Every background job in one register, filled by `activity.rs`. The
|
||||
@@ -311,74 +310,6 @@ export component AppWindow inherits Window {
|
||||
in property <int> activity-kept: 0;
|
||||
callback activity-clear-finished();
|
||||
|
||||
in property <bool> library-scanning: false;
|
||||
/// The catalog on this device is being opened and checked, on a worker,
|
||||
/// before the scan starts (`library_ui::open_catalog_soon`).
|
||||
///
|
||||
/// Separate from `library-scanning` because they are different answers to
|
||||
/// the same question: this one is reading a file that is already here, and
|
||||
/// that one is walking a tree over the network. Both are true at once for
|
||||
/// the first moment of a launch, and only the more specific of them is
|
||||
/// worth putting on an empty grid.
|
||||
in property <bool> library-opening: false;
|
||||
in property <string> library-status: "";
|
||||
in property <string> library-error: "";
|
||||
|
||||
// --- offline mode (FR-CAT-9) ---
|
||||
//
|
||||
// The library keeps working from local data when the server cannot be
|
||||
// reached: thumbnails come from the shards, and rating and sorting are
|
||||
// catalog operations that never needed the network. What stops is opening
|
||||
// an original that was never stored locally.
|
||||
//
|
||||
// `offline-reason` carries the transport's own message ("connection
|
||||
// refused", "dns error") because it is usually specific enough to act on,
|
||||
// where a bare "offline" leaves the user guessing whether it is their wifi
|
||||
// or the server.
|
||||
in property <bool> library-offline: false;
|
||||
in property <string> library-offline-reason: "";
|
||||
in property <string> library-offline-since: "";
|
||||
// Narrows the grid to images whose RAW is stored locally — the ones that
|
||||
// can actually be opened while offline. Off by default: the catalog is the
|
||||
// library, and hiding most of it the moment a connection drops would read
|
||||
// as data loss rather than as a filter.
|
||||
in-out property <bool> library-local-only: false;
|
||||
in property <int> library-local-count: 0;
|
||||
callback library-toggle-local-only();
|
||||
/// Whether the grid is narrowed to the timeline's visible span.
|
||||
in-out property <bool> library-range-active: false;
|
||||
callback library-toggle-date-range();
|
||||
/// TRACES: FR-CAT-5
|
||||
callback library-clear-selection();
|
||||
callback library-select-all();
|
||||
/// TRACES: FR-CAT-7
|
||||
/// Whether the grid is showing something with a manual order to change —
|
||||
/// a single manual collection, not a set and not a saved filter.
|
||||
in property <bool> library-reorderable: false;
|
||||
/// Move the selection beside the photograph at this row of the loaded
|
||||
/// window: after it where the drop landed on the cell's trailing half,
|
||||
/// before it otherwise.
|
||||
callback library-reorder-to(int, bool);
|
||||
callback library-collection-from-selection(string);
|
||||
/// TRACES: FR-CAT-6
|
||||
in property <string> library-range-from;
|
||||
in property <string> library-range-to;
|
||||
in property <bool> library-range-invalid: false;
|
||||
callback library-range-edited(string, string);
|
||||
/// The same range as a band on the capture-time axis, and the ends of it
|
||||
/// dragged. Fractions of the span the axis is drawn over — Rust owns the
|
||||
/// conversion both ways, so the band lands where the finger left it.
|
||||
in property <float> library-range-from-fraction: -1;
|
||||
in property <float> library-range-to-fraction: -1;
|
||||
callback library-timeline-range-changed(float, float);
|
||||
callback library-retry-connection();
|
||||
|
||||
// --- pinning a collection offline (FR-NC-6a) ---
|
||||
in property <bool> library-scope-pinned: false;
|
||||
in property <int> library-pin-done: 0;
|
||||
in property <int> library-pin-total: 0;
|
||||
callback library-toggle-pin-scope();
|
||||
|
||||
// The question a held collection asks: keep these on the device, or give
|
||||
// the disk back. Driven entirely from Rust — the counts and the byte sizes
|
||||
// in the labels are catalog reads, and an empty `title` is what closes it.
|
||||
@@ -408,32 +339,6 @@ export component AppWindow inherits Window {
|
||||
callback recovery-rebuild();
|
||||
callback recovery-dismiss();
|
||||
|
||||
in property <string> library-root-label: "";
|
||||
in-out property <[TimelineBar]> library-timeline;
|
||||
in property <string> library-timeline-label: "";
|
||||
in property <string> library-window-label: "";
|
||||
in property <int> library-offset: 0;
|
||||
|
||||
in property <int> library-sweep-done: 0;
|
||||
in property <int> library-sweep-total: 0;
|
||||
|
||||
in property <int> library-current-bucket: 0;
|
||||
in property <float> library-current-fraction: -1;
|
||||
in property <bool> library-timeline-anchored: false;
|
||||
|
||||
callback library-scrub-fraction(float);
|
||||
callback library-timeline-pinch(float);
|
||||
callback library-timeline-pan(float);
|
||||
callback library-timeline-zoom(int);
|
||||
in-out property <int> library-columns: 1;
|
||||
in property <bool> library-syncing: false;
|
||||
/// TRACES: FR-CAT-3 | FR-NC-3
|
||||
/// The whole-library thumbnail pass, launched from the settings page.
|
||||
/// `library-open` gates the button on there being a library to sweep —
|
||||
/// the page is reachable from the launch screen too.
|
||||
in property <bool> library-thumbnailing: false;
|
||||
in property <bool> library-open: false;
|
||||
|
||||
// --- people and faces (FR-CULL-8 … FR-CULL-12) ---
|
||||
//
|
||||
// A third top-level screen rather than a panel: naming a cluster and
|
||||
@@ -495,46 +400,9 @@ export component AppWindow inherits Window {
|
||||
callback identity-stop-indexing();
|
||||
callback identity-check-coverage();
|
||||
callback identity-delete-all();
|
||||
callback library-thumbnail-all();
|
||||
in-out property <length> library-cell-size: 180px;
|
||||
/// Whether this session is a touch one, which decides if the rating strip
|
||||
/// waits to be hovered or stands open. Seeded from the platform by Rust
|
||||
/// and latched by the grid on the first finger it sees.
|
||||
in-out property <bool> library-touched: false;
|
||||
callback library-zoom-cells(int);
|
||||
/// A pinch step on the grid: the ratio since the last update.
|
||||
callback library-pinch-cells(float);
|
||||
/// A pinch on the grid has begun, so the press that opened it was not one.
|
||||
callback library-pinch-started();
|
||||
in property <int> library-scroll-to: 0;
|
||||
in property <int> library-scroll-token: 0;
|
||||
/// Which row of the loaded window is the photograph currently open in
|
||||
/// develop, so the roll can mark it. `-1` when it is not in the window.
|
||||
in property <int> library-roll-current: -1;
|
||||
/// Whether the photo roll is out. Owned here rather than by the roll, so
|
||||
/// it survives the develop view being torn down and rebuilt.
|
||||
in-out property <bool> roll-open: false;
|
||||
/// Centre the roll on the open photograph the next time the strip settles.
|
||||
///
|
||||
/// Raised by Rust when a develop session begins — a photograph opened from
|
||||
/// the grid — and cleared by the strip itself once honoured. Owned here for
|
||||
/// the same reason `roll-open` is: the develop view is rebuilt whenever an
|
||||
/// overlay screen covers it, and a request raised before that must survive
|
||||
/// it. See `PhotoRoll::centre-request`.
|
||||
in-out property <bool> library-roll-centre: false;
|
||||
/// A photograph was chosen from the roll: the row within the loaded
|
||||
/// window, which is what a cell click reports too.
|
||||
callback library-roll-pick(int);
|
||||
callback library-sync-now();
|
||||
callback library-columns-changed();
|
||||
callback library-scrolled(int);
|
||||
/// How many cells the grid's viewport shows at once. Rust sizes the
|
||||
/// loaded window from it — see `library_ui::window_move`.
|
||||
callback library-viewport-cells(int);
|
||||
callback library-cell-clicked(int);
|
||||
callback library-rescan();
|
||||
/// Grid → launch screen, to pick a different folder or account.
|
||||
callback library-change();
|
||||
/// Develop → grid.
|
||||
callback back-to-library();
|
||||
|
||||
@@ -559,129 +427,6 @@ export component AppWindow inherits Window {
|
||||
/// Ask about a collection's local copies: the tray on a row, or a long
|
||||
/// press anywhere on it.
|
||||
callback collection-offline-menu(int);
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// File the grid's selection in a collection without dragging it there:
|
||||
/// the target's id, and whether to take the images out of the collection
|
||||
/// currently being shown.
|
||||
callback library-file-in-collection(int, bool);
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// Keywording the grid's selection. The catalog has been searchable by
|
||||
/// keyword since it existed and there was nowhere to type one; this is it.
|
||||
///
|
||||
/// The vocabulary arrives already answered against the selection — each row
|
||||
/// says how many of the selected photographs carry that word — because only
|
||||
/// Rust knows what is selected, and a `.slint` file counting it would need
|
||||
/// the selection as a second model that could disagree with the first.
|
||||
in property <[KeywordRow]> library-keywords;
|
||||
/// The sheet is opening: recompute the rows against the selection as it
|
||||
/// stands now. Pulled rather than pushed, because the selection changes on
|
||||
/// every arrow key and the sheet is shut for almost all of them.
|
||||
callback library-keywords-opened();
|
||||
/// Put a keyword on the selection, creating it if it is new. By name, so a
|
||||
/// word typed into the field and a word tapped in the list are one path.
|
||||
callback library-assign-keyword(string);
|
||||
/// Take a keyword off the selection. Never deletes the keyword itself —
|
||||
/// it stays in the vocabulary and on every other photograph that carries it.
|
||||
callback library-unassign-keyword(string);
|
||||
/// TRACES: FR-UI-2
|
||||
/// Whether a tap in the grid selects rather than opens, and the button
|
||||
/// that turns it on. The long press does the same thing without it.
|
||||
in property <bool> library-select-mode: false;
|
||||
callback library-toggle-select-mode();
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// The row a press has held long enough to pick up, or `-1`.
|
||||
///
|
||||
/// Set by the same hold that turns on selection mode and cleared by the
|
||||
/// grid when the press ends — `in-out` because both ends write it. See
|
||||
/// `held-row` in `library.slint` for what it draws and what it stops.
|
||||
in-out property <int> library-held-row: -1;
|
||||
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||||
callback library-cell-press-ended();
|
||||
|
||||
// --- drag and drop ---
|
||||
//
|
||||
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
|
||||
// click-versus-drag threshold, arbitration against the grid's Flickable,
|
||||
// and the image drawn under the cursor. So there is no badge to position
|
||||
// and no hover state to mirror here — only the payload, which Rust builds
|
||||
// from the selection, and the count the header shows.
|
||||
/// The drag payload, built by Rust from the current selection.
|
||||
pure callback library-drag-payload() -> data-transfer;
|
||||
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
|
||||
/// stack where several images are being dragged. Composited in Rust.
|
||||
in property <image> library-drag-image;
|
||||
/// A drag began on a cell, so an unselected one can be promoted before the
|
||||
/// payload is read.
|
||||
callback library-drag-started(int);
|
||||
callback library-drag-finished();
|
||||
in property <int> library-selected-count: 0;
|
||||
|
||||
callback library-cell-pressed(int, bool, bool);
|
||||
callback library-remove-from-collection();
|
||||
|
||||
// --- where the selection is filed (FR-CAT-7) ---------------------------
|
||||
//
|
||||
// `library-remove-from-collection` can only ever mean the collection the
|
||||
// grid is scoped to — it is the only one that button can name. Opens the
|
||||
// membership sheet, whose state (`Collections.membership-open` and
|
||||
// `.membership-rows`) is on the sidebar's own global — only the library
|
||||
// side knows the selection has changed enough to be worth recomputing
|
||||
// the sheet against.
|
||||
callback library-open-membership();
|
||||
|
||||
// The keyboard cursor: where the arrows are in the library, as an image
|
||||
// ordinal. Rust owns it — clamping it needs the library's length, and
|
||||
// moving it may have to swap the loaded window underneath.
|
||||
in property <int> library-cursor: -1;
|
||||
callback library-move-cursor(int, bool);
|
||||
callback library-open-cursor();
|
||||
|
||||
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
|
||||
//
|
||||
// Stars and pick/reject, set from the grid and persisted to the catalog
|
||||
// and the sidecar. Every image enters unrated, which is a state of its
|
||||
// own rather than a zero score.
|
||||
/// A star was clicked on a cell: row, then the rating 0..5.
|
||||
callback library-cell-rated(int, int);
|
||||
/// The trash target was clicked on one cell, by row.
|
||||
callback library-cell-trashed(int);
|
||||
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
|
||||
callback library-burst-toggled(int);
|
||||
/// One frame of an open burst was named as the one the group folds to, by
|
||||
/// row (FR-CULL-5).
|
||||
callback library-burst-representative-chosen(int);
|
||||
/// Move the grid selection to the trash — the `Delete` key.
|
||||
callback library-trash-selection();
|
||||
/// A judgement key was pressed, applying to the whole selection. One of
|
||||
/// the two arguments is -1, saying which axis was *not* meant.
|
||||
callback library-judged(int, int);
|
||||
|
||||
/// Whose photographs the grid is narrowed to, one entry each.
|
||||
in property <[PersonChip]> library-filter-people;
|
||||
/// Whether those people are an intersection rather than a union.
|
||||
in property <bool> library-filter-people-all: false;
|
||||
callback library-filter-person-cleared(int);
|
||||
callback library-filter-people-mode-toggled();
|
||||
/// TRACES: FR-CULL-8a | FR-CULL-13
|
||||
/// Whether the grid is narrowed to photographs with nobody blinking.
|
||||
in property <bool> library-filter-eyes-open: false;
|
||||
callback library-filter-eyes-open-toggled();
|
||||
/// Everyone the library knows, for the filter bar's people tray.
|
||||
/// TRACES: FR-UI-4
|
||||
/// The gesture reference's rows, read from the generated table.
|
||||
in property <[GestureRow]> library-gestures;
|
||||
in property <[PersonChip]> library-people;
|
||||
callback library-people-listed();
|
||||
callback library-filter-person-toggled(int);
|
||||
in property <int> library-filter-min-rating: 0;
|
||||
in property <bool> library-filter-unjudged: false;
|
||||
in property <int> library-filter-flag: 0;
|
||||
/// Images per star count, index 0 unrated, for the filter chips.
|
||||
in-out property <[int]> library-rating-counts;
|
||||
|
||||
callback library-filter-min-rating-changed(int);
|
||||
callback library-filter-unjudged-changed(bool);
|
||||
callback library-filter-flag-changed(int);
|
||||
|
||||
callback next-image();
|
||||
callback prev-image();
|
||||
@@ -809,8 +554,6 @@ export component AppWindow inherits Window {
|
||||
in property <float> merge-fill-mirror: 48;
|
||||
in property <float> merge-fill-feather: 48;
|
||||
in property <bool> merge-done: false;
|
||||
/// Merge the grid's selection: opens the page and starts the alignment.
|
||||
callback library-merge-selection();
|
||||
/// The confirmation (FR-MRG-1). Nothing is written before this.
|
||||
callback merge-confirm();
|
||||
callback merge-abandon();
|
||||
@@ -1098,13 +841,6 @@ in property <bool> panel-visible: true;
|
||||
/// composes its own label around a selection count — so it is given the
|
||||
/// fact and not the sentence.
|
||||
in property <bool> export-to-server: false;
|
||||
/// Whether a batch is running. The grid's export button becomes its cancel
|
||||
/// button; there is nowhere else a long batch can be stopped from.
|
||||
in property <bool> library-exporting: false;
|
||||
/// Export the grid's selection in the background (FR-EXP-7).
|
||||
callback library-export-selection();
|
||||
/// TRACES: NFR-ARCH-3
|
||||
callback library-cancel-export();
|
||||
/// Show or hide the develop column.
|
||||
callback toggle-panel();
|
||||
/// Export the image on screen, using the settings as they stand.
|
||||
@@ -1410,7 +1146,7 @@ in property <bool> panel-visible: true;
|
||||
activity-running: root.activity-running;
|
||||
activity-kept: root.activity-kept;
|
||||
clear-finished() => { root.activity-clear-finished(); }
|
||||
thumbnailing: root.library-thumbnailing;
|
||||
thumbnailing: Library.library-thumbnailing;
|
||||
// The face pass reuses the Identity Manager's own state rather
|
||||
// than duplicating it: one job, startable from either place, and
|
||||
// a button that cannot disagree with the screen about whether it
|
||||
@@ -1421,8 +1157,8 @@ in property <bool> panel-visible: true;
|
||||
face-detector-labels: root.settings-face-detector-labels;
|
||||
face-detector-selected: root.settings-face-detector-selected;
|
||||
face-detector-picked(i) => { root.settings-face-detector-picked(i); }
|
||||
library-open: root.library-open;
|
||||
thumbnail-library() => { root.library-thumbnail-all(); }
|
||||
library-open: Library.library-open;
|
||||
thumbnail-library() => { Library.library-thumbnail-all(); }
|
||||
index-faces() => { root.identity-index(); }
|
||||
reindex-faces() => { root.identity-reindex(); }
|
||||
|
||||
@@ -1471,7 +1207,7 @@ in property <bool> panel-visible: true;
|
||||
confirm-all() => { root.identity-confirm-all(); }
|
||||
split-picked() => { root.identity-split-picked(); }
|
||||
compact: !root.expanded;
|
||||
photos-filtered: root.library-filter-people.length > 0;
|
||||
photos-filtered: Library.library-filter-people.length > 0;
|
||||
regrouping: root.identity-regrouping;
|
||||
regroup-status: root.identity-regroup-status;
|
||||
merge-probability: root.identity-merge-probability;
|
||||
@@ -1560,7 +1296,7 @@ in property <bool> panel-visible: true;
|
||||
height: 100%;
|
||||
rows: Collections.collection-rows;
|
||||
selected-id: root.collection-selected;
|
||||
total-images: root.library-total;
|
||||
total-images: Library.library-total;
|
||||
error: Collections.collection-error;
|
||||
|
||||
// TRACES: FR-NC-6a
|
||||
@@ -1568,10 +1304,10 @@ in property <bool> panel-visible: true;
|
||||
// referred to the tree rather than to the library.
|
||||
// They read the same window state the header did — one
|
||||
// source per fact — and call the same handlers.
|
||||
scanning: root.library-scanning;
|
||||
scope-pinned: root.library-scope-pinned;
|
||||
toggle-pin-scope() => { root.library-toggle-pin-scope(); }
|
||||
change-library() => { root.library-change(); }
|
||||
scanning: Library.library-scanning;
|
||||
scope-pinned: Library.library-scope-pinned;
|
||||
toggle-pin-scope() => { Library.library-toggle-pin-scope(); }
|
||||
change-library() => { Library.library-change(); }
|
||||
|
||||
select(id) => { root.collection-select(id); }
|
||||
toggle(id) => { Collections.collection-toggle(id); }
|
||||
@@ -1592,7 +1328,7 @@ in property <bool> panel-visible: true;
|
||||
rename-cancel() => { Collections.collection-rename-cancel(); }
|
||||
trash-count: Collections.trash-count;
|
||||
trash-label: Collections.trash-label;
|
||||
selected-count: root.library-selected-count;
|
||||
selected-count: Library.library-selected-count;
|
||||
|
||||
dropped-on(id) => { Collections.collection-dropped(id); }
|
||||
collection-drag-payload(id) => { return Collections.collection-drag-payload(id); }
|
||||
@@ -1610,50 +1346,50 @@ in property <bool> panel-visible: true;
|
||||
// 768 logical pixels, which fits the title and the status
|
||||
// line but not the library's buttons beside them.
|
||||
expanded: root.expanded;
|
||||
cells: root.library-cells;
|
||||
total: root.library-total;
|
||||
scanning: root.library-scanning;
|
||||
opening: root.library-opening;
|
||||
scan-status: root.library-status;
|
||||
scan-error: root.library-error;
|
||||
cells: Library.library-cells;
|
||||
total: Library.library-total;
|
||||
scanning: Library.library-scanning;
|
||||
opening: Library.library-opening;
|
||||
scan-status: Library.library-status;
|
||||
scan-error: Library.library-error;
|
||||
|
||||
offline: root.library-offline;
|
||||
offline-reason: root.library-offline-reason;
|
||||
offline-since: root.library-offline-since;
|
||||
retry-connection() => { root.library-retry-connection(); }
|
||||
local-only: root.library-local-only;
|
||||
local-count: root.library-local-count;
|
||||
toggle-local-only() => { root.library-toggle-local-only(); }
|
||||
range-active: root.library-range-active;
|
||||
toggle-date-range() => { root.library-toggle-date-range(); }
|
||||
clear-selection() => { root.library-clear-selection(); }
|
||||
select-all() => { root.library-select-all(); }
|
||||
reorderable: root.library-reorderable;
|
||||
reorder-to(row, after) => { root.library-reorder-to(row, after); }
|
||||
offline: Library.library-offline;
|
||||
offline-reason: Library.library-offline-reason;
|
||||
offline-since: Library.library-offline-since;
|
||||
retry-connection() => { Library.library-retry-connection(); }
|
||||
local-only: Library.library-local-only;
|
||||
local-count: Library.library-local-count;
|
||||
toggle-local-only() => { Library.library-toggle-local-only(); }
|
||||
range-active: Library.library-range-active;
|
||||
toggle-date-range() => { Library.library-toggle-date-range(); }
|
||||
clear-selection() => { Library.library-clear-selection(); }
|
||||
select-all() => { Library.library-select-all(); }
|
||||
reorderable: Library.library-reorderable;
|
||||
reorder-to(row, after) => { Library.library-reorder-to(row, after); }
|
||||
collection-from-selection(name) => {
|
||||
root.library-collection-from-selection(name);
|
||||
Library.library-collection-from-selection(name);
|
||||
}
|
||||
range-from: root.library-range-from;
|
||||
range-to: root.library-range-to;
|
||||
range-invalid: root.library-range-invalid;
|
||||
range-edited(a, b) => { root.library-range-edited(a, b); }
|
||||
range-from-fraction: root.library-range-from-fraction;
|
||||
range-to-fraction: root.library-range-to-fraction;
|
||||
range-from: Library.library-range-from;
|
||||
range-to: Library.library-range-to;
|
||||
range-invalid: Library.library-range-invalid;
|
||||
range-edited(a, b) => { Library.library-range-edited(a, b); }
|
||||
range-from-fraction: Library.library-range-from-fraction;
|
||||
range-to-fraction: Library.library-range-to-fraction;
|
||||
timeline-range-changed(a, b) => {
|
||||
root.library-timeline-range-changed(a, b);
|
||||
Library.library-timeline-range-changed(a, b);
|
||||
}
|
||||
|
||||
collections-visible: root.collections-visible;
|
||||
toggle-collections() => { root.toggle-collections(); }
|
||||
|
||||
pin-done: root.library-pin-done;
|
||||
pin-total: root.library-pin-total;
|
||||
root-label: root.library-root-label;
|
||||
timeline: root.library-timeline;
|
||||
timeline-label: root.library-timeline-label;
|
||||
window-label: root.library-window-label;
|
||||
offset: root.library-offset;
|
||||
selected-count: root.library-selected-count;
|
||||
pin-done: Library.library-pin-done;
|
||||
pin-total: Library.library-pin-total;
|
||||
root-label: Library.library-root-label;
|
||||
timeline: Library.library-timeline;
|
||||
timeline-label: Library.library-timeline-label;
|
||||
window-label: Library.library-window-label;
|
||||
offset: Library.library-offset;
|
||||
selected-count: Library.library-selected-count;
|
||||
scope-label: Collections.collection-scope-label;
|
||||
settings-armed: Transfer.armed;
|
||||
paste-settings-to-selection => {
|
||||
@@ -1664,108 +1400,108 @@ in property <bool> panel-visible: true;
|
||||
// sheet says it would act on and what the apply handler
|
||||
// reads back to decide it means the batch.
|
||||
open-presets => {
|
||||
root.preset-apply-count = root.library-selected-count;
|
||||
root.preset-apply-count = Library.library-selected-count;
|
||||
root.preset-can-save = false;
|
||||
root.presets-open = true;
|
||||
}
|
||||
|
||||
// TRACES: FR-EXP-7
|
||||
exporting: root.library-exporting;
|
||||
exporting: Library.library-exporting;
|
||||
export-to-server: root.export-to-server;
|
||||
export-selection => { root.library-export-selection(); }
|
||||
cancel-export => { root.library-cancel-export(); }
|
||||
export-selection => { Library.library-export-selection(); }
|
||||
cancel-export => { Library.library-cancel-export(); }
|
||||
// TRACES: FR-MRG-1
|
||||
merge-selection => { root.library-merge-selection(); }
|
||||
merge-selection => { Library.library-merge-selection(); }
|
||||
|
||||
sweep-done: root.library-sweep-done;
|
||||
sweep-total: root.library-sweep-total;
|
||||
sweep-done: Library.library-sweep-done;
|
||||
sweep-total: Library.library-sweep-total;
|
||||
|
||||
current-bucket: root.library-current-bucket;
|
||||
current-bucket-fraction: root.library-current-fraction;
|
||||
timeline-anchored: root.library-timeline-anchored;
|
||||
current-bucket: Library.library-current-bucket;
|
||||
current-bucket-fraction: Library.library-current-fraction;
|
||||
timeline-anchored: Library.library-timeline-anchored;
|
||||
|
||||
scrub-fraction(f) => { root.library-scrub-fraction(f); }
|
||||
timeline-pinch(r) => { root.library-timeline-pinch(r); }
|
||||
timeline-pan(d) => { root.library-timeline-pan(d); }
|
||||
timeline-zoom(d) => { root.library-timeline-zoom(d); }
|
||||
syncing: root.library-syncing;
|
||||
requested-cell-size: root.library-cell-size;
|
||||
touched <=> root.library-touched;
|
||||
zoom-cells(d) => { root.library-zoom-cells(d); }
|
||||
pinch-cells(r) => { root.library-pinch-cells(r); }
|
||||
pinch-started() => { root.library-pinch-started(); }
|
||||
scroll-to: root.library-scroll-to;
|
||||
scroll-token: root.library-scroll-token;
|
||||
sync-now() => { root.library-sync-now(); }
|
||||
scrub-fraction(f) => { Library.library-scrub-fraction(f); }
|
||||
timeline-pinch(r) => { Library.library-timeline-pinch(r); }
|
||||
timeline-pan(d) => { Library.library-timeline-pan(d); }
|
||||
timeline-zoom(d) => { Library.library-timeline-zoom(d); }
|
||||
syncing: Library.library-syncing;
|
||||
requested-cell-size: Library.library-cell-size;
|
||||
touched <=> Library.library-touched;
|
||||
zoom-cells(d) => { Library.library-zoom-cells(d); }
|
||||
pinch-cells(r) => { Library.library-pinch-cells(r); }
|
||||
pinch-started() => { Library.library-pinch-started(); }
|
||||
scroll-to: Library.library-scroll-to;
|
||||
scroll-token: Library.library-scroll-token;
|
||||
sync-now() => { Library.library-sync-now(); }
|
||||
columns-changed(n) => {
|
||||
root.library-columns = n;
|
||||
Library.library-columns = n;
|
||||
// Which cell begins a row just changed, and month headings sit on
|
||||
// row-leading cells — so they must be recomputed, not just moved.
|
||||
root.library-columns-changed();
|
||||
Library.library-columns-changed();
|
||||
}
|
||||
scrolled(i) => { root.library-scrolled(i); }
|
||||
viewport-cells-changed(n) => { root.library-viewport-cells(n); }
|
||||
cell-clicked(i) => { root.library-cell-clicked(i); }
|
||||
rescan() => { root.library-rescan(); }
|
||||
scrolled(i) => { Library.library-scrolled(i); }
|
||||
viewport-cells-changed(n) => { Library.library-viewport-cells(n); }
|
||||
cell-clicked(i) => { Library.library-cell-clicked(i); }
|
||||
rescan() => { Library.library-rescan(); }
|
||||
can-import: root.import-supported;
|
||||
open-import() => { root.import-open(); }
|
||||
open-settings() => { root.settings-open(); }
|
||||
open-people() => { root.identity-open(); }
|
||||
|
||||
cell-pressed(i, ctrl, shift) => {
|
||||
root.library-cell-pressed(i, ctrl, shift);
|
||||
Library.library-cell-pressed(i, ctrl, shift);
|
||||
}
|
||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||
select-mode: root.library-select-mode;
|
||||
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||||
held-row <=> root.library-held-row;
|
||||
cell-press-ended() => { Library.library-cell-press-ended(); }
|
||||
select-mode: Library.library-select-mode;
|
||||
toggle-select-mode() => { Library.library-toggle-select-mode(); }
|
||||
held-row <=> Library.library-held-row;
|
||||
// The sidebar's rows, not a second model: the sheet files
|
||||
// into the same tree the sidebar draws.
|
||||
collections: Collections.collection-rows;
|
||||
file-in-collection(id, moves) => {
|
||||
root.library-file-in-collection(id, moves);
|
||||
Library.library-file-in-collection(id, moves);
|
||||
}
|
||||
keywords: root.library-keywords;
|
||||
keywords-opened() => { root.library-keywords-opened(); }
|
||||
assign-keyword(word) => { root.library-assign-keyword(word); }
|
||||
unassign-keyword(word) => { root.library-unassign-keyword(word); }
|
||||
cursor: root.library-cursor;
|
||||
keywords: Library.library-keywords;
|
||||
keywords-opened() => { Library.library-keywords-opened(); }
|
||||
assign-keyword(word) => { Library.library-assign-keyword(word); }
|
||||
unassign-keyword(word) => { Library.library-unassign-keyword(word); }
|
||||
cursor: Library.library-cursor;
|
||||
move-cursor(delta, extend) => {
|
||||
root.library-move-cursor(delta, extend);
|
||||
Library.library-move-cursor(delta, extend);
|
||||
}
|
||||
open-cursor() => { root.library-open-cursor(); }
|
||||
drag-image: root.library-drag-image;
|
||||
drag-payload() => { return root.library-drag-payload(); }
|
||||
drag-started(i) => { root.library-drag-started(i); }
|
||||
drag-finished() => { root.library-drag-finished(); }
|
||||
open-cursor() => { Library.library-open-cursor(); }
|
||||
drag-image: Library.library-drag-image;
|
||||
drag-payload() => { return Library.library-drag-payload(); }
|
||||
drag-started(i) => { Library.library-drag-started(i); }
|
||||
drag-finished() => { Library.library-drag-finished(); }
|
||||
remove-from-collection() => {
|
||||
root.library-remove-from-collection();
|
||||
Library.library-remove-from-collection();
|
||||
}
|
||||
open-membership() => { root.library-open-membership(); }
|
||||
open-membership() => { Library.library-open-membership(); }
|
||||
|
||||
filter-people: root.library-filter-people;
|
||||
filter-people-all: root.library-filter-people-all;
|
||||
filter-eyes-open: root.library-filter-eyes-open;
|
||||
people: root.library-people;
|
||||
gestures: root.library-gestures;
|
||||
filter-min-rating: root.library-filter-min-rating;
|
||||
filter-unjudged: root.library-filter-unjudged;
|
||||
filter-flag: root.library-filter-flag;
|
||||
rating-counts: root.library-rating-counts;
|
||||
filter-people: Library.library-filter-people;
|
||||
filter-people-all: Library.library-filter-people-all;
|
||||
filter-eyes-open: Library.library-filter-eyes-open;
|
||||
people: Library.library-people;
|
||||
gestures: Library.library-gestures;
|
||||
filter-min-rating: Library.library-filter-min-rating;
|
||||
filter-unjudged: Library.library-filter-unjudged;
|
||||
filter-flag: Library.library-filter-flag;
|
||||
rating-counts: Library.library-rating-counts;
|
||||
|
||||
cell-rated(i, n) => { root.library-cell-rated(i, n); }
|
||||
cell-trashed(i) => { root.library-cell-trashed(i); }
|
||||
burst-toggled(i) => { root.library-burst-toggled(i); }
|
||||
cell-rated(i, n) => { Library.library-cell-rated(i, n); }
|
||||
cell-trashed(i) => { Library.library-cell-trashed(i); }
|
||||
burst-toggled(i) => { Library.library-burst-toggled(i); }
|
||||
burst-representative-chosen(i) => {
|
||||
root.library-burst-representative-chosen(i);
|
||||
Library.library-burst-representative-chosen(i);
|
||||
}
|
||||
trash-selection() => { root.library-trash-selection(); }
|
||||
trash-selection() => { Library.library-trash-selection(); }
|
||||
// Derived from the sidebar's own selection rather than
|
||||
// mirrored in a second property: `-1` is already the sentinel
|
||||
// for the trash row (see collections.slint), and two sources
|
||||
// for one fact is how they come to disagree.
|
||||
viewing-trash: root.collection-selected == -1;
|
||||
judged(rating, flag) => { root.library-judged(rating, flag); }
|
||||
judged(rating, flag) => { Library.library-judged(rating, flag); }
|
||||
// `F2` renames whatever the grid is scoped to. Answered from
|
||||
// the sidebar's selection rather than a second property, for
|
||||
// the same reason `viewing-trash` above is: one fact, one
|
||||
@@ -1776,17 +1512,17 @@ in property <bool> panel-visible: true;
|
||||
Collections.collection-rename-start(root.collection-selected);
|
||||
}
|
||||
filter-min-rating-changed(n) => {
|
||||
root.library-filter-min-rating-changed(n);
|
||||
Library.library-filter-min-rating-changed(n);
|
||||
}
|
||||
filter-unjudged-toggled(on) => {
|
||||
root.library-filter-unjudged-changed(on);
|
||||
Library.library-filter-unjudged-changed(on);
|
||||
}
|
||||
filter-flag-changed(f) => { root.library-filter-flag-changed(f); }
|
||||
filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
|
||||
filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); }
|
||||
filter-eyes-open-toggled() => { root.library-filter-eyes-open-toggled(); }
|
||||
people-listed() => { root.library-people-listed(); }
|
||||
filter-person-toggled(id) => { root.library-filter-person-toggled(id); }
|
||||
filter-flag-changed(f) => { Library.library-filter-flag-changed(f); }
|
||||
filter-person-cleared(id) => { Library.library-filter-person-cleared(id); }
|
||||
filter-people-mode-toggled() => { Library.library-filter-people-mode-toggled(); }
|
||||
filter-eyes-open-toggled() => { Library.library-filter-eyes-open-toggled(); }
|
||||
people-listed() => { Library.library-people-listed(); }
|
||||
filter-person-toggled(id) => { Library.library-filter-person-toggled(id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1805,7 +1541,7 @@ in property <bool> panel-visible: true;
|
||||
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
|
||||
// Only where a library was opened; command-line files have no
|
||||
// grid to return to.
|
||||
can-return-to-library: root.library-total > 0;
|
||||
can-return-to-library: Library.library-total > 0;
|
||||
panel-visible: root.panel-visible;
|
||||
export-label: root.export-label;
|
||||
export-busy: root.export-busy;
|
||||
@@ -2315,7 +2051,6 @@ in property <bool> panel-visible: true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- crop overlay ------------------------------------------
|
||||
//
|
||||
// Placed over the fitted image, so the fractions it draws are
|
||||
@@ -2657,7 +2392,7 @@ in property <bool> panel-visible: true;
|
||||
// instead — the controls ride on top of the roll's
|
||||
// band, at whatever height it currently stands.
|
||||
property <length> roll-clear:
|
||||
root.library-total > 0
|
||||
Library.library-total > 0
|
||||
? (root.roll-open
|
||||
? Theme.roll-strip + Theme.roll-reach
|
||||
: Theme.roll-reach)
|
||||
@@ -2770,14 +2505,14 @@ in property <bool> panel-visible: true;
|
||||
//
|
||||
// Last in the canvas, so it draws over the photograph
|
||||
// rather than under it.
|
||||
if root.library-total > 0: PhotoRoll {
|
||||
if Library.library-total > 0: PhotoRoll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cells: root.library-cells;
|
||||
current: root.library-roll-current;
|
||||
cells: Library.library-cells;
|
||||
current: Library.library-roll-current;
|
||||
open <=> root.roll-open;
|
||||
centre-request <=> root.library-roll-centre;
|
||||
pick(i) => { root.library-roll-pick(i); }
|
||||
centre-request <=> Library.library-roll-centre;
|
||||
pick(i) => { Library.library-roll-pick(i); }
|
||||
}
|
||||
|
||||
// Report size changes so the render target can be resized to
|
||||
@@ -3179,7 +2914,7 @@ in property <bool> panel-visible: true;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
open: Collections.membership-open;
|
||||
selected-count: root.library-selected-count;
|
||||
selected-count: Library.library-selected-count;
|
||||
rows: Collections.membership-rows;
|
||||
remove(id) => { Collections.membership-remove(id); }
|
||||
dismiss() => { Collections.membership-dismiss(); }
|
||||
|
||||
@@ -479,6 +479,276 @@ export struct LibraryCell {
|
||||
burst-representative: bool,
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-3 | FR-CAT-4 | FR-CAT-5 | FR-CAT-6 | FR-CAT-7 | FR-CULL-4 | FR-CULL-5
|
||||
// The library grid's own state: the loaded window of cells, the selection,
|
||||
// the timeline, the filter bar, ratings and flags, keywording, drag and drop,
|
||||
// and the routes into and out of the screen.
|
||||
//
|
||||
// A global rather than properties and callbacks on the window root — see
|
||||
// `session.slint` for why the develop panels stopped taking their wiring
|
||||
// through the window root, and `collections.slint` for the sidebar's own
|
||||
// global; the same reasoning applies here. What is shared with the sidebar
|
||||
// (the collection the grid is scoped to, whether the sidebar is shown) stays
|
||||
// on the window, because `run()`'s panel-layout wiring and the sidebar's own
|
||||
// Rust still reach it there.
|
||||
export global Library {
|
||||
in-out property <[LibraryCell]> library-cells;
|
||||
in property <int> library-total: 0;
|
||||
// Narrows the grid to images whose RAW is stored locally — the ones that
|
||||
// can actually be opened while offline. Off by default: the catalog is the
|
||||
// library, and hiding most of it the moment a connection drops would read
|
||||
// as data loss rather than as a filter.
|
||||
in-out property <bool> library-local-only: false;
|
||||
in property <int> library-local-count: 0;
|
||||
callback library-toggle-local-only();
|
||||
/// TRACES: FR-CAT-5
|
||||
callback library-clear-selection();
|
||||
callback library-select-all();
|
||||
/// TRACES: FR-CAT-7
|
||||
/// Whether the grid is showing something with a manual order to change —
|
||||
/// a single manual collection, not a set and not a saved filter.
|
||||
in property <bool> library-reorderable: false;
|
||||
/// Move the selection beside the photograph at this row of the loaded
|
||||
/// window: after it where the drop landed on the cell's trailing half,
|
||||
/// before it otherwise.
|
||||
callback library-reorder-to(int, bool);
|
||||
callback library-collection-from-selection(string);
|
||||
in-out property <int> library-columns: 1;
|
||||
in-out property <length> library-cell-size: 180px;
|
||||
/// Whether this session is a touch one, which decides if the rating strip
|
||||
/// waits to be hovered or stands open. Seeded from the platform by Rust
|
||||
/// and latched by the grid on the first finger it sees.
|
||||
in-out property <bool> library-touched: false;
|
||||
callback library-zoom-cells(int);
|
||||
/// A pinch step on the grid: the ratio since the last update.
|
||||
callback library-pinch-cells(float);
|
||||
/// A pinch on the grid has begun, so the press that opened it was not one.
|
||||
callback library-pinch-started();
|
||||
in property <int> library-scroll-to: 0;
|
||||
in property <int> library-scroll-token: 0;
|
||||
/// Which row of the loaded window is the photograph currently open in
|
||||
/// develop, so the roll can mark it. `-1` when it is not in the window.
|
||||
in property <int> library-roll-current: -1;
|
||||
/// Centre the roll on the open photograph the next time the strip settles.
|
||||
///
|
||||
/// Raised by Rust when a develop session begins — a photograph opened from
|
||||
/// the grid — and cleared by the strip itself once honoured. Owned here for
|
||||
/// the same reason `roll-open` is: the develop view is rebuilt whenever an
|
||||
/// overlay screen covers it, and a request raised before that must survive
|
||||
/// it. See `PhotoRoll::centre-request`.
|
||||
in-out property <bool> library-roll-centre: false;
|
||||
/// A photograph was chosen from the roll: the row within the loaded
|
||||
/// window, which is what a cell click reports too.
|
||||
callback library-roll-pick(int);
|
||||
callback library-columns-changed();
|
||||
callback library-scrolled(int);
|
||||
/// How many cells the grid's viewport shows at once. Rust sizes the
|
||||
/// loaded window from it — see `library_ui::window_move`.
|
||||
callback library-viewport-cells(int);
|
||||
callback library-cell-clicked(int);
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// File the grid's selection in a collection without dragging it there:
|
||||
/// the target's id, and whether to take the images out of the collection
|
||||
/// currently being shown.
|
||||
callback library-file-in-collection(int, bool);
|
||||
/// TRACES: FR-UI-2
|
||||
/// Whether a tap in the grid selects rather than opens, and the button
|
||||
/// that turns it on. The long press does the same thing without it.
|
||||
in property <bool> library-select-mode: false;
|
||||
callback library-toggle-select-mode();
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// The row a press has held long enough to pick up, or `-1`.
|
||||
///
|
||||
/// Set by the same hold that turns on selection mode and cleared by the
|
||||
/// grid when the press ends — `in-out` because both ends write it. See
|
||||
/// `held-row` in `library.slint` for what it draws and what it stops.
|
||||
in-out property <int> library-held-row: -1;
|
||||
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||||
callback library-cell-press-ended();
|
||||
// --- drag and drop ---
|
||||
//
|
||||
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
|
||||
// click-versus-drag threshold, arbitration against the grid's Flickable,
|
||||
// and the image drawn under the cursor. So there is no badge to position
|
||||
// and no hover state to mirror here — only the payload, which Rust builds
|
||||
// from the selection, and the count the header shows.
|
||||
/// The drag payload, built by Rust from the current selection.
|
||||
pure callback library-drag-payload() -> data-transfer;
|
||||
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
|
||||
/// stack where several images are being dragged. Composited in Rust.
|
||||
in property <image> library-drag-image;
|
||||
/// A drag began on a cell, so an unselected one can be promoted before the
|
||||
/// payload is read.
|
||||
callback library-drag-started(int);
|
||||
callback library-drag-finished();
|
||||
in property <int> library-selected-count: 0;
|
||||
|
||||
callback library-cell-pressed(int, bool, bool);
|
||||
callback library-remove-from-collection();
|
||||
// --- where the selection is filed (FR-CAT-7) ---------------------------
|
||||
//
|
||||
// `library-remove-from-collection` can only ever mean the collection the
|
||||
// grid is scoped to — it is the only one that button can name. Opens the
|
||||
// membership sheet, whose state (`Collections.membership-open` and
|
||||
// `.membership-rows`) is on the sidebar's own global — only the library
|
||||
// side knows the selection has changed enough to be worth recomputing
|
||||
// the sheet against.
|
||||
callback library-open-membership();
|
||||
// The keyboard cursor: where the arrows are in the library, as an image
|
||||
// ordinal. Rust owns it — clamping it needs the library's length, and
|
||||
// moving it may have to swap the loaded window underneath.
|
||||
in property <int> library-cursor: -1;
|
||||
callback library-move-cursor(int, bool);
|
||||
callback library-open-cursor();
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// Keywording the grid's selection. The catalog has been searchable by
|
||||
/// keyword since it existed and there was nowhere to type one; this is it.
|
||||
///
|
||||
/// The vocabulary arrives already answered against the selection — each row
|
||||
/// says how many of the selected photographs carry that word — because only
|
||||
/// Rust knows what is selected, and a `.slint` file counting it would need
|
||||
/// the selection as a second model that could disagree with the first.
|
||||
in property <[KeywordRow]> library-keywords;
|
||||
/// The sheet is opening: recompute the rows against the selection as it
|
||||
/// stands now. Pulled rather than pushed, because the selection changes on
|
||||
/// every arrow key and the sheet is shut for almost all of them.
|
||||
callback library-keywords-opened();
|
||||
/// Put a keyword on the selection, creating it if it is new. By name, so a
|
||||
/// word typed into the field and a word tapped in the list are one path.
|
||||
callback library-assign-keyword(string);
|
||||
/// Take a keyword off the selection. Never deletes the keyword itself —
|
||||
/// it stays in the vocabulary and on every other photograph that carries it.
|
||||
callback library-unassign-keyword(string);
|
||||
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
|
||||
//
|
||||
// Stars and pick/reject, set from the grid and persisted to the catalog
|
||||
// and the sidecar. Every image enters unrated, which is a state of its
|
||||
// own rather than a zero score.
|
||||
/// A star was clicked on a cell: row, then the rating 0..5.
|
||||
callback library-cell-rated(int, int);
|
||||
/// The trash target was clicked on one cell, by row.
|
||||
callback library-cell-trashed(int);
|
||||
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
|
||||
callback library-burst-toggled(int);
|
||||
/// One frame of an open burst was named as the one the group folds to, by
|
||||
/// row (FR-CULL-5).
|
||||
callback library-burst-representative-chosen(int);
|
||||
/// Move the grid selection to the trash — the `Delete` key.
|
||||
callback library-trash-selection();
|
||||
/// A judgement key was pressed, applying to the whole selection. One of
|
||||
/// the two arguments is -1, saying which axis was *not* meant.
|
||||
callback library-judged(int, int);
|
||||
in property <int> library-filter-min-rating: 0;
|
||||
in property <bool> library-filter-unjudged: false;
|
||||
in property <int> library-filter-flag: 0;
|
||||
/// Images per star count, index 0 unrated, for the filter chips.
|
||||
in-out property <[int]> library-rating-counts;
|
||||
callback library-filter-min-rating-changed(int);
|
||||
callback library-filter-unjudged-changed(bool);
|
||||
callback library-filter-flag-changed(int);
|
||||
/// Whether the grid is narrowed to the timeline's visible span.
|
||||
in-out property <bool> library-range-active: false;
|
||||
callback library-toggle-date-range();
|
||||
/// TRACES: FR-CAT-6
|
||||
in property <string> library-range-from;
|
||||
in property <string> library-range-to;
|
||||
in property <bool> library-range-invalid: false;
|
||||
callback library-range-edited(string, string);
|
||||
/// The same range as a band on the capture-time axis, and the ends of it
|
||||
/// dragged. Fractions of the span the axis is drawn over — Rust owns the
|
||||
/// conversion both ways, so the band lands where the finger left it.
|
||||
in property <float> library-range-from-fraction: -1;
|
||||
in property <float> library-range-to-fraction: -1;
|
||||
callback library-timeline-range-changed(float, float);
|
||||
in property <string> library-root-label: "";
|
||||
in-out property <[TimelineBar]> library-timeline;
|
||||
in property <string> library-timeline-label: "";
|
||||
in property <string> library-window-label: "";
|
||||
in property <int> library-offset: 0;
|
||||
|
||||
in property <int> library-sweep-done: 0;
|
||||
in property <int> library-sweep-total: 0;
|
||||
|
||||
in property <int> library-current-bucket: 0;
|
||||
in property <float> library-current-fraction: -1;
|
||||
in property <bool> library-timeline-anchored: false;
|
||||
|
||||
callback library-scrub-fraction(float);
|
||||
callback library-timeline-pinch(float);
|
||||
callback library-timeline-pan(float);
|
||||
callback library-timeline-zoom(int);
|
||||
/// Whose photographs the grid is narrowed to, one entry each.
|
||||
in property <[PersonChip]> library-filter-people;
|
||||
/// Whether those people are an intersection rather than a union.
|
||||
in property <bool> library-filter-people-all: false;
|
||||
callback library-filter-person-cleared(int);
|
||||
callback library-filter-people-mode-toggled();
|
||||
/// TRACES: FR-CULL-8a | FR-CULL-13
|
||||
/// Whether the grid is narrowed to photographs with nobody blinking.
|
||||
in property <bool> library-filter-eyes-open: false;
|
||||
callback library-filter-eyes-open-toggled();
|
||||
/// Everyone the library knows, for the filter bar's people tray.
|
||||
/// TRACES: FR-UI-4
|
||||
/// The gesture reference's rows, read from the generated table.
|
||||
in property <[GestureRow]> library-gestures;
|
||||
in property <[PersonChip]> library-people;
|
||||
callback library-people-listed();
|
||||
callback library-filter-person-toggled(int);
|
||||
in property <bool> library-scanning: false;
|
||||
/// The catalog on this device is being opened and checked, on a worker,
|
||||
/// before the scan starts (`library_ui::open_catalog_soon`).
|
||||
///
|
||||
/// Separate from `library-scanning` because they are different answers to
|
||||
/// the same question: this one is reading a file that is already here, and
|
||||
/// that one is walking a tree over the network. Both are true at once for
|
||||
/// the first moment of a launch, and only the more specific of them is
|
||||
/// worth putting on an empty grid.
|
||||
in property <bool> library-opening: false;
|
||||
in property <string> library-status: "";
|
||||
in property <string> library-error: "";
|
||||
// --- offline mode (FR-CAT-9) ---
|
||||
//
|
||||
// The library keeps working from local data when the server cannot be
|
||||
// reached: thumbnails come from the shards, and rating and sorting are
|
||||
// catalog operations that never needed the network. What stops is opening
|
||||
// an original that was never stored locally.
|
||||
//
|
||||
// `offline-reason` carries the transport's own message ("connection
|
||||
// refused", "dns error") because it is usually specific enough to act on,
|
||||
// where a bare "offline" leaves the user guessing whether it is their wifi
|
||||
// or the server.
|
||||
in property <bool> library-offline: false;
|
||||
in property <string> library-offline-reason: "";
|
||||
in property <string> library-offline-since: "";
|
||||
callback library-retry-connection();
|
||||
// --- pinning a collection offline (FR-NC-6a) ---
|
||||
in property <bool> library-scope-pinned: false;
|
||||
in property <int> library-pin-done: 0;
|
||||
in property <int> library-pin-total: 0;
|
||||
callback library-toggle-pin-scope();
|
||||
in property <bool> library-syncing: false;
|
||||
/// TRACES: FR-CAT-3 | FR-NC-3
|
||||
/// The whole-library thumbnail pass, launched from the settings page.
|
||||
/// `library-open` gates the button on there being a library to sweep —
|
||||
/// the page is reachable from the launch screen too.
|
||||
in property <bool> library-thumbnailing: false;
|
||||
in property <bool> library-open: false;
|
||||
callback library-thumbnail-all();
|
||||
callback library-sync-now();
|
||||
callback library-rescan();
|
||||
/// Grid → launch screen, to pick a different folder or account.
|
||||
callback library-change();
|
||||
/// Merge the grid's selection: opens the page and starts the alignment.
|
||||
callback library-merge-selection();
|
||||
/// Whether a batch is running. The grid's export button becomes its cancel
|
||||
/// button; there is nowhere else a long batch can be stopped from.
|
||||
in property <bool> library-exporting: false;
|
||||
/// Export the grid's selection in the background (FR-EXP-7).
|
||||
callback library-export-selection();
|
||||
/// TRACES: NFR-ARCH-3
|
||||
callback library-cancel-export();
|
||||
}
|
||||
|
||||
// The photo roll: the grid's loaded window along the foot of the develop view.
|
||||
//
|
||||
// # Why this exists
|
||||
|
||||
Reference in New Issue
Block a user