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.
481 lines
20 KiB
Plaintext
481 lines
20 KiB
Plaintext
// TRACES: FR-EXP-7 | FR-UI-5
|
|
// The export defaults, drawn wherever an export is chosen.
|
|
//
|
|
// Two places show them: the settings page, among everything else, and the
|
|
// export sheet Ctrl+E opens, alone with an Export button beneath. They are
|
|
// one set of values — every export runs on the defaults, there is no
|
|
// per-export copy — so the state is a global Rust writes once, and the two
|
|
// panels below read it, rather than forty properties threaded separately
|
|
// through each surface and bound to agree.
|
|
import { Theme } from "theme.slint";
|
|
import { Button, Label, Value, Caption } from "widgets.slint";
|
|
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
|
|
|
|
export global ExportOptions {
|
|
// --- the image ---------------------------------------------------
|
|
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;
|
|
|
|
// --- the file ----------------------------------------------------
|
|
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();
|
|
}
|
|
|
|
// What the image becomes: format, quality, colour, size, sharpening. The
|
|
// body of a panel, not a panel — each surface draws its own frame and
|
|
// heading around it.
|
|
export component ExportImageOptions inherits VerticalLayout {
|
|
spacing: Theme.gap;
|
|
|
|
Segmented {
|
|
label: "Format";
|
|
options: ExportOptions.format-labels;
|
|
selected: ExportOptions.format-selected;
|
|
picked(i) => { ExportOptions.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: ExportOptions.quality-enabled ? "1 to 100"
|
|
: "the chosen format is lossless";
|
|
value: ExportOptions.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: ExportOptions.quality-enabled;
|
|
changed(v) => { ExportOptions.quality-changed(v); }
|
|
}
|
|
|
|
Segmented {
|
|
label: "Colour space";
|
|
hint: "profile embedded on export";
|
|
options: ExportOptions.colour-labels;
|
|
selected: ExportOptions.colour-selected;
|
|
picked(i) => { ExportOptions.colour-picked(i); }
|
|
}
|
|
|
|
Segmented {
|
|
label: "Size";
|
|
options: ExportOptions.sizing-labels;
|
|
selected: ExportOptions.sizing-selected;
|
|
picked(i) => { ExportOptions.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 ExportOptions.sizing-has-value: TextRow {
|
|
label: ExportOptions.sizing-value-label;
|
|
text: ExportOptions.sizing-value;
|
|
unit: ExportOptions.sizing-unit;
|
|
field-width: 90px;
|
|
accepted(t) => { ExportOptions.sizing-value-changed(t); }
|
|
}
|
|
|
|
// TRACES: FR-EXP-3
|
|
// The second axis, for the box modes only.
|
|
if ExportOptions.sizing-has-height: TextRow {
|
|
label: "Height";
|
|
text: ExportOptions.sizing-height;
|
|
unit: "px high";
|
|
field-width: 90px;
|
|
accepted(t) => { ExportOptions.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 ExportOptions.sizing-has-height: Segmented {
|
|
label: "Screen";
|
|
hint: "fills in both numbers";
|
|
options: ExportOptions.screen-labels;
|
|
selected: ExportOptions.screen-selected;
|
|
picked(i) => { ExportOptions.screen-picked(i); }
|
|
}
|
|
|
|
Check {
|
|
label: "Allow upscaling";
|
|
hint: "Off, a request larger than the source exports "
|
|
+ "at source size rather than failing.";
|
|
checked: ExportOptions.allow-upscaling;
|
|
toggled(on) => { ExportOptions.upscaling-toggled(on); }
|
|
}
|
|
|
|
Segmented {
|
|
label: "Output sharpening";
|
|
hint: "scaled by the resize factor";
|
|
options: ExportOptions.sharpening-labels;
|
|
selected: ExportOptions.sharpening-selected;
|
|
picked(i) => { ExportOptions.sharpening-picked(i); }
|
|
}
|
|
}
|
|
|
|
// What the file is called, where it lands, and what leaves with it.
|
|
export component ExportFileOptions inherits VerticalLayout {
|
|
spacing: Theme.gap;
|
|
|
|
TextRow {
|
|
label: "Filename template";
|
|
hint: "{name} {seq} {date} {dimensions} {preset}";
|
|
text <=> ExportOptions.filename-template;
|
|
field-width: 260px;
|
|
placeholder: "{name}";
|
|
accepted(t) => { ExportOptions.template-changed(t); }
|
|
}
|
|
|
|
Segmented {
|
|
label: "If the file exists";
|
|
options: ExportOptions.collision-labels;
|
|
selected: ExportOptions.collision-selected;
|
|
picked(i) => { ExportOptions.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: ExportOptions.target-labels;
|
|
selected: ExportOptions.target-selected;
|
|
picked(i) => { ExportOptions.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 <=> ExportOptions.destination;
|
|
field-width: 320px;
|
|
placeholder: ExportOptions.destination-hint;
|
|
accepted(t) => { ExportOptions.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 ExportOptions.browse-available && !ExportOptions.browse-open: HorizontalLayout {
|
|
alignment: start;
|
|
Button {
|
|
text: "Choose folder…";
|
|
clicked => { ExportOptions.browse-open-picker(); }
|
|
}
|
|
}
|
|
|
|
if ExportOptions.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: !ExportOptions.browse-at-root;
|
|
clicked => { ExportOptions.browse-up(); }
|
|
}
|
|
|
|
Value {
|
|
text: ExportOptions.browse-path;
|
|
overflow: elide;
|
|
horizontal-stretch: 1;
|
|
vertical-alignment: center;
|
|
}
|
|
|
|
Caption {
|
|
text: ExportOptions.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 ExportOptions.browse-entries.length == 0
|
|
&& !ExportOptions.browse-loading: Caption {
|
|
text: "No folders here. "
|
|
+ "Use this one, or go up.";
|
|
}
|
|
|
|
for name in ExportOptions.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 => { ExportOptions.browse-into(name); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
alignment: end;
|
|
|
|
Button {
|
|
text: "Cancel";
|
|
clicked => { ExportOptions.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 => { ExportOptions.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: ExportOptions.strip-location;
|
|
toggled(on) => { ExportOptions.strip-location-toggled(on); }
|
|
}
|
|
}
|
|
|
|
// TRACES: FR-EXP-7 | FR-UI-5
|
|
// The export defaults on their own, over whatever is open, with the button
|
|
// that uses them — what Ctrl+E opens, and the nearest thing this application
|
|
// has to another editor's export dialogue.
|
|
//
|
|
// Every change here is saved as it is made, exactly as on the settings page,
|
|
// because they are the same values: closing without exporting keeps them, and
|
|
// the next Ctrl+Shift+E runs on what was chosen here.
|
|
export component ExportSheet inherits Rectangle {
|
|
/// What pressing Export would do — "Export JPEG", "Export 40 to the
|
|
/// library" — from whichever view opened the sheet.
|
|
in property <string> action-label: "Export";
|
|
/// False when there is nothing to export, or an export is already running.
|
|
in property <bool> can-export: true;
|
|
/// A save that failed, as the settings page reports it.
|
|
in property <string> error;
|
|
|
|
callback export();
|
|
callback dismiss();
|
|
|
|
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(); }
|
|
}
|
|
|
|
Rectangle {
|
|
// The settings column's width, so the panels lay out as they do there.
|
|
width: min(680px, parent.width - 2 * Theme.gap-lg);
|
|
height: min(body.preferred-height + footer.preferred-height
|
|
+ 3 * Theme.gap-lg,
|
|
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 { }
|
|
|
|
VerticalLayout {
|
|
padding: Theme.gap-lg;
|
|
spacing: Theme.gap-lg;
|
|
|
|
// The options scroll and the buttons do not: on a short window the
|
|
// form is longer than the card, and an Export button scrolled out
|
|
// of sight is a sheet with no way to do what it is for.
|
|
Flickable {
|
|
vertical-stretch: 1;
|
|
viewport-height: body.preferred-height;
|
|
|
|
body := VerticalLayout {
|
|
width: 100%;
|
|
spacing: Theme.gap;
|
|
alignment: start;
|
|
|
|
Text {
|
|
text: "Export";
|
|
color: Theme.ink;
|
|
font-size: Theme.text-lg;
|
|
font-weight: 600;
|
|
}
|
|
|
|
if root.error != "": Caption {
|
|
text: root.error;
|
|
warn: true;
|
|
wrap: word-wrap;
|
|
}
|
|
|
|
ExportImageOptions {}
|
|
ExportFileOptions {}
|
|
}
|
|
}
|
|
|
|
footer := HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
alignment: end;
|
|
|
|
Button {
|
|
text: "Close";
|
|
clicked => { root.dismiss(); }
|
|
}
|
|
|
|
Button {
|
|
text: root.action-label;
|
|
primary: true;
|
|
enabled: root.can-export;
|
|
clicked => { root.export(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|