b9891e2c04cf415d95d47c0dfa2df603a2ac88da
58
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
1980fda737 |
Show the library on launch instead of scanning first
A launch does not have to discover the library. The catalog from the last run is on disk, complete, with its thumbnails in the shards beside it — exactly the state offline mode already leans on when the server cannot be reached. Every launch that *could* reach the server threw that away. The catalog handle was only opened when the scan reported Done, so the grid sat on "Scanning…" over an empty EmptyState for as long as a recursive WebDAV walk of the whole tree takes. On a real library that walk is essentially the whole startup time, and it was spent hiding a grid that was ready before it began. The catalog is now opened and the first window loaded before the scan thread is spawned — before, so the schema migration cannot race the worker opening the same file, and so the first thumbnail batch is already in flight while the walk runs. The scan still replaces all of it the moment it lands; it just no longer gates the first paint on the network. A first run has nothing to open, which stays silent: `Catalog::open` creates the file, the grid reads an empty catalog, and the empty state goes on saying "Scanning…" — which is true, and which an error here would contradict. |
||
|
|
80f1a210cc |
Keep the view pointing at the same photograph when the columns change
Two faults behind "the gallery randomly glitches to blank and needs a scroll to reset it", and behind the scrolling that skips. Cells are drawn at their absolute place in the library, so the row a photograph sits on is `index / columns`. When `columns` changes every cell moves — and the Flickable's `viewport-y` did not move with them. The view was left pointing at a row that now holds entirely different photographs, typically thousands of images from the ones the loaded window covers, so the grid drew nothing at all. It stayed that way until a scroll reported a first-visible row and dragged the window back under the view, which is exactly the reset the user found. None of its triggers are rare: a resize, the collections sidebar opening, a zoom step, or turning the tablet over. The last first-visible ordinal names the photograph being looked at, so it is now sent back through `scroll-to` and the view lands on that same photograph at whatever row it now occupies. The second fault is the window-move test. At either end of a scope the window is pinned — the first screenful cannot be centred further back than zero, the last cannot start past the last full screenful — so the margin test was unsatisfiable there and every row crossed in the first or last quarter of a window re-read the catalog, rebuilt the model and issued a thumbnail batch to arrive at the offset it already held. On a library of twenty-odd thousand that is a stutter at the top and the bottom of every collection, which is where a cull begins and ends. The decision is now a rule with tests rather than four lines inside the scroll handler: both of its ways of being wrong are invisible in the code and obvious on a tablet. |
||
|
|
deabac0923 |
Carry a photograph's thumbnail across a reload
Work in progress found uncommitted in the tree, committed as its own change so the fixes that follow can be read separately. Not authored in this session; the description below is written from the diff. `load_window` rebuilds every row, and a scroll reloads once the view has travelled a quarter of the loaded window — so three quarters of the cells being rebuilt are the same photographs already on screen. Rebuilding them empty blanked the grid to `Theme.ground` and refilled it a beat later, once a worker had re-read and re-decoded each one from the store. That is the black flash on every screenful of scrolling, and on a column change, a zoom step, a filter and a return from develop. Thumbnails are now held by `image_id` across the swap — a refcount per cell, no pixels move — along with the "no preview" verdict, which is an answer about the file worth keeping for the same reason. `requested` is rebuilt from what the new model actually holds rather than cleared, so a carried cell is not fetched again while one newly scrolled in still is. The size class each cell's pixels came from is tracked alongside, so a grid zoomed past that class still asks for the sharper one. Also guards the whole `scrolled` and `columns-changed` handlers on `show-library` rather than just the resume latch: a Flickable being torn down passes its viewport through zero, which was indistinguishable from a fling to the top and reloaded the window against the first rows of the catalog every time an image was opened. |
||
|
|
6acc73a9fa |
Drag a collection onto another to nest it
The tree could be built nested but never rearranged: `set_parent` existed, with its cycle check and its tests, and nothing in the UI called it. A collection created in the wrong place stayed there. Each row is already a drop target, so it becomes a `DragArea` too — wrapped at the instantiation site the way the grid's cells are, which keeps the row's own TouchArea nested underneath and a click still selecting. `allow-move`, not copy: a collection has one parent, unlike a photograph, which is filed in as many collections as you like. The drop is handed only the target's id, so the source is remembered from the press that precedes the drag — Slint builds the payload through a `pure` binding, which must not have side effects. An image drag always fills `dragging`, so an empty payload with a remembered row is unambiguously a rearrangement; the row is taken rather than read, or a later empty drop would move a collection nobody touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
edcaf42ded |
Show only the photographs taken in the period you are looking at
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Failing after 29s
Build and test / Android (aarch64) (push) Failing after 9m28s
The library could be narrowed by rating, flag and availability, but not by when a photograph was taken — so finding a fortnight meant scrolling to it and holding position. The range rides on `RatingFilter` for the reason `local_only` already does: every query path threads that one struct, so the count in the header cannot claim a total the grid does not draw. Undated images are excluded whenever either end is set — they cannot be inside or outside a span, and drawing them made the range look as though it had not applied. Taken from the timeline rather than typed into two date fields. Finding the period is what the histogram is for, and having found it the user should not have to read the dates off the axis and key them back in. The histogram keeps drawing the full extent while the range is on, or there would be nowhere to widen back out from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d2d5d6f22b |
Keep the selection visible when the grid scrolls under it
Scrolling rebuilds every cell, and a fresh cell carries `selected: false`. `sync_badges` and `sync_ratings` refilled what the rebuild cleared; nothing refilled the selection, so the ticks vanished on every scroll. The selection itself was never lost — it is a set of image ids and survives untouched — which made this worse than losing it: the header buttons still acted on forty photographs the user could no longer see were held. `load_window` reaches the selection through a `Weak` handle set at wiring. Weak because the two controllers are joined only through the window, and an `Rc` each way would leak both; absent, the grid draws nothing selected, which is what it did before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02d629922f |
Draw the date histogram over the collection you are looking at
Build and test / Desktop (Linux) (push) Failing after 57m25s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m45s
The timeline counted the whole library whatever the grid was showing, so opening a collection left a fortnight in Arosa as one column of a fifteen-year axis — an axis describing photographs that were not on screen. Scope the buckets and the span to the same collection and rating filter the grid uses. `timeline_range` counts `images` alone and cannot express the membership join, so the scoped query lives beside the other scoped readers in the UI and shares their descendants-of-scope rule. `catalog_span` now delegates to the same scoped reader. Zoom and scrub measured the full library while the bars were scoped, so a scrub could land on an instant the collection did not contain and send the view somewhere the user had not asked to go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d913e50948 |
Select photographs with a finger, and take a collection with you
Two things a tablet could not do. Both existed for a pointer and had no touch form at all, which on Android meant the collection sidebar was somewhere to look at rather than somewhere to file into. **Selecting more than one.** Ctrl-click and shift-click are the only ways into a multi-selection, and touch has neither. Holding a cell now enters selection mode, where a tap toggles — reported to Rust as a ctrl-press, so it goes through the same `apply_press` as everything else rather than growing a second copy of the selection rules. A double tap takes the run between where selecting began and there: the touch form of shift-click, and the reason the anchor from *before* the double tap has to be remembered, since both of its taps move the anchor onto the cell being tapped. A "Select" button does the same thing where a gesture would go undiscovered (FR-UI-4). **Filing without a drag.** A one-finger drag beginning in the grid belongs to the Flickable that scrolls it — that is the arbitration working, not a bug to route around — so the selection can now be filed from a sheet listing the sidebar's own rows. Copy by default, as the drag has always been; moving out of the collection being shown is a switch, because it is the one that takes something away. **Taking a collection offline.** The machinery was there and reachable only by scoping the grid to a collection and finding a button behind a disclosure. Holding a collection's name now asks the question directly, and the tray on a row and the header button ask the same one — three affordances doing two different things is how a user comes to avoid all three. The question is asked rather than a toggle flipped because both answers are expensive: one downloads gigabytes, the other deletes them, and the counts and sizes go in the buttons where they are read before the tap. `Cache::release` is new and is the destructive half `unpin` deliberately is not. "Remove the local copies" is asked by someone whose device is full, and withdrawing a promise while leaving the bytes for a future eviction to notice is not an answer to it. It unpins before forgetting, or the next pin fetch would dutifully download everything it just deleted. The sidebar's trays read `tier_actual`, never `tier_desired`: the question is whether these will open on the aeroplane, and a pin whose download has not run yet answers no. TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d3c8c521f |
Export a whole selection, on a thread that is not the interface's
The export button rendered, resampled and encoded a 24 MP frame on the UI
thread and the window was dead for all of it. That was written down as a known
compromise, on the grounds that a batch is what makes the wait intolerable
rather than merely noticeable. This is the batch, so the compromise comes due.
The grid's selection now exports (FR-EXP-7). A worker thread takes a clone of
the `GpuContext` — an `Arc` pair over a device and a queue — and opens each
photograph for itself: fetch, sidecar, decode, demosaic, render at full size,
resample, sharpen, encode, write. Nothing of that touches the interface, which
keeps drawing throughout, and the progress goes where every other background
job's does: one row in the activity register, with a count and a bar.
Why the worker does not borrow the session it could have had. A
`DevelopSession` owns the `AdjustPass` the canvas renders from, so handing it
to a worker would stop the develop view drawing for the length of the batch —
the same freeze, moved. Opening a session per image instead costs a
`Demosaicer` and an `AdjustPass` each time round, and the pipeline cache is
per-pass so the composed shader is recompiled per image rather than once for
the run. Against a full-resolution decode, render and encode that is a few
percent, and it keeps this file out of the pipeline `develop` owns. A reusable
export pass is the obvious next economy if a profile ever says so.
The open image is the exception, and it is why the develop button is not simply
a one-image batch. Its edit lives in the interface's session and may not have
reached a sidecar yet, so a worker that re-opened the file would export the
saved version rather than the one on screen. That frame is therefore rendered
by the caller and handed over as `Source::Rendered`; everything after the
render — the Lanczos reduction, the encode, the write, which is the larger half
of the wait and all of its variance — still leaves the UI thread. So the
develop export is no longer synchronous, but it is not fully off-thread either,
and the doc comment says so rather than claiming otherwise.
Cancellation (NFR-ARCH-3) is an `AtomicBool` read between stages, and the
export button becomes the cancel button while a run is live — a batch that
could only be stopped by not touching the selection would be a trap. Waits on
another worker use `recv_timeout` rather than `recv`, so a cancelled batch
sitting on a forty-megabyte download gives up within 100 ms instead of when the
transfer finishes. The honest bound is worse than that: a frame already in
render has no interior stopping point, so the worst case is one image. Closing
that needs the render itself to become interruptible, which is NFR-ARCH-2's
scheduler and not a finer poll here.
Failures are per image and typed (NFR-ARCH-4). One unreadable body, one folder
that cannot be written, one server that went away — each is a message on the
channel, a line in the log, and a count in the summary, and the batch carries
on. A run with any failure keeps its row until it is cleared, because that is
the row somebody came to the list to find; a cancelled run does not, because
they asked for it.
Two collisions that look alike and are not. `CollisionPolicy` is the user's
answer to "a file of this name was already there", and Overwrite is a fine
answer to that. It is not an answer to "the frame I exported four seconds ago
was also called this" — two folders in a library each holding an IMG_0001 is
ordinary — so a name the run has already issued is always stepped past whatever
the policy says about the folder. Both halves are held by tests.
Supporting changes, each smaller than it sounds. `open_session` comes out of
`load_bytes` so the worker shares the JPEG-versus-RAW routing rather than
carrying a copy that would drift; the half that builds a `slint::Image` stays
behind, where it belongs. `LibraryController::selected_image_paths` answers
from the catalog rather than from the loaded window, because selection is by id
and survives a scrub — a selection made before scrolling routinely names
photographs no row holds. `cache_context_for` takes an id for the same reason,
so a batch reads the originals cache instead of re-downloading three hundred
files. `format_date` is shared so `{date}` and the timeline agree about what
day a photograph was taken.
Left undone, deliberately: the batch is sequential, where FR-EXP-7 asks for all
available cores. Four full-resolution frames in flight is tens of megabytes
each and a straightforward way to exhaust a tablet, and the GPU is shared with
the interface in any case. Also undone: exporting with a chosen preset rather
than the current export settings — that is FR-EXP-5's machinery, which does not
exist yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
cb1d2be240 |
Choose the export folder by walking the server, not by typing it
The destination for a Nextcloud export was a text field. Nobody recalls the exact spelling of a path three levels down, and getting it wrong does not fail — `create_dir` makes whatever was typed, so a misremembered folder becomes a new one at the root and the exports are somewhere nobody looks. So it is picked the way the library root is picked, using the same `FolderBrowser` model the launch screen drives: up, into, and "use this folder", confirming the folder currently *shown* rather than one selected in the list. Same rule in both places, so the phrase means one thing. The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a near-twin of the launch screen's, because that one reaches into the `LaunchController` for its session and reports onto the launch screen's error line, while this one is handed credentials and writes to the settings page. Factoring them together needs a function taking both controllers or a trait implemented twice to abstract two call sites — more machinery than the twenty lines it saves. What matters is shared already: navigation behaves identically because both drive the same model. The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`, because listing a remote folder needs credentials and the settings page holds no session on purpose — it is reachable before a library is opened and must not depend on one existing. With no account the picker says to sign in first, rather than showing an empty list that reads as a server with no folders. Details that are decisions rather than accidents: the picker opens at the library root rather than at whatever half-typed path is in the field, which would list nothing and look broken. The listing area is a fixed 180px, since a folder with sixty children would otherwise push the rest of the settings page off the bottom. "Up" is disabled at the root rather than hidden, so the row does not jump as the user navigates. A failed listing leaves the picker open on the folder it was showing — where the user had got to is not something to discard over a dropped request. And the chosen folder saves immediately like every other setting on a page that has no Save button. The poll timer lives on the controller for the reason `LaunchController` keeps its own there: a `slint::Timer` stops when dropped, so one local to the function that starts it would be collected before the listing arrived. Carries in-flight work from a parallel session — a segmentation pass in dr-gpu, a sidecar cache, and the develop panel's continuing changes. 1020 tests pass, fmt clean. One clippy warning remains and is not mine: `sidecar_cache::dir` is unused while that work is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e00c99b864 |
Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is the button, and the place the bytes go. **Everything is staged first.** An export bound for the server is written to a local outbox and uploaded afterwards; offline is not a special case, it is the same path with a drain that finds the server absent. Doing it the other way — upload directly, stage only on failure — makes the failure path the one that is rarely exercised and always broken, and a network drop mid-batch leaves some exports existing and some not with nothing recording which. Staged first, an export is finished the moment it is written and the upload is a promise kept later. The outbox sits beside the catalog rather than under the cache. dr_catalog's cache already draws that line: passive entries are a convenience and go under LRU, pinned ones are a promise and never do. An export awaiting upload is a promise — the user was told it succeeded — and sweeping it for disk would destroy the only copy. Bytes are written before the destination record, so a kill between the two leaves an orphan the drain ignores rather than a record pointing at nothing. The status line says "Queued for Exports/2026", never "Exported to Nextcloud", until it has actually landed. There is a test asserting that wording, because the tempting shorter sentence is a claim the app cannot keep. The drain runs on the sync pass, before the shards: a thumbnail shard can be rebuilt from the originals and the catalog is an index, but a queued export exists nowhere else. `DevelopSession::render_for_export` renders the framed size rather than reusing the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding that would hand the user a soft, screen-sized file with nothing to say anything had been lost (FR-EXP-9). One compromise, recorded rather than hidden: the export runs synchronously on the UI thread, so the window is unresponsive for the few hundred milliseconds a full-resolution render and encode takes. Moving a DevelopSession and its GPU pass to a worker is a larger change than one button earns, and it is batch export that makes the wait intolerable rather than merely noticeable. Still missing: the Nextcloud folder *picker*. The destination is typed into Settings for now. `FolderBrowser` in launch.rs is already the reusable model for it — it browses a remote tree and nothing about it is specific to choosing a library root — but wiring it into the settings page needs a listing worker and browser UI there, which is its own piece of work. Carries in-flight work from a parallel session — presets, the develop copy and paste, and the node schema's `presentation` and `enum` support. One misplaced callback in settings_ui.rs is moved from `render` to `wire`: registered in `render` it borrowed a `&SettingsController` into a 'static closure and would not compile, and that file's own docs say render pushes properties while wire connects callbacks. 992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
65e6a96a65 |
Say what the application is doing, in one bar and one list
Every background job reported into a window property of its own — library-thumbs-done, library-pin-total, library-syncing — which only the grid ever read. A pin download that outlived the view it was started from drew nothing at all once the user opened an image, and there was no answer anywhere to "what is this busy with", because the answer was spread across eight properties nothing collected. They report to one register now (ui/dr-ui/src/activity.rs). It publishes an aggregate, which draws a three-pixel bar across the top of the shell in every view, and a row per job, which the settings page lists: scans, thumbnail batches, pin and open downloads, sidecar uploads, the sync and the trash. Failures stay on the list until they are cleared; routine successes do not, or a scroll would bury them. The handle removes a still-running job when it drops, so a worker that dies mid-transfer takes its row with it rather than leaving the bar sweeping for the rest of the session. Also carries in-flight work from a parallel session — the drawn icon set and the dr-pipeline ops split. dr-pipeline's build script does not compile at this commit; ui/dr-ui does, with clippy clean and its tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |