Build and test / Desktop (Linux) (push) Successful in 21m53s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Successful in 32s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 53m45s
The column was 280px, a number chosen for a tablet, with 380px bolted on later for a desktop. Both were guesses at how much room the widest row inside needs, and a guess is what cannot work here: the mode strip is one chip per attribute the *operation set declares*, so the row is generated and no constant in app.slint can track it. When the guess came up short the failure was not a tidy clip. The Flickable inside the column never had its `viewport-width` set, so the viewport took its content's preferred width, and a viewport wider than its Flickable is *centred* in it — the same rule the note on the seam's `x: 0` already records a few lines below. So the column lost half of each edge rather than one of them: "HISTOGRAM" read "ISTOGRAM", "Straighten" read "aighten", Copy sat centred while Paste ran off the far side. It looked like a rendering fault and it was an alignment one. So the column asks instead of guessing. Every panel that can appear in it — image, histogram, geometry, settings transfer, masks, repairs, adjust, history — now publishes a `content-width`: how wide it has to be before it starts clipping itself, read off its own layout rather than asserted. Each declares that as its `min-width` too, and that is what makes the aggregation automatic: `column` is a layout, so it already reports the largest minimum among its children, and it does so for the panels that come and go with the mode as well, which live inside `if`s and cannot be named from outside. Grep `content-width` in ui/dr-ui/ui to see every panel with a say in the answer. The mode strip is named explicitly only because it is pinned outside that layout, so nothing else measures it. There is no floor left. A floor is one more guess and the panels state their own minimums now. The only thing still above the measurement is `panel-max-width`, which is not a size but a policy — a column may not take the window from the photograph it exists to serve — and it comes from Rust beside `layout-class` because a width read from `root.width` inside the layout that `root.width` depends on is a binding loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
7.1 KiB
Plaintext
179 lines
7.1 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(); }
|
|
}
|
|
}
|
|
}
|