Remember where the photographer was

Opening the application was always a fresh arrival at the beginning of
the library, whatever you had been doing when you closed it.

What is written down is the view, the scope, the rating filter and the
photograph on screen -- the open one in develop, the first visible one in
the grid. Not just a scroll position: a position without the filter that
produced it names a row of a list that no longer exists. Restoring them
has an order for the same reason -- scope, then filter, then position,
then the view -- because each step changes what an ordinal *means*.

Addressed by remote path and collection UUID, never by an ordinal or a
row id. `images.id` and `collections.id` are local to one catalog, and a
grid ordinal is local to one ordering; a record naming either would land
somewhere arbitrary on a second device and after any filter change on
this one. Where the ordinal is needed, `library::ordinal_of_path`
computes it through the grid's own `ORDER BY`, taken verbatim by a window
function rather than spelled a second time as an inequality -- which is
the mistake `grid_order_for` already warns about, and which a manually
ordered collection would make unreadable.

Every failure degrades rather than reports. A collection this device has
not merged leaves the scope at the whole library; a photograph that has
since been deleted falls back to when it was taken, which puts the grid
in the right week; a torn file yields no place and the library opens at
the top. Reopening develop is the one thing that requires an exact match,
because a canvas on a path that no longer resolves is a filename over an
empty frame.

