Commit Graph
349 Commits
Author SHA1 Message Date
dtourolle 7b5f62019b Merge branch 'master' into fix/gallery-selection
# Conflicts:
#	docs/traceability.md
2026-08-30 11:04:13 +02:00
dtourolleandClaude Opus 5 f86438ac0b Merge: the formatter's pass over the accessibility test
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:51 +02:00
dtourolleandClaude Opus 5 158642d4ce Reflow the accessibility test the way rustfmt wants it
The author could not run cargo; this is the formatter's first pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:51 +02:00
dtourolle 458025c607 Merge the scene model: a second, ADE20K-trained graph for per-category grades
Five commits. `models/` becomes one tree at the repository root so the
weight the application carries is a single `du -sh`; the export script
stops building its multi-gigabyte venv in RAM; `yolo26s-sem-ade20k`
joins the instance model rather than replacing it; the decoder turns its
logits into a partition of unity over eight photographic categories; and
four packaging routes put the file somewhere each platform can find it.

The instance model stays exactly where it was. A semantic model merges
every pixel of a class into one region, so it cannot separate two people,
and separating two people is what clicking a subject needs. The scene tab
grades whole categories and does not care. docs/segmentation.md §16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 10:50:49 +02:00
dtourolleandClaude Opus 5 6974604e84 Merge: say what each control is, so a screen reader can use one
NFR-A11Y-2 went from five accessible-* declarations in the whole
interface -- all five on the colour mixer's swatch row -- to
seventy-three, on twelve shared components and four screens. The one that
mattered is SliderTrack: the most-used control in the application, until
now unnamed, and now carrying a label, a formatted readout and
increment/decrement/set-value, so it is adjustable rather than merely
readable. Fixing the shared components covered library.slint's
twenty-seven buttons and eleven chips without editing that file at all.

NFR-A11Y-1 is scaffolded and one screen of nine is converted -- 38 @tr()
calls, about a tenth of the interface's strings. Slint's translate()
returns the original when no bundle is active, so a converted string and
a literal behave identically today and each remaining screen is an
independent commit.

Two accessibility defects are recorded rather than fixed, as TD-6 and
TD-7 with measurements: ink-faint reaches 4.5:1 on no surface (3.97 at
best) and rule reaches 3:1 on none. Fixing either re-derives the palette
beside a photograph, which wants a screenshot and an opinion.

Verified: fmt, clippy -D warnings, 563 tests including a new integration
test that walks the markup and fails on an unnamed control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:38 +02:00
dtourolleandClaude Opus 5 8e4e24ad09 Say what the drag payload actually is: the arming, not the cargo
`drag-payload` was documented as "called when a drag starts, so it
always reflects the selection as it is at that moment". Neither half is
true, and the comment is the only reason anyone would believe the drop
reads it.

`DragArea` tests `data.is_empty()` in its event filter — on every
pointer event, the first one included, which arrives long before there
is a drag. And a Slint binding that calls a callback has no dependency
to be invalidated on, so it is evaluated once, when a finger first lands
on that cell, and cached for the life of the cell. What it answers is
therefore always an empty selection.

None of the drop handlers read it; every one of them reads `dragging`,
which `drag-started` fills in at the moment that matters. What this
callback does is keep the `DragArea` armed, and it manages that only
because `set_user_data` is called unconditionally — an empty `Vec` is
still user data. Guarding that call, which reads as an obvious tidy-up,
would silently stop the grid dragging at all.

Comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:10 +02:00
dtourolleandClaude Opus 5 1a59fb33c1 Stop the press that starts a drag from deselecting what it grabbed
Dragging a selection of forty photographs onto a collection filed one.

Selection mode reports every press as a ctrl-press — deliberately, so
touch and pointer go through one set of rules rather than two — and ctrl
toggled. So the press that took hold of one of the forty took it *out*
of the selection on the way down. `drag-started` then looked at the cell
under the finger, found it unselected, and did exactly what it is meant
to do with an unselected cell: made it the whole selection and carried
it alone. The only sign was the grabbed cell's ring blinking out at the
moment the user began to move.

A plain press has never had this problem, because pressing an
already-selected cell has always been documented to leave the selection
alone — for precisely this reason. Ctrl now does the same: adding still
happens on the press, since the drag reads the selection immediately,
but *removing* is handed back as `Press::Deferred` and applied by the
click. Slint reports a click only for a press that stayed within
`tap-slop`, so a tap still toggles and a drag never does.

The unit tests now go through a `click` helper — a press and the release
that follows it — because that is the only thing a user can perform, and
calling `apply_press` alone would assert against half the policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:49:51 +02:00
dtourolleandClaude Opus 5 7312aceded Get the scene model onto the devices that need it
The decoder can load from a path; nothing yet put a file at one. Four
packaging routes, and one lookup that finds the result.

## Not `include_bytes!`, unlike the instance model

The instance model is 11 MB and compiled in, which was the right call for
it: Android hands the app no filesystem path (ARCH §6.9) and 11 MB is
tolerable. The scene model is 24 MB, and 35 MB of constants in the binary
is paid by every install whether or not the tab is ever opened.

So it follows `models/face/` instead — carried as an APK asset, unpacked
once at first launch into the shared directory a desktop install already
uses, after which every lookup finds it where it finds a desktop user's.
Assets are stored rather than deflated in the APK, so unpacking is a copy
rather than an inflate.

`embedded-scene-model` exists for the desktop build with nowhere else to
read from, and for tests wanting the real graph. Off by default, which is
the asymmetry with `embedded-model` and the reason for a separate
feature.

## Three files, all or none

`scene_model` insists on the graph, its vocabulary and the category
descriptor together, for the reason `face_models` insists on its pair: a
graph alone decodes to 150 anonymous channels. Reporting the set missing
beats starting and failing at the first inference.

## The two model sets are not the same kind of thing

`install_bundled_models` now carries both, and the distinction is worth
keeping in view. Face weights are absent from the repository *by design*
— the InsightFace grant is research-only (docs/faces.md §2) — so a build
carrying none is ordinary. The scene model is committed, so a build
carrying none means a checkout without `git lfs pull`.

