Commit Graph
21 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 6a97fdf6f9 Put the adjustment groups in the rail where a finger is driving
Reported from the tablet: the tool rail is very useful there, and the same
interface under a mouse and keyboard is not. That is `ui-navigation.md` D-N2's
central assumption failing in use, and the interesting part is which half of
it failed.

D-N2 was right that platform is the wrong axis and width is the wrong axis: a
tablet in landscape wants what a desktop wants, and a desktop window dragged
narrow wants what a small screen wants. `apply_layout_class` still decides the
layout class from the window and nothing here changes that. What D-N2 got
wrong is the sentence "touch changes hit regions, not layout" — it identified
input as the real difference between the targets and then assumed that
difference could never reach the layout.

Two controls answer one question — which group of adjustments am I looking at
— and neither is better in general. A horizontal strip above the column is one
gesture to a target the eye has already found, and it pans when the operation
set is rich, so a group can sit off the end with nothing saying so: a pointer
user tolerates that, a finger user never discovers it. The same list down the
rail is every entry visible at once, each finger-sized, on the edge of the
screen the hand is already holding, and it costs no width because the rail is
already there.

So `ToolRail` grows a second section, and `GroupStrip` stands down when it
does. The two are never both on screen, which is why they can share
`adjust-tab-picked`: Rust is not told which was pressed and has no reason to
want to. Mode and group stay independent axes as N1 requires — one entry lit
in each section, and choosing a group while a tool is held still filters
without putting the tool down.

They stay drawn differently, which N1 also required. The tools fill with
`active-dim` and invert their ink; the groups take a bar down the leading
edge — the strip's underline turned ninety degrees — so a lit entry says which
kind of state it is without the reader having to remember which section it was
in. The rule between the sections is the second signal.

The rail scrolls now. Its own note argued against a Flickable because "this
list is four entries written in this file"; with the groups in it the list
comes from the operation set, which is exactly the "something the user's data
decides" that note excluded this control from.

The axis is input, and it is a preference because the automatic answer is a
guess that cannot be made reliable. Neither platform can be asked what the
user is holding: an Android tablet in a keyboard case is being driven like a
desktop, and a touchscreen laptop is whichever its owner says.
`dr_plat::is_touch_first` reports the usual case per platform, and
`GroupNavigation` lets it be overridden. Settings names what Automatic
resolves to on this device rather than leaving it to be found by pressing.

D-N6 records the reversal beside the decision it reverses, including the half
that still stands and the question it opens: whether Local is a mode at all,
or a scope that would collapse the two sections into one list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:14:03 +02:00
dtourolleandClaude Opus 5 bb35665bd2 Let a paste carry some kinds of edit and not others
FR-DEV-6 asks for presets "covering a subset of the edit graph". What
landed with the named presets covered two subsets: everything, and
everything but the crop. "Match the colour but not the sharpening" had
no way to be said.

`Scope` is now a set of `Attribute` — the same six kinds every operation
already declares and the develop panel already builds its tabs from. The
photographer ticking "tone and colour" is naming the groups they
navigate by, and neither this module nor the interface has to name an
operation to do it (FR-DEV-3c).

The pleasing part is what left. Framing used to be excluded by an
explicit test against one operation's id; it is now excluded because
Geometry is not in the default set. The special case dissolved into the
general rule, and the argument for it — a crop is a decision about *this*
photograph, and carrying it across forty destroys forty compositions —
is now a statement about a kind of edit rather than about a node. All
thirty-three existing preset tests pass unchanged, which is the evidence
that the generalisation kept its promises.

One decision that is a field rather than a rule, because the two cases
genuinely differ. An operation this build cannot classify — from a newer
version, arriving over sync — travels under "everything" and "everything
but the crop", because those are claims about the whole edit and an
unrecognised operation is part of it (FR-NC-8). It does not travel under
a hand-picked set, because that is a claim about kinds, and an unknown
kind is not one of the kinds that were ticked.

The settings page's "Copy crop and rotation" checkbox is gone, replaced
by the same chips the preset sheet draws. It asked the right first
question — geometry is the kind whose accidental travel destroys work —
but it was the only question a boolean could ask. The field stays in
`Settings`, read exactly once to seed the new set, so anyone who had
ticked it keeps their behaviour.

