// 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 { Develop } from "session.slint"; import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint"; import { SliderTrack, ControlRow, CurveEditor, Segmented, ChoiceChip, ChipGrid, Check } 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, // TRACES: FR-DEV-3 // This operation is driven by pointing at the photograph as well as by // its sliders, so the group's heading carries the control that arms the // canvas. Identical on every row of the group, for the reason // `group-modified` above is: the heading is one of those rows. // // Set in Rust from the widget the operation asked for, never from which // operation it is (ARCH §4.3a) — the panel still does not know that white // balance exists, only that something here can be sampled. group-samples: 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; /// TRACES: FR-DEV-3 /// Whether this group can be set by pointing at the photograph. in property has-sampler: false; /// Whether the canvas is currently waiting for that point. in property sampling: false; callback reset(); /// Arm the sampler, or put it away if it is already armed. callback sample(); 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; } // GESTURE: Set the white balance from the photograph // where: Develop // touch: Press "pick" in the group's heading, then tap something // neutral in the picture // pointer: Press "pick", then click something neutral // why: Sampling a neutral is the first move of the tonal pass — // every colour judgement afterwards is measured against // where the grey was put — and guessing at two sliders // until a wall stops looking green is the wrong way round. // One click is one sample and one step to undo; the sliders // stay, because a sampled neutral is where the decision // starts rather than where it ends. // // TRACES: FR-DEV-3 | NFR-A11Y-2 // The eyedropper, built exactly as the reset beside it is: a word, a // hit target grown to a thumb, and a role so it is announced as // something that can be pressed rather than read as a caption. // // A word rather than a drawn pipette, because this heading has no // icons in it and one would be the only glyph in a column of text — // and because "pick" says what happens next, which a pipette only // says to somebody who already knows. // // It stays lit while armed. Arming changes what a click on the // photograph *does*, and a mode with nothing saying it is on is the // fault the develop view's own mode strip exists to prevent. Rectangle { width: 30px; visible: root.has-sampler; accessible-role: button; accessible-label: "Set from a neutral in the photograph"; accessible-enabled: root.has-sampler; accessible-checkable: true; accessible-checked: root.sampling; accessible-action-default => { if (root.has-sampler) { root.sample(); } } sample-touch := TouchArea { width: 100%; height: max(parent.height, Theme.touch-target); y: (parent.height - self.height) / 2; enabled: root.has-sampler; mouse-cursor: pointer; clicked => { root.sample(); } } Caption { text: "pick"; emphasised: root.sampling || sample-touch.has-hover; horizontal-alignment: right; width: 100%; height: 100%; } } Rectangle { width: 34px; visible: root.has-reset; // A `Caption` is a `Text`, which Slint announces as text — so // without this the reset reads as the word "reset" sitting beside // the heading rather than as something that can be pressed. accessible-role: button; accessible-label: "Reset"; accessible-enabled: root.has-reset; accessible-action-default => { if (root.has-reset) { root.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 { // The same two strings `ControlRow` is drawing above the track. // Drawn text and announced text are separate channels — Slint // associates neither with the control on its own — so the label a // sighted user reads and the label a screen reader hears come from // one expression each, rather than the second being left empty. label: root.data.param-label; readout: Readout.of(root.data); // The descriptor's declared precision, as a step: a parameter in // whole units nudges by one and one in stops by a hundredth, // which is the same quantum the readout is rounded to. step: root.data.precision == 0 ? 1.0 : 0.01; 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 becomes the track'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. // // It used to be announced on this row, with the track below it silent. Now // that every `SliderTrack` names itself (NFR-A11Y-2) the two would nest — a // slider inside a slider, the outer one carrying the value and the inner one // carrying the actions that can change it — so the row stands down and hands // the same three strings to the control that owns the gesture. 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; 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; label: root.data.param-label; readout: Readout.of(root.data); 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); /// The gesture is over and this is the value to keep. /// /// **Not the same thing as `drag-changed`, which is hover.** `SliderTrack` /// defines `engaged` as `has-hover || claimed`, because a `Flickable` /// withholds the press and hover is the only signal that gets through in /// time to stand it down. That makes it exactly right for what it is for — /// telling a scrolling ancestor to let go — and exactly wrong for anything /// that must run once when the user has finished choosing: it fires on the /// way past without a drag at all, and then does not fire again while the /// pointer stays on the track, however many times the value is dragged. callback committed(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 { label: root.label; readout: (Math.round(root.value * 10) / 10) + root.unit; step: 0.1; value: root.value; default-value: root.default-value; minimum: root.minimum; maximum: root.maximum; changed(v) => { root.changed(v); } committed(v) => { root.committed(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 setting that is either on or off. The value is 1 or 0, so a tick // is an ordinary parameter change like a slider's — the row carries a // `float` either way and the core clamps it back to a boolean. // // No reset affordance, and none is missing: a switch has two states // and its default is one of them, so returning to the default is a // click on the box. The `modified` marker still comes from the group, // which is where the panel says something has been changed. if root.data.kind == "bool": Check { label: root.data.param-label; checked: root.data.value != 0; // The graph owns the value — an undo step and a pasted preset both // move it without anyone touching this box — so the row draws what // the model says and never decides for itself. controlled: true; toggled(on) => { root.param-changed( root.data.op-index, root.data.param-index, on ? 1 : 0); } } // 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); // Wrapped, because this column's width is the largest any panel // asks for and an operation may declare as many choices as it // likes. `film_sim`'s six film formats laid in one row came to // 414px, which set the width of the sidebar on every screen. columns: 3; 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; columns: 3; 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); } } } } } /// TRACES: FR-DEV-3 /// The frame: its shape, its angle, and the two ways of turning it over. /// /// A global rather than nine properties and eight callbacks on the panel — see /// `session.slint` for why the develop panels stopped taking their wiring /// through the window root. /// /// Everything here is **mirrored from the session rather than held here**, for /// the same reason the crop rect is: Rust clamps and wraps these, so the panel /// must show what was actually applied and not what the click asked for. /// /// The crop overlay's own state is not here. Whether the overlay is up is /// `Develop.cropping`, a reading of the view's mode, and the rect itself is /// dragged on the photograph rather than set from this panel. export global Framing { in property angle: 0.0; /// The straighten slider's travel either way, from the descriptor. 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 — what /// lights the section's dot and enables its reset. Distinct from the crop /// rect being non-full: a rotation or a flip is an edit with the crop /// still full. in property modified: false; /// TRACES: FR-DEV-3 /// The ratios the crop may be locked to, and which one is chosen. /// /// Named by Rust rather than listed here: the set is `CropAspect::CHOICES` /// and the labels come off it, so a ratio added there appears without this /// file changing. in property <[string]> aspects; in property aspect: 0; /// Whether the chosen ratio is standing on its short edge. in property portrait: false; /// Whether the chosen ratio has a second orientation at all — `Free` and /// the square do not, and the switch says so rather than vanishing. in property aspect-turnable: false; /// Quarter turns, positive clockwise. callback rotate(int); callback flip-h-toggled(); callback flip-v-toggled(); callback angle-changed(float); /// TRACES: FR-DEV-3 /// The straightening gesture is over and this is the angle to keep. /// /// What the auto-crop hangs off — see `DevelopSession::auto_crop_to_angle` /// for why it must be the end of the drag and not every frame of it, and /// `PlainSlider::committed` for why it must not be `drag-changed`. callback angle-committed(float); /// Lock the crop to one of `aspects`, by index. callback aspect-picked(int); /// Stand the chosen ratio on its other edge. callback portrait-toggled(); /// Crop, angle, rotation and flips back to neutral, leaving colour alone. callback reset(); } // 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 ComposePanel inherits Rectangle { 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: "COMPOSE"; modified: Framing.modified; has-reset: Framing.modified; reset => { Framing.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. // // The words are still written, once each, as `label` — the width // argument is about the column, and a screen reader has no column. // The two flips also declare themselves checkable, which // `IconButton` cannot do on its own: `active` says a toggle is on // and says nothing at all about whether an inactive control is a // toggle that is off, so only these call sites know that the two // rotations are not toggles and these two are. HorizontalLayout { spacing: Theme.gap-sm; IconButton { icon: "rotate-ccw"; label: "Rotate left"; enabled: Develop.enabled; clicked => { Framing.rotate(-1); } } IconButton { icon: "rotate-cw"; label: "Rotate right"; enabled: Develop.enabled; clicked => { Framing.rotate(1); } } Rectangle { horizontal-stretch: 1; } IconButton { icon: "flip-h"; label: "Flip horizontally"; active: Framing.flip-h; accessible-checkable: true; accessible-checked: Framing.flip-h; enabled: Develop.enabled; clicked => { Framing.flip-h-toggled(); } } IconButton { icon: "flip-v"; label: "Flip vertically"; active: Framing.flip-v; accessible-checkable: true; accessible-checked: Framing.flip-v; enabled: Develop.enabled; clicked => { Framing.flip-v-toggled(); } } } PlainSlider { label: "Straighten"; value: Framing.angle; default-value: 0.0; minimum: -Framing.max-straighten; maximum: Framing.max-straighten; unit: "°"; changed(v) => { Framing.angle-changed(v); } committed(v) => { Framing.angle-committed(v); } reset => { Framing.angle-changed(0); } } // TRACES: FR-DEV-3 // The ratio the crop is held to. // // **A grid rather than a row.** Six chips laid side by side need // over 400px, and this column's width is the largest any panel // declares — so a single row would widen every other panel in the // application to fit a control that is on screen only while // cropping. Three columns is what the column already has room for. // // The chips are drawn from the model rather than written out, so // the offered set lives in one place: `CropAspect::CHOICES`. Their // positions are computed from the index for the same reason — a // seventh ratio wraps onto a third row by itself. // **Three individually-conditional children, not one `if` around a // nested layout.** A nested `VerticalLayout` under a condition // under-reports its height here, and the panels below it get drawn // on top of one another — invisible in this file and obvious on // screen. `app.slint` and `masks.slint` both carry the same note // for the same reason. // **Only while the crop overlay is up.** Shown at all times these // would be a control for a gesture that is not on screen; hidden // behind a lid they would be a feature nobody finds. if Develop.cropping: Label { text: "Ratio"; body: true; } // The same wrapping grid the generated enum rows use, for the // same reason: six chips in a row would be 414px of chips setting // the width of a column that is meant to be 280. if Develop.cropping: ChipGrid { options: Framing.aspects; selected: Framing.aspect; enabled: Develop.enabled; picked(i) => { Framing.aspect-picked(i); } } // One chip, lit when the ratio is standing on its short edge, // rather than a Landscape/Portrait pair: the second chip in such a // pair says nothing the first does not, and this column is narrow. // // Disabled rather than hidden where the ratio has no second // orientation, so the panel does not change height as the chips // above it are tried. // // In a container of its own so it takes one chip's width and sits // under the first column: a `ChoiceChip` placed straight into the // `VerticalLayout` is stretched across the whole panel, which reads // as a button for the section rather than as one more chip. if Develop.cropping: Rectangle { height: Theme.control-height; ChoiceChip { x: 0; width: 88px; height: 100%; label: "Portrait"; selected: Framing.portrait; enabled: Develop.enabled && Framing.aspect-turnable; clicked => { Framing.portrait-toggled(); } } } } } } /// TRACES: FR-DEV-6 /// The settings clipboard, as the develop column sees it. /// /// A global rather than three properties and three callbacks on the panel — /// `session.slint` gives the argument. /// /// The clipboard itself is a **window-lifetime** thing and not a view's: a /// copy is taken in develop and may be pasted onto a selection back in the /// library grid, and the grid's own paste stays on the window root where the /// grid can reach it. What is here is the develop half — the two buttons and /// what they would do. export global Transfer { /// 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, at the current scope — "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(); /// TRACES: FR-DEV-6 /// Open the named-preset sheet over the open photograph. Beside copy and /// paste because it is the same thought given a name — this edit, kept. /// /// Handled in Rust rather than by setting the sheet's properties here, as /// the panel's instantiation used to: a global cannot bind the window's /// state, and the handler has to say what saving would capture anyway. /// The grid opens the same sheet with its own count, from `app.slint`. callback open-presets(); } // 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 { padding: Theme.gap; spacing: Theme.gap-sm; HorizontalLayout { PanelHeading { text: "SETTINGS"; } Rectangle { horizontal-stretch: 1; } } HorizontalLayout { spacing: Theme.gap-sm; Button { text: "Copy"; enabled: Develop.enabled; horizontal-stretch: 1; clicked => { Transfer.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: Develop.enabled && Transfer.armed; horizontal-stretch: 1; clicked => { Transfer.paste(); } } } // TRACES: FR-DEV-6 // The saved half. On its own row rather than a third of the one above, // because copy and paste are a pair — one arms the other — and a button // that does neither sitting between them would read as part of that pair. Button { text: "Presets…"; enabled: Develop.enabled; clicked => { Transfer.open-presets(); } } if Transfer.armed: Caption { text: Transfer.summary + (Transfer.framing-withheld ? " · crop not included" : ""); } } /// TRACES: FR-DEV-3 /// The generated controls: what they show, what they are pointed at, and what /// pressing one means. /// /// A global rather than eleven properties and eight callbacks spread over two /// panels — `session.slint` gives the argument. `GroupStrip` and `AdjustPanel` /// share it because they are one control between them: the strip says which /// slice of `rows` the panel is drawing, and a strip that filtered a different /// panel from the one beside it would be a control for nothing. /// /// **This names no operation and no parameter.** Everything here is a model /// the core supplied or an index back into it (ARCH §4.3a, FR-DEV-3a). export global Adjustments { in property <[ParamRow]> rows; /// What these controls are pointed at — the whole photograph, or one mask /// layer by name. /// /// Supplied already resolved: whether a layer is selected and what it is /// called are session facts, and deriving them in the panel would need /// that file to reason about the mask stack. in property scope: "ADJUST"; /// The groups the strip offers, already resolved, and which is chosen. /// -1 is "everything". in property <[string]> tabs; in property active-tab: -1; callback tab-picked(int); /// TRACES: FR-UI-1 | FR-UI-7 /// Whether the groups are chosen from the tool rail instead of from the /// strip above the column. /// /// Owned by Rust, which composes it from the input modality and the /// photographer's override — see `ToolRail::groups-in-rail` for why the /// axis is input rather than platform or width. /// /// The two controls are mutually exclusive by construction: this shows one /// and hides the other, so there is never a moment with two ways to answer /// the same question, and no state to keep in step between them. in property groups-in-rail: false; /// 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); /// TRACES: FR-DEV-16 /// The control last moved, so a key can put it back. /// /// The generated rows have no focus: they are a model the repeater /// rebuilds, and a focus ring for them would be a larger change than the /// binding wants. What a photographer means by "reset that" from the /// keyboard is the slider they just dragged too far, and that is a thing /// the panel can remember without any of them being focused. -1 is none, /// which is where every photograph starts — see `lib.rs`, which clears /// these on open so the key cannot reach back into the previous edit. in-out property touched-op: -1; in-out property touched-param: -1; /// Every parameter of one operation back to its default. A section's reset /// and a curve's reset are the same action, so they share the one callback /// rather than duplicating a handler that would have to be kept in step /// with it. callback curve-reset(int); /// Plot a different one of the curve's curves. callback curve-channel-picked(int); callback reset-all(); // TRACES: FR-DEV-3 // The on-canvas sampler: which group has armed it. // // The op index rather than a bool, because arming is a question about a // *group* — two operations could ask for a sampler and only one of them // can be waiting for the click. -1 is none armed, the sentinel this file // already uses for "no swatch" and "everything" and for the same reason: // a Slint struct or property cannot carry an optional. in-out property sampling-op: -1; /// The heading's picker was pressed for the operation at this index. /// /// A function rather than a callback into Rust, and that is deliberate: /// arming changes what a click on the photograph does and nothing about /// the edit, so there is nothing for the session to hear about until a /// point is actually picked. It lived in `app.slint`'s instantiation of /// the panel before, which is exactly the sort of behaviour a second /// instantiation would have had to copy. public function arm-sampler(op: int) { self.sampling-op = self.sampling-op == op ? -1 : op; } // 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 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. /// TRACES: FR-DEV-3f /// Whether the stock picker belongs in the group on screen. /// /// Decided in Rust from what the operation says it is about, because a /// stock is not a parameter and so is not filtered by the row builder that /// hides everything else. This file still names no group. in property film-in-group: true; 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); } /// The group strip: which kind of adjustment the column is showing. /// /// One kind of entry, which is the change. This was `ModeStrip` and carried /// two: the canvas tools — crop, local, repair — as filled chips, and the /// adjustment groups as underlined words, in a single row that asked the eye /// to tell a mode from a filter by the shape of its highlight. The tools are /// in `ToolRail` now (`toolrail.slint`, which carries the reasoning), and what /// is left here is one row of one thing. /// /// A group is something you are *looking at*, not something you are *in*, and /// that distinction is now made by the two controls being in different places /// rather than by two treatments of one control. It is also why the two stay /// independent: picking a group while a tool is held filters the selected /// layer's chain and does not put the tool down, 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 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). With the tools gone there is /// no longer any hand-written word in this row at all, save "All". export component GroupStrip inherits Rectangle { /// Whether the strip has anything to offer and is the control that offers /// it: an open photograph, with the groups not already in the rail. /// /// Derived here rather than bound at the instantiation, so a second /// composition of this column cannot get the rule slightly different. property live: Develop.enabled && !Adjustments.groups-in-rail; background: Theme.surface; height: root.live ? layout.preferred-height : 0px; visible: root.live; // 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. // // This is now the *only* answer, and it is the right one here. The column // this sits in is a mandated width (`panel-width` in `style.yaml`) while // the row's contents are one word per group the operation set declares — // generated, and so unbounded in principle. It used to publish a // `content-width` that the column sized itself from, which made a rich // operation set quietly take width from the photograph. A row that pans is // the price of a photograph that does not move. 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; // **Where a canvas tool goes: not here.** A mode that arms a gesture // on the photograph is a row in `ToolRail`'s table, one file over. // // Three of them used to sit at the head of this row as filled chips, // with a one-pixel divider after them. Both are gone, and nothing has // replaced them — no heading, no lead-in. A run of words with one // underlined is a tab bar, which is exactly what this is, and it needs // saying only while there is something else in the row to be told // apart from. The width that buys back is the point: this row pans // when the operation set is rich, so anything permanently occupying // its left-hand end is paid for by every group after the third. all := TouchArea { width: 34px; height: Theme.touch-target; mouse-cursor: pointer; clicked => { Adjustments.tab-picked(-1); } Label { text: "All"; emphasised: Adjustments.active-tab == -1 || all.has-hover; vertical-alignment: center; } Rectangle { y: parent.height - 2px; height: 2px; width: parent.width; background: Adjustments.active-tab == -1 ? Theme.ink : transparent; } } for tab[i] in Adjustments.tabs: tab-area := TouchArea { width: name.preferred-width + 10px; height: Theme.touch-target; mouse-cursor: pointer; clicked => { Adjustments.tab-picked(i); } name := Label { text: tab; emphasised: Adjustments.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: Adjustments.active-tab == i ? Theme.ink : transparent; } } } } } // 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. export component AdjustPanel inherits Rectangle { /// Whether the stock list is open. Pure interface state: it changes no /// parameter and the core never hears about it. /// /// Private to the panel rather than in `Adjustments`, and deliberately: it /// is a lid, and two drawings of this panel may honestly have their lids /// in different positions. private property film-expanded: false; /// One stock. Shorter than a touch target on purpose — see the row below. private property film-row-height: 32px; /// How much column the open list may take before it scrolls instead. private property film-list-max: 320px; 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; // **The sliders are what `panel-width` was chosen against.** No panel gets // a say in the column's width any more — it is one number in `style.yaml` // — but this is the panel that number has to be right for. Each row is a // label, a value and a track, and a track squeezed below the width its // handle needs is a control that cannot be set accurately, which is the // whole job. If the column is ever narrowed, narrow it against this. layout := VerticalLayout { padding: Theme.gap; spacing: Theme.gap-sm; alignment: start; HorizontalLayout { PanelHeading { text: Adjustments.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 => { Adjustments.reset-all(); } Label { text: "reset"; emphasised: reset.has-hover; horizontal-alignment: right; } } } if !Develop.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 Develop.enabled && Adjustments.film-in-group && Adjustments.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: Adjustments.film-stocks[Adjustments.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; } } } } // **Its own scroller, bounded.** Two dozen stocks laid out at full // height made the list the tallest thing in the column, so the // develop panel scrolled as one and reaching Velvia dragged every // slider below it off the screen — the answer to a question that // is asked once pushing aside the controls used constantly. // // The viewport is counted rather than measured, for the reason the // tool rail records: a viewport that asks a layout how tall it // wants to be, while the layout takes its height from the viewport, // is a cycle Slint settles by handing back the height it was given // — and the content is then clipped in silence instead of // scrolling. Every row here is `film-row-height` by construction, // so multiplying is exact. if root.film-expanded: Flickable { height: min(Adjustments.film-stocks.length * root.film-row-height, root.film-list-max); viewport-width: self.width; viewport-height: Adjustments.film-stocks.length * root.film-row-height; list := VerticalLayout { height: parent.viewport-height; spacing: 0px; alignment: start; for stock[i] in Adjustments.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 => { Adjustments.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 == Adjustments.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 Adjustments.film-can-print: HorizontalLayout { spacing: Theme.gap-sm; alignment: start; print-toggle := TouchArea { width: 120px; height: Theme.touch-target; clicked => { Adjustments.film-print-toggled(!Adjustments.film-print); } Label { text: Adjustments.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 Develop.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 // `Adjustments.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 Adjustments.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; // TRACES: FR-DEV-3 // And, where the operation asked for one, the // control that arms the canvas. The heading knows // only that it has a sampler; which group is // waiting is the panel's business, because only // the panel can see the others. has-sampler: row.group-samples; sampling: Adjustments.sampling-op == row.op-index; sample => { Adjustments.arm-sampler(row.op-index); } // Resetting is what *this* panel's groups do; the // heading itself has no opinion about it. reset => { Adjustments.curve-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: Adjustments.curve-samples; curve-channels: Adjustments.curve-channels; curve-channel: Adjustments.curve-channel; drag-changed(on) => { root.slider-dragging = on; } param-changed(op, param, v) => { Adjustments.touched-op = op; Adjustments.touched-param = param; Adjustments.param-changed(op, param, v); } param-reset(op, param) => { Adjustments.param-reset(op, param); } curve-reset(op) => { Adjustments.curve-reset(op); } curve-channel-picked(i) => { Adjustments.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. }