The chained lookup in the_intersect_button_joins_a_part_that_intersects was one line past rustfmt's width, so fmt --check failed on the branch. Split as rustfmt wants it; no behaviour changes.
1864 lines
72 KiB
Rust
1864 lines
72 KiB
Rust
//! Wiring the local-adjustment panel to the develop session.
|
|
//!
|
|
//! Everything here is translation: Slint rows in one direction, callbacks in
|
|
//! the other. The decisions all live in [`crate::develop::DevelopSession`] and
|
|
//! [`crate::segmentation`], which is the same split every other `*_ui` module
|
|
//! in this crate draws.
|
|
//!
|
|
//! # The things this module does decide
|
|
//!
|
|
//! Selecting a mask layer re-scopes the adjust panel to that layer's chain.
|
|
//! That happens because [`crate::develop::DevelopSession::rows`] answers
|
|
//! differently once a layer is active, so the sync below only has to call
|
|
//! `sync_rows` afterwards — there is no second panel and no duplicated
|
|
//! control-building code. It is worth stating plainly because the absence of
|
|
//! code is easy to mistake for an omission.
|
|
//!
|
|
//! And whether a segmentation that has finished is still wanted. Finding the
|
|
//! subjects takes most of a second on a 22 MP frame, so it runs on a worker
|
|
//! and the window polls for the answer — which means the answer can arrive
|
|
//! for a photograph the user has left. [`delivery`] is that rule, kept as a
|
|
//! named function with tests because both of its ways of being wrong are
|
|
//! silent: applied to the wrong image it draws outlines that follow a subject
|
|
//! which is not in the picture, and discarded too eagerly it throws away work
|
|
//! the user waited for.
|
|
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
|
|
|
|
use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId};
|
|
use crate::segmentation;
|
|
use crate::{
|
|
sync_rows, Adjustments, AppWindow, CategoryRow, GradientHandle, MaskRow, Masking, ParamRow,
|
|
PartRow, Steps, 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 {
|
|
match session.active_masks() {
|
|
[] => GLOBAL_SCOPE.to_string(),
|
|
[id] => session
|
|
.mask_layers()
|
|
.into_iter()
|
|
.find(|(layer_id, ..)| layer_id == id)
|
|
.map_or_else(
|
|
|| GLOBAL_SCOPE.to_string(),
|
|
|(_, label, ..)| label.to_uppercase(),
|
|
),
|
|
many => format!("{} LAYERS", many.len()),
|
|
}
|
|
}
|
|
|
|
/// How often the window looks to see whether the model has finished.
|
|
///
|
|
/// The interval `apply_when_ready` polls a sidecar fetch at, for the same
|
|
/// reason: a tick that finds nothing costs a `try_recv` on an empty channel,
|
|
/// and twenty a second is imperceptible against a result that took most of a
|
|
/// second to produce.
|
|
const POLL: std::time::Duration = std::time::Duration::from_millis(50);
|
|
|
|
/// What to do with a segmentation that has finished.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Delivery {
|
|
Apply,
|
|
Discard,
|
|
}
|
|
|
|
/// Does this answer still belong to the photograph on screen?
|
|
///
|
|
/// The session a job was taken from names the photograph it is about, and
|
|
/// sessions are never reused — so re-opening the same file is a different
|
|
/// answer to this, correctly: the second session has no segmentation and its
|
|
/// panel says so.
|
|
fn delivery(computed_for: SessionId, open: Option<SessionId>) -> Delivery {
|
|
match open {
|
|
Some(id) if id == computed_for => Delivery::Apply,
|
|
_ => Delivery::Discard,
|
|
}
|
|
}
|
|
|
|
fn open_session(session: &Rc<RefCell<Option<DevelopSession>>>) -> Option<SessionId> {
|
|
session.borrow().as_ref().map(|s| s.id())
|
|
}
|
|
|
|
/// The one segmentation that may be in flight.
|
|
///
|
|
/// One at a time. Two runs would be two model loads, two proxy readbacks and
|
|
/// two cores for a single answer, and the second to land would overwrite the
|
|
/// first — so the cost buys nothing. The button is already insensitive while
|
|
/// `segmenting` is set, but that is a property of the window; this is the
|
|
/// invariant, so it lives where it can be tested.
|
|
#[derive(Default)]
|
|
struct Running {
|
|
/// The photograph the running job was started for.
|
|
started_for: Option<SessionId>,
|
|
/// Tells that job nobody wants its answer.
|
|
abandon: Abandon,
|
|
/// Held here rather than by its own closure. A timer that kept itself
|
|
/// alive to be able to stop itself would be an `Rc` cycle — one leaked
|
|
/// timer and one leaked channel per photograph segmented — and owning it
|
|
/// here also means starting the next job drops the previous timer instead
|
|
/// of leaving it polling a channel nothing will send on.
|
|
poll: Option<Rc<slint::Timer>>,
|
|
}
|
|
|
|
impl Running {
|
|
/// Claim the slot for `id`, or refuse because a run for that same
|
|
/// photograph is already under way.
|
|
///
|
|
/// A job left over from a photograph the user has since left is *not* a
|
|
/// reason to refuse: it is abandoned and displaced. Refusing would leave
|
|
/// the next photograph's "Find subjects" doing nothing for as long as a
|
|
/// run nobody wants takes to finish, which is exactly the wait this whole
|
|
/// change exists to remove.
|
|
fn start(&mut self, id: SessionId, abandon: Abandon) -> bool {
|
|
if self.started_for == Some(id) {
|
|
return false;
|
|
}
|
|
self.abandon.now();
|
|
self.started_for = Some(id);
|
|
self.abandon = abandon;
|
|
true
|
|
}
|
|
|
|
/// Give the slot up, if `id` still holds it.
|
|
///
|
|
/// Abandoning on the way out covers the discard case and costs nothing in
|
|
/// the success case, where the run it names has already finished.
|
|
fn finish(&mut self, id: SessionId) {
|
|
if self.started_for == Some(id) {
|
|
self.abandon.now();
|
|
self.started_for = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19c
|
|
/// One of [`crate::develop::MASK_COLOURS`] as the compositor draws it.
|
|
///
|
|
/// The table is linear sRGB because that is what the shader mixes in; the
|
|
/// swatch on the panel is display-encoded, so the conversion is here and not
|
|
/// in the table — a swatch that showed the linear values would be a darker,
|
|
/// duller colour than the one it promised.
|
|
fn mask_colour(index: usize) -> slint::Color {
|
|
let [r, g, b] = crate::develop::MASK_COLOURS[index % crate::develop::MASK_COLOURS.len()];
|
|
let encode = |v: f32| {
|
|
let v = v.clamp(0.0, 1.0);
|
|
if v <= 0.003_130_8 {
|
|
v * 12.92
|
|
} else {
|
|
1.055 * v.powf(1.0 / 2.4) - 0.055
|
|
}
|
|
};
|
|
slint::Color::from_rgb_f32(encode(r), encode(g), encode(b))
|
|
}
|
|
|
|
/// A descriptor name as a list label.
|
|
///
|
|
/// Only the first letter, because the names in `models/scene/categories.txt`
|
|
/// are already the words a photographer would use — "sky", "vegetation" — and
|
|
/// a lookup table would be a second place to edit every time one is added.
|
|
/// Title case on a multi-word name would be wrong anyway: "Swimming pool", not
|
|
/// "Swimming Pool".
|
|
fn category_label(name: &str) -> String {
|
|
let mut chars = name.chars();
|
|
match chars.next() {
|
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Push every mask-related property from the session into the window.
|
|
pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
|
let slot = session.borrow();
|
|
let masking = window.global::<Masking>();
|
|
let Some(s) = slot.as_ref() else {
|
|
masking.set_masks(ModelRc::new(VecModel::<MaskRow>::default()));
|
|
masking.set_subjects(ModelRc::new(VecModel::<SubjectRow>::default()));
|
|
masking.set_categories(ModelRc::new(VecModel::<CategoryRow>::default()));
|
|
masking.set_segmented(false);
|
|
window
|
|
.global::<Adjustments>()
|
|
.set_scope(GLOBAL_SCOPE.into());
|
|
clear_handles(window);
|
|
window.set_overlay_on(false);
|
|
return;
|
|
};
|
|
|
|
let rows: Vec<MaskRow> = s
|
|
.mask_layers()
|
|
.into_iter()
|
|
.map(|(id, label, enabled, selected)| {
|
|
// Read once each: both answer with a tuple, and asking three
|
|
// times for three fields of one would walk the stack three times
|
|
// per row per sync.
|
|
let (band_lo, band_hi, band_softness) = s.mask_band(&id);
|
|
let (hue, hue_width) = s.mask_hue(&id);
|
|
MaskRow {
|
|
stale: s.mask_is_stale(&id),
|
|
adjusted: s.mask_is_adjusted(&id),
|
|
kind: s.mask_kind(&id).into(),
|
|
inverted: s.mask_inverted(&id),
|
|
opacity: s.mask_opacity(&id),
|
|
feather: s.mask_feather(&id),
|
|
falloff: s.mask_falloff(&id) as i32,
|
|
morphology: s.mask_morphology(&id) as i32,
|
|
morph_radius: s.mask_morph_radius(&id),
|
|
refine: s.mask_refine(&id),
|
|
refinable: s.mask_is_refinable(&id),
|
|
shapeable: s.mask_is_shapeable(&id),
|
|
ranged: s.mask_is_ranged(&id),
|
|
chromatic: s.mask_is_chromatic(&id),
|
|
band_lo,
|
|
band_hi,
|
|
band_softness,
|
|
hue,
|
|
hue_width,
|
|
shown: s.mask_shown(&id),
|
|
colour: mask_colour(s.mask_colour(&id)),
|
|
colour_index: s.mask_colour(&id) as i32,
|
|
id: id.into(),
|
|
label: label.into(),
|
|
enabled,
|
|
selected,
|
|
}
|
|
})
|
|
.collect();
|
|
masking.set_masks(ModelRc::new(VecModel::from(rows)));
|
|
|
|
let subjects: Vec<SubjectRow> = s
|
|
.detected_subjects()
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, (label, score))| SubjectRow {
|
|
index: i as i32,
|
|
label: label.into(),
|
|
score,
|
|
})
|
|
.collect();
|
|
masking.set_subjects(ModelRc::new(VecModel::from(subjects)));
|
|
|
|
// Already largest-first from the precompute, and already filtered to the
|
|
// ones with enough coverage to be worth a control.
|
|
let categories: Vec<CategoryRow> = s
|
|
.categories()
|
|
.iter()
|
|
.map(|c| CategoryRow {
|
|
name: c.name.as_ref().into(),
|
|
label: category_label(&c.name).into(),
|
|
coverage: c.coverage,
|
|
})
|
|
.collect();
|
|
masking.set_categories(ModelRc::new(VecModel::from(categories)));
|
|
|
|
// TRACES: FR-DEV-19a
|
|
// Only the selected layer's parts, and only when exactly one is selected:
|
|
// a stroke has one target, and so does the edge the sliders shape.
|
|
let parts: Vec<PartRow> = match s.active_masks() {
|
|
[id] => s
|
|
.mask_parts(id)
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, (part_id, label, join, hidden))| PartRow {
|
|
id: part_id.into(),
|
|
label: label.into(),
|
|
join: join as i32,
|
|
selected: i == s.active_part(),
|
|
base: i == 0,
|
|
hidden,
|
|
})
|
|
.collect(),
|
|
_ => Vec::new(),
|
|
};
|
|
masking.set_parts(ModelRc::new(VecModel::from(parts)));
|
|
|
|
// TRACES: FR-DEV-19c
|
|
// Pushed rather than assumed, because the session is where it lives. The
|
|
// palette goes with it so the swatches and the shader read one table.
|
|
masking.set_mask_view_style(s.mask_view_style() as i32);
|
|
masking.set_mask_colours(ModelRc::new(VecModel::from(
|
|
(0..crate::develop::MASK_COLOURS.len())
|
|
.map(mask_colour)
|
|
.collect::<Vec<_>>(),
|
|
)));
|
|
|
|
let (radius, hardness, flow) = s.brush();
|
|
masking.set_brush_radius(radius);
|
|
masking.set_brush_hardness(hardness);
|
|
masking.set_brush_flow(flow);
|
|
|
|
masking.set_segmented(s.has_segmentation());
|
|
window
|
|
.global::<Adjustments>()
|
|
.set_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
|
|
// slider event would be a memcpy per frame for a picture that changes only
|
|
// when the level does.
|
|
//
|
|
// TRACES: FR-DEV-19c
|
|
// **Stood down while a mask is being shown.** The two overlays answer
|
|
// different questions — this one is what the model *detected*, the reveal
|
|
// is what a layer resolves to — and both at once is a false-coloured
|
|
// picture over a tinted one, through which neither can be read. The one
|
|
// describing the layer being worked on wins, because by the time a mask
|
|
// has been chosen the detections are what the photographer is choosing
|
|
// *between* rather than what they are looking at.
|
|
match s.overlay_image().filter(|_| !s.any_mask_shown()) {
|
|
Some(image) => {
|
|
window.set_region_overlay(image);
|
|
window.set_overlay_on(true);
|
|
}
|
|
None => window.set_overlay_on(false),
|
|
}
|
|
|
|
// Which part of it the view is showing. Pushed on every sync *and* on
|
|
// every redraw, because zooming and panning change this without changing
|
|
// anything else the panel shows.
|
|
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
|
|
/// changes no mask and no row, so nothing else in `sync` needs to run, and
|
|
/// rebuilding the row models on every frame of a drag would be wasteful.
|
|
pub(crate) fn sync_overlay_view(window: &AppWindow, session: &DevelopSession) {
|
|
let (x, y, w, h) = session.overlay_clip();
|
|
window.set_overlay_clip_x(x);
|
|
window.set_overlay_clip_y(y);
|
|
window.set_overlay_clip_w(w);
|
|
window.set_overlay_clip_h(h);
|
|
window.set_overlay_angle(session.angle());
|
|
}
|
|
|
|
/// Install the panel's callbacks.
|
|
pub(crate) fn wire(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
wire_segment(window, session, rows, redraw);
|
|
wire_mask_refine(window, session, rows, redraw);
|
|
wire_gradient_drag(window, session, redraw);
|
|
wire_region_select(window, session, rows, redraw);
|
|
wire_stack(window, session, rows, redraw);
|
|
wire_edge_treatment(window, session, redraw);
|
|
wire_layers_paint(window, session, rows, redraw);
|
|
wire_layers_parts(window, session, rows, redraw);
|
|
}
|
|
|
|
/// --- computing the region map -----------------------------------------
|
|
fn wire_segment(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
let running: Rc<RefCell<Running>> = Rc::default();
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
let running = running.clone();
|
|
window.global::<Masking>().on_segment(move |fine| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
// Both the photograph and the device come out of the session, so
|
|
// no session is nothing to look at and nothing to look with.
|
|
let Some(job) = session.borrow().as_ref().map(|s| s.segmentation_job()) else {
|
|
return;
|
|
};
|
|
let id = job.session();
|
|
|
|
let claimed = running.borrow_mut().start(id, job.abandon());
|
|
if !claimed {
|
|
return;
|
|
}
|
|
|
|
// Set before the thread rather than by it, so there is no moment
|
|
// in which the press has been taken and nothing on screen says so.
|
|
// The button reads "Looking…" and goes insensitive off this.
|
|
w.global::<Masking>().set_segmenting(true);
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
// The only thing the button decides. Everything else about the
|
|
// run is the same, which is what makes the second pass a genuine
|
|
// re-run of the first rather than a different feature.
|
|
let options = segmentation::Options {
|
|
fine,
|
|
..segmentation::Options::default()
|
|
};
|
|
std::thread::spawn(move || {
|
|
// A failed send means the window stopped waiting — the user
|
|
// moved on, or the app is closing. Neither is worth reporting:
|
|
// the answer was unwanted before it existed.
|
|
let _ = tx.send(job.run(&options));
|
|
});
|
|
|
|
watch(&w, id, rx, &session, &rows, &redraw, &running);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// --- refining one subject's mask ---------------------------------------
|
|
///
|
|
/// Its own `Running` slot rather than sharing the segmentation one: the
|
|
/// two answer different questions (the whole frame's subjects versus one
|
|
/// already-found instance) and there is no reason a refine in flight
|
|
/// should block a fresh "Find subjects", or the reverse.
|
|
fn wire_mask_refine(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
let refining: Rc<RefCell<Running>> = Rc::default();
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
let running = refining.clone();
|
|
window.global::<Masking>().on_mask_refined(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let Some(index) = session
|
|
.borrow()
|
|
.as_ref()
|
|
.and_then(|s| s.subject_instance_index(&id))
|
|
else {
|
|
return;
|
|
};
|
|
let Some(job) = session.borrow().as_ref().and_then(|s| s.refine_job(index)) else {
|
|
return;
|
|
};
|
|
let session_id = job.session();
|
|
|
|
let claimed = running.borrow_mut().start(session_id, job.abandon());
|
|
if !claimed {
|
|
return;
|
|
}
|
|
|
|
w.global::<Masking>().set_refining(true);
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let _ = tx.send(job.run());
|
|
});
|
|
|
|
watch_refine(&w, session_id, rx, &session, &rows, &redraw, &running);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// --- 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.
|
|
fn wire_gradient_drag(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
let dragging: Rc<RefCell<Option<dr_pipeline::mask::MaskSource>>> = Rc::new(RefCell::new(None));
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
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 };
|
|
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;
|
|
}
|
|
*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();
|
|
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.global::<Steps>()
|
|
.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
|
|
w.global::<Steps>()
|
|
.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// --- selecting on the photograph --------------------------------------
|
|
fn wire_region_select(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.on_region_picked(move |x, y, _add| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let picked = session
|
|
.borrow_mut()
|
|
.as_mut()
|
|
.and_then(|s| s.select_region_at(x, y));
|
|
if picked.is_none() {
|
|
// A click that hit no region is not an error and must not
|
|
// clear the selection: the most likely cause is the letterbox
|
|
// margin, and losing a selection to a near-miss is the kind of
|
|
// thing that makes a tool feel hostile.
|
|
return;
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// --- the stack ---------------------------------------------------------
|
|
fn wire_stack(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_selected(move |id, extend| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
{
|
|
let mut slot = session.borrow_mut();
|
|
let Some(s) = slot.as_mut() else { return };
|
|
if extend {
|
|
// Control/command-click: add or remove this one layer,
|
|
// keeping whatever else was already selected.
|
|
s.toggle_active_mask(&id);
|
|
} else if s.active_masks() == [id.to_string()] {
|
|
// Clicking the sole selected layer again deselects it,
|
|
// which is how the panel gets back to the whole
|
|
// photograph without a separate "edit globally" control.
|
|
// Only when it is the *only* one selected — a plain click
|
|
// on one row of a multi-selection collapses to just that
|
|
// row rather than clearing everything, which is the more
|
|
// useful reading of "I clicked a specific layer".
|
|
s.set_active_mask(None);
|
|
} else {
|
|
s.set_active_mask(Some(&id));
|
|
}
|
|
}
|
|
sync(&w, &session);
|
|
// The scope changed, so the adjust panel below is now describing a
|
|
// different chain.
|
|
sync_rows(&w, &rows, &session);
|
|
// TRACES: FR-DEV-19c
|
|
// And so has the picture, when a mask is being shown: the
|
|
// reveal follows the selection, so choosing another layer
|
|
// draws another mask.
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.global::<Masking>().on_mask_removed(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.remove_mask(&id);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window.global::<Masking>().on_mask_toggled(move |id, on| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_enabled(&id, on);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
// TRACES: FR-DEV-16
|
|
// The keyboard's copy of the ring. A mixed selection goes to shown:
|
|
// a layer nobody can see is the one being asked about, and turning
|
|
// the rest off to match it would hide work in the name of hiding it.
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window.global::<Masking>().on_selected_toggled(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
{
|
|
let mut slot = session.borrow_mut();
|
|
let Some(s) = slot.as_mut() else { return };
|
|
let ids: Vec<String> = s.active_masks().to_vec();
|
|
if ids.is_empty() {
|
|
return;
|
|
}
|
|
let all_shown = ids
|
|
.iter()
|
|
.all(|id| s.masks().get(id).is_some_and(|l| l.enabled));
|
|
for id in &ids {
|
|
s.set_mask_enabled(id, !all_shown);
|
|
}
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_invert_toggled(move |id, on| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_invert(&id, on);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_opacity_changed(move |id, value| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_opacity(&id, value);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// --- the edge treatment -------------------------------------------------
|
|
///
|
|
/// All four read one distance field, so all four are live: nothing here
|
|
/// rebuilds anything except a compound morphology, which `develop` keys on
|
|
/// separately.
|
|
fn wire_edge_treatment(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_feather_changed(move |id, value| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_feather(&id, value);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
// TRACES: FR-DEV-10
|
|
// A range mask's band, and a colour range's arc.
|
|
//
|
|
// Two handlers rather than five, because a band is one control: moving its
|
|
// lower bound past its upper swaps them, and a callback per field would
|
|
// have to answer that with a third of the answer. Each carries the whole
|
|
// of the thing it changes and the model puts it back in order.
|
|
//
|
|
// Cheap, unlike the refine below: a band is three floats in a uniform, so
|
|
// the mask redraws in the rasterisation the redraw was going to run
|
|
// anyway and nothing is rebuilt on the CPU at all.
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_band_changed(move |id, lo, hi, softness| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_band(&id, lo, hi, softness);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_hue_changed(move |id, hue, width| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_hue(&id, hue, width);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
// The refine control. Same shape as the feather's handler, and a separate
|
|
// one for the same reason every other mask control has its own: the
|
|
// callback carries the layer id, so there is nothing to share.
|
|
//
|
|
// `SliderRow::changed` fires once per completed gesture rather than once
|
|
// per movement, which is what this control needs — it rebuilds the
|
|
// layer's distance field, where a feather only re-reads one.
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_refine_changed(move |id, value| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_refine(&id, value);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_falloff_picked(move |id, index| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_falloff(&id, index.max(0) as usize);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_morphology_picked(move |id, index| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_morphology(&id, index.max(0) as usize);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_morph_radius_changed(move |id, value| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_morph_radius(&id, value);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// --- adding layers ------------------------------------------------------
|
|
/// TRACES: FR-DEV-19b
|
|
/// Painting. Three handlers for one gesture, and the middle one runs per
|
|
/// pointer report — so it does the least it can: map the point, record it,
|
|
/// redraw. No sync, because nothing the panel shows changes while a finger
|
|
/// is down, and rebuilding eight rows a frame is how a brush starts to lag.
|
|
fn wire_layers_paint(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window.on_paint_began(move |x, y| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let erase = w.global::<Masking>().get_tool() == 2;
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.begin_mask_stroke(x, y, erase);
|
|
}
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window.on_paint_moved(move |x, y| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.extend_mask_stroke(x, y);
|
|
}
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.on_paint_ended(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.end_mask_stroke();
|
|
}
|
|
// Now the panel is worth rebuilding: the gesture may have joined a
|
|
// part to the mask, and the history has a step in it.
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_brush_changed(move |radius, hardness, flow| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_brush(radius, hardness, flow);
|
|
}
|
|
sync(&w, &session);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window.global::<Masking>().on_tool_picked(move |tool| {
|
|
// **The tool is written back here, and that is the whole of this
|
|
// handler.** `Masking.tool` is an `in` property: the panel reads
|
|
// it to light the right chip and `app.slint` reads it to decide
|
|
// whether a drag on the photograph paints, but only Rust may write
|
|
// it. So a version of this that recorded nothing left the strip
|
|
// reporting "Select" however many times "Paint" was pressed, and
|
|
// the paint area was never armed — the brush, the parts, the whole
|
|
// of FR-DEV-19b, reachable from no control in the application.
|
|
//
|
|
// It still changes no pixel, which is what the previous note was
|
|
// getting at: the tool arms a gesture and belongs to the
|
|
// interface, not to the edit, so it takes no history step and is
|
|
// not stored.
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let tool = tool.clamp(0, 2);
|
|
w.global::<Masking>().set_tool(tool);
|
|
|
|
// TRACES: FR-DEV-19c
|
|
// Arming a brush shows the mask, if nothing was showing it. The
|
|
// same nudge `on_part_added` makes and on the same argument: a
|
|
// photographer about to correct an edge by hand needs to see the
|
|
// edge, and "the tool did nothing" is what a stroke into an
|
|
// invisible mask looks like.
|
|
//
|
|
// A nudge on an explicit action, never a standing rule. Turning
|
|
// the view off and then picking the eraser leaves it off — the
|
|
// trap `overlay-hidden` documents is that an automatic reveal
|
|
// which re-arms a switch somebody turned off is worse than no
|
|
// automatic reveal at all.
|
|
if tool > 0 {
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
if let Some(id) = s.active_mask().map(str::to_owned) {
|
|
if !s.mask_shown(&id) {
|
|
s.set_mask_shown(&id, true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-synced because arming the brush is what makes the parts of a
|
|
// mask worth showing.
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
// TRACES: FR-DEV-19c
|
|
// Three handlers, one shape: change how the masks are looked at, then a
|
|
// redraw and not only a sync, because the reveal is in the composed
|
|
// shader and what changed is the picture rather than the panel.
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_view_style_picked(move |style| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_view_style(style.max(0) as usize);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_shown_toggled(move |id, shown| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_shown(&id, shown);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_mask_colour_picked(move |id, colour| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_colour(&id, colour.max(0) as usize);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Parts of the active mask, and the buttons that add a new layer —
|
|
/// gradient, brush, range, category or subject.
|
|
fn wire_layers_parts(
|
|
window: &AppWindow,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
) {
|
|
// TRACES: FR-DEV-19a
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_part_selected(move |_id, index| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_active_part(index.max(0) as usize);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.global::<Masking>().on_part_added(move |id, join| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.add_mask_part(&id, join.max(0) as usize);
|
|
}
|
|
// Armed, because adding an empty painted part and leaving the
|
|
// pointer in "select" would look like the button did nothing —
|
|
// the part covers no pixels until something is painted into it.
|
|
if w.global::<Masking>().get_tool() == 0 {
|
|
w.global::<Masking>().set_tool(1);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_part_removed(move |id, index| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.remove_mask_part(&id, index.max(0) as usize);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_part_join_picked(move |id, index, join| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_part_join(&id, index.max(0) as usize, join.max(0) as usize);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
// TRACES: FR-DEV-19a
|
|
// A part left out of the build. The picture changes and the row's
|
|
// eye does, so both are redrawn; the adjust rows are not, because
|
|
// the layer's chain is not what moved.
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window
|
|
.global::<Masking>()
|
|
.on_part_hidden_toggled(move |id, index, hidden| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.set_mask_part_hidden(&id, index.max(0) as usize, hidden);
|
|
}
|
|
sync(&w, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.global::<Masking>().on_add_gradient(move |radial| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.add_gradient_mask(radial);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
// TRACES: FR-DEV-19b
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.global::<Masking>().on_add_brush(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let made = session
|
|
.borrow_mut()
|
|
.as_mut()
|
|
.and_then(|s| s.add_brush_mask())
|
|
.is_some();
|
|
// Armed, and this is not a convenience: the layer covers no pixel
|
|
// until something is painted into it, so a press that made a row
|
|
// and left the pointer in "select" would be indistinguishable from
|
|
// a button that did nothing. The same reasoning as `on_part_added`
|
|
// below. Showing the mask is `DevelopSession::show_new_mask`'s
|
|
// job and every route to a new layer goes through it, so only the
|
|
// tool is set here.
|
|
if made {
|
|
w.global::<Masking>().set_tool(1);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
// TRACES: FR-DEV-10
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.global::<Masking>().on_add_range(move |chromatic| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.add_range_mask(chromatic);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.global::<Masking>().on_add_category(move |name| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.add_category_mask(&name);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.global::<Masking>().on_add_subject(move |index| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.add_subject_mask(index.max(0) as usize);
|
|
}
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Wait for a segmentation without the window waiting with it.
|
|
///
|
|
/// A repeating timer rather than a callback from the worker, because Slint
|
|
/// properties may only be touched from the thread that owns the event loop and
|
|
/// this is the shape `apply_when_ready` already uses for the sidecar fetch.
|
|
///
|
|
/// Two things end the wait, and only one of them is the answer arriving. The
|
|
/// other is the photograph changing underneath: that is checked *first*, on
|
|
/// every tick, so the abandonment reaches the worker while it may still be in
|
|
/// the proxy — and so the next photograph's "Find subjects" is available
|
|
/// within a tick rather than at the end of a run nobody wants.
|
|
fn watch(
|
|
window: &AppWindow,
|
|
id: SessionId,
|
|
rx: std::sync::mpsc::Receiver<Result<Option<Segmented>, String>>,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
running: &Rc<RefCell<Running>>,
|
|
) {
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let rows = rows.clone();
|
|
let redraw = redraw.clone();
|
|
let slot = running.clone();
|
|
|
|
let timer = Rc::new(slint::Timer::default());
|
|
let stop = Rc::downgrade(&timer);
|
|
timer.start(slint::TimerMode::Repeated, POLL, move || {
|
|
let done = || {
|
|
if let Some(t) = stop.upgrade() {
|
|
t.stop();
|
|
}
|
|
};
|
|
|
|
if delivery(id, open_session(&session)) == Delivery::Discard {
|
|
// Nothing is touched on the way out. `segmenting` belongs to the
|
|
// photograph now open — which may well have a run of its own going
|
|
// — and the only route to here is through `reset`, which cleared
|
|
// it for that image already.
|
|
slot.borrow_mut().finish(id);
|
|
done();
|
|
return;
|
|
}
|
|
|
|
let Ok(answer) = rx.try_recv() else { return };
|
|
slot.borrow_mut().finish(id);
|
|
done();
|
|
|
|
let Some(w) = weak.upgrade() else { return };
|
|
match answer {
|
|
Ok(Some(found)) => {
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.adopt_segmentation(found);
|
|
}
|
|
}
|
|
// Abandoned. Unreachable from here in practice — the check above
|
|
// catches every case that raises the flag — and handled rather
|
|
// than asserted, because the cost of being wrong is one wasted
|
|
// sync against a panic in a photographer's hands.
|
|
Ok(None) => {}
|
|
Err(e) => log::warn!("segmentation failed: {e}"),
|
|
}
|
|
w.global::<Masking>().set_segmenting(false);
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
running.borrow_mut().poll = Some(timer);
|
|
}
|
|
|
|
/// [`watch`]'s counterpart for a refine pass — same shape, same reason: the
|
|
/// window polls rather than being called back from the worker, and a
|
|
/// photograph the user has left discards its answer instead of applying it.
|
|
fn watch_refine(
|
|
window: &AppWindow,
|
|
id: SessionId,
|
|
rx: std::sync::mpsc::Receiver<Result<Option<RefinedInstance>, String>>,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<VecModel<ParamRow>>,
|
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
|
running: &Rc<RefCell<Running>>,
|
|
) {
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let rows = rows.clone();
|
|
let redraw = redraw.clone();
|
|
let slot = running.clone();
|
|
|
|
let timer = Rc::new(slint::Timer::default());
|
|
let stop = Rc::downgrade(&timer);
|
|
timer.start(slint::TimerMode::Repeated, POLL, move || {
|
|
let done = || {
|
|
if let Some(t) = stop.upgrade() {
|
|
t.stop();
|
|
}
|
|
};
|
|
|
|
if delivery(id, open_session(&session)) == Delivery::Discard {
|
|
slot.borrow_mut().finish(id);
|
|
done();
|
|
return;
|
|
}
|
|
|
|
let Ok(answer) = rx.try_recv() else { return };
|
|
slot.borrow_mut().finish(id);
|
|
done();
|
|
|
|
let Some(w) = weak.upgrade() else { return };
|
|
match answer {
|
|
Ok(Some(refined)) => {
|
|
if let Some(s) = session.borrow_mut().as_mut() {
|
|
s.adopt_refined(refined);
|
|
}
|
|
}
|
|
// Abandoned, or the model found nothing of the same class in the
|
|
// padded crop — both leave the instance exactly as it was.
|
|
Ok(None) => {}
|
|
Err(e) => log::warn!("refine failed: {e}"),
|
|
}
|
|
w.global::<Masking>().set_refining(false);
|
|
sync(&w, &session);
|
|
sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
});
|
|
running.borrow_mut().poll = Some(timer);
|
|
}
|
|
|
|
/// 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: 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_overlay_on(false);
|
|
let masking = window.global::<Masking>();
|
|
masking.set_segmenting(false);
|
|
masking.set_refining(false);
|
|
masking.set_segmented(false);
|
|
// TRACES: FR-DEV-19b | FR-DEV-19c
|
|
// The tool and the mask view are both about one selected layer, and the
|
|
// next photograph has none. Left standing, they would arm a brush over a
|
|
// photograph with nothing to paint into and claim a mask was being shown.
|
|
masking.set_tool(0);
|
|
masking.set_mask_view_style(0);
|
|
masking.set_masks(ModelRc::new(VecModel::<MaskRow>::default()));
|
|
masking.set_subjects(ModelRc::new(VecModel::<SubjectRow>::default()));
|
|
masking.set_categories(ModelRc::new(VecModel::<CategoryRow>::default()));
|
|
window
|
|
.global::<Adjustments>()
|
|
.set_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-19b
|
|
/// Pressing Paint on a mask the model made must not answer with a
|
|
/// refusal about a subject not being a brush. The correction the
|
|
/// photographer is about to make *is* a new part, so it is made.
|
|
#[test]
|
|
fn painting_on_a_mask_that_cannot_hold_a_stroke_joins_a_part_that_can() {
|
|
let Some(mut s) = session() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let id = s.add_gradient_mask(true).expect("radial");
|
|
s.set_active_mask(Some(&id));
|
|
|
|
assert_eq!(s.mask_parts(&id).len(), 1, "one selection to begin with");
|
|
assert!(s.begin_mask_stroke(0.5, 0.5, false), "the stroke started");
|
|
s.extend_mask_stroke(0.55, 0.5);
|
|
s.end_mask_stroke();
|
|
|
|
let parts = s.mask_parts(&id);
|
|
assert_eq!(parts.len(), 2, "a painted part was joined");
|
|
assert_eq!(parts[1].1, "brush");
|
|
assert_eq!(s.active_part(), 1, "and the tools point at it");
|
|
}
|
|
|
|
/// A stroke is one decision. Recording per pointer report would make
|
|
/// taking a mark back cost as many presses as making it did.
|
|
#[test]
|
|
fn a_whole_stroke_is_one_step_in_the_history() {
|
|
let Some(mut s) = session() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let id = s.add_gradient_mask(true).expect("radial");
|
|
s.set_active_mask(Some(&id));
|
|
let before = s.history_rows().len();
|
|
|
|
assert!(s.begin_mask_stroke(0.4, 0.4, false));
|
|
for i in 0..12 {
|
|
s.extend_mask_stroke(0.4 + i as f32 * 0.02, 0.5);
|
|
}
|
|
s.end_mask_stroke();
|
|
|
|
// Two: joining the part is its own step, and the stroke is the other.
|
|
assert_eq!(
|
|
s.history_rows().len(),
|
|
before + 2,
|
|
"a stroke recorded more than one step"
|
|
);
|
|
}
|
|
|
|
/// A gesture the window cancels leaves nothing behind — including in the
|
|
/// history, because as far as the photographer is concerned nothing
|
|
/// happened.
|
|
#[test]
|
|
fn a_cancelled_stroke_leaves_no_mark() {
|
|
let Some(mut s) = session() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let id = s.add_gradient_mask(true).expect("radial");
|
|
s.set_active_mask(Some(&id));
|
|
assert!(s.begin_mask_stroke(0.4, 0.4, false));
|
|
s.extend_mask_stroke(0.5, 0.5);
|
|
let steps = s.history_rows().len();
|
|
s.cancel_mask_stroke();
|
|
|
|
assert_eq!(s.history_rows().len(), steps, "nothing was recorded");
|
|
assert!(
|
|
s.masks()
|
|
.get(&id)
|
|
.expect("layer")
|
|
.part(1)
|
|
.expect("part")
|
|
.strokes()
|
|
.is_empty(),
|
|
"and the abandoned stroke is not in the mask"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19a
|
|
/// The edge controls point at the part that is selected, so that dragging
|
|
/// the feather while a correction is selected does not silently soften the
|
|
/// model's mask underneath it.
|
|
#[test]
|
|
fn the_edge_controls_shape_the_selected_part() {
|
|
let Some(mut s) = session() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let id = s.add_gradient_mask(true).expect("radial");
|
|
s.set_active_mask(Some(&id));
|
|
s.set_mask_feather(&id, 0.03);
|
|
|
|
let index = s.add_mask_part(&id, 0).expect("a part was joined");
|
|
assert_eq!(s.active_part(), index);
|
|
s.set_mask_feather(&id, 0.0);
|
|
|
|
assert_eq!(s.mask_feather(&id), 0.0, "the selected part was shaped");
|
|
s.set_active_part(0);
|
|
assert_eq!(s.mask_feather(&id), 0.03, "and the base was left alone");
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19a
|
|
/// The panel's third join reaches the layer: the "∩ Intersect" button
|
|
/// joins an intersecting part, the part row reports it as the chip's third
|
|
/// state, and cycling the chip on from there comes back round to a union.
|
|
#[test]
|
|
fn the_intersect_button_joins_a_part_that_intersects() {
|
|
use dr_pipeline::mask::Join;
|
|
let Some(mut s) = session() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let id = s.add_gradient_mask(true).expect("radial");
|
|
s.set_active_mask(Some(&id));
|
|
|
|
let index = s.add_mask_part(&id, 2).expect("a part was joined");
|
|
let join = |s: &DevelopSession| {
|
|
s.masks()
|
|
.get(&id)
|
|
.expect("layer")
|
|
.part(index)
|
|
.expect("part")
|
|
.join
|
|
};
|
|
assert_eq!(join(&s), Join::Intersect);
|
|
assert_eq!(s.mask_parts(&id)[index].2, 2, "the chip shows ∩");
|
|
|
|
s.set_mask_part_join(&id, index, (2 + 1) % 3);
|
|
assert_eq!(join(&s), Join::Union, "and the chip cycles back to +");
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
|
|
/// The ordinary case: the answer comes back to the photograph that asked.
|
|
#[test]
|
|
fn a_result_for_the_open_photograph_is_applied() {
|
|
let open = SessionId::next();
|
|
assert_eq!(delivery(open, Some(open)), Delivery::Apply);
|
|
}
|
|
|
|
/// The bug this rule exists for. Two thirds of a second is long enough to
|
|
/// press "Find subjects", think better of it and swipe to the next frame —
|
|
/// and the result that lands then describes a picture nobody is looking at.
|
|
#[test]
|
|
fn a_result_for_a_photograph_the_user_has_left_is_discarded() {
|
|
let asked = SessionId::next();
|
|
let now_open = SessionId::next();
|
|
assert_eq!(delivery(asked, Some(now_open)), Delivery::Discard);
|
|
}
|
|
|
|
/// Back to the library, or a frame that failed to decode: there is no
|
|
/// session to apply anything to.
|
|
#[test]
|
|
fn a_result_arriving_with_nothing_open_is_discarded() {
|
|
assert_eq!(delivery(SessionId::next(), None), Delivery::Discard);
|
|
}
|
|
|
|
/// Re-opening the same file is a new session, so a result outstanding from
|
|
/// the previous visit does not land in it. Conservative on purpose: the
|
|
/// alternative is keying on the path, and the second visit's panel would
|
|
/// then be filled from a proxy rendered before the first visit's edits.
|
|
#[test]
|
|
fn re_opening_the_same_file_does_not_inherit_a_result() {
|
|
let first_visit = SessionId::next();
|
|
let second_visit = SessionId::next();
|
|
assert_ne!(first_visit, second_visit);
|
|
assert_eq!(delivery(first_visit, Some(second_visit)), Delivery::Discard);
|
|
}
|
|
|
|
/// Pressing the button twice must not put two model runs on two cores for
|
|
/// one answer.
|
|
#[test]
|
|
fn one_photograph_cannot_start_two_segmentations() {
|
|
let mut running = Running::default();
|
|
let id = SessionId::next();
|
|
assert!(running.start(id, Abandon::default()));
|
|
assert!(!running.start(id, Abandon::default()), "already looking");
|
|
}
|
|
|
|
/// And the other half of that rule: a run left over from a photograph the
|
|
/// user has left must not hold the slot against the one now on screen.
|
|
#[test]
|
|
fn a_new_photograph_displaces_a_run_nobody_is_waiting_for() {
|
|
let mut running = Running::default();
|
|
let stale = Abandon::default();
|
|
assert!(running.start(SessionId::next(), stale.clone()));
|
|
|
|
assert!(running.start(SessionId::next(), Abandon::default()));
|
|
assert!(
|
|
stale.asked(),
|
|
"the displaced run is told its answer is unwanted"
|
|
);
|
|
}
|
|
|
|
/// A run that finishes releases the slot, so the same photograph can be
|
|
/// segmented again — after a failed attempt worth retrying, say.
|
|
#[test]
|
|
fn finishing_frees_the_slot() {
|
|
let mut running = Running::default();
|
|
let id = SessionId::next();
|
|
assert!(running.start(id, Abandon::default()));
|
|
running.finish(id);
|
|
assert!(running.start(id, Abandon::default()));
|
|
}
|
|
|
|
/// A timer left over from an earlier job reports in after the slot has
|
|
/// moved on. It must not cancel the run that now holds it, or the user
|
|
/// would be able to start a third while the second is still going.
|
|
#[test]
|
|
fn a_late_finish_does_not_release_someone_elses_slot() {
|
|
let mut running = Running::default();
|
|
let departed = SessionId::next();
|
|
assert!(running.start(departed, Abandon::default()));
|
|
|
|
let now_open = SessionId::next();
|
|
let live = Abandon::default();
|
|
assert!(running.start(now_open, live.clone()));
|
|
|
|
running.finish(departed);
|
|
assert!(!live.asked(), "the live run is left alone");
|
|
assert!(
|
|
!running.start(now_open, Abandon::default()),
|
|
"and it still holds the slot"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19b | FR-DEV-19c
|
|
/// The order the canvas handlers are declared in, which decides which of them
|
|
/// receives a press.
|
|
///
|
|
/// Not a unit test of anything this file does, and here rather than nowhere
|
|
/// because there is nowhere better: it guards a fault that compiles, passes
|
|
/// every other test, and takes four separate tools out of the application at
|
|
/// once.
|
|
///
|
|
/// Slint hit-tests siblings front-to-back and a `TouchArea` grabs the first
|
|
/// press it is offered, so **the last handler declared is the one that wins**.
|
|
/// The full-canvas pan/zoom area must therefore come *first*, and `app.slint`
|
|
/// had it last — with each of the four handlers behind it carrying a comment
|
|
/// claiming it sat "above the pan/zoom area" because it was written earlier in
|
|
/// the file.
|
|
#[cfg(test)]
|
|
mod canvas_order {
|
|
/// Where each canvas handler is declared, by byte offset.
|
|
fn at(needle: &str) -> usize {
|
|
let source = include_str!("../ui/app.slint");
|
|
source
|
|
.find(needle)
|
|
.unwrap_or_else(|| panic!("app.slint no longer contains `{needle}`"))
|
|
}
|
|
|
|
#[test]
|
|
fn the_pan_backstop_is_declared_before_every_tool_it_would_swallow() {
|
|
let pan = at("--- the pan/zoom backstop ---");
|
|
for tool in [
|
|
"pick := TouchArea",
|
|
"paint := TouchArea",
|
|
"Develop.repairing && root.total > 0",
|
|
"root.sampling && root.total > 0",
|
|
] {
|
|
assert!(
|
|
pan < at(tool),
|
|
"`{tool}` is declared before the pan/zoom area, so the pan area \
|
|
is in front of it and will take every press it was meant to get"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The other end of the same rule: a handle drawn on the photograph has to
|
|
/// beat the tool armed over it, or a gradient cannot be moved while one is.
|
|
#[test]
|
|
fn the_gradient_handles_are_declared_last() {
|
|
let handles = at("GradientHandles {");
|
|
for behind in ["pick := TouchArea", "paint := TouchArea"] {
|
|
assert!(
|
|
handles > at(behind),
|
|
"`{behind}` is declared after GradientHandles and would swallow \
|
|
a press meant for a handle"
|
|
);
|
|
}
|
|
}
|
|
}
|