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
+143
View File
@@ -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;