Neither is fatal. A photo editor that refuses to start over a missing
grading feature is worse than one that starts without it, so both report
themselves unavailable exactly as face indexing already did.

The LFS-pointer guards apply to the `.onnx` only. The vocabulary and the
descriptor are legitimately a few kilobytes, and a size check that fails
on them would be a guard against the wrong thing.

`scene_model` is exported ahead of the tab that will consume it so the
packaging added here has something to be verified against — assets
written where no lookup looks would be a silent mistake for as long as
the tab took to arrive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:48:54 +02:00
dtourolleandClaude Opus 5 1ff52102b6 Drop the anchor bookkeeping the double tap took with it
`previous_anchor` existed for one gesture: a double tap in selection
mode took the range from where selecting began, and both taps had
already moved the anchor onto the cell being tapped, so the origin the
user meant had to be remembered separately.

That gesture is gone — "Select to…" says what it is about to do instead
of hiding a forty-image range behind a thing a hand does by accident —
and what is left is a field that four places write, `PressUndo` carries,
`cancel_press` restores, and nothing at all reads. `apply_press` is
`select_row`'s only call now that there is no anchor to remember, so the
wrapper goes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:40:07 +02:00
dtourolleandClaude Opus 5 8848bbcff3 Stop drawing a hover ring on a device that cannot hover
A cell drew a 1px ring while the pointer was over it, in the same colour
as the 2px ring that means "selected". On a tablet there is no pointer,
and `has-hover` is not the harmless no-op that implies: Slint raises it
on a touch press and lowers it on the `Exit` that normally follows the
release — but the release that ends a pinch carries no `Exit` at all,
and neither does a finger lifting while a second one is still down.

So resizing the thumbnails, which is a pinch, left a ring around
whichever cell each finger had come down on. The grid then showed boxes
around photographs that were not selected, a pixel thinner than the ones
that were, with nothing to tell them apart.

Hover chrome has no meaning once there has been a finger, so it is not
drawn: the same `touched` latch the rating strip already keys off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:36:46 +02:00
dtourolleandClaude Opus 5 8eeb9ba0f6 Offer the backup, and then the rebuild, when the index turns out to be damaged
NFR-R6 asks for an integrity check at startup and two offers behind it, and
none of it existed. `PRAGMA integrity_check` appeared nowhere in the tree,
`Catalog::open` was `open` → `configure` → `migrate` → `backfill` and nothing
else, and corruption therefore surfaced as whatever rusqlite error the first
unlucky query happened to produce — "database disk image is malformed"
attached to a thumbnail refresh, elided into a 34px banner, over an empty
grid saying "No images found · Check the library folder". Two messages that
disagreed, and no way forward but deleting catalog.sqlite by hand.

The property that makes the second offer real was already here and load-
bearing: the catalog is an index, not a source of truth, rebuildable from
sources plus sidecars (invariant §5.2.4, cited by schema.rs, trash.rs and
lib.rs). And sync.rs already knew how to take a coherent snapshot of a WAL
database. What was missing was the check, the type, and the conversation.

Four pieces:

**The type.** `CatalogError::Corrupt`, and — the part that makes it worth
having — a hand-written `From<rusqlite::Error>` that classifies rather than
wraps. `SQLITE_CORRUPT` and `SQLITE_NOTADB` become `Corrupt` wherever they
arise, so a background job that trips over the damage first reports the same
thing the startup check would have. `SQLITE_IOERR` and `SQLITE_BUSY`
deliberately do not: a dropped network mount is a different problem, and
telling someone to rebuild their index would be a wrong answer delivered
confidently.

**The check.** `Catalog::open_verified`, `quick_check` before the open rather
than after, because opening runs migrations and a damaged catalog with an
intact header would otherwise have structure rewritten on top of structure
that is already wrong. Bound to `open_verified` and not to `open`: the check
reads every page, which is affordable once at startup where a user can answer
a question, and not affordable on the dozens of opens a session's background
tasks make.

**The backup.** NFR-R2's second clause, taken between `configure` and
`migrate` in `Catalog::open`. A migration is the one routine operation that
rewrites table structure, so it is the likeliest way this file becomes
unreadable, and it is the last moment the pre-migration state exists to be
copied. Three generations, through SQLite's backup API after a TRUNCATE
checkpoint — never `fs::copy`, which on a WAL database backs up a state older
than the catalog and possibly torn. A failure to take the copy is logged, not
raised: a full disk must not be what makes a library unopenable.

**The conversation.** The first line of the dialogue is that the photographs
and the edits are safe, before the diagnosis, because that is the question the
user is actually asking. Then the two offers, which are *not* interchangeable
and are not presented as if they were: a restore keeps collections, and a
rebuild cannot, because a manual collection is a set of images assembled by
hand and nothing in the filesystem records it (docs/catalog.md §8.1). The
labels say so, and the rebuild does not take the affirmative styling while a
restore is on the table.

One thing that is a fix rather than a feature: `show_catalog_now` now gates
the scan. `Catalog::open` succeeds on a file whose header survived, so the
scan that used to start immediately afterwards would write folder ETags and
image rows into damaged pages in the seconds while the user was still reading
the question — turning a file that had a backup into one where the backup is
the only copy left.

Restore also deletes the damaged catalog's `-wal` and `-shm`. That step is
easy to leave out and fatal to leave out: a journal belonging to the old file,
sitting beside the new one under the same name, is replayed into it on the
next open. That is not a restore, it is a fresh corruption with the evidence
gone.

Tested by corrupting a fixture catalog — 500 images and a collection, then
every page past the second overwritten — and driving both branches. The
restore is asserted on the collection, because a collection is precisely what
distinguishes the two paths; the rebuild on the damaged file being kept and
the next open producing an empty catalog at the current schema. Plus the
`SQLITE_NOTADB` presentation, a damaged backup being refused rather than
installed, and a v1 catalog whose pre-migration backup comes back reading
v1 rather than v11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:34:12 +02:00
dtourolleandClaude Opus 5 53b04dc561 Let the accessibility test see a callback that takes an argument
`sets()` recognised `name:` and `name =>` and not `name(arg) =>`, which is how
Slint writes a callback handler with a parameter. There is exactly one of those
among the properties asserted — `accessible-action-set-value`, the action a
screen reader uses to type a number into a slider — so the test reported
`SliderTrack` as missing the action it declares four lines above.

