Commit Graph
83 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 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>
2026-08-27 22:23:37 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 22:17:59 +02:00
dtourolle 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
2026-08-27 21:43:31 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 21:35:08 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 21:33:17 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 21:23:44 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 21:21:31 +02:00
dtourolle 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.
2026-08-27 19:08:23 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 18:44:51 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 18:32:00 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 13:33:36 +02:00
dtourolle 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
2026-08-27 11:57:38 +02:00
dtourolleandClaude Opus 5 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>
2026-08-27 09:22:43 +02:00
dtourolleandClaude Opus 5 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>
2026-08-26 23:14:17 +02:00
dtourolleandClaude Opus 5 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>
2026-08-26 23:04:35 +02:00
dtourolleandClaude Opus 5 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>
2026-08-26 22:13:41 +02:00
dtourolleandClaude Opus 5 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>
2026-08-26 21:05:41 +02:00
dtourolleandClaude Opus 5 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>
2026-08-26 20:58:13 +02:00
dtourolleandClaude Opus 5 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>
2026-08-25 19:57:41 +02:00
dtourolleandClaude Opus 5 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>
2026-08-24 19:48:58 +02:00
dtourolleandClaude Opus 5 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>
2026-08-23 18:42:42 +02:00
dtourolleandClaude Opus 5 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>
2026-08-23 17:57:35 +02:00
dtourolleandClaude Sonnet 5 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>
2026-08-23 13:17:07 +02:00
dtourolleandClaude Opus 5 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>
2026-08-23 12:40:14 +02:00
dtourolleandClaude Opus 5 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>
2026-08-23 11:41:17 +02:00
dtourolleandClaude Opus 5 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>
2026-08-23 10:54:35 +02:00
dtourolleandClaude Opus 5 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>
2026-08-22 21:09:26 +02:00
dtourolle 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
2026-08-22 19:44:42 +02:00
dtourolleandClaude Opus 5 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>
2026-08-22 19:20:13 +02:00
dtourolle 0d9910efc6 Merge branch 'worktree-agent-a89309a856c8f4947' into integration 2026-08-22 19:00:56 +02:00
dtourolle 21500b45be Merge branch 'worktree-agent-abfe489c84c337e7c' into integration 2026-08-22 19:00:56 +02:00
dtourolleandClaude Opus 5 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>
2026-08-22 16:06:21 +02:00
dtourolleandClaude Opus 5 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>
2026-08-22 15:51:11 +02:00
dtourolleandClaude Opus 5 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>
2026-08-22 15:49:02 +02:00
dtourolle 1526c957cf wip: ingest 2026-08-22 15:34:43 +02:00
dtourolleandClaude Opus 5 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>
2026-08-22 13:20:41 +02:00
dtourolle 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.
2026-08-22 10:57:08 +02:00
dtourolle 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.
2026-08-22 10:46:35 +02:00
dtourolle 924a837389 Group the panel by what operations say they are about
A strip of groups over the adjust panel — Light, Colour, Detail — derived
from the attributes the operations declare. `adjust.slint` names none of
them: the strings arrive resolved and the panel only draws them, so a new
operation joins the right group by saying what it is and this file does not
change (FR-DEV-3a).

A group nothing carries is not offered, so a tab never opens onto nothing.
Geometry is left out because its one operation prefers an on-canvas widget
and is skipped by the row builder — a Geometry tab would be empty while
`GeometryPanel` holds the real controls. The strip appears only when there
is more than one group to choose between; a single tab is a control with one
option.

The selected group is underlined rather than filled. The accent means
*modified* everywhere else in this interface, and spending it on "which tab"
would blunt the one signal the panel has.

**The trap, and it nearly bit again.** `op_index` on a row counts over every
capability, not over the ones a filter kept — it is how a row routes back to
the core. Renumbering it while filtering would make a slider drive a
different operation, which looks like a rendering fault rather than a
routing one. `rows_filtered` keeps `enumerate` over the full list and only
`group_head` is a position within the emitted rows; a test moves a value
through a filtered row and checks it lands where it was asked to.

