Commit Graph
45 Commits
Author SHA1 Message Date
dtourolle e957d483fc Give the film list a scrollbar on the desktop
A list cut off at its edge looks, to a mouse, like a list that ends
there. The open film list shows ten rows of twenty-eight, and nothing
said there were more. The user asked for visible scrollbars on every
platform but Android.

ScrollBar (widgets.slint) is a vertical bar drawn over a Flickable's
right-hand edge. It shows the share on screen and the position, it can
be dragged by the thumb from wherever it was grabbed, a click on the
track moves a page toward the click, and the wheel over it scrolls. It
is the Flickable's sibling rather than a wrapper, bound to
`viewport-y <=> flick.viewport-y` and the two heights, so a scroller
keeps its own sizing. It is drawn over the content rather than beside
it, so the mandated column widths are not reduced. With nothing to
scroll it is not drawn and takes no input.

Whether to draw it is Scrolling.bars, which Rust sets from
dr_plat::is_touch_first(). That is the same function that puts the
develop groups in the rail, and it answers the same question: what is
the user pointing with? On Android, lists still scroll by flick only.
Placing the bar inside another scroller would put it back in that
scroller's arbitration. The film list can have one because it is now a
popup.
2026-09-26 07:24:45 -04:00
dtourolle 80938b0527 Open the film list as a popup, so every stock can be scrolled to
The open stock list showed ten rows, None to Kodak Kodachrome 64, and
the other eighteen - all seven black-and-white stocks among them - could
not be reached. The data was whole; the list could not be scrolled.

Reproduced on the manual rig (Xvfb, xdotool, the automation hook):

- a drag on the list scrolled the develop column, never the list;
- a wheel run over the list scrolled the column past it, whenever the
  column had scrolled under that pointer in the last 800 ms - which is
  how the list is reached, by wheeling the column down to it. After a
  pause and a pointer move the wheel did reach the list;
- no key did anything.

The cause is Slint's routing, not the list. Since 2d878c2 the list was a
Flickable inside the develop column's Flickable, and Slint offers every
pointer event to the outermost Flickable first
(input_event_filter_before_children, i-slint-core 1.17.1 flickable.rs).
The column holds a press back (DelayForwarding) and intercepts the first
move past 8 px on the axis it can scroll, so the list never saw a drag.
For the wheel it intercepts while its own last wheel event is under
800 ms old and within 2 px, and always for a touchpad gesture that opens
with TouchPhase::Started - so on a touchpad the list could get no wheel
at all.

