Files
DarkRoom/docs/dev/ui-navigation.md
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

829 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI navigation: finding things once there are many
TRACES: FR-UI-1 | FR-UI-3 | FR-UI-5 | FR-DEV-3a | FR-DEV-3c
Successor to [`ui-refinement.md`](archive/ui-refinement.md), which asked how the interface should *look*.
This asks how someone finds anything in it. The two are sequenced together at
the end.
## Why now
Local adjustments landed (D14, `segmentation.md` §14) and the develop column
went from four panels to six: image, histogram, geometry, settings, local,
adjust. Adjust alone is ten operations, and the colour mixer contributes
thirty-six parameters by itself. That is already past what one scrolling
column presents well, and the operation set is meant to keep growing —
FR-DEV-3 lists texture, clarity, sharpening and noise reduction as v1, none of
which exist yet.
But the count is the lesser problem. **Local adjustments introduced a mode
without introducing a way to see it**, and that is the part that can lose
someone's work rather than merely slow them down.
---
## 1. The three problems, which are not one problem
### 1.1 Scope — invisible state
Selecting a mask layer silently re-points the adjust panel at that layer's
chain. Same thirty sliders, different meaning, and the only indication is a
caption between the two panels.
Three ways that bites:
- An exposure change lands on the whole photograph when it was meant for a
face, or the reverse. Both are silent; both are discovered later.
- **The histogram does not follow scope.** The instrument the tonal controls
are judged against reports the whole frame while the slider edits a
subject's face. FR-DSP-7 asks the histogram to describe what the
photographer is looking at; under a mask it currently does not.
- Undo interleaves global and local edits with nothing distinguishing them.
This is the classic modal fault, and the classic remedy applies: make the mode
visible, or make it not a mode. See §3.
### 1.2 Extent — six panels in 280px
The column scrolls as one (`ui-refinement.md` Workstream C explains why: a
scroller inside a scroller gives every drag a third thing to be lost to). At
six panels that scroll is long enough that the histogram — the instrument
everything tonal is judged against — is frequently off screen while the
sliders it reports on are being dragged.
### 1.3 View — grid and develop are still separate screens
Diagnosed as `ui-refinement.md` Workstream F and not yet built. Unchanged by
this document, which assumes F lands.
---
## 2. What other programs do
Worth summarising honestly, because all three problems are solved elsewhere
and the solutions have known costs.
### On a desktop, two families
**A collapsible stack.** Lightroom Classic's right panel is a vertical column
of modules — Basic, Tone Curve, HSL, Detail, Lens, Effects — each collapsible,
each reporting whether anything inside it has been touched. Everything is in
one place and in a fixed order, so muscle memory works; the cost is a long
scroll and a lot of triangles.
**Tool tabs.** Capture One puts an icon strip at the top of the tool panel and
gives each tab a curated set of tools; RawTherapee tabs the right-hand panel
the same way. Scroll is bounded and there is a clear sense of place; the cost
is that a control you cannot name is in one of eight places, and switching tabs
loses the context you were comparing against.
**Groups over a stack.** darktable combines both — a row of group icons
filtering a long list of collapsible modules, plus a search box. It is the most
powerful and the most often described as overwhelming, which is worth reading
as a warning about combining mechanisms rather than about either one.
Across all of them, **masking is its own tool**, not a panel among peers.
Lightroom opens a masking tool with its own layer list and its own canvas
overlay; Capture One makes layers a persistent selector at the top of the
adjustments tab. Nobody makes a mask a panel that silently rewires a different
panel — which is what DarkRoom currently does.
### On a phone, one family — and why it does not apply here
Lightroom Mobile, Photomator and VSCO all converge on the same shape: **a
horizontal strip of tool icons along the bottom**, and tapping one replaces a
bottom sheet with that tool's controls. Snapseed goes further — one tool fills
the screen and a vertical swipe chooses the parameter while a horizontal one
sets it.
The convergence is not fashion. Three physical facts drive it:
- **Thumb reach.** A one-handed grip reaches the bottom third. A right-hand
column is a mouse idiom.
- **The image needs the screen.** A 280px column is a fifth of a desktop
window and most of a phone in portrait.
- **There is no hover.** Disclosure triangles and hover-revealed affordances
are worth less; a control is either visible or gone.
**None of the first two apply to DarkRoom's targets**, which are a 12-inch
tablet and a desktop (§3, D-N2). Nobody thumbs a 12-inch tablet one-handed,
and its narrow dimension is not narrow. The third does apply, and is handled
already — see D-N2.
---
## 3. The decisions
### D-N1 — Local adjustment becomes a mode · **DECIDED**
Not a panel that re-points another panel. A mode, in the sense `crop-mode`
already is: it changes what the canvas does, scopes what the column shows, and
is left explicitly.
**Why this shape rather than louder signalling.** The app already has this
pattern and the user already knows it. Crop mode arms a canvas interaction,
draws an overlay, gives the column one job, and exits by the same control that
entered it. Local masking is the same animal — a canvas interaction plus a
scoped panel — and building it as a peer panel is what created §1.1. Making it
a mode removes the ambiguity by construction instead of describing it in a
caption.
It also inherits machinery that exists. `lib.rs` already resolves Escape and
the Android back gesture to "leave the innermost state first" (FR-UI-5); local
mode joins that stack and needs no new exit concept.
In local mode:
- the canvas turns the overlay on and arms click-to-select;
- the column shows the mask stack and, beneath it, the adjustments **scoped to
the selected layer**;
- the header names the scope — the layer, not "adjust";
- leaving returns to the whole photograph, by Escape, by back, or by the mode
control.
**The histogram follows the scope.** Under a mask it reduces over the masked
pixels only. This is FR-DSP-7 read literally — it asks the histogram to
describe what is being looked at — and without it the instrument and the
controls disagree about what they are measuring. Costs a mask term in the
histogram reduction, which already runs per frame over the displayed frame.
### D-N2 — One layout, because both targets are wide · **PARTLY REVERSED**
> **Reversed for navigation, 2026-09-05, by use.** The reasoning below is
> still right about *size* and still right that `cfg(target_os)` is the wrong
> axis. It is wrong in one place, and the wrong bit is the sentence "touch
> changes **hit regions, not layout**". See D-N6.
>
> **Reversed for portrait, 2026-09-06, by arithmetic.** "Both orientations of
> both targets are the expanded class" is still true and is no longer the
> point. It was worked out for a 4:3 panel; the tablet's is 25:16, and on that
> aspect a column *beside* the photograph in portrait leaves it a strip. See
> D-N7, which keeps the layout class and adds an axis D-N2 did not consider.
The question was whether desktop and Android should diverge. The answer turns
out to be that **neither the platform nor the width axis separates DarkRoom's
targets**, so there is no divergence to build.
**The targets are a 12-inch tablet and a desktop.** No phone, decided
2026-08-22. A 12-inch tablet is roughly 1024 logical pixels across in portrait
and 1400 in landscape; `EXPANDED_MIN_WIDTH` is 820. **Both orientations of both
targets are the expanded class.** The compact class now fires only when a
desktop window is dragged under 820px, which is a case to degrade gracefully
into, not a second interface to design.
**Platform would have been the wrong axis anyway**, and it is worth recording
why so it is not proposed again. A tablet in landscape wants what a desktop
wants; a desktop window dragged narrow wants what a small screen wants.
Splitting on `cfg(target_os)` gives one *physical* situation two answers
depending on which binary it happens to be. `apply_layout_class` already says
this in its own comment — "logical pixels, not a device check" — and it was
right.
**What actually differs between the two targets is input, not size**, and the
architecture has already decided that too. `WidgetDemand::precise_pointing`
exists for a frontend driving a television with a remote, and its own
documentation states the position: *touch is fine, since hit regions grow to
the modality* (FR-UI-7). Touch changes **hit regions, not layout**. A control
is drawn where it belongs and its target grows past its own bounds — which
`Check` and the mask rows already do.
So: **one develop layout, tuned for a wide viewport, with touch targets
throughout.** The consequences worth stating:
- **A guaranteed-wide viewport is an asset.** The extent problem (§1.2) can be
solved by pinning rather than by hiding — see N4.
- **No hover-only affordance may carry meaning.** Hover may *emphasise*; it may
never be the only way to discover a control. The mask rows already obey this
— the eye and the delete target are drawn, not revealed.
- **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~~ — 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-N6 — The groups move to the rail under a finger · **DECIDED**
Reported from a tablet: the tool rail is *"very useful"* there, and the same
interface with a mouse and keyboard is not ergonomic. That is D-N2's assumption
failing in the field, and it is worth being precise about which half failed.
**What D-N2 got right.** Platform is the wrong axis, and width is the wrong
axis. A tablet in landscape wants what a desktop wants; a desktop window
dragged narrow wants what a small screen wants. `apply_layout_class` still
decides the layout class from the window, and nothing here changes that.
**What it got wrong.** It identified input as the real difference between the
targets and then concluded that input changes only hit regions. Two controls
answering one question — *which group of adjustments am I looking at* —
disprove it:
- **A horizontal strip above the column.** One gesture to a target the eye has
already found; costs one row of a column with height to spare. It pans when
the operation set is rich, so a group can be off the end with nothing saying
so — which a pointer user tolerates and a finger user does not discover.
- **A vertical run down the rail.** Every entry visible at once, each a
finger-sized target, on the edge of the screen the hand is already holding.
Costs nothing extra in width, because the rail is already there and already
mandated.
Neither is better in general. The first is better with a pointer and the second
is better with a finger, which is a divergence on **input modality** — the axis
D-N2 itself named.
**The shape.** `ToolRail` grows a second section below a rule: the same
`adjust-tabs` model the strip takes, plus "All". `GroupStrip` stands down when
the rail carries them, so the two are never both on screen and there is no
state to keep in step. Mode and group stay independent axes exactly as N1
requires — one entry lit in each section, and picking a group while a tool is
held still filters without putting the tool down.
**Drawn differently, still.** N1 insisted a mode and a filter must not be told
apart by the shape of their highlight alone. The tools fill with `active-dim`
and invert their ink; the groups take a bar down the leading edge — the
underline from the horizontal strip, turned ninety degrees. The rule between
the sections is the second signal.
**The rail scrolls now.** Its own note argued against a Flickable on the
grounds that four entries were written in the file. With the groups in it the
list is generated from the operation set, which is exactly the "something the
user's data decides" the note excluded it from.
**And it is a preference, because the automatic answer is a guess.** Neither
platform can be asked what the user is actually holding — an Android tablet in
a keyboard case is being driven like a desktop, and a touchscreen laptop is
whichever its owner says. `dr_plat::is_touch_first` reports the usual case per
platform and `dr_types::GroupNavigation` lets it be overridden; Settings names
what Automatic resolves to on this device rather than leaving it to be found by
pressing.
**Still open: whether Local is a mode at all.** The rail now holds two kinds of
entry, and a third reading is available — that Compose and Repair are
categories with a canvas gesture attached, while Local edits *nothing* and
instead changes what every other category applies to. That would make it a
**scope**, not a peer of the tools, and would collapse the two sections into
one list of seven. It is the tidier model and a much larger change; deferred
until the two-section rail has been lived with. §1.1's complaint was that scope
was invisible, so this is the same argument arriving from the other end.
### D-N7 — The column docks under the photograph on a tall window · **DECIDED**
D-N2 dismissed portrait with one number: a 12-inch tablet is about 1024
logical pixels across in portrait, which clears `EXPANDED_MIN_WIDTH`, so
portrait is expanded, so there is nothing to design. The number was for a 4:3
panel. **The tablet's panel is 3000 × 1920**, which is 25:16 — closer to a
sheet of A4 than to an iPad — and the same arithmetic on that aspect comes out
the other way.
**What the photograph gets.** Logical size depends on the density Android
reports, which nothing in this repository records (N6 measures it). At a scale
of 2.0 the window is 960 × 1500 in portrait; at 1.75 it is 1097 × 1714. Take
the first, subtract the 60px rail, the 360px column and the 44px status bar,
and the canvas beside the column is **540 × 1456** — a strip two and a half
times taller than it is wide. Against a column *under* the canvas, 480px tall:
| Photograph | Beside the column | Under the column | Gain |
|---|---|---|---|
| 3:2, landscape | 540 × 360 | 900 × 600 | 2.8× the area |
| 2:3, portrait | 540 × 810 | 651 × 976 | 1.4× the area |
At 1.75 the figures move and the ratios hold (2.4× and 1.3×). So on this panel
the dock wins for **both** orientations of the photograph, not only the
landscape frame one would guess it was for. That is what makes it a decision
rather than a preference: the column is on the right because the eye's path is
tool, photograph, adjustment, and in portrait the photograph in the middle of
that path is the thing being starved.
**What it is not.** N5 said "no bottom sheet, no second layout, no tool strip
along the bottom — those solve a phone". Still true of all three. This is not
a sheet: the column does not slide over the photograph, it sits beside it on
the other axis, with the same contents, the same collapse and the same toggle.
It is not a second layout in D-N2's sense: the layout class is still decided
by width, the compact class still means what it meant, and a tall narrow
desktop window gets exactly what a portrait tablet gets, which is FR-UI-1's
rule. And the rail does not move — `toolrail.slint` argued it never should,
and a vertical list of finger-sized entries wants height, which portrait has
more of.
**The axis is aspect, not width, and it is independent of the class.** A 960
wide portrait window is expanded by width and wants the dock; a 1500 wide
landscape one is expanded by width and does not. So this is a third property
beside `layout-class` and `panel-max-width`, set from the same place for the
same reason — a size that both derives from and feeds the layout is a binding
loop in Slint, and `apply_layout_class` already measures the window. The
window-resized callback reports width alone today and grows a height. The
threshold is height above 1.2 × width, with hysteresis wide enough that a
window resized across square does not flap.
**Slint cannot turn a layout on its side**, and does not need to. The develop
view is one `HorizontalLayout` of rail, canvas and column; it becomes a
`Rectangle` whose three children take `x`, `y`, `width` and `height` from the
flag. The column's own `VerticalLayout` and `Flickable` are untouched. The
alternative — the column subtree declared twice under two `if`s — is 400 lines
of bindings copied, in a file whose own notes record conditional children in
layouts as the shape that has produced binding loops before.
**The contents are the real cost.** Everything in the column was drawn for a
360px vertical scroll, and the dock is 900 to 1040 wide by about 480 tall.
Stretched to that width the stack works — sliders get longer tracks, the
histogram divides the width, text wraps — and it is one long scroll in a short
box, with the histogram scrolling away from the sliders it serves, which is
§1.2 again. The width is two and a half to three of today's columns, so the
composition that fits it is three of them side by side (N9): the instruments,
the sliders, and the panels the mode adds. That needs the panels declared a
second time, which is cheap only once their callbacks stop being forwarded
through the window root by hand (N8). The stretched stack ships first as the
stopgap (N7), because the photograph gets its area back on day one and the
dock's contents can be got right afterwards.
**The dock's height is mandated, as the column's width is.** `panel-width`
exists because a column that sizes itself to its contents is a photograph
that changes size when a caption does; a dock has the same disease on the
other axis. `dock-height` in `style.yaml`, 480 until N6 says otherwise: room
for the pinned instruments (about 260px per N4) and a group of sliders under
them, and a 3:2 frame at 900 wide still fits above it at either scale.
**The filmstrip stays where it is.** It takes its strip off the bottom of the
photograph on demand and it keeps doing so; with the dock below it sits
between the two. The photograph loses 108px while the roll is open, which is
what it loses today, and the roll is not made part of the dock because it is a
different kind of thing — navigation, not adjustment — and D-N6 has already
been through why two kinds of entry in one control need a rule between them.
**Not remembered separately.** `PanelChoices` keeps the user's open-or-closed
override per layout class. The dock does not add a class and does not add a
remembered state: closing the column in landscape closes the dock in
portrait, because it is the same column.
### D-N3 — Collapsible panels, not tool tabs · **OPEN**
For the expanded layout, extend `ui-refinement.md` Workstream C from
sections-inside-adjust to the panels themselves: each collapses to a header
carrying a modified dot, and collapse state survives a drag elsewhere.
**Why this over tabs, on the evidence above.** Tabs need a taxonomy, and the
taxonomy is the problem. `ui-refinement.md` condemns the `starts-group` flag
for being the core telling the panel where sections go, and FR-DEV-3a requires
that adding an operation needs no UI edit. A tab strip built from a hardcoded
op-id → tab table in `dr-ui` breaks the second; one built from a `group:` field
in `ops/*.yaml` risks breaking the first.
There *is* a legitimate route to tabs, and it should be recorded rather than
discovered later: the descriptor could declare an operation's **nature** —
tone, colour, detail, optics — the same shape as `Affects` and `ParamKind`
already take. The core would be saying *what the operation is*, which is its
business, and the frontend would remain free to render that as a tab, a
section heading, or nothing at all. That stays on the right side of §4.3a.
**The recommendation is to defer it.** Ten operations do not need eight tabs,
collapse needs no taxonomy at all, and the nature field is easy to add later
and awkward to remove. Revisit when the operation count passes roughly fifteen
— which FR-DEV-3's outstanding list will reach.
**Open, because it is a taste call**: whether the expanded layout should also
gain tabs, or stay a single collapsible stack indefinitely.
---
## 4. Workstreams
Numbered N to avoid colliding with `ui-refinement.md`'s A–F.
### N1 — The mode strip — **done**
**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*,
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.
**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:
```
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.
**Deliverable.** Entering local mode turns the overlay on and arms picking
without either being a separate toggle — they are what the mode *is*. The
column shows the mask stack, then the scoped adjustments. The adjust header
names the layer.
Leaving local mode clears the selection so the adjustments are unambiguously
global again.
**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, 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
showing, because a histogram of a face is a strange shape and the user should
know why.
**Done when.** Selecting a layer visibly changes the histogram, and leaving
local mode restores the frame's.
### N4 — Collapsible panels
**Depends on** `ui-refinement.md` A. Extends C.
**Deliverable, two halves.**
*Collapse.* Each panel collapses to its header; headers carry the modified dot
C defines; state is keyed by panel identity and survives a slider drag.
Default: histogram open, the rest collapsed until touched.
*Pin.* The histogram and the scope header do **not** scroll. They sit above the
scrolling region, always visible.
Pinning is the half a guaranteed-wide viewport buys, and it addresses §1.2
directly rather than obliquely. The complaint is not that the column is long —
it is that the instrument every tonal control is judged against scrolls away
from the controls it reports on. Collapsing panels shortens the scroll;
pinning removes the problem. Together they cost about 260px of fixed height,
which a target that is never under 820px wide and rarely under 1000 tall can
afford.
**Done when.** The histogram is visible while any tonal slider is being
dragged, at every window size the targets produce, without the user having
scrolled to arrange it.
### N5 — Compact degrades, rather than diverges
**Depends on** N4.
Not a second interface. Below `EXPANDED_MIN_WIDTH` the develop column already
overlays rather than sits beside the canvas, and `apply_layout_class` already
remembers the user's override per class. With N4's collapse in place a narrow
window is a one-panel-at-a-time column by consequence rather than by design.
**Deliverable.** Confirm the narrow case is usable and fix what is not. ~~No
bottom sheet, no second layout, no tool strip along the bottom — those solve a
phone, and there is no phone.~~ Still no sheet and still no strip; but a
*tall* window is not a narrow one, and D-N7 (2026-09-06) puts the column under
the photograph there. N6–N9 carry it. This workstream keeps the narrow case.
**Done when.** A desktop window dragged to 700px shows the photograph and a
usable column, and nothing is unreachable that was reachable at 1400px.
### N6 — Measure the tablet
**Depends on** nothing. Hours.
Every figure in D-N7 is computed at a guessed scale factor. The tablet's
panel is 3000 × 1920 physical; its logical size is that divided by whatever
density Android reports, and the two plausible answers (2.0 and 1.75) put the
dock's width at 900 or 1037 and its available height 200px apart.
**Deliverable.** Log the window's scale factor and logical size once at
startup, beside the existing `apply_layout_class` call, at a level that
reaches `adb logcat`. Run it on the tablet in both orientations. Record the
four numbers in D-N7 and, if they move the table, correct it. #30
(NFR-COMPAT-1) wants a reference device named; this is one of the numbers
that names it.
**Done when.** D-N7 cites measured logical sizes, not a scale it assumed, and
`dock-height` has been checked against the measured portrait height.
### N7 — The column docks on a tall window
**Depends on** N6 only for the value of `dock-height`; the shape does not
wait for it.
**Deliverable, three parts.**
*The flag.* `window-resized` reports height as well as width.
`apply_layout_class` derives a third property, `column-below`, from the
aspect with hysteresis (enter above 1.25, leave below 1.15, or thereabouts —
the point is that a window resized across square does not flap), and sets it
beside `layout-class` and `panel-max-width`. It is not a layout class and
`PanelChoices` does not learn about it.
*The frame.* The `HorizontalLayout` holding `ToolRail`, `canvas-area` and
`develop-column` becomes a `Rectangle` and each child takes `x`, `y`, `width`
and `height` from the flag. The rail is full height on the left in both
cases. With the flag off the geometry is what the layout produced, to the
pixel — screenshot before and after and diff them. With it on, the column is
`dock-height` tall and runs from the rail's edge to the window's; the canvas
has what is left above it. `panel-visible` collapses the dock to zero height
exactly as it collapses the column to zero width.
*The contents, as a stopgap.* The column's stack stretches to the dock's
width: the Flickable's viewport width follows the dock, the `min-width` floor
that keeps a mandated column honest is still there and is simply not binding.
Sliders take the width. Nothing is reflowed; N9 does that.
`dock-height` goes in `style.yaml` next to `panel-width`, with the reasoning
D-N7 gives, and is read as `Theme.dock-height`.
**Done when.** On the tablet in portrait, a 3:2 photograph is drawn at the
width of the canvas, not at the width the old column left it; turning the
tablet moves the column back beside it with the same scroll position and the
same panels open; a desktop window dragged taller than it is wide does the
same; and the screenshot diff in landscape is empty.
### N8 — Develop callbacks onto a global
**Depends on** nothing; can run beside N7.
The develop panels — `AdjustPanel`, `MaskPanel`, `SpotPanel`, `ComposePanel`,
`TransferPanel`, `FocusPanel`, `HistogramPanel`, `InfoPanel` — each forward
their callbacks and take their inputs through the window root, and the
instantiation in `app.slint` that wires one up is twenty to forty lines. N9
needs each of them declared a second time, and copying that wiring is the
kind of duplication that drifts.
**Deliverable.** A Slint global (or one per panel family, if a single one
reads badly) carrying the develop callbacks and the inputs the panels bind
to. A panel calls the global directly; Rust hooks the global instead of the
window. The existing instantiation in the column shrinks to the properties
that genuinely differ by placement, which should be none. `Readout` in
`adjust.slint` is the precedent for a global in this codebase.
The tests in `ui/dr-ui/src` that drive these callbacks through the window
move to the global; count them before starting, so the ticket knows its own
size.
**Done when.** No develop panel's instantiation in `app.slint` forwards a
callback by hand, every existing test passes, and a second instantiation of
any panel is under five lines.
### N9 — Three columns in the dock
**Depends on** N7 and N8.
**Deliverable.** A second composition of the same panels for the dock, in
three columns of equal width side by side, each its own scroll:
1. **Instruments** — `InfoPanel`, `HistogramPanel`, `FocusPanel`. What the
sliders are judged against, pinned by construction: it does not scroll
with them because it is not in their column.
2. **Sliders** — `GroupStrip` above `AdjustPanel`, exactly as in the column.
With a group selected this is one screen of sliders; with All it scrolls.
3. **The mode's panels** — `ComposePanel` in photo mode (copy and paste
moved to the top bar, 2026-09-22),
`SpotPanel` in repair, `MaskPanel` in local. Empty otherwise, which is
a signal of its own about which mode the view is in (§1.1).
Three columns at 900 wide are 300 each, and at 1037 they are 346: inside the
280–360 band `PANEL_MIN_WIDTH`'s note says the sliders stay accurate over.
The dock takes the three-column composition only when a third of its width
is at least `PANEL_MIN_WIDTH` — 840px of dock, which both scales of the
tablet exceed. Below that it keeps N7's stretched stack. Two compositions,
not three: a dock too narrow for three columns is a desktop window in an odd
shape, and N5 says that case degrades.
The panels are declared twice, once per composition, which is what N8 made
cheap. Declaring each once and positioning it by hand in both modes was
considered and rejected: a column that scrolls is a `Flickable`, a
`Flickable`'s children are its children, and a panel cannot be in two.
**Done when.** On the tablet in portrait the histogram is visible while any
slider in any group is dragged, with no scrolling to arrange it — N4's own
criterion, met in the dock by construction; every panel reachable in
landscape is reachable in portrait; and the instantiation of each panel in
the dock is a handful of lines.
---
## 5. Sequencing
```
ui-refinement A ──┬── C ──── N4 ──┐
└── D ──── F ├── N5
N1 ── N2 ── N3 ───────────────────┘
N6 ── N7 ──┐
├── N9
N8 ──┘
```
N1–N3 are independent of `ui-refinement.md` and can start now; they touch the
canvas and the develop column's contents, not its layout. N4 needs C's
`Section`. N5 needs both and should land last, as F does — it is the one that
rearranges everything.
N6–N9 are the portrait dock (D-N7) and run beside the first row rather than
after it. N7 is the one that touches the develop view's frame, so it should
not land in the same wave as F; N8 touches only plumbing and can. N9 waits
for both, and gains from N4 if N4 is in by then — a collapsible panel in a
300px column is worth more than in a 360px one.
## 6. Invariants, for every workstream
- **FR-DEV-3a.** Adding a pipeline operation must still surface in both
layouts with no UI edit. No file under `ui/` may name an operation.
- **ARCH §4.3a.** Composition is the frontend's decision. The core may declare
what an operation *is*; it may not declare where the panel puts it.
- **FR-UI-3 / FR-UI-7.** Touch changes hit regions, not layout. Every control
keeps a finger-sized target wherever it is drawn, and no hover-only
affordance and no modifier key may be the sole route to anything — a 12-inch
tablet has neither.
- **FR-UI-5.** Escape and the Android back gesture leave the innermost state
first. Every mode added here joins that order.
---
## 7. Place — the other half of "where am I"
§1 asked how someone *finds* anything. This asks how they stop losing what they
already found. Both are navigation; the second is the one nobody notices until
it is wrong, and then notices constantly.
### 7.1 Three failures, one cause
The library grid is gated on an `if` in the markup, so **every** route away from
it destroys the subtree and rebuilds it on return. The Flickable inside passes
its viewport through zero on the way out. Three consequences, reported
separately and all the same bug:
- *"Opening Settings and coming back puts me at the top."* The guard on the
scroll handler tested `show-library`, which stays true while Settings, Import,
People or the launch screen covers the grid. The teardown's scroll-to-zero
passed it, and the remembered position was overwritten with 0.
- *"Coming out of develop I lose the photograph I was editing."* The position
restored was where the *grid* was, not what was open — and walking the photo
roll moves the second a long way from the first.
- *"Launching puts me at the beginning."* Nothing was written down at all.
`app.slint` now computes `library-visible` once — the same expression the `if`
is spelled from — and Rust reads that rather than `show-library`. The two cannot
drift apart, which is what let them drift in the first place.
### 7.2 Two positions, and which one wins
Returning from develop has two candidates: `resume_at`, where the grid was, and
the open photograph. They agree in the ordinary case and disagree after a walk
along the roll.
The rule is not "pick one". The grid seeks to the remembered position, then
`reveal()`s the keyboard cursor, which is on the open photograph and which moves
the viewport as little as will bring it into view. A frame inside the remembered
screenful moves nothing; one outside it scrolls exactly far enough. One rule,
both behaviours — and the cursor rather than the selection, so a set of forty
photographs assembled in the grid survives having one of them opened.
### 7.3 A place is not a scroll position
What gets written down is the view, the scope, the filter and the photograph —
because a position without the filter that produced it names a row of a list
that no longer exists. Restoring them has an order for the same reason: scope,
then filter, then position, then the view. Each step changes what an ordinal
*means*.
Addressed by remote path and collection UUID, never by an ordinal or a row id.
See FR-UI-8 and `dr_types::place` for why, and `library::ordinal_of_path` for the
one place the ordering is inverted — through the grid's own `ORDER BY`, taken
verbatim, rather than spelled a second time.
### 7.4 The handover, and when to refuse it
The record travels through `.darkroom-derived/place.json`, so a session begun on
the desktop continues on the tablet. Newest wins; there is nothing to merge.
The interesting decision is the refusal. A place arriving from another device is
welcome on the way in and unwelcome the moment the photographer has started
working — a grid that jumped somewhere else mid-scroll because a round trip
finally landed would have lost their place to the feature meant to keep it. So
any scroll, scrub, scope change, filter or opened photograph closes the latch,
and a record that arrives after that is still written to disk and simply takes
effect at the next launch.
### 7.5 Two smaller instruments that were saying nothing
Both were "correct" in the sense of not being wrong, and both were useless.
- **The capture-time marker** rested greyed at mid-track until the first scroll,
on the reasoning that anchoring it would imply a choice the user had not made.
But the sidebar's claim is to say *when* you are, and that is known from the
first frame. It is now seeded from wherever the view sits.
- **The photo roll** brought the open frame into view by the shortest move,
which put it hard against one edge with nothing on that side. It now centres
on the first reveal of a develop session and steps minimally thereafter —
a one-shot request the strip consumes, so an overlay screen rebuilding the
view does not undo a roll the user has scrolled by hand.