Every panel in the develop column declared its inputs and its callbacks and had `app.slint` bind each one to a property or a callback on the window root. That is fine while a panel is drawn once. N9 draws them a second time, in the portrait dock, and the wiring is what would have to be copied: `MaskPanel` alone ran to forty lines of forwarding, and a callback added to one copy and not the other compiles, renders, and simply does nothing on the layout nobody was looking at. So the wiring moved to Slint globals. A panel reads the global and calls the global; Rust hooks the global instead of the window; and the instantiation in the column is now the panel's name and a pair of braces — every one of the ten children of the column, with no property that differs by placement left to supply. There is a global per panel family rather than one for all of them, and the reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`, `HistogramView` — is declared in the panel's own file, so a single global holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing `masks.slint` and `adjust.slint` while both imported the global back, which Slint rejects. Breaking that needs six model declarations relocated, which is a change to the data model and not to the plumbing this is about. A global beside the panel it serves also lets each name drop the prefix it was carrying only because the window root is one flat namespace: `root.spot-radius` is `Repair.radius`, and `root.peaking-on` is `Peaking.showing`. `session.slint` is new and holds the two facts every family needs and none of them owns: whether there is an open photograph to edit, and which mode the view is in, with the three readings of the mode derived once instead of at each of the dozen places that tested one. `ViewMode` moves there from `adjust.slint`, where it was only ever a lodger. Nothing on screen changes. What is not here: the tool rail and the status strip still take their properties at the instantiation, because they are drawn once and N9 does not copy them; the preset sheet's own state stays on the window, because the library grid opens the same sheet and a global cannot bind the window's state — which is why `Transfer.open-presets` is handled in `presets.rs`, beside the summary it already had to compute.
309 lines
12 KiB
Plaintext
309 lines
12 KiB
Plaintext
import { Theme } from "theme.slint";
|
|
import { Develop } from "session.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 selected repair, and the controls that describe it.
|
|
///
|
|
/// A global rather than properties on the panel — `session.slint` gives the
|
|
/// argument. Which repair is selected is a fact about the photograph, not
|
|
/// about a panel, and the keyboard's Delete acts on the same selection the
|
|
/// panel's button does; before this both went through the window root and the
|
|
/// two spellings had to be kept in step by hand.
|
|
///
|
|
/// `opacity` may be spelled plainly here. On the panel it had to be
|
|
/// `spot-opacity`, because every *element* already has an `opacity` built in
|
|
/// and overriding it would have faded the panel rather than described the
|
|
/// repair; a global is not an element and has no such built-in.
|
|
export global Repair {
|
|
/// Which repair the column is describing, or "" for none.
|
|
in-out property <string> selected: "";
|
|
/// Whether a repair is selected — everything below is about it.
|
|
///
|
|
/// Derived rather than pushed, so it cannot fall out of step with the
|
|
/// selection it is a reading of.
|
|
out property <bool> has-selection: selected != "";
|
|
/// 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;
|
|
in property <float> 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);
|
|
/// Take the named repair off the photograph. Named rather than implied,
|
|
/// because the keyboard reaches this too.
|
|
callback removed(string);
|
|
}
|
|
|
|
/// 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 {
|
|
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 Repair.count > 0: Value {
|
|
text: Repair.count + (Repair.count == 1 ? " spot" : " spots");
|
|
}
|
|
}
|
|
|
|
if !Develop.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 Develop.enabled && !Repair.has-selection: Caption {
|
|
text: Repair.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 Develop.enabled && Repair.has-selection: SliderRow {
|
|
label: "Size";
|
|
hint: "% of frame";
|
|
value: Repair.radius * 100;
|
|
default-value: 1.2;
|
|
minimum: 0.1;
|
|
maximum: 25;
|
|
precision: 1;
|
|
changed(v) => { Repair.radius-changed(v / 100); }
|
|
reset => { Repair.radius-changed(0.012); }
|
|
}
|
|
|
|
if Develop.enabled && Repair.has-selection: SliderRow {
|
|
label: "Feather";
|
|
hint: "% of size";
|
|
value: Repair.feather * 100;
|
|
default-value: 35;
|
|
minimum: 0;
|
|
maximum: 100;
|
|
precision: 0;
|
|
changed(v) => { Repair.feather-changed(v / 100); }
|
|
reset => { Repair.feather-changed(0.35); }
|
|
}
|
|
|
|
if Develop.enabled && Repair.has-selection: SliderRow {
|
|
label: "Opacity";
|
|
value: Repair.opacity * 100;
|
|
default-value: 100;
|
|
minimum: 0;
|
|
maximum: 100;
|
|
precision: 0;
|
|
changed(v) => { Repair.opacity-changed(v / 100); }
|
|
reset => { Repair.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 Develop.enabled && Repair.has-selection: Segmented {
|
|
label: "Blend";
|
|
options: ["Heal", "Clone"];
|
|
selected: Repair.mode;
|
|
picked(i) => { Repair.mode-picked(i); }
|
|
}
|
|
|
|
if Develop.enabled && Repair.has-selection: Rectangle {
|
|
height: Theme.gap-sm;
|
|
}
|
|
|
|
if Develop.enabled && Repair.has-selection: Button {
|
|
text: "Delete Repair";
|
|
clicked => { Repair.removed(Repair.selected); }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|