Files
DarkRoom/ui/dr-ui/ui/presets.slint
T
dtourolle 6683c14b40 Choose folders in the platform's dialogue, not by typing a path
Every folder the desktop asked for was a text field: the library folder
at launch, an import's source and second copy, a preset folder brought
over from Lightroom. A typed path is how a destination silently becomes
a new folder nobody meant — one wrong letter three levels down and the
write succeeds somewhere the photographer will never look — and a field
cannot make the folder that is not there yet.

They now open the platform's own dialogue through rfd: the XDG desktop
portal on Linux, the common item dialogue on Windows. The portal rather
than GTK because it reaches the user's files from inside the Flatpak and
needs no GTK in a Slint application, and it draws whichever desktop's
chooser is running, "New folder" included. It is awaited on Slint's
event loop (spawn_local), so the window keeps drawing while it is open,
and parented to the window so it opens over it.

PathRow shows what is chosen, read-only, beside the button. Android has
no filesystem dialogue — only SAF, which returns document trees, not
paths — so there the same rows stay typed fields (Pickers.local-paths).

The launch screen keeps the folder used last on screen with "Open
folder" beside it, so reopening is one press. Presets get two buttons,
a folder and a single .xmp file, because no platform dialogue picks
"a file or a folder" in one go.
2026-09-26 14:13:53 -04:00

473 lines
19 KiB
Plaintext

import { Theme } from "theme.slint";
import { Button, Field, Caption, PanelHeading } from "widgets.slint";
import { ChoiceChip } from "controls.slint";
import { Pickers } from "pickers.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 sheet: a section heading, or a preset.
//
// One flat list with the headings in it rather than a list of sections, 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.
export struct PresetRow {
// Non-empty on a heading row, which names no preset.
heading: string,
name: string,
origin: PresetOrigin,
}
// 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();
/// 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;
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.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.heading != "": PanelHeading {
text: entry.heading;
width: parent.width;
height: parent.height;
vertical-alignment: bottom;
}
if entry.heading == "" && root.renaming != entry.name: 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.origin == PresetOrigin.changed
? entry.name + " · changed"
: entry.name;
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.
if entry.heading == "" && root.renaming == entry.name: HorizontalLayout {
spacing: Theme.gap-sm;
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(); }
}
}
}
}
}