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
+38
View File
@@ -378,6 +378,11 @@ fn reset_view_state(window: &AppWindow) {
// back anyway, but a press that opens the next photograph — the roll is a
// tap away — must not carry a held original onto it.
window.set_showing_original(false);
// TRACES: FR-DEV-3
// And an armed sampler goes down with it: a picker left waiting would make
// the first click on the *next* photograph sample it, which is a click
// nobody meant to spend.
window.set_sampling_op(-1);
// TRACES: FR-DSP-7
// Emptied rather than left standing: the previous photograph's histogram
// beside the next one's filename is a confident, precise lie, and the gap
@@ -2833,6 +2838,39 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
render_now(&w, false);
});
}
{
// TRACES: FR-DEV-3
// A neutral picked off the photograph.
//
// The operation is not named here and is not passed in: the click
// carries a point, the session turns it into a colour, and
// `dr_pipeline::neutral` finds the operation that asked to be driven
// by one. This callback's whole contribution is that a click on the
// canvas is a click on the canvas.
//
// The rows are re-synced because the sample moved parameters the panel
// is showing — two of them, from one press, which is exactly the case
// a paste and an undo already go through this path for.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_neutral_picked(move |x, y| {
let Some(w) = weak.upgrade() else { return };
let sampled = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.sample_neutral(x, y));
if sampled {
sync_rows(&w, &rows, &session);
}
// Redrawn either way. A sample that found nothing usable still
// overwrote the frame the adjust pass was holding, and leaving the
// canvas showing a probe of the unedited image would look like the
// edit had been thrown away.
redraw(&w);
});
}
// TRACES: FR-DEV-5 | FR-DEV-7
// Clicking a row. Arriving six steps away costs what arriving from one