Files
DarkRoom/ui/dr-ui/src/spots_ui.rs
T
dtourolleandClaude Opus 5 8ab9440190 Put the repair tool on the photograph
A third chip beside Crop and Local, and the mode strip's own comment
predicted the shape: a mode that arms a gesture on the canvas and scopes
the column. Click a mark to cover it, drag the disc to move the repair,
drag the source circle to say where the patch comes from, Delete to remove
it. The source starts two and a half radii towards the middle of the
frame, which is FR-DEV-8's automatic placement in its cheap form — dust
sits on skies and skies are smooth, so it is usually right and always one
drag from fixed.

Two things are drawn deliberately. The circles are the size the repairs
actually are, because whether a disc covers a speck is the whole judgement
being made and a fixed-size dot would say nothing about it; the reach
around them is padded to a touch target so a spot on a dust mark can still
be picked up on a phone. And only the selected repair shows its source: a
dusty sky carries a dozen, and two dozen circles with nothing saying which
belongs to which is less information rather than more.

The panel edits what is stored while the canvas draws what is mapped, and
the two are pushed separately for that reason — a slider deriving its
value from the drawn radius would move differently at different zoom
levels. It is also the one panel built from SliderRow rather than a live
track: a repair has no OpId to coalesce a drag under, so a row that fires
once per gesture is what keeps undo one step per decision.

Verified as far as this environment allows: the strip renders and the
column re-scopes, photographed under XWayland. Synthetic clicks do not
reach this application, so the gestures are as-written rather than
as-felt, and docs/spot-removal.md says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:58:13 +02:00

293 lines
11 KiB
Rust