A false alarm rather than a false pass, and the less dangerous of the two. It
is still worth fixing rather than dropping the assertion: set-value is the
action that makes a slider reachable without dragging, which is most of what
the actions were added for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:31:01 +02:00
dtourolleandClaude Opus 5 46706fe622 Let the tool rail be pressed, not only read
The rail is the develop view's primary navigation and reached the platform as
four pieces of static text. The labels got through — a `Text` announces itself
— so a screen reader could read "Photo, Crop, Local, Repair" and had no way to
learn that any of them could be pressed, or which one was currently held. The
one control that decides what a click on the photograph does was a caption.

Each entry now declares itself a checkable button carrying the tool's own
word, with the held state reported rather than left to the fill. Checkable is
unconditional here, unlike `Button`'s: a rail entry is always a held-or-not
state, so an unheld one should say "not pressed" rather than pass for an
ordinary button.

The action repeats the click handler's expression rather than calling it. A
`TouchArea`'s `clicked` is raised by the pointer and cannot be raised from a
binding, so the alternative is a function wrapping two lines — and the
duplicated ternary sits four lines below the original where the two cannot
drift out of sight of each other.

Worth recording for the next control like this: `accessible-*` on an element
inside a `for` does work, despite the accessibility pass skipping repeated
elements. `process_repeater_components` runs first and moves the bindings into
a real component whose root is not repeated; what the later pass skips is the
empty placeholder left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:27:10 +02:00
dtourolleandClaude Opus 5 80298403f7 Name the launch screen's entries, and let its folder rows be pressed
A heading above a text field names it on screen and names nothing to the
platform: Slint associates the two only if something says so, and nothing did.
So the four entries a user signs in through — server, username, app password,
folder — reached AT-SPI as unnamed boxes with a separate piece of static text
floating above each. The word is now written twice, deliberately, and the
second copy is the one attached to the control being typed into.

FolderRow is the more consequential half. It is a Rectangle with a TouchArea
over it, which is a button to the user and a decorated box to the platform —
its label got through, because the `Value` inside is a Text, while the fact
that the row could be entered at all did not. The folder picker was therefore
readable and not navigable, which for the screen that chooses where the whole
library lives is the difference between using the application and not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:58 +02:00
dtourolleandClaude Opus 5 7409cb7767 Make the launch screen translatable, and say how the rest follows
`@tr(` appeared zero times in 14,482 lines of markup. Every string the user
reads was a literal, so NFR-A11Y-1 was not partly done or done badly — there
was nothing to extract and nothing a translator could have been given.

The mechanism turns out to cost almost nothing, and the reason is worth
stating because it decides the order of the work: a string written `@tr("Sign
in")` is already correct in a build with no translation at all. Slint's
`translate()` formats the original and hands it back when neither delivery
path is active, so a converted string and a literal are the same string until
someone writes a `.po`. That means the interface can be converted a screen at
a time rather than in one 14,000-line commit that nobody can review, and every
intermediate state is shippable.

So the delivery half is wired and left inert. `build.rs` asks for bundled
translations only once `lang/<lang>/LC_MESSAGES/dr-ui.po` exists — the first
catalogue anyone commits turns it on with no build-system change, and until
then a checkout with no `lang/` builds exactly as it did. Bundling rather than
the `gettext` feature because of Android: under ARCH §6.9's storage model
there is no path a `.mo` could sit at that the app can reach, and no C library
to link it against. The extraction command and the one flag that must not be
passed to it are recorded in the module docs.

The launch screen is converted whole: 32 calls covering every heading, button,
caption and placeholder. It goes first because it is the screen a user cannot
get past — an unreadable preferences page can be ignored, an unreadable sign-in
cannot. Four kinds of literal are deliberately left alone and the file says
which: the product name, example values whose shape is the message, `..`, and
the path separator.

Two things this leaves open, recorded rather than papered over. The headings
carry their own capitals, because `PanelHeading` draws what it is handed — so
a translator supplies "SERVEUR", not "Serveur", and styling in the string is a
real cost now paid rather than a surprise later. And `@tr()` is markup only:
the operation and parameter labels NFR-A11Y-1 names explicitly resolve in
`labels.rs`, in Rust, because the core may not depend on a localisation
library — those need a second mechanism, and it is not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:16 +02:00
dtourolleandClaude Opus 5 8e4befa727 Say what each control is, so a screen reader can use one
The whole interface carried five accessible-* declarations in 14,482 lines of
markup, all of them on a single row of the colour mixer, and nothing said so.
Every other control — every button, every tick-box, every chip, and the slider
the develop panel builds thirty-six of for the mixer alone — reached AT-SPI
and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a
user in that position; it is whether the application can be used at all.

The annotations go on the shared components rather than on the screens, which
is the same argument widgets.slint was written to make one layer down: a
control named where it is used is a control unnamed everywhere it is used
next. Twelve components now declare a role, a name and — where the control
does something — the action assistive technology invokes to do it. Three
screens were touched, and only where the component could not know the answer.

SliderTrack is the one that mattered most and the one that could not be fixed
from inside itself. It is handed four numbers and knows nothing about what
they mean, so it takes a `label` and a formatted `readout` and every wrapper
passes down what it was already drawing. A test asserts that every
instantiation does, because a track added without one announces "slider, 0.35"
and looks perfectly correct in a screenshot.

It also gains increment, decrement and set-value. A slider that can only be
dragged is a slider a pointer is a modifier for, which is the objection
ui-navigation D-N2 makes about hover-only affordances with the argument run
one step further; these three are what a screen reader drives a slider with,
and they commit as well as change — one nudge is a whole gesture, so a caller
that persists on `committed` must hear about it.

