// Local adjustments: the mask stack, the region overlay, and the subjects the // model recognised (FR-DEV-3). // // The panel is deliberately thin. A mask layer holds an ordinary develop // chain, so selecting one hands the *existing* adjust panel that chain — there // are no per-layer sliders here and there must not be, or every operation // added to `ops/` would need a local twin. import { Theme } from "theme.slint"; import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint"; import { Segmented, SliderRow } from "controls.slint"; /// One layer in the stack. export struct MaskRow { // Identity, opaque to this file — the callbacks hand it straight back. id: string, label: string, /// What kind of mask it is, for the row's second line. kind: string, enabled: bool, /// The layer the develop panel is currently editing. selected: bool, inverted: bool, opacity: float, /// Edge transition half-width, as a fraction of the frame's shorter edge. feather: float, /// Index into `Falloff::ALL`. falloff: int, /// Index into `Morphology::ALL`. morphology: int, morph-radius: float, /// Whether the edge controls apply. A gradient has its own geometry and /// does not go through the distance field, so showing them would offer /// controls that do nothing. shapeable: bool, /// Its region ids came from a different segmentation than the one loaded, /// so applying it would produce a confidently wrong shape. stale: bool, /// Whether it currently changes any pixel. A bare selection does not, and /// saying so is what stops it reading as broken. adjusted: bool, } /// Which part of a gradient a canvas handle drags. /// /// Named by the job rather than by the field, because the two gradients keep /// different things in the same place: `edge` is a linear ramp's width and a /// radial's major semi-axis, and `cross` is the radial's minor one. export enum HandleRole { centre, edge, cross, rotate } /// One draggable point on the photograph. /// /// Positioned in fractions of the image **as it is currently shown** — after /// the crop, the zoom and the pan — because that is the only space this file /// can draw in. Rust maps the mask's own source-space geometry into it on /// every frame, so the handle sits on the mask rather than beside it. export struct GradientHandle { role: HandleRole, x: float, y: float, } /// Something the model found. export struct SubjectRow { index: int, label: string, /// 0..1. Shown rather than hidden: the detector is a shortcut, not an /// authority, and a weak guess presented like a strong one makes the tool /// look wrong when it was merely unsure. score: float, } component MaskEntry inherits Rectangle { in property data; in property enabled: true; /// Whether a refine pass is running (for any row — only one runs at a /// time), so this row's button can say so and refuse a second press. in property refining: false; /// `true` when the click should add this row to the selection rather /// than replace it with just this row. callback selected(bool); callback removed(); callback toggled(bool); callback invert-toggled(bool); callback opacity-changed(float); callback feather-changed(float); callback falloff-picked(int); callback morphology-picked(int); callback morph-radius-changed(float); callback refined(); height: layout.preferred-height; background: root.data.selected ? Theme.surface-raised : transparent; border-radius: Theme.radius; touch := TouchArea { mouse-cursor: pointer; enabled: root.enabled; // `clicked` carries no modifiers, so a plain click and a // multi-select click need the pointer event itself: released, over // the row, with control or command held is "add to the selection" // rather than "replace it". pointer-event(event) => { if event.kind == PointerEventKind.up && event.button == PointerEventButton.left { root.selected(event.modifiers.control || event.modifiers.meta); } } } layout := VerticalLayout { padding: Theme.gap-sm; spacing: 2px; HorizontalLayout { spacing: Theme.gap-sm; // The eye. Disabling a layer is the A/B a local edit constantly // wants, so it is one press away rather than inside the row. visibility := TouchArea { width: Theme.touch-target; height: Theme.touch-target; mouse-cursor: pointer; enabled: root.enabled; clicked => { root.toggled(!root.data.enabled); } Label { text: root.data.enabled ? "◉" : "○"; emphasised: visibility.has-hover || root.data.enabled; horizontal-alignment: center; vertical-alignment: center; } } VerticalLayout { horizontal-stretch: 1; alignment: center; spacing: 0px; Label { text: root.data.label; emphasised: root.data.selected || touch.has-hover; overflow: elide; } Caption { // Elided rather than wrapped: this sits beside two fixed // targets in a row, and wrapping would give the rows of a // stack different heights for no gain. overflow: elide; // Three states worth distinguishing, and each has a // different remedy: stale needs the segmentation re-run, // unadjusted needs a slider moved, and the ordinary case // needs nothing said about it beyond what it is. text: root.data.stale ? "needs re-segmenting" : (root.data.adjusted ? root.data.kind : root.data.kind + " · no adjustment yet"); } } remove := TouchArea { width: Theme.touch-target; height: Theme.touch-target; mouse-cursor: pointer; enabled: root.enabled; clicked => { root.removed(); } Label { text: "×"; emphasised: remove.has-hover; horizontal-alignment: center; vertical-alignment: center; } } } // Opacity and invert appear only on the selected layer. On every row // they would triple the height of a stack of eight and put seven sets // of controls in front of the one being worked on. if root.data.selected: VerticalLayout { spacing: Theme.gap-sm; padding-top: Theme.gap-sm; SliderRow { label: "Opacity"; value: root.data.opacity; default-value: 1.0; minimum: 0.0; maximum: 1.0; changed(v) => { root.opacity-changed(v); } reset => { root.opacity-changed(1.0); } } invert := TouchArea { height: Theme.touch-target; mouse-cursor: pointer; clicked => { root.invert-toggled(!root.data.inverted); } Label { text: root.data.inverted ? "Inverted — edit applies outside" : "Invert"; emphasised: invert.has-hover || root.data.inverted; } } // Only a subject has a detection behind it worth re-running. A // gradient's edge is geometry the photographer placed, and there // is nothing for the model to find twice. if root.data.kind == "subject": Button { text: root.refining ? "Refining…" : "Refine mask"; enabled: !root.refining; clicked => { root.refined(); } } // The edge. All of it reads one signed distance field, so these // are free to move — none of them recomputes anything except a // compound morphology. if root.data.shapeable: SliderRow { label: "Feather"; hint: "How far the edit fades across the edge."; value: root.data.feather * 1000; default-value: 4; minimum: 0; maximum: 60; changed(v) => { root.feather-changed(v / 1000); } reset => { root.feather-changed(0.004); } } if root.data.shapeable: Segmented { label: "Falloff"; options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"]; selected: root.data.falloff; picked(i) => { root.falloff-picked(i); } } if root.data.shapeable: Segmented { label: "Edge"; hint: "Grow or shrink the outline the model drew. Close fills " + "pinholes; open removes specks."; options: ["None", "Grow", "Shrink", "Close", "Open"]; selected: root.data.morphology; picked(i) => { root.morphology-picked(i); } } if root.data.shapeable && root.data.morphology != 0: SliderRow { label: "Amount"; value: root.data.morph-radius * 1000; default-value: 0; minimum: 0; maximum: 40; changed(v) => { root.morph-radius-changed(v / 1000); } reset => { root.morph-radius-changed(0); } } } } } export component MaskPanel inherits Rectangle { in property enabled: true; in property <[MaskRow]> masks; in property <[SubjectRow]> subjects; /// A region map has been computed for this image. in property segmented: false; /// One is being computed now. in property segmenting: false; /// A refine pass is running for one subject layer. in property refining: false; /// Whether the photographer has hidden the overlay to see the photograph. /// Two-way bound to the window's own, so the canvas and this control never /// disagree about it. in-out property overlay-hidden: false; /// `true` asks for the slower, tiled pass. callback segment(bool); /// `true` when the click should add the layer to the selection rather /// than replace it. callback mask-selected(string, bool); callback mask-removed(string); callback mask-refined(string); callback mask-toggled(string, bool); callback mask-invert-toggled(string, bool); callback mask-opacity-changed(string, float); callback mask-feather-changed(string, float); callback mask-falloff-picked(string, int); callback mask-morphology-picked(string, int); callback mask-morph-radius-changed(string, float); callback add-gradient(bool); callback add-subject(int); background: Theme.surface; // **Flat, deliberately.** An earlier version grouped each state into its // own `if ...: VerticalLayout { ... }`. The nested layout under-reported // its height, so the divider and the mask row below it were placed over // the "Find regions" button — the sort of fault that is invisible in the // source and obvious the moment anyone looks at the panel. Conditioning // each child on its own keeps every element a direct child of one layout, /// 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 content-width: layout.preferred-width; min-width: root.content-width; // which is the layout that measures them. layout := VerticalLayout { padding: Theme.gap; spacing: Theme.gap-sm; alignment: start; HorizontalLayout { PanelHeading { text: "MASKS"; } Rectangle { horizontal-stretch: 1; } if root.segmented: Value { text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects"); } } if !root.enabled: Caption { text: "No image"; } // --- the region map --------------------------------------------- // // Segmentation is a thing the user starts. Half a second of watershed // and inference is too long to spend on every photograph opened, and // most edits never need one. if root.enabled && !root.segmented: Caption { text: "Find the subjects in this photograph, so a mask can follow one."; // Wrapped, or this sentence's single-line width becomes the // panel's minimum and levers the fixed 280px develop column open, // taking every other panel's controls off the right edge with it. wrap: word-wrap; } if root.enabled && !root.segmented: Button { text: root.segmenting ? "Looking…" : "Find subjects"; enabled: !root.segmenting; primary: true; clicked => { root.segment(false); } } // No "Overlay" button and no "Select" button. Both switched on things // that are now simply what local mode *is*: entering it draws the // region map and makes a click on the photograph mean "select". A mode // whose behaviour has to be armed separately is a mode that can be // entered and still do nothing, which is what these two allowed. if root.enabled && root.segmented: Caption { text: "Click a subject in the photograph to mask it."; wrap: word-wrap; } // Hiding the overlay is not the "Overlay" button this panel used to // have, and the distinction is the reason it is back. That one *armed* // the overlay: local mode could be entered and still show nothing, // which is the fault the comment below records. This one only takes an // overlay that is already there out of the way for a moment, which is // the one thing a photographer needs constantly and had no way to do — // a mask is judged against the photograph under it, and you cannot see // that photograph through the thing describing it. // // Reads as its own action rather than its state: "Show the mask" is // what pressing it will do, not what is currently true. if root.enabled && root.segmented: Button { text: root.overlay-hidden ? "Show the mask" : "Hide the mask"; clicked => { root.overlay-hidden = !root.overlay-hidden; } } // The second pass, offered rather than taken automatically. // // The model's input is a fixed 640x640 square and every frame is // letterboxed into it, so a subject that is small in the frame reaches // the model small — a bird at 200px in a 1600px proxy arrives at 80px, // and an outline traced at 80px is what a coarse mask is. Tiling runs // the model over overlapping windows instead, so that bird arrives at // its own size. // // It costs one inference per tile: about 2.8s for a 3x2 grid against // 470ms for the whole frame. That is the wrong trade for the usual // photograph, where the subject fills much of the frame and the first // pass is already the best answer available — so it is a button and // not a default, and it says what it costs. if root.enabled && root.segmented: Button { text: root.segmenting ? "Looking closer…" : "Look closer (slower)"; enabled: !root.segmenting; clicked => { root.segment(true); } } // Granularity, labelled by what it does rather than by its number: // "detail" is what a photographer is choosing between, where "300 // regions" is an implementation detail they would have to learn. // --- what the model found ---------------------------------------- if root.enabled && root.segmented && root.subjects.length > 0: Caption { // Says where these came from and how they differ from a region. // A list of four beside a count of three thousand invites exactly // one question, and the panel should answer it rather than // provoke it. text: "Click one to mask it. The outline is the model's, so soften or grow it below."; wrap: word-wrap; } if root.enabled && root.segmented: VerticalLayout { spacing: 0px; for subject in root.subjects: subject-row := TouchArea { height: Theme.touch-target; mouse-cursor: pointer; clicked => { root.add-subject(subject.index); } HorizontalLayout { spacing: Theme.gap-sm; Label { text: subject.label; emphasised: subject-row.has-hover; horizontal-stretch: 1; overflow: elide; } Value { text: round(subject.score * 100) + "%"; } } } } if root.enabled && root.segmented && root.subjects.length == 0: Caption { text: "Nothing recognised. The model knows people, animals and vehicles — " + "a landscape has no subject for it to find. Add a gradient instead."; wrap: word-wrap; } Rectangle { height: 1px; background: Theme.rule; } // --- the stack ----------------------------------------------------- Caption { text: "Masks"; } // Gradients need no segmentation, so they are offered whether or not // one has been computed — a graduated filter over a sky is a local // adjustment that never needed to know what a sky is. HorizontalLayout { spacing: Theme.gap-sm; Button { text: "Linear"; enabled: root.enabled; clicked => { root.add-gradient(false); } } Button { text: "Radial"; enabled: root.enabled; clicked => { root.add-gradient(true); } } } if root.enabled && root.masks.length == 0: Caption { text: "No local adjustments. Select a region, or add a gradient."; wrap: word-wrap; } for mask in root.masks: MaskEntry { data: mask; enabled: root.enabled; refining: root.refining; selected(extend) => { root.mask-selected(mask.id, extend); } removed => { root.mask-removed(mask.id); } refined => { root.mask-refined(mask.id); } toggled(on) => { root.mask-toggled(mask.id, on); } invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); } opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); } feather-changed(v) => { root.mask-feather-changed(mask.id, v); } falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); } morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); } morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); } } // No caption saying which chain the sliders below are pointed at. // // That sentence used to be the *only* indication that selecting a // layer had silently re-scoped thirty controls, and describing a // hazard in a caption is not the same as removing it. The adjust // panel's own heading names the layer it is editing now, which puts // the answer on the thing that changed rather than above it. } } /// TRACES: FR-DEV-3 | FR-UI-3 /// The mask's handles, drawn on the photograph. /// /// A linear or radial mask could be made and then never moved: there was /// nothing to grab, so a radial sat at the centre of the frame at its default /// size for ever. /// /// **Placed over the fitted image, not the canvas box.** The photograph is /// letterboxed inside the canvas and Slint does not report the fitted rect, so /// the develop view derives it and gives this component exactly that rect — /// which is why everything in here is a plain fraction of `root.width` and /// `root.height` rather than an offset from someone else's origin. Rust /// supplies the positions already mapped through the framing — the same map /// the shader applies — so a handle follows the mask through a zoom, a pan, a /// crop and a straightening rather than sitting where the mask used to be. /// /// **Drawn small, grabbed large.** The visible dot is 14px because a bigger /// one would hide the edge it is placed on, and the `TouchArea` is a full /// touch target, centred on it — `Button` establishes the same split. These /// are the first controls in the application meant to be dragged on the /// photograph, and a 12-inch tablet has no hover to reveal them with and no /// modifier to qualify them by, so what is drawn is all there is (FR-UI-7). export component GradientHandles inherits Rectangle { in property <[GradientHandle]> handles; /// role, from-x, from-y, to-x, to-y — all fractions of this rect. callback dragged(HandleRole, float, float, float, float); /// One history step per gesture, not per frame. callback released(); background: transparent; for handle in root.handles: Rectangle { x: handle.x * root.width - self.width / 2; y: handle.y * root.height - self.height / 2; width: Theme.touch-target; height: Theme.touch-target; // The centre moves the whole mask, so it is filled; the others shape // it and are rings. Shape rather than colour, because the handles sit // on a photograph and any colour they carried would be read as part // of it. property solid: handle.role == HandleRole.centre; Rectangle { width: handle.role == HandleRole.rotate ? 12px : 14px; height: self.width; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; border-radius: self.width / 2; border-width: 2px; // White with a dark ring, so the handle is visible against a blown // sky and against a black frame — one treatment, both extremes, no // theme token because this is drawn over the image and not over // the interface. border-color: #00000099; background: parent.solid ? #ffffff : #ffffff44; } drag := TouchArea { width: 100%; height: 100%; mouse-cursor: parent.solid ? MouseCursor.move : MouseCursor.crosshair; // Where the press landed, in the same fractions the callback // reports in. Captured on the way down so the whole 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); } if (ev.kind == PointerEventKind.up) { root.released(); } } moved => { if (self.pressed) { root.dragged( handle.role, self.from-x, self.from-y, self.fraction-x(self.mouse-x), self.fraction-y(self.mouse-y), ); } } } } }