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.
829 lines
42 KiB
Markdown
829 lines
42 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`](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.
|