Two decisions worth recording because the obvious alternative is wrong:

`active` on Button and IconButton is deliberately not announced from the
component. It says a toggle is on and says nothing about whether a control
that is *off* is a toggle at all, so announcing it would report every button
in the application as an unpressed toggle. The four call sites that mean a
toggle say so themselves, which Slint permits because the role is inherited.

SwatchSlider's row-level role is removed rather than kept. It was the one
control that had a label, and now that the track underneath it has one too the
two would nest — a slider inside a slider, the outer holding the value and the
inner holding the actions that can change it. The row stands down and hands
the same strings to the control that owns the gesture.

The test reads the markup the way darkroom-android's manifest test reads its
XML: there is no accessibility tree without a window, so what it defends is
the failure that actually happens — a role or a name lost in a refactor, which
compiles, renders identically, and is invisible to everyone not using a screen
reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:22:32 +02:00
dtourolleandClaude Opus 5 e22945a62d Tag the colour-independent status that was already built and unrecorded
NFR-A11Y-3 — no status conveyed by hue alone — read as untagged, and
outstanding.md said "no compliance work found". Both were wrong. Five places
already implement it, and four of them name the requirement in a comment
explaining the design; what none of them had was a TRACES line.

- `histogram.rs::percentage` states clipping as a figure and keeps `<0.1%`
  distinct from `0%`, so the text cannot say "none" while the marker beside it
  is lit.
- `histogram.slint`'s ClipReadout is the other half: a marker that appears and
  disappears rather than changing tint, and the figure next to it. Either alone
  reads.
- `library.slint`'s star strip is a solid star against an outline, differing in
  shape and luminance, over an achromatic palette.
- `library.slint`'s FlagMark is a tick against a cross, and a reject also dims
  its whole cell.
- `peaking.slint`'s colour chips say "Red" and "Cyan". A control for choosing
  between hues, presented only as hues, is unusable by exactly the person most
  likely to need it.

The tag is honest about being wider than the evidence, and outstanding.md now
records both gaps. Only the clipping clause has a test that would fail if the
behaviour were removed; the three Slint components are argued rather than
asserted. And the requirement's first named example — catalog colour labels —
has no interface at all: `label` is a nullable column nothing writes or shows.
That clause is untestable rather than satisfied, and closes when the label UI
is built with a shape from the start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:19:48 +02:00
dtourolleandClaude Opus 5 ef07e6ca3e Give the canvas tools a rail of their own, and the column one width
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Crop, Local and Repair were chips at the head of the develop column, sharing a
row with the adjustment groups and told apart from them by the shape of their
highlight. Three things followed from that, and only the last is cosmetic: the
column closes, so the way out of a mode went away with the way in — hence the
duplicate "Done Cropping" over the canvas; the chips are generated from the
operation set, so the widest thing in the sidebar was a row nobody had chosen
the contents of; and a mode and a filter are different kinds of state wearing
one control.

They are a fixed 60px rail down the left now, generated from a single table in
toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant;
nothing in app.slint is touched to add one. What is left of the strip is the
group filters, so it is GroupStrip.

The column stops measuring itself. Every panel published a content-width and
declared it as min-width, and the column took the largest — which spent the
photograph's pixels on whatever happened to be widest, and moved the image
sideways when switching tools swapped one set of panels for another. It is
panel-width now, one number in style.yaml.

That number is 360 and it is measured, not picked: the contents report a
minimum of 344 in every mode, and they do not compress below it because a Text
that does not elide reports the same minimum as preferred. 320 was tried and
sliced Paste down the middle. The Flickable's viewport is floored at the
layout's minimum rather than its preferred width for the same reason — content
that is never told how much room it has cannot adapt to having less.

Removing the eight content-width declarations repairs three comments an
earlier edit had spliced sentences into. The raw histogram's note on keeping
its hint short is rewritten rather than dropped: an over-long hint no longer
widens the column, it pushes the column's minimum past the width it has and
clips the panel, which makes that constraint sharper rather than obsolete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:59:50 +02:00
dtourolleandClaude Opus 5 9e519eb8a6 Make the thin ring the only mark a selected photograph carries
Two treatments said "selected" and neither said it well.

A selected cell got a 2px border and a lifted fill. The border is drawn
on the outside of a cell whose content sits 6px in, so it ate into the
thumbnail: selecting appeared to nudge the photograph. Inside it, a
second thin ring marked the anchor — the end a shift-click measures from
— and that ring was the clearest thing on the cell, so it read as *the*
selection to everyone who had not written it.

Worse, the ring outlived what it described. An anchor survives a
deselection, so a thin box sat around the last photograph touched with
nothing selected at all, indistinguishable from a cell that had stayed
behind. That is the one thing a selection cue must never be: ambiguous
about whether something is selected.

So the ring is now what it already looked like. One mark, drawn inside
the cell and 4px clear of its edge, so it never touches the thumbnail and
never changes a dimension — selecting adds ink and moves nothing. Two
pixels rather than one, because it is now carrying the whole cue across
forty cells at arm's length against a thumbnail of any brightness. The
outer border is hover alone.

The anchor keeps no mark, and loses nothing it was earning: the bar says
"Tap the last photograph" while a range is armed, which answers the
question the ring existed to answer. The ordinal still lives in the
controller and still decides where a range extends from; what is gone is
the claim that the user needs to see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:47:32 +02:00
dtourolleandClaude Opus 5 b120ce48ec Float the selection bar over the grid instead of above it
Selecting the first photograph inserted a 40px row into the view's
vertical flow, so every cell in the grid moved down by it. The act of
selecting shifted the thing being selected out from under the finger —
and a second tap aimed at the neighbour landed on the row below it,
which is the worst possible response to a gesture whose whole job is to
say "this one".

The bar is a floating one now, at the foot of the view. Nothing above it
is re-laid out, so selecting changes what is drawn and never where.

