Files
DarkRoom/ui/dr-ui/ui/spots.slint
T
dtourolleandClaude Opus 5 4fa914cbb1 Send each canvas overlay home to its own domain
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>
2026-08-27 21:35:08 +02:00

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),
);
}
}
}
}
}