Merge branch 'master' into worktree-faces-scrfd-mbf
Build and test / Desktop (Linux) (push) Failing after 25s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Successful in 58s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m26s

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/develop.rs
#	ui/dr-ui/src/segmentation.rs
This commit is contained in:
2026-08-27 11:57:38 +02:00
51 changed files with 7732 additions and 586 deletions
+16 -6
View File
@@ -673,6 +673,14 @@ export enum ViewMode {
/// The region map is drawn, a click on the photograph selects, and the
/// column is the mask stack and the selected layer's adjustments.
local,
/// TRACES: FR-DEV-8
/// The repairs are drawn on the photograph, a click places one, and the
/// column describes whichever is selected.
///
/// A mode rather than a panel button for the reason the enum exists at
/// all: arming a click on the canvas is something only one tool may be
/// doing at a time, and two flags could both be true.
spots,
}
/// The strip: what the photographer is working on.
@@ -741,15 +749,17 @@ export component ModeStrip inherits Rectangle {
// than as an underline, so at a glance the strip reads as two runs
// and it is never ambiguous which half a lit entry belongs to.
//
// **Where a brush goes.** Painting a mask is a third mode of exactly
// this shape — it arms a canvas gesture and scopes the column — so it
// joins this list and the `ViewMode` enum, and needs nothing else here.
// `MaskSource::Brush` and the stroke calls on `MaskLayer` land in the
// core separately; what is missing on this side is only the canvas
// interaction, which is the gradient handles' neighbour.
// **Where a canvas tool goes.** A mode that arms a gesture on the
// photograph and scopes the column joins this list and the `ViewMode`
// enum, and needs nothing else here. *Repair* is the first to arrive
// that way (FR-DEV-8); painting a mask is the same shape and still to
// come — `MaskSource::Brush` and the stroke calls on `MaskLayer`
// already exist in the core, and what is missing on this side is only
// the canvas interaction the repairs now have a pattern for.
for entry in [
{ label: "Crop", value: ViewMode.crop },
{ label: "Local", value: ViewMode.local },
{ label: "Repair", value: ViewMode.spots },
]: mode-chip := TouchArea {
width: mode-name.preferred-width + 2 * Theme.gap-sm;
height: Theme.touch-target;
+275 -7
View File
@@ -1,6 +1,8 @@
import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { SpotHandle, SpotPanel, SpotRole } from "spots.slint";
import { HistoryPanel, HistoryRow } from "history.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
@@ -11,7 +13,7 @@ import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView }
export { ViewMode, GradientHandle, HandleRole }
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
// Status strip — surfaces the GPU backend and adapter, which matters during
// v0.1 because assumption A1 is exactly "does this compositing path work on
@@ -327,6 +329,8 @@ export component AppWindow inherits Window {
/// comparison is written once rather than at each of a dozen call sites.
property <bool> cropping: root.view-mode == ViewMode.crop;
property <bool> local-mode: root.view-mode == ViewMode.local;
/// TRACES: FR-DEV-8
property <bool> repairing: root.view-mode == ViewMode.spots;
/// The crop rect in fractions of the frame, mirrored from Rust so the
/// overlay draws exactly what the pipeline holds.
@@ -372,6 +376,15 @@ export component AppWindow inherits Window {
in property <bool> can-redo: false;
callback undo();
callback redo();
/// Every step, newest first. Rust owns the order and stamps each row with
/// its own position in the stack, so nothing here does arithmetic to turn
/// a row back into a step.
in property <[HistoryRow]> history-rows;
/// What undo would take back, resolved. Empty when there is nowhere to go.
in property <string> undo-label: "";
/// A row's own `index`.
callback history-picked(int);
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
@@ -1019,6 +1032,51 @@ export component AppWindow inherits Window {
/// rather than as one per frame of the gesture.
callback gradient-handle-released();
// --- repairs (FR-DEV-8) -------------------------------------------------
/// Every repair on the photograph, as circles in fractions of the shown
/// image. Rust maps them through the framing, so they follow a crop, a
/// zoom, a pan and a rotation without anything here knowing about any of
/// those.
in property <[SpotHandle]> spot-handles;
/// A click on the photograph in repair mode, in fractions of the shown
/// image: cover what is here.
callback spot-placed(float, float);
/// A repair's circle dragged: which repair, which half of it, where the
/// press landed and where the pointer is now.
///
/// The press is carried rather than a running delta for the reason the
/// gradient handles carry it — the geometry is re-derived from where it
/// stood when the gesture began, so a drag cannot accumulate rounding
/// error along its length.
callback spot-handle-dragged(string, SpotRole, float, float, float, float);
/// A drag finished, so it can be one history step rather than one a frame.
callback spot-handle-released();
/// A repair chosen, so the column describes it.
callback spot-selected(string);
/// Take the selected repair off the photograph.
callback spot-removed(string);
/// Which repair the column is describing, or "" for none.
in-out property <string> selected-spot: "";
/// The selected repair's settings, and how many repairs there are.
///
/// Pushed as scalars rather than read off `spot-handles`, because the
/// circles carry what is *drawn* — a radius already mapped through the
/// framing into fractions of the shown image — and the panel edits what is
/// *stored*. Deriving one from the other would mean a slider that moved
/// differently at different zoom levels.
in property <int> spot-count: 0;
in property <float> spot-radius: 0.012;
in property <float> spot-feather: 0.35;
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone.
in property <int> spot-mode: 0;
callback spot-radius-changed(float);
callback spot-feather-changed(float);
callback spot-opacity-changed(float);
callback spot-mode-picked(int);
callback segment-image(bool);
/// A click on the photograph, in fractions of the shown image, plus
/// whether it should extend the selection rather than replace it.
@@ -1765,6 +1823,30 @@ in property <bool> panel-visible: true;
}
}
// TRACES: FR-DEV-8
// Placing a repair, beside the region picker and for the
// same reasons: a click and a pan want different handlers,
// and this one has to reach the click first.
//
// Below the circles declared further down, so a press that
// lands on an existing repair takes hold of it instead of
// making another one on top.
if root.repairing && root.total > 0 && root.load-error == "": TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
clicked => {
root.spot-placed(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
}
}
if root.total > 0 && root.load-error == "": TouchArea {
x: 0; y: 0;
width: 100%;
@@ -2104,6 +2186,123 @@ in property <bool> panel-visible: true;
}
}
// --- repairs (FR-DEV-8) -------------------------------------
//
// **Drawn at the size they are.** A gradient's handles are
// dots because a gradient has no edge to show; a repair is
// a disc, and whether that disc covers a speck of dust is
// the entire judgement a photographer is making. A
// fixed-size dot standing in for it would say nothing about
// the edit.
//
// The source circle appears for the **selected** repair
// only. Every repair showing both would double the circles
// on a dusty sky and leave no way to tell which source
// belongs to which disc; Rust decides, and simply does not
// send the others.
//
// Positioned against `shown-*` like everything else that
// has to land on the picture, and mapped through the
// framing on the Rust side, so a repair follows a crop, a
// zoom and a rotation rather than sitting where it used to
// be.
for spot in root.spot-handles: Rectangle {
property <length> disc: max(2 * spot.radius * parent.shown-h, 8px);
property <bool> is-source: spot.role == SpotRole.source;
x: parent.shown-x + spot.x * parent.shown-w - self.width / 2;
y: parent.shown-y + spot.y * parent.shown-h - self.height / 2;
// Never smaller than a finger, whatever the repair's
// own size: a spot on a dust speck is a few pixels
// across at fit-to-window, and a target that small
// cannot be picked up again on a phone (FR-UI-3). The
// *drawn* circle keeps its true size; only the reach
// is padded.
width: max(self.disc, Theme.touch-target);
height: self.width;
Rectangle {
width: parent.disc;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: spot.selected ? 2px : 1px;
// White on a dark ring, so a repair is visible on a
// blown sky and on a black frame alike — the same
// treatment the gradient handles use, and no theme
// token, because this is drawn over the photograph
// rather than over the interface.
border-color: spot.enabled ? #ffffffcc : #ffffff55;
background: parent.is-source
? #ffffff14
: (spot.selected ? #ffffff22 : transparent);
}
// A second, darker ring just outside the first. One
// white circle vanishes against a white sky however it
// is drawn; two rings of opposite tone cannot both
// vanish against anything.
Rectangle {
width: parent.disc + 2px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: 1px;
border-color: #00000066;
}
grab := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: MouseCursor.move;
// Where the press landed, in the fractions the
// callback reports in — captured on the way down so
// the 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 - canvas-area.shown-x)
/ max(canvas-area.shown-w, 1px);
}
function fraction-y(local-y: length) -> float {
return (parent.y + local-y - canvas-area.shown-y)
/ max(canvas-area.shown-h, 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);
// Touching a repair is choosing it. On the
// way *down*, so the column has re-scoped
// by the time the drag begins and the
// controls describe what is moving.
root.spot-selected(spot.id);
}
if (ev.kind == PointerEventKind.up) {
root.spot-handle-released();
}
}
moved => {
if (self.pressed) {
root.spot-handle-dragged(
spot.id,
spot.role,
self.from-x,
self.from-y,
self.fraction-x(self.mouse-x),
self.fraction-y(self.mouse-y),
);
}
}
}
}
// Arrow keys and space step through the folder.
//
// Focused on show rather than waiting for a click, exactly
@@ -2141,6 +2340,18 @@ in property <bool> panel-visible: true;
}
return accept;
}
// TRACES: FR-DEV-8
// Delete removes the selected repair, and only in
// repair mode: the same key in the grid judges
// photographs, and a key that means two things
// depending on a mode the user has forgotten they
// are in is how work disappears.
if (root.repairing && root.selected-spot != ""
&& (event.text == Key.Delete
|| event.text == Key.Backspace)) {
root.spot-removed(root.selected-spot);
return accept;
}
if (event.text == Key.RightArrow || event.text == " ") {
root.next-image();
return accept;
@@ -2183,7 +2394,9 @@ in property <bool> panel-visible: true;
// Names the mode being left rather than saying
// "Done", which was unambiguous while there was
// one mode and would not be with two.
text: root.cropping ? "Done Cropping" : "Done Masking";
text: root.cropping
? "Done Cropping"
: (root.repairing ? "Done Repairing" : "Done Masking");
active: true;
clicked => { root.mode-picked(ViewMode.photo); }
}
@@ -2308,13 +2521,13 @@ in property <bool> panel-visible: true;
// screen. `masks.slint` carries the same note for
// the same reason.
column := VerticalLayout {
if !root.local-mode: InfoPanel {
if !root.local-mode && !root.repairing: InfoPanel {
camera: root.camera;
exposure: root.exposure;
dimensions: root.dimensions;
}
if !root.local-mode: Rectangle {
if !root.local-mode && !root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
@@ -2344,7 +2557,7 @@ in property <bool> panel-visible: true;
// made rather than how it is applied: the frame is decided
// by eye first and the pipeline runs it last (see
// `dr_pipeline::framing` on the coordinate order).
if !root.local-mode: GeometryPanel {
if !root.local-mode && !root.repairing: GeometryPanel {
enabled: root.adjust-enabled;
angle: root.straighten;
max-straighten: root.max-straighten;
@@ -2360,7 +2573,7 @@ in property <bool> panel-visible: true;
reset => { root.framing-reset(); }
}
if !root.local-mode: Rectangle {
if !root.local-mode && !root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
@@ -2370,7 +2583,7 @@ in property <bool> panel-visible: true;
// burying it under thirty sliders would put the one
// control that operates on all of them below all of
// them.
if !root.local-mode: TransferPanel {
if !root.local-mode && !root.repairing: TransferPanel {
enabled: root.adjust-enabled;
armed: root.settings-armed;
summary: root.settings-summary;
@@ -2393,6 +2606,32 @@ in property <bool> panel-visible: true;
// peers that silently re-points another panel, it is
// what that mode's column *is*. That also takes one
// panel out of a scrolling column that had six.
// TRACES: FR-DEV-8
// What the column *is* in repair mode, on the same
// terms as the mask panel: not a panel among peers
// that quietly re-points the sliders below it, but
// the whole of what this mode has to say.
if root.repairing: SpotPanel {
enabled: root.adjust-enabled;
has-selection: root.selected-spot != "";
count: root.spot-count;
radius: root.spot-radius;
feather: root.spot-feather;
spot-opacity: root.spot-opacity;
mode: root.spot-mode;
radius-changed(v) => { root.spot-radius-changed(v); }
feather-changed(v) => { root.spot-feather-changed(v); }
opacity-changed(v) => { root.spot-opacity-changed(v); }
mode-picked(i) => { root.spot-mode-picked(i); }
removed => { root.spot-removed(root.selected-spot); }
}
if root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
if root.local-mode: MaskPanel {
enabled: root.adjust-enabled;
masks: root.mask-rows;
@@ -2469,6 +2708,35 @@ in property <bool> panel-visible: true;
film-picked(i) => { root.film-picked(i); }
film-print-toggled(on) => { root.film-print-toggled(on); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Last in the column, and the length is why. The
// stack runs sixty-four deep, so anywhere else it
// would push the controls it is a record of below
// the fold. Its own rows run newest-first, which
// puts the steps worth reaching immediately under
// the heading rather than at the far end of them.
//
// Present in local mode as well. The mask work
// *is* history — adding a layer, moving a
// gradient and feathering an edge are all steps —
// and a list that emptied when the photographer
// entered the mode that produces the most steps
// would be a list they stopped trusting.
HistoryPanel {
rows: root.history-rows;
enabled: root.adjust-enabled;
can-undo: root.can-undo;
can-redo: root.can-redo;
undo-label: root.undo-label;
undo => { root.undo(); }
redo => { root.redo(); }
picked(i) => { root.history-picked(i); }
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
// TRACES: FR-DEV-5 | FR-DEV-7
// The steps this photograph has been through, and the way back to any of them.
//
// **Nothing here names an operation, and nothing here names a step.** The rows
// arrive already resolved — Rust turns each step's localisation key into a
// display string against the UI's catalogue — so an operation added to the
// pipeline as a YAML declaration appears in this list under a sensible name
// with no change to this file, on the same terms it appears in the panel
// (FR-DEV-3c).
//
// **Why a list and not just the two buttons.** Undo answers "take back the
// last thing", which is the question a photographer asks about the mistake
// they have just noticed. It is the wrong instrument for the one they notice
// six adjustments later: eight presses, each changing the picture, with no way
// to see how far back the mistake was without passing through it. A step is a
// whole state in `dr-pipeline`, so arriving at one from six away costs what
// arriving from one does — which is what makes a row worth making clickable
// rather than decorative.
import { Theme } from "theme.slint";
import { PanelHeading, Caption, Label, Value, Button } from "widgets.slint";
// One step, flattened for Slint's model system.
export struct HistoryRow {
// Routing back to the core: this step's position in the stack. **Not** its
// position in this list, which runs the other way — see `history-rows` in
// `develop.rs` for why the two are deliberately different numbers.
index: int,
// Resolved in Rust against the UI's catalogue; the core deals in keys.
label: string,
// The state the photograph is in right now. Exactly one row carries it.
current: bool,
// A step the photographer has stepped back *out* of, still reachable by
// redo. Listed rather than hidden — redo would otherwise arrive somewhere
// this panel never mentioned — but drawn as the branch it is.
undone: bool,
}
component StepRow inherits Rectangle {
in property <HistoryRow> data;
in property <bool> enabled: true;
callback picked();
height: Theme.row-height;
background: root.data.current
? Theme.selected
: (touch.has-hover ? Theme.hover : transparent);
touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.picked(); }
}
HorizontalLayout {
// Small, because the panel around this already pads. The row's
// background is the highlight for the current step, so it wants to be
// a band the width of the column rather than a chip inset from it.
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
// The mark for where the photograph stands. A filled bar against the
// leading edge rather than a tick beside the name: the eye finds one
// edge down a column of forty rows, and a glyph in the text column
// would have to be read.
Rectangle {
width: 2px;
height: parent.height;
background: root.data.current ? Theme.active : transparent;
}
Label {
text: root.data.label;
emphasised: root.data.current || touch.has-hover;
horizontal-stretch: 1;
overflow: elide;
// Dimmed rather than removed: this step is a future the
// photographer stepped out of, and it is still where redo goes.
opacity: root.data.undone ? 0.45 : 1.0;
}
}
}
export component HistoryPanel inherits Rectangle {
in property <[HistoryRow]> rows;
in property <bool> enabled: true;
in property <bool> can-undo: false;
in property <bool> can-redo: false;
/// What undo would take back, already resolved. Empty when there is
/// nowhere to go.
in property <string> undo-label: "";
callback undo();
callback redo();
/// A row's own `index`, not its position in `rows`.
callback picked(int);
background: Theme.surface;
// **Flat, deliberately** — `MaskPanel` carries the long version of this
// and it applies here unchanged: a nested layout under-reports its height,
// so what follows it gets drawn on top of what came before, which is
// invisible in the source and obvious the moment anyone opens the panel.
// Every element is a direct child of the one layout that measures them,
// and the ones that come and go carry their own condition rather than
// being grouped inside a wrapper.
//
// No explicit height either, for the same reason `AdjustPanel` and
// `MaskPanel` declare none: the row count changes as the photographer
// works, and a height pinned to a layout's preferred size is one more
// thing that has to keep up with a repeater.
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
// A heading, not a collapsible. `GeometryPanel` carries the argument
// and it holds here: the list is last in the column, so what a lid
// would save is scrolling past nothing.
HorizontalLayout {
PanelHeading { text: "HISTORY"; }
Rectangle { horizontal-stretch: 1; }
if root.enabled && root.rows.length > 1: Value {
text: root.rows.length - 1 + (root.rows.length == 2 ? " step" : " steps");
}
}
if !root.enabled: Caption { text: "No image"; }
// The pair, here as well as in the status strip. The strip's copy is
// the one that survives the column being put away; this one sits
// against the list that says what it will do, which is the pairing
// that makes either of them legible.
if root.enabled: HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Undo";
enabled: root.can-undo;
horizontal-stretch: 1;
clicked => { root.undo(); }
}
Button {
text: "Redo";
enabled: root.can-redo;
horizontal-stretch: 1;
clicked => { root.redo(); }
}
}
// What the left-hand button would take back, spelled out.
//
// On a caption rather than on the button, because `Button` sizes to
// its label and does not elide: "Undo Highlights & Shadows" is most of
// a 280px column on its own, and two of those would lever the column
// open — `MaskPanel` has the same note about an unwrapped sentence.
//
// Undo only. Redo's destination is the row directly above the mark in
// the list below, where it can be seen; the step undo takes back is
// the one the photographer is standing on and stopped tracking after a
// run of small adjustments, which is the moment they are least willing
// to press a button to find out.
if root.enabled && root.can-undo: Caption {
text: "Undo: " + root.undo-label;
overflow: elide;
}
if root.enabled && root.rows.length > 0: Rectangle {
height: 1px;
background: Theme.rule;
}
// Newest first. The list is consulted to take back something just
// done rather than browsed from the beginning — the order
// `dr_catalog::trash` settled on for the same question — and it keeps
// the end being worked at against the heading rather than sixty-four
// rows below it.
for row in root.rows: StepRow {
data: row;
enabled: root.enabled;
picked => { root.picked(row.index); }
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Caption, Value } from "widgets.slint";
import { Segmented, SliderRow } from "controls.slint";
// TRACES: FR-DEV-8
// Spot removal on the canvas: what is drawn over the photograph, and what a
// finger can take hold of.
//
// A repair is two circles and the line between them — the disc being covered,
// and the patch it is copied from. Both are drawn at the size they actually
// are, not as abstract handles, because the size *is* the edit: a photographer
// judging whether a disc covers a mark is judging a circle against a speck, and
// a fixed-size dot standing in for it would tell them nothing.
//
// Positions arrive already mapped through the framing, as fractions of the
// shown image, exactly as `GradientHandle` does — see `spots_ui.rs`. Nothing
// here knows what a crop is.
/// Which half of a repair a handle is.
export enum SpotRole {
/// The disc over the mark. Dragging it moves the whole repair, source and
/// all, which is what a photographer means by nudging a spot.
destination,
/// Where the patch is read from. Dragging it moves the source alone.
source,
}
/// One circle of one repair, in fractions of the shown image.
export struct SpotHandle {
id: string,
role: SpotRole,
/// Centre, in fractions of the shown image's width and height.
x: float,
y: float,
/// The disc's radius as a fraction of the shown image's **height**.
///
/// One axis rather than two, because a repair is a circle: normalising x
/// by the width and y by the height would draw it as an ellipse on every
/// frame that is not square. The caller resolves the aspect on the way in.
radius: float,
/// Whether this repair is the one the panel is describing.
selected: bool,
/// Whether the repair draws at all — a spot switched off is still shown,
/// faintly, because it is still an edit somebody made.
enabled: bool,
}
/// TRACES: FR-DEV-8
/// The column while the repair tool is up.
///
/// One repair at a time, because that is how repairs are made: a photographer
/// covers a mark, looks at it, and moves on. There is no stack here for the
/// same reason there is one for masks — a mask is a thing you come back to and
/// re-shape, and a repair is either right or deleted.
///
/// **The controls describe the selected repair, and when none is selected they
/// describe nothing.** An earlier shape had them set the defaults for the
/// *next* repair, which reads identically on screen and does something
/// completely different: a photographer dragging Size with nothing selected
/// would see no change on the photograph and conclude the slider was broken.
export component SpotPanel inherits Rectangle {
in property <bool> enabled: true;
/// Whether a repair is selected — everything below is about it.
in property <bool> has-selection: false;
/// How many repairs are on this photograph.
in property <int> count: 0;
/// In frame units, which is what the model stores. The slider shows them
/// as a percentage of the frame's height, since "0.012" means nothing to
/// anybody and "1.2%" at least says how much of the picture is covered.
in property <float> radius: 0.012;
in property <float> feather: 0.35;
// `spot-opacity` and not `opacity`, which every element already has as a
// built-in: overriding it is refused by the compiler, and had it been
// allowed it would have faded the panel instead of describing the repair.
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone — the order the chips are listed in below.
in property <int> mode: 0;
callback radius-changed(float);
callback feather-changed(float);
callback opacity-changed(float);
callback mode-picked(int);
callback removed();
background: Theme.surface;
// Flat rather than nested, for the reason `MaskPanel` gives: a nested
// conditional layout under-reported its height and drew rows on top of one
// another.
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
HorizontalLayout {
PanelHeading { text: "REPAIR"; }
Rectangle { horizontal-stretch: 1; }
if root.count > 0: Value {
text: root.count + (root.count == 1 ? " spot" : " spots");
}
}
if !root.enabled: Caption { text: "No image"; }
// The instruction, which is the whole interface until the first click.
// A tool whose canvas gesture is its only way in has to say so, or it
// is a mode that appears to do nothing.
if root.enabled && !root.has-selection: Caption {
text: root.count == 0
? "Click a mark on the photograph to cover it."
: "Click a mark to cover it, or a circle to adjust it.";
wrap: word-wrap;
}
if root.enabled && root.has-selection: SliderRow {
label: "Size";
hint: "% of frame";
value: root.radius * 100;
default-value: 1.2;
minimum: 0.1;
maximum: 25;
precision: 1;
changed(v) => { root.radius-changed(v / 100); }
reset => { root.radius-changed(0.012); }
}
if root.enabled && root.has-selection: SliderRow {
label: "Feather";
hint: "% of size";
value: root.feather * 100;
default-value: 35;
minimum: 0;
maximum: 100;
precision: 0;
changed(v) => { root.feather-changed(v / 100); }
reset => { root.feather-changed(0.35); }
}
if root.enabled && root.has-selection: SliderRow {
label: "Opacity";
value: root.spot-opacity * 100;
default-value: 100;
minimum: 0;
maximum: 100;
precision: 0;
changed(v) => { root.opacity-changed(v / 100); }
reset => { root.opacity-changed(1.0); }
}
// Heal first, because it is the default and the right answer for dust.
// Clone is the escape for a repair that straddles an edge, where
// interpolating the boundary smears the edge across the disc.
if root.enabled && root.has-selection: Segmented {
label: "Blend";
options: ["Heal", "Clone"];
selected: root.mode;
picked(i) => { root.mode-picked(i); }
}
if root.enabled && root.has-selection: Rectangle {
height: Theme.gap-sm;
}
if root.enabled && root.has-selection: Button {
text: "Delete Repair";
clicked => { root.removed(); }
}
}
}