Two halves of the same complaint: a preset sheet that opens on "No presets yet" is homework, and a photographer with ten years of presets in Lightroom has no way to bring them. `dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be mostly a rename rather than a conversion, because Adobe and this pipeline already agree: exposure is in stops in both, and contrast, the four recovery controls, clarity, texture, vibrance and saturation are all ±100 in both. That is not imitation, it is the convention raw developers converged on — `highlights_shadows.yaml` cites it in as many words. Only sharpening needed arithmetic, Adobe's 0…150 against our 0…100. The white balance does not come across, and says so rather than guessing. Adobe writes absolute Kelvin for a raw file where ours is a relative nudge from what the camera recorded, so converting needs the *target image's* as-shot white balance — exactly what a preset cannot carry, since the same preset lands on a frame shot at 3200K and one shot at 7000K. A guess would be wrong on most images and invisibly so. A folder is read as readily as a file, nested, because that is the shape an exported preset folder is in and importing ninety files one at a time is asking someone not to bother. `dr_pipeline::starter` is six presets a first run begins with, written against this pipeline in its units and deliberately mild — a starting point, not a caricature. They are seeded when the library *file* does not exist rather than when the library is empty, so deleting all six does not hand them back on the next launch. Both of these name operations, and `ui_names_no_operation` was right to stop them living in `ui/`. That test exists because the failure is silent and cumulative, and it caught exactly what it was written for: a preset called "Punch" is a statement about contrast, clarity and vibrance, and a table mapping Adobe's vocabulary to ours is a statement about the pipeline. Neither is a fact about an interface. So the starter set went into `dr-pipeline`, and the importer into its own crate — between two walls, since `dr-pipeline` depends on nothing on purpose and XMP is real XML not worth hand-rolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
13 KiB
Plaintext
331 lines
13 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(); }
|
|
}
|
|
}
|
|
}
|
|
}
|