Files
DarkRoom/ui/dr-ui/ui/presets.slint
T
dtourolle 31bcc3a462 File presets in folders that open and close, as collections do
The presets menu and sheet listed every preset under flat section
headings, seventy rows to scroll past. They now list folders, closed
until opened, with how many presets each holds; opening one shows what
is inside it, folders and presets indented beneath.

A category is spelled in the name: "Portraits/Warm skin" is Warm skin
in a Portraits folder under Yours. The file format does not change, so
an older build lists the whole path as the name; renaming a preset is
how it moves, and saving or renaming into a folder opens the way to it.
A Lightroom import names what it reads after the folders below the one
chosen, and a "/" in a displayed name becomes "∕" so it files nothing.
The shipped film sections become Film › Colour, Cinema and Black and
white.

The tree is built and flattened in Rust (PresetTree), each row carrying
its depth, and which folders are open is remembered for the session.

A PopupWindow keeps the size it was shown at, so a folder opened in the
menu pushed its contents under "Save or manage…"; the menu is shown
again after each toggle to take its new height. That is a function on
the rail because Slint 1.17 generates Rust that does not compile for a
popup's close() reached from inside the popup. The sheet's list takes a
preferred height of up to 400px, since a Flickable reports next to
nothing and an opened folder showed three rows.

The manual describes the folders and naming. Its pictures show the menu
with Film › Colour open, and a black-and-white stock applied from Film
› Black and white; the scenes aim popup rows from the rail's entry, and
pick the menu's "Film" over the develop column's film chooser.
2026-09-27 18:37:56 -04:00

557 lines
22 KiB
Plaintext

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 <PresetRow> entry;
in property <length> 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 <bool> enabled: true;
in property <int> columns: 3;
in property <length> chip-width: 88px;
callback toggled(string);
property <int> rows: max(1, ceil(root.kinds.length / max(1, root.columns)));
property <length> 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 <bool> 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 <int> 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 <string> 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 <string> 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 <bool> 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 <string> 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 <string> 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 <bool> scope-empty: false;
/// What a paste would apply at this scope — "3 adjustments".
in property <string> summary;
/// False where there is no edit to copy from.
in property <bool> 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(); }
}
}
}
}
}