diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 555c9a8..b20abd4 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -770,6 +770,35 @@ impl DevelopSession { .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> { self.active_mask.as_deref() } diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index cf23dd6..b21eaf7 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -32,6 +32,7 @@ mod library_ui; mod live_style; mod net_runtime; mod presets; +mod masks_ui; mod segmentation; mod settings_store; 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 // before the new frame settles is exactly long enough to read it. 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. @@ -1723,6 +1729,12 @@ pub fn run(paths: Vec) -> Result<()> { 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 session = session.clone(); diff --git a/ui/dr-ui/src/masks_ui.rs b/ui/dr-ui/src/masks_ui.rs new file mode 100644 index 0000000..d54341e --- /dev/null +++ b/ui/dr-ui/src/masks_ui.rs @@ -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>>) { + 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_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 = 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 = 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>>, + rows: &Rc>, + redraw: &Rc, + gpu: Option, +) { + // --- 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::::default())); + window.set_subject_rows(ModelRc::new(VecModel::::default())); + window.set_editing_mask(false); +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index d332ac3..2a35dcb 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -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 expanded: true; in property 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 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 overlay-strength: 0.55; + in property overlay-on: false; + /// Clicking the canvas selects a region instead of panning. + in property 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 shift-held: false; + + in property <[MaskRow]> mask-rows; + in property <[SubjectRow]> subject-rows; + in property segmented: false; + in property segmenting: false; + in property segmentation-level: 300; + in property segmentation-regions: 0; + in property 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 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 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 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 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; diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint new file mode 100644 index 0000000..0c0d375 --- /dev/null +++ b/ui/dr-ui/ui/masks.slint @@ -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 data; + in property 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 enabled: true; + in property <[MaskRow]> masks; + in property <[SubjectRow]> subjects; + + /// A region map has been computed for this image. + in property segmented: false; + /// One is being computed now. + in property segmenting: false; + /// How many regions the ladder is currently cut to. + in property level: 300; + in property region-count: 0; + + /// Draw the false-coloured region map over the photograph. + in property overlay: false; + /// Clicking the photograph selects a region rather than panning. + in property 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 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."; + } + } +}