Commit Graph
63 Commits
Author SHA1 Message Date
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 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
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 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
dtourolleandClaude Opus 5 02b66ddfcf Document trash, collections, thumbnails, and the UI direction
Specs for the work that follows: soft delete via a MOVE that preserves the
remote id, the collection tree and smart collections, the thumbnail store,
and the derived-state folder.

Adds two design documents. ui-refinement.md names the structural gaps
between the v0.1 UI and something that feels like a photo editor.
view-composition.md proposes a view controller for the display layer,
against the 500-line run() that has become one by accretion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:36:23 +02:00
dtourolle 09e3043f4c Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
2026-08-09 15:20:39 +02:00
dtourolle c8bb08e661 Add folder scan with format selection; validate A3 on a real library
Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.

  dr-types::FormatFilter  the tick-box selection, seeing through VFS
                          placeholder suffixes so a dehydrated CR2 still
                          matches as a CR2
  dr-sync::scan           recursive walk, Depth:1 per directory, pruning
                          unchanged subtrees where the backend propagates
                          directory ETags

Verified against nextcloud.tourolle.paris (34.0.2) on a real library:

  browse root      32 entries, 98ms
  scan PhotosRaw   17,185 RAW files in 334 directories, 34.1s
                   (7,836 CR2 + 9,349 DNG)
  range read       262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
                   and enough to read "Canon EOS 6D | ISO 100"

That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.

Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.

Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
2026-08-09 12:22:31 +02:00
dtourolleandClaude Opus 5 78e3e6b846 Add the develop pipeline: demosaic and seven raw adjustments
Decode through display, on the GPU: black/white normalisation, Bayer
demosaic, camera colour transform, and the first seven adjustment
operations — white balance, exposure, highlights/shadows, blacks/whites,
brilliance, vibrance, saturation.

Composable shaders. Each operation contributes a WGSL fragment rather
than owning a pass, and dr-pipeline fuses the *active* ones into a single
compute shader. One texture read and one write per frame regardless of
how many adjustments are in play, while the operations stay independent
in Rust — adding one is a new file, with no central shader to edit. An
operation at neutral settings contributes no code, no uniform and no
branch. Uniforms are prefixed per operation so two may both declare
`amount`; helpers dedupe by name from a single source of truth.

Pipelines cache on a structure hash covering the op-set and its order but
not the values, so dragging a slider uploads uniforms and reuses the
compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one
pipeline compiled across ten slider positions.

The UI is generated, not written. EditGraph::capabilities() reports
parameters with their kinds, ranges, defaults and current values; the
panel builds one control per entry chosen by ParamKind. No file in ui/
names an operation, and dr-pipeline has no wgpu dependency, so codegen is
testable without a device (ARCH §6.5a).

Three defects found against real files, each silent:

- rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer
  populated. The live matrices are in `color_matrix`, keyed by
  illuminant. Reading the old field yields no colour transform at all.
