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>
This commit is contained in:
2026-08-22 13:20:41 +02:00
co-authored by Claude Opus 5
parent c75863c93f
commit 96a7b405c2
10 changed files with 2171 additions and 185 deletions
+130 -14
View File
@@ -187,10 +187,14 @@ throughout.** The consequences worth stating:
- **No modifier key may be required.** A tablet has no shift. Local masking
already lost its shift-click extend for this reason, and nothing should
reintroduce one as the only route to a feature.
- **Anything dragged needs a finger-sized target.** This is the live one:
gradient masks have no on-canvas handles yet, and when they arrive their
handles are the first control in the app designed to be dragged on a
photograph rather than in a panel.
- **Anything dragged needs a finger-sized target.** ~~This is the live one:
gradient masks have no on-canvas handles yet~~ — they have them now (N2a), and
their handles are the first control in the app designed to be dragged on a
photograph rather than in a panel. Drawn at 14px so they do not hide the edge
they sit on, with a full touch target centred on the drawing, which is the
split `Button` already establishes. Nothing about them is revealed by hover
and nothing about them is qualified by a modifier: what is drawn is all there
is.
### D-N3 — Collapsible panels, not tool tabs · **OPEN**
@@ -226,21 +230,59 @@ gain tabs, or stay a single collapsible stack indefinitely.
Numbered N to avoid colliding with `ui-refinement.md`'s A–F.
### N1 — The mode strip
### N1 — The mode strip — **done**
**Deliverable.** One control naming the current mode, replacing the implicit
**Deliverable.** ~~One control naming the current mode, replacing the implicit
`crop-mode` boolean: **Photo · Crop · Local**. Top of the canvas in expanded,
bottom in compact. The selected mode is the accent's job — it means *active*,
bottom in compact.~~ The selected mode is the accent's job — it means *active*,
which is exactly this.
`crop-mode` becomes one value of a mode enum rather than its own flag, so the
two modes cannot both be on, which today they can.
**Done when.** Entering crop from the strip does what the crop button did;
Escape and back leave the innermost mode; no two modes are ever active
together.
**Landed as one strip, not two.** The mode control and the existing group strip
(`All · Light · Colour`, derived from operation attributes) were going to sit
beside each other above the same column, which is two controls answering one
question — *what am I working on*. They are now one:
### N2 — Local mode
```
Crop · Local │ All Light Colour
```
Lightroom Mobile's bottom strip mixes Crop and Masking with Light and Colour
for the same reason, and it reads naturally because from the photographer's
side they are the same kind of choice.
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 is what lets both be read at once, and both are on at
once routinely — see below.
**Mode and group are independent axes.** Picking `Light` while a mask layer is
selected filters *that layer's* chain and does not leave local mode. The
alternative — a group press quietly dropping the scope — would be §1.1's fault
reintroduced from the other end, and it would make `Light` mean two things
depending on where it was pressed.
**Where it sits.** Pinned above the develop column, where the group strip
already was, rather than at the top of the canvas. The half that filters the
column belongs to the column, and moving it onto the photograph would put it
somewhere the four principles say chrome should not be. The canvas keeps one
button — now *"Done Cropping"* / *"Done Masking"*, naming the mode it leaves —
because the column can be closed on a narrow window and no mode may be
inescapable.
**Also landed.** `GeometryPanel`'s Crop button is gone: a second control
entering the same mode is a second thing that has to agree about which mode the
view is in.
**Done when.** ~~Entering crop from the strip does what the crop button did;
Escape and back leave the innermost mode; no two modes are ever active
together.~~ All three, checked on screen as well as in tests — `back_step` has
one `LeaveMode` step covering both modes, and the enum makes "no two at once"
unrepresentable rather than merely untested.
### N2 — Local mode — **done**
**Depends on** N1.
@@ -252,12 +294,86 @@ names the layer.
Leaving local mode clears the selection so the adjustments are unambiguously
global again.
**Done when.** There is no way to have a mask selected without knowing it, and
the two toggles that currently arm the overlay and picking are gone.
**Landed.** The "Overlay" and "Select" buttons are gone; entering the mode does
both, and `region-picking` is now derived from the mode rather than toggled.
The masking panel is no longer a panel among peers in the scrolling column — it
appears only in local mode, which is what takes the column from six panels to
three there.
**The scope is the adjust panel's own heading**, not a caption in the panel
above it. `ADJUST` becomes the layer's name. That is the difference between
describing the hazard and removing it: the heading of the thing that changed
cannot be skipped on the way to a slider, and a caption in a different panel
routinely was.
**What local mode drops from the column**: the capture metadata, the framing
controls and copy/paste. None is a property of a region within the photograph,
so all three would be controls in scope of nothing. The histogram stays and
still reports the whole frame — the disagreement §1.1 names is real and is N3's
to close; removing the instrument would be a worse answer than an honest one
that is not yet scoped.
**Done when.** ~~There is no way to have a mask selected without knowing it, and
the two toggles that currently arm the overlay and picking are gone.~~ Both.
### N2a — Gradient handles — **done**
Not a numbered workstream when this was written, and it belongs beside N2: the
canvas half of local mode.
`MaskSource::Linear` and `MaskSource::Radial` could be created and then not
moved, so a radial sat at the centre of the frame at its default size for ever.
They now carry handles on the photograph — the first controls in the
application designed to be dragged there rather than in a panel, and D-N2's
"the live one".
Three faults had to be fixed before a handle was worth drawing.
**A gradient did not render at all until the model had run.** The mask
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, because the generated 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 now derived from it, deliberately at
the same size because a subject's distance field is sampled against the array.
**A gradient's geometry was measured in raw `0..1` fractions**, so a 45° ramp
was not at 45° and a radial with equal radii drew an ellipse. Angles and
distances are now in the frame's isotropic units — y spans `0..1`, x spans
`0..aspect` — converted in exactly one place, `frame_delta` in `mask.wgsl`.
Only the *meaning* of the stored numbers changed; the sidecar format did not.
**Hit-testing has to go through the framing map.** A handle is drawn in output
coordinates and stored in source ones, and the two are separated by the crop,
the zoom, the pan, the straightening and the turns. `Framing::source_at` and
`Framing::output_at` are `wgsl_prologue` evaluated on the CPU, kept in that file
beside it so the correspondence is one file's problem.
**Handles.** A linear ramp has three — centre, width, angle. A radial has three
— centre and one per semi-axis, the major one carrying the ellipse's angle as
well as its length, because where an axis is put says both. A rotation arm was
tried on the radial and taken out: standing off the shape by a fixed distance,
it began outside the photograph at the size a new radial is created at.
**A drag is a displacement applied to where the mask was when the press
landed**, not a destination the handle is snapped to. Snapping jerks the handle
by up to half a touch target on the first press, and the target is finger-sized
(FR-UI-3).
**Two faults found by looking at the screen** rather than by reading the source,
both of the kind `ui-refinement.md`'s verification section warns about. A `1px`
rule with a size and no position is *centred* by Slint, so the develop column's
seam was a hairline down the middle of the panel — twice over, once in
`app.slint` and once in `AdjustPanel`. And handing Slint a new `ModelRc` 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. The model is now rewritten in place.
### N3 — Scope-following histogram
**Depends on** N2.
**Depends on** N2, which has landed, so this is next and is the outstanding
half of §1.1: the panel now says *which* chain the sliders edit, and the
instrument beside them still measures the other one.
**Deliverable.** The histogram reduction takes an optional mask; in local mode
it reduces over the selected layer's coverage. The panel says which it is