The grid's viewport grows by the same 40px while the bar is there rather
than the Flickable shrinking, which is what keeps that change invisible
too: every cell stays exactly where it was and there is simply further to
scroll, so the last row can be brought clear of the bar instead of being
trapped under it.

It also swallows presses that land on it. A bar floating over the grid is
a bar a thumb can reach for and miss into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:47:20 +02:00
dtourolle f41edc03ff Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 09:24:37 +02:00
dtourolleandClaude Opus 5 1cd5ab6815 Put the gesture reference in the application
The document the previous commit generates is for somebody reading the
repository. The person who needs it most is holding a tablet, has just
discovered that a hold does something, and has nowhere to ask what else
does.

So the same scan writes a table the application draws: a "Gestures"
button beside Settings, a sheet with the same scrim and dismissal as the
ones that file and name, and every gesture grouped by where it applies
with its touch, pointer and keyboard routes side by side. Not the `why` —
that is the argument for the design and belongs in the document; on a
phone-sized card it would bury the one line the sheet was opened to read.

The sheet's file knows nothing about what a gesture is. It draws the rows
it is handed, and the rows come from the generated table, because a help
screen with its text typed into it is a second description of one
behaviour — and the second description is always the one that goes stale.
The commit before this deleted a gesture; a hand-kept sheet would still
be describing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:30:21 +02:00
dtourolle 4b212089d2 Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 00:25:42 +02:00
dtourolleandClaude Opus 5 964e72bca2 Merge: the formatter's pass over the raw histogram
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:25:21 +02:00
dtourolleandClaude Opus 5 e38b730aa6 Reflow what rustfmt wanted in the raw histogram
The author could not run cargo, so this is the formatter's first pass
over the new module and its presentation half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:25:16 +02:00
dtourolleandClaude Opus 5 df90dd95a2 Merge: a histogram that reads the sensor, beside the one that reads the frame
FR-CULL-3's other two bullets. What existed was a display histogram
tagged FR-DSP-7, counting AdjustPass's 8-bit output with r == 255
clipping counters -- it says a highlight is gone precisely where this
requirement needs it to say the highlight is recoverable.

The new reduction runs over the demosaiced scene-linear texture on a
stops-below-saturation axis: camera-native, unbalanced, unmatrixed,
uncurved, normalised by the sensor's own black and white levels, so 1.0
is saturation by construction. Four series, and the fourth is the
brightest channel rather than luma, because a weighted sum of unbalanced
values is a number about nothing. Cached per photograph, not per frame:
nothing downstream of the demosaic can move a count.

Both readings are legitimate and answer different questions, so the
panel offers a choice rather than replacing one with the other.

ARCH 5.5 is amended to match. It specified a pre-demosaic reduction;
retaining the CFA samples costs 48 MB at 24 MP and 120 MB at 60 MP
resident on every photograph opened, whether or not anyone looks at the
histogram, on the platform ARCH 6.2 exists for. The spec now records two
reductions, why the more complete one was not worth its cost, and what
the cheaper one cannot answer: it counts pixels not photosites, it
cannot see above white, and it is measured after the CFA pattern is gone.

Verified: clippy -D warnings clean, 98 dr-gpu tests, 556 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:14:34 +02:00
dtourolleandClaude Opus 5 31a3580f9d Extract the gesture vocabulary from the code that implements it
Every gesture the application has was documented in the comment beside
the `TouchArea` that implements it. Excellent comments, and unreachable
by anyone not reading the source — which is the FR-UI-4 failure in a
different costume: a gesture nobody can find is a feature only its author
knows about.

Writing them out again in a hand-kept help page is the failure this
avoids. Two descriptions of one gesture drift, and it is always the prose
that drifts: the code is exercised every time somebody uses the
application and the page is exercised never. A help screen confidently
describing a double tap the grid stopped honouring last week is worse
than no help screen — and the grid did stop honouring one, in the commit
before this.

So the comment beside the implementation stays the only copy, and a
`GESTURE:` block beside it is scanned into two artefacts: `docs/gestures.md`
for a reader, and a Rust table for the application to draw a help sheet
from. Both committed, both gated, so neither can quietly stop describing
the code.

It lives in the traceability crate because it is the same operation on
the same input — walk the tree, pull structured tags out of comments,
render, fail if the committed artefact has moved. Only the vocabulary is
new. It scans `ui` and `apps` alone: a gesture needs an interface to be
performed on, and excluding `tools` is also what stops the scanner
extracting its own worked examples as broken gestures.

Fifteen gestures so far, across the library grid and the People screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:09:49 +02:00
dtourolleandClaude Opus 5 20c368d3fc Give each touch gesture one meaning, and give tap-to-open back
I broke opening a photograph. The dwell added in "Tell a tap on a
photograph from a hand going past" required a finger to stay down 120 ms,
and a deliberate tap is routinely quicker than that — so the grid stopped
opening anything. Duration was the wrong discriminator: a tap and a brush
are the same length.

**Travel is what separates them, and a graze is by definition a moving
contact.** A press now records where it landed and the release compares:
within 12px it is a tap, beyond that the hand was going somewhere else.
No dwell, so no deliberate tap can be refused, and the rule is the same
for a finger and a mouse — one rule instead of two, and the `touch`
argument the dwell needed goes away with it.

Two real conflicts went with it, because a gesture set that overlaps
itself is unlearnable however each half is documented.

**A drag was also a hold.** Grabbing a cell and moving inside 450 ms left
the hold timer armed underneath the drag, so it fired mid-gesture and put
the grid into selection mode nobody asked for — the drag finished into a
mode that changed what every later tap meant. Starting a drag now cancels
it, exactly as a pinch already did.

**A double tap was also a range.** In selection mode two taps on one cell
selected everything back to where selecting began: no visible state, no
warning, from a thing a hand does by accident. "Select to…" does that job
and announces itself first, so the double tap is gone and two taps are
now two toggles that land where they started. `extend_to_row` went with
it — a second range implementation that only the double tap reached,
where every other range goes through `apply_press`.

