Every local adjustment began from a shape: painted, drawn with a handle, or found by a model. So the only way to hold back a sky was to draw a line near where it ended, and the only way to warm skin was to paint round it — both of which put the edit's edge where the photographer put a gesture rather than where the picture changes. A gradient across a treeline halos, and an adjustment traced round a face stops on the outline of a hand. MaskSource grows two variants that select by what a pixel *is*. Luminance carries two bounds on the perceptual tone scale plus a softness; Colour carries an arc of hue, a range of chroma, and one softness for every edge of both. Five floats and three, so they diff, sync and merge per field under FR-NC-9 exactly as a gradient's geometry does — the property a stored raster has none of, and the reason the model's coverage had to sit beside its source rather than inside it. The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the demosaiced source as a sixth binding and two new modes read it: decode, balance, pull a clipped photosite back to neutral, apply the camera matrix, then weigh the band. Nothing crosses to the CPU but the numbers and the matrix, and each mask texel averages its own footprint in the source, so a band lands on the tone an area is rather than on whichever texel a proxy grid happened to land on. The photograph it measures is the one the camera recorded, before this edit. A band over the edited result would slide out from under the edit as the edit was made — raising the highlights would change which pixels counted as highlights, and the slider would chase its own mask. Feather, falloff and morphology stay off a range layer, which is what `shapeable` already meant. All three are functions of the signed distance from a boundary, and a range has no boundary to be at a distance from; its edge is the softness of its own band, in the band's units. Offering them would be four controls that move and change nothing.
1191 lines
46 KiB
Rust
1191 lines
46 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, AppWindow, CategoryRow, 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 {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 Some(s) = slot.as_ref() else {
|
|
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
|
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
|
window.set_category_rows(ModelRc::new(VecModel::<CategoryRow>::default()));
|
|
window.set_segmented(false);
|
|
window.set_adjust_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,
|
|
id: id.into(),
|
|
label: label.into(),
|
|
enabled,
|
|
selected,
|
|
}
|
|
})
|
|
.collect();
|
|
window.set_mask_rows(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();
|
|
window.set_subject_rows(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();
|
|
window.set_category_rows(ModelRc::new(VecModel::from(categories)));
|
|
|
|
window.set_segmented(s.has_segmentation());
|
|
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
|
|
// slider event would be a memcpy per frame for a picture that changes only
|
|
// when the level does.
|
|
match s.overlay_image() {
|
|
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)>,
|
|
) {
|
|
let running: Rc<RefCell<Running>> = Rc::default();
|
|
|
|
// --- computing the region map -----------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
let running = running.clone();
|
|
window.on_segment_image(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.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.
|
|
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.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.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.
|
|
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.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()));
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- selecting on the photograph --------------------------------------
|
|
{
|
|
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 ---------------------------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let rows = rows.clone();
|
|
window.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);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.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.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);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window.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.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.
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
window.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.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.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.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.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.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.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 ------------------------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.on_add_gradient_mask(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-10
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
let redraw = redraw.clone();
|
|
let rows = rows.clone();
|
|
window.on_add_range_mask(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.on_add_category_mask(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.on_add_subject_mask(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.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.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);
|
|
window.set_segmenting(false);
|
|
window.set_refining(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_category_rows(ModelRc::new(VecModel::<CategoryRow>::default()));
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
}
|