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 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 has-selection: selected != ""; /// How many repairs are on this photograph. in property 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 radius: 0.012; in property feather: 0.35; in property opacity: 1.0; /// 0 heal, 1 clone — the order the chips are listed in below. in property 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 disc: max(2 * spot.radius * root.height, 8px); property 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 from-x; property 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), ); } } } } }