Local adjustments took the develop column from four panels to six, and the operation set is meant to keep growing — FR-DEV-3 still lists texture, clarity, sharpening and noise reduction as v1. But the count is the lesser problem. The real one is that local adjustments introduced a *mode* without introducing a way to see it. Selecting a mask silently re-points thirty sliders at that layer, and the histogram those sliders are judged against goes on reporting the whole frame. I built that, and it is the fault that loses work rather than merely slowing someone down. Decided: local becomes a mode, in the sense `crop-mode` already is. The app has the pattern, the user knows it, and it removes the ambiguity by construction instead of describing it in a caption. It also inherits the Escape/back stack that already leaves the innermost state first. Recommended: diverge on **width**, not on platform. A tablet in landscape wants what a desktop wants and a narrow desktop window wants what a phone wants, so `cfg(target_os)` would give one physical situation two answers. `apply_layout_class` already classifies on width and already remembers a per-class override; this is a second consumer of a decision the app makes anyway. What must not diverge is the controls — both layouts consume the same generated capability model, so a new operation still needs no UI edit. Left open, because it is taste: whether the wide layout eventually gains tool tabs. Recorded rather than left to be rediscovered is the *legitimate* route to them — a descriptor declaring an operation's nature, the same shape as `Affects`, with the frontend free to render it as a tab or ignore it. Deferred because ten operations do not need eight tabs and the field is easy to add later and awkward to remove. Surveys what Lightroom, Capture One, darktable and the phone editors actually do, including the thing none of them do: make a mask a panel that rewires a different panel.
299 lines
13 KiB
Markdown
299 lines
13 KiB
Markdown
# 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`, 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
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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 — Diverge on **width**, not on platform · **RECOMMENDED**
|
||
|
||
The question was whether desktop and Android should diverge. They should
|
||
diverge, but not along that line.
|
||
|
||
**Platform is the wrong axis.** A tablet in landscape wants what a desktop
|
||
wants. A desktop window dragged to a third of the screen wants what a phone
|
||
wants. Splitting on `cfg(target_os)` would give the same physical situation two
|
||
answers depending on which binary it happened to be.
|
||
|
||
**Width is the right axis, and it already exists.** `apply_layout_class` in
|
||
`lib.rs` already classifies the window as `expanded` or `compact` on width
|
||
alone, already remembers a user override per class, and already drives panel
|
||
visibility. The divergence proposed here is a second consumer of a decision
|
||
the app is already making.
|
||
|
||
So:
|
||
|
||
| | **expanded** | **compact** |
|
||
|---|---|---|
|
||
| Modes | strip at the top of the canvas | strip along the bottom |
|
||
| Tools | side column, collapsible stack | bottom sheet, one group at a time |
|
||
| Mask stack | inline in the column | its own sheet, reached from the strip |
|
||
|
||
**What must not diverge is the controls.** Both layouts consume the same
|
||
`ParamRow` model, the same generated capability list and the same widgets.
|
||
Only the container differs. That is what keeps FR-DEV-3a intact — a new
|
||
operation appears in both layouts with no UI edit, because neither layout
|
||
knows what an operation is.
|
||
|
||
### 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
|
||
|
||
**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.
|
||
|
||
**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.
|
||
|
||
### N2 — Local mode
|
||
|
||
**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.
|
||
|
||
**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.
|
||
|
||
### N3 — Scope-following histogram
|
||
|
||
**Depends on** N2.
|
||
|
||
**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.** Each panel in the develop column collapses to its header;
|
||
headers carry the modified dot C defines; state is keyed by panel identity and
|
||
survives a slider drag. Default: image and histogram open, the rest collapsed
|
||
until touched.
|
||
|
||
**Done when.** A fresh develop view fits without scrolling at 900px tall, and
|
||
the histogram is reachable without scrolling past the sliders it reports on.
|
||
|
||
### N5 — Compact layout
|
||
|
||
**Depends on** N1, N4.
|
||
|
||
**Deliverable.** In the compact class the column becomes a bottom sheet and the
|
||
mode strip moves to the bottom. Tool groups are reached from the strip, one at
|
||
a time. Same models, same widgets, different container.
|
||
|
||
**Done when.** A phone-width window shows the photograph with a thumb-reachable
|
||
strip and no side column, and every control reachable in expanded is reachable
|
||
here.
|
||
|
||
---
|
||
|
||
## 5. Sequencing
|
||
|
||
```
|
||
ui-refinement A ──┬── C ──── N4 ──┐
|
||
└── D ──── F ├── N5
|
||
N1 ── N2 ── N3 ───────────────────┘
|
||
```
|
||
|
||
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.
|
||
|
||
## 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.** Touch targets survive the compact layout — it is the layout
|
||
most likely to be touched.
|
||
- **FR-UI-5.** Escape and the Android back gesture leave the innermost state
|
||
first. Every mode added here joins that order.
|