Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts: # core/dr-pipeline/tests/mask_sidecar.rs
This commit is contained in:
+206
-12
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user