Files
DarkRoom/ui/dr-ui/ui/presets.slint
T
dtourolle bddf3250c5 Add Lightroom's export and copy shortcuts to develop
Ctrl+E opens an export sheet: the export defaults on their own, over the
photograph, with an Export button. Ctrl+Shift+E exports straight away on
those defaults. There is no per-export copy of the settings, so what is
chosen in the sheet is saved as it is on the settings page, and the next
Ctrl+Shift+E uses it.

To make that one set of controls in two places, the export options move
out of the settings page into export.slint: an `ExportOptions` global
that Rust writes once, and two panels that read it. The window no longer
forwards forty `settings-*` properties to the page.

Ctrl+Shift+C opens a copy sheet with the edit-kind chips the preset
sheet already uses and a Copy button, which is how a paste leaves each
photograph's crop and rotation alone (Compose off). A and D step along
the roll beside the arrows. While either sheet is up the develop keys
stand down, so A cannot change the photograph behind the form, and
Escape closes it.
2026-09-24 05:11:16 +02:00

422 lines
16 KiB
Plaintext

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 <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 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 <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 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 <string> 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 <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.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(); }
}
}
}
}
// 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(); }
}
}
}
}
}