Show the regions, click one, and adjust it

The local panel sits above the adjust panel because it decides what those
sliders act on; below it, a photographer would set an exposure and only
then discover which scope it landed in. Selecting a layer re-scopes the
existing controls to that layer's chain — there is no second set of
sliders, and there must not be, or every operation added to `ops/` would
need a local twin.

The overlay is drawn over the canvas rather than blended into the render,
because it is a diagnostic and not an edit: it must not reach the
histogram, an export, or the texture handed to the compositor. Nearest-
neighbour always — the map's values are *names*, so smoothing between
region 4 and region 9 invents a colour belonging to neither and softens
exactly the edge the overlay exists to show.

Picking gets its own touch area above the pan handler. Panning wants
press-drag-release and picking wants a click; interleaving them in one
handler is how a drag ends up selecting a region the user was scrolling
past. Shift is tracked as window state because a TouchArea's click carries
no modifiers.

Three states a layer can be in are worth distinguishing, and each has a
different remedy: stale needs re-segmenting, "no adjustment yet" needs a
slider moved, and the ordinary case needs nothing said. A bare selection
renders nothing and looks identical to a broken mask, which is the first
thing a new user will hit.

Known rough edge, commented where it happens: segmentation blocks the UI
thread for about half a second. Moving it to a worker needs the develop
session — GPU resources behind a RefCell shared with every callback — to
be reachable from another thread, which is a restructuring rather than a
change to the call. The button says "Finding regions…" first so the stall
is announced rather than looking like a hang.
This commit is contained in:
2026-08-22 08:39:17 +02:00
parent 5ecb35864f
commit 9b4f0815e5
5 changed files with 856 additions and 0 deletions
+29
View File
@@ -770,6 +770,35 @@ impl DevelopSession {
.collect()
}
/// What kind of mask a layer is — "regions", "linear", "radial".
pub fn mask_kind(&self, id: &str) -> &'static str {
self.graph
.masks()
.get(id)
.map_or("", |l| l.source.kind())
}
pub fn mask_inverted(&self, id: &str) -> bool {
self.graph.masks().get(id).is_some_and(|l| l.invert)
}
pub fn mask_opacity(&self, id: &str) -> f32 {
self.graph.masks().get(id).map_or(1.0, |l| l.opacity)
}
/// Whether a layer has any adjustment on it yet.
///
/// Distinct from `is_active`, which also asks whether the layer is enabled
/// and visible. The panel wants specifically "you have made a selection
/// and not yet done anything with it", because that state looks identical
/// to a broken mask and is the most likely thing a first-time user hits.
pub fn mask_is_adjusted(&self, id: &str) -> bool {
self.graph
.masks()
.get(id)
.is_some_and(|l| l.active_ops().next().is_some())
}
pub fn active_mask(&self) -> Option<&str> {
self.active_mask.as_deref()
}
+12
View File
@@ -32,6 +32,7 @@ mod library_ui;
mod live_style;
mod net_runtime;
mod presets;
mod masks_ui;
mod segmentation;
mod settings_store;
mod settings_ui;
@@ -282,6 +283,11 @@ fn reset_view_state(window: &AppWindow) {
// beside the next one's filename is a confident, precise lie, and the gap
// before the new frame settles is exactly long enough to read it.
window.set_histogram(histogram::empty());
// TRACES: FR-DEV-3
// The region map belongs to one photograph. Carrying the stack, the
// overlay or the crosshair to the next one would offer a selection of
// regions that are not in the picture on screen.
masks_ui::reset(window);
}
/// Push the framing back to the geometry panel.
@@ -1723,6 +1729,12 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
redraw(&w);
});
}
// TRACES: FR-DEV-3
// The local-adjustment panel. Wired as a block rather than inline because
// it is a dozen callbacks that all say the same three things, and they
// read better beside each other than scattered through this function.
masks_ui::wire(&window, &session, &rows, &redraw, gpu.clone());
{
let weak = window.as_weak();
let session = session.clone();
+328
View File
@@ -0,0 +1,328 @@
//! 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 one thing 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.
use std::cell::RefCell;
use std::rc::Rc;
use slint::{ComponentHandle as _, ModelRc, VecModel};
use crate::develop::DevelopSession;
use crate::segmentation;
use crate::{sync_rows, AppWindow, MaskRow, ParamRow, SubjectRow};
/// 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_segmentation_regions(0);
window.set_editing_mask(false);
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()
.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),
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_segmentation_level(s.segmentation_level() as i32);
window.set_segmentation_regions(s.segmentation_region_count() as i32);
window.set_editing_mask(active.is_some());
// 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),
}
}
/// Install the panel's callbacks.
#[allow(clippy::too_many_arguments)]
pub(crate) fn wire(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>,
gpu: Option<dr_gpu::GpuContext>,
) {
// --- computing the region map -----------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_segment_image(move || {
let Some(w) = weak.upgrade() else { return };
let Some(ctx) = gpu.clone() else {
log::warn!("no GPU context; cannot segment");
return;
};
// **Blocking, on the UI thread, and flagged rather than hidden.**
//
// Half a second of watershed and inference. Moving it to a worker
// needs the session — which owns GPU resources and is behind a
// `RefCell` shared with every callback here — to be reachable from
// another thread, and that is a restructuring of the develop
// session rather than a change to this call.
//
// So it blocks, and the button says "Finding regions…" first: a
// frozen window with a stale label is the version of this that
// reads as a crash. See the note in `segmentation.rs` — this is
// the largest rough edge in the feature.
w.set_segmenting(true);
// Let the label reach the screen before the stall begins.
slint::Timer::single_shot(std::time::Duration::from_millis(16), {
let weak = w.as_weak();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
move || {
let Some(w) = weak.upgrade() else { return };
let options = segmentation::Options::default();
if let Some(s) = session.borrow_mut().as_mut() {
if let Err(e) = s.segment(&ctx, &options) {
log::warn!("segmentation failed: {e}");
}
}
w.set_segmenting(false);
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
}
});
});
}
// --- the overlay and the picking mode ---------------------------------
{
let weak = window.as_weak();
let session = session.clone();
window.on_overlay_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_overlay(on);
}
sync(&w, &session);
});
}
{
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 weak = window.as_weak();
let session = session.clone();
window.on_segmentation_level_changed(move |level| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_segmentation_level(level.max(2) as u32);
}
sync(&w, &session);
});
}
// --- 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, add));
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| {
let Some(w) = weak.upgrade() else { return };
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
// Clicking the selected layer again deselects it, which is how
// the panel gets back to the whole photograph without a
// separate "edit globally" control.
if s.active_mask() == Some(id.as_str()) {
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);
});
}
// --- 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);
});
}
}
/// 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.
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);
}