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.
This commit is contained in:
2026-09-24 05:11:16 +02:00
parent 41486bd59b
commit bddf3250c5
9 changed files with 898 additions and 571 deletions
+7 -353
View File
@@ -1,6 +1,7 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint";
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
import { ExportImageOptions, ExportFileOptions } from "export.slint";
import { ScopeChips, ScopeKind } from "presets.slint";
// Settings: how much disk the app may spend, and what an export defaults to.
@@ -204,82 +205,8 @@ export component SettingsPage inherits Rectangle {
in property <int> xmp-conflicts: 0;
callback xmp-reload();
// --- export --------------------------------------------------------
in property <[string]> format-labels;
in property <int> format-selected: 0;
in property <int> quality: 90;
in property <bool> quality-enabled: true;
in property <[string]> colour-labels;
in property <int> colour-selected: 0;
in property <[string]> sizing-labels;
in property <int> sizing-selected: 0;
in-out property <int> sizing-value: 0;
in property <bool> sizing-has-value: false;
in property <string> sizing-unit: "px";
/// "Size value" for the modes that take one number, "Width" for the box
/// modes, where a second field sits below it.
in property <string> sizing-value-label: "Size value";
in-out property <int> sizing-height: 0;
in property <bool> sizing-has-height: false;
/// The panels offered as buttons, and which one the numbers match — `-1`
/// where they match none, so a preset never stays lit over fields that
/// have since been typed over.
in property <[string]> screen-labels;
in property <int> screen-selected: -1;
in property <bool> allow-upscaling: false;
in property <[string]> sharpening-labels;
in property <int> sharpening-selected: 0;
in-out property <string> filename-template;
in property <[string]> collision-labels;
in property <int> collision-selected: 0;
in property <bool> strip-location: true;
in-out property <string> destination;
// What the destination field means depends on this, so the placeholder
// comes from Rust alongside it rather than being written twice here.
in property <string> destination-hint;
in property <[string]> target-labels;
in property <int> target-selected: 0;
// --- the remote folder picker ---------------------------------------
//
// The same navigation the launch screen uses to choose a library root,
// driven by the same `FolderBrowser` model in Rust. A folder on the
// server is not something anyone can be expected to type from memory.
in property <bool> browse-open: false;
in property <string> browse-path;
in property <[string]> browse-entries;
in property <bool> browse-loading: false;
/// At the library root, so there is nowhere up to go.
in property <bool> browse-at-root: true;
/// Whether the destination is one that can be walked.
///
/// A boolean from Rust rather than a test on `target-selected`. The index
/// was hardcoded to 1, which was Remote's position while both targets were
/// offered — and the moment Android's list narrowed to Remote alone, that
/// index became 0 and the button vanished on the one platform where it is
/// the *only* way to set a destination. An index into a list whose length
/// varies is not a fact about the target.
in property <bool> browse-available: false;
callback format-picked(int);
callback quality-changed(int);
callback colour-picked(int);
callback sizing-picked(int);
callback sizing-value-changed(string);
callback sizing-height-changed(string);
callback screen-picked(int);
callback upscaling-toggled(bool);
callback sharpening-picked(int);
callback template-changed(string);
callback collision-picked(int);
callback strip-location-toggled(bool);
callback destination-changed(string);
callback target-picked(int);
callback browse-open-picker();
callback browse-into(string);
callback browse-up();
callback browse-confirm();
callback browse-cancel();
// The export defaults are drawn from `ExportOptions` (export.slint),
// which the export sheet shares; nothing about them is declared here.
/// A save failed. The page's whole contract is that what it shows is
/// stored, so this cannot be swallowed.
@@ -785,113 +712,12 @@ export component SettingsPage inherits Rectangle {
PanelHeading { text: "EXPORT DEFAULTS"; }
Caption {
text: "What an export starts from. Every one of these "
+ "is still changeable per export.";
text: "What every export uses. Ctrl+E shows these on their own, "
+ "beside an Export button.";
wrap: word-wrap;
}
Segmented {
label: "Format";
options: root.format-labels;
selected: root.format-selected;
picked(i) => { root.format-picked(i); }
}
// A bounded number, so it gets the control for one.
//
// This was a free-text field: the range lived in the
// hint and was enforced nowhere, and `to-float()`
// answers 0 for anything unparseable — so a typo saved
// a quality of 0 and the page then showed the 0 back as
// though it had been asked for. The track carries the
// range and the box refuses what it cannot read.
SliderRow {
label: "Quality";
// Says why it is greyed rather than leaving the
// user to work out that PNG has no quality.
hint: root.quality-enabled ? "1 to 100"
: "the chosen format is lossless";
value: root.quality;
// No meaningful neutral: quality has a sensible
// default but not a *zero*, and a default marker
// partway along a track reads as one.
default-value: 1;
minimum: 1;
maximum: 100;
enabled: root.quality-enabled;
changed(v) => { root.quality-changed(v); }
}
Segmented {
label: "Colour space";
hint: "profile embedded on export";
options: root.colour-labels;
selected: root.colour-selected;
picked(i) => { root.colour-picked(i); }
}
Segmented {
label: "Size";
options: root.sizing-labels;
selected: root.sizing-selected;
picked(i) => { root.sizing-picked(i); }
}
// Only where the chosen mode carries a number:
// "Original" has none, and a field showing 0 beside it
// would invite the reading "zero pixels".
if root.sizing-has-value: TextRow {
label: root.sizing-value-label;
text: root.sizing-value;
unit: root.sizing-unit;
field-width: 90px;
accepted(t) => { root.sizing-value-changed(t); }
}
// TRACES: FR-EXP-3
// The second axis, for the box modes only.
if root.sizing-has-height: TextRow {
label: "Height";
text: root.sizing-height;
unit: "px high";
field-width: 90px;
accepted(t) => { root.sizing-height-changed(t); }
}
// TRACES: FR-EXP-3
// Panel sizes as buttons, because the numbers are the
// whole difficulty: a television's art mode accepts one
// resolution and rejects everything else, and getting
// it by typing four digits twice is a step at which a
// photographer discovers they were wrong only after the
// upload.
//
// Alongside the fields rather than instead of them —
// the presets are a shortcut to a pair of numbers, not
// a replacement for being able to say any pair.
if root.sizing-has-height: Segmented {
label: "Screen";
hint: "fills in both numbers";
options: root.screen-labels;
selected: root.screen-selected;
picked(i) => { root.screen-picked(i); }
}
Check {
label: "Allow upscaling";
hint: "Off, a request larger than the source exports "
+ "at source size rather than failing.";
checked: root.allow-upscaling;
toggled(on) => { root.upscaling-toggled(on); }
}
Segmented {
label: "Output sharpening";
hint: "scaled by the resize factor";
options: root.sharpening-labels;
selected: root.sharpening-selected;
picked(i) => { root.sharpening-picked(i); }
}
ExportImageOptions {}
}
}
@@ -1131,179 +957,7 @@ export component SettingsPage inherits Rectangle {
}
}
TextRow {
label: "Filename template";
hint: "{name} {seq} {date} {dimensions} {preset}";
text <=> root.filename-template;
field-width: 260px;
placeholder: "{name}";
accepted(t) => { root.template-changed(t); }
}
Segmented {
label: "If the file exists";
options: root.collision-labels;
selected: root.collision-selected;
picked(i) => { root.collision-picked(i); }
}
// Where the file lands, before what it is called: on
// Android the answer decides whether an export needs
// the Storage Access Framework at all, and on any
// platform a server destination is reached over a
// network that may not be there.
Segmented {
label: "Export to";
options: root.target-labels;
selected: root.target-selected;
picked(i) => { root.target-picked(i); }
}
TextRow {
label: "Destination";
// What an empty field does, not what it was once
// going to do: nothing asks, and an export with
// no folder is refused and says so in the header.
hint: "a folder on this device; exports are refused until one is set";
text <=> root.destination;
field-width: 320px;
placeholder: root.destination-hint;
accepted(t) => { root.destination-changed(t); }
}
// Offered only for a server destination. A folder on
// this device is chosen by the platform's own dialogue
// or typed; a folder on the server can only be found
// by walking it, and expecting anyone to recall the
// exact spelling of a path three levels down is how a
// destination silently becomes a new folder at the
// root.
if root.browse-available && !root.browse-open: HorizontalLayout {
alignment: start;
Button {
text: "Choose folder…";
clicked => { root.browse-open-picker(); }
}
}
if root.browse-open: Rectangle {
background: Theme.ground;
border-radius: Theme.radius;
height: picker.preferred-height + 2 * Theme.gap;
picker := VerticalLayout {
x: Theme.gap;
y: Theme.gap;
width: parent.width - 2 * Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "↑ Up";
// Disabled rather than hidden at the
// root: a control that vanishes moves
// everything beside it, and the row
// would jump as the user navigates.
enabled: !root.browse-at-root;
clicked => { root.browse-up(); }
}
Value {
text: root.browse-path;
overflow: elide;
horizontal-stretch: 1;
vertical-alignment: center;
}
Caption {
text: root.browse-loading ? "Listing…" : "";
vertical-alignment: center;
}
}
// A fixed height rather than one that grows
// with the listing: a folder with sixty
// children would otherwise push the rest of
// the settings page off the bottom.
Rectangle {
height: 180px;
background: Theme.surface;
border-radius: Theme.radius;
Flickable {
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
viewport-height: folders.preferred-height;
folders := VerticalLayout {
width: 100%;
spacing: 2px;
alignment: start;
if root.browse-entries.length == 0
&& !root.browse-loading: Caption {
text: "No folders here. "
+ "Use this one, or go up.";
}
for name in root.browse-entries: Rectangle {
height: 32px;
background: touch.has-hover
? Theme.surface-raised
: transparent;
border-radius: Theme.radius;
Label {
x: Theme.gap-sm;
text: name;
vertical-alignment: center;
overflow: elide;
width: parent.width - 2 * Theme.gap-sm;
}
touch := TouchArea {
clicked => { root.browse-into(name); }
}
}
}
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Cancel";
clicked => { root.browse-cancel(); }
}
// Confirms the folder currently *shown*,
// not one selected in the list — the same
// rule the library picker follows, so
// "use this one" means the same thing in
// both places.
Button {
text: "Use this folder";
active: true;
clicked => { root.browse-confirm(); }
}
}
}
}
Check {
label: "Strip location and personal metadata";
hint: "On. An export is usually the copy that leaves "
+ "this machine, and a location embedded in a "
+ "published photograph cannot be recalled.";
checked: root.strip-location;
toggled(on) => { root.strip-location-toggled(on); }
}
ExportFileOptions {}
}
}
}