Commit Graph
176 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 cf8f5b632f Show the develop frame itself, instead of a photocopy of it
The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.

Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.

**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.

dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).

**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.

**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.

**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.

`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.

**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.

Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:

- the adjust target satisfies every condition Slint's import checks, asserted
  in the crate that owns the descriptor, because a descriptor that drifts
  fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
  again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
  AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
  the repaint actually depends on.

The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.

**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.

56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:54:00 +02:00
dtourolleandClaude Opus 5 4b36ca66aa Render an export into the colour space its file will claim
The colour-managed export branch left one call site deliberately unfixed, and
this is it. `render_for_export` composed with the default sRGB shader, so a
Display P3 export failed with an accurate error rather than producing a
mislabelled file — the right way to leave a half-finished path, and no way to
leave it.

The space is chosen at render time because that is the only time it can be:
the conversion happens in the shader, before the clip to 0..1, so by the time
pixels reach an encoder they are in exactly one space and the only honest
thing left is to label them. `Frame::in_space` carries which, and a mismatch
between what was rendered and what was asked for stays a typed error.

Also regenerates the traceability matrix over the four merged branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:34:57 +02:00
dtourolle 1c16ca3d27 Merge branch 'colour-managed-export' 2026-08-17 09:28:22 +02:00
dtourolle 8e330a24e9 Merge branch 'undo-redo' 2026-08-17 09:26:23 +02:00
dtourolle c26b6082a2 Merge branch 'local-libraries' 2026-08-17 09:26:03 +02:00
dtourolle 9f577ed6a2 Merge branch 'xtrans-demosaic' 2026-08-17 09:25:28 +02:00
dtourolleandClaude Opus 5 8ea92545df Stop the export folder forgetting itself, and let Android reach one
Three faults, compounding into an export that could not be made to work on a
tablet at all and a destination that appeared to reset on its own.

**Switching target destroyed the destination.** One field held both a
filesystem path and a remote folder, so changing the target had to clear it —
`/home/x/Exports` carried to the server would have offered to create a folder
called `home` at the library root. The consequence was that merely looking at
the other option threw away the destination already chosen, which reads,
correctly, as a setting that will not stick. There are two fields now. Each
target remembers where it was pointed and switching is free; `active_destination`
picks between them so no caller can reach for the wrong one.

**The library root read as "unset".** The picker opens at the root, so
confirming it where it opens stored an empty string — indistinguishable from
"ask each time" and looking exactly like the picker had done nothing. Empty
now means the library root for a server destination, which is a real folder
and the one the photographs are already in; it means "ask" only for a device
folder, where no path is worth assuming. The page labels it so.

**Android defaulted to a target it cannot use.** A device folder there means
the Storage Access Framework, which provides no filesystem path (ARCH §6.9)
and is not implemented — so the default target could never succeed however the
destination was filled in. The export button said "no export folder is set",
the settings page offered no way to choose one, and the only way out was to
guess that the other target was the working one. The device target is now
absent from `ExportTarget::available()` on Android and the default there is the
server, which needs no platform work at all. A settings file carrying an
unreachable target — copied from a desktop, say — is corrected on read rather
than left to fail at the last step.

Seven tests, each named for the fault it prevents returning. The compatibility
one matters most: a file written before `remote_destination` existed keeps its
device path and gains an empty remote one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:25:15 +02:00
dtourolleandClaude Opus 5 914d14ec0d Tell the shader which colour space it is encoding for
The generated shader ended with `encode_srgb` and a clamp, so every
photograph leaving DarkRoom had been through sRGB's gamut whatever the
settings page said. Export refused the other three spaces rather than
tag clipped pixels with a gamut they did not contain — correct, and
not something an encoder could fix.

So the output space becomes a parameter of composition. `compose_for`
emits a constant primaries matrix after the camera matrix and before
the clip, and generates the transfer function to match: the sRGB curve
for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a
linear toe for ProPhoto. The ordering the camera matrix depends on is
untouched — operations still run in camera space — and sRGB emits no
conversion at all, so the shader compiled on nearly every frame is
byte-for-byte what it was.

The numbers live in dr-types, derived from four chromaticity pairs per
space rather than tabulated. That is not tidiness: the shader encodes
the pixels and the ICC profile describes them, and a file whose profile
disagrees with its own contents is worse than one with no profile. One
derivation makes them agree by construction, and can be checked against
the values the specifications publish.

Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB,
pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2,
a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because
untagged does not mean sRGB, it means guess.

The refusal survives in a sharper form. A `Frame` now carries the space
it was rendered in, and export refuses to label it anything else. The
develop session still composes for sRGB, so a P3 export from the
interface fails with an accurate error instead of producing a file that
lies — the frontend half is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:04:04 +02:00
dtourolleandClaude Opus 5 7f524d2fd0 Give a mis-drag a way back
Develop edits now save themselves to a sidecar the moment you leave the
image, so until this there was no way to undo one — the mistake was
persisted and the only recourse was to remember the old number.

The history is a stack of snapshots, because the edit graph is already
plain data: `Preset::capture` reduces it to what differs from default and
`Preset::apply` puts it back, so undo is those two calls and nothing else.
A command object per action, with an inverse beside it, would have been a
second thing every operation had to register — and operations are declared
in YAML precisely so that a new one needs no code written for it. A
snapshot cannot fall behind them.

The interesting part is coalescing. A slider drag emits an event per frame
and must be one step, not forty. Nothing in the interface reports a gesture
boundary — the same wall the render coalescing hit, and it is answered the
same way rather than by threading a "finger is down" out of every slider,
curve point and crop handle. What stands in for the boundary is the control
plus recency: changes to the same control within 700 ms amend one step.
Which control is "the same" is asked of the graph, not listed: an operation
whose declared presentation claims a parameter is one where a single
gesture moves several — a curve point carries an x and a y — so those
coalesce as one widget. Nothing in the history names the tone curve.

The compromise, and it is a real one: a control let go of and picked up
again within the window is one step rather than two. Buying the other
answer costs a gesture-boundary signal on every control, which is more
surface than the difference is worth.

The stack is bounded at 64 states for NFR-RES-1 — a develop session stays
open for hours. Sixty-four rather than a byte cap: what is being bounded is
steps a photographer would want back, and a byte cap would give the
elaborate edit the shallowest history, which is exactly backwards.

The session owns its history and every mutator records into it, so the
callbacks in `lib.rs` cannot change the edit and forget to — with a dozen
generic callbacks that would have been one press of undo away from wrong
every time a control was added. Opening a photograph makes its stored edit
the floor rather than a step: it is not work done in this sitting, and an
undo reaching behind it would discard a previous session's edit and then
save that on the way out.

Not yet done, from FR-DEV-5: history is per-session and in memory, and
there are no named snapshots. What mattered was that a saved mis-drag had
no way back at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:03:34 +02:00
dtourolleandClaude Opus 5 bb71f141e7 Let a folder on this machine be scanned into the catalog
`dr_catalog::scan` has known since it was written what a changed directory
means — when to prune, when to list, and the one question that decides whether
a deletion sweep is safe. It was fully tested and nothing called it, because
walking a real directory "belongs to the platform layer" and the platform layer
was eleven lines re-exporting `secrets`. So every photograph in DarkRoom
arrived over WebDAV, and a user without a Nextcloud account saw nothing at all.

This is the missing half: a `Storage` trait, a filesystem implementation of it,
and the driver that pours one into the other.

The trait is shaped by the platform it does *not* yet support. Android's SAF
gives no filesystem path, which is why `SourceRef` exists; less obviously, it
gives no way to *compose* one either — a document id is opaque, and the only
way to learn a child's id is the children query that returned it. So a listing
hands back the reference to each entry rather than a name for the caller to
join onto a parent, and there is deliberately no "path + name" helper anywhere
above `LocalStorage`. That single restriction is what makes SAF a second
implementation rather than a second set of call sites. A reference is otherwise
an opaque `(RootId, key)` pair the catalog stores verbatim and rebuilds later,
which a persisted tree grant supports exactly as a relative path does.

A `Path` now appears in one place: `LocalStorage::grant`, where the folder the
user picked is handed in. Everything above it addresses a `RootId`.

`dr_catalog::walk` is the seam. It probes a directory, asks `scan` what that
means, lists only when told to, and reconciles what it found against the rows
it holds. Two things it does are worth saying out loud, because both are ways
to lose a library:

Absence only counts where absence was observed. A listed folder proves its
missing images are gone; a pruned one proves nothing about its contents, and a
scan that was cancelled or that failed part-way proves nothing about folders it
never reached. So the file sweep runs per listed folder, the folder sweep runs
once at the end and only after a complete scan, and a root that cannot be
reached at all marks its images offline and deletes nothing — FR-CAT-9's line
between proven-absent and merely-unreachable, which is the difference between
unplugging a drive and losing everything on it.

A trashed image is absent from its folder on purpose. It is exempt from both
sweeps, and detached from a folder about to be deleted rather than cascaded
away with it, or a soft delete would come undone the first time the folder it
came from was rescanned.

Two things the tests taught, both changes to what was there before:

Modification times are now milliseconds, not seconds. Change detection asks
whether a timestamp moved, so the unit's granularity is the width of the window
in which a change is invisible — and a second is long enough to copy a card and
start a scan. The test that caught it looked like a test bug; it was not. SAF
reports milliseconds natively, so this is also the unit that needs no
conversion on the platform with the coarser clock.

And an in-place rewrite of an existing file is invisible to directory-level
pruning, because writing to a file moves neither its directory's mtime nor its
entry count. That is a real limit, now documented and held by a test rather
than left to be discovered. It bites less than it reads: an export, a restore,
`mv`, and every editor that saves safely write beside the file and rename over
it, which does move both.

Narrowing the format filter no longer deletes what it stops matching, which
fell out of the same principle: unticking JPEG says stop looking for new ones,
not discard the hundred already rated. The files are sitting right there.

`DirState` and `DirEntry` move to `dr-types`. They are the sentence the
platform says to the catalog and both crates need the same one; `scan`
re-exports them so nothing that used them has changed.

Not done: the UI. The launch screen's "Open library" flow is account-shaped
from the first field to the thumbnail worker, and giving it a local branch is
its own piece of work rather than a button. `cargo run -p dr-catalog --example
scan_local -- ~/Pictures` scans a real folder and reports what it cost; run it
twice to see the second run list nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 08:59:17 +02:00
dtourolleandClaude Opus 5 1c0994c807 Demosaic a Fujifilm sensor instead of refusing it
Every RAF stopped at the embedded preview, because the demosaicer had
one kernel and it was a Bayer kernel. D11 makes Fujifilm first-class and
FR-RAW-5 asks for it by name, so a hard error there was a promise we had
not kept.

X-Trans is a 6x6 tile, and nothing in the Bayer path survives that: the
missing channels sit at different offsets at all 36 positions, so there
is no fixed kernel to write. The new shader fits a weighted plane
through each channel's samples in a 5x5 window and carries the other two
channels across as the difference between those planes, keeping the
pixel's own measured value untouched. A plane rather than a mean because
the three channels are sampled at different places in the tile: a mean
compares a red taken slightly left of the pixel with a green taken
slightly right of it, and that offset is a colour cast that follows
every gradient in the frame. The fit is done in white-balanced space,
where the constant-colour-difference model it rests on is actually true
of a neutral subject; that alone halves the error at a luminance edge.

Two compromises, both deliberate.

It is not Markesteijn. There are no directional hypotheses and no
homogeneity map, so it does not resolve detail finer than the CFA period
and a hard edge arrives about two pixels wide. It cannot ring — the
output is bounded by the local sample range — so it does not produce the
worms FR-RAW-5 exists to avoid, but the quality that requirement asks
for is still owed.

The tile's phase is guessed rather than known. rawler has each body's
pattern exactly, as a 36-character string, but CfaPattern::XTrans throws
it away before dr-gpu sees the file, and it is not a constant to
hard-code: the bodies in that database start the tile at four different
origins. So the phase is read back out of the pixels, by grouping the 36
per-position means and taking the grouping with the least spread. That
part needs nothing from the scene. Telling red from blue does — shifting
the tile by half a tile turns it into itself with red and blue swapped,
so no geometry can decide it — and the as-shot white balance is what
breaks the tie. A frame that is almost entirely one colour can defeat
that; widening dr-decode to carry the pattern string would retire the
guess altogether.

The tests assert reconstruction, not success: a flat patch comes back
exactly at all six phases tested, and a linear ramp comes back exactly
too, which is the property the plane fit exists for and the one a mean
would fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 08:46:28 +02:00
dtourolleandClaude Opus 5 2330ed25e9 Let a grouped slider be dragged, by flattening the panel that drew it
Build and test / Desktop (Linux) (push) Failing after 1h4m48s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 9m43s
White balance, highlights and shadows, and the mixer took a press, jumped once,
and went dead under the finger. Exposure and contrast dragged perfectly — which
is what made it read as a slider bug rather than a layout one.

The panel nested. A group's head row drew the *whole* group, repeating over
`row.group-len` and indexing back into `root.rows`; every other row drew
nothing. So the inner repeater's model was read off the head row, and depended
on that row's identity. Moving any parameter in the group rewrites that row —
its own value changed, or `group-modified` flipped for its neighbours —
which re-evaluated the repeater, rebuilt its items, and destroyed the
`TouchArea` holding the live gesture. A lone parameter had no inner repeater,
so the five single-parameter operations were never affected.

Now each row draws only itself, so an update touches one control and nothing
structural. The facet heading comes off `starts-facet`, which Rust already
marks on the first row of a run, and the group heading is drawn by the row that
heads it. It also retires the old hazard of a head row building every control
in its group — thirty-six live TouchAreas behind the mixer's twelve visible
ones.

The same identity hazard reached the rows themselves through `ModelRc`, which
compares by identity rather than contents: a fresh empty model per row per call
made every row differ from itself, so `sync_rows` rewrote all of them on every
event. `points` now shares one empty model as `choices` already did.

Both are held by tests, because the failure is invisible in a still — every
value is right and the panel looks perfect. Curve rows are excluded: their
points model carries live coordinates, is rebuilt by design, and `sync_rows`
writes values through the existing model rather than swapping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:23:01 +02:00
dtourolleandClaude Opus 5 40d4e439a9 Drop the parameter the offline sidecar writer never used
`write_one_sidecar` took an `Option<&NextcloudBackend>` it ignored. It was a
leftover from the shape the write path had before online and offline separated
into two functions: the offline one records to the cache and queues, and has no
server to talk to by definition. A parameter that is always `None` and always
unused says the opposite — that there is a case where it is `Some` — and the
next reader has to check.

The closure it was threaded through is renamed to say what it does rather than
how it is called. `run(None)` needed the reader to know what `None` meant;
`queue_all()` is the sentence.

Also regenerates the traceability matrix, which now records FR-CAT-9's queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:17:31 +02:00
dtourolleandClaude Opus 5 cb1d2be240 Choose the export folder by walking the server, not by typing it
The destination for a Nextcloud export was a text field. Nobody recalls the
exact spelling of a path three levels down, and getting it wrong does not
fail — `create_dir` makes whatever was typed, so a misremembered folder
becomes a new one at the root and the exports are somewhere nobody looks.

So it is picked the way the library root is picked, using the same
`FolderBrowser` model the launch screen drives: up, into, and "use this
folder", confirming the folder currently *shown* rather than one selected in
the list. Same rule in both places, so the phrase means one thing.

The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a
near-twin of the launch screen's, because that one reaches into the
`LaunchController` for its session and reports onto the launch screen's error
line, while this one is handed credentials and writes to the settings page.
Factoring them together needs a function taking both controllers or a trait
implemented twice to abstract two call sites — more machinery than the twenty
lines it saves. What matters is shared already: navigation behaves identically
because both drive the same model.

The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`,
because listing a remote folder needs credentials and the settings page holds
no session on purpose — it is reachable before a library is opened and must
not depend on one existing. With no account the picker says to sign in first,
rather than showing an empty list that reads as a server with no folders.