The resulting vocabulary, one meaning each: tap opens, tap-and-slide does
nothing, hold starts selecting, drag files, two fingers resize, and while
selecting a tap only ever toggles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:09:37 +02:00
dtourolle da3b1487f9 Merge: drain the job queue that nothing was draining
FR-PLAT-AND-4's Rust half, and FR-PLAT-AND-3's resumability with it. The
queue's claim_next, complete, fail and recover_orphaned had no callers
outside their own tests, so the jobs table accumulated rows nothing ever
ran.

It also fixes a claim that was not safe across two connections: the
deferred transaction took a read lock for the SELECT and only tried to
upgrade at the UPDATE, so in WAL the second worker got
SQLITE_BUSY_SNAPSHOT, which a busy handler cannot retry away. It never
double-claimed, but the loser errored. Now one UPDATE ... RETURNING.

No handler is wired, deliberately. The only enqueue site reachable in
the shipping app produces remote thumbnail jobs already served by the
async grid worker, and inventing a second network path blind is not
worth a requirement reading as covered on the strength of plumbing.

Verified: clippy -D warnings clean, 376 dr-catalog and 548 dr-ui tests,
18 runner tests including four-thread contention and crash recovery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/library.rs
#	ui/dr-ui/src/settings_ui.rs
2026-08-29 23:49:38 +02:00
dtourolle 4b6c110816 Count the sensor's own numbers, so a cull can see headroom the render hides
FR-CULL-3's remaining two bullets. What existed was a *display* histogram
tagged FR-DSP-7: it binds AdjustPass's Rgba8Unorm output, recovers an 8-bit
code value, and counts clipping as `r == 255`. Its own documentation says a
clipped bin means "a highlight that is actually gone rather than one the
transform might still recover", which is the opposite of what a culling
decision needs. FR-CULL-3 asks for the histogram of the sensor data, on the
explicit grounds that a rendered image "systematically lies about what is
recoverable in the raw", and a readout that measures the render cannot answer
that however it is presented.

So this is a second instrument beside the first rather than a setting on it.
Both are true; they are true about different things; the panel offers both
behind a chip row and the words travel with the numbers, because a raw
saturation figure drawn under a heading saying Highlights would be mislabelled
exactly where the difference matters.

**What is reduced over, and what it cost to decide.** ARCH §5.5 specified the
pre-demosaic CFA samples. This reduces over the demosaiced scene-linear
texture instead, and §5.5 is amended to record the choice rather than let the
specification and the code disagree in silence. The texture is camera-native —
unbalanced, unmatrixed, uncurved — and normalised by the sensor's own black
and white levels, so 1.0 is saturation by construction and the distribution
below it is the headroom question with no calibration to carry. Retaining the
CFA samples would mean keeping the packed u32 buffer Demosaicer::run currently
drops: 48 MB at 24 MP, 120 MB at 60 MP, resident per open photograph whether
or not anyone looks at the histogram, on a platform §6.2 exists because memory
is scarce on.

Three things it therefore cannot say, written into the module docs and into
§5.5 rather than left to be discovered: it counts pixels not photosites, so a
saturated site drags its interpolated neighbours up and per-channel clipping
is smeared by about a demosaic kernel; it cannot see above white, because
demosaic.wgsl clamps each photosite at 1.0 for its own good reasons (a Canon
6D reads to 16383 against a declared 15070) so "at saturation" and "a stop
past it" share a bin; and it is measured after the CFA pattern is gone, so it
can name which colour clipped in the reconstructed image but not which
photosite went first.

The axis is stops below saturation, 16 bins per stop over 256 bins — the same
bin count the display reduction uses, so the fold into drawable columns is
shared and a divergence between the two plots would have to be deliberate. A
linear axis spends half its width on the top stop, which is why nobody has
ever drawn a useful linear raw histogram. The fourth series is the brightest
channel rather than luma: these values are unbalanced, so any weighted sum of
them is a number about nothing, and the brightest channel is the one that
saturates first and so the one the headroom question is actually about.

It is a property of the file and not of the render, which has two
consequences. It is computed once per photograph and cached — nothing
downstream of the demosaic can move a count in it — so a cull does not pay the
display histogram's per-frame cost three thousand times. And it describes the
whole frame rather than the visible region, deliberately opposite to
DevelopSession::histogram: a crop changes what is on screen and changes
nothing about what the sensor recorded.

Tags are on the reduction, the type, its constructor and the presentation
arithmetic, each of which has a test that fails if the behaviour goes. The
Slint panel and the push from lib.rs keep their reasoning as prose: nothing
asserts them, and a tag would claim coverage the assertions are not making.
2026-08-29 23:36:21 +02:00
dtourolleandClaude Opus 5 846a249156 Drain the queue that nothing has ever drained
`jobs` has been a complete durable work queue since the catalog was
written, and nothing has ever taken a job out of it. `claim_next`,
`complete`, `fail` and `recover_orphaned` had no callers outside their own
tests; `enqueue` had three. So the table grew one row per photograph and
kept it forever, and FR-PLAT-AND-3's resumability was a property of code
that never ran.

`runner` is the missing half. It owns no thread, no clock and no policy,
and that is the whole design: on Android the process does not decide when
background work may run. WorkManager does, subject to Doze, battery saver
and FR-NC-6's network constraints, and it revokes permission mid-job by
calling onStopped(). So the runner exposes `run_one` — claim, run, record —
and `drain`, which repeats it against a budget, a deadline and a
cancellation flag the host owns. A `Worker.doWork()` with ten minutes calls
drain with a deadline; a desktop idle pass calls it with none. That is the
seam the Android service plugs into, and it needs no Android to test.

Handlers are supplied from above, because the catalog knows what needs
doing and nothing about how: a thumbnail needs a decoder and a fetch needs
a network stack, neither of which belongs under core/dr-catalog. A runner
claims only kinds some handler declares, so a queue holding work this
device cannot do is left alone rather than failed five times.

Four outcomes, and only two of them are the job's fault. Done deletes the
row; Retry backs off; Abandon gives up now, for a failure no retry can fix;
Interrupted releases the claim with its attempt refunded and ends the
drain, because the host stopped rather than the job — five backgroundings
in a row must not mark good work as failed. Process death is the fifth and
cannot report itself, which is what `recover` is for.

