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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint";
|
||||
import { MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
@@ -792,6 +793,51 @@ export component AppWindow inherits Window {
|
||||
in property <bool> expanded: true;
|
||||
in property <string> layout-class: "expanded";
|
||||
|
||||
// --- local adjustments (FR-DEV-3) ---------------------------------------
|
||||
|
||||
/// The false-coloured region map, drawn over the photograph.
|
||||
///
|
||||
/// An empty image when there is nothing to draw, which is also how the
|
||||
/// overlay is hidden — one source of truth rather than a flag that can
|
||||
/// disagree with the picture.
|
||||
in property <image> region-overlay;
|
||||
/// How strongly the overlay is composited. Not 1.0: the point of looking
|
||||
/// at it is to judge whether a boundary follows the *photograph*, and an
|
||||
/// opaque map hides the thing being judged.
|
||||
in property <float> overlay-strength: 0.55;
|
||||
in property <bool> overlay-on: false;
|
||||
/// Clicking the canvas selects a region instead of panning.
|
||||
in property <bool> region-picking: false;
|
||||
/// Whether shift is down, tracked by the develop key scope below.
|
||||
///
|
||||
/// A `TouchArea`'s `clicked` carries no modifiers, so the state has to be
|
||||
/// held between the key event and the click. Private to the window because
|
||||
/// nothing outside it has any business setting it.
|
||||
property <bool> shift-held: false;
|
||||
|
||||
in property <[MaskRow]> mask-rows;
|
||||
in property <[SubjectRow]> subject-rows;
|
||||
in property <bool> segmented: false;
|
||||
in property <bool> segmenting: false;
|
||||
in property <int> segmentation-level: 300;
|
||||
in property <int> segmentation-regions: 0;
|
||||
in property <bool> editing-mask: false;
|
||||
|
||||
callback segment-image();
|
||||
callback overlay-toggled(bool);
|
||||
callback region-picking-toggled(bool);
|
||||
callback segmentation-level-changed(int);
|
||||
/// A click on the photograph, in fractions of the shown image, plus
|
||||
/// whether it should extend the selection rather than replace it.
|
||||
callback region-picked(float, float, bool);
|
||||
callback mask-selected(string);
|
||||
callback mask-removed(string);
|
||||
callback mask-toggled(string, bool);
|
||||
callback mask-invert-toggled(string, bool);
|
||||
callback mask-opacity-changed(string, float);
|
||||
callback add-gradient-mask(bool);
|
||||
callback add-subject-mask(int);
|
||||
|
||||
/// Whether the develop column — image info, geometry, adjust — is shown.
|
||||
///
|
||||
/// Distinct from `expanded`, which is the *layout class* and only supplies
|
||||
@@ -1276,6 +1322,29 @@ in property <bool> panel-visible: true;
|
||||
visible: root.total > 0 && root.load-error == "";
|
||||
}
|
||||
|
||||
// The region map, composited over the photograph at the
|
||||
// same fitted rect.
|
||||
//
|
||||
// Drawn here 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 the develop
|
||||
// pass hands the compositor.
|
||||
if root.overlay-on && root.total > 0: Image {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
source: root.region-overlay;
|
||||
image-fit: fill;
|
||||
opacity: root.overlay-strength;
|
||||
// Nearest-neighbour, always. The map is proxy-sized
|
||||
// and its values are *names*: smoothing between region
|
||||
// 4 and region 9 invents a colour belonging to
|
||||
// neither, and draws a soft edge exactly where the
|
||||
// point is to see a hard one.
|
||||
image-rendering: ImageRendering.pixelated;
|
||||
}
|
||||
|
||||
// Where the photograph actually sits inside this box.
|
||||
//
|
||||
// `image-fit: contain` letterboxes, and Slint does not report
|
||||
@@ -1344,6 +1413,31 @@ in property <bool> panel-visible: true;
|
||||
cancelled => { self.last-scale = 1.0; }
|
||||
}
|
||||
|
||||
// Region picking, above the pan/zoom area so a click
|
||||
// reaches it first. A separate area rather than a branch
|
||||
// inside the one below: panning wants press-drag-release
|
||||
// and picking wants a click, and interleaving the two in
|
||||
// one handler is how a drag ends up selecting a region the
|
||||
// user was only scrolling past.
|
||||
if root.region-picking && root.total > 0 && root.load-error == "": pick := TouchArea {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
mouse-cursor: MouseCursor.crosshair;
|
||||
enabled: !root.crop-mode;
|
||||
|
||||
clicked => {
|
||||
root.region-picked(
|
||||
self.mouse-x / max(self.width, 1px),
|
||||
self.mouse-y / max(self.height, 1px),
|
||||
// Shift extends or removes, the modifier every
|
||||
// selection tool uses for the same thing.
|
||||
root.shift-held,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if root.total > 0 && root.load-error == "": TouchArea {
|
||||
x: 0; y: 0;
|
||||
width: 100%;
|
||||
@@ -1595,7 +1689,18 @@ in property <bool> panel-visible: true;
|
||||
FocusScope {
|
||||
init => { self.focus(); }
|
||||
|
||||
// Shift is state, not an event: the click that needs
|
||||
// to know about it arrives through a `TouchArea`,
|
||||
// which reports no modifiers of its own. Tracked on
|
||||
// both edges so releasing it while the pointer is over
|
||||
// the canvas is noticed.
|
||||
key-released(event) => {
|
||||
root.shift-held = event.modifiers.shift;
|
||||
return reject;
|
||||
}
|
||||
|
||||
key-pressed(event) => {
|
||||
root.shift-held = event.modifiers.shift;
|
||||
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
|
||||
// of the letter, because the logical key that
|
||||
// reaches us carries the shift: holding it for
|
||||
@@ -1786,6 +1891,44 @@ in property <bool> panel-visible: true;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// Above the adjust panel, because it decides what
|
||||
// those sliders act on. Below it, the photographer
|
||||
// would set an exposure and only then discover which
|
||||
// scope it landed in.
|
||||
MaskPanel {
|
||||
enabled: root.adjust-enabled;
|
||||
masks: root.mask-rows;
|
||||
subjects: root.subject-rows;
|
||||
segmented: root.segmented;
|
||||
segmenting: root.segmenting;
|
||||
level: root.segmentation-level;
|
||||
region-count: root.segmentation-regions;
|
||||
overlay: root.overlay-on;
|
||||
picking: root.region-picking;
|
||||
editing-mask: root.editing-mask;
|
||||
|
||||
segment => { root.segment-image(); }
|
||||
overlay-toggled(on) => { root.overlay-toggled(on); }
|
||||
picking-toggled(on) => { root.region-picking-toggled(on); }
|
||||
level-changed(v) => { root.segmentation-level-changed(v); }
|
||||
mask-selected(id) => { root.mask-selected(id); }
|
||||
mask-removed(id) => { root.mask-removed(id); }
|
||||
mask-toggled(id, on) => { root.mask-toggled(id, on); }
|
||||
mask-invert-toggled(id, on) => {
|
||||
root.mask-invert-toggled(id, on);
|
||||
}
|
||||
mask-opacity-changed(id, v) => {
|
||||
root.mask-opacity-changed(id, v);
|
||||
}
|
||||
add-gradient(radial) => { root.add-gradient-mask(radial); }
|
||||
add-subject(i) => { root.add-subject-mask(i); }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
adjust := AdjustPanel {
|
||||
rows: root.adjust-rows;
|
||||
enabled: root.adjust-enabled;
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// Local adjustments: the mask stack, the region overlay, and the subjects the
|
||||
// model recognised (FR-DEV-3).
|
||||
//
|
||||
// The panel is deliberately thin. A mask layer holds an ordinary develop
|
||||
// chain, so selecting one hands the *existing* adjust panel that chain — there
|
||||
// are no per-layer sliders here and there must not be, or every operation
|
||||
// added to `ops/` would need a local twin.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
|
||||
import { SliderRow } from "controls.slint";
|
||||
|
||||
/// One layer in the stack.
|
||||
export struct MaskRow {
|
||||
// Identity, opaque to this file — the callbacks hand it straight back.
|
||||
id: string,
|
||||
label: string,
|
||||
/// What kind of mask it is, for the row's second line.
|
||||
kind: string,
|
||||
enabled: bool,
|
||||
/// The layer the develop panel is currently editing.
|
||||
selected: bool,
|
||||
inverted: bool,
|
||||
opacity: float,
|
||||
/// Its region ids came from a different segmentation than the one loaded,
|
||||
/// so applying it would produce a confidently wrong shape.
|
||||
stale: bool,
|
||||
/// Whether it currently changes any pixel. A bare selection does not, and
|
||||
/// saying so is what stops it reading as broken.
|
||||
adjusted: bool,
|
||||
}
|
||||
|
||||
/// Something the model found.
|
||||
export struct SubjectRow {
|
||||
index: int,
|
||||
label: string,
|
||||
/// 0..1. Shown rather than hidden: the detector is a shortcut, not an
|
||||
/// authority, and a weak guess presented like a strong one makes the tool
|
||||
/// look wrong when it was merely unsure.
|
||||
score: float,
|
||||
}
|
||||
|
||||
component MaskEntry inherits Rectangle {
|
||||
in property <MaskRow> data;
|
||||
in property <bool> enabled: true;
|
||||
|
||||
callback selected();
|
||||
callback removed();
|
||||
callback toggled(bool);
|
||||
callback invert-toggled(bool);
|
||||
callback opacity-changed(float);
|
||||
|
||||
height: layout.preferred-height;
|
||||
background: root.data.selected ? Theme.surface-raised : transparent;
|
||||
border-radius: Theme.radius;
|
||||
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.selected(); }
|
||||
}
|
||||
|
||||
layout := VerticalLayout {
|
||||
padding: Theme.gap-sm;
|
||||
spacing: 2px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// The eye. Disabling a layer is the A/B a local edit constantly
|
||||
// wants, so it is one press away rather than inside the row.
|
||||
visibility := TouchArea {
|
||||
width: Theme.touch-target;
|
||||
height: Theme.touch-target;
|
||||
mouse-cursor: pointer;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.toggled(!root.data.enabled); }
|
||||
|
||||
Label {
|
||||
text: root.data.enabled ? "◉" : "○";
|
||||
emphasised: visibility.has-hover || root.data.enabled;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
alignment: center;
|
||||
spacing: 0px;
|
||||
|
||||
Label {
|
||||
text: root.data.label;
|
||||
emphasised: root.data.selected || touch.has-hover;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
Caption {
|
||||
// Three states worth distinguishing, and each has a
|
||||
// different remedy: stale needs the segmentation re-run,
|
||||
// unadjusted needs a slider moved, and the ordinary case
|
||||
// needs nothing said about it beyond what it is.
|
||||
text: root.data.stale
|
||||
? "needs re-segmenting"
|
||||
: (root.data.adjusted
|
||||
? root.data.kind
|
||||
: root.data.kind + " · no adjustment yet");
|
||||
}
|
||||
}
|
||||
|
||||
remove := TouchArea {
|
||||
width: Theme.touch-target;
|
||||
height: Theme.touch-target;
|
||||
mouse-cursor: pointer;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.removed(); }
|
||||
|
||||
Label {
|
||||
text: "×";
|
||||
emphasised: remove.has-hover;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opacity and invert appear only on the selected layer. On every row
|
||||
// they would triple the height of a stack of eight and put seven sets
|
||||
// of controls in front of the one being worked on.
|
||||
if root.data.selected: VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
padding-top: Theme.gap-sm;
|
||||
|
||||
SliderRow {
|
||||
label: "Opacity";
|
||||
value: root.data.opacity;
|
||||
default-value: 1.0;
|
||||
minimum: 0.0;
|
||||
maximum: 1.0;
|
||||
changed(v) => { root.opacity-changed(v); }
|
||||
reset => { root.opacity-changed(1.0); }
|
||||
}
|
||||
|
||||
invert := TouchArea {
|
||||
height: Theme.touch-target;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.invert-toggled(!root.data.inverted); }
|
||||
|
||||
Label {
|
||||
text: root.data.inverted ? "Inverted — edit applies outside" : "Invert";
|
||||
emphasised: invert.has-hover || root.data.inverted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component MaskPanel inherits Rectangle {
|
||||
in property <bool> enabled: true;
|
||||
in property <[MaskRow]> masks;
|
||||
in property <[SubjectRow]> subjects;
|
||||
|
||||
/// A region map has been computed for this image.
|
||||
in property <bool> segmented: false;
|
||||
/// One is being computed now.
|
||||
in property <bool> segmenting: false;
|
||||
/// How many regions the ladder is currently cut to.
|
||||
in property <int> level: 300;
|
||||
in property <int> region-count: 0;
|
||||
|
||||
/// Draw the false-coloured region map over the photograph.
|
||||
in property <bool> overlay: false;
|
||||
/// Clicking the photograph selects a region rather than panning.
|
||||
in property <bool> picking: false;
|
||||
/// A layer is selected, so the adjust panel below is scoped to it.
|
||||
///
|
||||
/// Supplied rather than derived: Slint has no `any` over a model, and the
|
||||
/// core already knows the answer.
|
||||
in property <bool> editing-mask: false;
|
||||
|
||||
callback segment();
|
||||
callback overlay-toggled(bool);
|
||||
callback picking-toggled(bool);
|
||||
callback level-changed(int);
|
||||
|
||||
callback mask-selected(string);
|
||||
callback mask-removed(string);
|
||||
callback mask-toggled(string, bool);
|
||||
callback mask-invert-toggled(string, bool);
|
||||
callback mask-opacity-changed(string, float);
|
||||
|
||||
callback add-gradient(bool);
|
||||
callback add-subject(int);
|
||||
|
||||
background: Theme.surface;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
|
||||
HorizontalLayout {
|
||||
PanelHeading { text: "LOCAL"; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
if root.segmented: Value { text: root.region-count + " regions"; }
|
||||
}
|
||||
|
||||
if !root.enabled: Caption { text: "No image"; }
|
||||
|
||||
// --- the region map ------------------------------------------------
|
||||
//
|
||||
// Segmentation is a thing the user starts. Half a second of watershed
|
||||
// and inference is too long to spend on every photograph opened, and
|
||||
// most edits never need one.
|
||||
if root.enabled && !root.segmented: VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Caption {
|
||||
text: "Find the regions in this photograph, so a mask can snap to them.";
|
||||
}
|
||||
|
||||
Button {
|
||||
text: root.segmenting ? "Finding regions…" : "Find regions";
|
||||
enabled: !root.segmenting;
|
||||
primary: true;
|
||||
clicked => { root.segment(); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.enabled && root.segmented: VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// The overlay the whole map is judged by. Without it the
|
||||
// granularity control is a number with no visible meaning.
|
||||
Button {
|
||||
text: "Overlay";
|
||||
active: root.overlay;
|
||||
clicked => { root.overlay-toggled(!root.overlay); }
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Select";
|
||||
active: root.picking;
|
||||
clicked => { root.picking-toggled(!root.picking); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.picking: Caption {
|
||||
text: "Click the photograph to select a region. Shift-click to add or remove.";
|
||||
}
|
||||
|
||||
// Granularity. Labelled by what it does rather than by its number:
|
||||
// "detail" is what a photographer is choosing between, where "300
|
||||
// regions" is an implementation detail they would have to learn.
|
||||
SliderRow {
|
||||
label: "Detail";
|
||||
value: root.level;
|
||||
default-value: 300;
|
||||
minimum: 8;
|
||||
maximum: 2000;
|
||||
changed(v) => { root.level-changed(v); }
|
||||
reset => { root.level-changed(300); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- what the model found -------------------------------------------
|
||||
if root.enabled && root.segmented && root.subjects.length > 0: VerticalLayout {
|
||||
spacing: 2px;
|
||||
padding-top: Theme.gap-sm;
|
||||
|
||||
Caption { text: "Recognised"; }
|
||||
|
||||
for subject[i] in root.subjects: subject-row := TouchArea {
|
||||
height: Theme.touch-target;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.add-subject(subject.index); }
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Label {
|
||||
text: subject.label;
|
||||
emphasised: subject-row.has-hover;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Value { text: round(subject.score * 100) + "%"; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// --- the stack -------------------------------------------------------
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Caption { text: "Masks"; horizontal-stretch: 1; }
|
||||
|
||||
// Gradients need no segmentation, so they are offered whether or
|
||||
// not one has been computed — a graduated filter over a sky is a
|
||||
// local adjustment that never needed to know what a sky is.
|
||||
Button {
|
||||
text: "Linear";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.add-gradient(false); }
|
||||
}
|
||||
Button {
|
||||
text: "Radial";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.add-gradient(true); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.enabled && root.masks.length == 0: Caption {
|
||||
text: "No local adjustments. Select a region, or add a gradient.";
|
||||
}
|
||||
|
||||
for mask in root.masks: MaskEntry {
|
||||
data: mask;
|
||||
enabled: root.enabled;
|
||||
selected => { root.mask-selected(mask.id); }
|
||||
removed => { root.mask-removed(mask.id); }
|
||||
toggled(on) => { root.mask-toggled(mask.id, on); }
|
||||
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
|
||||
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
|
||||
}
|
||||
|
||||
// The one thing the panel has to say about the adjust panel below
|
||||
// it, because otherwise selecting a layer silently changes what those
|
||||
// sliders mean — the single most confusing thing a scoped panel can
|
||||
// do.
|
||||
if root.enabled && root.masks.length > 0: Caption {
|
||||
text: root.editing-mask
|
||||
? "The controls below adjust the selected mask."
|
||||
: "The controls below adjust the whole photograph.";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user