Six tests, including that a nonsense index falls back to showing everything
rather than to showing nothing.
2026-08-22 10:22:47 +02:00
dtourolle a881fa3693 Use transform-rotation, which is what Slint calls it now
rotation-angle is deprecated in 1.17 and was the only warning the build
still emitted.
2026-08-22 09:03:59 +02:00
dtourolle e7dbdeb21f Keep the overlay on the photograph when the view moves
The overlay is a source-space picture; the canvas beside it shows whatever
the crop, the zoom and the pan selected out of that same space. Drawn whole
it stayed frame-sized while the photograph moved underneath, so zooming in
left a map of the whole picture stretched over a detail of it.

It now reports the visible rectangle as a clip, which the compositor
applies for nothing. Resampling on the CPU instead would mean rebuilding a
megapixel image on every frame of a drag, and putting it on the GPU would
add a second texture to keep in step with the view.

Pushed from the render path rather than the panel's sync: a pan changes no
mask and no row, so nothing else needs to run, and rebuilding the row
models on every frame of a drag would be waste.

Straightening is handled by rotating the image. A quarter turn or a flip
permutes the axes and a clip rectangle cannot say that — noted where it
happens rather than left to be discovered. The proper fix is to run the
overlay through the same shader prologue the photograph goes through, which
is the right answer and a larger one than this.

Four tests, and the one that matters asserts the clip *narrows* when zoomed
— which is precisely what it failed to do.
2026-08-22 08:53:02 +02:00
dtourolle 37f63edbf9 Take the watershed out of the product path
It does not work on a photograph, so nothing should offer it. `Segmentation`
is now one model pass and what it recognised: no region field, no merge
tree, no label upload, no granularity slider, and no readback of the whole
proxy to build a graph that collapses.

A click means "the object under the cursor". The region-selection path went
with the hierarchy it indexed — including the shift-click add/subtract,
which has no meaning for a whole object and would have been a modifier that
silently did nothing.

The passes, the hierarchy and the semantic prior stay in `dr-gpu` and
`dr-segment`, tested and documented. It is the *merge criterion* that
fails — the saddle is the minimum gradient along a boundary, so one weak
pixel merges two regions and real gradient noise puts a weak pixel on every
boundary. That is one function to replace, and the evidence for replacing it
is worth keeping. What is gone is the wiring, the option, and the control
that offered a user a choice with no outcome.

`MaskSource::Regions` remains in the pipeline: it is tested, it round-trips
through the sidecar, and a stored layer that names regions must still load
and be reported stale rather than failing to parse.
2026-08-22 08:39:17 +02:00
dtourolle 6163b63895 Put the edge controls where the mask is
Feather, falloff, grow/shrink/close/open and their amount, on the selected
layer. All four read the one distance field, so all four are live — nothing
recomputes except a compound morphology, and the session keys on that
separately so a feather drag rebuilds nothing.

Shown only for sources that go through the distance field. A gradient
carries its own falloff in its geometry, and offering a second one would be
two controls fighting over the same edge.

Picking an operation seeds a small amount if none is set. Selecting "Grow"
and seeing nothing happen would read as a broken control rather than as a
radius of zero.
2026-08-22 08:39:17 +02:00
dtourolle ee10097435 Mask the subject the model found, not the regions underneath it
The watershed hierarchy does not survive a photograph, so local masking
stops depending on it. A layer can now be one recognised object, and the
object's own coverage is the mask.

`Options::watershed` defaults off. It costs ~80 ms plus a full-resolution
readback to produce a ladder that collapses, and paying that on every
photograph buys a control that misleads. Kept switchable rather than
deleted: the passes and the hierarchy are correct in themselves and it is
the merge criterion that fails, which is a change to one function.

Masks now rasterise in **source** space at proxy resolution and are sampled
by the composed shader after the framing map. That fixes a real bug: they
were rasterised in output space, so zooming slid the photograph underneath
a mask that stayed pinned to the viewport, and cropping moved every
adjustment to a different part of the picture. Doing it this way also
leaves the framing map in exactly one place — a second copy in the mask
shader would have been a second thing to keep in step, failing only when
straightened.

A subject is stored as identity, not pixels: the mask is megabytes and is
reproducible by running the same model over the same image, so the sidecar
carries the index, the class and the score, and the session carries the
pixels. The class is there to be checked — if instance 3 comes back a "car"
where it was a "dog", something changed and the layer is stale rather than
silently masking the wrong thing.

The overlay now draws instances and is transparent everywhere else. The
region version covered every pixel and so hid the photograph it was drawn
over; the question it exists to answer is whether an outline follows the
subject, which you can only answer by seeing both.

