The crop rectangle, the gradient handles and the repair discs were 480 lines inside `canvas-area` in app.slint, while the panels that drive them already lived in adjust.slint, masks.slint and spots.slint. spots.slint even opens by describing "what is drawn over the photograph, and what a finger can take hold of" -- which was not in it. They stayed behind because all three are positioned against `shown-*`, the fitted image rect the develop view derives because Slint does not report it. That is now the interface rather than the obstacle: each overlay is *given* that rect as its own bounds, so every position inside is a plain fraction of `root.width`, and none of them reaches out to `canvas-area` for an origin any more. GradientHandles joins MaskPanel in masks.slint and SpotHandles joins SpotPanel in spots.slint, each file now holding one domain's panel and its canvas overlay together, matching masks_ui.rs and spots_ui.rs. CropOverlay gets crop.slint of its own rather than growing adjust.slint. Arithmetic is unchanged: the old fraction-x subtracted shown-x from a coordinate measured relative to canvas-area, and the new one measures from an origin that already is shown-x. The handles stay unconditional rather than gaining an emptiness guard, so the repeater identity that spots_ui::sync_handles warns about is untouched. app.slint: 2834 -> 2490 lines. Verified by running the desktop app on a photograph, with the crop overlay's guard temporarily forced open so all three instantiate -- no binding loop, no panic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
298 lines
12 KiB
Plaintext
298 lines
12 KiB
Plaintext
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;
|
|
|
|
/// TRACES: FR-UI-2
|
|
/// How wide this panel has to be before it starts clipping itself. The
|
|
/// develop column is the largest of these and nothing else; publishing it
|
|
/// as `min-width` too is what makes the enclosing layout aggregate them
|
|
/// without this panel having to be referenced by name.
|
|
out property <length> content-width: layout.preferred-width;
|
|
min-width: root.content-width;
|
|
|
|
// 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.
|
|
layout := 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(); }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-8
|
|
/// The repairs, drawn on the photograph.
|
|
///
|
|
/// **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.
|
|
///
|
|
/// Sized by the develop view to the fitted image rect, so every position in
|
|
/// here is a plain fraction of `root.width` and `root.height`. The fractions
|
|
/// themselves arrive already mapped through the framing, so a repair follows a
|
|
/// crop, a zoom and a rotation rather than sitting where it used to be.
|
|
export component SpotHandles inherits Rectangle {
|
|
in property <[SpotHandle]> handles;
|
|
|
|
/// Touching a repair chooses it.
|
|
callback selected(string);
|
|
/// id, role, from-x, from-y, to-x, to-y — fractions of this rect.
|
|
callback dragged(string, SpotRole, float, float, float, float);
|
|
callback released();
|
|
|
|
background: transparent;
|
|
|
|
for spot in root.handles: Rectangle {
|
|
property <length> disc: max(2 * spot.radius * root.height, 8px);
|
|
property <bool> is-source: spot.role == SpotRole.source;
|
|
|
|
x: spot.x * root.width - self.width / 2;
|
|
y: spot.y * root.height - 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) / max(root.width, 1px);
|
|
}
|
|
function fraction-y(local-y: length) -> float {
|
|
return (parent.y + local-y) / max(root.height, 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.selected(spot.id);
|
|
}
|
|
if (ev.kind == PointerEventKind.up) {
|
|
root.released();
|
|
}
|
|
}
|
|
|
|
moved => {
|
|
if (self.pressed) {
|
|
root.dragged(
|
|
spot.id,
|
|
spot.role,
|
|
self.from-x,
|
|
self.from-y,
|
|
self.fraction-x(self.mouse-x),
|
|
self.fraction-y(self.mouse-y),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|