import { Theme } from "theme.slint"; import { Button, Field, Caption, PanelHeading, Label } from "widgets.slint"; import { ChoiceChip } from "controls.slint"; import { Pickers } from "pickers.slint"; import { Icon } from "icons.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 // Where a listed preset comes from, which decides what can be done to it. export enum PresetOrigin { // The photographer's own. yours, // Shipped with the application. Applied, never renamed or deleted. shipped, // The photographer's copy under a shipped preset's name, standing in for // it. Deleting it reverts to the shipped one; renaming it makes it theirs. changed, } // TRACES: FR-DEV-6 // One row of the preset tree: a category folder, or a preset inside one. // // One flat list with the folders in it rather than a tree, for the reason the // rows are placed by index below: a nested `for` inside a `Flickable` has no // layout to size it, and the arithmetic stays one line. Rust flattens the // tree (`PresetTree` in `presets.rs`) and leaves out whatever is inside a // closed folder, exactly as the collections tree arrives. export struct PresetRow { // A category folder rather than a preset. folder: bool, // The folder's identity, handed back when it is opened or closed. Empty // on a preset. key: string, // What the row says: the folder's name, or the preset's name without the // categories it is filed in. label: string, // How many folders deep, from 0 for the top level. depth: int, // Whether a folder is open. expanded: bool, // How many presets a folder holds, however deep. count: int, // The preset's whole name — categories and all — which is what applying, // renaming and deleting it are asked for by. Empty on a folder. name: string, origin: PresetOrigin, } // TRACES: FR-DEV-6 // A category folder: the disclosure arrow, its name and how many presets it // holds. The whole row opens and closes it — unlike a collection, a folder // here is nothing to select, so there is no second target to keep apart. export component PresetFolderRow inherits TouchArea { in property entry; in property indent: Theme.indent; mouse-cursor: pointer; accessible-role: button; accessible-label: root.entry.label; accessible-description: root.entry.count + " presets"; accessible-expandable: true; accessible-expanded: root.entry.expanded; Rectangle { border-radius: Theme.radius; background: root.pressed ? Theme.pressed : (root.has-hover ? Theme.hover : transparent); } HorizontalLayout { padding-left: Theme.gap-sm + root.entry.depth * root.indent; padding-right: Theme.gap; spacing: Theme.gap-sm; Rectangle { width: 14px; Icon { name: root.entry.expanded ? "chevron-down" : "chevron-right"; ink: Theme.ink-faint; size: 10px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } } Label { text: root.entry.label; emphasised: true; horizontal-stretch: 1; vertical-alignment: center; overflow: elide; } Caption { text: root.entry.count; vertical-alignment: center; } } } // 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 photographer's presets and the shipped ones, sectioned, in the /// order they are listed. in property <[PresetRow]> rows; /// 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(); /// A category folder was opened or closed, by its key. callback folder-toggled(string); /// 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 path, and what came of the last attempt. /// Chosen in the platform's dialogue where there is one — `true` asks for /// a single `.xmp` file, `false` for a folder of them — and typed where /// there is not (Android). in property import-report; callback import-presets(string); callback browse-presets(bool); 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; // A `/` files it: "Portraits/Warm skin" goes in a folder // called Portraits, under Yours. name := Field { placeholder: "Name this edit, or Folder/Name, 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; // As tall as the rows, up to a limit: the card is sized from // what it holds, and a Flickable reports next to nothing, so // without this an opened folder showed three rows and hid the // rest behind a scroll. preferred-height: min(root.rows.length * (Theme.touch-target + 2px), 400px); viewport-height: root.rows.length * (Theme.touch-target + 2px); for entry[i] in root.rows: Rectangle { y: i * (Theme.touch-target + 2px); width: parent.width; height: Theme.touch-target; if entry.folder: PresetFolderRow { entry: entry; width: parent.width; height: parent.height; clicked => { root.folder-toggled(entry.key); } } if !entry.folder && root.renaming != entry.name: HorizontalLayout { spacing: Theme.gap-sm; // Under its folder's name, as the collections tree // indents a child. padding-left: Theme.gap-sm + entry.depth * Theme.indent; // 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.origin == PresetOrigin.changed ? entry.label + " · changed" : entry.label; horizontal-stretch: 1; clicked => { root.apply(entry.name); } } // A shipped preset is applied and nothing else: it is // not the photographer's to rename or delete, and a // button that did either would be one that lied. // Saving over its name is how it is changed. if entry.origin != PresetOrigin.shipped: Button { text: "Rename"; clicked => { root.renaming = entry.name; } } // "Revert" on a changed one, because that is what // deleting the copy does: the shipped preset is // still there underneath it. if entry.origin != PresetOrigin.shipped: Button { text: entry.origin == PresetOrigin.changed ? "Revert" : "Delete"; clicked => { root.remove(entry.name); } } } // 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. // The whole name, categories and all: renaming is how a // preset moves to another folder. if !entry.folder && root.renaming == entry.name: HorizontalLayout { spacing: Theme.gap-sm; padding-left: Theme.gap-sm + entry.depth * Theme.indent; rename-field := Field { text: entry.name; horizontal-stretch: 1; accepted(text) => { root.rename(entry.name, text); root.renaming = ""; } } Button { text: "Cancel"; clicked => { root.renaming = ""; } } } } } // 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.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"; } // Two buttons rather than one dialogue, because no platform // dialogue picks "a file or a folder" in one go: the portal // and Windows each ask for one kind. if Pickers.local-paths: HorizontalLayout { spacing: Theme.gap-sm; Button { text: "Folder…"; horizontal-stretch: 1; clicked => { root.browse-presets(false); } } Button { text: ".xmp file…"; horizontal-stretch: 1; clicked => { root.browse-presets(true); } } } if !Pickers.local-paths: 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(); } } } } } // TRACES: FR-DEV-6 | FR-UI-5 // Which kinds of edit a copy carries, and the Copy button — what Ctrl+Shift+C // opens, as Lightroom's "Copy Settings" does. // // The same scope the preset sheet and the settings page draw, not a second // one: a paste applies it whenever it happens, so choosing here and pressing // Ctrl+V later mean the same thing. Turning Compose off is how a crop and a // rotation stay behind. export component CopySheet inherits Rectangle { in property <[ScopeKind]> scope-kinds; in property scope-empty: false; /// What a paste would apply at this scope — "3 adjustments". in property summary; /// False where there is no edit to copy from. in property can-copy: true; callback scope-toggled(string); callback copy(); callback dismiss(); background: #000000CC; TouchArea { clicked => { root.dismiss(); } } 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; TouchArea { } sheet := VerticalLayout { padding: Theme.gap-lg; spacing: Theme.gap; Text { text: "Copy settings"; color: Theme.ink; font-size: Theme.text-lg; font-weight: 600; } Caption { text: "The kinds of edit a paste carries. Leave Compose off " + "to keep each photograph's own crop and rotation."; wrap: word-wrap; } ScopeChips { kinds: root.scope-kinds; toggled(name) => { root.scope-toggled(name); } } // The summary is of what is on the clipboard, so before the first // copy there is none — and an empty line would be a gap that looks // like a missing message. if root.scope-empty || root.summary != "": Caption { text: root.scope-empty ? "Nothing chosen, so a paste would change nothing." : root.summary; warn: root.scope-empty; wrap: word-wrap; } HorizontalLayout { spacing: Theme.gap-sm; alignment: end; Button { text: "Close"; clicked => { root.dismiss(); } } Button { text: "Copy"; primary: true; enabled: root.can-copy && !root.scope-empty; clicked => { root.copy(); } } } } } }