6c363cee979ae8b76789f69df991dc8aa01c2839
108
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6c363cee97 |
Let the launch screen scroll, now that it offers three routes
The signed-out screen was a `VerticalLayout { alignment: center }` with
no scroll. That was already tight with two routes; the folder option
made the column taller than a 900x560 window, and a centred layout that
overflows clips at *both* ends — so the masthead and the last route
disappear together, with nothing on screen to suggest either existed.
A Flickable whose viewport follows the content, and a top padding that
centres the column only while it fits. Both cases checked against the
running app: centred at 1000x1800, top-aligned and scrollable at
900x560.
|
||
|
|
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.
|
||
|
|
c81d6865a7 |
Stop the name field running through the buttons under it
The Identity header was two rows pinned to 32px and 36px. Neither number was big enough for what the row held: a `Field` is `Theme.touch-target` — 44px — and a `Button` is `Theme.control-height`. Slint honours a child's own height and lets it overflow the box the layout gave it, so the name field drew 44px from the top of a 32px row while the button strip began at 38px. The overlap was 6px of text box sitting on top of "Confirm all". Neither row states a height any more. The first takes the height of what is in it, and the strip takes the height of a control — read from the theme rather than from the row inside it, since `actions` sizes itself from the Flickable's viewport and measuring it back would be a binding loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c7cdf7e5f0 |
Merge: face quality gates, and identity filters that combine
Build and test / Desktop (Linux) (push) Successful in 32m56s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Successful in 39s
🐳 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 52m43s
Two things the People screen was missing. Faces were being indexed at any size and any sharpness — 40 pixels on the box and no blur gate at all — so most of what the library held was background strangers and motion-blurred passers-by, and the blurred ones were quietly bridging unrelated clusters. Both floors are now measured on the real library with `face_index --quality` rather than guessed: 64 source pixels across the aligned crop, and a contrast-invariant sharpness of 0.020. And the grid could only ever be narrowed to one person, which cannot express "the pictures the two of them are in together". The filter now holds a set with a union/intersection mode, built a person at a time from the Identity screen and taken apart chip by chip on the filter bar. fmt, clippy -D warnings and the full workspace suite pass on the merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
329c388d30 |
Merge: each canvas overlay goes home to its own domain
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h23m17s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Successful in 25s
Build and test / Android (aarch64) (push) Failing after 52m22s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
4fa914cbb1 |
Send each canvas overlay home to its own domain
The crop rectangle, the gradient handles and the repair discs were 480 lines inside `canvas-area` in app.slint, while the panels that drive them already lived in adjust.slint, masks.slint and spots.slint. spots.slint even opens by describing "what is drawn over the photograph, and what a finger can take hold of" -- which was not in it. They stayed behind because all three are positioned against `shown-*`, the fitted image rect the develop view derives because Slint does not report it. That is now the interface rather than the obstacle: each overlay is *given* that rect as its own bounds, so every position inside is a plain fraction of `root.width`, and none of them reaches out to `canvas-area` for an origin any more. GradientHandles joins MaskPanel in masks.slint and SpotHandles joins SpotPanel in spots.slint, each file now holding one domain's panel and its canvas overlay together, matching masks_ui.rs and spots_ui.rs. CropOverlay gets crop.slint of its own rather than growing adjust.slint. Arithmetic is unchanged: the old fraction-x subtracted shown-x from a coordinate measured relative to canvas-area, and the new one measures from an origin that already is shown-x. The handles stay unconditional rather than gaining an emptiness guard, so the repeater identity that spots_ui::sync_handles warns about is untouched. app.slint: 2834 -> 2490 lines. Verified by running the desktop app on a photograph, with the crop overlay's guard temporarily forced open so all three instantiate -- no binding loop, no panic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ffc9e1aea7 |
Give the develop view's chrome a file of its own
StatusBar and InfoPanel sat above AppWindow in app.slint, which read as though they were part of the application shell. They are not: neither is instantiated anywhere but the develop view, and the shell's actual job -- choosing which of the five screens is up -- is easier to follow without two unrelated components standing in front of it. Moved verbatim to develop.slint, matching src/develop.rs. No behaviour change; app.slint loses 232 lines and gains one import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cafa63ca6f |
Let the develop column ask how wide it needs to be
Build and test / Desktop (Linux) (push) Successful in 21m53s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Successful in 32s
🐳 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 53m45s
The column was 280px, a number chosen for a tablet, with 380px bolted on later for a desktop. Both were guesses at how much room the widest row inside needs, and a guess is what cannot work here: the mode strip is one chip per attribute the *operation set declares*, so the row is generated and no constant in app.slint can track it. When the guess came up short the failure was not a tidy clip. The Flickable inside the column never had its `viewport-width` set, so the viewport took its content's preferred width, and a viewport wider than its Flickable is *centred* in it — the same rule the note on the seam's `x: 0` already records a few lines below. So the column lost half of each edge rather than one of them: "HISTOGRAM" read "ISTOGRAM", "Straighten" read "aighten", Copy sat centred while Paste ran off the far side. It looked like a rendering fault and it was an alignment one. So the column asks instead of guessing. Every panel that can appear in it — image, histogram, geometry, settings transfer, masks, repairs, adjust, history — now publishes a `content-width`: how wide it has to be before it starts clipping itself, read off its own layout rather than asserted. Each declares that as its `min-width` too, and that is what makes the aggregation automatic: `column` is a layout, so it already reports the largest minimum among its children, and it does so for the panels that come and go with the mode as well, which live inside `if`s and cannot be named from outside. Grep `content-width` in ui/dr-ui/ui to see every panel with a say in the answer. The mode strip is named explicitly only because it is pinned outside that layout, so nothing else measures it. There is no floor left. A floor is one more guess and the panels state their own minimums now. The only thing still above the measurement is `panel-max-width`, which is not a size but a policy — a column may not take the window from the photograph it exists to serve — and it comes from Rust beside `layout-class` because a width read from `root.width` inside the layout that `root.width` depends on is a binding loop. 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> |
||
|
|
28c046130c | Merge branch 'master' into android-bundled-face-models | ||
|
|
510d1a26cb |
Regenerate the traceability matrix
The platform layer was never scanned, so every FR-PLAT-* and NFR-PORT-* tag in dr-plat was invisible. Coverage 51.4% -> 57.6%, almost all of it pre-existing tags that were simply not being counted. |
||
|
|
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>
|
||
|
|
131004393d |
Follow the canvas from one display to the next
The rest of FR-DSP-8. The develop session now carries the space its canvas is encoded into, and `render` composes for it instead of for sRGB — which is the whole of the change to the pixel path, because the output space was always a parameter of composition and always entered the structure hash. A display change is a recomposition. The space is set on the way into every render rather than pushed when the window moves, so a photograph opened while the window already sits on the second monitor is right on its first frame instead of flashing the wrong colour until the next poll. Which display that is comes from sampling the window's position and scale factor twice a second — Slint reports neither a move nor a display change — and re-surveying only when they differ. Settings shows what came back under ABOUT: the display, the space, why, and the other monitors, because the failure FR-DSP-8 names is one that is invisible from the display you are reading the page on. Fractional scaling: the canvas is now rendered at the physical pixel size of the box it occupies rather than the logical one, so the compositor presents it 1:1. At 1.25 it was previously handed 1600 samples to fill 2000 device pixels, and the softness that produces reads like a bad demosaic rather than like a scaling bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
57c0cc0d35 |
Keep the name typed into a cluster when the next one is opened
Naming a cluster and moving straight to the next is the gesture this screen
exists for, and it discarded the name every time. Two causes, both in the same
four lines.
`Field.text` is two-way bound to its `TextInput`. Binding it to `selected-name`
therefore works exactly once: the first keystroke writes through the `<=>` and
**replaces** the declarative binding, after which the field follows nothing.
Switching clusters left the previous cluster's half-typed text on screen,
attached to the new person.
And `Field` only reported `accepted`, which is Enter. A name typed and then
abandoned by clicking the next face never reached Rust at all.
So `Field` gains an `edited` callback, the screen keeps the draft with the
person it was typed for, and the draft is written when the selection moves or
the screen closes. The field is then reset from a revision counter the screen
watches.
A counter rather than `changed selected-name`, because the name is not a key:
naming six clusters "Anna" in a row never changes `selected-name`, and the field
would keep the half-typed text from the cluster before. Nor `changed
selected-person`, since accepting a namesake merge lands the user back on a
person they may already have been on.
The draft carries its `PersonId`. A reload can move the selection out from under
a half-typed name — a merge arriving through a sync, a deletion — and applying
it to whoever is selected now would rename a stranger. If the person is gone
when the draft lands, it is dropped rather than resurrecting a row the rail no
longer shows.
`None` and `Some("")` are kept distinct. A user who cleared the field means to
clear the name; a user who never touched it means to leave it alone. Collapsing
those two erases names by walking past them.
An implicit commit does not raise the namesake merge offer. That question is
about a screen the user has already left, and answering it on their behalf while
they look at the next cluster is not a question at all — the offer stays on the
explicit submit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
725f7bf77f |
Offer the merge when two people turn out to share a name
Over-clustering is the normal state of a freshly indexed library — FR-CULL-10 says so — which means one person arrives as several groups and the user names each of them the same thing. Until now that produced several people called Anna and no way to join them: `identity::merge` existed, `faces::merge_people` existed with its redirect tombstone, and `merge-into(int)` sat in identity.slint declared, never emitted and never wired. The screen had a split button and no merge. So a rename that collides now offers one. Type a name another person already carries and a strip appears under the field: "Someone else is already called Anna (14 faces). Merge them?" Offered, not performed. `people.uuid` is the identity and the name is not — the schema comment on that column is explicit that two devices naming the same cluster independently is the case it was built for — so two people sharing a name is legal, and folding them together on a keystroke would be the screen making an identity decision on the user's behalf. That is the thing this screen spends a whole button avoiding. The rename always lands first, and declining leaves it exactly as typed. There is nothing to undo because nothing was done. Details that are not arbitrary: The comparison is trimmed and case-insensitive. "anna" on a phone keyboard and "Anna" on a desktop are one intention, and an offer that appeared only when the capitalisation matched would read as a bug. An empty name collides with nothing. Every unnamed cluster renders as "Unnamed (n faces)"; if that counted as a collision the offer would appear on every cluster in a fresh library, and accepting it would fold the library into one person. The newly-named person folds into the one that already held the name, not the reverse. The older person is the one other devices have seen and the one whose confirmations are more likely to be real. Selection follows the merge, because landing on an empty screen after a successful action reads as a failure. The offer is retired when the person changes, and when a refresh finds its target gone — merged from the other side of a sync, or deleted. An offer left standing would fold whoever happens to be selected now. A merged-away person is not a namesake: `faces::people` already excludes redirects, so the offer does not reappear the instant it is accepted. Four tests over the collision rules, and the existing 467 still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d777f7f44d |
Merge branch 'master' into worktree-faces-scrfd-mbf
Build and test / Desktop (Linux) (push) Failing after 25s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Successful in 58s
🐳 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 33m26s
# Conflicts: # docs/traceability.md # ui/dr-ui/src/develop.rs # ui/dr-ui/src/segmentation.rs |
||
|
|
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> |
||
|
|
61c4547b9c |
Make the Identity Manager a peer of the library and develop
It was reachable only from the library header, which made it a side trip rather than a mode. It is now reachable from develop's header too, beside the way back, because that is the same kind of move -- leaving this photograph for somewhere else in the library -- and a screen you can only reach from one of the other two is not a peer of them. Leaving returns to whichever screen opened it, and the button says which. A back button that read "Library" while returning to develop would be lying about the one thing a back button has to be right about. The develop session is only hidden, never torn down, so returning to it costs nothing and keeps the photographer's place. The header now matches the other two screens rather than using a close cross: three screens whose headers disagree read as three applications. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d7a81375ee |
List the steps, and let a photographer step straight to one
Build and test / Desktop (Linux) (push) Successful in 19m30s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m5s
Undo answers "take back the last thing", which is the question asked about a mistake just noticed. It is the wrong instrument for one noticed six adjustments later: eight presses, each changing the picture, with no way to see how far back the mistake is without passing through it. A step is a whole state, so arriving from six away costs what arriving from one does — which is what makes a row worth making clickable rather than decorative. `Edit::Discrete` had to go for the list to be worth drawing. Seventeen call sites recorded the same anonymous step, which is fine for deciding whether two changes are one gesture and useless for a panel: seventeen rows reading "Discrete" is not a history. Every variant now carries enough to name itself, and the compiler enumerated the sites that had to start saying so. A step that moved a parameter is still named out of the descriptor, so an operation added as a YAML declaration appears in the history correctly named with nothing written for it (FR-DEV-3c). Choosing a film stock was not undoable at all. The pick went straight to `choose_film`, which nothing on the history's path ever sees. `pick_film` records it, and is separate because the same call is also how a *restored* edit gets its tables back — recording that would push a step for the undo the photographer had just asked for. The list is rebuilt off a revision rather than off every redraw. A drag ends in a redraw per frame while folding into one step, so the unconditional version would tear down and recreate every row sixty times a second to arrive back at the list already on screen. The counter is process-wide: a per-instance one starts every photograph at the same number, so a frontend holding "the revision I last drew" would keep the previous image's steps on screen — invisible while every image opens with one identical row, and a wrong-photograph bug the moment persisted history means it does not. The step names that no descriptor can supply are constants with a roll, and a test walks the roll rather than a second copy of it. `resolve` splits so that "is this catalogued?" can be asked: `derive` turns `history.mask_toggled` into "Mask Toggled", which names a field rather than an act and, being perfectly readable, is a mistake nobody would look at twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
26a1eb7e28 |
Record that face detection has run, not just what it found
An image with no faces in it was indistinguishable from one that had never been looked at, so every landscape, still life and document scan in the library was re-detected on every pass, for ever. In a real library that is most of it: on the 23,527-image test library, 64 of the first 110 images indexed contain no face at all. Schema v9 adds face_index, a run marker per (image, model) carrying the face count and the proxy edge it read. Keyed on the model, so a model change puts every image back in the queue by itself. That makes a coverage figure possible, which is the thing a user actually wants to see. The audit also splits the outstanding set by whether a proxy exists, because 23,417 awaiting a proxy and 110 ready to index are different problems, and telling the user to run indexing again would not fix the first. The Identity screen gains Index faces, Stop, and the coverage line. examples/face_index.rs is the same check and sweep without a window, which is the right shape for an overnight pass. Measured on the real library in release: 3.5 images/second, 110 images and 125 faces in 30 seconds, and a second run correctly finds nothing left to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
10b10569e2 |
Add the Identity screen
A third top-level screen beside the library and develop, because naming a cluster and pulling a stranger out of it are tasks with their own rhythm and need the whole window. The screen is designed around the clustering being wrong, which is FR-CULL-10 rather than pessimism: grouping over-merges on siblings, on parents and children, and on the same person a decade apart. So Split off sits next to Confirm all rather than behind a menu, the confirm/reject pair is on the face itself, and a group the system found is drawn differently from a person the user has vouched for. Splitting rejects before it confirms. Without that the next clustering pass suggests the face straight back and the user's correction becomes an argument they keep having. Face crops come from the proxies the grid already built, one decode per image rather than per face -- a group photograph holding six faces of one family is one JPEG. Where the calibration is not fitted the screen says confidence is unavailable instead of printing a percentage that looks measured, which is FR-CULL-9's rule at the point it becomes visible. The verdict controls use drawn icons, not tick and cross characters: ui/icons.slint exists because those render as tofu on Android. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8ab9440190 |
Put the repair tool on the photograph
A third chip beside Crop and Local, and the mode strip's own comment predicted the shape: a mode that arms a gesture on the canvas and scopes the column. Click a mark to cover it, drag the disc to move the repair, drag the source circle to say where the patch comes from, Delete to remove it. The source starts two and a half radii towards the middle of the frame, which is FR-DEV-8's automatic placement in its cheap form — dust sits on skies and skies are smooth, so it is usually right and always one drag from fixed. Two things are drawn deliberately. The circles are the size the repairs actually are, because whether a disc covers a speck is the whole judgement being made and a fixed-size dot would say nothing about it; the reach around them is padded to a touch target so a spot on a dust mark can still be picked up on a phone. And only the selected repair shows its source: a dusty sky carries a dozen, and two dozen circles with nothing saying which belongs to which is less information rather than more. The panel edits what is stored while the canvas draws what is mapped, and the two are pushed separately for that reason — a slider deriving its value from the drawn radius would move differently at different zoom levels. It is also the one panel built from SliderRow rather than a live track: a repair has no OpId to coalesce a drag under, so a row that fires once per gesture is what keeps undo one step per decision. Verified as far as this environment allows: the strip renders and the column re-scopes, photographed under XWayland. Synthetic clicks do not reach this application, so the gestures are as-written rather than as-felt, and docs/spot-removal.md says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6d18517d28 |
Ship every stock that exists, black and white included
Three profiles was what the first cut needed to prove the model. This is the rest of the open data: 23 camera stocks and 9 papers, which is all of spektrafilm. Black and white was the gap, and it turned out not to be a gap in the data -- it was a gap in where I looked. Upstream's `main` has 28 colour profiles and nothing monochrome; `dev` has three more, and they are Tri-X, Double-X and the 2302 print film they go onto. So the answer to "do we have B&W" was yes all along, and it needed the dev branch rather than a fortnight digitising Ilford's datasheet graphs by eye. Those three are pinned to `dev` per stock; the colour stocks stay on the released branch. A monochrome profile is single-channel -- one emulsion, not three -- and spreading that one layer across all three is exact rather than an approximation: three layers with identical sensitivity and identical curves respond identically, which is what one layer does. The dye is the trap. The renderer *sums* the three layers' contributions, so replicating it unchanged renders every frame three times too dense -- neutrally, and therefore plausibly. A third each reconstructs the single emulsion, and two tests hold both halves: that the densities stay equal, and that they sum to one emulsion and not three. Double-X and 2302 ship five curves apiece, measured at five development times -- 4 to 12 minutes for Double-X. That is push and pull processing as measured data. The standard 6.5 minutes is what ships; the rest is in the upstream file waiting for a control to ask for it. Two stocks are `support: film` and are nevertheless what a negative is printed *onto*: the cine projection films 2383 and 2393, which the Vision3 stocks print to. Filtering the picker on support alone offered a projection stock as something to load in a camera, so it filters on stage, with a test saying so. The picker had to change shape twice over. Chips were right for three stocks and off the edge of a 280px column at twenty-four, and the column that replaced them was a thousand pixels standing between the photographer and every slider below. It is a disclosure now: one row carrying the answer, opened to change it, closed again on choosing. That is the opposite of the argument this panel used to take the lids off its sliders, and deliberately so -- an instrument you compare wants to be visible, and a list you consult once wants to be out of the way. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
b0b6dd559a |
Show what is selected, and let a collection be made of it
Build and test / Desktop (Linux) (push) Failing after 2m37s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Failing after 6s
Three things a selection needed and did not have. **Seeing it.** The count existed — "12 selected" — in the header row, which scrolls sideways. On a tablet it sat past the right-hand edge along with every button beside it, so a selection was something you could make and then not see. A selection you cannot see is one you act on by accident. **Putting it down.** The only way to clear one was "Done", which also leaves select mode — so after filing forty photographs the next forty began by re-entering a mode the user had not meant to leave. Clearing is now its own action and keeps the mode. **Filing it somewhere new.** Making a collection of a selection took four steps: create one, find it in the tree, select the photographs again because creating it changed the scope, then add them. It is one press, which is how a selection is usually meant — it is gathered *because* it is going somewhere. The new collection is created at the top level rather than inside the current scope, unlike the tree's "+". A selection can be gathered from anywhere, including across collections, so filing it under whichever one happens to be open would put it somewhere its contents did not come from. It opens straight into its name field, for the reason `collection_new` already does: the placeholder name is nobody's choice, and making the user find the rename afterwards is asking them to finish a job we started. All of it on its own strip beside the date range's, appearing only while there is a selection — the third control this session that was invisible for being put in a row that scrolls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5a9beedca6 |
Give the range's ends a row of their own, not a corner of someone else's
Build and test / Desktop (Linux) (push) Failing after 2m32s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Failing after 1m3s
Build and test / Android (aarch64) (push) Failing after 7s
Third attempt at one control, and each failure was a different reason it could not be seen. It narrowed to the whole library, because it took its span from a timeline zoom that is zero until someone zooms. The fields that fixed that went into the chip row, which scrolls sideways, so they sat past the right-hand edge. And moving them "below the chips" put them inside the same `Rectangle` — which stacks its children at the origin rather than laying them out, so they were drawn over the chips inside a strip 34px tall, unconstrained in width and clipped in height. A `Rectangle` is not a layout. The row is a sibling of the chips' strip in the header's `VerticalLayout` now, with a height of its own and the width of the window: two 108px fields, a "to", and a warning when what was typed is not a date — about 354px, against 768 on a tablet in portrait. Left-aligned and inset by the same gap the chips use, so the two rows begin on one vertical line instead of a few pixels apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
db43d8ec8b |
Put the range's ends where a tablet can see them
"Limit to range" looked broken a second time, for a second reason. The ends were added to the filter chip row, and that row scrolls: its own comment records that fourteen chips do not fit across 768 logical pixels, "so that is every tablet in portrait". Two date fields and a caption went straight past the right-hand edge, into the part of the row that has to be panned to. So the fix for a control that appeared to do nothing was itself invisible, and pressing the chip still looked like it did nothing. They have their own line now, below the chips and outside the Flickable, and it exists only while a range does. Nothing competes with it for width, and nothing has to be panned to reach it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b3641b5307 |
Let a subject's mask be refined, and let several masks be edited at once
Two related gaps in the mask panel, from the same conversation: a subject's outline is only ever as sharp as the whole-frame pass that found it, and a change meant for several layers had to be dragged once per layer. ## Refine mask A "Refine mask" button on a subject layer re-runs detection on a padded crop around that instance's own box instead of the whole frame — the subject reaches the model at its own size rather than squeezed into the model's fixed 640x640 window alongside everything else in the photograph. `RefineJob` mirrors `SegmentationJob`'s split (built on the session, run off it, adopted back), and the crop itself is rendered through `Framing::set_view` — the same ephemeral viewport the interactive zoom already uses to render a region above proxy resolution, so no new render path and no change to the model's own input size was needed. `dr-segment` is untouched: `Tiling::Whole` already treats whatever buffer it is handed as the one window. The result is still downsampled onto the shared proxy grid every instance's mask lives on, but from a sharper source than the whole-frame pass ever saw for that subject, which is what the edge actually reads out of. ## Multi-select `active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain click still replaces the selection; a control- or command-click toggles one layer in or out of it. `set_param` and `reset_op` fan out to every selected layer, each set to the exact value the slider now shows rather than offset by however far it already was — one slider, one reading, applied everywhere selected. Dragging a gradient's on-canvas handle is deliberately not extended to multi-select: several gradients have no single geometry a shared handle could move, so `gradient_handles` stays empty unless exactly one layer is selected. Co-Authored-By: Claude Sonnet 5 <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> |
||
|
|
a8969c149e |
Move the instrumentation off the header and into About
The render backend, the layout class and the frame rate sat permanently in a 44px strip that also carries the only way out of develop, the undo pair, the panel toggle and the export button. They cost about 200 logical pixels, and a `HorizontalLayout` given less width than its children's minimums does not shrink them — it runs off the end. There was already a breakpoint hiding them below 820px, which is why a tablet in portrait (768) looked fine and landscape (1200) did not: above the breakpoint the readouts came back and pushed the header off the right-hand edge. A breakpoint that hides a problem at one size and not another is a workaround, and this removes the reason for it rather than moving it. They are diagnostics — read once when something looks wrong, and then not again — so they are in Settings under ABOUT, beside the version, which is where someone goes when they have a bug to report rather than a photograph to edit. The version is there for the same reason and comes from `CARGO_PKG_VERSION`, so the line cannot disagree with the binary showing it. The frame rate keeps its warning hue. It is the number that says whether the zero-copy display path is holding up, which is the assumption the whole display design rests on, and that is worth colour wherever it is shown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7fcb8dc107 |
Give the develop column room, and the mask a way out of the way
Three faults reported together, and they share a shape: each is something the panel decided on the photographer's behalf. **The column was 280px on every screen.** That width was chosen for a tablet, where the column is a large fraction of the display and every pixel of it is taken from the photograph. On a desktop window the mode strip alone — two modes, a separator, "All", and a chip per attribute the operation set declares — does not fit, so it scrolled sideways. A control you have to pan to reach is one you do not know is there. 380px when the window is classed expanded, 280px when it is not; driven off `layout-class` because reading the window width inside the layout that sets it is a binding loop. **The overlay could not be hidden.** An earlier "Overlay" button was removed for a good reason — it *armed* the overlay, so local mode could be entered and still show nothing. Hiding is the opposite need and was never served: a mask is judged against the photograph beneath it, and that photograph is exactly what the overlay covers. `overlay-hidden` is kept separate from `overlay-on` so a recompute cannot switch the overlay back on under someone who just turned it off. **The masks were coarse because the model saw the subject small.** The graph's input is a fixed 640x640 and every frame is letterboxed into it, so a bird 200px across in a 1600px proxy reaches the model at 80px. `Tiling::Grid` has been implemented and tested since the segmentation spike and defaulted off, because it costs one inference per tile — 2.8s for a 3x2 grid against 470ms. Now offered as "Look closer (slower)", which says what it costs, rather than spending it on every image or on none. The tiling choice enters the segmentation signature. A mask stores the signature its region ids index into, and a tiled run finds different instances in a different order; sharing a signature would silently reinterpret a layer built against the coarse pass — a wrong mask rather than a stale one, and nothing announces it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
85aae25d0f |
Let the header readouts shrink instead of pushing the controls off screen
Found uncommitted in the working tree and committed as its own change rather than folded into the sync work beside it: the three header readouts gain `overflow: elide` and `horizontal-stretch: 1`, which is what actually lets a Text shrink under pressure in Slint. Without the stretch they kept their full natural width and pushed the sidebar toggle and the More button past the right edge on a narrow window, reachable only by knowing to flick-scroll. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d04087af83 |
Import into the library, which is on the server
There was a local destination, defaulting to ~/Pictures, and an "upload" switch that could be turned off. That was wrong twice over. DarkRoom's library *is* a folder on a Nextcloud server (FR-NC-6) — there is no local library — so a user-chosen local destination built a second pile of photographs that no view in the application ever lists, and made "where did my import go" a question with two answers. An import now has exactly one destination and the page asks nothing about it. With no account there is nowhere to go at all, so Import is refused rather than quietly filling a folder. What lands on the device is a staging copy in a directory the app owns, the same shape `export` uses for its outbox and for the reason its module docs give: staging first is the only path, not a fallback for being offline. The bytes have to reach disk before the network — streaming a card straight to the server would let a move-import erase a card against an in-flight upload, and would make importing impossible with no connection (FR-NC-10). A staged file is removed once the server confirms it; one that is not confirmed stays queued, and the next import drains it. And the rule that was stated but never enforced: `retirable` was reported and `dr_ingest::retire` was never called by anything, so a move-import silently behaved as a copy. The card is now emptied by the worker, of exactly those photographs the *server* has confirmed — not those merely written here, because the staging copy is removed moments later and anything unconfirmed would then exist nowhere at all. FR-NC-7b said "copied locally first ... then queued for upload", which is a staging area; it has been rewritten to say so in terms that do not also permit what was built. 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> |
||
|
|
28325af448 |
Make the thumbnails while the originals are still in hand
An import read every byte of every original, uploaded them, and then left the grid to fetch a preview range back out of each one over the network — for files that had been on this machine minutes earlier. The pixels are now made from the bytes already in memory, on the fast path FR-CAT-3 names: locate the camera's own embedded JPEG, decode a few hundred KB, downscale, orient. Never a demosaic. A file with no usable preview yields none, which is not an error and simply leaves the grid to fetch one later. The filing waits for the upload, and only the filing. The shard store is keyed by `oc:fileid` (FR-NC-5) and that does not exist until the server has the file — so the thumbnail is made from the local copy and held until an id can be attached to it. One listing per folder supplies every id at once, rather than a PROPFIND per photograph over a link that may be mobile data. Best-effort throughout: a thumbnail that cannot be filed costs the grid one preview fetch later, and failing an import over it would be the wrong trade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b1bf68022d |
Do not offer an import where one cannot be done
The Import button went into the library header unconditionally, so an Android user got a page that opens, finds nothing, and cannot be pressed — worse than no page at all, because it reads as broken rather than as absent. `dr_plat::imports_supported()` answers the question the interface actually has, which is not "did we find a volume". An empty list on Linux means plug one in; false here means it cannot be done on this device however hard the user tries. It is false on Android for two reasons that both have to be fixed before it changes: there is no mount table to read and no path to type, and nothing implements WritableStorage except LocalStorage. The button is hidden rather than disabled. The buttons beside it come and go with the selection — unavailable now, available in a moment — where this one never will be here, and a permanently disabled control teaches the reader that the row lies. What this gates is the interface, not the engine. dr-ingest takes storage traits and never a path, and cross-compiles to aarch64-linux-android today; it should need no changes when a SAF implementation lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0d9910efc6 | Merge branch 'worktree-agent-a89309a856c8f4947' into integration | ||
|
|
21500b45be | Merge branch 'worktree-agent-abfe489c84c337e7c' into integration | ||
|
|
13d003b89d |
Let the curve widget plot whichever curve is asked for
An operation with four curves and a panel that draws one plot needs a way to say which. The panel finds out the way it finds out everything else: the points are faceted with the subject they act on, consecutive parameters sharing a subject are one curve, and a widget spanning several of them gets a selector over their names. Nothing in ui/ contains the word "red", and an operation that grows a fifth curve arrives with a fifth chip. The names ride on the panel rather than on the curve's row, because a Slint model is compared by identity: a fresh list built on every parameter event would make the row look changed every time, and rewriting a row rebuilds the element holding the drag in progress. That is the hazard the in-place point update already exists to avoid. Which curve is on show is interface state, not an edit. It changes no pixel, so it takes no history step, reaches no sidecar, and redraws nothing — the photograph on screen is already right. 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> |
||
|
|
939f33a3a3 |
Say on the import page where the photographs end up
Three lint findings and the gap the third one was pointing at. `Context::library_label` was dead: the page named the folder on this machine and said nothing at all about the server, which is half of what the Import button commits to and the half that takes minutes rather than seconds. It now names both, and states the order — copied and verified here first, then uploaded (FR-NC-7b) — beside the destinations rather than beside the button, because someone watching a slow upload needs to already know the photographs are safe on disk. The label is cached when the page opens rather than read in `render`: reaching it goes through the context closure to the account, and `render` runs on every keystroke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1526c957cf | wip: ingest | ||
|
|
96a7b405c2 |
Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's chain. Same panel, same order, same sliders, different meaning — and the only thing that said so was a sentence in the panel above, which a photographer reaching for the exposure slider has no reason to read. An exposure change lands on the whole frame when it was meant for a face, or the reverse; both are silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the dangerous one and it is: the others in that document cost time, this one costs work. The remedy is the classic one for a modal fault — make the mode visible — and the application already had the pattern. Crop arms a canvas interaction, draws an overlay, gives the column one job and is left by the control that entered it. Local masking is the same animal built as a peer panel, and that is what created the ambiguity. So `crop-mode` stops being a bare boolean and becomes one value of a three-state mode, which is the point: two modes could both be on before, and now that is not a state the interface can be in rather than one it is tested against. **One strip, not two.** The mode control was going to sit beside the group strip that filters the adjustments, which is two controls above one column answering the same question — what am I working on. They are one control now, `Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's bottom strip has for the same reason. The two halves are different kinds of state and are drawn differently: a mode is a chip that fills with the accent when it is on, a group is a word with a rule under it. That difference is what lets both be read at once, which they routinely are — picking Light while a mask is selected filters *that layer's* chain and does not leave the mode. Dropping the scope on a group press would be the same fault coming back from the other end, and would make Light mean two things depending on where it was pressed. The strip stays pinned above the develop column rather than moving to the top of the canvas as the document proposed. The half that filters the column belongs to the column, and the photograph is the subject. The canvas keeps one button, which now names the mode it leaves rather than saying "Done" — that was unambiguous with one mode and would not be with two — because the column can be closed on a narrow window and no mode may be inescapable. Entering a mode is a side effect, so Rust owns it rather than the strip writing the property: crop drops the zoom, local turns the overlay on, and leaving clears the selection. That last one is the fix. The "Overlay" and "Select" toggles are gone because they armed things that are simply what the mode *is* — a mode that has to be switched on separately is one you can enter and have do nothing. Escape and the Android back gesture join `back_step` as one `LeaveMode` rather than a second exit concept, and the mode is left before the zoom is: it was entered later, and it is the bigger step back. The heading is where the scope goes. Not a caption beside the panel, the heading *of* the panel that changed — `ADJUST` becomes the layer's name, the same string the selected row in the stack shows. That is the difference between describing a hazard and removing it. **Handles on the photograph.** A linear or radial mask could be created and then not moved, so a radial sat at the centre of the frame at its default size for ever. Three faults stood in the way of drawing one. The first is that a gradient did not render at all until the model had run. The rasteriser was built on the way out of `segment` and the array's size was read *off* the segmentation, so a gradient added to an unsegmented photograph produced nothing — silently, in the same way exports and thumbnails once did: the shader still emits the layer's block and the empty placeholder multiplies it by zero. The proxy size is a property of the photograph. Both are derived from it now, and deliberately at the same size rather than by coincidence, because a subject's distance field is sampled against that array. The second is hit-testing. A handle is drawn in output coordinates and stored in source ones, and between them lie the crop, the zoom, the pan, the straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated on the CPU, kept in that file beside it so that keeping the two in step is one file's problem — a handle mapped through anything less drifts off the mask the moment the view moves, which is exactly what masks are rasterised in source space to avoid. The third is that a drag is a displacement, not a destination. Each handle answers to the movement of the pointer since the press, applied to where the mask was when the press landed. Snapping the handle to the pointer instead jerks it by up to half a touch target on the first press, and the target is finger-sized because a tablet has no hover to reveal a control and no modifier to qualify it. A ramp gets three handles — centre, width, angle. An ellipse gets three too: centre and one per semi-axis, the major one carrying the direction as well as the length, because where an axis is put says both. It had a fourth, and it is gone: standing off the shape by a fixed distance, the rotation arm began outside the photograph at the size a new radial is created at, so the first thing anyone saw was a control they could not reach without first shrinking the mask. Two faults here were found by looking at the screen rather than at the source, both of the kind that cannot be found any other way. A `1px` rule with a size and no position is *centred* by Slint, so the seam between the photograph and the column was a hairline down the middle of the panel, through the histogram and every slider under it — twice, once in `app.slint` and once in `AdjustPanel`. And handing Slint a fresh model for the handles on every pointer event made the repeater rebuild its items, taking the `TouchArea` holding the gesture with them: the handle jumped once and then went dead under a finger that was still down. `develop.rs` carries the same warning about the parameter rows, where it broke slider drags; the model is rewritten in place now. The tests worth having are the ones about ambiguity and about the map. That the same row reads the frame's value, then the layer's, then the frame's again is §1.1 in one assertion. That dragging a handle onto another gradient's matching handle *produces* that gradient closes the loop between the two directions of the framing map, through a view that is cropped, zoomed, panned, straightened and quarter-turned at once — a one-legged map is invisible when the framing is neutral, because then both legs are the identity. Not done here: the histogram still reports the whole frame while the sliders edit a layer. That disagreement is real and is N3's, which this unblocks. The strip has room for a Brush entry beside Crop and Local when the painted masks land in the core, and it needs nothing here but the canvas interaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
586698db00 |
Give the strip a layout to sit in
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 1m2s
🐳 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 9m38s
The develop column's panel is a Rectangle, not a layout — a note four lines above explains why it is not an `if`. Two children of one therefore both sit at its origin, so pinning the strip beside the Flickable overlapped them and collapsed the whole develop view to a sliver. Wrapped in a VerticalLayout. The strip is pinned by being outside the Flickable rather than by any coordinate, which is what keeps it working at any column height. Caught by screenshotting the device rather than by the build, which was clean throughout — a Slint layout fault is invisible in the source and obvious the moment anyone looks. |
||
|
|
9a7b045df4 |
Pin the group strip where it can be found
It was inside AdjustPanel, which on a tablet put it below five other panels and off the bottom of the screen: present, working, and unreachable without scrolling past everything it exists to save you scrolling past. A control that answers "where is everything else" cannot itself be somewhere else. Now a GroupStrip above the scrolling column, so it never scrolls away. It still names no group — the strings arrive resolved from whatever the operations declared themselves to be about. |