The list is now a PopupWindow under the Film row. A popup is its own
item tree: while it is open, events go to it and to nothing beneath it,
so the list scrolls by wheel, drag and flick however the panel is nested
and whatever the column did last. The alternative, standing the column
down while the pointer is over the list (the sliders' hover trick), fixes
the drag but not the wheel - `interactive: false` does not gate wheel
interception - so it would have left the bug for touchpad users.

The reason 2d878c2 bounded the list still holds: it is at most 320 px
and never lengthens the column, and now it covers the sliders instead of
pushing them down. The column cannot be scrolled while it is open, which
suits a one-click question; it closes on choosing, on Escape or Back, or
on a press outside it.

Keys, with the list open: Up and Down move along it from the chosen
stock and scroll it into view, Enter chooses, Escape or Back closes it
unchanged. A popup is its own focus tree, so the keys are taken when it
opens and Slint returns focus to the develop view's scope when it closes.
The Film row gains a button role, so a screen reader and the automation
hook can name it.
2026-09-26 07:24:28 -04:00
dtourolle a6ea6ba83f Let the manual's scripts find a control by its name
Every scene in tools/manual aimed at window pixels written in by hand, so
a panel that gained a row moved every slider under it and the recording
went on dragging where the slider used to be. The develop column has
already moved that way (Compose now sits above Adjust), and nothing said.

A build with the `automation` feature listens on the Unix socket named
by DR_AUTOMATION and answers where an element is: by its accessible
label, the name a screen reader reads, or by its markup id for the few
things that are not controls (the canvas, the crop rectangle). It uses
Slint's element queries, which need the compiler's debug tables, so the
feature also turns those on in build.rs. It only answers questions; the
input is still xdotool's real pointer. No default build has the feature,
and one that has it listens only when the variable is set.

drive.py gains click-on, drag-on, hold-on, wait-for, wait-gone, labels
and ids. The grid's cells are now named by their file, each rating star
by its value, the sidebar's + as "New collection", and the Adjust
heading's reset as "Reset all adjustments" - controls a screen reader
could not reach before either.
2026-09-25 07:26:36 -04:00
dtourolle d489a34190 Drive develop, the grid, the sidebar and People from the keyboard
An audit of every action by view against the keys the handlers bind left
develop without zoom, pan, fit or a way back to the grid, the grid without
select-none, thumbnail size or keywording, People with no key at all, and
the export and copy sheets without Enter. It also found the reverse gap
FR-UI-5 forbids: pick and reject had no route but P, X and U, and the
2026-09-19 amendment's judging in develop had not been built.

Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the
view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified
view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid
a mask. 0-5, P, X and U rate and flag the open photograph without moving on,
with stars and Pick/Reject in the top bar as the pointer and touch route.
= and - nudge the control last moved by a hundredth of its travel; the
framing sliders, perspective included, now count as "last moved", so R puts
them back as well. J turns the selected mask part's join chip.

Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the
thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives
pick and reject a pointer and touch route. Sidebar: Enter commits a
collection's name, and Enter or Escape hands the keyboard back to the grid,
where it used to go nowhere until something was clicked. People: Up and Down
walk the rail, F2 puts the name field under the keys, and Escape or Back now
leave the screen the way its back button does instead of doing nothing.
Sheets: Enter does what the export or copy sheet's button does.

The choices follow Lightroom where it has one. No new key steals typing: the
grid's and People's keys live on focus holders that are not ancestors of any
text field, and the sheets' Enter comes after a focused field has had it.
Every binding is tagged beside its handler, and the gate added in the
previous commit holds the two to each other.
2026-09-24 23:42:26 -04:00
dtourolle 150e53e878 Link fifty gestures on the help sheet to the manual section that shows them
The sheet could now offer "See it", but no gesture said where to look.

Every GESTURE tag whose move the manual describes names that section:
the white-balance picker, zoom and pan, masks, undo and snapshots,
export, colour labels and ratings, selection, collections, the People
page, thumbnail size. Fifty of the fifty-one; the one left, putting a
single control back to its default, has no section and is too small to
earn one.

Three important gestures had nowhere to land, so the manual gains three
short sections, without pictures for now: Bursts (opening a folded
burst, choosing the frame it shows, and the eyes-open filter), Moving
between photographs (the roll, the arrows and A/D in develop) and
Copying settings (Copy, Paste, Paste to N, and choosing what a copy
carries). The page, the gesture book and docs/gestures.md are
regenerated from them.
2026-09-24 23:25:37 -04:00
dtourolle 114d979397 Add Vertical and Horizontal perspective sliders to Compose
The keystone existed in framing but nothing in develop could reach it:
framing is presented by its own Compose panel rather than generated, so
new framing parameters get no control until the panel names them.

Compose now has Vertical and Horizontal sliders under Straighten,
mirrored from the session like the angle, recorded as parameter steps
("Vertical Perspective" in the history), cleared by the Compose reset
and by opening the next photograph. Releasing either slider refits the
crop the way releasing the straighten slider does: a keystone alone
needs no crop, but it moves the empty corners of a straightened frame,
so the crop that avoided them before may not after, or may have room
to grow back.
2026-09-24 22:13:12 -04:00
dtourolle c3d1f83b96 Say so when a crop leaves a mask outside the frame
Cropping tighter past a mask layer made it invisible without a word:
the layer stayed in the panel and the sidecar, and its adjustment went
on landing on pixels nobody would see again.

When a crop is let go, develop now measures what the gesture did to the
mask stack (dr_pipeline::orphan) and, if any layer is now entirely or
mostly outside the frame, shows a notice over the photograph: how many
layers, their names, "Undo crop" and "Keep crop". The crop is already
applied and nothing waits on the answer.

The crop overlay gains a release callback carrying the rect the press
began from, so the measurement runs once per gesture and never on the
drag's per-frame changes. Choosing a ratio is measured the same way,
being a crop committed in one click.

"Undo crop" is the ordinary undo, and the notice is tied to the history
revision it was raised at: the redraw that follows any history move
clears it, so the crop and its warning go back as one step. A second
drag folded into the same step is measured from where that step began.
A crop that strands nothing shows nothing.
2026-09-24 21:52:03 -04:00
dtourolle 9ebaa15099 Move copy, paste and presets into the develop top bar
They sat in the develop column under a "SETTINGS" heading, which read as
application settings, and went away with the panel toggle and in the
mask and spot modes. The strip is where undo already is for the same
reason: these act on the whole edit, not on any one panel.

The paste button still names what it would apply. The TransferPanel
component is gone; the Transfer global and its Rust wiring are
unchanged.
2026-09-22 21:37:31 -04:00
dtourolle 9cc52fd72b Bind the two develop gestures that were described and not bound
FR-DEV-16's book said resetting a control and hiding a mask layer were
reachable by pointer and by finger, and stopped there. The reason was
honest: the generated rows have no focus, so "reset the focused control"
named a thing the panel could not point at. But a photographer at the
keyboard means something narrower than focus. They mean the slider they
just dragged too far, and that is a thing the panel can remember.

So the Adjustments global keeps the last control moved — two indices,
written where the panel forwards the change and cleared when the next
photograph opens, so a reset cannot reach back into the previous edit
through an index that happens to be shared. R puts it back, through the
same callback the track's double-click takes, and is silent until
something has moved.

The mask layer needs no such notion, because the panel already has a
selection: the rows the edge controls point at. H hides or shows those,
through the path the ring at the head of the row takes, so it is an edit
and a history step exactly as the ring is. A mixed selection goes to
shown, since the layer nobody can see is the one being asked about.

Both tags now carry the key, and the book says so.
2026-09-12 01:08:09 +02:00
dtourolle 2d878c2117 Offer the film stock in its own group, and let its list scroll itself
Two faults in one control, both reported from the tablet.

The stock picker appeared in every group. It is not a parameter, so it is not
a row, so the filter that hides every other control when a group is chosen
never saw it — "Kodachrome" sat at the top of Light, of Colour and of Detail
alike. Three places it does not belong, and the one it does no more prominent
than the rest. The descriptor has said `Effect` and only `Effect` since the
film moved there; nothing was asking it.

So the panel now asks. It cannot ask directly — a generated panel may not know
which operation a control belongs to — so the session answers, from what the
operation declares it is about, and a stock re-declared as something else would
move on its own. The flag is recomputed when the group changes as well as when
the film does, which is the half that would have made it stale exactly when it
mattered.

And the open list was unbounded, so it made the develop column taller and the
column scrolled as one: reaching Velvia dragged every slider below it off the
screen, an answer given once pushing aside the controls used constantly. It now
scrolls within a bounded height of its own.

That viewport is counted rather than measured, for the reason the tool rail
records a few files away: a viewport that asks a layout how tall it wants to be,
while the layout takes its height from the viewport, is a cycle Slint settles by
handing back the height it was given — and the content is then clipped in
silence rather than scrolling. Every row here is one fixed height, so
multiplying is exact.

The group rule has a test. The scrolling does not, and cannot: it is a layout,
and a layout fault is invisible to the compiler and to every assertion that can
be written about it.
2026-09-07 20:42:15 +02:00
dtourolle 0a5eab0487 Wire the develop panels through globals, so a second copy is one line
Every panel in the develop column declared its inputs and its callbacks and
had `app.slint` bind each one to a property or a callback on the window root.
That is fine while a panel is drawn once. N9 draws them a second time, in the
portrait dock, and the wiring is what would have to be copied: `MaskPanel`
alone ran to forty lines of forwarding, and a callback added to one copy and
not the other compiles, renders, and simply does nothing on the layout nobody
was looking at.

So the wiring moved to Slint globals. A panel reads the global and calls the
global; Rust hooks the global instead of the window; and the instantiation in
the column is now the panel's name and a pair of braces — every one of the ten
children of the column, with no property that differs by placement left to
supply.

There is a global per panel family rather than one for all of them, and the
reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`,
`HistogramView` — is declared in the panel's own file, so a single global
holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing
`masks.slint` and `adjust.slint` while both imported the global back, which
Slint rejects. Breaking that needs six model declarations relocated, which is a
change to the data model and not to the plumbing this is about. A global beside
the panel it serves also lets each name drop the prefix it was carrying only
because the window root is one flat namespace: `root.spot-radius` is
`Repair.radius`, and `root.peaking-on` is `Peaking.showing`.

`session.slint` is new and holds the two facts every family needs and none of
them owns: whether there is an open photograph to edit, and which mode the view
is in, with the three readings of the mode derived once instead of at each of
the dozen places that tested one. `ViewMode` moves there from `adjust.slint`,
where it was only ever a lodger.

Nothing on screen changes. What is not here: the tool rail and the status strip
still take their properties at the instantiation, because they are drawn once
and N9 does not copy them; the preset sheet's own state stays on the window,
because the library grid opens the same sheet and a global cannot bind the
window's state — which is why `Transfer.open-presets` is handled in
`presets.rs`, beside the summary it already had to compute.
2026-09-07 20:01:01 +02:00
dtourolle af89433aee Offer the lens profile as a tick box, since applying it silently reads as absent
The develop panel's Optics group is three manual sliders: distortion, chromatic
aberration and lens vignetting. The automatic correction was already there — the
file's EXIF lens is matched against the bundled Lensfun database on open and the
coefficients are fanned out to all three — but nothing in the interface said so
except a line of grey text under the camera reading "· corrected", and there was
no way to decline it. From the outside that is indistinguishable from the
feature not existing, which is how it was read.

`dr-lens` states the rule this breaks: an automatic correction that silently
does nothing is worse than one the user can see is unavailable. The caption
satisfied the letter of it and not the point — a photographer looking for
"apply the lens profile" found three sliders and no switch.

So the profile is now a control. It is a capability rather than a flag on the
session, because everything a photographer sets travels one road: the capability
list feeds the generated panel, `Preset` captures it, the sidecar stores it and
the undo stack replays it. A bool on the side would have needed adding to each
of those four by hand and would have been forgotten in at least one — which is
exactly how the mask stack came to be missing from the history.

It is on by default, which is what `switch_on` is for: the coefficients are a
measurement of the lens that took the photograph, so accepting them is neutral
and declining them is the edit. The sidecar therefore stores nothing for the
ordinary case and the correction still happens.

The switch appears only where a profile was matched. A tick box on a photograph
whose lens the database has never heard of would be a control that looks
available and does nothing, which is the failure the rule above names rather
than an instance of following it — those photographs are told "· no profile" in
words instead, and one whose box is unticked now says "· profile off", which is
a third fact and not either of the other two.

Two things had to be built underneath. `ParamKind::Bool` was in the core's
closed enum and mapped to a row kind here, and had no control behind it in
`adjust.slint`: a parameter declaring itself a switch was flattened into a row
that drew nothing at all. Nothing shipped had one until now, so the gap cost
nothing and was invisible. And `Check` self-toggled, which is right for a
settings page that owns its value and wrong for a panel row that is a view of
the edit graph — the click would have answered by replacing the binding with a
literal, and the next undo or pasted preset would have moved the value with the
tick left where the finger put it. It now takes `controlled`, and the generated
row uses it.

The manual sliders are unchanged and still trim whatever the profile leaves, so
switching it off is "correct this by hand" rather than "stop correcting".
2026-09-07 00:27:47 +02:00
dtourolle 3994caba12 Write down the develop gestures, since only their author knew them
FR-UI-4 says a gesture with no visible counterpart is a feature only its
author knows about, and the vocabulary the application actually publishes had
two sections in it — the library grid and people. Develop had none. Every one
of its gestures was documented in the comment beside the `TouchArea` that
implements it, which is where the previous sixteen were before this scanner
existed, and unreachable to anybody not reading the source.

Thirteen now carry tags: magnify by pinch or wheel, pan a magnified frame,
fit and 1:1, hold to see the original, sample a neutral, undo and redo, step
through the folder, reset one control, show or hide a mask layer, choose a
group of adjustments, and copy and paste the settings. Each has a pointer and
a touch route, so none of them is keyboard-only.

Three bindings were genuinely missing and are added here rather than merely
described. Ctrl+C and Ctrl+V for the settings clipboard, which the Settings
panel's own comment has claimed existed for as long as the panel has and
nothing bound; and [ and ] to step through the adjustment groups. The groups
are whatever the operation set declares itself to be about, so there are as
many as the pipeline has and no key can name one of them — stepping is the
binding that survives a node being added, and "everything" is part of the
cycle rather than a way out of it.

Two are described and not bound. Resetting a control from the keyboard and
toggling a mask layer from the keyboard both need a notion of which control
or layer has focus, and the generated panel has none — the rows are a model
the repeater rebuilds, and inventing a focus ring for them is a larger change
than a keyboard shortcut. Both are reachable by pointer and by finger, and
the tags say so rather than promising a key that is not there.
2026-09-06 19:01:52 +02:00
dtourolle 901f51e6c4 Point at something grey and let the pipeline work out the rest
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since
it was written, and only the first half existed. `WidgetKind::WhitePoint` was
in the vocabulary and `develop::supported` answered false for it, so the node
degraded to two sliders — correct behaviour that had quietly become the only
behaviour. Sampling a neutral is the first move of the global tonal pass and
every colour judgement afterwards is measured against where the grey was put,
so guessing at two sliders until a wall stops looking green is the wrong way
round.

The awkward part is that a picker genuinely needs to know how far a hundred
units of temperature move red against blue, and that number is declared in
the node's own file. So the inversion lives in `dr_pipeline::neutral` rather
than in the interface: the canvas hands over a colour, the core finds the
operation that asked to be driven by a pixel and bisects its declared
response until the sample comes back grey. Nothing in `ui/` names white
balance, and nothing holds a second copy of a response that would be wrong
the first time somebody adjusted the range. A bisection rather than a
closed-form inverse because only monotonicity is part of the bargain — the
expression is free to become a table tomorrow.

The result is rounded to the precision the control is drawn at, which is not
cosmetic: unrounded, sampling something already neutral lands a
ten-thousandth off zero, and the photograph comes back modified with an undo
step for a correction of nothing.

On the panel side this needed one distinction the generated path was
missing. `is_on_canvas` was being read as "and so the panel draws nothing for
it", which is right for a crop — four edge fractions are not controls anyone
drags in a list — and wrong for an eyedropper, which *writes* temperature and
tint and leaves them exactly the controls a photographer reaches for next.
So a sampling widget keeps its sliders and puts the affordance that arms the
canvas in the group's heading, built like the reset beside it. One click, one
sample, one history step: `Edit::Action` never coalesces, and there is no
hover preview to fill the stack with temperatures nobody chose.

Declaring the presentation also groups temperature and tint under one undo
step, where they were two. That follows from what `Presentation` means and
reads correctly — white balance is one decision — but it is a change, and
worth saying so.
2026-09-06 19:01:52 +02:00
dtourolleandClaude Opus 5 8e7b1350bf Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves
from `[tone, colour]` to `[effect]`.

Two categories were doing the wrong job. "Geometry" describes what crop,
straighten and the quarter turns do to coordinates — but it describes lens
distortion correction exactly as well, and that is not a compositional
choice at all. Naming the attribute for the photographer's decision is what
separates it from `Optics`: one is what the lens did, the other is what
they chose. The maths the two have in common is not the thing worth
filing them under.

A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in
the Light group beside exposure and again in Colour beside white balance —
two places, neither of which is where anyone looks for it. It is neither:
`Effect` is defined in this same file as "applied rather than corrected — a
look, not a fix", which is what a stock is. That it moves tone and colour
is true of every look, and is not what the attribute is for.

`from_name` still accepts "geometry" on the way in. That string is
persisted in `develop.copy_attributes`, and an entry it fails to parse is
not an error — `presets::scope_for` logs it and drops it — so without the
alias an existing settings file would have quietly narrowed what a paste
carries. `name` writes the current spelling, so the file migrates itself
the first time it is saved.

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

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

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

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

Two decisions worth recording because the obvious alternative is wrong:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:59:50 +02:00
dtourolleandClaude Opus 5 5a8327824f Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between
images, and batch-apply to a selection. The last two have been here for
a while; this is the first.

The format is the sidecar's, deliberately. A preset *is* the non-default
half of a version, so the lines are the same lines keyed the same way,
which makes the two files diffable against each other and lets someone
debugging an edit paste a block from one into the other. One file rather
than one per preset: a preset per file makes the name a path, and every
name then has to survive a filesystem — a `/` becomes a directory, a
name differing only in case collides on one platform and not another,
and renaming becomes two operations that can half-fail. As a key in a
document it is none of those.

Unknown *parameters* needed no machinery. `Preset` already holds
whatever keys it is given and resolves them against the descriptors only
at apply time, so one written by a newer build survives by being stored.
Only lines that are not `op.param = float` at all are preserved
verbatim, which is the sidecar's version-skew promise made here too.

Applying is the paste path with a different source, so a preset reaches
a selection through the sidecar read-modify-write that was already
there: no graph, no decode, no GPU, forty files or one.

Two smaller decisions worth the record. A library that fails to parse is
held empty in memory and *not* written back over — settings regenerate
themselves and this is work, so a parse failure must not be the moment
it is destroyed. And every save persists immediately and rolls the
in-memory copy back if the write fails, so the sheet never lists a
preset the file does not have.

The grid's "Presets" button is gated on the selection alone, unlike the
"Paste to 40" beside it. That button needs a clipboard armed this
session; the preset list is whatever was saved last month, and hiding it
behind an unrelated action is what makes a feature only its author knows
about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:07:14 +02:00
dtourolleandClaude Opus 5 ff6c313bab Wrap the chip rows, so one six-choice parameter stops sizing the sidebar
The develop column asked for 482px. Every comment in it, a dozen of them,
describes it as a 280px column — and on a tablet it was taking 40% of the
screen from the photograph it exists to serve.

Measured rather than guessed, because none of it is visible in the source:
the column takes the widest width any panel declares, `AdjustPanel` wanted
482 of it, its rows wanted 458, and after the 44px scroll gutter the
widest single row was 414. That row is `film_sim`'s `format` — six film
formats from 35mm to 8x10, laid out by `Segmented` as six 64px chips in a
`HorizontalLayout` that cannot wrap. 6 x 64 + 5 x 6 = 414, exactly.

Nothing about that is the film simulation's fault. An operation declares
its parameters and the panel decides how to draw them (FR-DEV-3a), so a
node is entitled to offer six choices; it is the drawing that has to cope.
Any future operation with a five-choice enum would have done the same
thing, silently, to every screen in the application.

`ChipGrid` is the general answer: chips placed by index arithmetic inside
a plain `Rectangle`, wrapping at a column count, declaring a width that
depends on the columns rather than on the number of choices. `Segmented`
takes a `columns` property and uses it when asked — zero, one row however
many chips, stays the settings page's behaviour, where the page is
full-width and reading the alternatives side by side is the whole argument
for chips over a dropdown.

The generated enum rows and the curve-channel picker now wrap at three,
and the crop ratio chips use the shared grid instead of the private copy
of it they shipped with last week.

The column measures 351 now, down from 482, and what sets it is the mode
strip rather than a parameter — which is a control the user chose to have
on screen rather than an accident of one node's variant list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:16:43 +02:00
dtourolleandClaude Opus 5 c38df01bf7 Hang the auto-crop off the slider's commit, not off the pointer passing over
The straighten auto-crop worked once and then stopped. It was keyed on
`PlainSlider::drag-changed`, whose name is a lie inherited from what it
forwards: `SliderTrack` defines `engaged` as `has-hover || claimed`, and
it has to — a `Flickable` withholds the press for 100ms, so hover is the
only signal that arrives in time to stand the scrolling ancestor down.
That is the right definition for the job it was written for and the wrong
one for this.

Keyed on hover, the correction fires when the pointer first crosses the
track — before anything has been dragged — and then does not fire again
for as long as the pointer stays on it, however many times the angle is
changed. Which is exactly what "it only works once" looks like.

`SliderTrack` already publishes the signal this wants. `committed` fires
on release, once per gesture, after the final `changed`, and its doc
comment says so in as many words. It was simply not forwarded through
`PlainSlider`, so it now is, and the geometry panel's callback is a
`committed(float)` rather than a `drag-changed(bool)`.

The angle needs no re-applying here: the track emits its last `changed`
before it commits, so the value is already in the graph by the time this
runs. What is left is the correction that has to happen exactly once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:21 +02:00
dtourolleandClaude Opus 5 2c8768ac29 Lay the ratio chips out by hand, because Slint will not lay them out
The ratio chips shipped inside a `GridLayout` with `row` and `col`
computed from the repeater's index. It compiles. On screen every chip
piles into a single row that runs off the panel, and the terminal fills
with

    Internal error in Slint: RepeatedItemTree::grid_layout_input_data()
    not implemented

once per frame. A `for` inside a `GridLayout` is not supported, and
nothing says so until it is running.

A `HorizontalLayout` is not the answer either: six chips side by side
need over 400px, and this column's width is the largest width any panel
declares — so one row would widen every other panel in the application to
fit a control that is on screen only while cropping.

So the grid is arithmetic over the index inside a plain `Rectangle`. It
costs the layout engine nothing, it wraps a seventh ratio onto a third
row by itself, and — because a bare `Rectangle` declares no preferred
width — it takes the width the column already has instead of setting it.
The Portrait chip gets a container of its own for the same reason: a
`ChoiceChip` dropped straight into a `VerticalLayout` is stretched the
full width of the panel and reads as a button for the section rather than
as one more chip.

The three conditional pieces are also now individually-conditional
children of the one layout rather than a nested layout under a single
`if`, which is the convention `app.slint` and `masks.slint` already carry
notes about: a conditional nested layout under-reports its height here
and the panels below it draw on top of one another.

All of this was invisible in the source and obvious in a screenshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:20 +02:00
dtourolleandClaude Opus 5 d9eb8faffd Crop away the corners a straighten exposed, once the slider is let go
Turning a rectangle inside its own bounds exposes its corners: there is no
source pixel out there, and the shader renders it black. Nothing in the
render prevents that, deliberately — a free angle does not change the
output size, which is what leaves the frame where the user put it while
the slider moves. Correct during the drag; four black wedges on the
finished photograph.

Letting go of the slider now pulls the crop inside the area the angle
leaves defined. `Framing::max_inscribed_crop` already computed that
bound and had no caller; this is the caller its doc comment described.

**Once, at the end of the gesture.** Applied per frame it would shrink
the crop on every step of the slider and never grow it back, so a user
who overshot to 20° and came back to 3° would be left with a crop
ratcheted down by the excursion rather than by the angle they settled on.
Per gesture it is bounded by the angles actually rested at, and undo steps
back through them.

**The crop is fitted into the bound, not replaced by it.** A crop placed
deliberately off-centre is a decision, and an automatic correction that
recentred it would undo the user's work to fix a problem they did not
have. `CropRect::fitted_into` scales only as far as the bound demands and
then slides the rect the shortest distance needed to be inside — so a
ratio locked in the crop panel survives the straighten too, since the
shape is never touched.

It returns the rect unchanged, bit for bit, when nothing needed to move.
That matters more than it looks: this runs on every release of the
slider, including releases at zero, and a rect that drifted by a rounding
error each time would be an edit recorded for no reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:19 +02:00
dtourolleandClaude Opus 5 e6ad906bc1 Let the crop be held to a ratio while it is dragged
A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
not choosing four edges — they are choosing one edge and a known shape.
Free-dragging every corner made them do that arithmetic by eye on every
drag, and get it slightly wrong.

The panel now offers Free, Original, 1:1, 3:2, 4:3 and 16:9, with a
Portrait switch for the ones that have two orientations. Original follows
the frame rather than naming a number, so it stays right on the next
photograph from another body and after a quarter turn.

**The ratio is of output pixels, and the rect is not.** `CropRect` is
stored in fractions of a frame that is not itself square, so holding a
shape needs the frame's size — `ratio * height / width` of the frame.
Skipping that gives a "1:1" crop that is square only on a square
photograph, which is the one case nobody would test on, so the conversion
lives in `CropRect::with_aspect` where it is explained and pinned by a
test that asserts the fractions are *not* equal.

Two decisions worth recording:

The reshaped rect **grows** onto the ratio rather than shrinking onto it,
then scales down only as far as the frame's edge demands. Fitting inside
instead makes a one-axis drag do nothing at all — the other axis clamps
the first straight back, and the handle simply refuses to move.

The overlay now reports **which corner the drag is holding**, because
reshaping onto a ratio has to know which corner is nailed down and only
the handle that took the press knows that. A move reports no corner and
keeps its shape: reshaping about a centre would pull an over-moved rect
smaller instead of sliding it along the edge.

The lock lives with the window rather than the session. A `DevelopSession`
is per image, and cropping a set of frames to one shape is exactly when
the lock earns its place. It is not an edit and reaches no sidecar — what
is saved is the rectangle it produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:48:51 +02:00
dtourolleandClaude Opus 5 cafa63ca6f Let the develop column ask how wide it needs to be
Build and test / Desktop (Linux) (push) Successful in 21m53s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Successful in 32s
🐳 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 53m45s
The column was 280px, a number chosen for a tablet, with 380px bolted on
later for a desktop. Both were guesses at how much room the widest row
inside needs, and a guess is what cannot work here: the mode strip is one
chip per attribute the *operation set declares*, so the row is generated
and no constant in app.slint can track it.

When the guess came up short the failure was not a tidy clip. The
Flickable inside the column never had its `viewport-width` set, so the
viewport took its content's preferred width, and a viewport wider than
its Flickable is *centred* in it — the same rule the note on the seam's
`x: 0` already records a few lines below. So the column lost half of each
edge rather than one of them: "HISTOGRAM" read "ISTOGRAM", "Straighten"
read "aighten", Copy sat centred while Paste ran off the far side. It
looked like a rendering fault and it was an alignment one.

So the column asks instead of guessing. Every panel that can appear in it
— image, histogram, geometry, settings transfer, masks, repairs, adjust,
history — now publishes a `content-width`: how wide it has to be before
it starts clipping itself, read off its own layout rather than asserted.
Each declares that as its `min-width` too, and that is what makes the
aggregation automatic: `column` is a layout, so it already reports the
largest minimum among its children, and it does so for the panels that
come and go with the mode as well, which live inside `if`s and cannot be
named from outside. Grep `content-width` in ui/dr-ui/ui to see every
panel with a say in the answer. The mode strip is named explicitly only
because it is pinned outside that layout, so nothing else measures it.

There is no floor left. A floor is one more guess and the panels state
their own minimums now. The only thing still above the measurement is
`panel-max-width`, which is not a size but a policy — a column may not
take the window from the photograph it exists to serve — and it comes
from Rust beside `layout-class` because a width read from `root.width`
inside the layout that `root.width` depends on is a binding loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:23:44 +02:00
dtourolleandClaude Opus 5 8ab9440190 Put the repair tool on the photograph
A third chip beside Crop and Local, and the mode strip's own comment
predicted the shape: a mode that arms a gesture on the canvas and scopes
the column. Click a mark to cover it, drag the disc to move the repair,
drag the source circle to say where the patch comes from, Delete to remove
it. The source starts two and a half radii towards the middle of the
frame, which is FR-DEV-8's automatic placement in its cheap form — dust
sits on skies and skies are smooth, so it is usually right and always one
drag from fixed.

Two things are drawn deliberately. The circles are the size the repairs
actually are, because whether a disc covers a speck is the whole judgement
being made and a fixed-size dot would say nothing about it; the reach
around them is padded to a touch target so a spot on a dust mark can still
be picked up on a phone. And only the selected repair shows its source: a
dusty sky carries a dozen, and two dozen circles with nothing saying which
belongs to which is less information rather than more.

The panel edits what is stored while the canvas draws what is mapped, and
the two are pushed separately for that reason — a slider deriving its
value from the drawn radius would move differently at different zoom
levels. It is also the one panel built from SliderRow rather than a live
track: a repair has no OpId to coalesce a drag under, so a row that fires
once per gesture is what keeps undo one step per decision.

Verified as far as this environment allows: the strip renders and the
column re-scopes, photographed under XWayland. Synthetic clicks do not
reach this application, so the gestures are as-written rather than
as-felt, and docs/spot-removal.md says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:58:13 +02:00
dtourolleandClaude Opus 5 6d18517d28 Ship every stock that exists, black and white included
Three profiles was what the first cut needed to prove the model. This is
the rest of the open data: 23 camera stocks and 9 papers, which is all of
spektrafilm.

Black and white was the gap, and it turned out not to be a gap in the
data -- it was a gap in where I looked. Upstream's `main` has 28 colour
profiles and nothing monochrome; `dev` has three more, and they are
Tri-X, Double-X and the 2302 print film they go onto. So the answer to
"do we have B&W" was yes all along, and it needed the dev branch rather
than a fortnight digitising Ilford's datasheet graphs by eye. Those three
are pinned to `dev` per stock; the colour stocks stay on the released
branch.

A monochrome profile is single-channel -- one emulsion, not three -- and
spreading that one layer across all three is exact rather than an
approximation: three layers with identical sensitivity and identical
curves respond identically, which is what one layer does. The dye is the
trap. The renderer *sums* the three layers' contributions, so replicating
it unchanged renders every frame three times too dense -- neutrally, and
therefore plausibly. A third each reconstructs the single emulsion, and
two tests hold both halves: that the densities stay equal, and that they
sum to one emulsion and not three.

Double-X and 2302 ship five curves apiece, measured at five development
times -- 4 to 12 minutes for Double-X. That is push and pull processing as
measured data. The standard 6.5 minutes is what ships; the rest is in the
upstream file waiting for a control to ask for it.

Two stocks are `support: film` and are nevertheless what a negative is
printed *onto*: the cine projection films 2383 and 2393, which the
Vision3 stocks print to. Filtering the picker on support alone offered a
projection stock as something to load in a camera, so it filters on stage,
with a test saying so.

The picker had to change shape twice over. Chips were right for three
stocks and off the edge of a 280px column at twenty-four, and the column
that replaced them was a thousand pixels standing between the
photographer and every slider below. It is a disclosure now: one row
carrying the answer, opened to change it, closed again on choosing. That
is the opposite of the argument this panel used to take the lids off its
sliders, and deliberately so -- an instrument you compare wants to be
visible, and a list you consult once wants to be out of the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:30:11 +02:00
dtourolleandClaude Opus 5 baa8957e80 Let a photographer choose the film, and remember which one
The stock model rendered correctly and nothing could ask for it. This is
the picker, and the sidecar key that makes the choice outlive the session.

How the choice persists was the open question, and the answer was already
written down twice in sidecar.rs: `rating` is a top-level key "because a
rating is not an edit", and `masks` are one "because a layer is not a
scalar". A stock is that kind of thing -- a choice of material, not a
number a slider moves -- so it is a top-level key too.

It stores the **id**, not an index. Stocks are files that users add, so an
index would mean installing a profile silently changed which film every
existing photograph had been developed on. A name this build has no
profile for still round-trips untouched, because the alternative is that
syncing to an older phone quietly un-develops the picture.

Only the names travel. Turning one back into tables needs the profile
database, which dr-pipeline deliberately does not link, so `Version::apply`
clears the film and the session re-bakes -- after the parameters, because
the bake reads the film's own exposure sliders and the print balance is
solved against them. That is also why moving those sliders rebuilds the
lookup where no other control in the panel does: an enlarger's filtration
depends on how the negative was exposed.

The panel keeps its rule. It still names no operation and still generates
every control from a declared parameter kind; the stock gets a bespoke
control beside those, exactly as the mask stack does, and for the same
reason. The film's exposure and print exposure arrive as ordinary
generated sliders.

Two defaults worth stating. Picking a colour negative prints it, because
an unprinted one is an orange strip and offering that as the first thing
somebody sees after choosing Portra reads as a bug rather than as a
choice -- the toggle is there for anyone who wants the scan. And a paste
carries no film: a preset is a parameter map, and a stock is not a
parameter, so pasting one would paste a choice the clipboard never took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:57:41 +02:00
dtourolle 0d9910efc6 Merge branch 'worktree-agent-a89309a856c8f4947' into integration 2026-08-22 19:00:56 +02:00
dtourolleandClaude Opus 5 13d003b89d Let the curve widget plot whichever curve is asked for
An operation with four curves and a panel that draws one plot needs a way to
say which. The panel finds out the way it finds out everything else: the
points are faceted with the subject they act on, consecutive parameters
sharing a subject are one curve, and a widget spanning several of them gets
a selector over their names. Nothing in ui/ contains the word "red", and an
operation that grows a fifth curve arrives with a fifth chip.

The names ride on the panel rather than on the curve's row, because a Slint
model is compared by identity: a fresh list built on every parameter event
would make the row look changed every time, and rewriting a row rebuilds the
element holding the drag in progress. That is the hazard the in-place point
update already exists to avoid.

Which curve is on show is interface state, not an edit. It changes no pixel,
so it takes no history step, reaches no sidecar, and redraws nothing — the
photograph on screen is already right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:21 +02:00
dtourolleandClaude Opus 5 96a7b405c2 Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's
chain. Same panel, same order, same sliders, different meaning — and the only
thing that said so was a sentence in the panel above, which a photographer
reaching for the exposure slider has no reason to read. An exposure change
lands on the whole frame when it was meant for a face, or the reverse; both are
silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the
dangerous one and it is: the others in that document cost time, this one costs
work.

The remedy is the classic one for a modal fault — make the mode visible — and
the application already had the pattern. Crop arms a canvas interaction, draws
an overlay, gives the column one job and is left by the control that entered
it. Local masking is the same animal built as a peer panel, and that is what
created the ambiguity. So `crop-mode` stops being a bare boolean and becomes
one value of a three-state mode, which is the point: two modes could both be on
before, and now that is not a state the interface can be in rather than one it
is tested against.

**One strip, not two.** The mode control was going to sit beside the group
strip that filters the adjustments, which is two controls above one column
answering the same question — what am I working on. They are one control now,
`Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's
bottom strip has for the same reason. The two halves are different kinds of
state and are drawn differently: a mode is a chip that fills with the accent
when it is on, a group is a word with a rule under it. That difference is what
lets both be read at once, which they routinely are — picking Light while a
mask is selected filters *that layer's* chain and does not leave the mode.
Dropping the scope on a group press would be the same fault coming back from
the other end, and would make Light mean two things depending on where it was
pressed.

The strip stays pinned above the develop column rather than moving to the top
of the canvas as the document proposed. The half that filters the column
belongs to the column, and the photograph is the subject. The canvas keeps one
button, which now names the mode it leaves rather than saying "Done" — that was
unambiguous with one mode and would not be with two — because the column can be
closed on a narrow window and no mode may be inescapable.

Entering a mode is a side effect, so Rust owns it rather than the strip writing
the property: crop drops the zoom, local turns the overlay on, and leaving
clears the selection. That last one is the fix. The "Overlay" and "Select"
toggles are gone because they armed things that are simply what the mode *is* —
a mode that has to be switched on separately is one you can enter and have do
nothing. Escape and the Android back gesture join `back_step` as one
`LeaveMode` rather than a second exit concept, and the mode is left before the
zoom is: it was entered later, and it is the bigger step back.

The heading is where the scope goes. Not a caption beside the panel, the
heading *of* the panel that changed — `ADJUST` becomes the layer's name, the
same string the selected row in the stack shows. That is the difference between
describing a hazard and removing it.

**Handles on the photograph.** A linear or radial mask could be created and
then not moved, so a radial sat at the centre of the frame at its default size
for ever. Three faults stood in the way of drawing one.

The first is that a gradient did not render at all until the model had run. The
rasteriser was built on the way out of `segment` and the array's size was read
*off* the segmentation, so a gradient added to an unsegmented photograph
produced nothing — silently, in the same way exports and thumbnails once did:
the shader still emits the layer's block and the empty placeholder multiplies it
by zero. The proxy size is a property of the photograph. Both are derived from
it now, and deliberately at the same size rather than by coincidence, because a
subject's distance field is sampled against that array.

The second is hit-testing. A handle is drawn in output coordinates and stored
in source ones, and between them lie the crop, the zoom, the pan, the
straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated
on the CPU, kept in that file beside it so that keeping the two in step is one
file's problem — a handle mapped through anything less drifts off the mask the
moment the view moves, which is exactly what masks are rasterised in source
space to avoid.

The third is that a drag is a displacement, not a destination. Each handle
answers to the movement of the pointer since the press, applied to where the
mask was when the press landed. Snapping the handle to the pointer instead
jerks it by up to half a touch target on the first press, and the target is
finger-sized because a tablet has no hover to reveal a control and no modifier
to qualify it.

A ramp gets three handles — centre, width, angle. An ellipse gets three too:
centre and one per semi-axis, the major one carrying the direction as well as
the length, because where an axis is put says both. It had a fourth, and it is
gone: standing off the shape by a fixed distance, the rotation arm began
outside the photograph at the size a new radial is created at, so the first
thing anyone saw was a control they could not reach without first shrinking the
mask.

Two faults here were found by looking at the screen rather than at the source,
both of the kind that cannot be found any other way. A `1px` rule with a size
and no position is *centred* by Slint, so the seam between the photograph and
the column was a hairline down the middle of the panel, through the histogram
and every slider under it — twice, once in `app.slint` and once in
`AdjustPanel`. And handing Slint a fresh model for the handles on every pointer
event made the repeater rebuild its items, taking the `TouchArea` holding the
gesture with them: the handle jumped once and then went dead under a finger
that was still down. `develop.rs` carries the same warning about the parameter
rows, where it broke slider drags; the model is rewritten in place now.

The tests worth having are the ones about ambiguity and about the map. That the
same row reads the frame's value, then the layer's, then the frame's again is
§1.1 in one assertion. That dragging a handle onto another gradient's matching
handle *produces* that gradient closes the loop between the two directions of
the framing map, through a view that is cropped, zoomed, panned, straightened
and quarter-turned at once — a one-legged map is invisible when the framing is
neutral, because then both legs are the identity.

Not done here: the histogram still reports the whole frame while the sliders
edit a layer. That disagreement is real and is N3's, which this unblocks. The
strip has room for a Brush entry beside Crop and Local when the painted masks
land in the core, and it needs nothing here but the canvas interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:20:41 +02:00
dtourolle 9a7b045df4 Pin the group strip where it can be found
It was inside AdjustPanel, which on a tablet put it below five other panels
and off the bottom of the screen: present, working, and unreachable without
scrolling past everything it exists to save you scrolling past. A control
that answers "where is everything else" cannot itself be somewhere else.

Now a GroupStrip above the scrolling column, so it never scrolls away. It
still names no group — the strings arrive resolved from whatever the
operations declared themselves to be about.
2026-08-22 10:46:35 +02:00
dtourolle 924a837389 Group the panel by what operations say they are about
A strip of groups over the adjust panel — Light, Colour, Detail — derived
from the attributes the operations declare. `adjust.slint` names none of
them: the strings arrive resolved and the panel only draws them, so a new
operation joins the right group by saying what it is and this file does not
change (FR-DEV-3a).

A group nothing carries is not offered, so a tab never opens onto nothing.
Geometry is left out because its one operation prefers an on-canvas widget
and is skipped by the row builder — a Geometry tab would be empty while
`GeometryPanel` holds the real controls. The strip appears only when there
is more than one group to choose between; a single tab is a control with one
option.

The selected group is underlined rather than filled. The accent means
*modified* everywhere else in this interface, and spending it on "which tab"
would blunt the one signal the panel has.

**The trap, and it nearly bit again.** `op_index` on a row counts over every
capability, not over the ones a filter kept — it is how a row routes back to
the core. Renumbering it while filtering would make a slider drive a
different operation, which looks like a rendering fault rather than a
routing one. `rows_filtered` keeps `enumerate` over the full list and only
`group_head` is a position within the emitted rows; a test moves a value
through a filtered row and checks it lands where it was asked to.

Six tests, including that a nonsense index falls back to showing everything
rather than to showing nothing.
2026-08-22 10:22:47 +02:00
dtourolleandClaude Opus 5 d2024da368 Scroll the develop column as one, histogram and all
Only the sliders scrolled. The capture metadata, the histogram, the geometry
controls and copy-and-paste all sat above them in a fixed layout, so on a
280px column in portrait they took the height the sliders needed — and the
histogram, which is the instrument the sliders are judged against, could
neither be scrolled to nor scrolled past.

The Flickable moves out of `AdjustPanel` and around the whole column. Nesting
one inside the other was not an option: a slider drag already has to be won
against one scroller, and a second would give it a third thing to be lost to.

`slider-dragging` becomes an `out` property for the same reason. The
arbitration is unchanged and still necessary — a track stands the scroller
down as soon as a finger touches it, or every attempt to drag a slider would
scroll the column instead — but the scroller obeying it now lives a level up.
The scroll gutter stays where it is, as padding inside the content: it is what
guarantees somewhere to put a thumb that means "scroll" and nothing else, and
it matters more now that it serves the entire column.

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