Files
DarkRoom/ui/dr-ui/ui/spots.slint
T
dtourolleandClaude Opus 5 ef07e6ca3e
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Give the canvas tools a rail of their own, and the column one width
Crop, Local and Repair were chips at the head of the develop column, sharing a
row with the adjustment groups and told apart from them by the shape of their
highlight. Three things followed from that, and only the last is cosmetic: the
column closes, so the way out of a mode went away with the way in — hence the
duplicate "Done Cropping" over the canvas; the chips are generated from the
operation set, so the widest thing in the sidebar was a row nobody had chosen
the contents of; and a mode and a filter are different kinds of state wearing
one control.

They are a fixed 60px rail down the left now, generated from a single table in
toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant;
nothing in app.slint is touched to add one. What is left of the strip is the
group filters, so it is GroupStrip.

The column stops measuring itself. Every panel published a content-width and
declared it as min-width, and the column took the largest — which spent the
photograph's pixels on whatever happened to be widest, and moved the image
sideways when switching tools swapped one set of panels for another. It is
panel-width now, one number in style.yaml.

That number is 360 and it is measured, not picked: the contents report a
minimum of 344 in every mode, and they do not compress below it because a Text
that does not elide reports the same minimum as preferred. 320 was tried and
sliced Paste down the middle. The Flickable's viewport is floored at the
layout's minimum rather than its preferred width for the same reason — content
that is never told how much room it has cannot adapt to having less.

Removing the eight content-width declarations repairs three comments an
earlier edit had spliced sentences into. The raw histogram's note on keeping
its hint short is rewritten rather than dropped: an over-long hint no longer
widens the column, it pushes the column's minimum past the width it has and
clips the panel, which makes that constraint sharper rather than obsolete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:59:50 +02:00

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