Details that are decisions rather than accidents: the picker opens at the
library root rather than at whatever half-typed path is in the field, which
would list nothing and look broken. The listing area is a fixed 180px, since a
folder with sixty children would otherwise push the rest of the settings page
off the bottom. "Up" is disabled at the root rather than hidden, so the row
does not jump as the user navigates. A failed listing leaves the picker open
on the folder it was showing — where the user had got to is not something to
discard over a dropped request. And the chosen folder saves immediately like
every other setting on a page that has no Save button.

The poll timer lives on the controller for the reason `LaunchController` keeps
its own there: a `slint::Timer` stops when dropped, so one local to the
function that starts it would be collected before the listing arrived.

Carries in-flight work from a parallel session — a segmentation pass in
dr-gpu, a sidecar cache, and the develop panel's continuing changes.

1020 tests pass, fmt clean. One clippy warning remains and is not mine:
`sidecar_cache::dir` is unused while that work is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:12:01 +02:00
dtourolleandClaude Opus 5 e00c99b864 Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is
the button, and the place the bytes go.

**Everything is staged first.** An export bound for the server is written to a
local outbox and uploaded afterwards; offline is not a special case, it is the
same path with a drain that finds the server absent. Doing it the other way —
upload directly, stage only on failure — makes the failure path the one that
is rarely exercised and always broken, and a network drop mid-batch leaves
some exports existing and some not with nothing recording which. Staged first,
an export is finished the moment it is written and the upload is a promise
kept later.

The outbox sits beside the catalog rather than under the cache. dr_catalog's
cache already draws that line: passive entries are a convenience and go under
LRU, pinned ones are a promise and never do. An export awaiting upload is a
promise — the user was told it succeeded — and sweeping it for disk would
destroy the only copy. Bytes are written before the destination record, so a
kill between the two leaves an orphan the drain ignores rather than a record
pointing at nothing.