Recovery is called from `show_catalog_now`, which is the one place a
catalog is opened for a session and already returns early if one is open.
It has to be exactly once and before any worker starts: there is no owner
column, so a second pass while a worker held a claim would take it away.
The attempt a dead claim consumed is deliberately kept — a job that takes
the process down with it is indistinguishable from one that fails, and the
attempt counter is the only evidence that survives a death.

The tests cover claiming under contention twice over: sequentially across
two connections, and with four threads on four connections against one
catalog on disk, asserting every job ran exactly once. Plus completion,
backoff, giving up, abandoning, interruption, budget, deadline,
cancellation, and a job orphaned by a simulated crash being reclaimed and
run once rather than lost or repeated.

Not wired to a handler yet, and deliberately not: the only enqueue site
the app actually reaches is the remote scan's, whose thumbnails are already
served by the async grid worker, and `walk`'s two sites are reachable only
from the scan_local example. Inventing a handler to make the plumbing look
used is how a requirement comes to read as covered by code that does not
implement it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:31:47 +02:00
dtourolleandClaude Opus 5 7418b040da Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00
dtourolleandClaude Opus 5 085ab766b3 Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00
dtourolleandClaude Opus 5 55cb9b5b30 Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".

It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:26 +02:00
dtourolleandClaude Opus 5 e5db849f04 Mark a burst in the grid, and let it be folded away
The counterpart to the grouping: where the signatures come from, and how a
group reaches a cell.

Signatures are computed from the 256px thumbnails dr-thumbs already holds --
vastly more resolution than a 9x8 reduction can use -- so a library that has
been browsed, or that has synced somebody else's shards, has already paid for
them and no RAW is decoded for this. The consequence is stated rather than
hidden: an image with no thumbnail gets no signature and never joins a burst.
That is self-correcting, and it is why the pass runs when the thumbnail sweep
finishes rather than on a timer. Nothing happens at import and nothing happens
at query time.

The mark is drawn as a child of the cell's TouchArea, for the same reason the
star strip is: a click on it must not also reach `cell-clicked` and throw the
user into develop, and children are hit-tested before the element they sit in.
It is never hidden on hover the way the stars are -- a collapsed burst stands
in for frames that are not on screen, and something has to say so whether or
not a pointer is nearby.

Folding changes what the grid's *query* returns rather than what its cells
draw, because the grid is a window over an ordered query and the frames a fold
hides are mostly not loaded. So the predicate joins VISIBLE in every query
that lists or counts cells -- the window, the header's count, the run a
shift-click resolves, and the ordinal a scrub lands on -- under the discipline
VISIBLE's own comment sets out: present in four places of five is worse than
absent, because the counts disagree with the cells and neither looks wrong on
its own. There is a test for exactly that.

`the_window_read_walks_the_ordering_index` now includes the burst clause. It
asserts on the query plan while holding its own copy of the query, so left
alone it would have gone on reporting green against a query the grid no longer
runs. If the clause costs `images_grid_order` and puts the sort back, that
fails here rather than becoming jitter someone measures in six months.

The pass keeps its own drain timer in a thread-local instead of taking fields
on the library controller, so everything the feature needs to run lives in one
file and the screen that starts it holds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:26 +02:00
dtourolleandClaude Opus 5 d0b671d4db Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.

So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.

Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.

**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.

And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.

The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:46 +02:00
dtourolleandClaude Opus 5 e465ff0c80 Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became
a selector term, and it was effectively unreachable. The only control
that could add a second person lived on the People screen, behind
selecting them there, and it appeared only once the grid was already
narrowed to somebody — so "photographs with both of them" needed a
two-screen round trip the user had to guess at.

A filter belongs on the filter bar. A "People" chip there opens a tray of
everyone the library knows; tapping a name adds or removes them, and the
any/all chip beside it — already there, and already the thing nobody
found — now has something to sit next to that explains it. The caption
leads the row so a pair of chips means something before either is
pressed.

The tray is a strip under the bar rather than a popup, the way the
develop column's film picker is: the view scrolls as one, so an inline
strip is taller content and not a second overlay to dismiss. It scrolls
horizontally for the same hard reason the bar above it does — a layout
cannot be narrower than its children's minimums, and forty people would
otherwise set the minimum width of the whole view.

The roster is built on open, not kept in step: indexing and regrouping
change who exists, and a list cached at startup would be stale for
exactly the user who has just been naming people. Named first, then by
how much of them the library holds — the catalog orders by face count
alone, which puts a dozen unnamed strangers ahead of the two people the
user actually cares about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 6d5de21fb7 Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel
was already answered — the Flickable claims the pointer and the press is
cancelled — but a contact that neither travels nor lasts reaches a
TouchArea as an ordinary press and release, and it was landing the user
in develop.

So a finger now has to stay down for `TAP_MIN_MS` before letting go
counts as opening anything. That is the floor under a tap where the 450 ms
`HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above
is a hold that starts a selection. One scale, three gestures.

Only a finger is held to it. A mouse click is a discrete decision made by
a button and is routinely over in thirty milliseconds, so `cell-pressed`
now reports whether a finger did it — the same finger-id convention the
pinch arbitration beside it already uses — and the dwell applies to touch
alone.

A graze still *selects* the cell it landed on, because the press already
did that. That is the right failure mode: something visible and
reversible rather than a silent nothing, and rather than develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 e40f2bfee9 Let go of the keyboard when a name is finished
Pressing Enter on a person's name committed it and then kept the field
focused, so on a tablet the on-screen keyboard stayed up over the faces
the user had pressed Enter to get back to. The name looked accepted and
the screen looked stuck.

`Field` grows `release-focus()`, the other half of the `take-focus()` it
already had, and the Identity screen calls it from `accepted`. A function
rather than a property for the reason the existing one gives: focus is an
event, and bound to a property it would fight anything else that took it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 f5edd49b6b Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.

Three pieces, because it could not be fewer:

`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.

Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.

