import { Theme } from "theme.slint"; import { Button, Field, Caption, PanelHeading } from "widgets.slint"; import { ChoiceChip } from "controls.slint"; // TRACES: FR-DEV-6 // One kind of edit, and whether a copy carries it. // // The `name` is the stable identifier the pipeline and the settings file both // use (`tone`, `colour`, …); `label` is what a person reads. Actions are sent // by name rather than by index because this model is rebuilt from Rust // whenever the set changes, and an index would name whatever moved into that // slot. export struct ScopeKind { name: string, label: string, on: bool, } // TRACES: FR-DEV-6 | FR-UI-2 // Which kinds of edit travel: a multi-select block of chips that wraps. // // Laid out by index arithmetic rather than by a layout, for the reason // `ChipGrid` sets out at length: Slint has no wrapping layout, a // `HorizontalLayout` of chips reports its minimum width as the sum of all of // them, and a `for` inside a `GridLayout` compiles and then fails at run time. // This is `ChipGrid` with the single selection replaced by a flag per chip — // close enough to want sharing, different enough in its model that sharing // would mean one component doing two jobs badly. // // **Not placed in the develop column.** Six chips there would set the width of // the whole sidebar, which is the bug `ChipGrid`'s comment records; this lives // on the full-width surfaces — the preset sheet and the settings page — and // the develop panel says in words what these say in chips. export component ScopeChips inherits Rectangle { in property <[ScopeKind]> kinds; in property enabled: true; in property columns: 3; in property chip-width: 88px; callback toggled(string); property rows: max(1, ceil(root.kinds.length / max(1, root.columns))); property pitch: Theme.control-height + Theme.gap-sm; min-width: root.columns * root.chip-width + (root.columns - 1) * Theme.gap-sm; height: root.rows * root.pitch - Theme.gap-sm; for kind[i] in root.kinds: ChoiceChip { x: mod(i, root.columns) * (root.chip-width + Theme.gap-sm); y: floor(i / root.columns) * root.pitch; width: root.chip-width; height: Theme.control-height; label: kind.label; selected: kind.on; enabled: root.enabled; clicked => { root.toggled(kind.name); } } } // TRACES: FR-DEV-6 // The named preset sheet: save the edit in hand, and apply a saved one. // // # One sheet for both views // // A preset is saved in develop, where there is an edit to capture, and applied // most often in the library, where there is a selection to apply it to. Those // are two different moments and it is the same list, so this is one component // mounted at the shell rather than a panel in each view — the same reasoning // the clipboard's properties are declared on the window for. // // What differs between the two is not the sheet but its *answers*: `can-save` // is false with nothing open, and `apply-count` says whether applying means // this photograph or those forty. Both are facts the shell already holds. // // # The same card, scrim and dismissal as the filing and keywording sheets // // Deliberately. A user who has filed a selection knows how this works, and a // second idiom for the same gesture would be a second thing to learn for no // gain. export component PresetSheet inherits Rectangle { /// The saved names, in the order they are stored. in property <[string]> names; /// Whether there is an edit in hand to save. False in the library, where /// nothing is open, and with an image that failed to decode. in property can-save: false; /// What a preset would be applied to: 0 means the open photograph, and /// anything higher means that many selected ones. in property apply-count: 0; /// What saving would capture — "3 adjustments" — from the same routine the /// clipboard's summary comes from, so the two cannot disagree. in property capture-summary; /// Set while a name is refused, and cleared by the next keystroke. Prose /// rather than a code, because the shell knows why and this does not. in property name-error; callback save(string); callback apply(string); callback rename(string, string); callback remove(string); callback dismiss(); /// Every keystroke in the name field, so the shell can clear a refusal the /// user has started correcting. callback name-edited(string); /// TRACES: FR-DEV-6 /// Which kinds of edit an apply carries, and the toggle that changes it. in property <[ScopeKind]> scope-kinds; /// Whether the selection names nothing, so the sheet can say that applying /// would do nothing rather than letting it silently not. in property scope-empty: false; callback scope-toggled(string); /// TRACES: FR-DEV-6 /// Read Lightroom presets from a typed path, and what came of the last /// attempt. A typed path rather than a file dialogue because that is the /// idiom this application already has — the launch screen takes a library /// the same way, and there is no native picker anywhere in it. in property import-report; callback import-presets(string); background: #000000CC; // Swallows the taps that miss the card, and closes. First, so the card's // own controls sit above it. TouchArea { clicked => { root.dismiss(); } } // Which row is being renamed, by name. Empty means none. // // A name rather than an index: the list is rebuilt from Rust after every // change, and an index would point at whatever moved into that slot. property renaming: ""; Rectangle { width: min(420px, parent.width - 2 * Theme.gap-lg); height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg); x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.surface; border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; // Stops a press on the card reaching the scrim behind it. TouchArea { } sheet := VerticalLayout { padding: Theme.gap-lg; spacing: Theme.gap; Text { text: "Presets"; color: Theme.ink; font-size: Theme.text-lg; font-weight: 600; } // Saving, first: it is the half that has something to say about // the photograph currently open, and it disappears entirely in the // library rather than sitting there disabled — a permanently dead // control teaches the reader that the sheet lies. if root.can-save: VerticalLayout { spacing: Theme.gap-sm; name := Field { placeholder: "Name this edit and press return"; accepted(text) => { root.save(text); // Cleared only once the shell has accepted it. A // refused name the user has to retype is a refusal // that costs more than the mistake did, so the field // keeps the text and `name-error` says why. if (root.name-error == "") { self.text = ""; } } edited(text) => { root.name-edited(text); } } if root.name-error != "": Caption { text: root.name-error; warn: true; } // The bare summary, phrased exactly as the develop panel // phrases the clipboard's — same routine, same words, so the // two cannot appear to disagree about one edit. if root.name-error == "": Caption { text: root.capture-summary; } } if root.can-save: Rectangle { height: 1px; background: Theme.rule; } Flickable { vertical-stretch: 1; // A floor, so the list is not squeezed out of existence by the // field and the button around it on a short window. min-height: 120px; viewport-height: root.names.length * (Theme.touch-target + 2px); for entry[i] in root.names: Rectangle { y: i * (Theme.touch-target + 2px); width: parent.width; height: Theme.touch-target; if root.renaming != entry: HorizontalLayout { spacing: Theme.gap-sm; // The name is the apply button rather than a label // beside one. Applying is what this list is for, and a // row whose largest target does nothing is a row that // gets pressed by accident and then distrusted. Button { text: entry; horizontal-stretch: 1; clicked => { root.apply(entry); } } Button { text: "Rename"; clicked => { root.renaming = entry; } } Button { text: "Delete"; clicked => { root.remove(entry); } } } // Renaming in place rather than in a second sheet: a // dialogue over a dialogue is where a user loses track of // which one Escape closes. if root.renaming == entry: HorizontalLayout { spacing: Theme.gap-sm; rename-field := Field { text: entry; horizontal-stretch: 1; accepted(text) => { root.rename(entry, text); root.renaming = ""; } } Button { text: "Cancel"; clicked => { root.renaming = ""; } } } } } if root.names.length == 0: Text { text: root.can-save ? "No presets yet. Name the edit above to make the first." : "No presets yet. Open a photograph and save one from the develop panel."; color: Theme.ink-faint; font-size: Theme.text-sm; wrap: word-wrap; width: parent.width; } // Says what applying would do *before* it is done, the same way // the grid's "Paste to 40" does — a count in the label is worth // more than a confirmation asking the same question afterwards. if root.names.length > 0 && root.apply-count > 0: Caption { text: root.apply-count == 1 ? "Applies to 1 selected photograph" : "Applies to " + root.apply-count + " selected photographs"; } Rectangle { height: 1px; background: Theme.rule; } // TRACES: FR-DEV-6 // Bringing a Lightroom library across. A folder rather than a // file is the expected case — an exported preset folder, nested // one level per group — so the field says so. VerticalLayout { spacing: Theme.gap-sm; PanelHeading { text: "IMPORT"; } Field { placeholder: "Path to a .xmp file or folder"; accepted(path) => { root.import-presets(path); } } if root.import-report != "": Caption { text: root.import-report; } } Rectangle { height: 1px; background: Theme.rule; } // TRACES: FR-DEV-6 // What an apply carries. At the bottom, because it qualifies every // action above it rather than being one of them — and because the // answer is remembered, so most visits never touch it. // // An apply-time control rather than a save-time one: a preset is // captured whole (see `Preset::capture`), so changing this after // saving still takes effect, where a preset that had already // discarded its colour could never grow it back. VerticalLayout { spacing: Theme.gap-sm; PanelHeading { text: "CARRIES"; } ScopeChips { kinds: root.scope-kinds; toggled(name) => { root.scope-toggled(name); } } if root.scope-empty: Caption { text: "Nothing selected — applying would change nothing."; warn: true; } } Rectangle { height: 1px; background: Theme.rule; } Button { text: "Done"; clicked => { root.dismiss(); } } } } }