Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts: # core/dr-pipeline/tests/mask_sidecar.rs
This commit is contained in:
+314
-18
@@ -26,11 +26,40 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use slint::{ComponentHandle as _, ModelRc, VecModel};
|
||||
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
|
||||
|
||||
use crate::develop::{Abandon, DevelopSession, Segmented, SessionId};
|
||||
use crate::segmentation;
|
||||
use crate::{sync_rows, AppWindow, MaskRow, ParamRow, SubjectRow};
|
||||
use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow};
|
||||
|
||||
/// What the adjust panel's heading says when the controls are global.
|
||||
///
|
||||
/// The panel's own default too, and named because the two have to agree: a
|
||||
/// literal in both would eventually be a literal in one.
|
||||
pub(crate) const GLOBAL_SCOPE: &str = "ADJUST";
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// What the adjust panel is pointed at, for its heading.
|
||||
///
|
||||
/// **The layer's name, not the word "adjust".** Selecting a layer re-points
|
||||
/// every control in that panel at that layer's chain, and the heading is the
|
||||
/// one piece of text a photographer cannot avoid reading on the way to a
|
||||
/// slider. Upper case because the heading style is, and it is the *same*
|
||||
/// string the row in the stack above shows — one name for one thing, so the
|
||||
/// selected row and the panel it scopes cannot appear to disagree.
|
||||
pub(crate) fn scope_label(session: &DevelopSession) -> String {
|
||||
let Some(id) = session.active_mask() else {
|
||||
return GLOBAL_SCOPE.to_string();
|
||||
};
|
||||
session
|
||||
.mask_layers()
|
||||
.into_iter()
|
||||
.find(|(layer_id, ..)| layer_id == id)
|
||||
.map_or_else(
|
||||
|| GLOBAL_SCOPE.to_string(),
|
||||
|(_, label, ..)| label.to_uppercase(),
|
||||
)
|
||||
}
|
||||
|
||||
/// How often the window looks to see whether the model has finished.
|
||||
///
|
||||
@@ -123,12 +152,12 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
|
||||
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
||||
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
||||
window.set_segmented(false);
|
||||
window.set_editing_mask(false);
|
||||
window.set_adjust_scope(GLOBAL_SCOPE.into());
|
||||
clear_handles(window);
|
||||
window.set_overlay_on(false);
|
||||
return;
|
||||
};
|
||||
|
||||
let active = s.active_mask().map(|id| id.to_string());
|
||||
let rows: Vec<MaskRow> = s
|
||||
.mask_layers()
|
||||
.into_iter()
|
||||
@@ -164,7 +193,8 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
|
||||
window.set_subject_rows(ModelRc::new(VecModel::from(subjects)));
|
||||
|
||||
window.set_segmented(s.has_segmentation());
|
||||
window.set_editing_mask(active.is_some());
|
||||
window.set_adjust_scope(scope_label(s).into());
|
||||
sync_handles(window, s);
|
||||
|
||||
// The overlay is regenerated only when there is one to draw. It is a
|
||||
// proxy-sized RGBA buffer — a megabyte or so — and rebuilding it on every
|
||||
@@ -184,6 +214,71 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
|
||||
sync_overlay_view(window, s);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-UI-3
|
||||
/// Move the canvas handles to where the selected gradient now is.
|
||||
///
|
||||
/// # Why this is not `set_gradient_handles(VecModel::from(…))`
|
||||
///
|
||||
/// **A fresh model kills the gesture that is moving them.** The handles are a
|
||||
/// repeater over this model, and handing Slint a new `ModelRc` makes it throw
|
||||
/// the repeated items away and build new ones — including the `TouchArea`
|
||||
/// holding the pointer. A drag therefore moved the handle exactly once, on the
|
||||
/// first pointer event, and then went dead under the finger with the button
|
||||
/// still down. Seen on screen and invisible in the source; `develop.rs` carries
|
||||
/// the same warning about the parameter rows, where it broke slider drags.
|
||||
///
|
||||
/// So the model is kept and its rows are rewritten in place. Slint updates the
|
||||
/// existing item rather than replacing it, and the handle stays under the
|
||||
/// pointer for the whole drag.
|
||||
///
|
||||
/// Split out from [`sync`] for a second reason too: a drag emits a pointer
|
||||
/// event a frame, and rebuilding the mask stack and the subject list on each of
|
||||
/// them would be a model rewrite per frame for lists that did not change.
|
||||
pub(crate) fn sync_handles(window: &AppWindow, session: &DevelopSession) {
|
||||
let next = session.gradient_handles();
|
||||
let model = handle_model();
|
||||
|
||||
while model.row_count() > next.len() {
|
||||
model.remove(model.row_count() - 1);
|
||||
}
|
||||
for (i, handle) in next.into_iter().enumerate() {
|
||||
if i < model.row_count() {
|
||||
// Only where it actually moved: an unchanged row written back is
|
||||
// still a change notification, and the point of this function is
|
||||
// to emit as few of those as the truth allows.
|
||||
if model.row_data(i).as_ref() != Some(&handle) {
|
||||
model.set_row_data(i, handle);
|
||||
}
|
||||
} else {
|
||||
model.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
window.set_gradient_handles(model.into());
|
||||
}
|
||||
|
||||
/// The handles' model, held for the life of the process.
|
||||
///
|
||||
/// One shared identity, for the reason [`sync_handles`] gives. A thread-local
|
||||
/// because the interface is single-threaded and this is the same shape
|
||||
/// `develop.rs` uses for its shared empty models.
|
||||
fn handle_model() -> Rc<VecModel<GradientHandle>> {
|
||||
thread_local! {
|
||||
static HANDLES: Rc<VecModel<GradientHandle>> = Rc::new(VecModel::default());
|
||||
}
|
||||
HANDLES.with(Clone::clone)
|
||||
}
|
||||
|
||||
/// Take the handles off the canvas, emptying the held model rather than
|
||||
/// replacing it — see [`sync_handles`] for why the identity is kept.
|
||||
fn clear_handles(window: &AppWindow) {
|
||||
let model = handle_model();
|
||||
while model.row_count() > 0 {
|
||||
model.remove(model.row_count() - 1);
|
||||
}
|
||||
window.set_gradient_handles(model.into());
|
||||
}
|
||||
|
||||
/// Push the overlay's clip rectangle and angle.
|
||||
///
|
||||
/// Separate from [`sync`] because it is called from the render path too: a pan
|
||||
@@ -246,23 +341,61 @@ pub(crate) fn wire(
|
||||
});
|
||||
}
|
||||
|
||||
// --- the overlay and the picking mode ---------------------------------
|
||||
// --- dragging a gradient on the photograph ----------------------------
|
||||
//
|
||||
// The geometry the gesture started from, held for its duration.
|
||||
//
|
||||
// **A drag is applied to where the mask was when the press landed**, not
|
||||
// to where it was one frame ago. Accumulating frame by frame would let the
|
||||
// clamps compound — a radius dragged past its limit and back would not
|
||||
// return to where it started — and would make the result depend on how
|
||||
// many events the pointer happened to deliver.
|
||||
let dragging: Rc<RefCell<Option<dr_pipeline::mask::MaskSource>>> = Rc::new(RefCell::new(None));
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
window.on_overlay_toggled(move |on| {
|
||||
let redraw = redraw.clone();
|
||||
let dragging = dragging.clone();
|
||||
window.on_gradient_handle_dragged(move |role, from_x, from_y, to_x, to_y| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_overlay(on);
|
||||
let origin = dragging.borrow().clone();
|
||||
let started = session.borrow_mut().as_mut().and_then(|s| {
|
||||
s.drag_gradient_handle(role, origin.as_ref(), (from_x, from_y), (to_x, to_y))
|
||||
});
|
||||
if started.is_none() {
|
||||
return;
|
||||
}
|
||||
sync(&w, &session);
|
||||
*dragging.borrow_mut() = started;
|
||||
// Only the handles, not the whole panel: nothing in the mask stack
|
||||
// or the subject list changed, and rewriting those models on every
|
||||
// frame of a drag is work for no difference. See `sync_handles` for
|
||||
// the sharper reason — a full `sync` would also take the gesture
|
||||
// out from under the finger.
|
||||
if let Some(s) = session.borrow().as_ref() {
|
||||
sync_handles(&w, s);
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.on_region_picking_toggled(move |on| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
w.set_region_picking(on);
|
||||
let session = session.clone();
|
||||
let dragging = dragging.clone();
|
||||
window.on_gradient_handle_released(move || {
|
||||
// Forgotten on release, so the next gesture measures from wherever
|
||||
// this one left the mask rather than from where this one began.
|
||||
if dragging.borrow_mut().take().is_none() {
|
||||
// A press with no movement. Nothing changed, so recording a
|
||||
// step would put an identical snapshot on the undo stack.
|
||||
return;
|
||||
}
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.commit_gradient_drag();
|
||||
}
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
|
||||
w.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -536,17 +669,180 @@ fn watch(
|
||||
/// Clear the panel when the open image changes.
|
||||
///
|
||||
/// Its own function rather than a call to [`sync`] with an empty session,
|
||||
/// because the *window* state has to be reset too: picking mode and the
|
||||
/// overlay are properties of looking at one photograph, and carrying them to
|
||||
/// the next one leaves a crosshair over an image with no region map behind it.
|
||||
/// because the *window* state has to be reset too: the overlay and the scope
|
||||
/// are properties of looking at one photograph, and carrying them to the next
|
||||
/// one would draw a region map over an image that has none and name a heading
|
||||
/// after a layer that is not there. Picking is not among them any more — it
|
||||
/// follows the view mode, which `reset_view_state` returns to `photo`.
|
||||
pub(crate) fn reset(window: &AppWindow) {
|
||||
window.set_region_picking(false);
|
||||
window.set_overlay_on(false);
|
||||
window.set_segmenting(false);
|
||||
window.set_segmented(false);
|
||||
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
||||
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
||||
window.set_editing_mask(false);
|
||||
window.set_adjust_scope(GLOBAL_SCOPE.into());
|
||||
clear_handles(window);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A session over a flat frame. No segmentation, which is deliberate: a
|
||||
/// gradient needs none, and the tests below are about scope rather than
|
||||
/// about what the model found.
|
||||
fn session() -> Option<DevelopSession> {
|
||||
let ctx = pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()?;
|
||||
let rgba: Vec<u8> = (0..32 * 32).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL).ok()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-UI-1
|
||||
/// The fault this pass exists for, stated as a test.
|
||||
///
|
||||
/// Selecting a mask layer re-points every control in the adjust panel at
|
||||
/// that layer's chain. Before this, the only thing that said so was a
|
||||
/// caption in a *different* panel, which a photographer reaching for the
|
||||
/// exposure slider has no reason to read. The heading of the panel that
|
||||
/// changed now carries the answer, so the two cannot be read apart — and
|
||||
/// this asserts they cannot come apart either.
|
||||
#[test]
|
||||
fn the_heading_says_which_chain_the_controls_are_pointed_at() {
|
||||
let Some(mut s) = session() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
assert_eq!(scope_label(&s), GLOBAL_SCOPE, "nothing selected");
|
||||
|
||||
let id = s
|
||||
.add_gradient_mask(false)
|
||||
.expect("a gradient needs no model");
|
||||
assert_ne!(
|
||||
scope_label(&s),
|
||||
GLOBAL_SCOPE,
|
||||
"adding a layer selects it, so the panel is already scoped to it \
|
||||
and must already say so"
|
||||
);
|
||||
|
||||
// And the name is the one the row in the stack shows. Two names for
|
||||
// one layer would let the selected row and the panel it scopes appear
|
||||
// to disagree.
|
||||
let row = s
|
||||
.mask_layers()
|
||||
.into_iter()
|
||||
.find(|(layer_id, ..)| *layer_id == id)
|
||||
.expect("the layer is in the stack");
|
||||
assert_eq!(scope_label(&s), row.1.to_uppercase());
|
||||
|
||||
s.set_active_mask(None);
|
||||
assert_eq!(
|
||||
scope_label(&s),
|
||||
GLOBAL_SCOPE,
|
||||
"clearing the selection must put the heading back, or the panel \
|
||||
would go on naming a layer it is no longer editing"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-5
|
||||
/// Leaving local mode is what clears the selection, and this is the half
|
||||
/// of it that can be tested without a window.
|
||||
///
|
||||
/// The mode handler in `lib.rs` calls `set_active_mask(None)`; what has to
|
||||
/// be true afterwards is that the controls are global *and say so*. A mode
|
||||
/// that was left with a layer still selected would leave thirty sliders
|
||||
/// pointed at a region of the photograph with nothing on screen saying it.
|
||||
#[test]
|
||||
fn clearing_the_selection_returns_the_rows_to_the_whole_photograph() {
|
||||
let Some(mut s) = session() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// The scope is invisible in the *shape* of the panel — a layer holds
|
||||
// the same chain the frame does, so both produce the same rows in the
|
||||
// same order. It is only visible in what those rows read, which is
|
||||
// precisely why the fault was silent: the panel looks identical either
|
||||
// way and means something different.
|
||||
//
|
||||
// Addressed by index rather than by name, because no part of the
|
||||
// frontend may route by a parameter's identity (FR-DEV-3a).
|
||||
let first = s.rows()[0].clone();
|
||||
s.set_param(first.op_index, first.param_index, first.maximum);
|
||||
assert_eq!(s.rows()[0].value, first.maximum, "the global chain moved");
|
||||
|
||||
// Adding a layer selects it, so the same row is now the layer's.
|
||||
s.add_gradient_mask(true).expect("gradient");
|
||||
assert_eq!(
|
||||
s.rows()[0].value,
|
||||
first.default_value,
|
||||
"the same control, pointed somewhere else and reading its own \
|
||||
value — the whole hazard, in one row"
|
||||
);
|
||||
|
||||
s.set_active_mask(None);
|
||||
assert_eq!(
|
||||
s.rows()[0].value,
|
||||
first.maximum,
|
||||
"and leaving the layer puts the frame's value back"
|
||||
);
|
||||
assert_eq!(scope_label(&s), GLOBAL_SCOPE);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-3
|
||||
/// The handles' model keeps one identity for the life of the process.
|
||||
///
|
||||
/// **This is what makes a drag last longer than one frame.** The handles
|
||||
/// are a repeater over this model, and a *new* `ModelRc` makes Slint throw
|
||||
/// the repeated items away and build fresh ones — taking the `TouchArea`
|
||||
/// that holds the pointer with them. The symptom is precise and was seen
|
||||
/// on screen before it was understood: the handle jumps once, on the first
|
||||
/// pointer event, and then sits dead under a finger that is still down.
|
||||
///
|
||||
/// It cannot be asserted through a window without a Slint backend, so it is
|
||||
/// asserted where it is decided. Every path that touches the handles —
|
||||
/// `sync_handles` and `clear_handles` — goes through this one model.
|
||||
#[test]
|
||||
fn the_handles_are_one_model_rewritten_rather_than_a_new_one_each_time() {
|
||||
assert!(
|
||||
Rc::ptr_eq(&handle_model(), &handle_model()),
|
||||
"a fresh model per sync destroys the gesture that is moving the \
|
||||
handles"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-UI-3
|
||||
/// A gradient offers handles; a mask with nothing to drag offers none.
|
||||
///
|
||||
/// This is the panel's whole test for whether to draw anything on the
|
||||
/// canvas, so it is worth pinning: handles over a subject mask would
|
||||
/// suggest an outline that cannot be moved can be.
|
||||
#[test]
|
||||
fn only_a_selected_gradient_puts_handles_on_the_canvas() {
|
||||
let Some(mut s) = session() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
assert!(s.gradient_handles().is_empty(), "nothing selected");
|
||||
|
||||
s.add_gradient_mask(false).expect("linear");
|
||||
assert_eq!(s.gradient_handles().len(), 3, "centre, width and rotation");
|
||||
|
||||
s.add_gradient_mask(true).expect("radial");
|
||||
assert_eq!(
|
||||
s.gradient_handles().len(),
|
||||
3,
|
||||
"centre and two semi-axes — the major one carries the angle, so \
|
||||
an ellipse needs no fourth handle to say it twice"
|
||||
);
|
||||
|
||||
s.set_active_mask(None);
|
||||
assert!(
|
||||
s.gradient_handles().is_empty(),
|
||||
"a gradient nobody has selected is not being edited"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user