Point at something grey and let the pipeline work out the rest

FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since
it was written, and only the first half existed. `WidgetKind::WhitePoint` was
in the vocabulary and `develop::supported` answered false for it, so the node
degraded to two sliders — correct behaviour that had quietly become the only
behaviour. Sampling a neutral is the first move of the global tonal pass and
every colour judgement afterwards is measured against where the grey was put,
so guessing at two sliders until a wall stops looking green is the wrong way
round.

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

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

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

Declaring the presentation also groups temperature and tint under one undo
step, where they were two. That follows from what `Presentation` means and
reads correctly — white balance is one decision — but it is a change, and
worth saying so.
This commit is contained in:
2026-09-06 19:01:52 +02:00
parent 2584b9ecbc
commit 901f51e6c4
11 changed files with 969 additions and 60 deletions
+86
View File
@@ -42,6 +42,17 @@ export struct ParamRow {
// cannot see the others.
group-modified: bool,
// TRACES: FR-DEV-3
// This operation is driven by pointing at the photograph as well as by
// its sliders, so the group's heading carries the control that arms the
// canvas. Identical on every row of the group, for the reason
// `group-modified` above is: the heading is one of those rows.
//
// Set in Rust from the widget the operation asked for, never from which
// operation it is (ARCH §4.3a) — the panel still does not know that white
// balance exists, only that something here can be sampled.
group-samples: bool,
// Which control to build. Mirrors ParamKind, plus the widget kinds an
// operation can request through its presentation.
kind: string, // "scalar" | "bool" | "enum" | "curve"
@@ -111,8 +122,15 @@ component GroupHeading inherits Rectangle {
in property <bool> modified: false;
/// Whether this group has anything to reset.
in property <bool> has-reset: true;
/// TRACES: FR-DEV-3
/// Whether this group can be set by pointing at the photograph.
in property <bool> has-sampler: false;
/// Whether the canvas is currently waiting for that point.
in property <bool> sampling: false;
callback reset();
/// Arm the sampler, or put it away if it is already armed.
callback sample();
height: Theme.control-height;
@@ -136,6 +154,52 @@ component GroupHeading inherits Rectangle {
visible: root.modified;
}
// TRACES: FR-DEV-3 | NFR-A11Y-2
// The eyedropper, built exactly as the reset beside it is: a word, a
// hit target grown to a thumb, and a role so it is announced as
// something that can be pressed rather than read as a caption.
//
// A word rather than a drawn pipette, because this heading has no
// icons in it and one would be the only glyph in a column of text —
// and because "pick" says what happens next, which a pipette only
// says to somebody who already knows.
//
// It stays lit while armed. Arming changes what a click on the
// photograph *does*, and a mode with nothing saying it is on is the
// fault the develop view's own mode strip exists to prevent.
Rectangle {
width: 30px;
visible: root.has-sampler;
accessible-role: button;
accessible-label: "Set from a neutral in the photograph";
accessible-enabled: root.has-sampler;
accessible-checkable: true;
accessible-checked: root.sampling;
accessible-action-default => {
if (root.has-sampler) {
root.sample();
}
}
sample-touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.has-sampler;
mouse-cursor: pointer;
clicked => { root.sample(); }
}
Caption {
text: "pick";
emphasised: root.sampling || sample-touch.has-hover;
horizontal-alignment: right;
width: 100%;
height: 100%;
}
}
Rectangle {
width: 34px;
visible: root.has-reset;
@@ -993,6 +1057,19 @@ export component AdjustPanel inherits Rectangle {
callback op-reset(int);
callback reset-all();
// TRACES: FR-DEV-3
// The on-canvas sampler: which group has armed it, and the request to arm
// one.
//
// The op index rather than a bool, because arming is a question about a
// *group* — two operations could ask for a sampler and only one of them
// can be waiting for the click. -1 is none armed, the sentinel this file
// already uses for "no swatch" and "everything" and for the same reason:
// a Slint struct or property cannot carry an optional.
in property <int> sampling-op: -1;
/// The heading's picker was pressed, for the operation at this index.
callback sampler-armed(int);
// TRACES: FR-DEV-3f
// The film stock, which is not a parameter and so is not a `ParamRow`.
//
@@ -1276,6 +1353,15 @@ export component AdjustPanel inherits Rectangle {
GroupHeading {
title: row.op-label;
modified: row.group-modified;
// TRACES: FR-DEV-3
// And, where the operation asked for one, the
// control that arms the canvas. The heading knows
// only that it has a sampler; which group is
// waiting is the panel's business, because only
// the panel can see the others.
has-sampler: row.group-samples;
sampling: root.sampling-op == row.op-index;
sample => { root.sampler-armed(row.op-index); }
// Resetting is what *this* panel's groups do; the
// heading itself has no opinion about it.
reset => { root.op-reset(row.op-index); }
+72
View File
@@ -237,6 +237,30 @@ export component AppWindow inherits Window {
/// the two cannot get out of step and strand the view on the original.
callback compare-original(bool);
// --- sampling a neutral off the photograph (FR-DEV-3) ---
/// TRACES: FR-DEV-3
/// Which operation's on-canvas sampler is armed, or -1 for none.
///
/// Held here rather than in Rust, unlike the view mode beside it, and the
/// difference is real: entering a mode has side effects on the session and
/// this has none. Arming changes what the next click on the photograph
/// means and nothing else — no parameter moves, no history step is taken,
/// and the session has nothing to hear about until a point is picked. It
/// is one-shot: the click that samples disarms it.
///
/// An index rather than a bool because arming belongs to a *group*. Two
/// operations could offer a sampler and only one of them can be waiting.
in-out property <int> sampling-op: -1;
/// Shorthand for the condition the canvas tests, written once.
property <bool> sampling: root.sampling-op >= 0;
/// A neutral was picked, in fractions of the visible image.
///
/// The operation is not passed: which one a sampled colour belongs to is
/// settled by what the chain declared, and this file has no business
/// knowing. See `dr_pipeline::neutral`.
callback neutral-picked(float, float);
/// Every step, newest first. Rust owns the order and stamps each row with
/// its own position in the stack, so nothing here does arithmetic to turn
/// a row back into a step.
@@ -2050,6 +2074,43 @@ in property <bool> panel-visible: true;
}
}
// TRACES: FR-DEV-3 | FR-UI-7
// Sampling a neutral, beside the repair placer above and
// built exactly like it: a click on the fitted image, over
// the pan area so the click reaches it first, and a
// separate handler rather than a branch inside the pan —
// panning wants press-drag-release and this wants a click,
// and interleaving the two is how a drag ends up sampling
// whatever it happened to travel over.
//
// **One click, one sample, one history step.** There is no
// hover preview: previewing would mean a render and a
// solve per pixel the pointer crossed, and if any of them
// were recorded the stack would fill with a hundred
// temperatures nobody chose. The arming state is what says
// the next click will do something instead.
if root.sampling && root.total > 0 && root.load-error == "": TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
clicked => {
root.neutral-picked(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
// One shot. Leaving it armed would make the next
// click on the photograph — to place a mask, to
// pan — a second sample, and the photographer
// would have to remember to put a tool away that
// has already done its job.
root.sampling-op = -1;
}
}
if root.total > 0 && root.load-error == "": TouchArea {
x: 0; y: 0;
width: 100%;
@@ -2831,6 +2892,17 @@ in property <bool> panel-visible: true;
// kept in step with it.
op-reset(op) => { root.curve-reset(op); }
reset-all => { root.reset-all(); }
// TRACES: FR-DEV-3
// Arming is a toggle and it is resolved here,
// not in Rust: it changes what a click on the
// photograph does and nothing about the edit,
// so there is nothing for the session to hear
// about until a point is actually picked.
sampling-op: root.sampling-op;
sampler-armed(op) => {
root.sampling-op =
root.sampling-op == op ? -1 : op;
}
// TRACES: FR-DEV-3f
film-stocks: root.film-stocks;
film-selected: root.film-selected;