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.
422 lines
16 KiB
Plaintext
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(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|