The record lives in `dr-types` beside `Settings` and the store lives here
beside `SettingsStore`, for the reason `dr-types`' manifest gives: a JSON
serialiser in `core/` would be paid for by every crate there. Two files
and two lifetimes, though -- resetting preferences must not forget where
you were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 20:39:50 +02:00
co-authored by Claude Opus 5
parent 8bf5e13faf
commit d44bffa4a8
10 changed files with 1380 additions and 26 deletions
+1
View File
@@ -48,6 +48,7 @@ mod masks_ui;
pub mod memory;
mod net_runtime;
mod peaking;
mod place;
mod preset_store;
mod presets;
mod recovery_ui;
+274
View File
@@ -1069,6 +1069,26 @@ pub fn catalog_path(account: &Account) -> PathBuf {
data_root().join(account.namespace()).join("catalog.sqlite")
}
/// TRACES: FR-UI-8
/// Where this library's last position is remembered.
///
/// Beside the catalog, under the same account namespace, for the reason
/// `catalog_path` gives: a place belongs to one library, and two folders on one
/// disk are two libraries with two positions.
///
/// **The name matches the file that travels.** The copy on the server is
/// `place.json` under `.darkroom-derived/`, and the exchange between them is a
/// straight newest-wins swap of the same bytes — so calling the local one
/// anything else would be one more thing to keep in step for no gain.
///
/// In the data directory rather than the cache one. The consequence is milder
/// here than for the sidecars `data_root` was moved for — losing a place costs
/// a scroll, not a day of culling — but a file the system is free to delete is
/// one that would rarely survive long enough to be read.
pub fn place_path(account: &Account) -> PathBuf {
data_root().join(account.namespace()).join("place.json")
}
/// The directory every account's data hangs off.
///
/// **Not the cache directory, and on Android that distinction is the whole
@@ -4230,6 +4250,89 @@ pub fn read_ids_span(
Ok(ids)
}
/// TRACES: FR-UI-8
/// Where one photograph sits in the grid, by its remote path.
///
/// The inverse of [`read_ids_span`], and it exists for the same reason that one
/// does: an ordinal only names a photograph relative to an ordering, so it has
/// to be *computed* through the ordering the cells are drawn with rather than
/// guessed at. A restored place that landed on a row read through a different
/// `ORDER BY` would open the library at a photograph the user has never seen,
/// which looks exactly like the position having been forgotten.
///
/// # Why a window function rather than a count
///
/// The obvious implementation is "count the rows that sort before this one",
/// and it would need this file to spell the ordering out a second time — as an
/// inequality, with its own handling of the `captured_at IS NULL` term and its
/// own tie-break. [`grid_order_for`] already warns what a second spelling
/// costs, and a manually ordered collection makes it worse: that ordering is a
/// correlated subquery, and an inequality over it is not something anyone
/// should have to read.
///
/// `row_number() OVER ({order})` takes the ordering *verbatim* from the same
/// function the window read uses, so the two cannot disagree by construction.
/// It is a full pass over the scope rather than an index seek, which is the
/// cost of that guarantee — and it is paid once, at launch, against a query the
/// grid runs several times per screenful of scrolling.
///
/// `Ok(None)` means the path is not in this grid: deleted, trashed, filtered
/// out, or in a collection the place did not name. The caller falls back to
/// when the photograph was taken, which is what makes a place survive its
/// subject.
///
/// # Binding order
///
/// The window's parameters come first, unlike in [`read_ids_span`]. SQLite
/// binds anonymous `?` by their position **in the SQL text**, and here the
/// `OVER (...)` clause is in the select list — ahead of the `WHERE` the scope
/// narrows. Swapping the two silently looks up a collection by an image id.
pub fn ordinal_of_path(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
filter: &RatingFilter,
trash: bool,
path: &str,
) -> Result<Option<usize>, dr_catalog::CatalogError> {
let (inner, mut params) = if trash {
(
format!(
"SELECT i.source_ref AS sref,
row_number() OVER ({TRASH_ORDER}) - 1 AS ord
FROM images i
WHERE {TRASHED}"
),
Vec::new(),
)
} else {
let (clause, scope_params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
let folded = uncollapsed("i");
let (order, mut params) = grid_order_for(catalog, scope);
// The window first, then the scope — see the note above.
params.extend(scope_params);
(
format!(
"SELECT i.source_ref AS sref,
row_number() OVER ({order}) - 1 AS ord
FROM images i
WHERE {VISIBLE}{rated}{folded}{clause}"
),
params,
)
};
params.push(rusqlite::types::Value::Text(path.to_string()));
let mut stmt = catalog
.connection()
.prepare(&format!("SELECT ord FROM ({inner}) WHERE sref = ?"))?;
let mut rows = stmt.query(rusqlite::params_from_iter(params.iter()))?;
match rows.next()? {
Some(r) => Ok(Some(r.get::<_, i64>(0)?.max(0) as usize)),
None => Ok(None),
}
}
/// The columns every windowed read selects, in the order [`row_to_cell`] reads
/// them.
///
@@ -4830,6 +4933,177 @@ mod tests {
}
}
/// The fixture the ordinal tests share: a mix of dated, undated, shadowed
/// and trashed rows, which is what makes the grid's ordering non-obvious.
fn a_small_library() -> Catalog {
let catalog = Catalog::in_memory().unwrap();
let c = catalog.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
for (id, name, captured, shadow, trashed) in [
(1i64, "2019/a.CR2", Some(100i64), None, None),
(2, "2019/b.CR2", Some(200), None, None),
// Shadowed by its RAW sibling: never a row of the grid.
(3, "2019/b.JPG", Some(200), Some(2i64), None),
(4, "2020/c.CR2", Some(300), None, None),
// Undated sorts last, whatever its name.
(5, "2018/d.CR2", None, None, None),
// Trashed: out of the library, and the only row of the trash.
(6, "2020/e.CR2", Some(400), None, Some(9i64)),
] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, shadowed_by,
trashed_at, added_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, 0)",
rusqlite::params![id, name, captured, shadow, trashed],
)
.unwrap();
}
catalog
}
#[test]
fn every_cell_reports_the_ordinal_it_is_drawn_at() {
// TRACES: FR-UI-8
// The invariant the whole restore rests on, stated the strongest way
// there is: walk the window the grid actually draws and ask for each
// cell's ordinal by path. Anything less — a spot check, or a hand-built
// expectation — would pass while `ordinal_of_path` and `read_cells`
// disagreed about undated rows, shadowed rows or the tie-break, which
// is precisely where an ordering drifts.
let catalog = a_small_library();
let filter = RatingFilter::default();
let cells = read_cells(&catalog, 0, 100).unwrap();
assert_eq!(cells.len(), 4, "shadowed and trashed are not rows");
for (i, cell) in cells.iter().enumerate() {
assert_eq!(
ordinal_of_path(&catalog, None, &filter, false, &cell.remote_path).unwrap(),
Some(i),
"{} is drawn at row {i}",
cell.remote_path
);
}
}
#[test]
fn a_photograph_that_is_not_in_the_grid_reports_nothing() {
// Deleted, never scanned, or hidden behind its RAW. All three are the
// same answer, and the caller falls back to the capture time — which is
// only reachable if this says `None` rather than guessing.
let catalog = a_small_library();
let filter = RatingFilter::default();
assert_eq!(
ordinal_of_path(&catalog, None, &filter, false, "2019/gone.CR2").unwrap(),
None,
"never heard of it"
);
assert_eq!(
ordinal_of_path(&catalog, None, &filter, false, "2019/b.JPG").unwrap(),
None,
"shadowed by its RAW"
);
assert_eq!(
ordinal_of_path(&catalog, None, &filter, false, "2020/e.CR2").unwrap(),
None,
"trashed"
);
}
#[test]
fn the_trash_is_ordinalled_through_its_own_list() {
// The trash orders by deletion time and lists exactly what the library
// excludes, so an ordinal taken through the library's ordering would
// name a different photograph — or, here, nothing at all.
let catalog = a_small_library();
let filter = RatingFilter::default();
assert_eq!(
ordinal_of_path(&catalog, None, &filter, true, "2020/e.CR2").unwrap(),
Some(0)
);
assert_eq!(
ordinal_of_path(&catalog, None, &filter, true, "2019/a.CR2").unwrap(),
None,
"a live photograph is not in the trash"
);
}
#[test]
fn a_filter_moves_the_ordinal_with_the_grid() {
// TRACES: FR-UI-8
// A place carries the filter it was recorded under *and* the position,
// and the second is only meaningful under the first. Restoring them in
// the wrong order — position, then filter — would land on a row of a
// list that no longer exists, which is the bug this pairing exists to
// rule out.
let catalog = a_small_library();
let c = catalog.connection();
// Three stars on the third photograph only.
c.execute(
"INSERT INTO versions(id, image_id, uuid, name, is_default, rating)
VALUES (1, 4, 'u4', 'default', 1, 3)",
[],
)
.unwrap();
let unfiltered = RatingFilter::default();
assert_eq!(
ordinal_of_path(&catalog, None, &unfiltered, false, "2020/c.CR2").unwrap(),
Some(2)
);
let starred = RatingFilter {
min_rating: 3,
..RatingFilter::default()
};
assert_eq!(
ordinal_of_path(&catalog, None, &starred, false, "2020/c.CR2").unwrap(),
Some(0),
"the only survivor of the filter is the first row of it"
);
assert_eq!(
ordinal_of_path(&catalog, None, &starred, false, "2019/a.CR2").unwrap(),
None,
"filtered out, so it has no position in this grid"
);
}
#[test]
fn a_collection_is_ordinalled_through_its_own_scope() {
// The window's parameters are bound ahead of the scope's — see the note
// on `ordinal_of_path`. Getting that order wrong looks up a collection
// by an image id, which fails silently as "not in this grid".
let catalog = a_small_library();
let c = catalog.connection();
let coll =
dr_catalog::collections::create(c, "Trip", None, dr_catalog::CollectionKind::Manual)
.unwrap();
// The second and third photographs, out of order, so position matters.
dr_catalog::collections::add_images(c, coll, &[dr_types::ImageId(4), dr_types::ImageId(2)])
.unwrap();
let filter = RatingFilter::default();
let cells = read_cells_scoped(&catalog, Some(coll), &filter, 0, 100).unwrap();
assert_eq!(cells.len(), 2);
for (i, cell) in cells.iter().enumerate() {
assert_eq!(
ordinal_of_path(&catalog, Some(coll), &filter, false, &cell.remote_path).unwrap(),
Some(i),
"{} is drawn at row {i} of the collection",
cell.remote_path
);
}
assert_eq!(
ordinal_of_path(&catalog, Some(coll), &filter, false, "2019/a.CR2").unwrap(),
None,
"not a member"
);
}
#[test]
fn the_thumbnail_pass_asks_only_for_what_is_missing() {
// The work list is the whole point of the pass being resumable and of
+470 -2
View File
@@ -192,6 +192,46 @@ pub struct LibraryController {
/// and read here. Shared rather than passed per call because a rescan, a
/// scrub and a drop all reload the window and must all honour it.
scope: RefCell<Option<dr_types::CollectionId>>,
/// TRACES: FR-UI-8
/// How to open a photograph in develop.
///
/// Held so a restored place that was left in develop can put the user back
/// there. The closure is the grid's own — `wire` is handed it and stashes a
/// clone — rather than a second route into the develop view, which would be
/// a second place for "persist the outgoing edit first" to be forgotten.
open_image: RefCell<Option<OpenImage>>,
/// Where this library's position is written, once one is open.
///
/// `None` before a library has been opened, and on the local-files path,
/// which has no library to have a position in.
place_store: RefCell<Option<crate::place::PlaceStore>>,
/// What this device calls itself in a place it writes.
///
/// [`dr_thumbs::ThumbStore::client_id`], so the place and the thumbnail
/// shards a device publishes carry the same name — which is what makes
/// `.darkroom-derived/` readable by a human wondering which machine put
/// what there. Informational only: see [`crate::place`] on why the
/// timestamp, not the device, decides an exchange.
place_device: RefCell<String>,
/// Coalesces the writes a flick would otherwise make one of per event.
place_timer: RefCell<Option<slint::Timer>>,
/// Whether a place is being applied right now.
///
/// A restore moves the scope, the filter and the viewport, and every one of
/// those is something [`note_place`] otherwise treats as the photographer
/// having moved. Without this a restore would close its own handover latch
/// and, worse, write the record back out under a fresh timestamp — so the
/// device that had just *adopted* a place from the tablet would claim to be
/// the newer of the two.
applying_place: std::cell::Cell<bool>,
/// Whether the photographer has moved since this library was opened.
///
/// The latch that decides whether a place arriving from another device may
/// still be applied. A handover is only welcome before the user has started
/// working: a grid that jumped somewhere else *while being scrolled*, because
/// a network round-trip finally landed, would be worse than never handing
/// over at all.
place_untouched: std::cell::Cell<bool>,
/// TRACES: FR-CAT-15
/// Whether the grid is listing the trash rather than the library.
///
@@ -388,6 +428,12 @@ impl LibraryController {
sync_timer: RefCell::new(None),
session: RefCell::new(None),
scope: RefCell::new(None),
open_image: RefCell::new(None),
place_store: RefCell::new(None),
place_device: RefCell::new(String::new()),
place_timer: RefCell::new(None),
place_untouched: std::cell::Cell::new(true),
applying_place: std::cell::Cell::new(false),
viewing_trash: std::cell::Cell::new(false),
coll_ctl: RefCell::new(None),
timeline_zoom: RefCell::new(0),
@@ -806,6 +852,11 @@ impl LibraryController {
/// or a drop that altered the collection being shown.
pub fn reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
load_window(window, ctl);
// TRACES: FR-UI-8
// The scope changed, or the collection under it did. Either way this is the
// entry point every such change comes through, which is why the record is
// taken here rather than at each of the callers.
note_place(window, ctl);
}
/// Open a library: show the grid, start a scan, then fill in thumbnails.
@@ -834,6 +885,18 @@ pub fn open(
let filter = account.format_filter();
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
// TRACES: FR-UI-8
// Where this library's position is kept, and what this device signs it
// with. Before the catalog opens, because `adopt_catalog` is what reads the
// record back and it is the next thing to run.
*ctl.place_store.borrow_mut() = Some(crate::place::PlaceStore::open_at(library::place_path(
&conn.account,
)));
*ctl.place_device.borrow_mut() = device_id(&conn.account);
// A fresh library is one nobody has moved in yet, so a place arriving from
// another device may still be applied — see `place_untouched`.
ctl.place_untouched.set(true);
window.set_show_library(true);
window.set_library_open(true);
window.set_library_scanning(true);
@@ -2028,6 +2091,14 @@ fn open_catalog_for_offline(
}
}
/// TRACES: FR-UI-8
/// The grid's own route into the develop view, as something that can be held.
///
/// Named because it is threaded through the controller as well as passed to
/// `wire`, and a `RefCell<Option<Rc<dyn Fn(String)>>>` written out twice is a
/// signature nobody reads.
type OpenImage = Rc<dyn Fn(String)>;
/// How long a run of geometry changes has to stop for before the window is
/// reloaded against it.
///
@@ -2208,7 +2279,43 @@ fn adopt_catalog(
// membership — the same order the scan's completion uses.
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
*ctl.catalog.borrow_mut() = Some(cat);
load_window(window, ctl);
// TRACES: FR-UI-8
// Where the photographer was, before the first window is read rather than
// after. The scope and the filter both change what the grid's query
// returns, so restoring them afterwards would mean reading the top of the
// whole library, drawing it, and then reading again — a visible jump on
// every launch, and on a remote library a screenful of thumbnails fetched
// for photographs nobody asked to see.
//
// The sidebar is refreshed above rather than below for the same ordering
// reason: a scope is restored by naming a row of a tree that has to exist.
let stored = ctl.place_store.borrow().as_ref().and_then(|s| s.load());
match stored {
Some(place) => apply_place(window, ctl, coll_ctl, &place),
None => load_window(window, ctl),
}
}
/// TRACES: FR-UI-8
/// What this device signs a place with.
///
/// The thumbnail store's client id, which is already this device's name among
/// the clients sharing a library — it is what qualifies a shard's remote
/// filename. Reusing it means the place and the shards a machine publishes
/// carry one name in `.darkroom-derived/`, rather than two that have to be
/// correlated by hand.
///
/// Empty where the store will not open. The field is informational, so an
/// unsigned place is worth strictly more than no place at all.
fn device_id(account: &Account) -> String {
match dr_thumbs::ThumbStore::open(&library::thumbs_dir(account)) {
Ok(store) => store.client_id().to_string(),
Err(e) => {
log::debug!("no device id for the place record: {e}");
String::new()
}
}
}
/// Drop the open catalog, so the next `show_catalog_now` opens the file
@@ -4806,6 +4913,338 @@ fn report_position(window: &AppWindow, ctl: &Rc<LibraryController>, row: usize)
window.set_total(window.get_library_total());
}
// --- where the photographer was (FR-UI-8) --------------------------------
/// How long a movement has to settle before it is written down.
///
/// A flick reports a scroll several times per screenful, and a place is a file
/// write — one per event would put disk I/O inside the gesture NFR-P5 governs,
/// for a record only the *last* of which is ever read. Longer than
/// [`GEOMETRY_SETTLE`] because nothing on screen waits for this: the reload a
/// geometry change schedules is visible, and this is not.
const PLACE_SETTLE: std::time::Duration = std::time::Duration::from_millis(750);
/// The photographer moved. Write it down, once they stop.
///
/// Replacing the timer rather than adding one is what makes this a debounce:
/// a `slint::Timer` is stopped by being dropped, so the previous pending write
/// goes with the old handle.
fn note_place(window: &AppWindow, ctl: &Rc<LibraryController>) {
if ctl.applying_place.get() {
// A restore is not a movement — see `applying_place`.
return;
}
// The handover latch closes on the first movement — see `place_untouched`.
ctl.place_untouched.set(false);
if ctl.place_store.borrow().is_none() {
return;
}
let timer = slint::Timer::default();
let weak = window.as_weak();
let held = ctl.clone();
timer.start(slint::TimerMode::SingleShot, PLACE_SETTLE, move || {
if let Some(w) = weak.upgrade() {
write_place(&w, &held);
}
});
*ctl.place_timer.borrow_mut() = Some(timer);
}
/// Write the place out now, without waiting for a settle.
///
/// For the moments there may be no "later": leaving the grid for develop, and
/// coming back. Both are single deliberate acts rather than runs of events, and
/// both change the one field a debounce is most likely to lose — which view the
/// user is in.
fn write_place(window: &AppWindow, ctl: &Rc<LibraryController>) {
if ctl.applying_place.get() {
return;
}
// Opening a photograph is as much "the photographer has started" as
// scrolling is, and this is the path that records it — so the handover
// latch closes here too, not only in `note_place`. Without it a record
// arriving from another device could pull someone out of the image they had
// just opened.
ctl.place_untouched.set(false);
let Some(place) = current_place(window, ctl) else {
return;
};
if let Some(store) = ctl.place_store.borrow().as_ref() {
store.save(&place);
}
}
/// Where the photographer is, as a record that can be written down.
///
/// `None` where there is nothing worth recording: no library open, or the
/// launch screen in front of everything. A place is never written from the
/// launch screen — the user is *choosing* a library there, and recording "you
/// were at the launch screen" would make the next launch skip the position it
/// had just been asked to keep.
fn current_place(window: &AppWindow, ctl: &Rc<LibraryController>) -> Option<dr_types::Place> {
use dr_types::{Place, PlaceScope, Screen};
if window.get_show_launch() || !window.get_library_open() {
return None;
}
// Which view, and — the same question in both — which photograph.
//
// In the grid it is the first one visible, which `resume_at` already
// tracks; in develop it is the open one, which `report_position` keeps
// `index` holding. Both are library ordinals against the same ordering, so
// the lookup below is one piece of code rather than two.
let in_library = window.get_show_library();
let at = if in_library {
ctl.resume_at.get()
} else {
window.get_index().max(0) as usize
};
// Resolved through the loaded window rather than by a query: the model has
// the path and the capture time side by side already, and this runs on a
// settle after scrolling — the one moment a query is least welcome.
let offset = *ctl.offset.borrow();
let row = at.checked_sub(offset);
let path = row
.and_then(|r| ctl.paths.borrow().get(r).cloned())
.unwrap_or_default();
let captured_at = row.and_then(|r| ctl.captured_at.borrow().get(r).copied().flatten());
// The scope, in the vocabulary both devices share. A collection this device
// cannot name is recorded as the whole library rather than as a name
// nothing can resolve — see `collections::uuid_of`.
let scope = if ctl.viewing_trash.get() {
PlaceScope::Trash
} else {
match *ctl.scope.borrow() {
None => PlaceScope::Library,
Some(id) => {
let borrow = ctl.catalog.borrow();
let uuid = borrow
.as_ref()
.and_then(|c| dr_catalog::collections::uuid_of(c.connection(), id).ok())
.flatten();
match uuid {
Some(uuid) => PlaceScope::Collection { uuid },
None => PlaceScope::Library,
}
}
}
};
Some(Place {
at: library::now_secs(),
device: ctl.place_device.borrow().clone(),
screen: if in_library {
Screen::Library
} else {
Screen::Develop
},
scope,
path,
captured_at,
filter: (&*ctl.filter.borrow()).into(),
})
}
/// Put the photographer back where a record says they were.
///
/// # The order is the whole function
///
/// Scope, then filter, then position, then — last of all — the view. Each step
/// changes what an ordinal *means*: the grid is a window over a query, and a
/// row resolved before the scope was narrowed names a photograph in a different
/// list. Resolving the path last is what makes the restored position address the
/// grid the user is actually about to see.
///
/// # Nothing here is allowed to fail loudly
///
/// Every step degrades to "the state we would have been in anyway". A collection
/// this device has not merged yet leaves the scope at the whole library; a
/// photograph that has been deleted falls back to when it was taken; a capture
/// time that resolves to nothing leaves the grid at the top. A place is a
/// convenience, and an application that would not open because one was stale
/// would be trading something that matters for something that does not.
pub(crate) fn apply_place(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
place: &dr_types::Place,
) {
use dr_types::{PlaceScope, Screen};
ctl.applying_place.set(true);
// --- the scope --------------------------------------------------------
//
// Through the same callback the sidebar fires, rather than by setting the
// two controllers' fields by hand. Both of them hold a scope, and so does
// the header's label and the reorderable flag; there is exactly one piece
// of code that keeps those four in step and this is not the place to write
// a second.
let selected = match &place.scope {
PlaceScope::Library => 0,
PlaceScope::Trash => -1,
PlaceScope::Collection { uuid } => {
let borrow = ctl.catalog.borrow();
let found = borrow
.as_ref()
.and_then(|c| dr_catalog::collections::id_for_uuid(c.connection(), uuid).ok())
.flatten();
drop(borrow);
match found {
Some(id) => id.0 as i32,
// Recorded on another device, and this one has not merged the
// collection yet. The whole library is the honest fallback: an
// empty grid under a name nothing can resolve reads as data
// loss.
None => {
log::info!("the stored place names a collection this device does not have yet");
0
}
}
}
};
window.invoke_collection_select(selected);
// --- the filter -------------------------------------------------------
*ctl.filter.borrow_mut() = (&place.filter).into();
push_filter(window, ctl);
// --- the position -----------------------------------------------------
let resolved = resolve_place(window, ctl, 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 resolved = resolved.filter(|(at, _)| *at < total);
match resolved {
Some((at, _)) => {
ctl.resume_at.set(at);
// Moves the capture-time marker with it — see `resume_position`.
resume_position(window, ctl, coll_ctl, None);
}
None => refilter(window, ctl),
}
// --- and the view -----------------------------------------------------
//
// Only where the photograph itself was found. A capture time that merely
// landed in the right week is a good answer for the grid and no answer at
// all for a canvas: develop would open on a path that no longer resolves
// and show a filename over an empty frame.
if place.screen == Screen::Develop {
if let Some((at, Found::Photograph)) = resolved {
if let Some(open) = ctl.open_image.borrow().clone() {
window.set_show_library(false);
window.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);
}
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());
}
}
}
// Last, so nothing between here and the top is mistaken for the
// photographer having moved.
ctl.applying_place.set(false);
}
/// The library ordinal a record names, or `None` if it names nothing here.
///
/// Two ways, and the second is why a place survives its subject. The path is
/// exact and is tried first. Failing that — the photograph was deleted, moved,
/// or filtered out by the very filter this place restored — the capture time
/// puts the grid in the right week, which is close enough that the user
/// recognises where they are.
fn resolve_place(
window: &AppWindow,
ctl: &Rc<LibraryController>,
place: &dr_types::Place,
) -> Option<(usize, Found)> {
let borrow = ctl.catalog.borrow();
let catalog = borrow.as_ref()?;
if !place.path.is_empty() {
match library::ordinal_of_path(
catalog,
*ctl.scope.borrow(),
&ctl.filter.borrow(),
ctl.viewing_trash.get(),
&place.path,
) {
Ok(Some(at)) => return Some((at, Found::Photograph)),
Ok(None) => {}
Err(e) => log::debug!("resolving the stored place: {e}"),
}
}
let when = place.captured_at?;
drop(borrow);
// Through the scrubber's own route, so "an instant, as an ordinal" has one
// spelling. It sets the viewport and the loaded window together, which is
// exactly what a restore needs, and it anchors the timeline marker on the
// way.
scrub_to(window, ctl, when);
// Where it landed, read back rather than recomputed: `scrub_to` sets the
// offset to the ordinal it resolved and `load_window` then clamps it against
// the end of the library, so the clamped value is the one the grid is
// actually showing.
Some((*ctl.offset.borrow(), Found::Thereabouts))
}
/// How exactly a place was resolved.
///
/// The distinction is what stops a restore reopening develop on the wrong
/// photograph. Landing in the right week is a perfectly good answer for a grid
/// — the user recognises where they are — and no answer at all for a canvas,
/// which would open on a path that no longer resolves and show a filename over
/// an empty frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Found {
/// The photograph itself is still in this grid, at this ordinal.
Photograph,
/// It is not, so this is where its capture time falls instead.
Thereabouts,
}
/// Put the whole rating filter on screen.
///
/// The filter bar is drawn from half a dozen properties, each of which is set
/// where its own control is handled — which is right while the user is moving
/// them one at a time, and leaves nothing that can restore all of them at once.
/// A restored place that narrowed the grid without moving the stars would show
/// a filtered library under a bar claiming it was not filtered, and the user's
/// 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);
// 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);
ctl.local_only.set(filter.local_only);
push_people_chips(window, ctl);
}
/// Bring the grid back to where the photographer left it, and — coming out of
/// develop — put the keyboard cursor on the photograph they were editing.
///
@@ -4952,7 +5391,12 @@ pub fn wire<F>(
// Shared rather than moved: a click and `Return` both open an image, and
// they are two callbacks.
let on_open_image = Rc::new(on_open_image);
let on_open_image: OpenImage = Rc::new(on_open_image);
// TRACES: FR-UI-8
// And a third caller, which is not a callback at all: a place recorded in
// develop reopens it at launch. Through the same closure, so the edit-saving
// and identity bookkeeping it does are not something a restore can skip.
*ctl.open_image.borrow_mut() = Some(on_open_image.clone());
{
let weak = window.as_weak();
@@ -4988,6 +5432,11 @@ pub fn wire<F>(
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);
}
});
}
@@ -5012,6 +5461,10 @@ pub fn wire<F>(
w.set_library_roll_current(i);
on_open_image(path);
report_position(&w, &ctl, i as usize);
// TRACES: FR-UI-8
// Debounced, unlike the two entries into develop: the roll is walked
// frame by frame and every step is a different photograph.
note_place(&w, &ctl);
});
}
@@ -5056,6 +5509,7 @@ pub fn wire<F>(
w.set_library_roll_centre(true);
on_open(path);
report_position(&w, &ctl, row.unwrap_or(0));
write_place(&w, &ctl);
}
});
}
@@ -5270,6 +5724,11 @@ pub fn wire<F>(
// 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);
// The same position where a rebuilt grid will look for it.
//
// `scroll-to` is read by `seek()`, which runs on a `scroll-token`
@@ -5488,6 +5947,10 @@ pub fn wire<F>(
resume_position(&w, &ctl, &coll_ctl, Some(open));
w.set_show_library(true);
// TRACES: FR-UI-8
// After the flag, not before: `current_place` reads it to say which
// view the record is of, and this is the moment it becomes the grid.
write_place(&w, &ctl);
});
}
@@ -6206,6 +6669,11 @@ fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
*ctl.offset.borrow_mut() = 0;
ctl.requested.borrow_mut().clear();
load_window(window, ctl);
// TRACES: FR-UI-8
// A narrowed grid is part of where you are: coming back to five-star frames
// of one person and finding the whole library is the same loss of place as
// coming back to the top of it.
note_place(window, ctl);
}
fn stop(slot: &RefCell<Option<slint::Timer>>) {
+300
View File
@@ -0,0 +1,300 @@
//! TRACES: FR-UI-8
//! Reading and writing the place — where the photographer was.
//!
//! The record itself is [`dr_types::Place`], which is where the reasoning about
//! what a place *is* lives: why it is not a setting, why it names photographs by
//! remote path and collections by UUID rather than by any local integer, and why
//! the newer of two records simply wins. This module is the half that touches a
//! disk, split off for the reason `dr-types`' manifest gives about
//! `settings.json`: a JSON serialiser in `core/` would be paid for by every
//! crate there.
//!
//! A near-twin of [`crate::settings_store`] in shape, and deliberately not an
//! extension of it. Two files, two lifetimes — signing out forgets a session and
//! must not discard a cache budget; resetting preferences must not forget where
//! you were.
use std::path::PathBuf;
use dr_types::{Place, StoredFilter};
use crate::library::{PeopleMode, RatingFilter};
impl From<&RatingFilter> for StoredFilter {
fn from(f: &RatingFilter) -> Self {
Self {
min_rating: f.min_rating,
unjudged: f.unjudged,
flag: f.flag,
local_only: f.local_only,
captured_from: f.captured_from,
captured_to: f.captured_to,
people: f.people.clone(),
people_all: matches!(f.people_mode, PeopleMode::All),
}
}
}
impl From<&StoredFilter> for RatingFilter {
fn from(s: &StoredFilter) -> Self {
Self {
min_rating: s.min_rating,
unjudged: s.unjudged,
flag: s.flag,
local_only: s.local_only,
captured_from: s.captured_from,
captured_to: s.captured_to,
people: s.people.clone(),
people_mode: if s.people_all {
PeopleMode::All
} else {
PeopleMode::Any
},
}
}
}
/// Parse a record as it travels — the body of the file, local or remote.
///
/// `None` rather than an error for anything unreadable. A place is advisory:
/// the only thing lost by discarding one is that the grid opens at the top, and
/// an application that refused to start because a position file was truncated
/// would be trading a certainty for an inconvenience. The failure is logged so
/// it is not silent.
pub fn from_bytes(bytes: &[u8], whence: &str) -> Option<Place> {
match serde_json::from_slice::<Place>(bytes) {
Ok(p) => Some(p),
Err(e) => {
log::warn!("{whence} is not a readable place ({e}); ignoring it");
None
}
}
}
/// The bytes that travel, local file and server copy alike.
pub fn to_bytes(place: &Place) -> Result<Vec<u8>, serde_json::Error> {
serde_json::to_vec_pretty(place)
}
/// Loads and saves the place, beside the catalog it belongs to.
///
/// One file per library rather than one file holding every library's place,
/// because that is how everything else per-account is stored — the catalog, the
/// thumbnail shards, the un-uploaded sidecars all hang off
/// `Account::namespace()` — and because the file that travels to the server has
/// to be exactly one library's anyway. A map would need splitting on the way
/// out and merging on the way in, for no gain.
///
/// In the *data* directory, not the cache one. `library::place_path` explains
/// why that distinction is load-bearing on Android; here it matters less —
/// losing a place costs a scroll — but a file that sat in a directory the
/// system deletes at will would be one that never survived to be useful.
pub struct PlaceStore {
path: PathBuf,
}
impl PlaceStore {
pub fn open_at(path: PathBuf) -> Self {
Self { path }
}
/// The stored place, or `None` if there is not one.
///
/// A missing file is a first run, not a failure, and neither is an
/// unparseable one — see [`from_bytes`].
pub fn load(&self) -> Option<Place> {
match std::fs::read(&self.path) {
Ok(bytes) => from_bytes(&bytes, &self.path.display().to_string()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
log::warn!("reading {}: {e}", self.path.display());
None
}
}
}
/// Write it, creating the directory if the account is new.
///
/// Errors are logged and swallowed. This is called from a scroll settling,
/// and a photographer cannot act on "the place could not be written" — the
/// next scroll writes it again, and if the disk is genuinely gone there are
/// louder failures already on screen.
pub fn save(&self, place: &Place) {
if let Some(dir) = self.path.parent() {
if let Err(e) = std::fs::create_dir_all(dir) {
log::warn!("creating {}: {e}", dir.display());
return;
}
}
let bytes = match to_bytes(place) {
Ok(b) => b,
Err(e) => {
log::warn!("serialising the place: {e}");
return;
}
};
if let Err(e) = std::fs::write(&self.path, bytes) {
log::warn!("writing {}: {e}", self.path.display());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::{PlaceScope, Screen};
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("darkroom-place-test-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn a_place(at: i64) -> Place {
Place {
at,
device: "desktop".into(),
screen: Screen::Develop,
scope: PlaceScope::Collection {
uuid: "0d1f-…-9c".into(),
},
path: "2019/2019-04-03/DSC_1234.NEF".into(),
captured_at: Some(1_554_300_000),
filter: StoredFilter {
min_rating: 3,
unjudged: false,
flag: Some(dr_types::FlagState::Pick),
local_only: true,
captured_from: Some(1),
captured_to: Some(2),
people: vec![7, 9],
people_all: true,
},
}
}
#[test]
fn round_trips_through_the_file() {
let store = PlaceStore::open_at(dir("round-trip").join("place.json"));
let place = a_place(1000);
store.save(&place);
assert_eq!(store.load(), Some(place));
}
#[test]
fn a_missing_file_is_a_first_run() {
let store = PlaceStore::open_at(dir("missing").join("place.json"));
assert_eq!(store.load(), None);
}
#[test]
fn the_directory_is_created_on_the_way_out() {
// A first launch against a new account has no data directory yet, and
// the place is written before anything else in it has been.
let store = PlaceStore::open_at(dir("mkdir").join("deep").join("er").join("place.json"));
store.save(&a_place(1));
assert!(store.load().is_some());
}
#[test]
fn a_torn_file_is_ignored_rather_than_fatal() {
let path = dir("torn").join("place.json");
std::fs::write(&path, b"{\"at\": 12, \"screen\":").unwrap();
let store = PlaceStore::open_at(path);
assert_eq!(store.load(), None, "and the app opens at the top instead");
}
#[test]
fn a_file_from_a_newer_build_keeps_what_it_can() {
// The additive-change contract: a field this build does not know must
// not cost it the fields it does.
let path = dir("newer").join("place.json");
std::fs::write(
&path,
br#"{"at": 42, "path": "a/b.NEF", "zoom_level": 3, "screen": "develop"}"#,
)
.unwrap();
let got = PlaceStore::open_at(path).load().expect("still readable");
assert_eq!(got.at, 42);
assert_eq!(got.path, "a/b.NEF");
assert_eq!(got.screen, Screen::Develop);
}
#[test]
fn a_file_from_an_older_build_defaults_what_is_missing() {
let path = dir("older").join("place.json");
std::fs::write(&path, br#"{"at": 42, "path": "a/b.NEF"}"#).unwrap();
let got = PlaceStore::open_at(path).load().expect("still readable");
assert_eq!(got.screen, Screen::Library, "the safe view to arrive in");
assert_eq!(got.scope, PlaceScope::Library);
assert_eq!(got.filter, StoredFilter::default());
}
#[test]
fn the_newer_record_wins_in_both_directions() {
let older = a_place(100);
let newer = a_place(200);
assert!(newer.supersedes(Some(&older)));
assert!(!older.supersedes(Some(&newer)));
assert!(older.supersedes(None), "anything beats nothing");
}
#[test]
fn an_equal_timestamp_settles_rather_than_ping_pongs() {
// Two idle devices re-reading the same record must not each decide they
// have something to say.
let place = a_place(100);
assert!(!place.supersedes(Some(&place.clone())));
}
#[test]
fn the_filter_survives_the_projection() {
// The record and the query must narrow the grid by the same thing, or a
// restored place shows a different set than the one that was left.
let original = RatingFilter {
min_rating: 4,
unjudged: true,
flag: Some(dr_types::FlagState::Reject),
local_only: true,
captured_from: Some(10),
captured_to: Some(20),
people: vec![3, 5, 8],
people_mode: PeopleMode::All,
};
let back: RatingFilter = (&StoredFilter::from(&original)).into();
assert_eq!(back, original);
let empty = RatingFilter::default();
let back: RatingFilter = (&StoredFilter::from(&empty)).into();
assert_eq!(back, empty);
assert!(back.is_unfiltered());
}
#[test]
fn a_scope_travels_as_a_uuid() {
// The guard against someone reaching for `collections.id`, which the
// schema calls local and colliding across devices.
let json = serde_json::to_string(&PlaceScope::Collection {
uuid: "abc-123".into(),
})
.unwrap();
assert!(json.contains("abc-123"), "{json}");
assert_eq!(
serde_json::from_str::<PlaceScope>(&json).unwrap(),
PlaceScope::Collection {
uuid: "abc-123".into()
}
);
}
#[test]
fn the_trash_is_its_own_scope() {
let json = serde_json::to_string(&PlaceScope::Trash).unwrap();
assert_eq!(
serde_json::from_str::<PlaceScope>(&json).unwrap(),
PlaceScope::Trash
);
}
}