Say which photograph the sliders are pointed at

Selecting a mask layer silently re-points about thirty controls at that layer's
chain. Same panel, same order, same sliders, different meaning — and the only
thing that said so was a sentence in the panel above, which a photographer
reaching for the exposure slider has no reason to read. An exposure change
lands on the whole frame when it was meant for a face, or the reverse; both are
silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the
dangerous one and it is: the others in that document cost time, this one costs
work.

The remedy is the classic one for a modal fault — make the mode visible — and
the application already had the pattern. Crop arms a canvas interaction, draws
an overlay, gives the column one job and is left by the control that entered
it. Local masking is the same animal built as a peer panel, and that is what
created the ambiguity. So `crop-mode` stops being a bare boolean and becomes
one value of a three-state mode, which is the point: two modes could both be on
before, and now that is not a state the interface can be in rather than one it
is tested against.

**One strip, not two.** The mode control was going to sit beside the group
strip that filters the adjustments, which is two controls above one column
answering the same question — what am I working on. They are one control now,
`Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's
bottom strip has for the same reason. 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 difference is what
lets both be read at once, which they routinely are — picking Light while a
mask is selected filters *that layer's* chain and does not leave the mode.
Dropping the scope on a group press would be the same fault coming back from
the other end, and would make Light mean two things depending on where it was
pressed.

The strip stays pinned above the develop column rather than moving to the top
of the canvas as the document proposed. The half that filters the column
belongs to the column, and the photograph is the subject. The canvas keeps one
button, which now names the mode it leaves rather than saying "Done" — that was
unambiguous with one mode and would not be with two — because the column can be
closed on a narrow window and no mode may be inescapable.

Entering a mode is a side effect, so Rust owns it rather than the strip writing
the property: crop drops the zoom, local turns the overlay on, and leaving
clears the selection. That last one is the fix. The "Overlay" and "Select"
toggles are gone because they armed things that are simply what the mode *is* —
a mode that has to be switched on separately is one you can enter and have do
nothing. Escape and the Android back gesture join `back_step` as one
`LeaveMode` rather than a second exit concept, and the mode is left before the
zoom is: it was entered later, and it is the bigger step back.

The heading is where the scope goes. Not a caption beside the panel, the
heading *of* the panel that changed — `ADJUST` becomes the layer's name, the
same string the selected row in the stack shows. That is the difference between
describing a hazard and removing it.

**Handles on the photograph.** A linear or radial mask could be created and
then not moved, so a radial sat at the centre of the frame at its default size
for ever. Three faults stood in the way of drawing one.

The first is that a gradient did not render at all until the model had run. The
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, in the same way exports and thumbnails once did:
the 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 derived from
it now, and deliberately at the same size rather than by coincidence, because a
subject's distance field is sampled against that array.

The second is hit-testing. A handle is drawn in output coordinates and stored
in source ones, and between them lie the crop, the zoom, the pan, the
straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated
on the CPU, kept in that file beside it so that keeping the two in step is one
file's problem — a handle mapped through anything less drifts off the mask the
moment the view moves, which is exactly what masks are rasterised in source
space to avoid.

The third is that a drag is a displacement, not a destination. Each handle
answers to the movement of the pointer since the press, applied to where the
mask was when the press landed. Snapping the handle to the pointer instead
jerks it by up to half a touch target on the first press, and the target is
finger-sized because a tablet has no hover to reveal a control and no modifier
to qualify it.

A ramp gets three handles — centre, width, angle. An ellipse gets three too:
centre and one per semi-axis, the major one carrying the direction as well as
the length, because where an axis is put says both. It had a fourth, and it is
gone: standing off the shape by a fixed distance, the rotation arm began
outside the photograph at the size a new radial is created at, so the first
thing anyone saw was a control they could not reach without first shrinking the
mask.

Two faults here were found by looking at the screen rather than at the source,
both of the kind that cannot be found any other way. A `1px` rule with a size
and no position is *centred* by Slint, so the seam between the photograph and
the column was a hairline down the middle of the panel, through the histogram
and every slider under it — twice, once in `app.slint` and once in
`AdjustPanel`. And handing Slint a fresh model 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. `develop.rs` carries the same warning about the parameter
rows, where it broke slider drags; the model is rewritten in place now.

The tests worth having are the ones about ambiguity and about the map. That the
same row reads the frame's value, then the layer's, then the frame's again is
§1.1 in one assertion. That dragging a handle onto another gradient's matching
handle *produces* that gradient closes the loop between the two directions of
the framing map, through a view that is cropped, zoomed, panned, straightened
and quarter-turned at once — a one-legged map is invisible when the framing is
neutral, because then both legs are the identity.

Not done here: the histogram still reports the whole frame while the sliders
edit a layer. That disagreement is real and is N3's, which this unblocks. The
strip has room for a Brush entry beside Crop and Local when the painted masks
land in the core, and it needs nothing here but the canvas interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 13:20:41 +02:00
co-authored by Claude Opus 5
parent c75863c93f
commit 96a7b405c2
10 changed files with 2171 additions and 185 deletions
+206 -12
View File
@@ -838,26 +838,63 @@ impl DevelopSession {
//
// It also means the array does not reallocate when the window
// resizes, and does not need redrawing when the view moves.
let Some(seg) = self.segmentation.as_ref() else {
return false;
};
let (pw, ph) = seg.proxy_size();
let (pw, ph) = self.mask_raster_size();
let subjects = self.subjects.as_ref();
// **Built here, not in `segment`.** A gradient needs no segmentation —
// a graduated filter over a sky never had to know what a sky is — but
// the rasteriser was only ever constructed on the way out of one, so
// adding a gradient to a photograph nobody had segmented produced an
// array that was never rasterised and a layer that drew nothing at
// all. Silently: the generated shader still emits the layer's block
// and the empty placeholder multiplies it by zero, which is the same
// failure exports and thumbnails had.
//
// The shader compile this costs is paid once, on the first frame after
// the first mask is added — a button press, not a frame anyone is
// dragging through. `is_neutral` above is what keeps it off the path
// of every photograph that has no local adjustment at all.
if self.masks.is_none() {
let ctx = self.ctx.clone();
self.masks = dr_gpu::MaskPass::new(&ctx)
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
.ok();
}
let Some(pass) = self.masks.as_mut() else {
return false;
};
// No label field: region masks were the watershed's, and nothing
// produces one any more. A stored layer that still names regions is
// skipped by the rasteriser rather than drawn wrong.
pass.render(
self.graph.masks(),
None,
subjects,
pw as u32,
ph as u32,
pass.render(self.graph.masks(), None, subjects, pw, ph)
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
.is_ok()
}
/// The size the mask array is rasterised at, in source space.
///
/// **A property of the photograph, not of the segmentation.** The two come
/// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`,
/// and they have to: a subject layer's distance field is built at the
/// segmentation's proxy and sampled against this array, so the two sizes
/// agreeing is a requirement rather than a coincidence. Reading the size
/// *off* the segmentation is what made it look like a dependency, and made
/// a gradient — which indexes nothing — wait for a model to run.
///
/// The aspect must be the source's either way. A gradient's geometry is
/// measured against the frame's own proportions, so a square array over a
/// 3:2 photograph would stretch every circle it drew.
fn mask_raster_size(&self) -> (u32, u32) {
if let Some(seg) = self.segmentation.as_ref() {
let (pw, ph) = seg.proxy_size();
return (pw as u32, ph as u32);
}
let (sw, sh) = self.demosaiced.size();
let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0);
(
((sw as f32 * scale) as u32).max(1),
((sh as f32 * scale) as u32).max(1),
)
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
.is_ok()
}
// ----------------------------------------------------------------------
@@ -1087,6 +1124,71 @@ impl DevelopSession {
self.active_mask.as_deref()
}
/// TRACES: FR-DEV-3 | FR-UI-3
/// The selected gradient's handles, in fractions of the shown image.
///
/// Empty unless a gradient layer is selected, which is what makes this the
/// panel's whole test for "is there anything to draw on the canvas".
///
/// Recomputed on every redraw rather than cached, because the answer
/// changes with the *view* and not only with the mask: a pan moves every
/// handle and touches no geometry. Four handles through an affine map is
/// not work worth caching, and a cache keyed on the wrong thing is how a
/// handle comes to sit where the mask used to be.
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
let Some(layer) = self.active_layer() else {
return Vec::new();
};
let (sw, sh) = self.demosaiced.size();
crate::gradient::handles(&layer.source, self.graph.framing(), (sw, sh))
}
/// Drag one handle of the selected gradient, from `press` to `now`, both
/// in fractions of the shown image.
///
/// `origin` is the geometry the gesture started from — see
/// [`crate::gradient::drag`] for why a drag is applied to that rather than
/// accumulated. Returns it, so the caller can hold it for the rest of the
/// gesture; `None` when there is no gradient selected to drag.
pub fn drag_gradient_handle(
&mut self,
role: crate::HandleRole,
origin: Option<&MaskSource>,
press: (f32, f32),
now: (f32, f32),
) -> Option<MaskSource> {
let (sw, sh) = self.demosaiced.size();
let framing = *self.graph.framing();
let id = self.active_mask.clone()?;
let start = match origin {
Some(s) => s.clone(),
None => self.graph.masks().get(&id)?.source.clone(),
};
let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh));
self.graph.masks_mut().get_mut(&id)?.source = moved;
// **Nothing recorded here.** A drag delivers a pointer event a frame,
// and a history step per frame would make undo walk a gesture back
// pixel by pixel. `Edit` coalesces by operation id and a mask's shape
// is not an operation, so there is no key to coalesce under — the
// honest answer is to record once, on release.
Some(start)
}
/// A handle drag finished: one history step for the whole gesture.
///
/// Called on the pointer's release rather than on each move, which is what
/// makes a drag one decision in the undo stack however many frames it took.
pub fn commit_gradient_drag(&mut self) {
self.history.record(&self.graph, Edit::Discrete);
}
/// The selected layer's mask rule, for a caller that has to remember what
/// a gesture started from.
pub fn active_mask_source(&self) -> Option<MaskSource> {
self.active_layer().map(|l| l.source.clone())
}
/// Select a layer for editing, or `None` to return the panel to the
/// global chain.
pub fn set_active_mask(&mut self, id: Option<&str>) {
@@ -2035,6 +2137,98 @@ mod tests {
pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()
}
/// TRACES: FR-DEV-3
/// A gradient needs no segmentation, and until now it silently got no mask.
///
/// The rasteriser was built on the way out of `segment`, so a gradient
/// added to a photograph nobody had segmented had nothing to draw it — and
/// the failure was invisible from every side. The generated shader still
/// emits the layer's block, the empty placeholder multiplies it by zero,
/// and the result is a well-formed frame with the local adjustment simply
/// absent. No error, no warning, and nothing on screen to tell it apart
/// from a mask the user had placed badly.
///
/// A graduated filter over a sky never had to know what a sky is, so the
/// dependency was wrong as well as silent.
#[test]
fn a_gradient_renders_on_a_photograph_nobody_has_segmented() {
let Some(ctx) = headless() else { return };
// Mid grey, so a brightening layer is unambiguous either way.
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!session.has_segmentation(),
"the point of the test is that there is none"
);
let before = read_back(&ctx, &session.render(64, 64).expect("render"));
// A radial over the middle, brightened hard. Addressed by index, so
// this names no operation (FR-DEV-3a).
session.add_gradient_mask(true).expect("a radial");
let row = session.rows()[0].clone();
session.set_param(row.op_index, row.param_index, row.maximum);
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize];
assert!(
centre(&after) > centre(&before) + 20,
"the middle of the frame must brighten: {} against {}",
centre(&after),
centre(&before)
);
// And only the middle: a mask that failed to rasterise the other way —
// covering everything — would pass the assertion above.
let corner = |px: &[u8]| px[0];
assert_eq!(
corner(&after),
corner(&before),
"the corner is outside the radial and must not move"
);
}
/// TRACES: FR-DEV-3
/// The mask array and the segmentation proxy are the same size on purpose.
///
/// They have to be: a subject layer's distance field is built at the
/// segmentation's proxy resolution and sampled against the array, so if the
/// two ever diverged a subject mask would be drawn at the wrong scale —
/// a mask that is confidently in the wrong place, which is worse than none.
///
/// It used to hold because the array's size was *read off* the
/// segmentation, which also made a gradient wait for a model it does not
/// use. Deriving both from the photograph keeps the agreement and drops the
/// dependency, and this is what stops the agreement being an accident.
#[test]
fn the_mask_array_is_the_size_the_segmentation_will_use() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..100 * 100)
.flat_map(|_| [128u8, 128, 128, 255])
.collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
.expect("session");
let before = session.mask_raster_size();
if session
.segment(&ctx, &crate::segmentation::Options::default())
.is_err()
{
eprintln!("no model; skipping");
return;
}
assert_eq!(
before,
session.mask_raster_size(),
"a mask rasterised before the model ran must not move when it does"
);
}
#[test]
fn an_unzoomed_overlay_shows_the_whole_frame() {
let Some(ctx) = headless() else { return };