The chips are deliberately not in the develop column. Six of them there
would set the width of the whole sidebar, which is the bug `ChipGrid`'s
comment records at length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:38:19 +02:00
dtourolleandClaude Opus 5 bd33487054 Say which axis each export dimension is, in the one place that renders
The box modes put two numeric fields one above the other, and on screen
they are two anonymous numbers: `TextRow` draws its label behind the
field rather than above it, so "Width" and "Height" never appear. That
fault is older than this feature — the storage panel's cache sizes have
the same missing labels, and the single "Size value" row always did — and
it belongs in its own change rather than being fixed under cover of this
one.

But one unlabelled number is survivable and two are not, so the axis goes
where the page does render it: the unit. It reads "3840 px wide" above
"2160 px high", which is the sentence the user is trying to write anyway.

The `label` bindings stay correct and stay where they are, so this
becomes redundant rather than wrong the day `TextRow` is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:21 +02:00
dtourolleandClaude Opus 5 3bb68cff69 Export at an exact resolution, and offer the panels worth naming
Export sizing could bound an image but not fix it. Long edge, short edge
and percentage all preserve the aspect ratio by letting one dimension
fall where it may, which is right for most work and useless against a
display that accepts one resolution and rejects everything else — a
television's art mode, a digital frame, a wallpaper slot.

FR-EXP-3 has always listed both halves of the answer, and this adds them.
**Fit box** scales to fit inside a width and height, so nothing is thrown
away and the result is smaller than the box on one axis unless the crop
already matches it. **Fill box** scales to cover the box and cuts the
overhang off the middle, so the file is exactly the pixels asked for.

Fill is the only mode in the file that discards image data, so two things
about it are worth stating. The overhang comes off symmetrically: the
crop tool is where a photographer decides which part of a frame survives,
and this stage having an opinion of its own would fight it. And locking
the crop to the same ratio leaves nothing here to cut, which is the
workflow the two features are meant to be used in.

With upscaling off and a source too small to cover, a fill box keeps its
*shape* rather than falling back to the source's: exporting a 3:2 file
where 16:9 was asked for is silently wrong in exactly the way the mode
exists to prevent, so the box shrinks instead. The existing rule — clamp,
never fail — is otherwise unchanged.

Four panel sizes are offered as buttons beside the fields. Getting 3840 x
2160 by typing four digits twice is a step at which the mistake is
discovered after the upload rather than before it. They fill in the
numbers and nothing else, in particular not the fit/fill choice: both are
legitimate against a screen, and guessing would discard the edges of a
photograph for a user who wanted them. The list is panels rather than
platforms, because a screen has one exact pixel count for ever where
"what a photo site wants" would rot in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:20 +02:00
dtourolle 28c046130c Merge branch 'master' into android-bundled-face-models 2026-08-27 20:22:42 +02:00
dtourolleandClaude Opus 5 c8c6368542 Index the whole library by fetching what it has not seen
"Index faces in the whole library" could not. Its work list was intersected
with the thumbnail store at `ThumbSize::Large`, and nothing fills that class for
a whole library — `SWEEP_THUMB_SIZE` is deliberately `Grid`, because the large
class is ~860 MB of shards against ~200 MB and every syncing device pays it. So
the only images with a large proxy were the ones the user had personally zoomed
into or opened in the loupe. On this library that was 220 of 23,529.

The comment defending it misread the requirement:

    // Requesting one here would put face indexing on the network path,
    // which FR-CULL-8 explicitly keeps it off.

FR-CULL-8 keeps indexing off the **full decode**, not the network, and then says
the opposite in the same paragraph: "where no proxy exists, the job requests one
at background priority rather than decoding inline". faces.md §7 repeats it.
Neither was implemented.

So the pass fetches. Same two-stage route the thumbnail sweep uses — the header,
then the located preview's own byte range (FR-NC-3) — so no whole file is pulled
and no RAW is decoded, because an embedded preview is a JPEG. The work list is
now every visible image with no `face_index` row for the model: 23,308 here,
against nearly none before.

**It indexes at the resolution the preview actually has**, not the 1024 the old
tier would have given. `locate_preview` already picks the largest embedded
preview, and the thumbnail sweep was decoding it and throwing the detail away at
`downscale_to(256)`. A face 2% across the frame is 5 px on a grid thumbnail and
~61 px at the cap here — and 112 is what the embedder samples, so this is the
difference between an upsampled crop and a real one. `crop_px` records which,
per face, as §7 intended.

Capped at 3072 rather than truly full: `index_proxy` needs packed `f32` RGB at
12 bytes a pixel, so a 24 MP frame is ~288 MB and the fetch lanes hold one each.
The constant is named and sits next to the reason.

