Files
DarkRoom/ui/dr-ui/ui/masks.slint
T
dtourolleandClaude Opus 5 ef07e6ca3e
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Give the canvas tools a rail of their own, and the column one width
Crop, Local and Repair were chips at the head of the develop column, sharing a
row with the adjustment groups and told apart from them by the shape of their
highlight. Three things followed from that, and only the last is cosmetic: the
column closes, so the way out of a mode went away with the way in — hence the
duplicate "Done Cropping" over the canvas; the chips are generated from the
operation set, so the widest thing in the sidebar was a row nobody had chosen
the contents of; and a mode and a filter are different kinds of state wearing
one control.

They are a fixed 60px rail down the left now, generated from a single table in
toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant;
nothing in app.slint is touched to add one. What is left of the strip is the
group filters, so it is GroupStrip.

The column stops measuring itself. Every panel published a content-width and
declared it as min-width, and the column took the largest — which spent the
photograph's pixels on whatever happened to be widest, and moved the image
sideways when switching tools swapped one set of panels for another. It is
panel-width now, one number in style.yaml.

That number is 360 and it is measured, not picked: the contents report a
minimum of 344 in every mode, and they do not compress below it because a Text
that does not elide reports the same minimum as preferred. 320 was tried and
sliced Paste down the middle. The Flickable's viewport is floored at the
layout's minimum rather than its preferred width for the same reason — content
that is never told how much room it has cannot adapt to having less.

Removing the eight content-width declarations repairs three comments an
earlier edit had spliced sentences into. The raw histogram's note on keeping
its hint short is rewritten rather than dropped: an over-long hint no longer
widens the column, it pushes the column's minimum past the width it has and
clips the panel, which makes that constraint sharper rather than obsolete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:59:50 +02:00

