// Generic adjustment controls, generated from pipeline capabilities. // // **Nothing here names an operation.** There is no "exposure slider" and no // "saturation section" — the panel walks a model the core supplies and // instantiates one control per entry, choosing the control from the // parameter's declared kind (ARCH §4.3, FR-DEV-3a). Adding an operation to // the pipeline makes it appear here with no change to this file // (FR-DEV-3c). import { Theme } from "theme.slint"; import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint"; import { SliderTrack, ControlRow, CurveEditor, Segmented } from "controls.slint"; // One parameter, flattened for Slint's model system. // // Flat rather than nested because Slint models do not nest cleanly; the Rust // side flattens the capability tree into this and carries the indices needed // to route a change back. export struct ParamRow { // Routing back to the core. Opaque to this file. op-index: int, param-index: int, // Resolved display strings. Resolution happens in Rust against the UI's // catalogue, because the core deals in localisation keys only. op-label: string, param-label: string, // Grouping, derived in Rust from where `op-index` changes. // // The model is flat and Slint cannot slice one, so a group says where it // begins and how long it is and the panel indexes back into `rows` from // there. `group-head` is this row's group's first index — a row heads its // group exactly when its own index equals it, which is what replaced the // core-supplied `starts-group` flag (ARCH §4.3a: the core does not decide // that the panel has sections). group-head: int, group-len: int, // Any parameter of this operation differs from its default. Identical on // every row of a group, because the heading is one of those rows and // cannot see the others. group-modified: bool, // Which control to build. Mirrors ParamKind, plus the widget kinds an // operation can request through its presentation. kind: string, // "scalar" | "bool" | "enum" | "curve" // A run of rows inside a group, for an operation whose parameters form a // grid rather than a list. // // The colour mixer is twelve hue bands times three channels, and as a flat // list it read as "Hue / Saturation / Luminance" twelve times over with // nothing saying which band any row belonged to. Rust stacks the rows so // each channel's twelve are together and marks the first of each run; this // file names the run and lets the swatch identify the row. // // `facet-label` is the run's name and is empty on an ordinary parameter, // which is every operation but the mixer. facet-label: string, starts-facet: bool, // Where this row's subject sits on the hue wheel, in degrees, or -1 for a // row whose subject is not a colour. // // A sentinel because a Slint struct cannot carry an optional, and -1 // rather than any in-range value because 0° is red — a real band, and the // first one. swatch-hue: float, value: float, default-value: float, minimum: float, maximum: float, precision: int, unit: string, // Curve rows only: the point coordinates, x and y interleaved. // // Carried on the row rather than fetched separately because a Slint // model row is the unit of update — splitting them would let the curve // and its points refresh out of step. Empty for every other kind. // // `param-index` on a curve row is the index of the *first* point // parameter, so a drag routes back by offsetting from it. points: [float], // Enum rows only: the variant names, in index order. // // Resolved in Rust against the UI's catalogue, like every other label // here — the core publishes localisation keys and never a display string. // `value` on such a row is the chosen index, which is why an enum needs no // separate selection field. choices: [string], } // The name of a group of controls, and what can be done to the group. // // What is left of `Section` once the collapsing is taken out: the name, the dot // that says something inside differs from its default, and the reset. The // sidebar is a column of instruments, and an instrument behind a lid is one the // user has to remember to open — several of these held a single slider, so the // lid was most of the row. // // The reset stays visible rather than appearing on hover, as the section's did. // A hover-only control is one no finger can find, and this panel is now // expected to be worked with a thumb. component GroupHeading inherits Rectangle { in property title; /// Something inside differs from its default. in property modified: false; /// Whether this group has anything to reset. in property has-reset: true; callback reset(); height: Theme.control-height; HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; PanelHeading { text: root.title; sub: true; horizontal-stretch: 1; } Rectangle { width: 6px; height: 6px; y: (parent.height - self.height) / 2; border-radius: 3px; background: Theme.modified; visible: root.modified; } Rectangle { width: 34px; visible: root.has-reset; reset-touch := TouchArea { width: 100%; height: max(parent.height, Theme.touch-target); y: (parent.height - self.height) / 2; enabled: root.has-reset; mouse-cursor: pointer; clicked => { root.reset(); } } Caption { text: "reset"; emphasised: reset-touch.has-hover; horizontal-alignment: right; width: 100%; height: 100%; } } } } // A parameter's value, at the precision its descriptor declares. // // A global rather than the same ternary written into every control that shows // one. There are two such controls now and the rule belongs to neither of // them: precision comes from the descriptor, so a control in stops reads 1.25 // while one in whole units reads 25, and a copy that fell behind would show // the same parameter two ways in the same panel. `SliderTrack`'s preamble is // the longer version of this argument. global Readout { public pure function of(data: ParamRow) -> string { return data.precision == 0 ? Math.round(data.value) + data.unit : (Math.round(data.value * 100) / 100) + data.unit; } } // One generated parameter: a label, a readout, and the track above. // // The readout is *not* editable, where the settings page's `SliderRow` pairs // the same track with a number box. That is a considered difference rather than // an inconsistency: this column is 280px wide and the colour mixer alone puts // thirty-six of these in it, so a text box per row would be most of the width // and a keyboard target nobody is aiming for. The number is still reachable — // the track resets on double-click and right-click. component ParamSlider inherits Rectangle { in property data; callback changed(float); callback reset(); /// Forwarded from the track, for the panel's Flickable. callback drag-changed(bool); height: 46px; ControlRow { label: root.data.param-label; readout: Readout.of(root.data); // The one readout in the panel that has moved off its default is what // the eye is hunting for, and `modified` is the only thing left to say // it with once hue is gone. modified: root.data.value != root.data.default-value; SliderTrack { value: root.data.value; default-value: root.data.default-value; minimum: root.data.minimum; maximum: root.data.maximum; changed(v) => { root.changed(v); } reset => { root.reset(); } engaged-changed(on) => { root.drag-changed(on); } } } } // The name of a run of rows inside a group: the mixer's Hue, Saturation and // Luminance. // // A `Label` rather than a third `PanelHeading`. Two levels of // caps-with-tracking already stack above it — the panel's own `ADJUST` and the // operation's name — and a third in the same treatment would read as their // peer instead of as something *inside* the operation. Sentence case at the // same size says "a part of the group above" without another size or colour. component FacetHeading inherits Rectangle { in property title; height: Theme.control-height; HorizontalLayout { padding-left: Theme.gap-sm; padding-top: Theme.gap-sm; Label { text: root.title; } } } // One faceted parameter: a swatch, a track and a readout, on a single line. // // **The swatch is the label.** Twelve of these sit under a heading that // already names the channel, so the only thing a row has left to say is which // band it edits — and a 12px square says it in a fraction of the width the // word would take. That is what makes twelve rows fit where four did: the // mixer is thirty-six controls, and at `ParamSlider`'s two-line 46px it was // most of a screen of scrolling with the band name absent from every row of it // anyway. // // **The name is not thrown away, it moves.** It is the row's accessible label, // so a screen reader says "Orange" where the eye reads the colour, and the // catalogue in `labels.rs` is where the mapping is written down for anyone who // cannot separate two squares by eye. A row identified by colour *alone* // would be a control some photographers could not use, which is why the // spoken name is part of the design and not an afterthought. component SwatchSlider inherits Rectangle { in property data; callback changed(float); callback reset(); /// Forwarded from the track, for the panel's Flickable. callback drag-changed(bool); // The track's own height plus a hairline of air. Denser than a // `ParamSlider` because the label line it would need is gone, not because // the touch target shrank — `SliderTrack` still owns a full-width hit area // and the gestures behind it (FR-UI-3). height: Theme.touch-target / 2 + 4px; accessible-role: slider; accessible-label: root.data.param-label; accessible-value: Readout.of(root.data); accessible-value-minimum: root.data.minimum; accessible-value-maximum: root.data.maximum; HorizontalLayout { padding-left: Theme.gap-sm; spacing: Theme.gap-sm; Swatch { hue: root.data.swatch-hue; // Centred against the track rather than the row, which a layout // would do for a stretching child and cannot do for a fixed one. y: (parent.height - self.height) / 2; } SliderTrack { horizontal-stretch: 1; value: root.data.value; default-value: root.data.default-value; minimum: root.data.minimum; maximum: root.data.maximum; changed(v) => { root.changed(v); } reset => { root.reset(); } engaged-changed(on) => { root.drag-changed(on); } } Value { text: Readout.of(root.data); modified: root.data.value != root.data.default-value; placeholder: root.data.value == root.data.default-value; compact: true; // Fixed and right-aligned: a readout sized to its own text would // pull the track's end left and right as the number changed, and // twelve tracks that each ended somewhere different would be // impossible to compare down the column. width: 30px; horizontal-alignment: right; } } } // A slider the interface names itself, rather than one generated from a row. // // The straighten angle is reached through the session's own accessor, not // through a row index, so there is no `ParamRow` to feed it. Only the labels // and the source of the numbers differ — the track is the same component, and // so is every gesture it recognises. component PlainSlider inherits Rectangle { in property label; in property value; in property default-value: 0.0; in property minimum: -1.0; in property maximum: 1.0; in property unit; callback changed(float); callback reset(); callback drag-changed(bool); height: 46px; ControlRow { label: root.label; readout: (Math.round(root.value * 10) / 10) + root.unit; modified: root.value != root.default-value; SliderTrack { value: root.value; default-value: root.default-value; minimum: root.minimum; maximum: root.maximum; changed(v) => { root.changed(v); } reset => { root.reset(); } engaged-changed(on) => { root.drag-changed(on); } } } } // **The control registry: one row in, one control out.** // // Slint cannot instantiate a component from a runtime string, so mapping a // declared kind to a control is necessarily a chain of `if`s. The thing worth // insisting on is that there is exactly *one* such chain. There were two — the // panel draws a lone parameter bare and a group under a heading, and each // branch wrote out its own list of kinds — so `enum` would have had to be added // in both, and a kind added to only one would appear or vanish depending on how // many parameters its operation happened to declare. // // Everything below routes back through `param-changed` by index. This component // knows a curve point spans two parameters and a swatch names a hue band; it // knows nothing about which operation it is drawing, which is the property that // makes it a registry rather than a panel. component ParamControl inherits Rectangle { in property data; /// Curve rows only; ignored by every other kind. in property <[float]> curve-samples; /// The curves this widget can plot, named. Empty, or one entry, where /// there is nothing to choose between — see `curve-channel-picked`. in property <[string]> curve-channels; /// Which of `curve-channels` is on the grid. in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); /// Plot a different one of the operation's curves. callback curve-channel-picked(int); callback drag-changed(bool); height: layout.preferred-height; layout := VerticalLayout { spacing: 0px; alignment: start; // A row whose subject is a colour is identified by that colour; every // other scalar keeps its name. The two differ only in what stands in // for the label — the track, the gestures and the routing are the same // underneath. if root.data.kind == "scalar" && root.data.swatch-hue >= 0: SwatchSlider { data: root.data; drag-changed(on) => { root.drag-changed(on); } changed(v) => { root.param-changed(root.data.op-index, root.data.param-index, v); } reset => { root.param-reset(root.data.op-index, root.data.param-index); } } if root.data.kind == "scalar" && root.data.swatch-hue < 0: ParamSlider { data: root.data; drag-changed(on) => { root.drag-changed(on); } changed(v) => { root.param-changed(root.data.op-index, root.data.param-index, v); } reset => { root.param-reset(root.data.op-index, root.data.param-index); } } // A fixed list of alternatives. The value *is* the index, so picking // one is an ordinary parameter change and needs no separate route. // // Chips rather than a dropdown for the same reason the settings page // uses them: these lists are short, and a collapsed menu hides the // alternatives behind a click. ARCH §4.3 names the dropdown as the // pointer presentation of the same kind, so this is where that choice // will be made when the modality switch lands. if root.data.kind == "enum": Segmented { label: root.data.param-label; options: root.data.choices; selected: Math.round(root.data.value); picked(i) => { root.param-changed(root.data.op-index, root.data.param-index, i); } } // A curve, and — where the operation offers more than one — the choice // of which curve is on the grid. // // One plot rather than four stacked ones: the curves are read against // the diagonal and against each other, which needs the grid large, and // four grids at a quarter of the width would each be too small to // place a point in. So the selector switches the subject of a single // plot, and the names in it come from the core — this file does not // know that a colour channel is what is being chosen between, only // that the widget said it spans several named things. if root.data.kind == "curve": VerticalLayout { spacing: Theme.gap-sm; if root.curve-channels.length > 1: Segmented { // The operation's own name, which nothing else in this row // draws: a curve row heads no group, so without this the plot // would sit in the panel unlabelled. label: root.data.op-label; options: root.curve-channels; selected: root.curve-channel; picked(i) => { root.curve-channel-picked(i); } } CurveEditor { points: root.data.points; samples: root.curve-samples; drag-changed(on) => { root.drag-changed(on); } // A point carries two parameters, so the parameter index is // the row's base plus the point's offset. The base is the // first point of the curve *on show*, so switching curve // re-points the drag and this component still knows nothing // about which operation — or which curve — it is drawing. point-moved(point, x, y) => { root.param-changed( root.data.op-index, root.data.param-index + point * 2, x); root.param-changed( root.data.op-index, root.data.param-index + point * 2 + 1, y); } // Resetting a curve resets the operation, which is all four of // them — a photographer who double-clicks to start again means // the control, not the curve that happens to be on show. reset => { root.curve-reset(root.data.op-index); } } } } } // Crop, rotation, flips and straightening — the framing controls. // // **Why this is hand-built when the rest of the panel is generated.** The // generic path renders one slider per parameter, which for framing means eight // of them: four crop edges the user would have to type coordinates into, and a // "Rotate" slider running 0..3. Every one of those is a worse control than the // gesture it stands for — a crop is dragged on the photograph, and a quarter // turn is a button. So framing is presented rather than generated, and the // generic panel drops it (see `AdjustPanel.skip-op`). // // This does not weaken ARCH §4.3: nothing here reads a parameter *value* out // of a descriptor or routes by index. It calls named session actions, which is // what a bespoke widget for a known stage is entitled to do. export component GeometryPanel inherits Rectangle { in property enabled: true; in property angle: 0.0; in property max-straighten: 45.0; in property flip-h: false; in property flip-v: false; /// Any of crop, angle, rotation or flips differs from neutral. in property modified: false; callback rotate(int); callback flip-h-toggled(); callback flip-v-toggled(); callback angle-changed(float); callback angle-reset(); callback reset(); height: layout.preferred-height; layout := VerticalLayout { spacing: 0px; alignment: start; // A heading, not a collapsible. // // These controls are the reason the column is open; folding them away // behind a triangle put the sidebar's own contents one tap further // from the photograph and gave every group a lid that had to be // learned. `GroupHeading` keeps what the section was actually for — // naming the group, flagging that it holds an edit, and offering the // reset — without the hiding. GroupHeading { title: "GEOMETRY"; modified: root.modified; has-reset: root.modified; reset => { root.reset(); } } VerticalLayout { spacing: Theme.gap-sm; padding-bottom: Theme.gap-sm; // No crop button. Crop is one value of the view mode now, entered // and left from the strip pinned at the top of this column — a // second control that entered the same mode would be a second // thing that has to agree about which mode the view is in, and // the whole point of the enum is that there is one answer. // Rotation and flips. Icons rather than labels: four controls // named in words would wrap the 280px column, and each of // these shows its own result. HorizontalLayout { spacing: Theme.gap-sm; IconButton { icon: "rotate-ccw"; enabled: root.enabled; clicked => { root.rotate(-1); } } IconButton { icon: "rotate-cw"; enabled: root.enabled; clicked => { root.rotate(1); } } Rectangle { horizontal-stretch: 1; } IconButton { icon: "flip-h"; active: root.flip-h; enabled: root.enabled; clicked => { root.flip-h-toggled(); } } IconButton { icon: "flip-v"; active: root.flip-v; enabled: root.enabled; clicked => { root.flip-v-toggled(); } } } PlainSlider { label: "Straighten"; value: root.angle; default-value: 0.0; minimum: -root.max-straighten; maximum: root.max-straighten; unit: "°"; changed(v) => { root.angle-changed(v); } reset => { root.angle-reset(); } } } } } // The panel: a heading per multi-parameter operation, a control per parameter. // // **Why the loop is shaped the way it is.** `rows` is flat, and Slint can // neither slice a model nor nest a `for` over a run of it. What it *can* do is // repeat over an integer — `for n in row.group-len` — so each group's heading // row renders its whole group by indexing back into `rows` from `group-head`, // and every other row renders nothing. // // **Nothing here collapses.** Every group was a `Section` with a disclosure // triangle until it became clear what that cost on a tablet: five of the // pipeline's operations carry one parameter, so the lid was most of the row, // and a control behind a lid is one the user does not know the pipeline has. // The column is closed as a whole from the status strip instead, which is the // control that was actually wanted. // TRACES: FR-DEV-6 // Copying this photograph's settings, and pasting settings onto it. // // Buttons rather than a keyboard shortcut *alone*, because this has to work on // a tablet where there is no modifier key to hold and no menu bar to hang the // action from. The desktop shortcuts exist as well, wired in Rust; they are an // accelerator for a control that is on screen either way, which is what keeps // the feature discoverable on both platforms. // // The paste button carries what would be pasted rather than the bare word. // "Paste" alone asks the user to remember what they copied and, crucially, // whether the crop is coming with it — a question the label answers by // naming the count the *current* scope would apply. export component TransferPanel inherits VerticalLayout { in property enabled: true; /// Whether anything has been copied yet. Distinct from the clipboard /// being *neutral*: a copy of an unedited frame is a real thing to paste, /// since it clears the target. in property armed: false; /// What a paste would apply — "3 adjustments", or "Neutral". in property summary; /// Whether the clipboard holds framing the current scope is dropping. /// Only then is it worth saying anything about the crop. in property framing-withheld: false; callback copy(); callback paste(); padding: Theme.gap; spacing: Theme.gap-sm; HorizontalLayout { PanelHeading { text: "SETTINGS"; } Rectangle { horizontal-stretch: 1; } } HorizontalLayout { spacing: Theme.gap-sm; Button { text: "Copy"; enabled: root.enabled; horizontal-stretch: 1; clicked => { root.copy(); } } Button { text: "Paste"; // Enabled on `armed` rather than on the summary being non-empty, // so pasting a neutral copy — which clears this image — stays // available. Still needs an image to paste *onto*. enabled: root.enabled && root.armed; horizontal-stretch: 1; clicked => { root.paste(); } } } if root.armed: Caption { text: root.summary + (root.framing-withheld ? " · crop not included" : ""); } } /// Which mode the develop view is in. /// /// `crop` was a bare `bool` on the window and local masking was a panel with /// two toggles, so nothing stopped both being on at once — and nothing on /// screen said which of them the canvas and the column were obeying. One value /// with three states cannot be in two of them, which is the whole reason this /// is an enum rather than a tidier pair of flags. export enum ViewMode { /// The whole photograph. Sliders are global, the canvas pans and zooms. photo, /// The crop overlay is up and the canvas shows the uncropped frame. crop, /// The region map is drawn, a click on the photograph selects, and the /// column is the mask stack and the selected layer's adjustments. local, } /// The strip: what the photographer is working on. /// /// Two kinds of entry, deliberately together. /// /// **Modes** — crop and local — change the canvas as well as the column. They /// are drawn as chips and lit with the accent, which means *active* everywhere /// else in this interface and means exactly that here. /// /// **Groups** — the rest — filter the adjustments to one kind. They are /// underlined instead, because the accent is already spoken for and because /// they are a different sort of state: a mode is something you are *in*, a /// group is something you are *looking at*. /// /// Keeping them apart visually is what lets them be independent. Picking a /// group in local mode filters the selected layer's chain and does not leave /// the mode, so "Light" means the same thing wherever it is pressed — the /// alternative, where a group press silently dropped the scope, would be the /// §1.1 fault reintroduced from the other end. /// /// **Pinned above the scrolling column, not inside a panel.** It began inside /// `AdjustPanel`, which put it below five other panels and off the bottom of a /// tablet screen — present, working, and unreachable without scrolling past /// everything it was meant to help you avoid scrolling past. A control that /// answers "where is everything else" cannot itself be somewhere else. /// /// **This file names no group.** The strings arrive already resolved from /// whatever the operations declared themselves to be about, so a new operation /// joins a group without an edit here (FR-DEV-3a). The two modes are not /// operations — crop is a gesture on the canvas and local is a scope — so /// naming them breaks nothing. export component ModeStrip inherits Rectangle { in property <[string]> tabs; /// Index into `tabs`, or -1 for "everything". in property active-tab: -1; in property mode: ViewMode.photo; in property enabled: true; callback picked(int); callback mode-picked(ViewMode); background: Theme.surface; height: root.enabled ? layout.preferred-height : 0px; visible: root.enabled; // Scrolls rather than overflowing, exactly as the develop status strip // does and for the same reason: a `HorizontalLayout` given less width than // its children need does not shrink them, it runs off the end. Two modes // plus however many groups the operation set declares is already more than // a 280px column holds, and the column is where this is pinned. Flickable { width: 100%; height: 100%; viewport-height: self.height; viewport-width: max(self.width, layout.preferred-width); layout := HorizontalLayout { width: parent.viewport-width; padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap-sm; alignment: start; // A chip per mode. Drawn as an outline that fills when active rather // than as an underline, so at a glance the strip reads as two runs // and it is never ambiguous which half a lit entry belongs to. // // **Where a brush goes.** Painting a mask is a third mode of exactly // this shape — it arms a canvas gesture and scopes the column — so it // joins this list and the `ViewMode` enum, and needs nothing else here. // `MaskSource::Brush` and the stroke calls on `MaskLayer` land in the // core separately; what is missing on this side is only the canvas // interaction, which is the gradient handles' neighbour. for entry in [ { label: "Crop", value: ViewMode.crop }, { label: "Local", value: ViewMode.local }, ]: mode-chip := TouchArea { width: mode-name.preferred-width + 2 * Theme.gap-sm; height: Theme.touch-target; mouse-cursor: pointer; property on: root.mode == entry.value; // Pressing the mode you are already in leaves it, which is what // makes the strip the way out as well as the way in — the same // control both directions, as the crop button was. clicked => { root.mode-picked(self.on ? ViewMode.photo : entry.value); } Rectangle { y: (parent.height - self.height) / 2; height: Theme.control-height; width: parent.width; border-radius: Theme.radius; border-width: 1px; border-color: mode-chip.on ? Theme.active : Theme.rule; background: mode-chip.on ? Theme.active-dim : (mode-chip.has-hover ? Theme.hover : transparent); } mode-name := Text { text: entry.label; // Dark on the lit fill, which is near-white: the same // inversion `Button`'s primary state makes. color: mode-chip.on ? Theme.ground : Theme.ink-dim; font-size: Theme.text-sm; font-weight: 600; vertical-alignment: center; horizontal-alignment: center; } } // The divider between the two kinds. One pixel, and it is what stops // the strip reading as one undifferentiated row of five words. Rectangle { width: 1px; height: Theme.touch-target; background: Theme.rule; } all := TouchArea { width: 34px; height: Theme.touch-target; mouse-cursor: pointer; clicked => { root.picked(-1); } Label { text: "All"; emphasised: root.active-tab == -1 || all.has-hover; vertical-alignment: center; } Rectangle { y: parent.height - 2px; height: 2px; width: parent.width; background: root.active-tab == -1 ? Theme.ink : transparent; } } for tab[i] in root.tabs: tab-area := TouchArea { width: name.preferred-width + 10px; height: Theme.touch-target; mouse-cursor: pointer; clicked => { root.picked(i); } name := Label { text: tab; emphasised: root.active-tab == i || tab-area.has-hover; vertical-alignment: center; horizontal-alignment: center; } // Underlined rather than filled: the accent is the mode chips' // now, and spending it on "which group" as well would blunt both. Rectangle { y: parent.height - 2px; height: 2px; width: parent.width; background: root.active-tab == i ? Theme.ink : transparent; } } } } } export component AdjustPanel inherits Rectangle { in property <[ParamRow]> rows; in property enabled: true; /// What these controls are pointed at — the whole photograph, or one mask /// layer by name. /// /// **The heading, not a caption beside it.** Selecting a layer re-points /// every one of these controls at that layer's chain, and until now the /// only sign of it was a sentence in the panel above. Putting the answer /// in the heading of the thing that changed means the scope cannot be read /// without also reading what it applies to. /// /// Supplied already resolved: whether a layer is selected and what it is /// called are session facts, and deriving them here would need this file /// to reason about the mask stack. in property scope: "ADJUST"; /// The tone curve's sampled shape, evaluated in Rust by the same spline /// the shader runs so the drawn line cannot disagree with the applied one. in property <[float]> curve-samples; /// The curves the tone curve widget can plot, named by the core. Fewer /// than two of them means there is nothing to choose and no selector. in property <[string]> curve-channels; /// Which of them `curve-samples` and the row's points describe. in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); /// Plot a different one of the curve's curves. callback curve-channel-picked(int); /// Return every parameter of one operation to its default — the reset on /// a section's own header, beside the panel-wide one. callback op-reset(int); callback reset-all(); // TRACES: FR-DEV-3f // The film stock, which is not a parameter and so is not a `ParamRow`. // // A bespoke control for the same reason the mask panel is one: a stock is // a choice of *material*, named by an id, not a number on a slider. The // generic rows above keep their rule — they still name no operation — and // the film's own exposure sliders arrive through them like any other. // // Names are supplied already resolved, and the list is whatever profiles // are installed: this file does not know what a Kodachrome is. in property <[string]> film-stocks; /// Index into `film-stocks`. Zero is the first entry, which Rust makes /// "no film" — so a fresh photograph selects it without a sentinel. in property film-selected: 0; /// Whether the chosen stock is a negative with a paper to print on. False /// for a reversal stock, which is the picture already, and for no film. in property film-can-print: false; /// Print it, rather than view the negative as scanned. in property film-print: false; callback film-picked(int); callback film-print-toggled(bool); /// Whether the stock list is open. Pure interface state: it changes no /// parameter and the core never hears about it. private property film-expanded: false; background: Theme.surface; // A slider below has claimed the current gesture, so this panel must stop // competing for it. See the long note on `ParamSlider`'s `claimed`: without // this the Flickable takes any drag that drifts 8px vertically, which under // a finger is every drag. /// True while a track has claimed a gesture. /// /// `out` rather than private because the Flickable that must stand down /// for it is no longer in this component: the whole develop column /// scrolls as one, so the panel reports the drag and the column obeys it. out property slider-dragging: false; VerticalLayout { padding: Theme.gap; spacing: Theme.gap-sm; alignment: start; HorizontalLayout { PanelHeading { text: root.scope; // Elided rather than wrapped. A layer's name is the user's and // can be any length; a heading that wrapped would change the // panel's height as the selection moved, and an unwrapped one // would set the 280px column's minimum width from it. overflow: elide; horizontal-stretch: 1; } // No spacer: the heading takes the slack itself, so a long layer // name elides against the reset rather than pushing it off the // 280px column. reset := TouchArea { width: 44px; height: 20px; clicked => { root.reset-all(); } Label { text: "reset"; emphasised: reset.has-hover; horizontal-alignment: right; } } } if !root.enabled: Caption { text: "No image"; } // TRACES: FR-DEV-3f // Above the rows, because it decides what they mean: the exposure // slider below is a slider on *this stock's* characteristic curve, and // the same number is a different picture on a different film. if root.enabled && root.film-stocks.length > 1: VerticalLayout { spacing: Theme.gap-sm; // **Collapsed to one row, and opened only to change it.** // // The panel above deliberately took the lids off its *sliders* — // an instrument behind a lid is one you forget to open. A stock // list is the other kind of control: two dozen entries, chosen // once, and then only its answer matters. Left open it was a // thousand pixels of column standing between the photographer and // every slider below it. // // No PopupWindow, no scroller of its own: the develop column // scrolls as one, so the list is simply taller content while it is // open, and there is no second gesture to lose a drag to. summary := TouchArea { height: Theme.touch-target; mouse-cursor: pointer; clicked => { root.film-expanded = !root.film-expanded; } Rectangle { border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; background: summary.pressed ? Theme.pressed : (summary.has-hover ? Theme.hover : transparent); HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; Label { text: "Film"; vertical-alignment: center; } Value { // The chosen stock's own name, elided rather than // wrapped: it is a product name of any length and // this column is 280px. text: root.film-stocks[root.film-selected]; overflow: elide; horizontal-alignment: right; horizontal-stretch: 1; vertical-alignment: center; } Label { text: root.film-expanded ? "\u{25be}" : "\u{25b8}"; vertical-alignment: center; } } } } if root.film-expanded: VerticalLayout { spacing: 0px; for stock[i] in root.film-stocks: film-row := TouchArea { // Shorter than a touch target, which is a deliberate // exception and the only one here: a list row's neighbours // are other rows, so growing the hit area the way a chip // does would put each row's target over the one above it // and hand taps to the wrong film. height: 32px; mouse-cursor: pointer; clicked => { root.film-picked(i); // Closed on choosing. The answer is now in the summary // row, and leaving two dozen entries open after the // question has been answered is the behaviour this // control was collapsed to avoid. root.film-expanded = false; } Rectangle { border-radius: Theme.radius; background: i == root.film-selected ? Theme.active : (film-row.pressed ? Theme.pressed : (film-row.has-hover ? Theme.hover : transparent)); HorizontalLayout { padding-left: Theme.gap; padding-right: Theme.gap-sm; alignment: start; Label { text: stock; vertical-alignment: center; overflow: elide; } } } } } // Only for a negative. A reversal stock has no paper — it is the // photograph as it comes — so offering the choice would be // offering something that cannot happen. if root.film-can-print: HorizontalLayout { spacing: Theme.gap-sm; alignment: start; print-toggle := TouchArea { width: 120px; height: Theme.touch-target; clicked => { root.film-print-toggled(!root.film-print); } Label { text: root.film-print ? "Printed" : "Scanned negative"; emphasised: print-toggle.has-hover; } } } } // No Flickable here any more. The histogram, the capture metadata and // the geometry controls sat *above* this one and could not be scrolled // away, so on a 280px column in portrait they ate the height the // sliders needed and the instrument the sliders are judged against was // unreachable. The column scrolls as one now, and a scroller inside a // scroller would give every drag a third thing to be lost to. if root.enabled: VerticalLayout { content := VerticalLayout { spacing: 0px; alignment: start; // **The scroll gutter.** // // A strip down the right-hand edge that no control reaches, so // there is always somewhere to put a thumb that means "scroll" // and nothing else. // // It exists because of the arbitration in `SliderTrack`: a // track stands the Flickable down as soon as a finger touches // it, which is what makes dragging a slider reliable, and the // cost is that the track can no longer be used to scroll past. // The rows either side of a track were the only remaining // purchase, and on a panel that is mostly tracks that came to // aiming at a 20px band between controls. Reserving the space // outright is the honest version of what was left to chance. // // Padding rather than a spacer element, and that is what makes // it work: the strip is inside the Flickable but no child is // laid out into it, so nothing puts a TouchArea over it. A // press there reaches the Flickable directly, with no // arbitration to lose. // // A full touch target wide (FR-UI-3), because a gutter too // narrow to hit confidently is the problem it was added to fix. padding-right: Theme.touch-target; // **One row, one element, and each renders only itself.** // // This used to nest: a group's head row drew the *whole* group // by repeating over `row.group-len` and indexing back into // `root.rows` for each member, and every other row drew // nothing. It produced the right picture and could not be // dragged. // // The reason is worth writing down, because it is invisible in // a screenshot. The inner repeater's model was `row.group-len` // — read off the head row — so it depended on the head row's // *identity*. Moving any parameter in the group rewrites that // row: its own value changed, or `group-modified` flipped for // its neighbours. Rewriting it re-evaluated the repeater, which // rebuilt its items, which destroyed the `TouchArea` holding // the gesture. The slider took the press, jumped once, and went // dead under the finger for the rest of the drag. // // It only ever affected multi-parameter operations — white // balance, highlights and shadows, the mixer — because a lone // parameter had no inner repeater to rebuild. Exposure and // contrast dragged perfectly the whole time, which is exactly // what made it look like a slider bug rather than a layout one. // // Flat, each element depends only on its own `row`, so an // update touches one control and touches nothing structural. // It also drops the old hazard of the head row building every // control in its group — thirty-six live TouchAreas behind the // mixer's twelve visible ones. for row[i] in root.rows: VerticalLayout { spacing: 0px; // The group's name, drawn by the row that heads it, above // its own control rather than around the whole run. // // **A group of one is not a group.** Five of the pipeline's // operations carry a single parameter — exposure, contrast, // saturation, vibrance, brilliance — and giving each a // heading printed the operation's name in caps directly // above the same word as the slider's own label. Five times // over, that is a column that reads as chrome with controls // hidden in it. So a lone parameter is drawn bare; it loses // the group reset, which costs nothing, since the slider // already resets on double-click and right-click. if row.group-head == i && row.group-len > 1: VerticalLayout { spacing: 0px; padding-top: Theme.gap-sm; // A heading rather than a lid. Several of these groups // are two sliders; hiding two sliders behind a triangle // costs more than it saves, and a control the user // cannot see is one they do not know the pipeline has. GroupHeading { title: row.op-label; modified: row.group-modified; // Resetting is what *this* panel's groups do; the // heading itself has no opinion about it. reset => { root.op-reset(row.op-index); } } } // A group whose parameters form a grid names each run once. // Rust has already stacked the rows so a run is contiguous // and marked its first, which is what lets a flat loop draw // a heading that belongs to several rows. if row.starts-facet: FacetHeading { title: row.facet-label; } ParamControl { data: row; curve-samples: root.curve-samples; curve-channels: root.curve-channels; curve-channel: root.curve-channel; drag-changed(on) => { root.slider-dragging = on; } param-changed(op, param, v) => { root.param-changed(op, param, v); } param-reset(op, param) => { root.param-reset(op, param); } curve-reset(op) => { root.curve-reset(op); } curve-channel-picked(i) => { root.curve-channel-picked(i); } } } } } } // No seam of its own. // // There was one — a 1px `rule` rectangle — and being a sized child of a // plain Rectangle with no position, Slint *centred* it: a hairline drawn // straight down the middle of the panel, through every slider in it. The // column that hosts this panel already draws the seam between itself and // the photograph, so the fix is one rule in one place rather than two that // were never both wanted. }