Orientation is applied **before** detection, not after downscaling. That costs a
permutation of a larger buffer — ~15 ms against a ~150 ms decode — and buys the
entire class of bug this codebase keeps having: detection then runs on the
photograph rather than the sensor, so every box and landmark is already in the
space the catalog stores and the overlay draws, with no second mapping to get
backwards.

One detector and one embedder serve every lane. The lanes are concurrent futures
on a single thread, not threads, and inference contains no await, so a `RefCell`
borrow never overlaps another — a pair per lane would duplicate ~16 MB of
weights for no parallelism.

Images with no face in them are recorded too. `face_index` records that
detection *ran*, and zero is its most valuable value: without the row every
landscape and document scan returns on every pass, for ever, and in a personal
library that is most of it (§7a).

The old store-only pass survives as `spawn_store_face_sweep` for
`examples/face_index.rs`, which indexes a local store with no network. The
settings copy no longer claims indexing reads "the photographs already
thumbnailed above", and the audit line says "to fetch" rather than "awaiting a
proxy", which had become a blocker that no longer blocks.

Verified against the real catalog: the new work list returns 23,308 where the
old one returned effectively nothing. 469 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:51:53 +02:00
dtourolleandClaude Opus 5 131004393d Follow the canvas from one display to the next
The rest of FR-DSP-8. The develop session now carries the space its
canvas is encoded into, and `render` composes for it instead of for
sRGB — which is the whole of the change to the pixel path, because the
output space was always a parameter of composition and always entered
the structure hash. A display change is a recomposition.

The space is set on the way into every render rather than pushed when
the window moves, so a photograph opened while the window already sits
on the second monitor is right on its first frame instead of flashing
the wrong colour until the next poll.

Which display that is comes from sampling the window's position and
scale factor twice a second — Slint reports neither a move nor a
display change — and re-surveying only when they differ. Settings shows
what came back under ABOUT: the display, the space, why, and the other
monitors, because the failure FR-DSP-8 names is one that is invisible
from the display you are reading the page on.

Fractional scaling: the canvas is now rendered at the physical pixel
size of the box it occupies rather than the logical one, so the
compositor presents it 1:1. At 1.25 it was previously handed 1600
samples to fill 2000 device pixels, and the softness that produces
reads like a bad demosaic rather than like a scaling bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:44:51 +02:00
dtourolleandClaude Opus 5 25c88d9dbd Start face indexing from Settings
Beside the thumbnail sweep, because it is the same kind of thing: a job
that runs for an hour, is asked for once, and reports into the activity
list above it. It is also downstream of that sweep -- detection reads the
proxies it builds -- so the two belong in that order, and the coverage
line says how many images are waiting on a proxy rather than only how
many are left to index.

The button drives the Identity Manager's own state rather than a second
copy, so it cannot disagree with that screen about whether a pass is
running, and either place can start or stop it.

The pass now opens an activity row. The caption promises progress will
appear in the list above, and without a row it would not: the button
would be the only sign anything was happening, invisible from every
other screen.

Coverage is read when the Settings page opens. The figures live in the
catalog and this page deliberately holds no session, so they arrive
through a closure rather than being kept current -- they are only ever
looked at while the page is on screen, and the check is two counts and an
indexed scan.

Also adds DARKROOM_NO_SYNC. Redirecting XDG_DATA_HOME isolates a test
launch's catalog and thumbnails but not its server, and I found that out
by pushing a test catalog over the live one. The guard sits in
start_derived_sync rather than at its three call sites, because the sweep
firing a sync is correct and a flag checked in three places is one that
gets missed in a fourth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:22:43 +02:00
dtourolleandClaude Opus 5 fa4ad6e2d6 Drag the date range on the axis it is chosen from
The range could be turned on with a finger and not aimed with one. Its two
ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to
name days already drawn on the axis a thumb away; and the chip that seeds them
takes its span from the timeline's zoom and pan, which are a wheel and a middle
button. A touch screen has neither, so on Android the filter was a switch with
no aim.

The band is now on the timeline. Two ends with grips, dragged along the bars,
released to filter — the histogram was already how a period is found, and this
makes it how a period is stated. Both ends snap to whole days, which is what
the typed fields mean, what `show_range` reads back out, and a floor under a
range dragged shut. The fields stay for what dragging cannot do: name an exact
day, and say in words what the range is.

For that to work the axis had to stop following the range. Redrawn to the band,
it moved the ground under the very handles doing the narrowing, and there was
nothing outside the range left to widen back into.

