Commit Graph
6 Commits
Author SHA1 Message Date
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 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 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
dtourolle 8ad5c86ff9 Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.

Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.

The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.

Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.

serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.

Assisted-by: LLM
2026-08-09 21:11:38 +02:00
dtourolle 2ca1716a29 Make "Choose folder" an actual folder picker
It previously fetched the folder list and threw it away into a status
line — a button that looked like it worked and did not. Now it opens a
browsable picker: click a folder to descend, ".." to go back, "Use this
folder" to select, "Cancel" to leave the root unchanged.

Descends one level per click because that is what the backend supports:
Depth: infinity is frequently disabled server-side and prohibitively
expensive where it is not (ARCH §8.4).

The chosen root persists immediately on confirm, so it survives a crash
before the library is opened. Confirming at the account root is allowed —
a user may legitimately keep everything at the top level — and cancelling
leaves any previous selection untouched, which a test asserts.

Verified against nextcloud.tourolle.paris at both depths: 30 folders at
the root, 21 year-folders inside PhotosRaw.

19 launch tests, 38 in dr-ui.
2026-08-09 15:50:40 +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