// 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 { Develop } from "session.slint"; import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint"; import { Icon } from "icons.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, /// How strictly a category is cut back to the pixels whose colour agrees /// with it, in nats. Zero is the model's own weighting. refine: float, /// Whether this layer has a colour model fitted to refine against. Only a /// category has one, and only where the frame gave enough to fit it. refinable: bool, /// 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, /// Whether the band controls apply — a mask made from a range of the /// photograph's own values rather than from a shape (FR-DEV-10). /// /// The complement of `shapeable` in practice but not by definition: a /// range has no boundary in the picture, so it has no distance from one /// for a feather or a dilation to act on, and its edge is the softness of /// its own band instead. ranged: bool, /// Whether that range is over colour rather than over brightness, so the /// hue arc applies as well as the band. chromatic: bool, /// The band's lower and upper bounds, and the fade at each edge. Tone /// positions for a brightness range, colourfulness for a colour one — /// two readings of one control, which is why they share three fields. band-lo: float, band-hi: float, band-softness: float, /// A colour range's arc: centre and half-width, in turns. hue: float, hue-width: float, /// 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, /// TRACES: FR-DEV-19c /// Whether its mask is drawn over the photograph — the eye on its row. shown: bool, /// The colour it is drawn in, and which swatch that is. Both, because the /// row draws the colour and the swatch strip lights the index, and /// deriving one from the other here would mean a second copy of the /// palette in this file. colour: color, colour-index: int, } /// One selection inside a layer's mask (FR-DEV-19a). /// /// A mask is a fold over these, so the list is ordered and the order is what /// it means: a subtraction placed before the part it was meant to cut into /// takes nothing away. export struct PartRow { /// Identity, opaque to this file — the callbacks carry the index instead, /// because that is what a row is addressed by on screen. id: string, /// What this part selects — "brush", "subject", "radial". label: string, /// 0 adds to the mask, 1 takes away from it. Meaningless on the first. join: int, /// Whether the brush and the edge controls point at this part. selected: bool, /// The selection the layer began as. It joins nothing and cannot be /// removed — removing it is removing the layer. base: bool, /// Left out of the build, and kept. The row's eye. hidden: 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, } /// One photographic category the scene model weighed. export struct CategoryRow { /// The descriptor's name — "sky", "vegetation". Carried rather than an /// index because the descriptor is editable and an index would repoint. name: string, label: string, /// 0..1 of the frame. Shown for the same reason a subject's score is: it /// tells the photographer whether the category is worth reaching for /// before they spend a click finding out. coverage: 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; /// TRACES: FR-DEV-19a /// What this layer's mask is built from. Empty unless the row is the one /// selected, since the parts of a mask nobody is editing are noise. in property <[PartRow]> parts; /// `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 refine-changed(float); callback falloff-picked(int); callback morphology-picked(int); callback morph-radius-changed(float); /// Lower bound, upper bound, softness — all three, because they are one /// control: dragging the lower past the upper swaps them, and a callback /// per field would have to answer that with a third of the answer. callback band-changed(float, float, float); /// Centre hue and half-width, in turns. callback hue-changed(float, float); callback refined(); /// TRACES: FR-DEV-19a /// Point the tools at one part, change how it joins, or take it out. callback part-selected(int); callback part-join-picked(int, int); callback part-hidden-toggled(int, bool); callback part-removed(int); /// Join a fresh painted correction: 0 adds, 1 subtracts. callback part-added(int); /// TRACES: FR-DEV-19c callback shown-toggled(bool); callback colour-picked(int); 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; // GESTURE: Show or hide one mask layer // where: Develop // touch: Tap the ring at the head of its row // pointer: Click the ring at the head of its row // keys: H, for the selected layer // why: Disabling a layer is the before-and-after a local // edit constantly wants, so it is one press away rather // than inside the row. It is an edit and does take a // keys: H, for the selected layer // history step, unlike holding "Before" — the layer // really is off until it is switched back on. // // 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; // Both lines below are bounded for the reason the subject // row is: a mask's label and kind come from the model, and an // unbounded `Text` asks for its whole string at layout time // even when `elide` means it will never draw it. A mask *is* a // segmentation result, so without this the column moved when a // subject was clicked as well as when one was found. Label { text: root.data.label; emphasised: root.data.selected || touch.has-hover; overflow: elide; min-width: 0px; max-width: 160px; } 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; min-width: 0px; max-width: 160px; // 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"); } } // GESTURE: Show or hide one mask on the photograph // where: Develop // touch: Tap the eye on its row // pointer: Click the eye on its row // why: A mask is judged by seeing where it falls, and two // are judged by seeing where they meet — so each row // has its own eye rather than the panel having one, // and the eye is drawn in the colour the mask shows // in, so the row says which shape on the picture is // its. Nothing about the edit changes: this is how // the photograph is looked at, and takes no history // step. // // TRACES: FR-DEV-19c eye := TouchArea { width: Theme.touch-target; height: Theme.touch-target; mouse-cursor: pointer; enabled: root.enabled; clicked => { root.shown-toggled(!root.data.shown); } Icon { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; size: 18px; name: root.data.shown ? "eye" : "eye-closed"; // Open, in the mask's own colour; closed, in the dim ink // every idle control wears — so a glance down the stack // says which masks are on the picture and in what. ink: root.data.shown ? root.data.colour : (eye.has-hover ? Theme.ink : Theme.ink-dim); } } 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; // TRACES: FR-DEV-19a // What this mask is built from, in the order it is built. // // Shown even when there is only one part, because the row is what // says the edge controls below belong to a *selection* rather than // to the whole layer — and because it is where the next one is // added. A photographer who cannot see that a mask has parts has // no way to guess that the feather they are dragging moves one of // them. for part[i] in root.parts: Rectangle { height: Theme.touch-target; background: part.selected ? Theme.surface : transparent; border-radius: Theme.radius; // Declared before the two buttons, so it sits *behind* them: a // press on the join chip or the cross does its own job, and a // press anywhere else on the row points the tools at the part. pick := TouchArea { mouse-cursor: pointer; enabled: root.enabled; clicked => { root.part-selected(i); } } HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; // One chip, two states, because there are two ways to // join and a photographer flips between them while // looking at the picture rather than at a menu. join := TouchArea { width: Theme.touch-target; mouse-cursor: part.base ? MouseCursor.default : MouseCursor.pointer; enabled: root.enabled && !part.base; clicked => { root.part-join-picked(i, part.join == 0 ? 1 : 0); } Label { // The first part joins nothing — a chip saying // "adds" on it would be a control that moves and // changes no pixel. text: part.base ? "◆" : (part.join == 0 ? "+" : "−"); emphasised: join.has-hover || part.selected; horizontal-alignment: center; vertical-alignment: center; } } Caption { text: part.label; overflow: elide; min-width: 0px; max-width: 140px; vertical-alignment: center; horizontal-stretch: 1; } // GESTURE: Leave one part out of a mask, and put it back // where: Develop // touch: Tap the ring on the part's row // pointer: Click the ring on the part's row // why: The question a correction raises is whether // it did what it was for — whether the stroke // filled the shoulder, whether the subtracted // gradient took only the sky. Removing it // answers that and loses it. The same ring the // layer wears, one row down, because it is the // same question about a smaller thing. // // TRACES: FR-DEV-19a part-eye := TouchArea { width: Theme.touch-target; mouse-cursor: pointer; enabled: root.enabled; clicked => { root.part-hidden-toggled(i, !part.hidden); } Label { text: part.hidden ? "○" : "◉"; emphasised: part-eye.has-hover || !part.hidden; horizontal-alignment: center; vertical-alignment: center; } } if !part.base: cut := TouchArea { width: Theme.touch-target; mouse-cursor: pointer; enabled: root.enabled; clicked => { root.part-removed(i); } Label { text: "×"; emphasised: cut.has-hover; horizontal-alignment: center; vertical-alignment: center; } } } } HorizontalLayout { spacing: Theme.gap-sm; Button { text: "+ Add"; enabled: root.enabled; clicked => { root.part-added(0); } } Button { text: "− Subtract"; enabled: root.enabled; clicked => { root.part-added(1); } } } // TRACES: FR-DEV-19b // **The brush lives with the mask it paints into.** It used to sit // at the top of the panel, a screen's height from the row that // had just been made, and appeared only once a row was selected — // so the whole of the answer to "how do I paint" was a strip // somebody had to scroll up to find and had no reason to look // for. Here it is under the parts it adds to, which is what a // stroke does. // // In the panel and not in the tool rail, still: the rail's // entries arm a gesture for the whole photograph, and a brush has // no meaning without a mask to paint into. Segmented { label: "Brush"; // Said on the model's own masks especially: a subject or a // category arrives approximately right, and the correction // the photographer reaches for is a stroke where it stopped // short and an erase where it leaked — which becomes a part // of this mask, joined to the model's, and can be taken out // again from the list above. hint: (root.data.kind == "subject" || root.data.kind == "category") ? "Correct the model's outline by hand: Paint adds to this " + "mask under the pointer, Erase takes away. Each becomes " + "a part above, and a stroke is one step in the history." : "Paint adds to this mask under the pointer; Erase takes " + "away. A stroke is one step in the history."; options: ["Select", "Paint", "Erase"]; selected: Masking.tool; picked(i) => { Masking.tool-picked(i); } } if Masking.tool > 0: SliderRow { label: "Size"; value: Masking.brush-radius; default-value: 0.05; minimum: 0.005; maximum: 0.4; precision: 3; changed(v) => { Masking.brush-changed(v, Masking.brush-hardness, Masking.brush-flow); } reset => { Masking.brush-changed(0.05, Masking.brush-hardness, Masking.brush-flow); } } if Masking.tool > 0: SliderRow { label: "Hardness"; value: Masking.brush-hardness; default-value: 0.5; minimum: 0.0; maximum: 1.0; precision: 2; changed(v) => { Masking.brush-changed(Masking.brush-radius, v, Masking.brush-flow); } reset => { Masking.brush-changed(Masking.brush-radius, 0.5, Masking.brush-flow); } } if Masking.tool > 0: SliderRow { label: "Flow"; value: Masking.brush-flow; default-value: 1.0; minimum: 0.05; maximum: 1.0; precision: 2; changed(v) => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, v); } reset => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, 1.0); } } // TRACES: FR-DEV-19c // The colour this mask is shown in. Swatches rather than a picker: // six colours that can be told apart at half strength over a // photograph is the whole of the requirement, and a wheel would // offer a thousand that cannot. Pressing one opens the eye, since // choosing a colour for a mask nobody can see would be a control // that changes no pixel. VerticalLayout { spacing: 4px; Caption { text: "Shown in"; } HorizontalLayout { spacing: Theme.gap-sm; alignment: start; for c[i] in Masking.mask-colours: swatch := TouchArea { width: Theme.control-height; height: Theme.control-height; mouse-cursor: pointer; enabled: root.enabled; clicked => { root.colour-picked(i); } Rectangle { width: 18px; height: 18px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; border-radius: 9px; background: c; // The chosen one is ringed rather than enlarged, // so the strip does not shimmer as the choice // moves along it. border-width: (root.data.shown && root.data.colour-index == i) ? 2px : 0px; border-color: Theme.ink; opacity: swatch.has-hover || (root.data.shown && root.data.colour-index == i) ? 1.0 : 0.7; } } } } 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(); } } // Which pixels are in the mask at all. Above the edge controls // because it decides the outline they then treat, and unlike them // it rebuilds the distance field — the same cost as a close or an // open, and the reason it commits on release rather than on every // sample of a drag. if root.data.refinable: SliderRow { label: "Refine"; hint: "Drop pixels whose colour does not belong — a flag in " + "the sky, a chimney, a bare branch — and pull the " + "outline onto the edge the photograph actually has. " + "Zero is the model's own outline."; value: root.data.refine; // Zero, which is the model's own outline — and *not* the // position a layer starts at, which is chosen per photograph // because a nat of evidence means different things over a // smooth sky and over a stone facade (see // `dr_segment::Refinement::gentle`). A fixed default here used // to be 4, which is half travel and looks like the natural // resting place; on an ordinary frame it takes three quarters // of the category away, so "reset" emptied the mask. default-value: 0; minimum: 0; maximum: 8; // Tenths, not whole nats. The interval between a flag going // and a cloud going is under three nats wide, so whole steps // would give the control about three useful positions inside // the range it exists to explore. precision: 1; changed(v) => { root.refine-changed(v); } reset => { root.refine-changed(0); } } // The band (FR-DEV-10). Above the edge controls because for a // range there are none: a band has no boundary in the picture and // therefore no distance from one, so `shapeable` is false and the // softness below is the whole of this mask's edge. // // Degrees and percentages rather than the turns and fractions // stored. Both are the units the quantity is discussed in, and a // hue slider reading 0.06 is a control the photographer has to // learn before it means anything. if root.data.chromatic: SliderRow { label: "Hue"; hint: "Which colour the mask follows. Skin sits just past red."; value: root.data.hue * 360; default-value: 22; minimum: 0; maximum: 360; changed(v) => { root.hue-changed(v / 360, root.data.hue-width); } reset => { root.hue-changed(0.06, root.data.hue-width); } } if root.data.chromatic: SliderRow { label: "Spread"; hint: "How far either side of that colour the mask reaches."; value: root.data.hue-width * 360; default-value: 18; minimum: 0; // Half the circle each way is the whole of it, so this is the // widest arc that is still a selection. maximum: 180; changed(v) => { root.hue-changed(root.data.hue, v / 360); } reset => { root.hue-changed(root.data.hue, 0.05); } } if root.data.ranged: SliderRow { // Named by what the end of the band *is* rather than by // "from", which says nothing on its own and says the wrong // thing on the colour range, where the axis is not brightness. label: root.data.chromatic ? "Least colourful" : "Darkest"; value: root.data.band-lo * 100; default-value: root.data.chromatic ? 15 : 50; minimum: 0; maximum: 100; changed(v) => { root.band-changed(v / 100, root.data.band-hi, root.data.band-softness); } reset => { root.band-changed(root.data.chromatic ? 0.15 : 0.5, root.data.band-hi, root.data.band-softness); } } if root.data.ranged: SliderRow { label: root.data.chromatic ? "Most colourful" : "Brightest"; value: root.data.band-hi * 100; default-value: 100; minimum: 0; maximum: 100; changed(v) => { root.band-changed(root.data.band-lo, v / 100, root.data.band-softness); } reset => { root.band-changed(root.data.band-lo, 1.0, root.data.band-softness); } } if root.data.ranged: SliderRow { label: "Softness"; hint: "How gradually the mask fades in at each end of the " + "range. At zero it is a threshold, and a threshold " + "over a photograph's own tones draws contour lines " + "across a smooth sky."; value: root.data.band-softness * 100; default-value: 15; minimum: 0; maximum: 100; changed(v) => { root.band-changed(root.data.band-lo, root.data.band-hi, v / 100); } reset => { root.band-changed(root.data.band-lo, root.data.band-hi, 0.15); } } // 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); } } // Wrapped, both of them: five chips in one row is 440px of // declared width, and in this column the widest panel's request // is what every panel gets — selecting a category mask levered // the whole sidebar open past the window's edge and clipped the // histogram, the groups strip and the subject list along with // it. See `ChipGrid`. if root.data.shapeable: Segmented { label: "Falloff"; options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"]; selected: root.data.falloff; columns: 3; 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; columns: 3; 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); } } } } } /// TRACES: FR-DEV-3 /// The mask stack, what the model found, and everything that can be done to a /// layer. /// /// A global rather than eight properties and eighteen callbacks on the panel. /// `session.slint` gives the general argument; this panel is the case that /// made it, because its instantiation in `app.slint` ran to forty lines of /// forwarding — the longest in the file, and every line of it saying the same /// thing twice. /// /// The overlay is the other half of the reason. `overlay-hidden` was a /// two-way binding between the panel and the window because the panel is where /// it is toggled and the canvas is what obeys it; neither owned it, so it was /// declared twice and tied together at the instantiation. Here it is declared /// once and both read it, which is what a two-way binding was standing in for. export global Masking { in property <[MaskRow]> masks; /// TRACES: FR-DEV-19a /// The parts of the selected layer. Empty when none is selected, or when /// more than one is — a stroke has one target, and so does an edge. in property <[PartRow]> parts; /// TRACES: FR-DEV-19b /// What a drag on the photograph does: 0 selects, 1 paints, 2 erases. /// /// Written by Rust and read here, like every other `in` property on this /// global: `tool-picked` is what the strip reports, and the answer comes /// back through this. A version of the handler that recorded nothing left /// the strip stuck on "Select" and the brush reachable from no control. in property tool: 0; /// TRACES: FR-DEV-19c /// How shown masks are drawn over the photograph — tint, alpha or edge, /// as an index into `dr_pipeline::mask::RevealStyle::ALL`. One style for /// every shown mask; *which* masks are shown is each row's own eye. /// /// **Not `overlay-hidden`.** That switch belongs to the region overlay, /// which is a picture of what the *model detected*; this is the mask a /// layer actually resolves to, feather, morphology, invert and all. Two /// overlays that look alike and mean different things is worse than /// either, so they are named apart and switched apart. in property mask-view-style: 0; /// The colours a mask may be shown in — `MASK_COLOURS` in the session, /// pushed rather than declared here so the swatch and the shader are /// reading one table. in property <[color]> mask-colours; /// The brush: radius as a fraction of the frame's shorter edge, hardness, /// and flow. in property brush-radius: 0.05; in property brush-hardness: 0.5; in property brush-flow: 1.0; in property <[SubjectRow]> subjects; in property <[CategoryRow]> categories; /// 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; /// TRACES: FR-DEV-3 /// The photographer's switch for the region overlay, kept separate from /// the window's `overlay-on` because the two answer different questions: /// `overlay-on` is "is there a mask to draw", which Rust decides, and this /// is "do I want to look at it right now", which only they can. /// /// Folding them into one property would mean the next recompute — any /// mask edit — silently switched the overlay back on under someone who had /// just turned it off to check their work against the photograph. 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); /// TRACES: FR-DEV-16 /// The same switch for whichever layers are selected, from the keyboard. /// No id, because the selection is the session's and this file cannot /// see it; Rust answers by flipping each selected layer through the same /// path the ring takes, so it is one history step per layer either way. callback selected-toggled(); callback mask-invert-toggled(string, bool); callback mask-opacity-changed(string, float); callback mask-feather-changed(string, float); callback mask-refine-changed(string, float); callback mask-falloff-picked(string, int); callback mask-morphology-picked(string, int); callback mask-morph-radius-changed(string, float); callback mask-band-changed(string, float, float, float); callback mask-hue-changed(string, float, float); /// TRACES: FR-DEV-19b callback tool-picked(int); /// TRACES: FR-DEV-19c callback mask-view-style-picked(int); callback mask-shown-toggled(string, bool); callback mask-colour-picked(string, int); callback brush-changed(float, float, float); /// TRACES: FR-DEV-19a callback part-selected(string, int); callback part-join-picked(string, int, int); /// Whether the part at this index is left out of the build. callback part-hidden-toggled(string, int, bool); callback part-removed(string, int); callback part-added(string, int); callback add-gradient(bool); /// TRACES: FR-DEV-19b callback add-brush(); /// `true` asks for the colour range rather than the brightness one. callback add-range(bool); callback add-subject(int); callback add-category(string); } export component MaskPanel inherits Rectangle { 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, // 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 Masking.segmented: Value { text: Masking.subjects.length + (Masking.subjects.length == 1 ? " subject" : " subjects"); } } if !Develop.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 Develop.enabled && !Masking.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 Develop.enabled && !Masking.segmented: Button { text: Masking.segmenting ? "Looking…" : "Find subjects"; enabled: !Masking.segmenting; primary: true; clicked => { Masking.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 Develop.enabled && Masking.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 what was found" // is what pressing it will do, not what is currently true. // // TRACES: FR-DEV-19c // **Named for what it actually hides**, which is not the mask. This // said "Show the mask" / "Hide the mask" while switching the // false-coloured picture of what the *model detected* — and now that // there is a control which really does show a mask (the eye on each // row of the stack), two things called the same thing and meaning // different ones would be worse than either. if Develop.enabled && Masking.segmented: Button { text: Masking.overlay-hidden ? "Show what was found" : "Hide what was found"; clicked => { Masking.overlay-hidden = !Masking.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 Develop.enabled && Masking.segmented: Button { text: Masking.segmenting ? "Looking closer…" : "Look closer (slower)"; enabled: !Masking.segmenting; clicked => { Masking.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 Develop.enabled && Masking.segmented && Masking.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 Develop.enabled && Masking.segmented: VerticalLayout { spacing: 0px; for subject in Masking.subjects: subject-row := TouchArea { height: Theme.touch-target; mouse-cursor: pointer; clicked => { Masking.add-subject(subject.index); } HorizontalLayout { spacing: Theme.gap-sm; Label { text: subject.label; emphasised: subject-row.has-hover; horizontal-stretch: 1; overflow: elide; // **`elide` is a paint-time behaviour, and this is a // layout-time problem.** A `Text` asks for the width of // its whole string whether or not it will draw all of // it, so without a stated maximum this row asked for // whatever the model happened to return, that became // `layout.preferred-width`, the panel publishes that as // its `min-width`, and the develop column takes the // widest minimum any panel declares. The column // therefore moved the instant segmentation finished — // a photograph the user was looking at, jumping // sideways because a label said "traffic light". // // Stated as a maximum for the reason `ChipGrid` // declares its width from its column count rather than // from its options: what a panel asks for must follow // from its structure, never from its data. Past this // the row elides, which is what `elide` was for. // // 160px is the same judgement as `ChipGrid`'s 88px // chip — comfortable for the class names this model // returns, and narrow enough that a subject list // cannot be what sets the column. min-width: 0px; max-width: 160px; } Value { text: round(subject.score * 100) + "%"; } } } } // --- whole categories --------------------------------------------- // // Below the subjects rather than above, and the order is the argument: // clicking the photograph is how a local adjustment usually starts, so // the things that were *found* come first. Categories are the move you // reach for deliberately — grade the sky, not this one bird. if Develop.enabled && Masking.segmented && Masking.categories.length > 0: Caption { text: "Or grade a whole category. One adjustment covers every pixel of it."; wrap: word-wrap; } if Develop.enabled && Masking.segmented: VerticalLayout { spacing: 0px; for category in Masking.categories: category-row := TouchArea { height: Theme.touch-target; mouse-cursor: pointer; clicked => { Masking.add-category(category.name); } HorizontalLayout { spacing: Theme.gap-sm; Label { text: category.label; emphasised: category-row.has-hover; horizontal-stretch: 1; overflow: elide; // Same layout-time reason as the subject row above: // a Text asks for its whole string's width whether or // not it elides, and the develop column takes the // widest minimum any panel declares. max-width: 160px; vertical-alignment: center; } Label { // Coverage as a percentage, which is the unit the // number means something in. A category under half a // percent never reaches this list at all. // A plain Label is already `ink-dim`; only // `emphasised` lifts it, and a coverage readout is // exactly the thing that should not compete with the // name beside it. text: round(category.coverage * 100) + "%"; vertical-alignment: center; } } } } if Develop.enabled && Masking.segmented && Masking.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"; } // TRACES: FR-DEV-19c // How every shown mask is drawn. One strip for all of them, above // the rows whose eyes decide which: a tint beside an outline beside an // alpha would be three pictures that cannot be read against each // other, where three tints in three colours are one. // // Three chips rather than a toggle because the three answer three // different questions and no one of them answers all three: a tint // says whether the right thing is selected, an alpha says where the // edge is, an outline says whether that edge is registered against // detail the other two hide. if Develop.enabled && Masking.masks.length > 0: Segmented { label: "Show masks as"; options: ["Tint", "Alpha", "Edge"]; selected: Masking.mask-view-style; picked(i) => { Masking.mask-view-style-picked(i); } } // 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: Develop.enabled; clicked => { Masking.add-gradient(false); } } Button { text: "Radial"; enabled: Develop.enabled; clicked => { Masking.add-gradient(true); } } // TRACES: FR-DEV-19b // **A mask that is nothing but a hand.** Beside the gradients // because it belongs to the same answer to "where" — and because // without it the only way to reach the brush was to make a // gradient nobody wanted and paint into that. // // It covers nothing until a stroke lands, so pressing this arms // the brush as well as making the layer: a row that appeared and // changed no pixel, with the pointer still in "select", would look // exactly like a button that did nothing. Button { text: "Paint"; enabled: Develop.enabled; clicked => { Masking.add-brush(); } } } // Ranges, on their own row rather than beside the gradients. A // gradient answers "where" and these answer "what", and putting four // buttons in one row would suggest four flavours of the same thing — // where the difference between them is the only thing a photographer // needs to understand to reach for the right one. // // "Tone" and "Colour", not "luminance range" and "colour range". What // the photographer is choosing is which property the mask follows. HorizontalLayout { spacing: Theme.gap-sm; Button { text: "Tone"; enabled: Develop.enabled; clicked => { Masking.add-range(false); } } Button { text: "Colour"; enabled: Develop.enabled; clicked => { Masking.add-range(true); } } } if Develop.enabled: Caption { text: "A tone or colour mask follows the photograph's own values, " + "so the edit blends where the picture does."; wrap: word-wrap; } if Develop.enabled && Masking.masks.length == 0: Caption { text: "No local adjustments. Select a region, or add a gradient."; wrap: word-wrap; } for mask in Masking.masks: MaskEntry { data: mask; enabled: Develop.enabled; refining: Masking.refining; selected(extend) => { Masking.mask-selected(mask.id, extend); } removed => { Masking.mask-removed(mask.id); } refined => { Masking.mask-refined(mask.id); } toggled(on) => { Masking.mask-toggled(mask.id, on); } invert-toggled(on) => { Masking.mask-invert-toggled(mask.id, on); } opacity-changed(v) => { Masking.mask-opacity-changed(mask.id, v); } feather-changed(v) => { Masking.mask-feather-changed(mask.id, v); } refine-changed(v) => { Masking.mask-refine-changed(mask.id, v); } falloff-picked(i) => { Masking.mask-falloff-picked(mask.id, i); } morphology-picked(i) => { Masking.mask-morphology-picked(mask.id, i); } morph-radius-changed(v) => { Masking.mask-morph-radius-changed(mask.id, v); } band-changed(lo, hi, soft) => { Masking.mask-band-changed(mask.id, lo, hi, soft); } hue-changed(h, w) => { Masking.mask-hue-changed(mask.id, h, w); } // Only the selected row is given any, so seven unselected stacks // of parts are not built and drawn behind the one being worked on. parts: mask.selected ? Masking.parts : []; part-selected(i) => { Masking.part-selected(mask.id, i); } part-join-picked(i, j) => { Masking.part-join-picked(mask.id, i, j); } part-hidden-toggled(i, on) => { Masking.part-hidden-toggled(mask.id, i, on); } part-removed(i) => { Masking.part-removed(mask.id, i); } part-added(j) => { Masking.part-added(mask.id, j); } shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); } colour-picked(i) => { Masking.mask-colour-picked(mask.id, i); } } // 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), ); } } } } }