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() .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> { pub fn active_mask(&self) -> Option<&str> {
self.active_mask.as_deref() self.active_mask.as_deref()
} }
+12
View File
@@ -32,6 +32,7 @@ mod library_ui;
mod live_style; mod live_style;
mod net_runtime; mod net_runtime;
mod presets; mod presets;
mod masks_ui;
mod segmentation; mod segmentation;
mod settings_store; mod settings_store;
mod settings_ui; 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 // 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. // before the new frame settles is exactly long enough to read it.
window.set_histogram(histogram::empty()); 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. /// Push the framing back to the geometry panel.
@@ -1723,6 +1729,12 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
redraw(&w); 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 weak = window.as_weak();
let session = session.clone(); 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);
}
+143
View File
@@ -1,5 +1,6 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint"; import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint";
import { MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { LaunchScreen } from "launch.slint"; import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint"; import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.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 <bool> expanded: true;
in property <string> layout-class: "expanded"; 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. /// Whether the develop column — image info, geometry, adjust — is shown.
/// ///
/// Distinct from `expanded`, which is the *layout class* and only supplies /// 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 == ""; 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. // Where the photograph actually sits inside this box.
// //
// `image-fit: contain` letterboxes, and Slint does not report // `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; } 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 { if root.total > 0 && root.load-error == "": TouchArea {
x: 0; y: 0; x: 0; y: 0;
width: 100%; width: 100%;
@@ -1595,7 +1689,18 @@ in property <bool> panel-visible: true;
FocusScope { FocusScope {
init => { self.focus(); } 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) => { key-pressed(event) => {
root.shift-held = event.modifiers.shift;
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases // Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
// of the letter, because the logical key that // of the letter, because the logical key that
// reaches us carries the shift: holding it for // reaches us carries the shift: holding it for
@@ -1786,6 +1891,44 @@ in property <bool> panel-visible: true;
background: Theme.rule; 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 { adjust := AdjustPanel {
rows: root.adjust-rows; rows: root.adjust-rows;
enabled: root.adjust-enabled; enabled: root.adjust-enabled;
+344
View File
@@ -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.";
}
}
}