The status line says "Queued for Exports/2026", never "Exported to Nextcloud",
until it has actually landed. There is a test asserting that wording, because
the tempting shorter sentence is a claim the app cannot keep.

The drain runs on the sync pass, before the shards: a thumbnail shard can be
rebuilt from the originals and the catalog is an index, but a queued export
exists nowhere else.

`DevelopSession::render_for_export` renders the framed size rather than reusing
the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding
that would hand the user a soft, screen-sized file with nothing to say anything
had been lost (FR-EXP-9).

One compromise, recorded rather than hidden: the export runs synchronously on
the UI thread, so the window is unresponsive for the few hundred milliseconds
a full-resolution render and encode takes. Moving a DevelopSession and its GPU
pass to a worker is a larger change than one button earns, and it is batch
export that makes the wait intolerable rather than merely noticeable.

Still missing: the Nextcloud folder *picker*. The destination is typed into
Settings for now. `FolderBrowser` in launch.rs is already the reusable model
for it — it browses a remote tree and nothing about it is specific to choosing
a library root — but wiring it into the settings page needs a listing worker
and browser UI there, which is its own piece of work.

Carries in-flight work from a parallel session — presets, the develop copy and
paste, and the node schema's `presentation` and `enum` support. One misplaced
callback in settings_ui.rs is moved from `render` to `wire`: registered in
`render` it borrowed a `&SettingsController` into a 'static closure and would
not compile, and that file's own docs say render pushes properties while wire
connects callbacks.

992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:58:17 +02:00
dtourolleandClaude Opus 5 23f0c4b76a Let a declared node ask for a widget, and write down how
Two gaps in the control vocabulary, both found by reading back what 0a331c7
actually shipped against what it claimed.

**`kind: enum` worked and was undocumented.** The whole point of that kind is
that a node author can declare a control without touching the interface, and
ops/README.md is where a node author looks. A kind absent from the table is one
nobody will use. It is now in the table, with the property that makes it cheap
spelled out: the value is the chosen index carried as an `f32` like every other
parameter, so a uniform expression can read it, the sidecar stores it
unchanged, and nothing along the way needed a second kind of value.

**A declared node could not ask for a widget at all.** `Presentation` gained an
ordered preference list and a demand, and `tone_curve` and `framing` use them —
but both are hand-written Rust. A YAML node had no way to say that several of
its parameters form one control, so the generated half of the pipeline was
locked out of the half of FR-DEV-3a that makes widgets extensible. Nodes now
take a `presentation:` block:

    presentation:
      widgets: [colour_wheel]
      demand: { two_dimensional: true }
      params: [hue, strength]

`demand` carries exactly two flags and there is deliberately no key for a pixel
width, a breakpoint or a platform name — those are the frontend's to decide,
and ARCH §4.3a is explicit that a core reasoning about them will eventually be
wrong about a display it never saw.

Widget names are spelled in the YAML the way `WidgetKind` spells them, so a
declaration and descriptor.rs cannot drift into two vocabularies for one idea,
and an unknown one is a build error listing the six that exist. `params:` is
checked against the node's own parameters: the widget claims what it names and
the generic path skips what was claimed, so a typo would silently drop a
parameter out of *both* — the one mistake here that produces no error and no
control.

Verified by declaring a presentation on a node, confirming the generated
`fn presentation` matched, and confirming both error paths report the file and
the key; then reverted, since no node in the chain wants a widget yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:54:10 +02:00
dtourolleandClaude Opus 5 5e4b8de18d Stop the mixer's bands from dividing one budget between them
Each band's delta was scaled by the summed weight of the bands that happened
to be adjusted:

    d_hue = d_hue / w_total;
    d_sat = d_sat / w_total;

The divisor is the wrong quantity, and it fails in two directions.

A band adjusted on its own divides by its own weight and cancels it — w*v/w is
v — so the falloff does nothing. Green saturation at +100 hit a pixel at 179°
exactly as hard as one at 120° and not at all at 181°: full strength across the
whole window, then a cliff. That seam is the thing the overlap exists to
prevent, and it was there the moment a single band was touched.

Worse, the divisor counts a band's weight even for a channel that band says
nothing about, so the controls compete. `red_hue` at +100 shifts a pure red
pixel +30°. Set `orange_sat` as well and the same pixel shifts +20°, while
orange's saturation bleeds into pure red at a third strength. Hue and
saturation on *neighbouring* colours were trading against each other — the two
sliders behaving as though only one of them could be spent.

The real fault is upstream of the division: the falloff window was ±60° while
the bands sit 30° apart, so the twelve weights sum to 2.0 rather than 1.0 and
*something* had to correct for it. Narrowing the window to the band spacing
makes them a partition of unity, and then nothing has to. The division is gone;
`w_total` stays, demoted to what it should always have been — the test for
whether any adjusted band reaches this pixel at all.

Everything the module header claimed is now true rather than aspirational: a
band reaches zero at its neighbours' centres, a hue halfway between two gets
half of each, twelve bands at +100 equals global +100, and a band pushed alone
reaches the full 30° of travel its own comment documents instead of whatever
fraction the other sliders left it.

`overlapping_weights_are_normalised` asserted the broken arithmetic verbatim,
so it is replaced rather than repaired. In its place: no delta may be divided
by w_total, the guard must survive, two adjacent bands must emit independent
terms, and — the property the rest now rests on — the twelve weights must sum
to one, swept at 0.1° around the wheel. That last test carries a Rust mirror of
`band_weight`, so a third test pins the mirror to the shader's own constants;
a copy nothing checks is how the window and the spacing drifted apart in the
first place.

Also gone: the red branch of `rgb_to_hcl` computed its hue twice and threw the
first away.

This changes how existing edits render. Mixer adjustments are more selective,
and where they were quietly cancelling each other they no longer are, so a
saved sidecar will not come back looking the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:36:52 +02:00
dtourolleandClaude Opus 5 69b12e327f Let framing say it wants the canvas, instead of the panel knowing
The generated panel opened with a special case:

    if op.id == dr_pipeline::framing::ID { continue; }

and a paragraph explaining that framing's eight parameters are eight bad
controls — four crop edges you would have to type coordinates into, a "rotate"
slider running 0..3, two switches — so `GeometryPanel` presents them as the
gestures they are instead.

Every word of that is true, and none of it was the frontend's to know. It is a
fact about the operation, and ARCH §4.3a is explicit that a frontend deciding
things by *naming a stage* is the boundary being crossed: a second frontend
would have had to learn the same special case, and nothing in the capability
output said why it existed.

Framing now declares a `Presentation` preferring `WidgetKind::CropOverlay`,
with a demand of two-dimensional dragging and — deliberately — no precise
pointing, since FR-UI-7 grows the handles to the modality and a crop is
forgiving. The widget owns all eight parameters rather than only the rect: a
frontend taking this on takes the whole framing control surface, and leaving
rotation and the flips behind would scatter them into the generated panel
underneath a crop control that already exists.

The panel's rule is now general. `supported` answers whether a kind is
implemented *anywhere* — drawn in the panel like the tone curve, or hosted on
the canvas like the crop — and `is_on_canvas` settles which afterwards, so the
two cannot disagree about the same kind. Any stage preferring an on-canvas
widget is skipped, with nothing named. A frontend that implements neither still
gets the eight sliders: tedious, complete, and the guarantee the whole hint
mechanism rests on.

**The tests were asserting against the wrong thing.** `rows_of` was a
hand-written simulation of the row generator, complete with its own copy of the
framing skip, so the suite was checking a second implementation kept in step by
hand. It was not in step: giving framing a presentation changed the real panel
and the simulation disagreed, which is exactly how a green suite hides a
regression. It now calls `rows_from`, and `rows_of_unfiltered` is gone.

`framing_is_not_generated_as_sliders` survives but asserts by routing rather
than by counting — no row may carry framing's capability index — so it cannot
be satisfied by two miscounts cancelling out. Alongside it, an invented stage
preferring a widget this frontend lacks, falling back to one the canvas hosts,
must also be skipped: if that ever needs a name added to pass, the special case
has grown back.

Not included: moving the crop overlay's markup out of app.slint into
controls.slint. It is cosmetic next to the above and app.slint is in another
session's working set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:29:23 +02:00
dtourolleandClaude Opus 5 0a331c717e Give the controls a vocabulary, and let a node ask for one
widgets.slint set the rule — screens consume components, and a bare `Theme.*`
at a call site means a component is missing — and it set it for chrome only.
The controls never got the same treatment, so they were written wherever they
were first needed and copied from there.

**The slider was private to the develop panel.** `SliderTrack`, with the
fifty-line preamble explaining how it wrests a drag away from a Flickable,
lived inside adjust.slint and no other screen could reach it. It shows: export
quality is a 1-to-100 value, and the settings page offered a free-text box for
it, with the range written in a hint and enforced nowhere. `to-float()` answers
0 for anything it cannot parse, so a typo saved a quality of 0 and the page
displayed the 0 back as though it had been asked for.

The tick-box was written twice, in launch.slint and settings.slint, from the
same 18px box and the same handler; the second carried a comment deferring the
lift until a third caller appeared. The label-and-hint header was written three
times inside settings.slint alone.