While there: a fixed number of equal bins instead of calendar buckets. Between
one calendar unit and the next the bar count is free to wander by a factor of
twelve, so zooming in halved it two steps out of three — the same picture drawn
wider until it jumped back to fine. Equal bins also include the empty ones, so
a bar's position on the track and the date under it are finally the same
quantity; before, a library with gaps drew a February six months wide and the
marker, the band and a click all pointed somewhere else. The count is a
setting, 32 or 64, because the right answer is a question about the screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 18:42:42 +02:00
dtourolleandClaude Opus 5 a8969c149e Move the instrumentation off the header and into About
The render backend, the layout class and the frame rate sat permanently in a
44px strip that also carries the only way out of develop, the undo pair, the
panel toggle and the export button. They cost about 200 logical pixels, and a
`HorizontalLayout` given less width than its children's minimums does not
shrink them — it runs off the end.

There was already a breakpoint hiding them below 820px, which is why a tablet
in portrait (768) looked fine and landscape (1200) did not: above the
breakpoint the readouts came back and pushed the header off the right-hand
edge. A breakpoint that hides a problem at one size and not another is a
workaround, and this removes the reason for it rather than moving it.

They are diagnostics — read once when something looks wrong, and then not
again — so they are in Settings under ABOUT, beside the version, which is
where someone goes when they have a bug to report rather than a photograph to
edit. The version is there for the same reason and comes from
`CARGO_PKG_VERSION`, so the line cannot disagree with the binary showing it.

The frame rate keeps its warning hue. It is the number that says whether the
zero-copy display path is holding up, which is the assumption the whole
display design rests on, and that is worth colour wherever it is shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:41:17 +02:00
dtourolleandClaude Opus 5 d91f1ec277 Thumbnail the whole library on request, and send the shards up
The grid fetches a preview only for cells that are actually browsed, which is
the right posture over a link that must not be saturated to show one screen
(FR-NC-3). The cost is that the thumbnail store ends up holding the fraction of
the library someone happened to scroll past — and that store is the one derived
thing worth syncing, since a second device that downloads the shards gets a
full grid without touching a single RAW. So the complete set is worth an hour
of range fetches paid once, deliberately, on a machine that can afford it.

That is what this adds: a pass over every visible image with an `oc:fileid`,
launched from the settings page and reported in the activity register like
every other background job. The work list is what the store lacks rather than a
flag in the catalog, so it is resumable by construction and safe to press
twice.

Lane-parallel like the metadata sweep, but it does what that sweep declined to.
The store is `&mut` and cannot cross lanes, which is why dating the library
skips thumbnails entirely; here the lanes fetch, decode and *encode*, and only
the ~20 KB result crosses back to the one thread that owns the store and writes
the chunk. The parallelism is real and the single-writer rule is not bent.

Grid class only. The large class is ~860 MB of shards against ~200 MB on the
reference library, paid by every device that syncs them; a photograph looked at
closely still gets its large thumbnail from the interactive path.

Dates come free — the header a preview needs is the header EXIF lives in — and
the pass ends by pushing the shards to the server. A filled store that never
leaves this device would be most of the cost for none of the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:34:59 +02:00
dtourolleandClaude Opus 5 44f0a4971b Show the folder picker on the platform that needs it most, and upload at once
Two faults, both of my own making, reported from the tablet as "I cannot
select a location" and "it does not upload".

**The picker button was gated on `target-selected == 1`.** That index was
Remote's position while both targets were offered. Making the target list
platform-aware narrowed Android's to Remote alone, so Remote became index 0
and the button disappeared — on the one platform where the picker is the
*only* way to set a destination, since a device folder is not reachable there
at all. It is gated on a boolean derived from the target now. An index into a
list whose length varies is not a fact about the target, and writing it as one
is what made a correct change break the thing it was meant to fix.

**A queued export waited for a sync pass.** Staging first is deliberate — an
export is finished on disk the moment it is written, and offline is then just
a longer queue — but nothing drained the outbox until the next sync, so
"Queued for Exports" sat unchanged and read, fairly, as an upload that never
happened. A finished batch that wrote anything now drains immediately. The
sync-pass drain stays: the first makes an upload feel immediate, the second is
what eventually delivers the exports made in a tunnel.

Committed without the parallel session's in-flight collection work, which is
mid-save and does not compile; verified by stashing it and building this tree
alone. 281 dr-ui tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:01:12 +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 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 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 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 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 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 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