FR-DEV-6 asks for three things — named presets, copy/paste between images, and batch-apply to a selection. The last two have been here for a while; this is the first. The format is the sidecar's, deliberately. A preset *is* the non-default half of a version, so the lines are the same lines keyed the same way, which makes the two files diffable against each other and lets someone debugging an edit paste a block from one into the other. One file rather than one per preset: a preset per file makes the name a path, and every name then has to survive a filesystem — a `/` becomes a directory, a name differing only in case collides on one platform and not another, and renaming becomes two operations that can half-fail. As a key in a document it is none of those. Unknown *parameters* needed no machinery. `Preset` already holds whatever keys it is given and resolves them against the descriptors only at apply time, so one written by a newer build survives by being stored. Only lines that are not `op.param = float` at all are preserved verbatim, which is the sidecar's version-skew promise made here too. Applying is the paste path with a different source, so a preset reaches a selection through the sidecar read-modify-write that was already there: no graph, no decode, no GPU, forty files or one. Two smaller decisions worth the record. A library that fails to parse is held empty in memory and *not* written back over — settings regenerate themselves and this is work, so a parse failure must not be the moment it is destroyed. And every save persists immediately and rolls the in-memory copy back if the write fails, so the sheet never lists a preset the file does not have. The grid's "Presets" button is gated on the selection alone, unlike the "Paste to 40" beside it. That button needs a clipboard armed this session; the preset list is whatever was saved last month, and hiding it behind an unrelated action is what makes a feature only its author knows about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
8.5 KiB
Plaintext
211 lines
8.5 KiB
Plaintext
import { Theme } from "theme.slint";
|
|
import { Button, Field, Caption } from "widgets.slint";
|
|
|
|
// 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);
|
|
|
|
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; }
|
|
|
|
Button {
|
|
text: "Done";
|
|
clicked => { root.dismiss(); }
|
|
}
|
|
}
|
|
}
|
|
}
|