Merge branch 'worktree-agent-a22a049c461818dbe' into integration

# Conflicts:
#	core/dr-pipeline/tests/mask_sidecar.rs
This commit is contained in:
2026-08-22 13:23:34 +02:00
14 changed files with 2352 additions and 196 deletions
+206 -12
View File
@@ -1020,26 +1020,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()
}
// ----------------------------------------------------------------------
@@ -1245,6 +1282,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>) {
@@ -2283,6 +2385,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 };
+765
View File
@@ -0,0 +1,765 @@
//! On-canvas handles for the gradient masks (FR-DEV-3, FR-UI-3).
//!
//! A linear or radial mask could be created and then not moved: it had no
//! handles, so a radial sat at the centre of the frame at its default size for
//! ever. These are the first controls in the application designed to be
//! dragged on the photograph rather than in a panel, and two things follow
//! from that which do not apply to a slider.
//!
//! # The geometry is stored where the mask is, not where the pointer is
//!
//! A gradient's geometry is in **normalised source coordinates**, because that
//! is where the mask is rasterised and it is what makes the mask survive a
//! crop, a zoom, a pan and an export at another size. The pointer arrives in
//! **output** coordinates — fractions of the photograph as it currently sits
//! on screen. Everything here is the journey between those two, and it goes
//! through [`dr_pipeline::Framing::source_at`], which is the same map the
//! shader applies. Anything less — the crop alone, say — would put a handle
//! on the mask at one zoom level and beside it at every other.
//!
//! # A drag is a displacement, not a destination
//!
//! Each handle answers to the *movement* of the pointer since the press, not
//! to where the pointer now is. Snapping the handle to the pointer instead
//! would jerk it by up to half a touch target the instant it was grabbed —
//! and the target is finger-sized (FR-UI-3), so that jerk is about 20 pixels
//! on a first press. The map is affine, so a displacement in output space is
//! exactly a displacement in source space and nothing is lost by working this
//! way.
//!
//! # Frame units
//!
//! Positions are fractions of each axis; **distances and angles are in the
//! frame's isotropic units**, where y spans `0..1` and x spans `0..aspect`.
//! See [`dr_pipeline::mask::MaskSource::Linear`] for why. Converting between
//! the two is [`to_frame`] and [`to_uv`], and they are the only place it
//! happens on this side.
use std::f32::consts::FRAC_PI_2;
use dr_pipeline::mask::MaskSource;
use dr_pipeline::Framing;
use crate::{GradientHandle, HandleRole};
/// How far the rotation handle stands off the shape it turns, in frame units.
///
/// Far enough that turning is a comfortable lever and near enough that it is
/// still on the photograph at a middling zoom.
const ROT_ARM: f32 = 0.22;
/// The closest a size handle is ever *drawn* to the centre, in frame units.
///
/// A hard-edged ramp has zero width and a radial can be dragged very small,
/// and a handle drawn at its true position would then sit underneath the
/// centre handle where it could never be grabbed again — the size would be a
/// one-way trip. So the drawn offset has a floor while the stored value does
/// not. The handle lies by at most this much, and only when the shape is
/// already smaller than a fingertip.
const MIN_ARM: f32 = 0.06;
/// The largest a gradient may be dragged, in frame units. Well past the
/// diagonal of any frame, so it bounds nothing a user would do and does bound
/// a drag flung off the edge of the screen.
const MAX_EXTENT: f32 = 4.0;
/// The smallest semi-axis a radial may be dragged to. Not zero: a radial with
/// a zero axis covers nothing and looks broken rather than small.
const MIN_RADIUS: f32 = 0.005;
/// Where every handle for `source` currently sits, in normalised **output**
/// coordinates.
///
/// Empty for a mask that is not a gradient — a subject's outline is the
/// model's and has nothing to drag.
///
/// A position outside `0..1` is returned rather than clamped: the caller draws
/// the handles inside the photograph's own rect and lets them clip, so a
/// handle panned off screen is absent instead of pinned to the edge claiming
/// the mask is somewhere it is not.
pub(crate) fn handles(
source: &MaskSource,
framing: &Framing,
src: (u32, u32),
) -> Vec<GradientHandle> {
let aspect = aspect_of(src);
points(source, aspect)
.into_iter()
.map(|(role, uv)| {
let (x, y) = framing.output_at(uv, src.0, src.1);
GradientHandle { role, x, y }
})
.collect()
}
/// The gradient `source` becomes when `role` is dragged from `press` to `now`,
/// both in normalised output coordinates.
///
/// `source` must be the geometry as it stood **when the press began**, so that
/// a drag is applied once rather than accumulated frame by frame. The caller
/// captures it on the way down for the same reason the crop handles capture
/// the rect they started from.
pub(crate) fn drag(
source: &MaskSource,
role: HandleRole,
press: (f32, f32),
now: (f32, f32),
framing: &Framing,
src: (u32, u32),
) -> MaskSource {
let aspect = aspect_of(src);
let from = framing.source_at(press, src.0, src.1);
let to = framing.source_at(now, src.0, src.1);
let delta = (to.0 - from.0, to.1 - from.1);
// Where this handle was, moved by what the pointer did. Everything below
// reads the field back out of that one moved point, so a handle cannot
// disagree with the shape it is drawn on.
let Some((_, start)) = points(source, aspect).into_iter().find(|(r, _)| *r == role) else {
return source.clone();
};
let moved = (start.0 + delta.0, start.1 + delta.1);
match source {
MaskSource::Linear {
centre,
angle,
width,
} => {
if role == HandleRole::Centre {
return MaskSource::Linear {
centre: moved,
angle: *angle,
width: *width,
};
}
let d = frame_delta(moved, *centre, aspect);
match role {
// The projection onto the ramp direction, doubled: `width` is
// the whole distance from full effect to none and the handle
// sits at half of it. Projected rather than measured, so
// dragging sideways changes the width by nothing — the
// rotation handle is what turns a ramp.
HandleRole::Edge => MaskSource::Linear {
centre: *centre,
angle: *angle,
width: (2.0 * dot(d, direction(*angle))).clamp(0.0, MAX_EXTENT),
},
HandleRole::Rotate => MaskSource::Linear {
centre: *centre,
// The arm lies along the ramp *line*, a quarter turn from
// the direction coverage increases in.
angle: bearing(d).map_or(*angle, |b| b - FRAC_PI_2),
width: *width,
},
_ => source.clone(),
}
}
MaskSource::Radial {
centre,
radii,
angle,
feather,
} => {
if role == HandleRole::Centre {
return MaskSource::Radial {
centre: moved,
radii: *radii,
angle: *angle,
feather: *feather,
};
}
let d = frame_delta(moved, *centre, aspect);
match role {
// The major axis, length *and* direction. Dragging outward
// resizes and dragging round turns, which is the one gesture
// an ellipse's own axis affords — and the reason there is no
// separate rotation handle.
HandleRole::Edge => MaskSource::Radial {
centre: *centre,
radii: (length(d).clamp(MIN_RADIUS, MAX_EXTENT), radii.1),
angle: bearing(d).unwrap_or(*angle),
feather: *feather,
},
// The minor axis, length only. Projected onto the axis it
// owns, so the ellipse keeps the angle the major handle set
// rather than the two fighting over it.
HandleRole::Cross => {
let minor = {
let major = direction(*angle);
(-major.1, major.0)
};
MaskSource::Radial {
centre: *centre,
radii: (radii.0, dot(d, minor).abs().clamp(MIN_RADIUS, MAX_EXTENT)),
angle: *angle,
feather: *feather,
}
}
_ => source.clone(),
}
}
// A subject or a region has no geometry of its own — its outline is
// the model's, and the edge controls in the panel are how it is
// shaped. A painted mask will be the same answer for a different
// reason: its geometry is the strokes, and a stroke is made by
// painting rather than by moving a handle.
_ => source.clone(),
}
}
/// Every handle's position in normalised **source** coordinates.
///
/// The single description both directions read: [`handles`] maps these onto
/// the screen, and [`drag`] moves one of them. Two lists would be two things
/// to keep in step, and the symptom of them disagreeing is a handle that
/// grabs at a distance.
fn points(source: &MaskSource, aspect: f32) -> Vec<(HandleRole, (f32, f32))> {
match source {
MaskSource::Linear {
centre,
angle,
width,
} => {
let along = direction(*angle);
let across = (-along.1, along.0);
vec![
(
HandleRole::Edge,
offset(*centre, along, (width * 0.5).max(MIN_ARM), aspect),
),
(HandleRole::Rotate, offset(*centre, across, ROT_ARM, aspect)),
(HandleRole::Centre, *centre),
]
}
// **Three handles, and no separate rotation.** The major-axis handle
// *is* the major axis, so where it is put says both how long the axis
// is and which way it points — the ellipse needs no fourth control to
// say the same thing twice.
//
// A rotation arm was tried 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, so the first thing the user saw was a handle
// they could not reach without first shrinking the mask.
MaskSource::Radial {
centre,
radii,
angle,
..
} => {
let major = direction(*angle);
let minor = (-major.1, major.0);
vec![
(
HandleRole::Edge,
offset(*centre, major, radii.0.max(MIN_ARM), aspect),
),
(
HandleRole::Cross,
offset(*centre, minor, radii.1.max(MIN_ARM), aspect),
),
(HandleRole::Centre, *centre),
]
}
_ => Vec::new(),
}
}
/// The frame's aspect, which is what separates a fraction of the width from a
/// fraction of the height.
fn aspect_of(src: (u32, u32)) -> f32 {
src.0.max(1) as f32 / src.1.max(1) as f32
}
fn to_frame(uv: (f32, f32), aspect: f32) -> (f32, f32) {
(uv.0 * aspect, uv.1)
}
fn to_uv(q: (f32, f32), aspect: f32) -> (f32, f32) {
(q.0 / aspect, q.1)
}
/// `centre` displaced by `distance` frame units along the unit vector `dir`,
/// returned in normalised coordinates.
fn offset(centre: (f32, f32), dir: (f32, f32), distance: f32, aspect: f32) -> (f32, f32) {
let q = to_frame(centre, aspect);
to_uv((q.0 + dir.0 * distance, q.1 + dir.1 * distance), aspect)
}
/// The vector from `centre` to `point`, in frame units.
fn frame_delta(point: (f32, f32), centre: (f32, f32), aspect: f32) -> (f32, f32) {
let a = to_frame(point, aspect);
let b = to_frame(centre, aspect);
(a.0 - b.0, a.1 - b.1)
}
fn direction(angle: f32) -> (f32, f32) {
(angle.cos(), angle.sin())
}
fn dot(a: (f32, f32), b: (f32, f32)) -> f32 {
a.0 * b.0 + a.1 * b.1
}
fn length(v: (f32, f32)) -> f32 {
(v.0 * v.0 + v.1 * v.1).sqrt()
}
/// Which way `d` points, or `None` when it is too short to have a direction.
///
/// A drag that lands on the centre would otherwise send the angle somewhere
/// arbitrary, and a gradient that spins when the pointer passes through its
/// middle is the sort of thing that makes a control feel broken.
fn bearing(d: (f32, f32)) -> Option<f32> {
((d.0 * d.0 + d.1 * d.1) > 1e-8).then(|| d.1.atan2(d.0))
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::framing::CropRect;
/// A 3:2 sensor. Square would hide every aspect fault in this file.
const SRC: (u32, u32) = (6000, 4000);
fn linear() -> MaskSource {
MaskSource::Linear {
centre: (0.5, 0.5),
angle: FRAC_PI_2,
width: 0.3,
}
}
fn radial() -> MaskSource {
MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.35, 0.25),
angle: 0.0,
feather: 0.5,
}
}
fn centre_of(source: &MaskSource) -> (f32, f32) {
match source {
MaskSource::Linear { centre, .. } | MaskSource::Radial { centre, .. } => *centre,
_ => panic!("not a gradient"),
}
}
fn spot(handles: &[GradientHandle], role: HandleRole) -> (f32, f32) {
let h = handles
.iter()
.find(|h| h.role == role)
.unwrap_or_else(|| panic!("no {role:?} handle"));
(h.x, h.y)
}
fn close(a: (f32, f32), b: (f32, f32), what: &str) {
assert!(
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3,
"{what}: {a:?} != {b:?}"
);
}
/// A view that is cropped, zoomed, panned, straightened and turned — the
/// composition, because a handle that is only ever tested unedited is
/// tested in the one state where the map is the identity.
fn moved_view() -> Framing {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.1,
y: 0.15,
width: 0.7,
height: 0.6,
});
f.set_view(CropRect {
x: 0.2,
y: 0.3,
width: 0.5,
height: 0.5,
});
f.set_param(dr_pipeline::framing::ANGLE, 6.0);
f.rotate_quarters(1);
f
}
#[test]
fn a_centre_drag_lands_where_the_pointer_did() {
// The whole point of the feature, and the thing that breaks silently:
// the handle must end up under the pointer, not under where the
// pointer would have been at some other zoom level.
let f = Framing::new();
let dragged = drag(
&linear(),
HandleRole::Centre,
(0.5, 0.5),
(0.3, 0.8),
&f,
SRC,
);
close(centre_of(&dragged), (0.3, 0.8), "unzoomed");
close(
f.output_at(centre_of(&dragged), SRC.0, SRC.1),
(0.3, 0.8),
"and reads back to the same place on screen",
);
}
#[test]
fn a_centre_drag_lands_where_the_pointer_did_after_the_view_moves() {
// The regression this exists for. With the view moved, an output
// fraction and a source fraction are different numbers, so a drag that
// forgot the framing map would put the mask somewhere the pointer
// never was — and the further the user had zoomed, the further off.
let f = moved_view();
let start = f.output_at(centre_of(&linear()), SRC.0, SRC.1);
let target = (0.62, 0.28);
let dragged = drag(&linear(), HandleRole::Centre, start, target, &f, SRC);
close(
f.output_at(centre_of(&dragged), SRC.0, SRC.1),
target,
"the centre is under the pointer",
);
assert!(
(centre_of(&dragged).0 - target.0).abs() > 0.05,
"and it is *not* simply the output fraction stored raw, which is \
the mistake this guards: {:?}",
centre_of(&dragged)
);
}
#[test]
fn dragging_a_handle_onto_another_gradients_handle_produces_that_gradient() {
// The contract for every handle at once, and the sharpest way to state
// it: put a handle where some other gradient's matching handle sits and
// the mask must *become* that gradient.
//
// Sharper than "the handle lands under the pointer", which is only
// true of the centre — the size handles project onto the axis they
// control and the rotation handle keeps its arm's length, so all three
// deliberately land somewhere other than the pointer. This holds for
// all of them, and it is what closes the loop between the two
// directions of the map: the position is computed one way, the field
// is recovered the other, and they have to be inverses.
//
// Through a moved view, because that is where a one-legged map hides:
// when the framing is neutral the two directions are both the
// identity and any pair of them agrees.
let f = moved_view();
// Each pair differs in exactly the field the named handle controls, so
// a handle that moved something else fails as well as one that landed
// in the wrong place.
let cases: Vec<(HandleRole, MaskSource, MaskSource)> = vec![
(
HandleRole::Centre,
linear(),
MaskSource::Linear {
centre: (0.31, 0.62),
angle: FRAC_PI_2,
width: 0.3,
},
),
(
HandleRole::Edge,
linear(),
MaskSource::Linear {
centre: (0.5, 0.5),
angle: FRAC_PI_2,
// Both widths well past twice `MIN_ARM`, or the drawn
// offset is the floor rather than the width and the test
// would be measuring the floor.
width: 0.52,
},
),
(
HandleRole::Rotate,
linear(),
MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.9,
width: 0.3,
},
),
(
HandleRole::Centre,
radial(),
MaskSource::Radial {
centre: (0.4, 0.34),
radii: (0.35, 0.25),
angle: 0.0,
feather: 0.5,
},
),
(
HandleRole::Edge,
radial(),
MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.52, 0.25),
angle: 0.0,
feather: 0.5,
},
),
(
HandleRole::Cross,
radial(),
MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.35, 0.41),
angle: 0.0,
feather: 0.5,
},
),
// The major-axis handle carries the angle as well as the length,
// so a turned ellipse is reached through `Edge` and there is no
// `Rotate` case for a radial to check.
(
HandleRole::Edge,
radial(),
MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.35, 0.25),
angle: 0.55,
feather: 0.5,
},
),
];
for (role, from, want) in cases {
let press = spot(&handles(&from, &f, SRC), role);
let target = spot(&handles(&want, &f, SRC), role);
let got = drag(&from, role, press, target, &f, SRC);
assert_eq!(
describe(&got),
describe(&want),
"{role:?} on a {}",
from.kind()
);
}
}
/// A gradient rounded to three places, so two of them can be compared
/// without floating-point noise deciding the outcome.
fn describe(source: &MaskSource) -> String {
let r = |v: f32| (v * 1000.0).round() as i32;
match source {
MaskSource::Linear {
centre,
angle,
width,
} => format!(
"linear c=({},{}) a={} w={}",
r(centre.0),
r(centre.1),
r(*angle),
r(*width)
),
MaskSource::Radial {
centre,
radii,
angle,
feather,
} => format!(
"radial c=({},{}) r=({},{}) a={} f={}",
r(centre.0),
r(centre.1),
r(radii.0),
r(radii.1),
r(*angle),
r(*feather)
),
other => other.kind().to_string(),
}
}
#[test]
fn the_size_handle_reads_the_projection_and_not_the_distance() {
// Dragging a width handle sideways must change nothing. Reading the
// raw distance instead would make every rotation of the pointer widen
// the ramp, so a user turning a gradient would find it growing.
let f = Framing::new();
let before = handles(&linear(), &f, SRC);
let edge = spot(&before, HandleRole::Edge);
// The ramp runs down the frame, so sideways is along x.
let sideways = drag(
&linear(),
HandleRole::Edge,
edge,
(edge.0 + 0.2, edge.1),
&f,
SRC,
);
match sideways {
MaskSource::Linear { width, .. } => {
assert!((width - 0.3).abs() < 1e-3, "width moved to {width}")
}
_ => panic!("kind changed"),
}
}
#[test]
fn a_hard_edged_ramp_keeps_a_reachable_size_handle() {
// Width zero is a legitimate mask — a hard edge — and its handle sits
// exactly on the centre. Drawn there it would be under the centre
// handle and the width could never be raised again, so the *drawn*
// offset has a floor while the stored width does not.
let hard = MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 0.0,
};
let f = Framing::new();
let hs = handles(&hard, &f, SRC);
let (cx, cy) = spot(&hs, HandleRole::Centre);
let (ex, ey) = spot(&hs, HandleRole::Edge);
let apart = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt();
assert!(apart > 0.02, "the two handles are on top of each other");
// And dragging it outward still sets a real width rather than one
// measured from the place the handle was drawn at.
let widened = drag(&hard, HandleRole::Edge, (ex, ey), (ex + 0.1, ey), &f, SRC);
match widened {
MaskSource::Linear { width, .. } => assert!(width > 0.0, "width stayed {width}"),
_ => panic!("kind changed"),
}
}
#[test]
fn turning_a_ramp_leaves_its_centre_and_width_alone() {
// Three fields, three handles, and each must move only its own — a
// rotation that also nudged the centre would make aiming a gradient a
// negotiation.
let f = Framing::new();
let rot = spot(&handles(&linear(), &f, SRC), HandleRole::Rotate);
let turned = drag(&linear(), HandleRole::Rotate, rot, (0.9, 0.2), &f, SRC);
match turned {
MaskSource::Linear {
centre,
angle,
width,
} => {
close(centre, (0.5, 0.5), "centre");
assert!((width - 0.3).abs() < 1e-6, "width moved to {width}");
assert!(
(angle - FRAC_PI_2).abs() > 0.1,
"the angle did not actually turn: {angle}"
);
}
_ => panic!("kind changed"),
}
}
#[test]
fn each_radial_handle_owns_one_semi_axis() {
// Two axes and one handle each. Scaling both together would make an
// ellipse unreachable, which is the shape a face wants.
let f = Framing::new();
let hs = handles(&radial(), &f, SRC);
let edge = spot(&hs, HandleRole::Edge);
let wider = drag(
&radial(),
HandleRole::Edge,
edge,
(edge.0 + 0.1, edge.1),
&f,
SRC,
);
match wider {
MaskSource::Radial { radii, .. } => {
assert!(radii.0 > 0.35, "the major axis grew: {radii:?}");
assert!(
(radii.1 - 0.25).abs() < 1e-6,
"the minor did not: {radii:?}"
);
}
_ => panic!("kind changed"),
}
// And the minor handle keeps the angle the major one set, rather than
// the two contradicting each other about which way the ellipse lies.
let turned = MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.35, 0.25),
angle: 0.7,
feather: 0.5,
};
let cross = spot(&handles(&turned, &f, SRC), HandleRole::Cross);
let taller = drag(
&turned,
HandleRole::Cross,
cross,
(cross.0 + 0.06, cross.1),
&f,
SRC,
);
match taller {
MaskSource::Radial { radii, angle, .. } => {
assert!((angle - 0.7).abs() < 1e-6, "the angle moved to {angle}");
assert!((radii.0 - 0.35).abs() < 1e-6, "the major moved: {radii:?}");
}
_ => panic!("kind changed"),
}
}
#[test]
fn a_new_radials_handles_are_all_on_the_photograph() {
// The first thing anyone sees. A handle placed by a fixed standoff
// from the shape starts outside the frame at the size a radial is
// created at, and a control you have to shrink the mask to reach is
// one nobody finds — a tablet has no hover to hint at it and no
// modifier to summon it (FR-UI-7).
let radial = MaskSource::Radial {
// What `add_gradient_mask` creates.
centre: (0.5, 0.5),
radii: (0.35, 0.35),
angle: 0.0,
feather: 0.5,
};
for h in handles(&radial, &Framing::new(), SRC) {
assert!(
(0.0..=1.0).contains(&h.x) && (0.0..=1.0).contains(&h.y),
"{:?} is off the picture at ({}, {})",
h.role,
h.x,
h.y
);
}
// The linear's rotation arm is measured from the centre rather than
// from an edge, so it has the same obligation and much more room.
let linear = MaskSource::Linear {
centre: (0.5, 0.5),
angle: FRAC_PI_2,
width: 0.3,
};
for h in handles(&linear, &Framing::new(), SRC) {
assert!(
(0.0..=1.0).contains(&h.x) && (0.0..=1.0).contains(&h.y),
"{:?} is off the picture at ({}, {})",
h.role,
h.x,
h.y
);
}
}
#[test]
fn a_subject_mask_offers_nothing_to_drag() {
// Its outline is the model's. Offering handles would suggest the
// shape can be moved, and the edge controls in the panel are what
// actually shapes one.
let subject = MaskSource::Subject {
signature: 1,
index: 0,
class: "person".into(),
score: 0.9,
};
assert!(handles(&subject, &Framing::new(), SRC).is_empty());
}
}
+101 -34
View File
@@ -24,6 +24,7 @@ mod collections_ui;
mod derived_sync;
mod develop;
mod export;
mod gradient;
mod histogram;
mod labels;
mod library;
@@ -257,11 +258,12 @@ fn is_supported(p: &Path) -> bool {
/// Return the view to its opening state for a newly loaded image.
///
/// Zoom and crop mode are properties of *looking at one photograph*, so
/// Zoom and the view mode are properties of *looking at one photograph*, so
/// carrying them to the next one would leave the second image cropped to a
/// rect chosen for the first.
/// rect chosen for the first — or, since local masking became a mode, would
/// open the next photograph with a mask stack it does not have.
fn reset_view_state(window: &AppWindow) {
window.set_crop_mode(false);
window.set_view_mode(ViewMode::Photo);
window.set_zoom(1.0);
window.set_zoomed(false);
window.set_crop_x(0.0);
@@ -1126,7 +1128,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Crop mode shows the whole frame, or the area being cropped away
// would not be on screen for the handles to drag across. The
// overlay draws the rect on top of it.
let rendered = if window.get_crop_mode() {
let rendered = if window.get_view_mode() == ViewMode::Crop {
s.render_uncropped(w, h).map(|(image, _, _)| image)
} else {
s.render(w, h)
@@ -1882,25 +1884,52 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
{
// Entering crop mode drops the zoom: the handles are placed against
// the whole frame, and a zoomed view would put most of that frame off
// screen where it cannot be dragged.
// TRACES: FR-UI-5
// **Entering a mode is a side effect, which is why Rust owns it** and
// the strip does not simply write the property. Each of the three has
// work to do that the interface cannot see:
//
// *Crop* drops the zoom. The handles are placed against the whole
// frame, and a zoomed view would put most of that frame off screen
// where it cannot be dragged.
//
// *Local* turns the region overlay on. It used to be a button in the
// masking panel, so the mode could be open with the overlay off —
// which is a mode you have entered that is doing nothing.
//
// *Leaving* clears the selection, and that is the fault this whole
// pass exists for: a selected layer silently re-points thirty sliders
// at that layer's chain, so a mode you have left must not leave one
// behind. After this the controls are unambiguously global again,
// which is what the panel's heading then says.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_crop_mode_toggled(move |on| {
let rows = rows.clone();
window.on_mode_picked(move |mode| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
if on {
s.reset_zoom();
let c = s.crop();
w.set_crop_x(c.x);
w.set_crop_y(c.y);
w.set_crop_w(c.width);
w.set_crop_h(c.height);
match mode {
ViewMode::Crop => {
s.reset_zoom();
let c = s.crop();
w.set_crop_x(c.x);
w.set_crop_y(c.y);
w.set_crop_w(c.width);
w.set_crop_h(c.height);
}
ViewMode::Local => s.set_overlay(true),
ViewMode::Photo => {
s.set_overlay(false);
s.set_active_mask(None);
}
}
}
w.set_crop_mode(on);
w.set_view_mode(mode);
masks_ui::sync(&w, &session);
// The scope may have just changed, so the panel below is now
// describing a different chain.
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
@@ -2267,7 +2296,7 @@ fn back_one_step(w: &AppWindow) -> bool {
launch: w.get_show_launch(),
browsing: w.get_launch_browsing(),
library: w.get_show_library(),
crop: w.get_crop_mode(),
mode: w.get_view_mode(),
zoomed: w.get_zoomed(),
// Files named on the command line have no grid behind them — the same
// condition the status strip uses to decide whether to offer the way
@@ -2283,7 +2312,7 @@ fn back_one_step(w: &AppWindow) -> bool {
match step {
BackStep::CloseSettings => w.invoke_settings_close(),
BackStep::CancelBrowse => w.invoke_launch_browse_cancel(),
BackStep::LeaveCrop => w.invoke_crop_mode_toggled(false),
BackStep::LeaveMode => w.invoke_mode_picked(ViewMode::Photo),
BackStep::ResetZoom => w.invoke_zoom_reset(),
BackStep::ToLibrary => w.invoke_back_to_library(),
BackStep::ClearScope => w.invoke_collection_select(0),
@@ -2297,13 +2326,21 @@ fn back_one_step(w: &AppWindow) -> bool {
/// stated and tested without a Slint backend: which of two states is left first
/// is the whole of this feature, and it is the part that is easy to get subtly
/// wrong when it is spelled out in nested `if`s over live properties.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
// No `Eq`: `ViewMode` is generated by Slint and derives only `PartialEq`,
// which is all the comparisons below need. `Default` still derives, and it
// gives `mode` the enum's own first variant — `photo`, which is what "no mode"
// means and what the tests below want as their baseline.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct NavState {
settings: bool,
launch: bool,
browsing: bool,
library: bool,
crop: bool,
/// Which develop mode is on, if any. One field rather than one flag per
/// mode, so "leave the innermost" cannot be asked of two at once — the
/// ordering below would have had to invent an answer for a state the
/// interface can no longer be in.
mode: ViewMode,
zoomed: bool,
has_grid: bool,
scoped: bool,
@@ -2314,7 +2351,10 @@ struct NavState {
enum BackStep {
CloseSettings,
CancelBrowse,
LeaveCrop,
/// Leave whichever develop mode is on — crop or local — and return to the
/// whole photograph. One step for both, because there is one mode at a
/// time and "back" means the same thing from either.
LeaveMode,
ResetZoom,
ToLibrary,
ClearScope,
@@ -2334,10 +2374,15 @@ fn back_step(s: NavState) -> Option<BackStep> {
}
if !s.library {
// Develop. Crop is a mode and zoom is a view state; both are left
// before the image is.
if s.crop {
return Some(BackStep::LeaveCrop);
// Develop. Crop and local are modes and zoom is a view state; all are
// left before the image is.
//
// The mode goes before the zoom because that is the order they were
// entered in — a photographer zooms to place a mask, not the other way
// about — and because leaving local mode drops the mask selection,
// which is a bigger step back than returning to fit.
if s.mode != ViewMode::Photo {
return Some(BackStep::LeaveMode);
}
if s.zoomed {
return Some(BackStep::ResetZoom);
@@ -2381,7 +2426,7 @@ mod tests {
let from_develop = NavState {
settings: true,
crop: true,
mode: ViewMode::Crop,
..developing()
};
assert_eq!(back_step(from_develop), Some(BackStep::CloseSettings));
@@ -2389,14 +2434,20 @@ mod tests {
#[test]
fn back_leaves_a_mode_before_it_leaves_the_image() {
// Crop then zoom then the view: innermost first, because that is the
// order they were entered in.
let cropping = NavState {
crop: true,
zoomed: true,
..developing()
};
assert_eq!(back_step(cropping), Some(BackStep::LeaveCrop));
// A mode, then zoom, then the view: innermost first, because that is
// the order they were entered in.
for mode in [ViewMode::Crop, ViewMode::Local] {
let in_mode = NavState {
mode,
zoomed: true,
..developing()
};
assert_eq!(
back_step(in_mode),
Some(BackStep::LeaveMode),
"{mode:?} must be left before the zoom is reset"
);
}
let zoomed = NavState {
zoomed: true,
@@ -2407,6 +2458,22 @@ mod tests {
assert_eq!(back_step(developing()), Some(BackStep::ToLibrary));
}
/// TRACES: FR-UI-5
/// Local masking joins the existing order rather than inventing an exit.
///
/// It is the point of making it a mode: before this, back and Escape did
/// nothing about a masking session, so the only way out of it was to find
/// the two toggles that had armed it and press them again — and neither
/// was anywhere near the photograph the user was looking at.
#[test]
fn local_masking_is_left_by_the_same_step_crop_is() {
let masking = NavState {
mode: ViewMode::Local,
..developing()
};
assert_eq!(back_step(masking), Some(BackStep::LeaveMode));
}
#[test]
fn back_from_an_image_with_no_grid_behind_it_is_the_top_of_the_stack() {
// Files named on the command line: there is no library to return to,
+314 -18
View File
@@ -26,11 +26,40 @@
use std::cell::RefCell;
use std::rc::Rc;
use slint::{ComponentHandle as _, ModelRc, VecModel};
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
use crate::develop::{Abandon, DevelopSession, Segmented, SessionId};
use crate::segmentation;
use crate::{sync_rows, AppWindow, MaskRow, ParamRow, SubjectRow};
use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow};
/// What the adjust panel's heading says when the controls are global.
///
/// The panel's own default too, and named because the two have to agree: a
/// literal in both would eventually be a literal in one.
pub(crate) const GLOBAL_SCOPE: &str = "ADJUST";
/// TRACES: FR-DEV-3
/// What the adjust panel is pointed at, for its heading.
///
/// **The layer's name, not the word "adjust".** Selecting a layer re-points
/// every control in that panel at that layer's chain, and the heading is the
/// one piece of text a photographer cannot avoid reading on the way to a
/// slider. Upper case because the heading style is, and it is the *same*
/// string the row in the stack above shows — one name for one thing, so the
/// selected row and the panel it scopes cannot appear to disagree.
pub(crate) fn scope_label(session: &DevelopSession) -> String {
let Some(id) = session.active_mask() else {
return GLOBAL_SCOPE.to_string();
};
session
.mask_layers()
.into_iter()
.find(|(layer_id, ..)| layer_id == id)
.map_or_else(
|| GLOBAL_SCOPE.to_string(),
|(_, label, ..)| label.to_uppercase(),
)
}
/// How often the window looks to see whether the model has finished.
///
@@ -123,12 +152,12 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
window.set_segmented(false);
window.set_editing_mask(false);
window.set_adjust_scope(GLOBAL_SCOPE.into());
clear_handles(window);
window.set_overlay_on(false);
return;
};
let active = s.active_mask().map(|id| id.to_string());
let rows: Vec<MaskRow> = s
.mask_layers()
.into_iter()
@@ -164,7 +193,8 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
window.set_subject_rows(ModelRc::new(VecModel::from(subjects)));
window.set_segmented(s.has_segmentation());
window.set_editing_mask(active.is_some());
window.set_adjust_scope(scope_label(s).into());
sync_handles(window, s);
// The overlay is regenerated only when there is one to draw. It is a
// proxy-sized RGBA buffer — a megabyte or so — and rebuilding it on every
@@ -184,6 +214,71 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
sync_overlay_view(window, s);
}
/// TRACES: FR-DEV-3 | FR-UI-3
/// Move the canvas handles to where the selected gradient now is.
///
/// # Why this is not `set_gradient_handles(VecModel::from(…))`
///
/// **A fresh model kills the gesture that is moving them.** The handles are a
/// repeater over this model, and handing Slint a new `ModelRc` makes it throw
/// the repeated items away and build new ones — including the `TouchArea`
/// holding the pointer. A drag therefore moved the handle exactly once, on the
/// first pointer event, and then went dead under the finger with the button
/// still down. Seen on screen and invisible in the source; `develop.rs` carries
/// the same warning about the parameter rows, where it broke slider drags.
///
/// So the model is kept and its rows are rewritten in place. Slint updates the
/// existing item rather than replacing it, and the handle stays under the
/// pointer for the whole drag.
///
/// Split out from [`sync`] for a second reason too: a drag emits a pointer
/// event a frame, and rebuilding the mask stack and the subject list on each of
/// them would be a model rewrite per frame for lists that did not change.
pub(crate) fn sync_handles(window: &AppWindow, session: &DevelopSession) {
let next = session.gradient_handles();
let model = handle_model();
while model.row_count() > next.len() {
model.remove(model.row_count() - 1);
}
for (i, handle) in next.into_iter().enumerate() {
if i < model.row_count() {
// Only where it actually moved: an unchanged row written back is
// still a change notification, and the point of this function is
// to emit as few of those as the truth allows.
if model.row_data(i).as_ref() != Some(&handle) {
model.set_row_data(i, handle);
}
} else {
model.push(handle);
}
}
window.set_gradient_handles(model.into());
}
/// The handles' model, held for the life of the process.
///
/// One shared identity, for the reason [`sync_handles`] gives. A thread-local
/// because the interface is single-threaded and this is the same shape
/// `develop.rs` uses for its shared empty models.
fn handle_model() -> Rc<VecModel<GradientHandle>> {
thread_local! {
static HANDLES: Rc<VecModel<GradientHandle>> = Rc::new(VecModel::default());
}
HANDLES.with(Clone::clone)
}
/// Take the handles off the canvas, emptying the held model rather than
/// replacing it — see [`sync_handles`] for why the identity is kept.
fn clear_handles(window: &AppWindow) {
let model = handle_model();
while model.row_count() > 0 {
model.remove(model.row_count() - 1);
}
window.set_gradient_handles(model.into());
}
/// Push the overlay's clip rectangle and angle.
///
/// Separate from [`sync`] because it is called from the render path too: a pan
@@ -246,23 +341,61 @@ pub(crate) fn wire(
});
}
// --- the overlay and the picking mode ---------------------------------
// --- dragging a gradient on the photograph ----------------------------
//
// The geometry the gesture started from, held for its duration.
//
// **A drag is applied to where the mask was when the press landed**, not
// to where it was one frame ago. Accumulating frame by frame would let the
// clamps compound — a radius dragged past its limit and back would not
// return to where it started — and would make the result depend on how
// many events the pointer happened to deliver.
let dragging: Rc<RefCell<Option<dr_pipeline::mask::MaskSource>>> = Rc::new(RefCell::new(None));
{
let weak = window.as_weak();
let session = session.clone();
window.on_overlay_toggled(move |on| {
let redraw = redraw.clone();
let dragging = dragging.clone();
window.on_gradient_handle_dragged(move |role, from_x, from_y, to_x, to_y| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_overlay(on);
let origin = dragging.borrow().clone();
let started = session.borrow_mut().as_mut().and_then(|s| {
s.drag_gradient_handle(role, origin.as_ref(), (from_x, from_y), (to_x, to_y))
});
if started.is_none() {
return;
}
sync(&w, &session);
*dragging.borrow_mut() = started;
// Only the handles, not the whole panel: nothing in the mask stack
// or the subject list changed, and rewriting those models on every
// frame of a drag is work for no difference. See `sync_handles` for
// the sharper reason — a full `sync` would also take the gesture
// out from under the finger.
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
window.on_region_picking_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
w.set_region_picking(on);
let session = session.clone();
let dragging = dragging.clone();
window.on_gradient_handle_released(move || {
// Forgotten on release, so the next gesture measures from wherever
// this one left the mask rather than from where this one began.
if dragging.borrow_mut().take().is_none() {
// A press with no movement. Nothing changed, so recording a
// step would put an identical snapshot on the undo stack.
return;
}
if let Some(s) = session.borrow_mut().as_mut() {
s.commit_gradient_drag();
}
if let Some(w) = weak.upgrade() {
w.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
w.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
}
});
}
@@ -536,17 +669,180 @@ fn watch(
/// Clear the panel when the open image changes.
///
/// Its own function rather than a call to [`sync`] with an empty session,
/// because the *window* state has to be reset too: picking mode and the
/// overlay are properties of looking at one photograph, and carrying them to
/// the next one leaves a crosshair over an image with no region map behind it.
/// because the *window* state has to be reset too: the overlay and the scope
/// are properties of looking at one photograph, and carrying them to the next
/// one would draw a region map over an image that has none and name a heading
/// after a layer that is not there. Picking is not among them any more — it
/// follows the view mode, which `reset_view_state` returns to `photo`.
pub(crate) fn reset(window: &AppWindow) {
window.set_region_picking(false);
window.set_overlay_on(false);
window.set_segmenting(false);
window.set_segmented(false);
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
window.set_editing_mask(false);
window.set_adjust_scope(GLOBAL_SCOPE.into());
clear_handles(window);
}
#[cfg(test)]
mod tests {
use super::*;
/// A session over a flat frame. No segmentation, which is deliberate: a
/// gradient needs none, and the tests below are about scope rather than
/// about what the model found.
fn session() -> Option<DevelopSession> {
let ctx = pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()?;
let rgba: Vec<u8> = (0..32 * 32).flat_map(|_| [128u8, 128, 128, 255]).collect();
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL).ok()
}
/// TRACES: FR-DEV-3 | FR-UI-1
/// The fault this pass exists for, stated as a test.
///
/// Selecting a mask layer re-points every control in the adjust panel at
/// that layer's chain. Before this, the only thing that said so was a
/// caption in a *different* panel, which a photographer reaching for the
/// exposure slider has no reason to read. The heading of the panel that
/// changed now carries the answer, so the two cannot be read apart — and
/// this asserts they cannot come apart either.
#[test]
fn the_heading_says_which_chain_the_controls_are_pointed_at() {
let Some(mut s) = session() else {
eprintln!("no adapter; skipping");
return;
};
assert_eq!(scope_label(&s), GLOBAL_SCOPE, "nothing selected");
let id = s
.add_gradient_mask(false)
.expect("a gradient needs no model");
assert_ne!(
scope_label(&s),
GLOBAL_SCOPE,
"adding a layer selects it, so the panel is already scoped to it \
and must already say so"
);
// And the name is the one the row in the stack shows. Two names for
// one layer would let the selected row and the panel it scopes appear
// to disagree.
let row = s
.mask_layers()
.into_iter()
.find(|(layer_id, ..)| *layer_id == id)
.expect("the layer is in the stack");
assert_eq!(scope_label(&s), row.1.to_uppercase());
s.set_active_mask(None);
assert_eq!(
scope_label(&s),
GLOBAL_SCOPE,
"clearing the selection must put the heading back, or the panel \
would go on naming a layer it is no longer editing"
);
}
/// TRACES: FR-UI-5
/// Leaving local mode is what clears the selection, and this is the half
/// of it that can be tested without a window.
///
/// The mode handler in `lib.rs` calls `set_active_mask(None)`; what has to
/// be true afterwards is that the controls are global *and say so*. A mode
/// that was left with a layer still selected would leave thirty sliders
/// pointed at a region of the photograph with nothing on screen saying it.
#[test]
fn clearing_the_selection_returns_the_rows_to_the_whole_photograph() {
let Some(mut s) = session() else {
eprintln!("no adapter; skipping");
return;
};
// The scope is invisible in the *shape* of the panel — a layer holds
// the same chain the frame does, so both produce the same rows in the
// same order. It is only visible in what those rows read, which is
// precisely why the fault was silent: the panel looks identical either
// way and means something different.
//
// Addressed by index rather than by name, because no part of the
// frontend may route by a parameter's identity (FR-DEV-3a).
let first = s.rows()[0].clone();
s.set_param(first.op_index, first.param_index, first.maximum);
assert_eq!(s.rows()[0].value, first.maximum, "the global chain moved");
// Adding a layer selects it, so the same row is now the layer's.
s.add_gradient_mask(true).expect("gradient");
assert_eq!(
s.rows()[0].value,
first.default_value,
"the same control, pointed somewhere else and reading its own \
value — the whole hazard, in one row"
);
s.set_active_mask(None);
assert_eq!(
s.rows()[0].value,
first.maximum,
"and leaving the layer puts the frame's value back"
);
assert_eq!(scope_label(&s), GLOBAL_SCOPE);
}
/// TRACES: FR-UI-3
/// The handles' model keeps one identity for the life of the process.
///
/// **This is what makes a drag last longer than one frame.** The handles
/// are a repeater over this model, and a *new* `ModelRc` makes Slint throw
/// the repeated items away and build fresh ones — taking the `TouchArea`
/// that holds the pointer with them. The symptom is precise and was seen
/// on screen before it was understood: the handle jumps once, on the first
/// pointer event, and then sits dead under a finger that is still down.
///
/// It cannot be asserted through a window without a Slint backend, so it is
/// asserted where it is decided. Every path that touches the handles —
/// `sync_handles` and `clear_handles` — goes through this one model.
#[test]
fn the_handles_are_one_model_rewritten_rather_than_a_new_one_each_time() {
assert!(
Rc::ptr_eq(&handle_model(), &handle_model()),
"a fresh model per sync destroys the gesture that is moving the \
handles"
);
}
/// TRACES: FR-DEV-3 | FR-UI-3
/// A gradient offers handles; a mask with nothing to drag offers none.
///
/// This is the panel's whole test for whether to draw anything on the
/// canvas, so it is worth pinning: handles over a subject mask would
/// suggest an outline that cannot be moved can be.
#[test]
fn only_a_selected_gradient_puts_handles_on_the_canvas() {
let Some(mut s) = session() else {
eprintln!("no adapter; skipping");
return;
};
assert!(s.gradient_handles().is_empty(), "nothing selected");
s.add_gradient_mask(false).expect("linear");
assert_eq!(s.gradient_handles().len(), 3, "centre, width and rotation");
s.add_gradient_mask(true).expect("radial");
assert_eq!(
s.gradient_handles().len(),
3,
"centre and two semi-axes — the major one carries the angle, so \
an ellipse needs no fourth handle to say it twice"
);
s.set_active_mask(None);
assert!(
s.gradient_handles().is_empty(),
"a gradient nobody has selected is not being edited"
);
}
}
#[cfg(test)]