From 7f350e8c4c64f95df3a0f59664964d7168db6d8a Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 14:02:34 +0200 Subject: [PATCH] Offer the categories in the masks panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/dr-ui/src/masks_ui.rs | 48 +++++++++++++++++++++++++++++- ui/dr-ui/ui/app.slint | 6 +++- ui/dr-ui/ui/masks.slint | 63 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/ui/dr-ui/src/masks_ui.rs b/ui/dr-ui/src/masks_ui.rs index 2553d05..152b822 100644 --- a/ui/dr-ui/src/masks_ui.rs +++ b/ui/dr-ui/src/masks_ui.rs @@ -30,7 +30,7 @@ use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel}; use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId}; use crate::segmentation; -use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow}; +use crate::{sync_rows, AppWindow, CategoryRow, GradientHandle, MaskRow, ParamRow, SubjectRow}; /// What the adjust panel's heading says when the controls are global. /// @@ -146,12 +146,28 @@ impl Running { } } +/// A descriptor name as a list label. +/// +/// Only the first letter, because the names in `models/scene/categories.txt` +/// are already the words a photographer would use — "sky", "vegetation" — and +/// a lookup table would be a second place to edit every time one is added. +/// Title case on a multi-word name would be wrong anyway: "Swimming pool", not +/// "Swimming Pool". +fn category_label(name: &str) -> String { + let mut chars = name.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + /// Push every mask-related property from the session into the window. pub(crate) fn sync(window: &AppWindow, session: &Rc>>) { let slot = session.borrow(); let Some(s) = slot.as_ref() else { window.set_mask_rows(ModelRc::new(VecModel::::default())); window.set_subject_rows(ModelRc::new(VecModel::::default())); + window.set_category_rows(ModelRc::new(VecModel::::default())); window.set_segmented(false); window.set_adjust_scope(GLOBAL_SCOPE.into()); clear_handles(window); @@ -193,6 +209,19 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc = s + .categories() + .iter() + .map(|c| CategoryRow { + name: c.name.as_ref().into(), + label: category_label(&c.name).into(), + coverage: c.coverage, + }) + .collect(); + window.set_category_rows(ModelRc::new(VecModel::from(categories))); + window.set_segmented(s.has_segmentation()); window.set_adjust_scope(scope_label(s).into()); sync_handles(window, s); @@ -636,6 +665,22 @@ pub(crate) fn wire( redraw(&w); }); } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + let rows = rows.clone(); + window.on_add_category_mask(move |name| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.add_category_mask(&name); + } + sync(&w, &session); + sync_rows(&w, &rows, &session); + redraw(&w); + }); + } + { let weak = window.as_weak(); let session = session.clone(); @@ -796,6 +841,7 @@ pub(crate) fn reset(window: &AppWindow) { window.set_segmented(false); window.set_mask_rows(ModelRc::new(VecModel::::default())); window.set_subject_rows(ModelRc::new(VecModel::::default())); + window.set_category_rows(ModelRc::new(VecModel::::default())); window.set_adjust_scope(GLOBAL_SCOPE.into()); clear_handles(window); } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 7d21dbb..196f8aa 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -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 segmented: false; in property segmenting: false; in property 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 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 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 { diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index f3bf94f..6c97449 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -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 data; in property enabled: true; @@ -270,6 +282,7 @@ export component MaskPanel inherits Rectangle { in property 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 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.";