- `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it
  divides each of four rows by its own sum, and the unused fourth
  (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it.
  `wb_coeffs[3]` is NaN for the same reason and is normalised at decode.
- As-shot white balance reached the uniform block but no shader read it,
  so the first render of a real CR2 came out violently green. Green
  photosites collect roughly twice the signal of red and blue. Now
  applied unconditionally before any operation, with tests on ordering.

Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected
interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves
visible zippering on any high-contrast edge at 1:1. Two of the four
packed CFA constants were wrong on the first attempt, so all four layouts
are asserted to reconstruct the same colour. Crop origins at odd
coordinates re-phase the pattern; without that, red and blue swap.

X-Trans reports GpuError::UnsupportedCfa rather than approximating with
the Bayer path, which would look like a corrupt file.

206 tests, including GPU tests proving every operation and the full
seven-operation chain generate compilable WGSL.

Known gaps: the display path still reads back to the CPU each frame,
which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the
`readback` feature and waits on spike S1 wiring Slint's texture import.
Curve shapes are a first draft and want tuning against real photographs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:37:58 +02:00
dtourolle cc1c5c892d Support requesting VFS hydration; fix Android TLS cross-compilation
Correcting the previous commit: I claimed VFS placeholders could not be
downloaded. That was wrong. The desktop client exposes a socket at
$XDG_RUNTIME_DIR/Nextcloud/socket speaking newline-delimited
COMMAND:argument, and MAKE_AVAILABLE_LOCALLY:<path> does fetch the file.
Verified against client 4.0.7: a 1-byte stub became a real 2.8MB file in
2.8 seconds.

Implemented as dr-sync-nextcloud::desktop_client, deliberately optional.
Android has no desktop client, no XDG_RUNTIME_DIR socket and no
placeholders, so detect() returns None there and callers fall back to the
connector. It earns its place only because it is ~30 lines with no
dependencies: where a library already lives in a VFS folder, asking the
client to fetch beats downloading a second copy over WebDAV and leaving
the client's placeholder state inconsistent.

What this does not change: hydration is whole-file, so it suits the
original tier and never browsing. Filling a grid this way downloads the
entire library. Range extraction remains the only mechanism satisfying
FR-NC-3, and ARCH §9.0 now says so precisely.

Also fixes two real Android build failures found by cross-compiling:

  - reqwest's `rustls` feature defaults to aws-lc-rs, whose aws-lc-sys
    crate is C and fails under the NDK — exactly the pain D1 chose Rust
    to avoid. Switched to rustls-no-provider + ring, installing the
    provider in the constructor so no caller can build a client that
    panics on first use.
  - ring itself needs CC/AR per target; cargo-ndk sets only the linker.
    Added them to the container.

87 tests passing. dr-sync-nextcloud cross-compiles for aarch64-linux-android.
2026-08-09 10:10:29 +02:00
dtourolle f8a718f42e Add Nextcloud connector; reject VFS as a transfer mechanism
Investigated using the Nextcloud desktop client's Virtual Files as a
cache instead of talking to the server directly. Measured on this
machine (client 4.0.7): the configured folder holds 121,785 placeholders
against 10,267 materialised files, including 7,037 CR2 and 9,411 DNG.

Three findings, each independently disqualifying:

  - Linux VFS is *suffix* mode. A dehydrated IMG.CR2 exists only as
    IMG.CR2.nextcloud holding one byte; the real name is absent.
  - Reading a placeholder does not hydrate it. dd of the first 256KB
    returned 1 byte, the stub was unchanged, and the real name never
    appeared. There is no FUSE layer — the stub is an inert marker.
  - Even with hydration the granularity is wrong: VFS has two states,
    1 byte or all bytes, and the preview tier needs a ~256KB prefix of
    a 27MB file. That is ~100x what FR-NC-3 requires.

Recorded as ARCH §9.0. Coexistence is still supported: dr-types now
recognises *.nextcloud stubs, and the viewer lists them as "not
downloaded" rather than as corrupt files or not at all.

So the connector talks to the server directly, as D7 specified.
Implemented: Login Flow v2, PROPFIND with oc:fileid and nc:has-preview,
ETag pruning via a Depth:0 probe, range GET with local slicing when the
server ignores the header, conditional PUT, and /core/preview with
forceIcon=false. delta() returns Unsupported and says why.

Chunked upload v2 is not implemented yet — put() rejects bodies over
5MB explicitly rather than silently truncating.

Two bugs found by testing: my hand-computed epoch in a date test was a
day out (the parser was right), and quick-xml reaches EOF on truncated
input without erroring, so unbalanced elements needed an explicit check
— a half-parsed multistatus must not look like an empty directory.

83 tests passing.
2026-08-09 09:09:27 +02:00
dtourolle 9717e59909 Add RAW decode and a working image viewer
dr-decode exposes four entry points rather than one decode, because
callers differ sharply in what they need (ARCH §3.2): culling wants a
preview, the grid wants metadata, only develop and export touch sensor
data. Fusing them forces a full decode where a header read suffices,
which is why Lightroom stalls ~2s per image while culling.

Smoke-tested against 1,852 real Canon CR2 files (EOS 6D, ~27MB each):

  metadata      0.2ms   from a 256KB header read, no full decode
  preview     ~250ms   5472x3648, downscaled to 2048 for display
  jpeg          3.0ms

Two findings worth recording:

rawler 0.7.2's CR2 decoder implements only full_image; thumbnail_image
and preview_image are unimplemented trait defaults returning None. So
every rung of the preview ladder resolves to a full-resolution decode at
~250ms — 5x over NFR-P13's 50ms budget. CR2 does carry smaller IFDs, so
the fix is our own IFD walk or an upstream contribution. The ladder is
written now so that fixing it is a decoder change, not a change to every
caller. Recorded in milestone-v0.1 risks.

Preview.downscale_to bounds memory: a 5472x3648 RGBA preview is 79.8MB,
which exhausts a phone's budget after a handful of images. Box-filtered
so downscaled thumbnails do not alias.

Also fixed a RefCell double-borrow that panicked on first navigation —
`*x.borrow_mut() = *x.borrow() + 1` holds both borrows at once. Verified
with 10,000 programmatic navigations.

58 tests passing. Traceability 20.3% (29/143).
2026-08-09 08:28:26 +02:00
dtourolle 0f202fd3f9 Add requirements traceability gate and Gitea pipelines
Ports JellyTau's traceability tooling to Rust, carrying across the bug it
was repaired for. That gate divided a traced count by frozen literal
denominators; the requirements file outgrew them and it reported 158%
coverage, so it could never fail its own threshold.

Two rules, both enforced by the extractor's own tests:

  - denominators parsed from docs/requirements.md at run time
  - coverage is |traced ∩ defined| / |defined|, never a raw traced count

The gate additionally fails hard on a misconfigured run — zero
requirements parsed or zero files scanned — rather than reporting a
plausible 0%, and on any orphan tag naming a requirement that does not
exist.

Adapted for DarkRoom: IDs are FR-CAT-1 / NFR-P13 / FR-DEV-3a shapes
rather than JellyTau's fixed three digits, and decisions (D), spikes (S),
milestone items (M) and test ids remain taggable while being excluded
from the denominator — counting them inflated it by 25.

Also adds dr-sync: the RemoteBackend trait and capability model, so the
Nextcloud connector is one implementation rather than the only shape the
engine understands. No mature Nextcloud crate exists (reqwest_dav is too
thin), so the connector will be hand-rolled over reqwest per D7.

Gitea workflows follow the same style: containerised, commented with the
reasoning, desktop and Android on every push, plus a CI check that no
core/ crate depends on the UI toolkit (ARCH §6.5a).

Coverage today: 13.3% (19/143). 50 tests passing.
2026-08-09 08:01:32 +02:00
dtourolle 82a5e21ec6 Initial workspace: GPU context, compute pass, adaptive Slint shell
Establishes the v0.1 foundations on both platforms:

- dr-types: SourceRef (never a filesystem path — Android SAF has none),
  Format, Availability, Validator with ETag quote normalisation
- dr-gpu: wgpu device, compute pass writing a storage texture, resize
- dr-ui: Slint shell with FR-UI-1 adaptive layout, computed in Rust to
  avoid a binding loop
- docker/android: pinned toolchain, verified producing API 28 ARM binaries

Measured the cost of the temporary CPU readback path (dr-gpu bench):
compute is 0.06-0.28ms across sizes while readback is 0.63-7.43ms, so
readback is 90-96% of frame time and scales with area. Recorded in
ARCH §6.1 — this is why spike S1 is the priority.

Mitigations pending S1: reuse the staging buffer, apply at most one
resize per frame, and cap render resolution at 2048 on the long edge.

10 tests passing; core crates cross-compile for aarch64-linux-android.
2026-08-09 07:42:05 +02:00