619 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { Segmented, 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,
/// Edge transition half-width, as a fraction of the frame's shorter edge.
feather: float,
/// Index into `Falloff::ALL`.
falloff: int,
/// Index into `Morphology::ALL`.
morphology: int,
morph-radius: float,
/// Whether the edge controls apply. A gradient has its own geometry and
/// does not go through the distance field, so showing them would offer
/// controls that do nothing.
shapeable: bool,
/// 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,
}
/// Which part of a gradient a canvas handle drags.
///
/// Named by the job rather than by the field, because the two gradients keep
/// different things in the same place: `edge` is a linear ramp's width and a
/// radial's major semi-axis, and `cross` is the radial's minor one.
export enum HandleRole { centre, edge, cross, rotate }
/// One draggable point on the photograph.
///
/// Positioned in fractions of the image **as it is currently shown** — after
/// the crop, the zoom and the pan — because that is the only space this file
/// can draw in. Rust maps the mask's own source-space geometry into it on
/// every frame, so the handle sits on the mask rather than beside it.
export struct GradientHandle {
role: HandleRole,
x: float,
y: float,
}
/// 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;
/// Whether a refine pass is running (for any row — only one runs at a
/// time), so this row's button can say so and refuse a second press.
in property <bool> refining: false;
/// `true` when the click should add this row to the selection rather
/// than replace it with just this row.
callback selected(bool);
callback removed();
callback toggled(bool);
callback invert-toggled(bool);
callback opacity-changed(float);
callback feather-changed(float);
callback falloff-picked(int);
callback morphology-picked(int);
callback morph-radius-changed(float);
callback refined();
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` carries no modifiers, so a plain click and a
// multi-select click need the pointer event itself: released, over
// the row, with control or command held is "add to the selection"
// rather than "replace it".
pointer-event(event) => {
if event.kind == PointerEventKind.up && event.button == PointerEventButton.left {
root.selected(event.modifiers.control || event.modifiers.meta);
}
}
}
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;
// Both lines below are bounded for the reason the subject
// row is: a mask's label and kind come from the model, and an
// unbounded `Text` asks for its whole string at layout time
// even when `elide` means it will never draw it. A mask *is* a
// segmentation result, so without this the column moved when a
// subject was clicked as well as when one was found.
Label {
text: root.data.label;
emphasised: root.data.selected || touch.has-hover;
overflow: elide;
min-width: 0px;
max-width: 160px;
}
Caption {
// Elided rather than wrapped: this sits beside two fixed
// targets in a row, and wrapping would give the rows of a
// stack different heights for no gain.
overflow: elide;
min-width: 0px;
max-width: 160px;
// 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;
}
}
// Only a subject has a detection behind it worth re-running. A
// gradient's edge is geometry the photographer placed, and there
// is nothing for the model to find twice.
if root.data.kind == "subject": Button {
text: root.refining ? "Refining…" : "Refine mask";
enabled: !root.refining;
clicked => { root.refined(); }
}
// The edge. All of it reads one signed distance field, so these
// are free to move — none of them recomputes anything except a
// compound morphology.
if root.data.shapeable: SliderRow {
label: "Feather";
hint: "How far the edit fades across the edge.";
value: root.data.feather * 1000;
default-value: 4;
minimum: 0;
maximum: 60;
changed(v) => { root.feather-changed(v / 1000); }
reset => { root.feather-changed(0.004); }
}
if root.data.shapeable: Segmented {
label: "Falloff";
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
selected: root.data.falloff;
picked(i) => { root.falloff-picked(i); }
}
if root.data.shapeable: Segmented {
label: "Edge";
hint: "Grow or shrink the outline the model drew. Close fills "
+ "pinholes; open removes specks.";
options: ["None", "Grow", "Shrink", "Close", "Open"];
selected: root.data.morphology;
picked(i) => { root.morphology-picked(i); }
}
if root.data.shapeable && root.data.morphology != 0: SliderRow {
label: "Amount";
value: root.data.morph-radius * 1000;
default-value: 0;
minimum: 0;
maximum: 40;
changed(v) => { root.morph-radius-changed(v / 1000); }
reset => { root.morph-radius-changed(0); }
}
}
}
}
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;
/// A refine pass is running for one subject layer.
in property <bool> refining: false;
/// Whether the photographer has hidden the overlay to see the photograph.
/// Two-way bound to the window's own, so the canvas and this control never
/// disagree about it.
in-out property <bool> overlay-hidden: false;
/// `true` asks for the slower, tiled pass.
callback segment(bool);
/// `true` when the click should add the layer to the selection rather
/// than replace it.
callback mask-selected(string, bool);
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
callback add-gradient(bool);
callback add-subject(int);
background: Theme.surface;
// **Flat, deliberately.** An earlier version grouped each state into its
// own `if ...: VerticalLayout { ... }`. The nested layout under-reported
// its height, so the divider and the mask row below it were placed over
// the "Find regions" button — the sort of fault that is invisible in the
// source and obvious the moment anyone looks at the panel. Conditioning
// each child on its own keeps every element a direct child of one layout,
// which is the layout that measures them.
layout := VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
HorizontalLayout {
PanelHeading { text: "MASKS"; }
Rectangle { horizontal-stretch: 1; }
if root.segmented: Value { text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects");
}
}
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: Caption {
text: "Find the subjects in this photograph, so a mask can follow one.";
// Wrapped, or this sentence's single-line width becomes the
// panel's minimum and levers the fixed 280px develop column open,
// taking every other panel's controls off the right edge with it.
wrap: word-wrap;
}
if root.enabled && !root.segmented: Button {
text: root.segmenting ? "Looking…" : "Find subjects";
enabled: !root.segmenting;
primary: true;
clicked => { root.segment(false); }
}
// No "Overlay" button and no "Select" button. Both switched on things
// that are now simply what local mode *is*: entering it draws the
// region map and makes a click on the photograph mean "select". A mode
// whose behaviour has to be armed separately is a mode that can be
// entered and still do nothing, which is what these two allowed.
if root.enabled && root.segmented: Caption {
text: "Click a subject in the photograph to mask it.";
wrap: word-wrap;
}
// Hiding the overlay is not the "Overlay" button this panel used to
// have, and the distinction is the reason it is back. That one *armed*
// the overlay: local mode could be entered and still show nothing,
// which is the fault the comment below records. This one only takes an
// overlay that is already there out of the way for a moment, which is
// the one thing a photographer needs constantly and had no way to do —
// a mask is judged against the photograph under it, and you cannot see
// that photograph through the thing describing it.
//
// Reads as its own action rather than its state: "Show the mask" is
// what pressing it will do, not what is currently true.
if root.enabled && root.segmented: Button {
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { root.overlay-hidden = !root.overlay-hidden; }
}
// The second pass, offered rather than taken automatically.
//
// The model's input is a fixed 640x640 square and every frame is
// letterboxed into it, so a subject that is small in the frame reaches
// the model small — a bird at 200px in a 1600px proxy arrives at 80px,
// and an outline traced at 80px is what a coarse mask is. Tiling runs
// the model over overlapping windows instead, so that bird arrives at
// its own size.
//
// It costs one inference per tile: about 2.8s for a 3x2 grid against
// 470ms for the whole frame. That is the wrong trade for the usual
// photograph, where the subject fills much of the frame and the first
// pass is already the best answer available — so it is a button and
// not a default, and it says what it costs.
if root.enabled && root.segmented: Button {
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !root.segmenting;
clicked => { root.segment(true); }
}
// 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.
// --- what the model found ----------------------------------------
if root.enabled && root.segmented && root.subjects.length > 0: Caption {
// Says where these came from and how they differ from a region.
// A list of four beside a count of three thousand invites exactly
// one question, and the panel should answer it rather than
// provoke it.
text: "Click one to mask it. The outline is the model's, so soften or grow it below.";
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
spacing: 0px;
for subject 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;
overflow: elide;
// **`elide` is a paint-time behaviour, and this is a
// layout-time problem.** A `Text` asks for the width of
// its whole string whether or not it will draw all of
// it, so without a stated maximum this row asked for
// whatever the model happened to return, that became
// `layout.preferred-width`, the panel publishes that as
// its `min-width`, and the develop column takes the
// widest minimum any panel declares. The column
// therefore moved the instant segmentation finished —
// a photograph the user was looking at, jumping
// sideways because a label said "traffic light".
//
// Stated as a maximum for the reason `ChipGrid`
// declares its width from its column count rather than
// from its options: what a panel asks for must follow
// from its structure, never from its data. Past this
// the row elides, which is what `elide` was for.
//
// 160px is the same judgement as `ChipGrid`'s 88px
// chip — comfortable for the class names this model
// returns, and narrow enough that a subject list
// cannot be what sets the column.
min-width: 0px;
max-width: 160px;
}
Value { text: round(subject.score * 100) + "%"; }
}
}
}
if root.enabled && root.segmented && root.subjects.length == 0: Caption {
text: "Nothing recognised. The model knows people, animals and vehicles — "
+ "a landscape has no subject for it to find. Add a gradient instead.";
wrap: word-wrap;
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// --- the stack -----------------------------------------------------
Caption { text: "Masks"; }
// 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.
HorizontalLayout {
spacing: Theme.gap-sm;
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.";
wrap: word-wrap;
}
for mask in root.masks: MaskEntry {
data: mask;
enabled: root.enabled;
refining: root.refining;
selected(extend) => { root.mask-selected(mask.id, extend); }
removed => { root.mask-removed(mask.id); }
refined => { root.mask-refined(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); }
feather-changed(v) => { root.mask-feather-changed(mask.id, v); }
falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); }
morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); }
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }
}
// No caption saying which chain the sliders below are pointed at.
//
// That sentence used to be the *only* indication that selecting a
// layer had silently re-scoped thirty controls, and describing a
// hazard in a caption is not the same as removing it. The adjust
// panel's own heading names the layer it is editing now, which puts
// the answer on the thing that changed rather than above it.
}
}
/// TRACES: FR-DEV-3 | FR-UI-3
/// The mask's handles, drawn on the photograph.
///
/// A linear or radial mask could be made and then never moved: there was
/// nothing to grab, so a radial sat at the centre of the frame at its default
/// size for ever.
///
/// **Placed over the fitted image, not the canvas box.** The photograph is
/// letterboxed inside the canvas and Slint does not report the fitted rect, so
/// the develop view derives it and gives this component exactly that rect —
/// which is why everything in here is a plain fraction of `root.width` and
/// `root.height` rather than an offset from someone else's origin. Rust
/// supplies the positions already mapped through the framing — the same map
/// the shader applies — so a handle follows the mask through a zoom, a pan, a
/// crop and a straightening rather than sitting where the mask used to be.
///
/// **Drawn small, grabbed large.** The visible dot is 14px because a bigger
/// one would hide the edge it is placed on, and the `TouchArea` is a full
/// touch target, centred on it — `Button` establishes the same split. These
/// are the first controls in the application meant to be dragged on the
/// photograph, and a 12-inch tablet has no hover to reveal them with and no
/// modifier to qualify them by, so what is drawn is all there is (FR-UI-7).
export component GradientHandles inherits Rectangle {
in property <[GradientHandle]> handles;
/// role, from-x, from-y, to-x, to-y — all fractions of this rect.
callback dragged(HandleRole, float, float, float, float);
/// One history step per gesture, not per frame.
callback released();
background: transparent;
for handle in root.handles: Rectangle {
x: handle.x * root.width - self.width / 2;
y: handle.y * root.height - self.height / 2;
width: Theme.touch-target;
height: Theme.touch-target;
// The centre moves the whole mask, so it is filled; the others shape
// it and are rings. Shape rather than colour, because the handles sit
// on a photograph and any colour they carried would be read as part
// of it.
property <bool> solid: handle.role == HandleRole.centre;
Rectangle {
width: handle.role == HandleRole.rotate ? 12px : 14px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: 2px;
// White with a dark ring, so the handle is visible against a blown
// sky and against a black frame — one treatment, both extremes, no
// theme token because this is drawn over the image and not over
// the interface.
border-color: #00000099;
background: parent.solid ? #ffffff : #ffffff44;
}
drag := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: parent.solid
? MouseCursor.move
: MouseCursor.crosshair;
// Where the press landed, in the same fractions the callback
// reports in. Captured on the way down so the whole gesture is
// measured from one origin.
property <float> from-x;
property <float> from-y;
function fraction-x(local-x: length) -> float {
return (parent.x + local-x) / max(root.width, 1px);
}
function fraction-y(local-y: length) -> float {
return (parent.y + local-y) / max(root.height, 1px);
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.from-x = self.fraction-x(self.pressed-x);
self.from-y = self.fraction-y(self.pressed-y);
}
if (ev.kind == PointerEventKind.up) {
root.released();
}
}
moved => {
if (self.pressed) {
root.dragged(
handle.role,
self.from-x,
self.from-y,
self.fraction-x(self.mouse-x),
self.fraction-y(self.mouse-y),
);
}
}
}
}
}