Offer the categories in the masks panel

Everything before this was reachable only from an example that writes PPMs.
This is the part a photographer can touch: a list under the subjects, click
one, get a mask layer for every pixel of that category.

## Under the subjects, and the order is the argument

Clicking the photograph is how a local adjustment usually starts, so the
things the model *found* come first. A category is the move you reach for
deliberately — grade the sky, not this one bird — and putting it second says
so without a word of explanation.

Coverage is shown for the same reason a subject's score is: it tells the
photographer whether a category is worth a click before they spend one
finding out.

## Not a new tab, which is what was asked for

A develop tab is derived from `Attribute`, not declared — the tabs exist
because operations claim an attribute, and no amount of Slint adds one. A
seventh attribute would have meant duplicating every adjustment once per
category, and the combinatorics get silly by the third.

Reached through masks instead, a category composes with every adjustment
that already exists, and inherits feather and falloff rather than needing
its own. What was described as "per-category sliders with feather and decay"
is exactly what this is; only the door is different.

## Verified as far as it can be here

Compiles, populates, round-trips, 560 dr-ui tests green. **Not clicked** —
synthetic input is blocked on this setup, so how it looks and feels is
unverified and wants a human at the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 14:02:34 +02:00
co-authored by Claude Opus 5
parent 763dfd353a
commit 7f350e8c4c
3 changed files with 115 additions and 2 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, GroupStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { CropOverlay } from "crop.slint";
import { HistoryPanel, HistoryRow } from "history.slint";
@@ -962,6 +962,7 @@ export component AppWindow inherits Window {
in property <[MaskRow]> mask-rows;
in property <[SubjectRow]> subject-rows;
in property <[CategoryRow]> category-rows;
in property <bool> segmented: false;
in property <bool> segmenting: false;
in property <bool> refining: false;
@@ -1045,6 +1046,7 @@ export component AppWindow inherits Window {
callback mask-morph-radius-changed(string, float);
callback add-gradient-mask(bool);
callback add-subject-mask(int);
callback add-category-mask(string);
/// Whether the develop column — image info, geometry, adjust — is shown.
///
@@ -2472,6 +2474,7 @@ in property <bool> panel-visible: true;
enabled: root.adjust-enabled;
masks: root.mask-rows;
subjects: root.subject-rows;
categories: root.category-rows;
segmented: root.segmented;
segmenting: root.segmenting;
refining: root.refining;
@@ -2505,6 +2508,7 @@ in property <bool> panel-visible: true;
}
add-gradient(radial) => { root.add-gradient-mask(radial); }
add-subject(i) => { root.add-subject-mask(i); }
add-category(n) => { root.add-category-mask(n); }
}
if root.local-mode: Rectangle {
+63
View File
@@ -70,6 +70,18 @@ export struct SubjectRow {
score: float,
}
/// One photographic category the scene model weighed.
export struct CategoryRow {
/// The descriptor's name — "sky", "vegetation". Carried rather than an
/// index because the descriptor is editable and an index would repoint.
name: string,
label: string,
/// 0..1 of the frame. Shown for the same reason a subject's score is: it
/// tells the photographer whether the category is worth reaching for
/// before they spend a click finding out.
coverage: float,
}
component MaskEntry inherits Rectangle {
in property <MaskRow> data;
in property <bool> enabled: true;
@@ -270,6 +282,7 @@ export component MaskPanel inherits Rectangle {
in property <bool> enabled: true;
in property <[MaskRow]> masks;
in property <[SubjectRow]> subjects;
in property <[CategoryRow]> categories;
/// A region map has been computed for this image.
in property <bool> segmented: false;
@@ -302,6 +315,7 @@ export component MaskPanel inherits Rectangle {
callback add-gradient(bool);
callback add-subject(int);
callback add-category(string);
background: Theme.surface;
@@ -450,6 +464,55 @@ export component MaskPanel inherits Rectangle {
}
}
// --- whole categories ---------------------------------------------
//
// Below the subjects rather than above, and the order is the argument:
// clicking the photograph is how a local adjustment usually starts, so
// the things that were *found* come first. Categories are the move you
// reach for deliberately — grade the sky, not this one bird.
if root.enabled && root.segmented && root.categories.length > 0: Caption {
text: "Or grade a whole category. One adjustment covers every pixel of it.";
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
spacing: 0px;
for category in root.categories: category-row := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.add-category(category.name); }
HorizontalLayout {
spacing: Theme.gap-sm;
Label {
text: category.label;
emphasised: category-row.has-hover;
horizontal-stretch: 1;
overflow: elide;
// Same layout-time reason as the subject row above:
// a Text asks for its whole string's width whether or
// not it elides, and the develop column takes the
// widest minimum any panel declares.
max-width: 160px;
vertical-alignment: center;
}
Label {
// Coverage as a percentage, which is the unit the
// number means something in. A category under half a
// percent never reaches this list at all.
// A plain Label is already `ink-dim`; only
// `emphasised` lifts it, and a coverage readout is
// exactly the thing that should not compete with the
// name beside it.
text: round(category.coverage * 100) + "%";
vertical-alignment: center;
}
}
}
}
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.";