`examples/local.rs` is the worked example: subject in colour with the rest
monochrome, and the subject lifted out of its background. Run on a 5472x3648
CR2 it finds two people and two cars, and the colour-pop keeps her hat and
hair while the wall and grass behind go grey.
2026-08-22 08:39:17 +02:00
dtourolle 9b4f0815e5 Show the regions, click one, and adjust it
The local panel sits above the adjust panel because it decides what those
sliders act on; below it, a photographer would set an exposure and only
then discover which scope it landed in. Selecting a layer re-scopes the
existing controls to that layer's chain — there is no second set of
sliders, and there must not be, or every operation added to `ops/` would
need a local twin.

The overlay is drawn over the canvas rather than blended into the render,
because it is a diagnostic and not an edit: it must not reach the
histogram, an export, or the texture handed to the compositor. Nearest-
neighbour always — the map's values are *names*, so smoothing between
region 4 and region 9 invents a colour belonging to neither and softens
exactly the edge the overlay exists to show.

Picking gets its own touch area above the pan handler. Panning wants
press-drag-release and picking wants a click; interleaving them in one
handler is how a drag ends up selecting a region the user was scrolling
past. Shift is tracked as window state because a TouchArea's click carries
no modifiers.

Three states a layer can be in are worth distinguishing, and each has a
different remedy: stale needs re-segmenting, "no adjustment yet" needs a
slider moved, and the ordinary case needs nothing said. A bare selection
renders nothing and looks identical to a broken mask, which is the first
thing a new user will hit.

Known rough edge, commented where it happens: segmentation blocks the UI
thread for about half a second. Moving it to a worker needs the develop
session — GPU resources behind a RefCell shared with every callback — to
be reachable from another thread, which is a restructuring rather than a
change to the call. The button says "Finding regions…" first so the stall
is announced rather than looking like a hang.
2026-08-22 08:39:17 +02:00
dtourolle 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.
2026-08-21 22:35:14 +02:00
dtourolle 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.
2026-08-21 22:03:46 +02:00
dtourolle 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.
2026-08-21 21:10:44 +02:00
dtourolle c72f197880 Stop a row of buttons deciding how wide the grid is
**A Slint layout cannot be narrower than its children's minimums.** Given
less room than they need it lays them out at those minimums, lets the row
run past the edge — and reports the oversized minimum upwards.

That second half is what made this more than cosmetic. The header, the
filter chips and the grid are siblings in one VerticalLayout, so the
widest row's minimum became the whole view's minimum: `LibraryGrid` was
laid out wider than the window. The grid then measured itself against
that inflated box and sized its columns to fill space that was off the
screen, so the right-hand column was cut by the edge no matter what the
tiling arithmetic did. Fourteen filter chips do not fit across 768
logical pixels, so that was every tablet in portrait.

It also explains why opening the collections sidebar did not reflow the
grid. The view was already pinned at a minimum wider than the window, so
taking 232px away for the sidebar could not shrink it — it just clipped
more of it.

Each of those rows is now a horizontal Flickable. A Flickable's own
minimum is nothing, since it exists to be smaller than what it holds, so
none of them can inflate anything — and the controls past the edge became
reachable instead of merely absent. Applied to all four: the library
header, the filter chips, the compact action row disclosed by "More", and
the develop strip.
2026-08-21 21:00:49 +02:00
dtourolle 90d6e551b3 Give the develop strip room for its controls on a tablet
The render backend, the layout class and the frame rate are developer
readouts, and they were sitting in the middle of the one strip that also
carries the way out of develop, the undo pair, the panel toggle and
export. A HorizontalLayout given less width than its children's minimums
does not shrink them — it runs off the end.

A tablet in portrait is 768 logical pixels, which is not enough, so
everything from the panel toggle rightwards was pushed off the right-hand
edge. Below the breakpoint the develop column is *also* closed by
default, so a toggle that could not be reached meant the column could not
be opened at all — and copy and paste live in that column. That is the
whole of "I have no idea how to copy a setting on Android and apply it to
other images": there was no way to.

The three readouts now collapse to zero width below the breakpoint. Width
and not an `if`, because this strip is inside the layout that `expanded`
feeds and a conditional child here is the shape that has already caused
binding loops in this file — and because a `visible: false` child still
takes its slot in a layout, so hiding alone would have freed nothing.
2026-08-21 20:38:48 +02:00