Files
DarkRoom/ui/dr-ui/src/masks_ui.rs
T
dtourolleandClaude Sonnet 5 b3641b5307 Let a subject's mask be refined, and let several masks be edited at once
Two related gaps in the mask panel, from the same conversation: a subject's
outline is only ever as sharp as the whole-frame pass that found it, and a
change meant for several layers had to be dragged once per layer.

## Refine mask

A "Refine mask" button on a subject layer re-runs detection on a padded crop
around that instance's own box instead of the whole frame — the subject
reaches the model at its own size rather than squeezed into the model's fixed
640x640 window alongside everything else in the photograph. `RefineJob`
mirrors `SegmentationJob`'s split (built on the session, run off it, adopted
back), and the crop itself is rendered through `Framing::set_view` — the same
ephemeral viewport the interactive zoom already uses to render a region above
proxy resolution, so no new render path and no change to the model's own
input size was needed. `dr-segment` is untouched: `Tiling::Whole` already
treats whatever buffer it is handed as the one window.

The result is still downsampled onto the shared proxy grid every instance's
mask lives on, but from a sharper source than the whole-frame pass ever saw
for that subject, which is what the edge actually reads out of.

## Multi-select

`active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain
click still replaces the selection; a control- or command-click toggles one
layer in or out of it. `set_param` and `reset_op` fan out to every selected
layer, each set to the exact value the slider now shows rather than offset by
however far it already was — one slider, one reading, applied everywhere
selected. Dragging a gradient's on-canvas handle is deliberately not
extended to multi-select: several gradients have no single geometry a shared
handle could move, so `gradient_handles` stays empty unless exactly one
layer is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:17:07 +02:00

1056 lines
41 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, 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;
}
}
}
/// 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_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)| 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),
shapeable: s.mask_is_shapeable(&id),
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)));
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);
});
}
{
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);
});
}
{
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_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"
);
}
}