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
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:
@@ -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
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user