The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.

`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:06 +02:00
dtourolleandClaude Opus 5 9d11554c71 Make the range gesture visible, and offer the whole grid at once
Touch has had a range gesture for as long as selection mode has: double-tap the
far end. It is invisible, it is unreliable on a grid that scrolls under the
second tap, and it extends from the anchor *before* the two taps moved it — a
rule subtle enough that the code needs two paragraphs to explain it to itself.
Nobody who was not told about it has ever used it.

"Select to…" is the same operation with state you can see. Press it, the strip
stops reporting and says "Tap the last photograph", and the next cell taken is
the far end. It reaches Rust as shift on `cell-pressed`, so it lands in
`apply_press` as the ctrl+shift it already is, and there is no third selection
policy to keep in step with the other two.

This is deliberately not the sweep gesture. A drag that paints cells can only
reach what is on screen, and the ranges that hurt on a tablet are longer than a
screenful — between the two taps here the user may scroll as far as they like,
and the run is resolved by the catalog rather than by what happened to be
loaded. A sweep is still worth having for short runs; it is not what this
should have rested on.

"Select all" beside it, asked of the catalog for the same reason: a select-all
that quietly meant "the hundred cells that happen to be loaded" is a lie the
user cannot see until the export runs.

The double-tap stays. It is tested, and an accelerator that costs nothing is
worth keeping for whoever has already learnt it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:01 +02:00
dtourolleandClaude Opus 5 d1f3b97245 Ask for the collection's name where the keyboard can reach it
"New collection from selection" created the collection under a placeholder
name and then opened the rename field in the sidebar tree.

On a tablet the sidebar is not on screen. It is instantiated all the same —
app.slint collapses it to zero width and `visible: false` rather than using an
`if`, because an `if` there is a layout loop Slint panics on — so the rename
field was created, its `init` took focus, and Android raised the on-screen
keyboard over a box nobody could see. Nothing else on the screen is focusable,
so the keyboard had nowhere to go: it stayed, the name could not be typed, and
the collection was already written under the name the user did not want.

Asked in a sheet instead, on the same card as the filing and keywording sheets,
before anything is written. That also fixes what was hiding behind it: an
abandoned rename used to leave a "New collection" in the tree, because the
collection existed before the name did.

`Field` gains `take-focus()` so a sheet whose field is the only thing to do in
it can answer the keyboard for the user — a function rather than a property,
because focus is an event and a bound property would re-take it on every
unrelated re-evaluation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:17:40 +02:00
dtourolleandClaude Opus 5 b9891e2c04 Merge master into tablet-selection
Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:52:42 +02:00
dtourolleandClaude Opus 5 23c74d7063 Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.

So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.

Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.

**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.

And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.

The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:32:26 +02:00
dtourolleandClaude Opus 5 a4b9deaf96 Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became
a selector term, and it was effectively unreachable. The only control
that could add a second person lived on the People screen, behind
selecting them there, and it appeared only once the grid was already
narrowed to somebody — so "photographs with both of them" needed a
two-screen round trip the user had to guess at.

A filter belongs on the filter bar. A "People" chip there opens a tray of
everyone the library knows; tapping a name adds or removes them, and the
any/all chip beside it — already there, and already the thing nobody
found — now has something to sit next to that explains it. The caption
leads the row so a pair of chips means something before either is
pressed.

The tray is a strip under the bar rather than a popup, the way the
develop column's film picker is: the view scrolls as one, so an inline
strip is taller content and not a second overlay to dismiss. It scrolls
horizontally for the same hard reason the bar above it does — a layout
cannot be narrower than its children's minimums, and forty people would
otherwise set the minimum width of the whole view.

The roster is built on open, not kept in step: indexing and regrouping
change who exists, and a list cached at startup would be stale for
exactly the user who has just been naming people. Named first, then by
how much of them the library holds — the catalog orders by face count
alone, which puts a dozen unnamed strangers ahead of the two people the
user actually cares about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:32:10 +02:00
dtourolleandClaude Opus 5 2403f355d6 Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel
was already answered — the Flickable claims the pointer and the press is
cancelled — but a contact that neither travels nor lasts reaches a
TouchArea as an ordinary press and release, and it was landing the user
in develop.

So a finger now has to stay down for `TAP_MIN_MS` before letting go
counts as opening anything. That is the floor under a tap where the 450 ms
`HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above
is a hold that starts a selection. One scale, three gestures.

Only a finger is held to it. A mouse click is a discrete decision made by
a button and is routinely over in thirty milliseconds, so `cell-pressed`
now reports whether a finger did it — the same finger-id convention the
pinch arbitration beside it already uses — and the dwell applies to touch
alone.

A graze still *selects* the cell it landed on, because the press already
did that. That is the right failure mode: something visible and
reversible rather than a silent nothing, and rather than develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:31:45 +02:00
dtourolleandClaude Opus 5 bbbc23f376 Let go of the keyboard when a name is finished
Pressing Enter on a person's name committed it and then kept the field
focused, so on a tablet the on-screen keyboard stayed up over the faces
the user had pressed Enter to get back to. The name looked accepted and
the screen looked stuck.

`Field` grows `release-focus()`, the other half of the `take-focus()` it
already had, and the Identity screen calls it from `accepted`. A function
rather than a property for the reason the existing one gives: focus is an
event, and bound to a property it would fight anything else that took it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:31:22 +02:00
dtourolleandClaude Opus 5 82d9d077b0 Merge: answer Android's memory warnings, and stop reporting a lost root as an empty library
FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and
live for Nextcloud roots, the SAF cause it names does not exist yet.

FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the
same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a
Service or a FileProvider. The container has JDK 17 and build-tools 36;
the build step is what is missing.

Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043
tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud,
dr-plat and dr-ui. The aarch64 target was checked before the branch was
finished but not after; no device was available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:46 +02:00
dtourolleandClaude Opus 5 75fd5619ca Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:38 +02:00
dtourolleandClaude Opus 5 2b812ebe21 Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:29 +02:00