8eeb9ba0f6112081a2b25ccffde1bdd0a2ad0361
70
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8eeb9ba0f6 |
Offer the backup, and then the rebuild, when the index turns out to be damaged
NFR-R6 asks for an integrity check at startup and two offers behind it, and none of it existed. `PRAGMA integrity_check` appeared nowhere in the tree, `Catalog::open` was `open` → `configure` → `migrate` → `backfill` and nothing else, and corruption therefore surfaced as whatever rusqlite error the first unlucky query happened to produce — "database disk image is malformed" attached to a thumbnail refresh, elided into a 34px banner, over an empty grid saying "No images found · Check the library folder". Two messages that disagreed, and no way forward but deleting catalog.sqlite by hand. The property that makes the second offer real was already here and load- bearing: the catalog is an index, not a source of truth, rebuildable from sources plus sidecars (invariant §5.2.4, cited by schema.rs, trash.rs and lib.rs). And sync.rs already knew how to take a coherent snapshot of a WAL database. What was missing was the check, the type, and the conversation. Four pieces: **The type.** `CatalogError::Corrupt`, and — the part that makes it worth having — a hand-written `From<rusqlite::Error>` that classifies rather than wraps. `SQLITE_CORRUPT` and `SQLITE_NOTADB` become `Corrupt` wherever they arise, so a background job that trips over the damage first reports the same thing the startup check would have. `SQLITE_IOERR` and `SQLITE_BUSY` deliberately do not: a dropped network mount is a different problem, and telling someone to rebuild their index would be a wrong answer delivered confidently. **The check.** `Catalog::open_verified`, `quick_check` before the open rather than after, because opening runs migrations and a damaged catalog with an intact header would otherwise have structure rewritten on top of structure that is already wrong. Bound to `open_verified` and not to `open`: the check reads every page, which is affordable once at startup where a user can answer a question, and not affordable on the dozens of opens a session's background tasks make. **The backup.** NFR-R2's second clause, taken between `configure` and `migrate` in `Catalog::open`. A migration is the one routine operation that rewrites table structure, so it is the likeliest way this file becomes unreadable, and it is the last moment the pre-migration state exists to be copied. Three generations, through SQLite's backup API after a TRUNCATE checkpoint — never `fs::copy`, which on a WAL database backs up a state older than the catalog and possibly torn. A failure to take the copy is logged, not raised: a full disk must not be what makes a library unopenable. **The conversation.** The first line of the dialogue is that the photographs and the edits are safe, before the diagnosis, because that is the question the user is actually asking. Then the two offers, which are *not* interchangeable and are not presented as if they were: a restore keeps collections, and a rebuild cannot, because a manual collection is a set of images assembled by hand and nothing in the filesystem records it (docs/catalog.md §8.1). The labels say so, and the rebuild does not take the affirmative styling while a restore is on the table. One thing that is a fix rather than a feature: `show_catalog_now` now gates the scan. `Catalog::open` succeeds on a file whose header survived, so the scan that used to start immediately afterwards would write folder ETags and image rows into damaged pages in the seconds while the user was still reading the question — turning a file that had a backup into one where the backup is the only copy left. Restore also deletes the damaged catalog's `-wal` and `-shm`. That step is easy to leave out and fatal to leave out: a journal belonging to the old file, sitting beside the new one under the same name, is replayed into it on the next open. That is not a restore, it is a fresh corruption with the evidence gone. Tested by corrupting a fixture catalog — 500 images and a collection, then every page past the second overwritten — and driving both branches. The restore is asserted on the collection, because a collection is precisely what distinguishes the two paths; the rebuild on the damaged file being kept and the next open producing an empty catalog at the current schema. Plus the `SQLITE_NOTADB` presentation, a damaged backup being refused rather than installed, and a v1 catalog whose pre-migration backup comes back reading v1 rather than v11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9e519eb8a6 |
Make the thin ring the only mark a selected photograph carries
Two treatments said "selected" and neither said it well. A selected cell got a 2px border and a lifted fill. The border is drawn on the outside of a cell whose content sits 6px in, so it ate into the thumbnail: selecting appeared to nudge the photograph. Inside it, a second thin ring marked the anchor — the end a shift-click measures from — and that ring was the clearest thing on the cell, so it read as *the* selection to everyone who had not written it. Worse, the ring outlived what it described. An anchor survives a deselection, so a thin box sat around the last photograph touched with nothing selected at all, indistinguishable from a cell that had stayed behind. That is the one thing a selection cue must never be: ambiguous about whether something is selected. So the ring is now what it already looked like. One mark, drawn inside the cell and 4px clear of its edge, so it never touches the thumbnail and never changes a dimension — selecting adds ink and moves nothing. Two pixels rather than one, because it is now carrying the whole cue across forty cells at arm's length against a thumbnail of any brightness. The outer border is hover alone. The anchor keeps no mark, and loses nothing it was earning: the bar says "Tap the last photograph" while a range is armed, which answers the question the ring existed to answer. The ordinal still lives in the controller and still decides where a range extends from; what is gone is the claim that the user needs to see it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f41edc03ff |
Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
1cd5ab6815 |
Put the gesture reference in the application
The document the previous commit generates is for somebody reading the repository. The person who needs it most is holding a tablet, has just discovered that a hold does something, and has nowhere to ask what else does. So the same scan writes a table the application draws: a "Gestures" button beside Settings, a sheet with the same scrim and dismissal as the ones that file and name, and every gesture grouped by where it applies with its touch, pointer and keyboard routes side by side. Not the `why` — that is the argument for the design and belongs in the document; on a phone-sized card it would bury the one line the sheet was opened to read. The sheet's file knows nothing about what a gesture is. It draws the rows it is handed, and the rows come from the generated table, because a help screen with its text typed into it is a second description of one behaviour — and the second description is always the one that goes stale. The commit before this deleted a gesture; a hand-kept sheet would still be describing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4b212089d2 |
Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
20c368d3fc |
Give each touch gesture one meaning, and give tap-to-open back
I broke opening a photograph. The dwell added in "Tell a tap on a photograph from a hand going past" required a finger to stay down 120 ms, and a deliberate tap is routinely quicker than that — so the grid stopped opening anything. Duration was the wrong discriminator: a tap and a brush are the same length. **Travel is what separates them, and a graze is by definition a moving contact.** A press now records where it landed and the release compares: within 12px it is a tap, beyond that the hand was going somewhere else. No dwell, so no deliberate tap can be refused, and the rule is the same for a finger and a mouse — one rule instead of two, and the `touch` argument the dwell needed goes away with it. Two real conflicts went with it, because a gesture set that overlaps itself is unlearnable however each half is documented. **A drag was also a hold.** Grabbing a cell and moving inside 450 ms left the hold timer armed underneath the drag, so it fired mid-gesture and put the grid into selection mode nobody asked for — the drag finished into a mode that changed what every later tap meant. Starting a drag now cancels it, exactly as a pinch already did. **A double tap was also a range.** In selection mode two taps on one cell selected everything back to where selecting began: no visible state, no warning, from a thing a hand does by accident. "Select to…" does that job and announces itself first, so the double tap is gone and two taps are now two toggles that land where they started. `extend_to_row` went with it — a second range implementation that only the double tap reached, where every other range goes through `apply_press`. The resulting vocabulary, one meaning each: tap opens, tap-and-slide does nothing, hold starts selecting, drag files, two fingers resize, and while selecting a tap only ever toggles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
846a249156 |
Drain the queue that nothing has ever drained
`jobs` has been a complete durable work queue since the catalog was written, and nothing has ever taken a job out of it. `claim_next`, `complete`, `fail` and `recover_orphaned` had no callers outside their own tests; `enqueue` had three. So the table grew one row per photograph and kept it forever, and FR-PLAT-AND-3's resumability was a property of code that never ran. `runner` is the missing half. It owns no thread, no clock and no policy, and that is the whole design: on Android the process does not decide when background work may run. WorkManager does, subject to Doze, battery saver and FR-NC-6's network constraints, and it revokes permission mid-job by calling onStopped(). So the runner exposes `run_one` — claim, run, record — and `drain`, which repeats it against a budget, a deadline and a cancellation flag the host owns. A `Worker.doWork()` with ten minutes calls drain with a deadline; a desktop idle pass calls it with none. That is the seam the Android service plugs into, and it needs no Android to test. Handlers are supplied from above, because the catalog knows what needs doing and nothing about how: a thumbnail needs a decoder and a fetch needs a network stack, neither of which belongs under core/dr-catalog. A runner claims only kinds some handler declares, so a queue holding work this device cannot do is left alone rather than failed five times. Four outcomes, and only two of them are the job's fault. Done deletes the row; Retry backs off; Abandon gives up now, for a failure no retry can fix; Interrupted releases the claim with its attempt refunded and ends the drain, because the host stopped rather than the job — five backgroundings in a row must not mark good work as failed. Process death is the fifth and cannot report itself, which is what `recover` is for. Recovery is called from `show_catalog_now`, which is the one place a catalog is opened for a session and already returns early if one is open. It has to be exactly once and before any worker starts: there is no owner column, so a second pass while a worker held a claim would take it away. The attempt a dead claim consumed is deliberately kept — a job that takes the process down with it is indistinguishable from one that fails, and the attempt counter is the only evidence that survives a death. The tests cover claiming under contention twice over: sequentially across two connections, and with four threads on four connections against one catalog on disk, asserting every job ran exactly once. Plus completion, backoff, giving up, abandoning, interruption, budget, deadline, cancellation, and a job orphaned by a simulated crash being reclaimed and run once rather than lost or repeated. Not wired to a handler yet, and deliberately not: the only enqueue site the app actually reaches is the remote scan's, whose thumbnails are already served by the async grid worker, and `walk`'s two sites are reachable only from the scan_local example. Inventing a handler to make the plumbing look used is how a requirement comes to read as covered by code that does not implement it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7418b040da |
Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan` stepped over a NotFound or PermissionDenied the way it does for a child that vanished mid-walk -- correct for a child, wrong for the root, where it ended the walk, returned Ok with nothing in it, and reported a successful scan of a library that was no longer there. A lost root is now its own error. The images under it are marked Availability::Offline per FR-CAT-9 and no catalog row is deleted; `library::persist` clears the mark per file as each one is listed again, so a root that comes back needs no repair step. Partly satisfied rather than closed, and the gap is worth stating. The recovery half is real and reachable on Android today, because `map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how a phone actually gets a library in this build. The causes the requirement names -- revocation, reinstall, a removed card -- are properties of a persisted tree permission, and there is none: SAF does not exist here, `SourceRef::Document` is constructed only in test modules, and `LocalStorage` rejects the variant outright. When SAF lands it becomes a third producer of this error and nothing above it changes, which is why the discovery belongs in the connector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
55cb9b5b30 |
Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files and clippy passed at -D warnings untouched, but one test panicked: `the_signature_does_not_change_with_scale`, on "attempt to multiply with overflow". It is the fixture, not the feature. `scene()`'s little LCG multiplied the block's y by the golden-ratio constant with a plain `*` while the term beside it already used `wrapping_mul`, so any scene taller than about 104 pixels overflowed in debug. Only the scale test builds one that large, which is why 345 of 346 passed around it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e5db849f04 |
Mark a burst in the grid, and let it be folded away
The counterpart to the grouping: where the signatures come from, and how a group reaches a cell. Signatures are computed from the 256px thumbnails dr-thumbs already holds -- vastly more resolution than a 9x8 reduction can use -- so a library that has been browsed, or that has synced somebody else's shards, has already paid for them and no RAW is decoded for this. The consequence is stated rather than hidden: an image with no thumbnail gets no signature and never joins a burst. That is self-correcting, and it is why the pass runs when the thumbnail sweep finishes rather than on a timer. Nothing happens at import and nothing happens at query time. The mark is drawn as a child of the cell's TouchArea, for the same reason the star strip is: a click on it must not also reach `cell-clicked` and throw the user into develop, and children are hit-tested before the element they sit in. It is never hidden on hover the way the stars are -- a collapsed burst stands in for frames that are not on screen, and something has to say so whether or not a pointer is nearby. Folding changes what the grid's *query* returns rather than what its cells draw, because the grid is a window over an ordered query and the frames a fold hides are mostly not loaded. So the predicate joins VISIBLE in every query that lists or counts cells -- the window, the header's count, the run a shift-click resolves, and the ordinal a scrub lands on -- under the discipline VISIBLE's own comment sets out: present in four places of five is worse than absent, because the counts disagree with the cells and neither looks wrong on its own. There is a test for exactly that. `the_window_read_walks_the_ordering_index` now includes the burst clause. It asserts on the query plan while holding its own copy of the query, so left alone it would have gone on reporting green against a query the grid no longer runs. If the clause costs `images_grid_order` and puts the sort back, that fails here rather than becoming jitter someone measures in six months. The pass keeps its own drain timer in a thread-local instead of taking fields on the library controller, so everything the feature needs to run lives in one file and the screen that starts it holds nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e465ff0c80 |
Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became a selector term, and it was effectively unreachable. The only control that could add a second person lived on the People screen, behind selecting them there, and it appeared only once the grid was already narrowed to somebody — so "photographs with both of them" needed a two-screen round trip the user had to guess at. A filter belongs on the filter bar. A "People" chip there opens a tray of everyone the library knows; tapping a name adds or removes them, and the any/all chip beside it — already there, and already the thing nobody found — now has something to sit next to that explains it. The caption leads the row so a pair of chips means something before either is pressed. The tray is a strip under the bar rather than a popup, the way the develop column's film picker is: the view scrolls as one, so an inline strip is taller content and not a second overlay to dismiss. It scrolls horizontally for the same hard reason the bar above it does — a layout cannot be narrower than its children's minimums, and forty people would otherwise set the minimum width of the whole view. The roster is built on open, not kept in step: indexing and regrouping change who exists, and a list cached at startup would be stale for exactly the user who has just been naming people. Named first, then by how much of them the library holds — the catalog orders by face count alone, which puts a dozen unnamed strangers ahead of the two people the user actually cares about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6d5de21fb7 |
Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel was already answered — the Flickable claims the pointer and the press is cancelled — but a contact that neither travels nor lasts reaches a TouchArea as an ordinary press and release, and it was landing the user in develop. So a finger now has to stay down for `TAP_MIN_MS` before letting go counts as opening anything. That is the floor under a tap where the 450 ms `HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above is a hold that starts a selection. One scale, three gestures. Only a finger is held to it. A mouse click is a discrete decision made by a button and is routinely over in thirty milliseconds, so `cell-pressed` now reports whether a finger did it — the same finger-id convention the pinch arbitration beside it already uses — and the dwell applies to touch alone. A graze still *selects* the cell it landed on, because the press already did that. That is the right failure mode: something visible and reversible rather than a silent nothing, and rather than develop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b9891e2c04 |
Merge master into tablet-selection
Two real conflicts, both from work that landed either side of the same lines rather than against them. `lib.rs`: the settings controller was hoisted above the People screen's wiring, and Android's thumbnail-tier eviction registered itself at the same point. Independent, so both stay. `library.rs`: manual collection ordering and burst folding each added a clause to the same two queries. The scoped range read now carries both — the folding matters there for one step further on than it does in the grid, because a collapsed burst is one cell, so an ordinal counted over a list still holding every frame names a photograph several places away from the one the user pointed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a4b9deaf96 |
Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became a selector term, and it was effectively unreachable. The only control that could add a second person lived on the People screen, behind selecting them there, and it appeared only once the grid was already narrowed to somebody — so "photographs with both of them" needed a two-screen round trip the user had to guess at. A filter belongs on the filter bar. A "People" chip there opens a tray of everyone the library knows; tapping a name adds or removes them, and the any/all chip beside it — already there, and already the thing nobody found — now has something to sit next to that explains it. The caption leads the row so a pair of chips means something before either is pressed. The tray is a strip under the bar rather than a popup, the way the develop column's film picker is: the view scrolls as one, so an inline strip is taller content and not a second overlay to dismiss. It scrolls horizontally for the same hard reason the bar above it does — a layout cannot be narrower than its children's minimums, and forty people would otherwise set the minimum width of the whole view. The roster is built on open, not kept in step: indexing and regrouping change who exists, and a list cached at startup would be stale for exactly the user who has just been naming people. Named first, then by how much of them the library holds — the catalog orders by face count alone, which puts a dozen unnamed strangers ahead of the two people the user actually cares about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2403f355d6 |
Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel was already answered — the Flickable claims the pointer and the press is cancelled — but a contact that neither travels nor lasts reaches a TouchArea as an ordinary press and release, and it was landing the user in develop. So a finger now has to stay down for `TAP_MIN_MS` before letting go counts as opening anything. That is the floor under a tap where the 450 ms `HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above is a hold that starts a selection. One scale, three gestures. Only a finger is held to it. A mouse click is a discrete decision made by a button and is routinely over in thirty milliseconds, so `cell-pressed` now reports whether a finger did it — the same finger-id convention the pinch arbitration beside it already uses — and the dwell applies to touch alone. A graze still *selects* the cell it landed on, because the press already did that. That is the right failure mode: something visible and reversible rather than a silent nothing, and rather than develop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
82d9d077b0 |
Merge: answer Android's memory warnings, and stop reporting a lost root as an empty library
FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and live for Nextcloud roots, the SAF cause it names does not exist yet. FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a Service or a FileProvider. The container has JDK 17 and build-tools 36; the build step is what is missing. Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043 tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud, dr-plat and dr-ui. The aarch64 target was checked before the branch was finished but not after; no device was available. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
75fd5619ca |
Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan` stepped over a NotFound or PermissionDenied the way it does for a child that vanished mid-walk -- correct for a child, wrong for the root, where it ended the walk, returned Ok with nothing in it, and reported a successful scan of a library that was no longer there. A lost root is now its own error. The images under it are marked Availability::Offline per FR-CAT-9 and no catalog row is deleted; `library::persist` clears the mark per file as each one is listed again, so a root that comes back needs no repair step. Partly satisfied rather than closed, and the gap is worth stating. The recovery half is real and reachable on Android today, because `map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how a phone actually gets a library in this build. The causes the requirement names -- revocation, reinstall, a removed card -- are properties of a persisted tree permission, and there is none: SAF does not exist here, `SourceRef::Document` is constructed only in test modules, and `LocalStorage` rejects the variant outright. When SAF lands it becomes a third producer of this error and nothing above it changes, which is why the discovery belongs in the connector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fc4157a1e0 |
Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files and clippy passed at -D warnings untouched, but one test panicked: `the_signature_does_not_change_with_scale`, on "attempt to multiply with overflow". It is the fixture, not the feature. `scene()`'s little LCG multiplied the block's y by the golden-ratio constant with a plain `*` while the term beside it already used `wrapping_mul`, so any scene taller than about 104 pixels overflowed in debug. Only the scale test builds one that large, which is why 345 of 346 passed around it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
115653a262 |
Mark a burst in the grid, and let it be folded away
The counterpart to the grouping: where the signatures come from, and how a group reaches a cell. Signatures are computed from the 256px thumbnails dr-thumbs already holds -- vastly more resolution than a 9x8 reduction can use -- so a library that has been browsed, or that has synced somebody else's shards, has already paid for them and no RAW is decoded for this. The consequence is stated rather than hidden: an image with no thumbnail gets no signature and never joins a burst. That is self-correcting, and it is why the pass runs when the thumbnail sweep finishes rather than on a timer. Nothing happens at import and nothing happens at query time. The mark is drawn as a child of the cell's TouchArea, for the same reason the star strip is: a click on it must not also reach `cell-clicked` and throw the user into develop, and children are hit-tested before the element they sit in. It is never hidden on hover the way the stars are -- a collapsed burst stands in for frames that are not on screen, and something has to say so whether or not a pointer is nearby. Folding changes what the grid's *query* returns rather than what its cells draw, because the grid is a window over an ordered query and the frames a fold hides are mostly not loaded. So the predicate joins VISIBLE in every query that lists or counts cells -- the window, the header's count, the run a shift-click resolves, and the ordinal a scrub lands on -- under the discipline VISIBLE's own comment sets out: present in four places of five is worse than absent, because the counts disagree with the cells and neither looks wrong on its own. There is a test for exactly that. `the_window_read_walks_the_ordering_index` now includes the burst clause. It asserts on the query plan while holding its own copy of the query, so left alone it would have gone on reporting green against a query the grid no longer runs. If the clause costs `images_grid_order` and puts the sort back, that fails here rather than becoming jitter someone measures in six months. The pass keeps its own drain timer in a thread-local instead of taking fields on the library controller, so everything the feature needs to run lives in one file and the screen that starts it holds nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5768100816 |
Borrow the library to index it, and give it back
The passes that need every photograph's bytes — thumbnails, face indexing — now borrow each one and release it at the end. On a placeholder library that is the difference between peak disk being the working set and being the whole library. Including on cancellation, which was nearly missed: the face sweep returns mid-loop when the user presses Stop, and without releasing there the disk is spent and nothing is delivered for it. `materialise` now answers whether *it* fetched the content. The pool used to work that out by listing a file's parent directory — one listing per file across a library — when the backend already had to `stat` it to decide whether to ask. One syscall instead of a directory walk, and it removes the bug class the tests found earlier: a file at the library root has no `parent()`, so every one of them read as already-downloaded. **Pinning is the retention control**, and it drives the model the catalog already had rather than a second one. `tier_desired` is what the user asked to keep hydrated, `pending_pins` is the resumable work list, and a pinned collection is never dehydrated for the same reason it was never evicted. It was in fact *broken* here before: `get` on a stub failed, and the pin worker logged "one unreadable file must not abandon the whole pin" and silently did nothing. Pinned originals on such a library are recorded with `path = NULL` (`Cache::record_in_place`) rather than copied under `originals/`. Two reasons, and the second is the important one. A copy would hold every pinned photograph twice, with the budget able to evict the half that was not costing the disk. And `release` deletes the file a row names — so a row that names none cannot delete anything, which puts the one catastrophic operation out of reach by construction rather than by remembering not to call it. Deleting a materialised file inside a synced tree removes the photograph from the server and every other device. Handing disk back is `spawn_dehydrate`, which asks the client. Two gaps written down rather than papered over (docs/storage.md §7): a hydrating pass cannot yet quote its cost, because a stub reports no size; and the two sweeps hold separate pools, so a library indexed for both fetches twice. |
||
|
|
f12aece07e |
Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
|
||
|
|
af5a13b3f7 |
Narrow the grid to several people at once, either way
"Show photos" could only ever mean one person. The two questions a photographer actually asks are "every picture of Anna or Bob" and "the pictures they are both in", and the second is not reachable by any sequence of single-person filters — no amount of switching between one person and another finds the frame they share. So the filter holds a *set* of people and a mode. `RatingFilter` was already the right home, as its own doc says: every query path threads it, so the count in the header and the cells in the grid are narrowed by the same thing, and this composes with stars, flags and the date range for free. The union is `EXISTS ... person_id IN (...)`. The intersection counts **distinct** people per image and compares against the size of the selection — one subquery rather than one per person, and it does not grow the statement with the selection. `DISTINCT` is what makes it correct: three faces of Anna in one frame must not satisfy a filter asking for Anna and Bob, and there is a test that says so. Any rather than All is the default. With one person the modes are the same filter, and adding a second to a union can only ever show more — so a user who has not noticed the toggle never ends up staring at an empty grid wondering what they broke. The toggle only appears at two people, because a control that demonstrably does nothing is a control that teaches the user to ignore it. Building the set needs no picker of its own: the Identity screen gains "And also…" beside "Show photos", offered only once the grid is already narrowed to somebody. Each person is a chip on the filter bar and each chip removes just that person, so a selection of three can be taken apart one at a time rather than only cleared wholesale. `RatingFilter` stops being `Copy`, since it now holds a `Vec`. Every query path already took it by reference; the casualties were two struct updates and one `Cell` that becomes a `RefCell`. 484 dr-ui tests pass, including the union, the intersection, that one person reads the same in both modes, and the repeated-faces trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d6380fecc8 |
Make the People screen a place work can be done
Five faults, all on one screen, and the Slint and Rust halves of each have to land together. **Regroup froze the window.** It ran inside the Slint callback, on the UI thread. It is much faster now, but fast is not bounded — the work grows with the library, and the one thing that must not grow with the library is how long the window stops answering. It runs on a worker thread with an mpsc channel and a 250ms poll, like every other long pass in this module, and the button says what it is doing instead of the window going quiet. Cancellation is dropping the receiver. Reclustering also prunes the empty groups the previous pass left, so pressing the button twice no longer fills the rail with "Unnamed (0 faces)". **The faces were a single row running off the screen.** The comment on the layout claimed to be a wrapping row; Slint has no flow layout and a HorizontalLayout does not wrap, so a person with forty faces was a person whose faces could not be reviewed past the fifth. It is now laid out the way the library grid lays out thumbnails, with the same arithmetic: choose how many columns of roughly the requested size fit, then divide the width between them so the cells fill the row exactly and nothing overhangs. **The header did not fit a phone.** A 240px name field beside five buttons is wider than an Android screen — and worse than not fitting, a layout cannot be narrower than its children's minimums, so the row reported that oversized minimum upwards and inflated the whole screen. The faces grid is its sibling, so it would have been measured against a width that was never on the display. The header is now two rows, the actions sit in a Flickable that scrolls rather than overflowing, and the rail narrows to 132px on the compact class. **Strangers crowded out the people who matter.** Most clusters in a real library are passers-by and other people's guests. "Not interested" sets a group aside; the rail hides it and says how many are hidden, with one button to bring them back. Reversible, and never a deletion — see the catalog commit for why. **A face was a dead end.** Identifying someone and then having no way to see their photographs is a filing cabinet with no drawer handles. "Show photos" narrows the library grid to that person and leaves a chip on the filter bar saying so, which is also how it is cleared. It is a term on `RatingFilter` rather than a grid scope of its own, exactly as that struct's own doc says new narrowing terms should be — so the count and the cells are narrowed by the same thing, and it composes with the others for free. Suggested faces count, not only confirmed ones, or a freshly grouped person would show an empty grid. Crops are read from where they are now stored, falling back to cutting one out of the proxy for faces indexed before that existed. 480 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c8c6368542 |
Index the whole library by fetching what it has not seen
"Index faces in the whole library" could not. Its work list was intersected
with the thumbnail store at `ThumbSize::Large`, and nothing fills that class for
a whole library — `SWEEP_THUMB_SIZE` is deliberately `Grid`, because the large
class is ~860 MB of shards against ~200 MB and every syncing device pays it. So
the only images with a large proxy were the ones the user had personally zoomed
into or opened in the loupe. On this library that was 220 of 23,529.
The comment defending it misread the requirement:
// Requesting one here would put face indexing on the network path,
// which FR-CULL-8 explicitly keeps it off.
FR-CULL-8 keeps indexing off the **full decode**, not the network, and then says
the opposite in the same paragraph: "where no proxy exists, the job requests one
at background priority rather than decoding inline". faces.md §7 repeats it.
Neither was implemented.
So the pass fetches. Same two-stage route the thumbnail sweep uses — the header,
then the located preview's own byte range (FR-NC-3) — so no whole file is pulled
and no RAW is decoded, because an embedded preview is a JPEG. The work list is
now every visible image with no `face_index` row for the model: 23,308 here,
against nearly none before.
**It indexes at the resolution the preview actually has**, not the 1024 the old
tier would have given. `locate_preview` already picks the largest embedded
preview, and the thumbnail sweep was decoding it and throwing the detail away at
`downscale_to(256)`. A face 2% across the frame is 5 px on a grid thumbnail and
~61 px at the cap here — and 112 is what the embedder samples, so this is the
difference between an upsampled crop and a real one. `crop_px` records which,
per face, as §7 intended.
Capped at 3072 rather than truly full: `index_proxy` needs packed `f32` RGB at
12 bytes a pixel, so a 24 MP frame is ~288 MB and the fetch lanes hold one each.
The constant is named and sits next to the reason.
Orientation is applied **before** detection, not after downscaling. That costs a
permutation of a larger buffer — ~15 ms against a ~150 ms decode — and buys the
entire class of bug this codebase keeps having: detection then runs on the
photograph rather than the sensor, so every box and landmark is already in the
space the catalog stores and the overlay draws, with no second mapping to get
backwards.
One detector and one embedder serve every lane. The lanes are concurrent futures
on a single thread, not threads, and inference contains no await, so a `RefCell`
borrow never overlaps another — a pair per lane would duplicate ~16 MB of
weights for no parallelism.
Images with no face in them are recorded too. `face_index` records that
detection *ran*, and zero is its most valuable value: without the row every
landscape and document scan returns on every pass, for ever, and in a personal
library that is most of it (§7a).
The old store-only pass survives as `spawn_store_face_sweep` for
`examples/face_index.rs`, which indexes a local store with no network. The
settings copy no longer claims indexing reads "the photographs already
thumbnailed above", and the audit line says "to fetch" rather than "awaiting a
proxy", which had become a blocker that no longer blocks.
Verified against the real catalog: the new work list returns 23,308 where the
old one returned effectively nothing. 469 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
25c88d9dbd |
Start face indexing from Settings
Beside the thumbnail sweep, because it is the same kind of thing: a job that runs for an hour, is asked for once, and reports into the activity list above it. It is also downstream of that sweep -- detection reads the proxies it builds -- so the two belong in that order, and the coverage line says how many images are waiting on a proxy rather than only how many are left to index. The button drives the Identity Manager's own state rather than a second copy, so it cannot disagree with that screen about whether a pass is running, and either place can start or stop it. The pass now opens an activity row. The caption promises progress will appear in the list above, and without a row it would not: the button would be the only sign anything was happening, invisible from every other screen. Coverage is read when the Settings page opens. The figures live in the catalog and this page deliberately holds no session, so they arrive through a closure rather than being kept current -- they are only ever looked at while the page is on screen, and the check is two counts and an indexed scan. Also adds DARKROOM_NO_SYNC. Redirecting XDG_DATA_HOME isolates a test launch's catalog and thumbnails but not its server, and I found that out by pushing a test catalog over the live one. The guard sits in start_derived_sync rather than at its three call sites, because the sweep firing a sync is correct and a flag checked in three places is one that gets missed in a fourth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6925aa2a86 |
Merge master into film-simulation
🐳 Android image / Build and push (push) Successful in 0s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 59s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Successful in 1m1s
Build and test / Android (aarch64) (push) Failing after 22m44s
Master gained the library's index paging while this branch was building the film simulation, and the two met in library_ui.rs. Only the generated traceability matrix conflicted; it is regenerated here rather than hand-resolved, which is what it is for. |
||
|
|
3330f350a4 |
Move the timeline marker from the window the grid already read
Build and test / Desktop (Linux) (push) Failing after 1m1s
Build and test / Layer separation (push) Successful in 24s
Traceability / Requirement traces (push) Successful in 22s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 22m19s
The last of the per-scroll queries, and the strangest of them: this one got slower the further down the library you had scrolled. The marker has to follow every scroll event or it advances in jerks while the photographs beside it move smoothly — that part is right and stays. What was wrong is that each event asked the catalog `LIMIT 1 OFFSET n`, and that is not a seek: SQLite reaches row `n` by producing and discarding the `n` rows before it. 0.02 ms near the top of the library, 0.7 ms at twenty thousand, per row crossed, on the thread drawing the frame. A flick therefore got choppier the longer it went on. The window the grid has already read holds the answer, and since the loaded window now covers the whole view, the row is nearly always in it. So this is a vector index at the position the ordinal has in the window, and the query survives only as the fallback for a row outside it — briefly, after a scrub or a keyboard jump, before the load lands. The fallback is also the less correct of the two, which is worth recording rather than quietly keeping: it counts in a dated-only ordering while the argument is a grid row, so the two disagree wherever undated frames sit in between. It is kept because a marker about to be corrected is not worth a second index, and because being wrong there is what it always did. The window path has no such disagreement — it reads the very cell the row belongs to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f231afe85 |
Stop re-answering questions about the library every time the window moves
The timeline's bars, the filter chips' counts, the "on this device" count and whether the scoped collection is pinned all describe the *library*. None of them can change because the view scrolled. `load_window` recomputed all four every time the window moved, which is several times per screenful. Together that is a `MIN`/`MAX`, a `GROUP BY`, two counts, and under a collection three more queries — about 8 ms of SQLite on the thread that is trying to draw the frame, for four answers that were already on screen and already right. They are now keyed on what they actually depend on: the scope, the filter, whether this is the trash, and the total. The total earns its place as the change detector as much as for the scrollbar — a scan landing, a delete or a restore all move it, and it was already read on every load. What a total cannot see is a rating edited under an unchanged count. That is covered, and deliberately not by widening the key: `apply_judgement` already refreshes the chips itself, because it has to report what actually landed rather than what was asked for. Same for the axis — a zoom, a pan, a scrub and dates arriving from the thumbnail worker each call `refresh_timeline` directly. Skipping the recompute here cannot leave anything stale on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
baa8957e80 |
Let a photographer choose the film, and remember which one
The stock model rendered correctly and nothing could ask for it. This is the picker, and the sidecar key that makes the choice outlive the session. How the choice persists was the open question, and the answer was already written down twice in sidecar.rs: `rating` is a top-level key "because a rating is not an edit", and `masks` are one "because a layer is not a scalar". A stock is that kind of thing -- a choice of material, not a number a slider moves -- so it is a top-level key too. It stores the **id**, not an index. Stocks are files that users add, so an index would mean installing a profile silently changed which film every existing photograph had been developed on. A name this build has no profile for still round-trips untouched, because the alternative is that syncing to an older phone quietly un-develops the picture. Only the names travel. Turning one back into tables needs the profile database, which dr-pipeline deliberately does not link, so `Version::apply` clears the film and the session re-bakes -- after the parameters, because the bake reads the film's own exposure sliders and the print balance is solved against them. That is also why moving those sliders rebuilds the lookup where no other control in the panel does: an enlarger's filtration depends on how the negative was exposed. The panel keeps its rule. It still names no operation and still generates every control from a declared parameter kind; the stock gets a bespoke control beside those, exactly as the mask stack does, and for the same reason. The film's exposure and print exposure arrive as ordinary generated sliders. Two defaults worth stating. Picking a colour negative prints it, because an unprinted one is an orange strip and offering that as the first thing somebody sees after choosing Portra reads as a bug rather than as a choice -- the toggle is there for anyone who wants the scan. And a paste carries no film: a preset is a parameter map, and a stock is not a parameter, so pasting one would paste a choice the clipboard never took. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dce1e66746 |
Show which cell a shift-click is measuring from
The gesture had a hidden operand. A range runs from the anchor — the last cell plainly clicked — to the cell shift-clicked, and nothing on screen said which one the anchor was. A user who could not tell where the range was being measured from had no way to predict what it would take and no clue why a wrong one came out wrong; often the anchor is not on screen at all, which is itself the answer to "why did that select so much". The anchor is marked with an inner ring, drawn inside the selection ring rather than in a colour of its own: it has to stay legible against a thumbnail of any brightness, and a hue would read as a second kind of selection. It is an ordinal, so it marks a row only while the photograph it names is in the loaded window — off screen it marks nothing, which is the honest answer, and `anchor` rides in the cell model beside `selected` so both are pushed by the one pass that already keeps the grid in step with the selection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
54f9cb54fb |
Take the whole run a shift-click names, not the part that happens to be loaded
Shift-clicking two photographs selected only the cells between them that were in the loaded window. The grid is a window of a hundred or so over a library of twenty thousand, and `apply_press` resolved the range against that window — `ids[lo_row..=hi_row]`, clamped to what was there. Everything else in the run had no id anywhere in the UI, so it was silently dropped. The user cannot see that: the selection count is off screen along with the photographs, and the gesture only announces itself when the drop files a dozen images instead of two hundred. The two ends are *ordinals*, and only the catalog knows what lies between them. `read_ids_span` asks it, through the same predicates, the same rating filter and the same ordering the window itself is read with — an ordinal names a photograph only relative to an ordering, so a run taken through any other one is a run through a different library. That ordering is now a constant, `GRID_ORDER`, shared by the window, the trash's own order beside it, and the run: capture time first, with the file name breaking ties and nothing more. A card written by two cameras interleaves names that have nothing to do with each other, and what "everything between these two" means to a photographer is a stretch of an afternoon. The query is reached through a closure handed to `CollectionsController` at wiring time rather than a catalog handle, because the scope and the filter that bound the run belong to the grid's controller. `apply_press` stays a pure function of what it is given, which is what keeps the selection rules testable with no library open — and the tests pass a run that reads a plain slice. Where there is nothing to ask, the loaded window is still used: a poorer answer than the catalog's and a far better one than a gesture that appears to do nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
22325415b1 |
Fetch what is on screen before what is not
The other half of the blank bottom row, on a library still filling its thumbnail store: the cells were in the model, and nobody had asked the server for them yet. A batch is fetched one image at a time, two round trips each, and it is abandoned wholesale the moment the window moves. Issued in model order, the front of that queue was the quarter-window of cells sitting *above* the view — which nobody is looking at — and the back of it was the bottom of the screen and the screenfuls below. So the last rows of the grid waited behind three quarters of a window's worth of fetches for photographs off screen, and every scroll threw the queue away and started over from above the view again. For as long as the scrolling continued, the bottom of the grid could be starved. The rows still address the model they were built against; only the order they are asked for in changes. On screen first, in reading order, then the rows below the view, then the rows above it. Below before above because that is where the view is going — scrolling back over cells already fetched is served from the store, and from `requested` without a fetch at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7c8e433911 |
Load the window around the whole view, not around its first cell
The bottom row of the grid was often blank, at a scroll position the user could sit at indefinitely. Two numbers decided when the loaded window follows the view, they lived in different languages, and they disagreed. The grid loaded three screenfuls and Rust held the window still until the first visible cell was three quarters of the way through them. Three quarters of three screenfuls is 2.25, and the view itself is one screenful tall — so the bottom of the screen had already travelled a quarter of a screenful past the last loaded cell before anything moved. Those rows are not in the model, so nothing is drawn for them. The same margin was wrong upward, and exactly so. The window is placed a quarter of itself behind the view, and the margin then declared the view too close to the top at precisely that distance: every single row scrolled upward re-read the catalog, rebuilt all 360 cells and re-queried their badges and ratings, and so did the row after it. So the grid now reports what it shows — a screenful, counting the row the scroll position has cut in half, which `visible-rows` alone undercounts and which is exactly the row reported missing — and Rust owns the rest: four screenfuls loaded, placed a quarter back, and moved once the view comes within half a screenful of an edge of them. One decision in one place, and the test now walks the view the length of the library and asserts the window covers it at every step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6b5ffc6a93 |
Anchor the delete on what the view was showing, not on the window's start
Build and test / Desktop (Linux) (push) Failing after 2m33s
Build and test / Layer separation (push) Successful in 23s
Traceability / Requirement traces (push) Successful in 22s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 6s
The grid still jumped. Anchoring on `offset` was wrong for a reason this file already states, a few hundred lines away: "the first *visible* ordinal, not the window's start: the loaded window deliberately begins a quarter of a screen above the view, so its first cell is one the user cannot see." Two things made `offset` the wrong number. It is a screen-quarter above what is being looked at, and by the point this runs it has already been re-clamped against the new, smaller total — so seeking to it moved the view somewhere the photographer had not been. A smaller jump than the original, and the same fault. `resume_at` is what the grid last reported as its first visible image, which is the photograph the person is actually looking at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cd64166b15 |
Keep the view on the photographs after some of them are deleted
Build and test / Desktop (Linux) (push) Failing after 2m21s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 4s
Deleting made the grid go blank and jump somewhere arbitrary. Two causes, both of them the viewport being left behind by everything else that moved. The Flickable is sized to the *whole* library so its scrollbar is a real address into twenty thousand images. Delete some and that content gets shorter, which leaves a view near the end scrolled past what now exists — cells sitting above a viewport looking at empty space. Slint does not pull a Flickable back on its own. (The clamp for that went in with the previous commit.) The jump is the other half. Cells are drawn at their absolute place in the library, `(i + offset) / columns`, and a delete re-clamps `offset` downward so the loaded window still fills. Nothing touches `viewport-y`, so the same scroll position now addresses different photographs and the grid appears to leap somewhere unrelated. `restore_position` re-anchors on the ordinal the view was showing, clamped into what is left. Not on the deleted image's own position, which no longer exists, and not on the top of the library, which would throw the scroll position away on every delete — after removing one frame from a wall of twenty thousand, the one you want next is the one that just moved into its place. Only on a shrink, and the shrink is detected by reading `library-total` before overwriting it. Re-anchoring on every load would fight a scrub, which sets exactly this property to go where the user asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fa4ad6e2d6 |
Drag the date range on the axis it is chosen from
The range could be turned on with a finger and not aimed with one. Its two ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to name days already drawn on the axis a thumb away; and the chip that seeds them takes its span from the timeline's zoom and pan, which are a wheel and a middle button. A touch screen has neither, so on Android the filter was a switch with no aim. The band is now on the timeline. Two ends with grips, dragged along the bars, released to filter — the histogram was already how a period is found, and this makes it how a period is stated. Both ends snap to whole days, which is what the typed fields mean, what `show_range` reads back out, and a floor under a range dragged shut. The fields stay for what dragging cannot do: name an exact day, and say in words what the range is. For that to work the axis had to stop following the range. Redrawn to the band, it moved the ground under the very handles doing the narrowing, and there was nothing outside the range left to widen back into. While there: a fixed number of equal bins instead of calendar buckets. Between one calendar unit and the next the bar count is free to wander by a factor of twelve, so zooming in halved it two steps out of three — the same picture drawn wider until it jumped back to fine. Equal bins also include the empty ones, so a bar's position on the track and the date under it are finally the same quantity; before, a library with gaps drew a February six months wide and the marker, the band and a click all pointed somewhere else. The count is a setting, 32 or 64, because the right answer is a question about the screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fb4b05fb6f |
Let the date range be opened before there is a date range
Build and test / Desktop (Linux) (push) Failing after 2m16s
Build and test / Layer separation (push) Successful in 23s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 6s
Pressing "limit to range" did nothing, and the reason was two early returns that sat above the line which shows the controls. If the catalog was not open, or nothing in it carried a capture date, the handler returned before `set_library_range_active`, so no state changed and nothing appeared. Capture dates are read from EXIF as thumbnails load, so a freshly opened library has none — the button was inert on exactly the libraries where someone is most likely to go looking for a date, and it failed by doing nothing at all, which is the hardest failure to report. Underneath that was a smaller mistake with the same shape: whether the controls showed was read from whether a range was set. The fields are how a range gets set, so requiring one before they appear is a door locked from the inside. The panel has its own state now, and the timeline span is a seed for the fields rather than a precondition for them — no span means two empty fields waiting to be typed into, which is a way to choose a range rather than a refusal to offer one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4b7648082e |
Let a date range be stated, and draw the axis at the scale it deserves
"Limit to range" did nothing, and the reason was not visible from the button. It took its span from the timeline's zoom, which is zero until someone zooms — so `zoomed_span` returned the whole library and the filter narrowed to everything. The chip lit up and the grid did not change. The range has ends now, shown and typed as `YYYY-MM-DD`. Seeding them from the timeline is kept, because zooming to a fortnight and pressing the chip is the fast path; the fields say which fortnight it landed on and let it be corrected. Ends given backwards are swapped rather than refused — there is exactly one range between two days — and the closing day is included, since "to the 5th" means the whole of the 5th and a range ending at its midnight contains none of it. `parse_date` refuses anything that is not a date rather than guessing at an order, because the alternative is a library silently filtered to a span nobody asked for. The axis then follows the range. It used to keep drawing the full extent while a range was on, because it was the only way back out; the typed ends are the way back out now, so it is free to show what was asked about. And bucket size is chosen by how many bars it makes rather than by fixed cut-offs. Each zoom step halves the span, so under thresholds the bar count halved with it until a boundary was crossed: fifteen years went 15 bars, 8, then 46, 23, 11, and finally 6. Zooming in made the picture coarser, which is the opposite of what zooming is for. Aiming at forty bars keeps the count in the same neighbourhood at every level, and the test asserts the property directly — halving a span never coarsens the bucket. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fbf286d504 |
Keep the mask when the photograph is closed
A local adjustment survived until the session ended and then did not exist. Nothing reported it, because nothing had failed. The sidecar format has carried masks since they were added, `Version::apply` restores them into the graph, and `Version::update` captures them — that work landed complete and was never called. The autosave writes `copy_settings()`, which is a `Preset`: a map from (operation, parameter) to a number. A mask is not a parameter. It is a rule about *where*, with a chain of its own, so it fell outside the only thing being written, and the read path had nothing to read. The save now carries the stack beside the preset, on both the local and the remote path. Deliberately not merged but replaced wholesale: this is the stack as it stands, so a layer the user deleted has to leave the file too. Merging two devices' stacks is `Sidecar::merge`'s job and belongs to sync (FR-NC-9). A paste still carries no masks, and the `Option` is how that is said. Settings travel between photographs; a mask does not, because it is drawn against one frame and describes nothing on another — and `Scope` cannot express that, since it filters parameters and a mask is not one. The test fails against the old save path with "the mask must come back with the photograph", which is the whole of the defect: not a crash, not an error, just an edit that was not there in the morning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5807b67693 |
Caption a photograph with its name, not with how it is stored
A grid cell is about four words wide and `.CR2` spent one of them saying something the photographer already knows: in a RAW library every frame ends the same way, so the extension distinguishes nothing while taking room from the part that does. The caption elides from the end under pressure, so an extension can push the digits that actually identify a frame off the visible part of its own label. Dropped in the view, not in the model. `LibraryCell::name` keeps the true filename and `remote_path` the full path, because both are used to find the file again and a stem is not a filename. The case it is wrong for, recorded rather than discovered later: a library holding `IMG_1234.CR2` beside `IMG_1234.JPG` now shows two cells captioned `IMG_1234`. They remain two rows with two thumbnails and two entries in the info panel, and a RAW+JPEG pair is usually one photograph anyway — but the caption alone no longer separates them. Only the last dot goes, and only when something precedes it: `2026.08.23-a.dng` keeps its dates, and `.hidden` keeps its leading dot, because that dot is how the name starts rather than an extension. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
11a63fc7a8 |
Refresh the collection tree when a sync brings membership
New-York gained 127 photographs from the tablet and the sidebar went on showing no number beside it. Two reasons, and the second is why the first was never noticed. The sync's completion handler reloads the grid and never rebuilds the tree — the scan already does, and the sync merges the same tables. And the condition it reloads under was `collections_gained > 0`, which counts collections, not members: a sync that files 127 photographs into a collection both devices already had gains no collection at all, so the count was zero and nothing refreshed. `SyncReport` now carries `members_gained` from the merge report, and either one rebuilds the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
56dd3187f1 |
Merge integration into wip/ingest
Second pass, against the detail-stage and thumbnail work that has landed since the first. Resolved and verified here rather than in the shared merge worktree, so what goes back is a fast-forward. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
d91f1ec277 |
Thumbnail the whole library on request, and send the shards up
The grid fetches a preview only for cells that are actually browsed, which is the right posture over a link that must not be saturated to show one screen (FR-NC-3). The cost is that the thumbnail store ends up holding the fraction of the library someone happened to scroll past — and that store is the one derived thing worth syncing, since a second device that downloads the shards gets a full grid without touching a single RAW. So the complete set is worth an hour of range fetches paid once, deliberately, on a machine that can afford it. That is what this adds: a pass over every visible image with an `oc:fileid`, launched from the settings page and reported in the activity register like every other background job. The work list is what the store lacks rather than a flag in the catalog, so it is resumable by construction and safe to press twice. Lane-parallel like the metadata sweep, but it does what that sweep declined to. The store is `&mut` and cannot cross lanes, which is why dating the library skips thumbnails entirely; here the lanes fetch, decode and *encode*, and only the ~20 KB result crosses back to the one thread that owns the store and writes the chunk. The parallelism is real and the single-writer rule is not bent. Grid class only. The large class is ~860 MB of shards against ~200 MB on the reference library, paid by every device that syncs them; a photograph looked at closely still gets its large thumbnail from the interactive path. Dates come free — the header a preview needs is the header EXIF lives in — and the pass ends by pushing the shards to the server. A filled store that never leaves this device would be most of the cost for none of the point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
49be1fe354 |
Give the library somewhere to type a keyword
A sheet over the grid, opened from the header beside "Add to collection" — deliberately the same card, scrim and dismissal as the filing sheet, because they are the same gesture applied to two kinds of label: pick the photographs, then say what they are. A user who has filed a selection already knows how this works. A word the whole selection carries, a word only some of it carries, and a word none of it carries are three visibly different marks. Half-applied shown as applied would be a lie about photographs the user cannot see from here, so a partial keyword draws a dash and says "3 of 12" beside it. Tapping a dash completes the keyword rather than removing it, which is what it means nine times in ten, and the tenth is one more tap away. The vocabulary is answered against the selection in Rust and pulled when the sheet opens rather than pushed on every selection change — the selection moves on each arrow key and the sheet is shut for almost all of them. Assign and unassign travel by name, so a word typed into the field and a word tapped in the list are one path rather than two, and the sheet never has to invent an identity for a keyword that does not exist yet. One gap, commented at the call site: unlike a star or a flag, a keyword is not queued to the image's sidecar, because the sidecar format has no field for one. So it reaches the user's other devices through the catalog merge, and a deleted catalog loses keywords where it would keep ratings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c36d4c80c8 |
Give the calendar one reading of a capture instant
The era-based conversion sat in library_ui.rs with two readers: the
timeline's month headings and, through format_date, the exporter's {date}
token. Import folder templates are a third, and three copies of a date
calculation that could drift apart is one too many — an image filed under a
date the timeline does not show it on is a file the user cannot find.
Moved to dr_types::time, which is where shared vocabulary lives, and given
the offset-aware reading an import needs: a shot taken at 23:30 in Tokyo
belongs in Tokyo's day, and filing by UTC would split one night's
photographs across two folders at whatever hour the offset happens to be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
caf61d41a5 |
Re-thumbnail a photograph from its own edit
A thumbnail comes from the file's embedded preview, which is the camera's idea of the photograph and knows nothing about what has been done to it since. So a frame could be cropped, turned upright and pulled two stops back, and the grid would go on showing the original — making the library, where a photographer spends most of their time, the one view in which an edit is invisible. The render is the framed output, not the sensor: `output_size` is what a crop, a quarter turn, a flip and a straighten all act on, so a thumbnail taken from the raw frame would be the right pixels in the wrong shape and still the wrong way up. It is the same path an export takes, at a size the store wants rather than at full resolution, and always sRGB — this is a JPEG in a shard that syncs between devices and is drawn as a cell, not a file anyone is finishing. Both size classes are replaced. The store keys on the class, so refreshing only the one the grid happens to be drawing leaves the other holding the unedited preview, and a zoom across the boundary would show the edit undoing itself. Each is rendered rather than downscaled from the larger, which would be a second and worse resampler than the GPU has already applied. It runs on the way out of develop, after the sidecar write is queued and never instead of it — the edit is what must not be lost, and a render that failed must not take the save down with it. Two cases are worth the work: an edit made in this sitting, which `can-undo` records even when it ends back at neutral, and an image opened with an edit already in its sidecar and left untouched, whose cached thumbnail has never shown that edit at all. A neutral image nobody touched fails both and costs nothing. Not covered: a batch paste onto a selection, which deliberately never opens a session — there is no rendered frame to take a thumbnail from, and downloading forty RAWs to make forty is exactly what that path exists to avoid. |
||
|
|
3085ec4d2e |
Leave the stars on screen on a touch device
**Hover is not something a finger does, but Slint reports it anyway.** `has-hover` goes true for any pointer event carrying a position, a touch press included, and false again on the `Exit` that follows the release. So the rating strip did appear on a tablet — for exactly the length of a tap. It flashed on under the finger, vanished as it lifted, and the tap carried on through to the cell and opened the photograph. An unjudged frame could not be rated from the grid at all. The previous fix stopped the strip disappearing when a *pointer* moved onto it; this is the same symptom with a different cause, and hover was the wrong signal in the first place. The strip now stands open where the session is a touch one. That is seeded from the platform rather than inferred, because inference needs a press to reach a cell and a quick flick never delivers one — the Flickable claims the gesture before the delay it would forward after — and a control that only appears once the finger is down has appeared too late to aim at. The grid still latches on the first non-zero touch id it sees, which is what covers a touchscreen on the desktop. One-way on purpose: a tablet with a mouse plugged in keeps the strips once touched, which is the harmless direction to be wrong in. The alternative is chrome that comes and goes as the user changes hands. |
||
|
|
2fba685e16 |
Make a pinch zoom the grid and nothing else
Two faults left over from making the gesture reach the grid at all. **It still opened photographs.** Checking the finger id stops the synthetic release Slint emits when the *second* finger lands, but not the other end of the gesture: lifting one finger of two leaves the other one down, and Slint replays that survivor as a fresh `Pressed` on whatever is under it — which is how it hands the pointer back to ordinary handling. Under it is a cell. So the cell was selected, and lifting that last finger was a complete, well-formed click on the same finger that pressed. No part of the event stream distinguishes it from a real tap, so the grid now remembers that a pinch just happened: a latch raised when the gesture starts and lowered a beat after it ends, during which cells take neither presses nor clicks. The press that *opens* a pinch is undone rather than suppressed — it has already happened by the time a second finger makes it a pinch. Undoing it has to be exact, or a cancel that restored the selection but left the anchor moved would make the next shift-click select a run from a cell nobody pointed at, so capture and restore are a tested pair. **And it was not smooth.** Two reasons. The pinch was thresholded into ±1 steps of 25%, so the grid lurched and then sat still; it now takes the ratio since the last update and tracks the fingers, with the drawn cell still landing on whole column counts because the columns divide the width. And `zoom-cells` was the one geometry change still reloading inline — a full catalog re-read, 360-row model rebuild and thumbnail batch per step, on the thread drawing the frame. It goes through the same settle timer as the rest now. |
||
|
|
6ae0af3f72 |
Swipe up in develop for the photo roll
Develop opens one photograph. The grid handed over a path and nothing else, so `index` and `total` were pinned to "1 of 1" on the way in and the only route to the next frame was back to the library, find your place, tap again. Fine once; intolerable through a set of forty, which is the situation the develop view exists for. The roll is the grid's already-loaded window along the foot of the canvas. Swipe up to bring it out, swipe down to put it away — the sheet gesture, already in the hands of anyone who has used a phone — and a handle is drawn at the edge so the gesture is discoverable rather than folklore, and so a pointer, which has no swipe to make, has a way in. `SwipeGestureHandler` wraps the strip rather than sitting over or under it, which is what it is built for: it delays a press the way a Flickable does, forwards it to the children if no swipe develops and claims it once one does, so a tap reaches the thumbnail and a drag does not. It covers only the band along the bottom — above that, a drag still belongs to the photograph, for panning and for the crop. Picking goes through the same path a cell click does, so the outgoing edit is persisted before the next image loads. The strip marks what is open and scrolls to keep the mark in view. The position readout now says where in the *library* the open photograph sits rather than "1 of 1". Set after the open rather than before, since the generic open path resets it — and given as the library ordinal, not the row in the loaded window, which is an artefact of how much has been paged in and would jump about as the window moves. |
||
|
|
d2c909414c |
Stop zooming rebuilding the grid twice a frame
A pinch is not one zoom step, it is a stream of them, and every step changes both the column count and the capacity — two reports. Each report re-queried the catalog, rebuilt all 360 rows of the model, re-read the badges and ratings for every one of them and spawned a thumbnail batch, synchronously, on the thread trying to draw the frame. Twice per step. That is why zooming juddered while scrolling the same grid is smooth: a scroll reloads a few times per screenful, a zoom reloaded twice a frame. None of that work is urgent, because none of it is about which photographs are on screen. The window holds the same images however they are laid out — the model already has them, and the cells re-flow from `columns` and `cell-size` with Rust not involved at all. What the reload actually recomputes is which cells begin a row, so the month headings land correctly, and which thumbnail size class to ask for now. Both can wait for the gesture to finish, so both are now coalesced behind a single settle timer: replacing the timer drops the previous one, and only the last report of a run lives long enough to fire. The anchor is captured on the first report of a run rather than read when the timer fires. As the grid re-flows the viewport keeps its pixel offset while the rows move underneath it, so the view drifts and reports the drift; reading the anchor at the end would faithfully return to wherever it had wandered. Taking it at the start returns to the photograph the user was looking at when they started the gesture. |