controls.slint is the input layer beside widgets.slint's chrome layer, and the
constraint that makes it reusable is that **nothing in it knows about
`ParamRow`** — that struct is the develop panel's flattening of the capability
model, and a control that imported it could only ever be used by the develop
panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in
the panel that owns the model. 658 lines came out of the three screens.

`SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer
presentation of a bounded scalar, and quality is its first adopter. It commits
on gesture end rather than on every movement, because the settings page saves
to disk on change and a two-second drag is a couple of hundred writes where a
text field committed once. The develop panel keeps the live stream — that is
what its pipeline is for — so `SliderTrack` now reports both.

**The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify
more than was built: an ordered preference list of widgets rather than one, the
demands a widget makes, and kinds beyond scalar and bool.

- `Presentation.widgets` is now a list, walked by `choose`, falling back to
  plain sliders. Falling off the end is not an error, and there is a test
  asserting an operation asking only for an unimplemented widget still yields
  one control per parameter.
- `WidgetDemand` carries what a widget inherently needs — two-dimensional
  dragging, precise pointing — and no pixels, breakpoints or platform names.
- `WidgetKind` grows to the specified set. There is deliberately no `Colour`
  *kind*: a colour is three numbers, and a value type that is not an `f32`
  would reach through the graph, the uniform block and the sidecar format to
  buy what `ColourWheel` over three scalars already describes. Every widget
  here is a hint over ordinary scalars, which is what keeps the fallback
  honest.
- `ParamKind::Enum` is the one new shape, and it fits because a variant index
  is exact in binary32. `kind: enum` with a `variants:` list works in
  `ops/*.yaml`, so a node declaring one gets a segmented control with no UI
  file edited — which is the promise ops/mod.rs already makes.

The panel's dispatch was duplicated: a lone parameter and a grouped one each
wrote out their own list of kinds, so `enum` would have had to be added twice
and a kind added to one would appear or vanish depending on how many parameters
its operation happened to declare. `ParamControl` is now the only such chain.

`rows_from` is free-standing rather than a method, which is what lets the
FR-DEV-3c acceptance test requirements.md asks for actually be written: an
operation the frontend has never heard of, appearing in a generated panel, with
no GPU in sight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:21:35 +02:00
dtourolleandClaude Opus 5 e7130ff891 Give the library header somewhere to put six buttons
A tablet in portrait is 1920 physical pixels at density 400 — 768 logical,
which is below the 820 breakpoint, so the library was already taking the
compact layout. The compact header simply was not compact: one flat
HorizontalLayout holding a menu button, a title, three status readouts and
six buttons.

Slint's HorizontalLayout has no wrap and no overflow. Given less width than
its children want it shrinks each to its minimum and lets the rest run past
the edge, so "Change library" arrived as "Change li…", the readouts elided to
nothing useful, and the row overflowed anyway.

The six actions move into a `HeaderActions` component drawn in one of two
places: inline in the header when expanded, and in a disclosure row under it
behind a "More" button when compact. One component rather than two copies,
because the alternative is six buttons with their visibility rules and
callbacks written twice, and the copy that rots is the one behind a
disclosure nobody opens while testing.

The row closes itself when the window widens, so rotating to landscape does
not leave a stray toolbar behind. "More" is a word rather than an ellipsis
glyph: "⋯" sitting in a header of elided labels reads as one more truncation,
which is the failure this change exists to remove.

`LibraryGrid` takes `expanded` to decide, from the window width rather than
the device (FR-UI-1) — so a narrow desktop window gets the same treatment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:04:06 +02:00
dtourolleandClaude Opus 5 9e47133304 Run the formatter over the shard-naming change
Build and test / android-image (push) Canceled after 1m38s
Build and test / Android (aarch64) (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Canceled after 1m34s
Build and test / Layer separation (push) Canceled after 0s
🐳 Android image / Build and push (push) Canceled after 1m38s
Traceability / Requirement traces (push) Successful in 1m2s
`cargo fmt --all -- --check` is a CI gate and 9a51cc8 landed two files past
it, so the build has been red on master since regardless of what came after.
Whitespace only — a wrapped `Ok(...)` and two `assert_eq!`s split across
lines. No logic is touched and the tests are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:29:28 +02:00
dtourolleandClaude Opus 5 151dcc3c02 Make a file out of a photograph
Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.

**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.

Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.

Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.

Three things are honest rather than done:

  - **Colour space.** sRGB only. The shader encodes and clips to sRGB before
    this crate sees a pixel, so tagging a file Display P3 would claim a gamut
    it does not contain. Refused with a typed error instead of mislabelled;
    honouring it is a pipeline change (FR-EXP-2).
  - **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
    enough to change what a batch feels like, and the settings page offers
    both because FR-EXP-1 lists them — so asking for one says so rather than
    writing a JPEG under a .avif name.
  - **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
    because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
    white lands on 65535 rather than a quarter-percent grey. Making it mean
    what it says needs the composer told what format to write.

Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.

Also here:

  - `AdjustPass::export_pixels`, ungated where `read_output` is behind a
    feature. The two are the same transfer and opposites in intent: reading
    pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
    against, while reading them back to encode a JPEG is the only way a file
    has ever been made. Separate methods so the instrumentation can count one
    without counting the other.
  - `ExportTarget`, so a destination can be a folder on the server. On Android
    that is the only destination needing no platform work whatsoever — a PUT
    against create_dir, already on the RemoteBackend trait, behaving
    identically on both platforms. Switching target clears the destination,
    since a path is not a remote folder and carrying one across would offer to
    create a folder called `home` at the library root.

Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.

Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:26:37 +02:00
dtourolleandClaude Opus 5 d7aeafaf84 Move to wgpu 29, the version Slint can share a device with
Build and test / Desktop (Linux) (push) Failing after 38s
Build and test / Layer separation (push) Successful in 24s
Traceability / Requirement traces (push) Successful in 1m3s
🐳 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 9m54s
Groundwork for spike S1. Importing a texture into a Slint scene requires it
to come from the *same* `wgpu::Device` Slint renders with, and Slint hands
out a device of the version it was compiled against. Slint 1.17 offers
`unstable-wgpu-28` and `unstable-wgpu-29` and nothing older, so wgpu 23 could
never have met it: two semver-incompatible wgpu crates in one tree are two
distinct types, and the device would not typecheck across the gap.

The version is therefore not a free choice, and the manifest now says so —
Slint and wgpu move together or not at all. The Slint requirement is also
corrected from "1.9" to the 1.17 it has actually been resolving to.

Nothing about the render path changes here. The readback bridge is still in
place and still the display path, so this is verified by the tests that
already existed rather than by anything new: 39 dr-gpu tests, which compare
real pixels off a real device, and 888 across the workspace, all passing.
Zero-copy lands separately and small.

What the six releases cost, in full:

  - `ImageCopyTexture`/`ImageCopyBuffer`/`ImageDataLayout` became the
    `TexelCopy*` names (24).
  - `Instance::new` takes the descriptor by value, and `InstanceDescriptor`
    lost its `Default` — it carries a boxed display handle now, so a headless
    context says `new_without_display_handle` and means it.
  - `request_adapter` returns `Result` rather than `Option` (24).
  - `DeviceDescriptor` absorbed the API trace from `request_device`'s second
    argument and gained `experimental_features` (25).
  - `PipelineLayoutDescriptor` takes `Option<&BindGroupLayout>` per slot, and
    `push_constant_ranges` became `immediate_size`.
  - `Maintain` became `PollType`, and `poll` is fallible.

Two of those are improvements worth having rather than churn. The error scope
is a guard whose `pop` runs on drop, so an early return from the pipeline
compiler no longer leaves a scope open on the device for whatever ran next to
fall into. And a fallible `poll` reports a lost device (NFR-R7) at the point
it happens, where before the map callback simply never arrived and the
failure surfaced later as a readback that spun out its poll limit.

Still to do for S1: dr-ui renders through `renderer-femtovg`, which is
OpenGL. Texture import needs Slint itself rendering on wgpu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:34:04 +02:00
dtourolleandClaude Opus 5 b08e94405c Give the activity row a name Slint will accept
`ActivityItem inherits VerticalLayout` declared `in property <ActivityRow>
row`, and every element that can sit in a GridLayout already carries a `row`
for its grid placement — so the declaration was an override of a built-in
rather than a new property, and the compiler refused it. dr-ui had not built
since.

Renamed to `job`, which is also the better word: the property is one
background job, and `row` described where it was drawn rather than what it
holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:33:45 +02:00
dtourolleandClaude Opus 5 3b0950c39b Regenerate the matrix that dr-thumbs moved out from under
Commit 9a51cc8 edited core/dr-thumbs/src/lib.rs and pushed its TRACES tag
from line 341 to 376 without rerunning the extractor, so the two rows that
cite it — FR-CAT-15 and NFR-RES-4 — kept pointing at a line that is now
something else. Nothing else in the report changed.

The matrix records line numbers, so any edit above a tag dates it; the CI
step exists precisely because that staleness is invisible in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:19:51 +02:00
dtourolleandClaude Opus 5 9a51cc88d6 Give a shard's remote name the client that wrote it
Build and test / Desktop (Linux) (push) Failing after 50s
Build and test / Layer separation (push) Successful in 24s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 9m39s
Shard ids are per store: every client fills its own numbering from 0, so
"shard 3" names different thumbnails on every device. The derived sync
published them into a flat shard-NNNN.sqlite namespace anyway, which left
two clients writing one name.

Both failures that follow were live. On upload, a client's open shard
overwrote a peer's file of the same id — content the peer still believed
was published and would never restore, because its own copy was sealed and
the name existed. On download, the loop skipped any remote id it already
held locally, which is the only safe reading of a name that says nothing
about who wrote it, so a client holding shards 0..5 never fetched the
peer's 0..5 at all. Between them, two populated clients exchanged almost
nothing: only shards numbered above the other's highest. A fresh device
worked, having no local shards to collide with, which is why this went
unnoticed — it is exactly the case the feature was written for.

The name is now shard-<client>-NNNN.sqlite. The client id is minted per
store in index.sqlite, beside the numbering it qualifies rather than in
settings: a store deleted and rebuilt restarts at shard 0 and must not
claim the remote names its predecessor wrote. Since our own ids now say
nothing about what we have taken from others, index.sqlite also keeps a
ledger of adopted remote names and the size each had when merged. A size
rather than a flag, because a peer's sealed shard never returns but its
open one grows, and re-merging the grown copy is how the thumbnails it
gained since arrive.

Flat names already on servers still parse, reporting no owner, so each
client adopts them once, and nothing is written under that form again. One
whose id and byte size match a local shard is that client's own earlier
upload by the same identity argument the upload path already makes for
sealed shards, so the rename does not cost every client a re-download of
its whole store. Older builds ignore the new names and stop receiving
shards until updated; their own uploads are still adopted, so nothing is
lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:15:12 +02:00
dtourolleandClaude Opus 5 f7e8cc99b1 Call them marks, not glyphs, now that they are drawn
The last comment left over from the icon change in 65e6a96, which moved
the star strip off Text and onto drawn paths. "Glyph" now names something
this file no longer has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:14:58 +02:00
dtourolleandClaude Opus 5 7c57f490fe Declare a develop operation in YAML, and generate the rest
An operation was, in the overwhelming majority of cases, four facts: what
its parameters are, what uniforms they compute, what WGSL those uniforms
drive, and where it sits in the chain. Written in Rust those four facts
arrived wrapped in ninety lines of trait implementation — a match on
parameter id to a struct field, another match back, an is_active comparing
each field to its default, a Vec<Uniform> built by hand. All mechanical,
and each one a place to make a silent mistake: a param() arm returning the
wrong field reads perfectly and breaks the sidecar round-trip.

So the four facts are the file now. core/dr-pipeline/ops/<id>.yaml is a
node, build.rs compiles it into the same Operation impl as before, and the
result lands in OUT_DIR — the same reasoning as style.yaml -> theme.slint,
including why it does not land beside the sources it would look exactly
like. Nothing downstream can tell a declared node from a hand-written one:
same &'static OpDescriptor, same fused-shader composition, same sidecar.

Nine nodes moved: exposure, white_balance, contrast, highlights_shadows,
blacks_whites, brilliance, vibrance, saturation, and the shared WGSL
helper registry. Their prose came with them, and so did their tests —
set/expect/expect_active/expect_wgsl in the declaration compile to real
#[test]s, so a node file carries its own proof rather than leaving it
behind in a file that no longer exists.

Two stayed in Rust and say so with `rust:`. The tone curve's neutral is a
relationship between five interpolated points rather than a set of values;
the colour mixer generates thirty-six faceted parameters from twelve
computed hue bands. A schema stretched to cover either would be a worse
language than Rust aimed at one caller. They still declare their position
here, because the chain's *order* is the one thing a reader comes to this
directory to learn, and an order written half in YAML and half in Rust
would be worse than either alone. default_chain() is generated from it.

Uniforms are derived by a small expression language — exp2(exposure),
blacks / 100 * 0.02 — compiled to Rust rather than interpreted, so an
unknown name or a wrong arity is a build error naming the file and the key
and the arithmetic costs nothing at runtime. The build script refuses a
duplicate order, a filename disagreeing with its id, a default outside its
own range, a test value the graph would clamp before the node saw it, a
helper that does not define the function it names, and a declared node
colliding with a file in src/ops.

Verified by adding a scratch node and removing it again: one file, no
other edit, and it joined the chain at its declared order with its test
running. 237 tests pass in dr-pipeline, clippy and fmt clean.

.yaml joins the traceability tool's scanned suffixes, because a node's
Rust now lives in OUT_DIR where a tag could never be linked from the
report. Coverage 47.7% -> 48.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:08:16 +02:00
dtourolleandClaude Opus 5 65e6a96a65 Say what the application is doing, in one bar and one list
Every background job reported into a window property of its own —
library-thumbs-done, library-pin-total, library-syncing — which only the
grid ever read. A pin download that outlived the view it was started from
drew nothing at all once the user opened an image, and there was no answer
anywhere to "what is this busy with", because the answer was spread across
eight properties nothing collected.

They report to one register now (ui/dr-ui/src/activity.rs). It publishes an
aggregate, which draws a three-pixel bar across the top of the shell in
every view, and a row per job, which the settings page lists: scans,
thumbnail batches, pin and open downloads, sidecar uploads, the sync and
the trash. Failures stay on the list until they are cleared; routine
successes do not, or a scroll would bury them.

The handle removes a still-running job when it drops, so a worker that dies
mid-transfer takes its row with it rather than leaving the bar sweeping for
the rest of the session.

Also carries in-flight work from a parallel session — the drawn icon set
and the dr-pipeline ops split. dr-pipeline's build script does not compile
at this commit; ui/dr-ui does, with clippy clean and its tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:32:09 +02:00
dtourolleandClaude Opus 5 70435b712e Walk the grid with the arrow keys, and open with Return
Build and test / Desktop (Linux) (push) Successful in 17m36s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 25s
🐳 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 9m17s
A cull is thousands of decisions. The grid already bound 0-5, P, X and U so
the judgement itself needs no mouse, and then made the photographer reach
for one to move to the next frame — which is most of the gesture, most of
the time. Arrows now move a cursor, shift+arrow extends the selection,
Home/End and PageUp/PageDown cover ground, and Return opens what the cursor
is on.

The cursor is a library ordinal, not a row of the loaded window. The window
is a few screenfuls around wherever the user is looking, so a cursor held as
a row would stop at its edge or keep counting into cells belonging to other
photographs; walking out of the window reloads it around the new position,
exactly as scrolling does. This is the argument the selection already made
by keying on ids rather than rows.

The anchor had that fault for real: it was a window row, so a shift-click
after a scroll extended from whatever image had since drifted into it. It is
an ordinal now, and apply_press takes the window's offset to map between the
two. A range longer than the loaded window truncates to what is loaded,
since selection is by id and an image the catalog has not been asked for has
none.

The pointer and the keyboard share select_row so the two cannot drift apart.
The one deliberate difference: a plain arrow collapses the selection onto
the cursor, where a plain click on an already-selected cell leaves it alone
— that exception exists so a multi-image drag can start from one of its
members, and there is no drag behind a keystroke.

The grid scrolls to the cursor only when the cursor leaves the viewport, and
then by as little as will do it. Reusing the scrub's seek() would put the
cursor's row at the top on every press, which makes a row unreadable as you
walk along it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:25:13 +02:00
dtourolleandClaude Opus 5 9b2ee0d0eb Show the colour mixer as three runs of twelve, each row a colour
Build and test / Desktop (Linux) (push) Successful in 17m20s
Build and test / Layer separation (push) Successful in 33s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 26s
Build and test / Android (aarch64) (push) Failing after 8m59s
The mixer was thirty-six sliders reading "Hue / Sat / Lum" twelve times
over with nothing saying which band any row belonged to. The identity was
there all along — the descriptor declares param.mixer.orange.sat and BANDS
carries orange at 30° — and was discarded on the way out: labels.rs had no
mixer entries, so every key fell through to a derived label that yields the
bare channel name.

A parameter can now say which aspect it adjusts and which subject it
adjusts it on, with the subject's hue where the subject is a colour
(descriptor::Facet). That is data about what the operation does, not a
layout: the mixer genuinely weights pixels around 30°. What to draw from
30°, and in what order to stack the runs, stay in dr-ui (ARCH §4.3a) —
develop.rs brings rows sharing an aspect together and marks the first of
each, and adjust.slint names the run once and draws a swatch, a track and a
readout on one line.

Grouped by channel rather than by band because an edit is almost never
"everything about orange"; it is the saturation of the greens, made by
comparing one channel across neighbouring bands. Twelve band sections put
those twelve rows in twelve different places.

The swatch is the label, which is what makes twelve rows fit where four
did. The band name is not lost: it is the row's accessible label, so the
control is not colour-only, and labels.rs is where the mapping is written
down — including chartreuse as "Yellow-Green" and spring as "Blue-Green",
since nobody hunting foliage scans a list for "Spring".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:14:13 +02:00
dtourolleandClaude Opus 5 ab4a7e00e7 Leave the adjust panel somewhere to scroll from
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 19m8s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Successful in 29s
Build and test / Android (aarch64) (push) Failing after 9m0s
A strip down the right-hand edge of the panel that no control reaches, so
there is always somewhere to put a thumb that means "scroll" and nothing
else.

This is the other half of the slider arbitration. A `SliderTrack` stands
the Flickable down the moment a finger touches it, which is what makes
dragging an adjustment reliable — and the price is that the track can no
longer be dragged past. What was left to scroll from was the ~20px band of
label between one control and the next, which on a panel that is mostly
tracks means aiming rather than reaching. Reserving the space outright is
the honest version of what had been left to chance.

Padding rather than a spacer element, and that is what makes it work: the
strip is inside the Flickable but no child is laid out into it, so nothing
puts a TouchArea over it. A press there reaches the Flickable directly,
with no arbitration to lose.

A full touch target wide (FR-UI-3). A gutter too narrow to hit confidently
would be the problem it was added to fix, in a smaller space. It costs the
tracks about 44px of a 280px column, which leaves travel enough that the
readout still moves a step per pixel at the precisions the descriptors ask
for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:54:57 +02:00
dtourolleandClaude Opus 5 67c0237ddd Make one slider, and take the lids off the develop column
Build and test / Desktop (Linux) (push) Successful in 20m28s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 Android image / Build and push (push) Successful in 13m45s
Build and test / android-image (push) Successful in 13m47s
Build and test / Android (aarch64) (push) Failing after 9m16s
Two complaints from a tablet, with one cause between them.

**Some sliders dragged and others only answered a tap.** They were not the
same control. `ParamSlider` read its geometry from a `ParamRow` for the
generated panel and `PlainSlider` took plain numbers for the straighten
angle, each with its own track, handle, hit area and gesture rules written
out separately — and a comment arguing the duplication was safe, because
"a slider that dragged differently depending on which panel it sat in
would be a worse inconsistency than the duplication".

That is exactly what happened. The touch arbitration fixed in the previous
commit went into `ParamSlider` and `CurveEditor`; `PlainSlider` kept the
old code, so two sliders in the same sidebar behaved differently and which
one you got depended on where you were dragging. The duplication failed to
survive its first change.

There is now one `SliderTrack`, owning the track, the hit area, the claim
test, the hover arbitration, click-to-jump and double-click reset. The two
wrappers differ only in where their numbers and labels come from.

**Nothing in the develop column collapses any more.** Every group was a
`Section` with a disclosure triangle, including five operations that carry
a single parameter — so the lid was most of the row, wrapping one slider
whose own label repeated the heading word for word. A control behind a lid
is one the user does not know the pipeline has.

`GroupHeading` keeps what the section was actually for: the name, the dot
that says something inside differs from its default, and the reset. The
reset is now permanently visible rather than appearing on hover, because a
hover-only control is one no finger can reach. IMAGE goes back to flat as
well. The column as a whole still closes, from the status strip — which is
the control that was wanted, at the level it makes sense at.

This costs no vertical space: `Section` defaulted to expanded and nothing
ever set it otherwise, so the panel was already unfolded and the lids were
overhead with no saving behind them.

Verified with cargo test -p dr-ui (192), clippy at -D warnings, and an
arm64-v8a release build installed on a tablet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:38:23 +02:00
dtourolleandClaude Opus 5 489465faf0 Show the photograph the way it was taken
Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

The tag is honoured as part of *reading the file*, at the same standing
as a RAW's masked-photosite crop, never as an edit. It lives as a
baseline on Framing rather than as a starting value for quarter_turns,
which is what keeps four things true: a sideways file opens unmodified,
reset returns it to upright rather than to the sensor's scan order, its
sidecar stays empty, and the rotate button still moves the image 90°
whatever the file underneath it says.

Framing::effective composes the baseline with the user's own turns
through the group law rather than by adding turns and OR-ing flags. The
naive version gets one case wrong — an odd baseline turn plus a user
mirror — and gets it wrong quietly, because the result is still a
plausible orientation. The composition collapses to a single
permutation, so obeying the tag costs nothing per pixel.

dr_decode::orientation is a header-only IFD walk, separate from
metadata() for the reason the entry points are separate at all: the grid
asks once per cell and must not build a rawler decoder to get one tag.
CR3 and RAF fall back to the full read, being neither TIFF nor JPEG.

Written down as FR-DEV-3h.

Known gap: thumbnails cached before this stay sideways. The store is
keyed by file and size, and its shards sync — invalidating them would
have every client re-download 25 MB a shard, which is not this commit's
call to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:37:05 +02:00
dtourolleandClaude Opus 5 b044a8c067 Build the Android CI image in CI, not on a laptop
The Android job ran in gitea.tourolle.paris/dtourolle/darkroom-android:latest,
a tag that had never been pushed. The image existed only as a local
darkroom-android:latest on one machine, so every Android job died at docker
pull with "manifest unknown" before reaching a step. The registry API confirms
it: that manifest is a 404 while the other builder images answer 200.

android-image.yml now builds and pushes it, following KPN's docker.yaml — host
runner rather than a container, so it has the Docker daemon and the host's
cached registry credentials, and a plain-git checkout because that host has no
Node for actions/checkout.

Where it diverges from KPN: that workflow gates on dorny/paths-filter running
inside the builder image, which here would need the very image that is missing.
The tag is the git tree hash of docker/android instead, which changes when and
only when a file there changes. An unrelated push reuses the image, a Dockerfile
edit cannot keep serving a stale latest, and a missing tag rebuilds itself
without a manual step.

The presence probe is curl against the registry API, not `docker manifest
inspect`. The latter exits 1 on this registry even for tags that are plainly
there — jellytau-builder:latest answers HTTP 200 while docker reports "manifest
unknown" for it — and trusting it would have rebuilt 7 GB on every push. The
HEAD request also yields Docker-Content-Digest, so latest is repointed only when
the digests actually disagree, without pulling any layers.

A probe that cannot authenticate falls through to building. Rebuilding when it
was unnecessary costs minutes; skipping a build that was needed is the failure
this commit exists to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:59:57 +02:00
dtourolleandClaude Opus 5 fe66eba87f Write down the trash requirement the code already implements
The traceability gate failed on an orphan tag: seventeen sites across
dr-catalog, dr-sync, dr-thumbs and the UI claim FR-CAT-15, and
requirements.md defines FR-CAT-1 through FR-CAT-14. Not a typo and not a
renumbering — the trash was built, designed and documented in the modules
that implement it, and the requirement itself was never written. An orphan
is the gate working: a tag naming an undefined ID would otherwise count as
covered, which is how a matrix comes to report coverage of things nobody
specified.

FR-CAT-15 now says what trash.rs does, in the terms the module already
argues: a soft delete moves the file into `.darkroom-trash/` and the
catalog records that it happened, because a flag alone would not survive
invariant 5.2.4 — the catalog is rebuildable from sources, so a rescan
would find every deleted file still in the library and re-index it. That
is also why the scanner exclusion is part of the requirement rather than
an implementation detail; the folder and the exclusion are one mechanism
and neither works alone. Permanent delete removes the file before the row,
a delete of something already gone counts as success, and the count and
bytes are shown before emptying.

docs/traceability.md is regenerated: 150 requirements, 71 covered, no
orphans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:48:31 +02:00
dtourolleandClaude Opus 5 03326242a1 Make the CI checks say what they mean, and format the workspace
Build and test / Desktop (Linux) (push) Successful in 1h23m26s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Failing after 1m9s
The Android job's "Verify minimum API level" step has never verified the
minimum API level. It took the first `*.so` anywhere under the target
directory, which is a host proc-macro from debug/deps — an x86-64 object
built by the runner's gcc, whose .comment section cannot mention Android
and so can never contradict the expected value. It now reads the artifact
under the target triple, compares against MIN_API parsed from the
Dockerfile rather than a second copy of the number, and fails on a
mismatch. Both sides are checked non-empty first: two failed parses would
otherwise compare equal and pass, which is the same silent success in a
new costume.

The Android image installs one SDK package per layer and keeps the
output. sdkmanager is a JVM program that aborts when it cannot get memory,
and the single `> /dev/null` step reported that as a bare "exit code 134"
while a retry re-downloaded everything that had already succeeded.

tools/ci-local.sh runs all four jobs — desktop, android, layering,
traceability — against the host toolchain, which is pinned to the same
1.92.0 CI installs. Its matrix check compares regeneration against the
working tree rather than against HEAD: CI starts from a clean checkout, so
git's answer is the right one there and reports every local run stale here.

The rest is rustfmt across the workspace, and the clippy findings that
surfaced once it did: manual_contains in dr-thumbs and collections_ui, a
map iterated as pairs for its keys, an index loop over a slice, and two
runtime assertions on a constant now made at compile time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:02:51 +02:00
dtourolleandClaude Opus 5 94a2686dcb Fit the interface to the system bars, the finger and the back key
Four faults that only show on a device, and one that was hiding on the
desktop too.

The system bars. Target SDK 36 forces edge-to-edge, so the window spans
the display and the develop status strip was drawn underneath the clock
and the wifi icons. Slint already computes the inset from Android's
OnApplyWindowInsetsListener and exposes it as Window.safe-area-insets;
nothing read it. The four views now sit inside a shell placed within the
safe area. Every inset is zero on the desktop, so that layout does not
move.

Sliders under a finger. A Flickable steals any gesture that drifts more
than 8 logical pixels along its scrolling axis within half a second of
the press, and it steals it by cancelling the child. ParamSlider's axis
test correctly declined to claim vertical drags, but nothing told the
Flickable to stand down once a drag was claimed — so an adjustment would
start moving and then be taken away mid-motion. A mouse holds a
horizontal line closely enough to stay under 8px; a finger does not,
which is why these worked on the desktop and not on the tablet. The
claim now sets `interactive: false` for the rest of the gesture.

The tone curve had the same fault and worse: its points are dragged
vertically, which is the Flickable's own axis, so every drag was stolen
— on the desktop as well.

The back gesture. Nothing handled it, so back closed the application
from anywhere in it. Android delivers it as Key.Back to the focused item
and bubbles it up the ancestors, which is the second reason the shell
wraps the views rather than sitting beside them. The order is innermost
first: settings, then crop, then zoom, then develop to the grid, then a
collection scope. Answering false at the top of the stack leaves Android
to close the activity, as it does for every other application there.
Escape does the same on a keyboard.

back_step is a pure function over a flat NavState so the ordering can be
tested without a backend: which of two states is left first is the whole
of the feature, and it is the part that is easy to get subtly wrong when
spelled out in nested ifs over live properties.

Develop's canvas now takes focus on show. Without it the arrow keys did
nothing until the canvas was clicked, and Key.Back had no focus item to
bubble from.

Panels that close. The collections sidebar and the develop column are
collapsible from the grid header and the status strip. The layout class
now supplies only the default: a panel closed to see more of a
photograph stays closed while the window keeps its shape, and the choice
is dropped when the class changes, because rotating a tablet asks a
different question from the one answered in landscape. IMAGE became a
Section, being the only group in the column that could not be put away
and the one whose content is read first and needed least.

Pinch to zoom on the develop canvas, anchored on the midpoint between
the fingers (FR-UI-4). The wheel is the desktop's answer and there is no
wheel on a tablet.

The develop status strip was 28px against the 44px headers on the
library and settings pages either side of it — the one screen where a
way out has to be found was the one drawn smallest. All three now agree.

Verified with cargo test -p dr-ui (192 passing, 6 new), clippy at
-D warnings, and an arm64-v8a release build packaged to an APK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:59:31 +02:00
dtourolle 4d78041d1d Many imorovments
Build and test / Desktop (Linux) (push) Failing after 1m8s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Canceled after 23s
Traceability / Requirement traces (push) Failing after 59s
2026-08-12 22:16:15 +02:00
dtourolleandClaude Opus 5 fa12afed18 Keep originals on this device, by pin and by use
Build and test / Desktop (Linux) (push) Failing after 1s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Layer separation (push) Failing after 1s
Traceability / Requirement traces (push) Failing after 2s
Fills in `image_cache`, which the previous commit's "On this device" filter
read but nothing wrote. Also carries in-flight work that shared these files:
the Android TLS root store, the settings page, and a regenerated
traceability report.

# Two populations, deliberately separate

An original is kept here for one of two reasons, and conflating them produces
the exact failure the feature exists to prevent.

**Pinned** originals were asked for. Pinning a collection before a trip is a
promise, so pinned rows are never evicted and never counted against the
budget — a cap that could silently delete a pinned trip would make pinning
worthless, because it could not be relied on without checking.

**Passively cached** originals are a side effect of working: develop already
downloads the whole file, so keeping it costs no bandwidth and saves the
entire transfer next time. This population is what the budget bounds, evicted
least-recently-used, because it otherwise grows until a day of culling fills
a disk.

Sharing one budget would let a large pin starve the passive cache, or let
browsing evict a pin. They are separate.

# What was built

`dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned
— and writes the bytes; deciding to download stays with the caller, which is
what keeps a crate with no network out of the network's business. Files are
written to a temporary and renamed, so a dropped connection cannot leave a
truncated file recorded as a complete original. They are named by image id,
not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different
photographs, and a flat cache keyed on the name would serve one for the other.

`spawn_full_fetch` became read-through. A hit is a disk read; a miss stores
what it downloads and enforces the budget. A cache that cannot be opened is a
miss, not a failure to open the photograph.

Pinning writes intent — `tier_desired` — without downloading, so the button
responds immediately, and `spawn_pin_fetch` fills it in sequentially
afterwards. Sequential because these are tens of megabytes each: the lanes
that make the thumbnail sweep fast buy little against one connection's
bandwidth and cost a great deal of memory. A pin interrupted by a lost
connection resumes from where it stopped.

Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something
inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot
answer for an image whose rule was deleted. A v4 catalog migrates in place;
existing rows default to unpinned, the safe direction.

The budget and "keep opened originals" come from the settings page rather than
a constant, and are applied at startup rather than only on change — a cache
capped at 2 GB last session would otherwise spend this one filling to the
default. Turning off keeping leaves what is already cached readable: those
bytes are paid for, and refusing them would re-download images sitting right
there, including pinned ones.

Also removes a doubled `#[test]` introduced in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:12:01 +02:00
dtourolleandClaude Opus 5 cd75e5a4c6 Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key
fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the
timeline scrub marker correction, the 423-Locked retry in the metadata
sweep, and the thumbnail size-class migration.

# Offline mode (FR-CAT-9)

The app previously assumed the server was reachable and treated its absence
as a series of unrelated per-operation failures. A launch without a
connection produced an empty grid, even with a complete catalog on disk and
every thumbnail already in the shards.

Reachability is now inferred from traffic the app was already making, rather
than probed for. `RemoteError::indicates_offline` draws the line that makes
this possible: a dead connection is offline, a 403 or a 500 is not — the
server answered, so blanking the library over one forbidden file would be a
worse error than the one being reported. `Reachability` turns those outcomes
into a state, so a library browsing happily never issues a probe at all.

Going offline takes one failure, because the user is already experiencing it.
Coming back requires evidence — a completed scan or a fetched thumbnail —
with a capped exponential backoff behind the manual retry, so twelve sweep
lanes failing together do not schedule twelve immediate probes.

What keeps working: the catalog opens even when the scan that normally
provides it failed, so the grid fills from the last successful scan.
Thumbnails come from the shards. Rating, flagging and collecting are catalog
writes that never touched the network. What stops is opening an original that
was never stored locally, and it now says so in those words instead of
reporting "network error: connection refused" over a photograph.

Work that is pure network is refused rather than left to fail slowly: the
metadata sweep, derived sync, and sidecar writes. The sweep would otherwise
spend a timeout per image across the whole library while the progress bar
implied something was happening. Deferring sidecars is a real gap rather than
a hidden one — a rating made offline reaches its sidecar only when that image
is judged again while connected — and it is recorded as such at the call site.

# The "On this device" filter

A chip beside the rating filters, narrowing the grid to images whose original
is held locally. It composes with the rating terms rather than replacing them,
so "five-star frames I can actually edit on this train" is one filter. The
predicate is SQL, like the rating terms and for the same reason: the count in
the header has to agree with the cells drawn.

It reads `image_cache.tier_actual`, which nothing writes yet — the next
commit fills it. Until then the chip honestly reports zero.

`Tier` gains an explicit on-disk encoding. The variants are ordered by
generosity and the derived `Ord` invites reordering them, which would
silently reinterpret every cached row; the round-trip test is what holds the
two in agreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:36:50 +02:00
dtourolleandClaude Opus 5 f6a100863e Place the timeline marker where the pointer actually is
Build and test / Desktop (Linux) (push) Failing after 6s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Layer separation (push) Failing after 1s
Traceability / Requirement traces (push) Failing after 1s
The marker was drawn from a bucket index while a click reported a
fraction of the track. Those are different quantities: bars each occupy
one equal slot whatever span of time they cover, and the index snapped
to the containing bucket's start edge, so the marker landed at the top
of whichever slot held the instant — close enough to pass on a dense
uniform axis, plainly wrong on a sparse one, and never under the click.

Send the fraction instead, computed as the exact inverse of the
interpolation the scrub handler applies, and position the marker from
it. Marker and click are now the same quantity by construction.

Scrolling the grid also left the marker behind: only an explicit scrub
ever wrote the position, so the axis claimed to say "when you are" and
stopped being true the moment the wheel moved. Map the first visible row
back to a capture time and move the marker with it.

That runs on every scroll event rather than behind the window-reload
guard, which fires a few times per screenful and would make the marker
advance in jerks. Only the marker moves, not the bars: rebuilding those
means a GROUP BY aggregate over the library, far too much for a flick,
and they do not change as the grid scrolls anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:08:32 +02:00
dtourolleandClaude Opus 5 8b7c1e7f10 Open the sign-in URL through an ACTION_VIEW Intent on Android
The previous commit made the missing launcher honest; this gives Android a real
one, so Login Flow v2 can complete on device.

Builds `new Intent(ACTION_VIEW, Uri.parse(url))` and hands it to
`startActivity` over JNI. The JavaVM and Activity come from ndk_context, which
android-activity's glue populates at startup — the same handle Slint's backend
uses, so there is no second VM to reconcile.

The login worker is a plain std::thread and therefore unknown to the JVM, where
any JNI call would abort the process. jni 0.22 scopes attachment to a closure
rather than returning a guard, so the whole Intent is built and dispatched
inside `attach_current_thread` and the thread detaches on the way out.

Names use `jni_str!` and signatures `jni_sig!`, both compile-time: a typo is a
build error rather than a NoSuchMethodError on the device. A pending Java
exception is checked and cleared before returning, since leaving one pending
makes the next JNI call fail somewhere unrelated; in practice it means
ActivityNotFoundException, i.e. no browser installed.

jni is pinned to 0.22 to match Slint's Android backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:35:38 +02:00
dtourolleandClaude Opus 5 0876977133 Report a missing browser launcher instead of faking success
`open_in_browser` gated its xdg-open path on `target_os = "linux"`, which is
false on Android — that is its own target_os. Android therefore took the
fallback arm, which discarded the URL and returned `Ok(())`.

Login Flow v2 cannot complete without a browser: the user approves the
sign-in there and `auth::poll` waits for that approval. Claiming success meant
the UI showed "Approve the sign-in in your browser" with no browser open, the
poll waited for an approval that could never arrive, and the worker eventually
dropped its channel — surfacing as "sign-in failed unexpectedly", which
pointed at the network rather than at the real cause. The server had in fact
been contacted successfully.

Gate on `unix && !android && !macos` so the arm matches what xdg-open actually
implies, and return Unsupported from the fallback. The caller treats it as
terminal rather than swallowing it with `let _ =`.

Android gets a real Intent-based launcher in the next commit; until then the
failure is at least honest about what happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:22:00 +02:00
dtourolleandClaude Opus 5 d49b4b41de Add thumbnail size classes and grid zoom; fix the scrub ordinal
The grid now zooms, which needs thumbnails at two resolutions rather than
one, and exposed a scrub that landed in the wrong place.

**Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and
`Large` (1024px, ~45 KB), with the class part of the store key so both
coexist. Storing everything large would take the reference library from
~200 MB to ~860 MB, and shards sync, so that is transfer cost on every
device rather than only disk. A store written before the class existed
migrates in place: its entries are all grid-sized, which is what the
column defaults to, so nothing already fetched is discarded.

`forget` now drops every size for an image. Reading a single row left the
other class's bytes on the shard's tally for good, sealing it early on
space nothing occupied.

**Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in
geometric steps, so the gesture feels the same at either end where a fixed
pixel step would be imperceptible at 400px and violent at 90px. Crossing
256px switches to the large class, so a zoomed cell is sharp rather than
upscaled. Columns and window capacity already derived from cell size, so
the grid reflows for free.

**The scrub landed about half a library too high.** It counted only dated
images while the grid shows all of them — 10,733 dated against 19,841
rows — and ignored `shadowed_by`. Verified against the live catalog: the
old formula gave 10,887, the new one 10,732, the true grid position
10,732. The scrub's count and the grid's window must use identical
predicates and ordering; a test now fails if they diverge.

**Timeline gestures are continuous.** Scrub and pan were quantised to
whole buckets, so a slow drag did nothing until it crossed a boundary and
then jumped a month. Both work in fractions of the visible span now, and
pinch-to-zoom arrives for tablet, where there is no wheel to reach the
axis with.

The pinch accumulator was wrong on first writing: it took at most one step
per update, so an 8x spread — three doublings — yielded one zoom level.
`log2().trunc()` now extracts every whole doubling and carries the
remainder. The original test asserted the wrong number and defended it in
a comment, which is worth remembering: a test can entrench a bug as
readily as catch one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:58:32 +02:00
dtourolle 75ce3846c3 Render draft frames while a gesture is still moving
The adjust pass and the readback both scale with pixel count, so a drag
paid full viewport cost on every frame it managed to produce. Halving
each edge while the gesture is moving is roughly a quarter of the work.

Detecting the gesture needed no new plumbing. No control reports a drag
boundary, and threading one out of every slider, curve point and crop
handle would be a lot of surface for what is a rendering concern. The
coalescing flag already carries the answer: a request that arrives while
a render is queued can only come from a control that moved again. A
click, a reset or a resize never coalesces, so those still render sharp
the first time and never show a draft frame.

A settle render restores full resolution 120 ms after the last change —
above the interval between events within a drag, so an ordinary gesture
never trips it mid-motion, and well under the point where waiting for
the sharp frame would be noticeable. Only one settle is ever queued.

The softness is therefore visible only while the image is moving too
fast to study.

dr-ui tests pass.
2026-08-09 21:56:44 +02:00
dtourolle 250ff1e327 Poll the adjust readback instead of parking the UI thread
`read_output` waited on the copy with `Maintain::Wait`, which parks the
calling thread until the GPU is done. That call is made from the UI
thread, so the interface was frozen for the length of the copy — the
note on this function measures it at ~7 ms at 4K against a 0.28 ms
compute pass, so nearly all of it was the wait.

`Maintain::Poll` drives the same callbacks without sleeping. The mapping
still completes and the pixels are identical; the thread simply is not
parked while it happens.

The poll loop is bounded. A lost device never delivers the map callback,
and spinning forever on that would hang the app rather than report the
error the caller already handles.

This does not remove the round-trip itself, which ARCH §6.1 forbids and
spike S1 replaces by importing the texture into Slint directly. It stops
the round-trip from blocking input until then.

dr-gpu tests pass, including those comparing readback pixels.
2026-08-09 21:52:59 +02:00
dtourolle 31dd86d8c0 Coalesce develop renders instead of rendering per input event
Every slider, curve-point and crop-handle drag ran a full render
straight from its `moved` handler. A render ends in a blocking GPU
readback, so that stall sat directly on the input path: touch events
arrive far faster than a render completes, the queue backed up, and
positions reached the handlers several samples stale — the jumpy
dragging.

The worse consequence was gestures being lost outright. When events go
unconsumed for long enough Android reclaims the stream and hands it to
the view underneath, so a drag stopped mid-gesture never received `up`,
only `cancel` — which every handler here treats as "abort the drag".

`redraw` no longer renders. It marks the canvas dirty and posts one
render onto the event loop, coalescing any further requests that arrive
while it is pending, and re-checks the flag afterwards so a value that
moved during the render is not left on a stale frame. Handlers now
return immediately, which is what keeps the gesture consumed.

A zero-delay `Timer` rather than `invoke_from_event_loop`: the latter
requires `Send` and this state is deliberately `Rc` on the UI thread.

All existing `redraw` call sites are unchanged — the signature is the
same and coalescing is internal.
2026-08-09 21:49:43 +02:00
dtourolleandClaude Opus 5 b4c1645c7a Address trash moves and deletes by path, not fileid
Trashing or emptying the trash failed on every scanned image with
"operation unsupported by this backend: fetch by fileid requires a
path".

RemoteId::Stable(oc:fileid) is an identity — it answers whether a file
is the same one after a move, and keys the thumbnail shards. It is not
an address: WebDAV exposes no fileid-addressable endpoint, so the
Nextcloud backend serves get/delete/move_to by path and rejects a bare
Stable. Both trash workers preferred the fileid whenever the catalog
knew one, so the unreachable Path fallback was the only arm that would
have worked, and the failure hit every properly-scanned image rather
than some edge case.

Address by path at both sites, and keep the fileid for what it is for:
the identity MOVE preserves, and the key the thumbnail cleanup uses.

Move::file_id was documented as "the id the MOVE addresses", which is
the wrong claim that seeded this; corrected, along with a note on
RemoteId itself so the distinction is stated where the type is defined.

Only the backend rejection was covered by a test. Added the positive
case, since that contract is what the call sites now depend on. The
workers build their own NextcloudBackend, so no test can reach the
call sites directly — closing that would mean injecting the backend,
which is left alone here.

Verified by build and test; not exercised against a live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:39:35 +02:00
dtourolle 9a24623e35 Fix the workspace build off-device
Two breaks that only appeared on a full `cargo test --workspace`.

`slint::android` exists only when compiling for Android, so darkroom-android
failed to compile on the host even though it is a workspace member. The entry
point is now gated on the target rather than on a feature.

The timeline forwarded `scrub` where the Timeline component declares
`scrub-to`, which the Slint compiler rejects.

Assisted-by: LLM
2026-08-09 21:15:53 +02:00