//! TRACES: FR-DEV-8
//! Wiring the repair tool to the develop session.
//!
//! Translation only, exactly as [`crate::masks_ui`] is: circles out in one
//! direction, gestures in the other, and every decision in
//! [`crate::develop::DevelopSession`]. What this module does decide is when the
//! canvas is redrawn and when the panel is rebuilt, and those are the two
//! things that go wrong quietly here — see [`sync_handles`] for the one that
//! took a gesture out from under the finger.
use std::cell::RefCell;
use std::rc::Rc;
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
use crate::develop::DevelopSession;
use crate::{AppWindow, SpotHandle};
/// TRACES: FR-DEV-8 | FR-UI-3
/// Move the circles to where the repairs now are.
///
/// # Why this is not `set_spot_handles(VecModel::from(…))`
///
/// **A fresh model kills the gesture that is moving them.** The circles are a
/// repeater over this model, and handing Slint a new `ModelRc` makes it throw
/// the repeated items away and build new ones — the `TouchArea` holding the
/// pointer included. The drag then dies under the finger with the button still
/// down. `masks_ui::sync_handles` carries the same warning, and `develop.rs`
/// carries it about the parameter rows, where it broke slider drags: this is
/// the third place the same mistake is available, which is why it is written
/// down in all three.
///
/// So the model is kept and its rows are rewritten in place.
pub(crate) fn sync_handles(window: &AppWindow, session: &DevelopSession) {
let next = session.spot_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.
if model.row_data(i).as_ref() != Some(&handle) {
model.set_row_data(i, handle);
}
} else {
model.push(handle);
}
}
window.set_spot_handles(ModelRc::from(model));
window.set_selected_spot(session.selected_spot_id().unwrap_or_default().into());
}
/// The circles' model, held for the life of the process — one shared identity,
/// for the reason [`sync_handles`] gives.
fn handle_model() -> Rc<VecModel<SpotHandle>> {
thread_local! {
static HANDLES: Rc<VecModel<SpotHandle>> = Rc::new(VecModel::default());
}
HANDLES.with(Clone::clone)
}
/// Take the repairs off the canvas between photographs.
///
/// Emptied rather than replaced, keeping the model's identity for the reason
/// [`sync_handles`] gives.
pub(crate) fn reset(window: &AppWindow) {
let model = handle_model();
while model.row_count() > 0 {
model.remove(model.row_count() - 1);
}
window.set_spot_handles(ModelRc::from(model));
window.set_selected_spot(Default::default());
window.set_spot_count(0);
}
/// Install the repair tool's callbacks.
pub(crate) fn wire(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
redraw: Rc<dyn Fn(&AppWindow)>,
) {
// --- placing ----------------------------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_spot_placed(move |x, y| {
let Some(w) = weak.upgrade() else { return };
let placed = session
.borrow_mut()
.as_mut()
.and_then(|s| s.place_spot(x, y));
if placed.is_none() {
// The letterbox margin, or a full set. Neither is an error and
// neither should clear the selection: a near-miss that threw
// away what the column was describing would read as hostile.
return;
}
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
sync_panel(&w, s);
}
sync_undo(&w, &session);
redraw(&w);
});
}
// --- dragging ---------------------------------------------------------
//
// The repair as it stood when the press landed, held for the gesture. A
// drag is applied to *that* rather than accumulated frame by frame: the
// clamps would otherwise compound, so a source dragged past its limit and
// back would not return to where it started, and the result would depend on
// how many pointer events the platform happened to deliver.
let dragging: Rc<RefCell<Option<dr_pipeline::Spot>>> = Rc::new(RefCell::new(None));
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let dragging = dragging.clone();
window.on_spot_handle_dragged(move |id, 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_spot(
id.as_str(),
role,
origin.as_ref(),
(from_x, from_y),
(to_x, to_y),
)
});
if started.is_none() {
return;
}
*dragging.borrow_mut() = started;
// Only the circles. Rebuilding the panel on every frame of a drag
// is work for no difference, and a full rebuild would take the
// gesture out from under the finger — see `sync_handles`.
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_spot_handle_released(move || {
// Forgotten on release, so the next gesture measures from wherever
// this one left the repair.
if dragging.borrow_mut().take().is_none() {
// A press with no movement — a selection, not a drag. Recording
// a step would put an identical snapshot on the undo stack.
return;
}
if let Some(s) = session.borrow_mut().as_mut() {
s.commit_spot_drag();
}
if let Some(w) = weak.upgrade() {
sync_undo(&w, &session);
}
});
}
// --- the column's controls --------------------------------------------
//
// One closure shape, four callbacks: each hands the session a change to
// make to whichever repair is selected, and the session decides whether
// there is one. Reading the selection here instead would put the same
// `Option` check in four places and let them disagree.
macro_rules! control {
($install:ident, $apply:expr) => {{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.$install(move |value| {
let Some(w) = weak.upgrade() else { return };
let changed = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.set_selected_spot(|spot| $apply(spot, value)));
if !changed {
return;
}
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
sync_panel(&w, s);
}
sync_undo(&w, &session);
redraw(&w);
});
}};
}
control!(on_spot_radius_changed, |spot: &mut dr_pipeline::Spot, v| {
spot.set_radius(v)
});
control!(
on_spot_feather_changed,
|spot: &mut dr_pipeline::Spot, v| { spot.set_feather(v) }
);
control!(
on_spot_opacity_changed,
|spot: &mut dr_pipeline::Spot, v| { spot.set_opacity(v) }
);
control!(on_spot_mode_picked, |spot: &mut dr_pipeline::Spot, i| {
spot.mode = if i == 1 {
dr_pipeline::SpotMode::Clone
} else {
dr_pipeline::SpotMode::Heal
}
});
// --- selecting and removing -------------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_spot_selected(move |id| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.select_spot(Some(id.as_str()));
}
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
sync_panel(&w, s);
}
// The canvas changes — the selected repair gains its source circle
// — but no pixel does, so this is a repaint of the overlay rather
// than of the photograph. `redraw` is the only hook there is, and
// it is cheap enough: the render caches on the invalidation key,
// which a selection does not move.
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_spot_removed(move |id| {
let Some(w) = weak.upgrade() else { return };
let removed = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.remove_spot(id.as_str()));
if !removed {
return;
}
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
sync_panel(&w, s);
}
sync_undo(&w, &session);
redraw(&w);
});
}
}
/// TRACES: FR-DEV-8
/// Push the selected repair's settings into the column.
///
/// Separate from [`sync_handles`] because they answer different questions and
/// change at different times: the circles move on every frame of a drag and on
/// every pan, and these move when a repair is selected or a control is used.
pub(crate) fn sync_panel(window: &AppWindow, session: &DevelopSession) {
window.set_spot_count(session.spot_count() as i32);
if let Some(spot) = session.selected_spot() {
window.set_spot_radius(spot.radius);
window.set_spot_feather(spot.feather);
window.set_spot_opacity(spot.opacity);
window.set_spot_mode(match spot.mode {
dr_pipeline::SpotMode::Heal => 0,
dr_pipeline::SpotMode::Clone => 1,
});
}
}
/// Push whether undo and redo have anywhere to go.
///
/// Every repair is a history step, so every one of them moves these — and a
/// disabled undo button after an edit that *is* undoable is the kind of small
/// lie that stops people trusting the button at all.
fn sync_undo(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
window.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
window.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
}