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
File diff suppressed because one or more lines are too long
+44 -14
View File
@@ -5,7 +5,7 @@
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it. Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
41 gestures, in 4 places. 44 gestures, in 4 places.
## Develop ## Develop
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
<sub>`ui/dr-ui/ui/app.slint:1782`</sub> <sub>`ui/dr-ui/ui/app.slint:1701`</sub>
### Move a magnified photograph about ### Move a magnified photograph about
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture. Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:1873`</sub> <sub>`ui/dr-ui/ui/app.slint:1792`</sub>
### Paint a mask by hand ### Paint a mask by hand
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make. A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:1960`</sub> <sub>`ui/dr-ui/ui/app.slint:1879`</sub>
### Take back the last change ### Take back the last change
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does. A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2180`</sub> <sub>`ui/dr-ui/ui/app.slint:2107`</sub>
### Do it again after taking it back ### Do it again after taking it back
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, or press Redo in the History header - **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z - **Keyboard** — Ctrl+Shift+Z
<sub>`ui/dr-ui/ui/app.slint:2193`</sub> <sub>`ui/dr-ui/ui/app.slint:2120`</sub>
### Copy the settings from this photograph ### Copy the settings from this photograph
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way. The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2226`</sub> <sub>`ui/dr-ui/ui/app.slint:2153`</sub>
### Paste the settings onto this photograph ### Paste the settings onto this photograph
@@ -81,7 +81,37 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope. The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2238`</sub> <sub>`ui/dr-ui/ui/app.slint:2165`</sub>
### Choose which kinds of edit a copy carries
- **Touch** — Open Presets and toggle the kinds
- **Pointer** — Open Presets and toggle the kinds
- **Keyboard** — Ctrl+Shift+C, which offers Copy beside them
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
<sub>`ui/dr-ui/ui/app.slint:2182`</sub>
### Export this photograph as the last one was
- **Touch** — Press Export in the top bar
- **Pointer** — Press Export in the top bar
- **Keyboard** — Ctrl+Shift+E
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
<sub>`ui/dr-ui/ui/app.slint:2211`</sub>
### Choose how to export, then export
- **Touch** — Open Settings, then Export defaults
- **Pointer** — Open Settings, then Export defaults
- **Keyboard** — Ctrl+E
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
<sub>`ui/dr-ui/ui/app.slint:2223`</sub>
### Change which group of adjustments is on screen ### Change which group of adjustments is on screen
@@ -91,7 +121,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added. The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2265`</sub> <sub>`ui/dr-ui/ui/app.slint:2248`</sub>
### Look at the photograph at 1:1 ### Look at the photograph at 1:1
@@ -101,17 +131,17 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
<sub>`ui/dr-ui/ui/app.slint:2300`</sub> <sub>`ui/dr-ui/ui/app.slint:2283`</sub>
### Move to the next or previous photograph ### Move to the next or previous photograph
- **Touch** — Tap a frame in the roll along the foot of the canvas - **Touch** — Tap a frame in the roll along the foot of the canvas
- **Pointer** — Click a frame in the roll - **Pointer** — Click a frame in the roll
- **Keyboard** — Right arrow or space for the next, left arrow for the one before - **Keyboard** — Right arrow, D or space for the next; left arrow or A for the one before
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse. Unmodified only: Ctrl+D and Ctrl+A are not this.
<sub>`ui/dr-ui/ui/app.slint:2352`</sub> <sub>`ui/dr-ui/ui/app.slint:2335`</sub>
### See the photograph before you edited it ### See the photograph before you edited it
@@ -121,7 +151,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards. Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2476`</sub> <sub>`ui/dr-ui/ui/app.slint:2468`</sub>
### Put one control back to its default ### Put one control back to its default
+22 -1
View File
@@ -78,6 +78,27 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Press Paste in the top bar", pointer: "Press Paste in the top bar",
keys: "Ctrl+V", keys: "Ctrl+V",
}, },
Gesture {
title: "Choose which kinds of edit a copy carries",
section: "Develop",
touch: "Open Presets and toggle the kinds",
pointer: "Open Presets and toggle the kinds",
keys: "Ctrl+Shift+C, which offers Copy beside them",
},
Gesture {
title: "Export this photograph as the last one was",
section: "Develop",
touch: "Press Export in the top bar",
pointer: "Press Export in the top bar",
keys: "Ctrl+Shift+E",
},
Gesture {
title: "Choose how to export, then export",
section: "Develop",
touch: "Open Settings, then Export defaults",
pointer: "Open Settings, then Export defaults",
keys: "Ctrl+E",
},
Gesture { Gesture {
title: "Change which group of adjustments is on screen", title: "Change which group of adjustments is on screen",
section: "Develop", section: "Develop",
@@ -97,7 +118,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop", section: "Develop",
touch: "Tap a frame in the roll along the foot of the canvas", touch: "Tap a frame in the roll along the foot of the canvas",
pointer: "Click a frame in the roll", pointer: "Click a frame in the roll",
keys: "Right arrow or space for the next, left arrow for the one before", keys: "Right arrow, D or space for the next; left arrow or A for the one before",
}, },
Gesture { Gesture {
title: "See the photograph before you edited it", title: "See the photograph before you edited it",
+5 -5
View File
@@ -1958,7 +1958,7 @@ fn wire_settings_screen(
{ {
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_open_picker(move || { window.global::<ExportOptions>().on_browse_open_picker(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
// Opens on the library root rather than on whatever the // Opens on the library root rather than on whatever the
// destination field happens to contain: a half-typed path // destination field happens to contain: a half-typed path
@@ -1975,7 +1975,7 @@ fn wire_settings_screen(
{ {
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_into(move |name| { window.global::<ExportOptions>().on_browse_into(move |name| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
let path = { let path = {
let mut browser = ctl.browser.borrow_mut(); let mut browser = ctl.browser.borrow_mut();
@@ -1993,7 +1993,7 @@ fn wire_settings_screen(
{ {
let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_up(move || { window.global::<ExportOptions>().on_browse_up(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
let path = { let path = {
let mut browser = ctl.browser.borrow_mut(); let mut browser = ctl.browser.borrow_mut();
@@ -2011,7 +2011,7 @@ fn wire_settings_screen(
{ {
let (weak, ctl) = (weak.clone(), ctl.clone()); let (weak, ctl) = (weak.clone(), ctl.clone());
window.on_settings_browse_confirm(move || { window.global::<ExportOptions>().on_browse_confirm(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
// The folder being *shown* is the one chosen, matching the // The folder being *shown* is the one chosen, matching the
// library picker — so "use this one" means the same thing in // library picker — so "use this one" means the same thing in
@@ -2029,7 +2029,7 @@ fn wire_settings_screen(
{ {
let (weak, ctl) = (weak.clone(), ctl.clone()); let (weak, ctl) = (weak.clone(), ctl.clone());
window.on_settings_browse_cancel(move || { window.global::<ExportOptions>().on_browse_cancel(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
ctl.browser.replace(None); ctl.browser.replace(None);
settings_ui::render(&w, &ctl); settings_ui::render(&w, &ctl);
+66 -51
View File
@@ -32,7 +32,7 @@ use dr_types::{
use slint::ComponentHandle; use slint::ComponentHandle;
use crate::settings_store::SettingsStore; use crate::settings_store::SettingsStore;
use crate::{Adjustments, AppWindow, Page}; use crate::{Adjustments, AppWindow, ExportOptions, Page};
use dr_sync::Connection; use dr_sync::Connection;
/// Shared settings state for the running window. /// Shared settings state for the running window.
@@ -245,38 +245,38 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
// Choice rows are sent as labels plus the selected index rather than as a // Choice rows are sent as labels plus the selected index rather than as a
// model of structs: the page draws a row of chips from them and nothing // model of structs: the page draws a row of chips from them and nothing
// else, so a label and an index is the whole of what it needs. // else, so a label and an index is the whole of what it needs.
window.set_settings_format_labels(labels(ExportFormat::ALL.iter().map(|f| f.label()))); window.global::<ExportOptions>().set_format_labels(labels(ExportFormat::ALL.iter().map(|f| f.label())));
window.set_settings_format_selected(index_of(&ExportFormat::ALL, &s.export.format)); window.global::<ExportOptions>().set_format_selected(index_of(&ExportFormat::ALL, &s.export.format));
window.set_settings_quality(s.export.quality as i32); window.global::<ExportOptions>().set_quality(s.export.quality as i32);
// Disabled rather than hidden for a lossless format: a control that // Disabled rather than hidden for a lossless format: a control that
// vanishes when PNG is picked reads as a bug, where a greyed one explains // vanishes when PNG is picked reads as a bug, where a greyed one explains
// itself. // itself.
window.set_settings_quality_enabled(s.export.format.is_lossy()); window.global::<ExportOptions>().set_quality_enabled(s.export.format.is_lossy());
window.set_settings_colour_labels(labels(ColourSpace::ALL.iter().map(|c| c.label()))); window.global::<ExportOptions>().set_colour_labels(labels(ColourSpace::ALL.iter().map(|c| c.label())));
window.set_settings_colour_selected(index_of(&ColourSpace::ALL, &s.export.colour_space)); window.global::<ExportOptions>().set_colour_selected(index_of(&ColourSpace::ALL, &s.export.colour_space));
window.set_settings_sizing_labels(labels(SizingMode::CHOICES.iter().map(|m| m.label()))); window.global::<ExportOptions>().set_sizing_labels(labels(SizingMode::CHOICES.iter().map(|m| m.label())));
// Compared by variant, not by equality: `LongEdge(900)` after the user // Compared by variant, not by equality: `LongEdge(900)` after the user
// typed their own number is still the "Long edge" choice, and equality // typed their own number is still the "Long edge" choice, and equality
// against `CHOICES` would light nothing. // against `CHOICES` would light nothing.
window.set_settings_sizing_selected( window.global::<ExportOptions>().set_sizing_selected(
SizingMode::CHOICES SizingMode::CHOICES
.iter() .iter()
.position(|m| m.same_mode(s.export.sizing)) .position(|m| m.same_mode(s.export.sizing))
.unwrap_or(0) as i32, .unwrap_or(0) as i32,
); );
window.set_settings_sizing_value(s.export.sizing.value().unwrap_or(0) as i32); window.global::<ExportOptions>().set_sizing_value(s.export.sizing.value().unwrap_or(0) as i32);
// `Original` carries no number, so the field beside the chips has nothing // `Original` carries no number, so the field beside the chips has nothing
// to edit and is hidden rather than shown holding a meaningless zero. // to edit and is hidden rather than shown holding a meaningless zero.
window.set_settings_sizing_has_value(s.export.sizing.value().is_some()); window.global::<ExportOptions>().set_sizing_has_value(s.export.sizing.value().is_some());
// TRACES: FR-EXP-3 // TRACES: FR-EXP-3
// The unit carries the axis for the box modes, so that two numeric fields // The unit carries the axis for the box modes, so that two numeric fields
// one above the other are not two anonymous numbers. It reads "3840 px // one above the other are not two anonymous numbers. It reads "3840 px
// wide" and "2160 px high", which is the sentence the user is trying to // wide" and "2160 px high", which is the sentence the user is trying to
// write anyway. // write anyway.
window.set_settings_sizing_unit( window.global::<ExportOptions>().set_sizing_unit(
match s.export.sizing { match s.export.sizing {
SizingMode::Percentage(_) => "%", SizingMode::Percentage(_) => "%",
SizingMode::FitBox(..) | SizingMode::FillBox(..) => "px wide", SizingMode::FitBox(..) | SizingMode::FillBox(..) => "px wide",
@@ -289,7 +289,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
// partner and both stop being "the size value" — one is the width and one // partner and both stop being "the size value" — one is the width and one
// is the height, and a page that called either of them "Size value" would // is the height, and a page that called either of them "Size value" would
// leave the user guessing which axis they were editing. // leave the user guessing which axis they were editing.
window.set_settings_sizing_value_label( window.global::<ExportOptions>().set_sizing_value_label(
if s.export.sizing.height().is_some() { if s.export.sizing.height().is_some() {
"Width" "Width"
} else { } else {
@@ -297,15 +297,15 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
} }
.into(), .into(),
); );
window.set_settings_sizing_height(s.export.sizing.height().unwrap_or(0) as i32); window.global::<ExportOptions>().set_sizing_height(s.export.sizing.height().unwrap_or(0) as i32);
window.set_settings_sizing_has_height(s.export.sizing.height().is_some()); window.global::<ExportOptions>().set_sizing_has_height(s.export.sizing.height().is_some());
// TRACES: FR-EXP-3 // TRACES: FR-EXP-3
// The panels, offered as buttons. Lit only on an exact match, and -1 — // The panels, offered as buttons. Lit only on an exact match, and -1 —
// no chip lit — where the user has typed their own numbers: a preset that // no chip lit — where the user has typed their own numbers: a preset that
// stayed selected while the fields said something else would be the page // stayed selected while the fields said something else would be the page
// claiming the export is 4K when it is not. // claiming the export is 4K when it is not.
window.set_settings_screen_labels(labels(ScreenSize::ALL.iter().map(|p| p.label))); window.global::<ExportOptions>().set_screen_labels(labels(ScreenSize::ALL.iter().map(|p| p.label)));
window.set_settings_screen_selected( window.global::<ExportOptions>().set_screen_selected(
ScreenSize::ALL ScreenSize::ALL
.iter() .iter()
.position(|p| { .position(|p| {
@@ -314,42 +314,42 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
}) })
.map_or(-1, |i| i as i32), .map_or(-1, |i| i as i32),
); );
window.set_settings_allow_upscaling(s.export.allow_upscaling); window.global::<ExportOptions>().set_allow_upscaling(s.export.allow_upscaling);
window.set_settings_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label()))); window.global::<ExportOptions>().set_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label())));
window.set_settings_sharpening_selected(index_of(&OutputSharpening::ALL, &s.export.sharpening)); window.global::<ExportOptions>().set_sharpening_selected(index_of(&OutputSharpening::ALL, &s.export.sharpening));
window.set_settings_filename_template(s.export.filename_template.clone().into()); window.global::<ExportOptions>().set_filename_template(s.export.filename_template.clone().into());
window.set_settings_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label()))); window.global::<ExportOptions>().set_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label())));
window.set_settings_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision)); window.global::<ExportOptions>().set_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision));
window.set_settings_strip_location(s.export.strip_location); window.global::<ExportOptions>().set_strip_location(s.export.strip_location);
// `available()`, not `ALL`: Android cannot write to a device folder, and // `available()`, not `ALL`: Android cannot write to a device folder, and
// offering a target that fails at the last step is what made export look // offering a target that fails at the last step is what made export look
// broken there rather than absent. // broken there rather than absent.
let targets = ExportTarget::available(); let targets = ExportTarget::available();
window.set_settings_target_labels(labels(targets.iter().map(|t| t.label()))); window.global::<ExportOptions>().set_target_labels(labels(targets.iter().map(|t| t.label())));
window.set_settings_target_selected(index_of(targets, &s.export.target)); window.global::<ExportOptions>().set_target_selected(index_of(targets, &s.export.target));
window.set_settings_destination(s.export.active_destination().into()); window.global::<ExportOptions>().set_destination(s.export.active_destination().into());
// Derived from the target itself, never from its position in a list whose // Derived from the target itself, never from its position in a list whose
// length differs by platform. // length differs by platform.
window.set_settings_browse_available(s.export.target.is_remote()); window.global::<ExportOptions>().set_browse_available(s.export.target.is_remote());
// The field means different things either side of the choice, and a // The field means different things either side of the choice, and a
// placeholder saying which is cheaper than a paragraph under it. // placeholder saying which is cheaper than a paragraph under it.
// The placeholder names what an empty field *means*, which differs by // The placeholder names what an empty field *means*, which differs by
// target: on a filesystem it is a question, on the server it is the // target: on a filesystem it is a question, on the server it is the
// library root. // library root.
window.set_settings_destination_hint(s.export.destination_label().into()); window.global::<ExportOptions>().set_destination_hint(s.export.destination_label().into());
// --- the remote folder picker -------------------------------------- // --- the remote folder picker --------------------------------------
{ {
let browser = controller.browser.borrow(); let browser = controller.browser.borrow();
window.set_settings_browse_open(browser.is_some()); window.global::<ExportOptions>().set_browse_open(browser.is_some());
match browser.as_ref() { match browser.as_ref() {
Some(b) => { Some(b) => {
// The root is shown as a word rather than as an empty string, // The root is shown as a word rather than as an empty string,
// which would read as a control that had lost its value. // which would read as a control that had lost its value.
window.set_settings_browse_path( window.global::<ExportOptions>().set_browse_path(
if b.path.is_empty() { if b.path.is_empty() {
"Library root".to_string() "Library root".to_string()
} else { } else {
@@ -357,9 +357,9 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
} }
.into(), .into(),
); );
window.set_settings_browse_loading(b.loading); window.global::<ExportOptions>().set_browse_loading(b.loading);
window.set_settings_browse_at_root(b.parent_path().is_none()); window.global::<ExportOptions>().set_browse_at_root(b.parent_path().is_none());
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from( window.global::<ExportOptions>().set_browse_entries(slint::ModelRc::new(slint::VecModel::from(
b.entries b.entries
.iter() .iter()
.map(|e| slint::SharedString::from(e.as_str())) .map(|e| slint::SharedString::from(e.as_str()))
@@ -367,10 +367,10 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
))); )));
} }
None => { None => {
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from( window.global::<ExportOptions>().set_browse_entries(slint::ModelRc::new(slint::VecModel::from(
Vec::<slint::SharedString>::new(), Vec::<slint::SharedString>::new(),
))); )));
window.set_settings_browse_loading(false); window.global::<ExportOptions>().set_browse_loading(false);
} }
} }
} }
@@ -440,6 +440,21 @@ fn wire_open_close(
}); });
} }
// TRACES: FR-EXP-7 | FR-UI-5
// The export sheet shows the same values as the settings page, so it is
// read from disk and rendered the same way before it opens — without the
// page, and without `on_open`, which prepares the page's other panels.
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_export_options_open(move || {
let Some(w) = weak.upgrade() else { return };
*ctl.settings.borrow_mut() = ctl.store.load();
render(&w, &ctl);
w.set_export_sheet_open(true);
});
}
{ {
let weak = window.as_weak(); let weak = window.as_weak();
window.on_settings_close(move || { window.on_settings_close(move || {
@@ -620,7 +635,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
}); });
} }
window.on_settings_format_picked(move |i| { window.global::<ExportOptions>().on_format_picked(move |i| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(f) = ExportFormat::ALL.get(i as usize).copied() { if let Some(f) = ExportFormat::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.format = f); ctl.edit(|s| s.export.format = f);
@@ -632,7 +647,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_quality_changed(move |q| { window.global::<ExportOptions>().on_quality_changed(move |q| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
// Cast before clamping: a negative from the control would wrap to a // Cast before clamping: a negative from the control would wrap to a
// large `u8` and land on 100 instead of the floor. // large `u8` and land on 100 instead of the floor.
@@ -656,7 +671,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_colour_picked(move |i| { window.global::<ExportOptions>().on_colour_picked(move |i| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(c) = ColourSpace::ALL.get(i as usize).copied() { if let Some(c) = ColourSpace::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.colour_space = c); ctl.edit(|s| s.export.colour_space = c);
@@ -668,7 +683,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_sizing_picked(move |i| { window.global::<ExportOptions>().on_sizing_picked(move |i| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(mode) = SizingMode::CHOICES.get(i as usize).copied() { if let Some(mode) = SizingMode::CHOICES.get(i as usize).copied() {
// Keeps the number the user already typed when they move // Keeps the number the user already typed when they move
@@ -700,7 +715,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_sizing_value_changed(move |text| { window.global::<ExportOptions>().on_sizing_value_changed(move |text| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
match text.trim().parse::<u32>() { match text.trim().parse::<u32>() {
Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_value(v)), Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_value(v)),
@@ -713,7 +728,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_sizing_height_changed(move |text| { window.global::<ExportOptions>().on_sizing_height_changed(move |text| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
match text.trim().parse::<u32>() { match text.trim().parse::<u32>() {
Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_height(v)), Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_height(v)),
@@ -734,7 +749,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
// photograph for a user who wanted them. // photograph for a user who wanted them.
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_screen_picked(move |i| { window.global::<ExportOptions>().on_screen_picked(move |i| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(p) = ScreenSize::ALL.get(i.max(0) as usize) { if let Some(p) = ScreenSize::ALL.get(i.max(0) as usize) {
ctl.edit(|s| s.export.sizing = s.export.sizing.with_box(p.width, p.height)); ctl.edit(|s| s.export.sizing = s.export.sizing.with_box(p.width, p.height));
@@ -746,7 +761,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_upscaling_toggled(move |on| { window.global::<ExportOptions>().on_upscaling_toggled(move |on| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.allow_upscaling = on); ctl.edit(|s| s.export.allow_upscaling = on);
render(&w, &ctl); render(&w, &ctl);
@@ -756,7 +771,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_sharpening_picked(move |i| { window.global::<ExportOptions>().on_sharpening_picked(move |i| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(x) = OutputSharpening::ALL.get(i as usize).copied() { if let Some(x) = OutputSharpening::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.sharpening = x); ctl.edit(|s| s.export.sharpening = x);
@@ -768,7 +783,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_template_changed(move |text| { window.global::<ExportOptions>().on_template_changed(move |text| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.filename_template = text.to_string()); ctl.edit(|s| s.export.filename_template = text.to_string());
render(&w, &ctl); render(&w, &ctl);
@@ -778,7 +793,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_collision_picked(move |i| { window.global::<ExportOptions>().on_collision_picked(move |i| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(c) = CollisionPolicy::ALL.get(i as usize).copied() { if let Some(c) = CollisionPolicy::ALL.get(i as usize).copied() {
ctl.edit(|s| s.export.collision = c); ctl.edit(|s| s.export.collision = c);
@@ -790,7 +805,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_strip_location_toggled(move |on| { window.global::<ExportOptions>().on_strip_location_toggled(move |on| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.strip_location = on); ctl.edit(|s| s.export.strip_location = on);
render(&w, &ctl); render(&w, &ctl);
@@ -800,7 +815,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_destination_changed(move |text| { window.global::<ExportOptions>().on_destination_changed(move |text| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.set_active_destination(text.to_string())); ctl.edit(|s| s.export.set_active_destination(text.to_string()));
render(&w, &ctl); render(&w, &ctl);
@@ -810,7 +825,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
window.on_settings_target_changed(move |i| { window.global::<ExportOptions>().on_target_picked(move |i| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(&t) = ExportTarget::available().get(i.max(0) as usize) { if let Some(&t) = ExportTarget::available().get(i.max(0) as usize) {
// Nothing is cleared. Each target keeps its own destination // Nothing is cleared. Each target keeps its own destination
@@ -1021,7 +1036,7 @@ mod tests {
fn switching_between_sized_modes_keeps_the_typed_number() { fn switching_between_sized_modes_keeps_the_typed_number() {
let ctl = controller("sizing"); let ctl = controller("sizing");
ctl.edit(|s| s.export.sizing = SizingMode::LongEdge(900)); ctl.edit(|s| s.export.sizing = SizingMode::LongEdge(900));
// What `on_settings_sizing_picked` does for a sized target. // What `ExportOptions::on_sizing_picked` does for a sized target.
ctl.edit(|s| { ctl.edit(|s| {
s.export.sizing = match s.export.sizing.value() { s.export.sizing = match s.export.sizing.value() {
Some(v) => SizingMode::ShortEdge(0).with_value(v), Some(v) => SizingMode::ShortEdge(0).with_value(v),
+147 -111
View File
@@ -14,7 +14,8 @@ import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, Collection
MembershipSheet, MembershipRow } from "collections.slint"; MembershipSheet, MembershipRow } from "collections.slint";
import { HistogramPanel, HistogramView, Levels } from "histogram.slint"; import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint"; import { RecoveryPrompt } from "recovery.slint";
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint"; import { CopySheet, PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { ExportOptions, ExportSheet } from "export.slint";
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint"; import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
import { SettingsPage } from "settings.slint"; import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint"; import { ImportPage } from "import.slint";
@@ -33,6 +34,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps } export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
export { Collections } export { Collections }
export { Library } export { Library }
export { ExportOptions }
/// Which top-level screen is on display. Exactly one at a time — replacing /// Which top-level screen is on display. Exactly one at a time — replacing
/// the `show-launch` / `show-library` / `show-identity` booleans, with /// the `show-launch` / `show-library` / `show-identity` booleans, with
@@ -607,39 +609,6 @@ export component AppWindow inherits Window {
in property <[string]> settings-group-nav-labels; in property <[string]> settings-group-nav-labels;
in property <int> settings-group-nav-selected: 0; in property <int> settings-group-nav-selected: 0;
in property <string> settings-group-nav-auto-says; in property <string> settings-group-nav-auto-says;
in property <[string]> settings-format-labels;
in property <int> settings-format-selected: 0;
in property <int> settings-quality: 90;
in property <bool> settings-quality-enabled: true;
in property <[string]> settings-colour-labels;
in property <int> settings-colour-selected: 0;
in property <[string]> settings-sizing-labels;
in property <int> settings-sizing-selected: 0;
in property <int> settings-sizing-value: 0;
in property <bool> settings-sizing-has-value: false;
in property <string> settings-sizing-unit: "px";
in property <string> settings-sizing-value-label: "Size value";
in property <int> settings-sizing-height: 0;
in property <bool> settings-sizing-has-height: false;
in property <[string]> settings-screen-labels;
in property <int> settings-screen-selected: -1;
in property <bool> settings-allow-upscaling: false;
in property <[string]> settings-sharpening-labels;
in property <int> settings-sharpening-selected: 0;
in property <string> settings-filename-template: "";
in property <[string]> settings-collision-labels;
in property <int> settings-collision-selected: 0;
in property <bool> settings-strip-location: true;
in property <string> settings-destination: "";
in property <string> settings-destination-hint;
in property <bool> settings-browse-open: false;
in property <string> settings-browse-path;
in property <[string]> settings-browse-entries;
in property <bool> settings-browse-loading: false;
in property <bool> settings-browse-at-root: true;
in property <bool> settings-browse-available: false;
in property <[string]> settings-target-labels;
in property <int> settings-target-selected: 0;
in property <string> settings-error: ""; in property <string> settings-error: "";
/// TRACES: FR-CULL-8 /// TRACES: FR-CULL-8
@@ -649,25 +618,6 @@ export component AppWindow inherits Window {
callback settings-face-detector-picked(int); callback settings-face-detector-picked(int);
callback settings-group-nav-picked(int); callback settings-group-nav-picked(int);
callback settings-format-picked(int);
callback settings-quality-changed(int);
callback settings-colour-picked(int);
callback settings-sizing-picked(int);
callback settings-sizing-value-changed(string);
callback settings-sizing-height-changed(string);
callback settings-screen-picked(int);
callback settings-upscaling-toggled(bool);
callback settings-sharpening-picked(int);
callback settings-template-changed(string);
callback settings-collision-picked(int);
callback settings-strip-location-toggled(bool);
callback settings-destination-changed(string);
callback settings-target-changed(int);
callback settings-browse-open-picker();
callback settings-browse-into(string);
callback settings-browse-up();
callback settings-browse-confirm();
callback settings-browse-cancel();
callback settings-reset(); callback settings-reset();
/// Show the settings page. Reads the file first, so a second instance's /// Show the settings page. Reads the file first, so a second instance's
@@ -675,13 +625,24 @@ export component AppWindow inherits Window {
callback settings-open(); callback settings-open();
callback settings-close(); callback settings-close();
/// TRACES: FR-EXP-7 | FR-UI-5
/// The export sheet: the export defaults on their own with an Export
/// button, over develop or the grid. Ctrl+E. Opened through Rust, which
/// reads the settings file first for the reason `settings-open` does.
in-out property <bool> export-sheet-open: false;
callback export-options-open();
/// TRACES: FR-DEV-6 | FR-UI-5
/// The copy sheet: which kinds of edit a copy carries, and Copy. Ctrl+Shift+C.
in-out property <bool> copy-sheet-open: false;
/// TRACES: FR-UI-5 /// TRACES: FR-UI-5
/// One photograph along, from the keyboard. /// One photograph along, from the keyboard.
/// ///
/// `next-image` and `prev-image` walk the files given on the command /// `next-image` and `prev-image` walk the files given on the command
/// line, and a photograph opened from a library leaves that list empty — /// line, and a photograph opened from a library leaves that list empty —
/// so with a library open the arrow keys and space did nothing at all, and /// so with a library open the arrows, space, A and D did nothing at all,
/// only a click on the roll moved on. With a library the step is the /// and only a click on the roll moved on. With a library the step is the
/// roll's own: open its neighbouring frame, exactly as clicking it would. /// roll's own: open its neighbouring frame, exactly as clicking it would.
function step-photo(delta: int) { function step-photo(delta: int) {
if (Library.library-total > 0) { if (Library.library-total > 0) {
@@ -955,6 +916,16 @@ in property <bool> panel-visible: true;
root.recovery-dismiss(); root.recovery-dismiss();
return accept; return accept;
} }
// The two shortcut sheets, which are drawn over everything
// but the recovery question.
if (root.export-sheet-open) {
root.export-sheet-open = false;
return accept;
}
if (root.copy-sheet-open) {
root.copy-sheet-open = false;
return accept;
}
// The row menu and the membership sheet are drawn over the // The row menu and the membership sheet are drawn over the
// offline prompt, so Back has to reach them first — and the // offline prompt, so Back has to reach them first — and the
// menu's own confirmation is a step Back must be able to take // menu's own confirmation is a step Back must be able to take
@@ -1106,61 +1077,9 @@ in property <bool> panel-visible: true;
group-nav-labels: root.settings-group-nav-labels; group-nav-labels: root.settings-group-nav-labels;
group-nav-selected: root.settings-group-nav-selected; group-nav-selected: root.settings-group-nav-selected;
group-nav-auto-says: root.settings-group-nav-auto-says; group-nav-auto-says: root.settings-group-nav-auto-says;
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
quality: root.settings-quality;
quality-enabled: root.settings-quality-enabled;
colour-labels: root.settings-colour-labels;
colour-selected: root.settings-colour-selected;
sizing-labels: root.settings-sizing-labels;
sizing-selected: root.settings-sizing-selected;
sizing-value: root.settings-sizing-value;
sizing-has-value: root.settings-sizing-has-value;
sizing-unit: root.settings-sizing-unit;
sizing-value-label: root.settings-sizing-value-label;
sizing-height: root.settings-sizing-height;
sizing-has-height: root.settings-sizing-has-height;
screen-labels: root.settings-screen-labels;
screen-selected: root.settings-screen-selected;
allow-upscaling: root.settings-allow-upscaling;
sharpening-labels: root.settings-sharpening-labels;
sharpening-selected: root.settings-sharpening-selected;
filename-template: root.settings-filename-template;
collision-labels: root.settings-collision-labels;
collision-selected: root.settings-collision-selected;
strip-location: root.settings-strip-location;
destination: root.settings-destination;
destination-hint: root.settings-destination-hint;
browse-open: root.settings-browse-open;
browse-path: root.settings-browse-path;
browse-entries: root.settings-browse-entries;
browse-loading: root.settings-browse-loading;
browse-at-root: root.settings-browse-at-root;
browse-available: root.settings-browse-available;
target-labels: root.settings-target-labels;
target-selected: root.settings-target-selected;
error: root.settings-error; error: root.settings-error;
group-nav-picked(i) => { root.settings-group-nav-picked(i); } group-nav-picked(i) => { root.settings-group-nav-picked(i); }
format-picked(i) => { root.settings-format-picked(i); }
quality-changed(q) => { root.settings-quality-changed(q); }
colour-picked(i) => { root.settings-colour-picked(i); }
sizing-picked(i) => { root.settings-sizing-picked(i); }
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
sizing-height-changed(t) => { root.settings-sizing-height-changed(t); }
screen-picked(i) => { root.settings-screen-picked(i); }
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
template-changed(t) => { root.settings-template-changed(t); }
collision-picked(i) => { root.settings-collision-picked(i); }
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
destination-changed(t) => { root.settings-destination-changed(t); }
target-picked(i) => { root.settings-target-changed(i); }
browse-open-picker() => { root.settings-browse-open-picker(); }
browse-into(n) => { root.settings-browse-into(n); }
browse-up() => { root.settings-browse-up(); }
browse-confirm() => { root.settings-browse-confirm(); }
browse-cancel() => { root.settings-browse-cancel(); }
activity-rows: root.activity-rows; activity-rows: root.activity-rows;
activity-running: root.activity-running; activity-running: root.activity-running;
@@ -2168,6 +2087,14 @@ in property <bool> panel-visible: true;
key-pressed(event) => { key-pressed(event) => {
root.shift-held = event.modifiers.shift; root.shift-held = event.modifiers.shift;
// A sheet is up over the photograph, and these
// keys would act on what it covers: A stepping to
// the next frame behind the export form. Declined
// rather than swallowed, so Escape still reaches
// the shell and closes the sheet.
if (root.export-sheet-open || root.copy-sheet-open) {
return reject;
}
// TRACES: FR-DEV-7 // TRACES: FR-DEV-7
// Hold backslash to see the unedited original. // Hold backslash to see the unedited original.
// Rust ignores a repeat that says what it already // Rust ignores a repeat that says what it already
@@ -2252,6 +2179,25 @@ in property <bool> panel-visible: true;
// Guarded on `settings-armed` for the reason the // Guarded on `settings-armed` for the reason the
// button is: a paste with nothing copied is not a // button is: a paste with nothing copied is not a
// clear, it is a keystroke that should do nothing. // clear, it is a keystroke that should do nothing.
// GESTURE: Choose which kinds of edit a copy carries
// where: Develop
// touch: Open Presets and toggle the kinds
// pointer: Open Presets and toggle the kinds
// keys: Ctrl+Shift+C, which offers Copy
// beside them
// why: Lightroom's Copy Settings. Pasting a
// look across a shoot usually means
// leaving each frame's crop and
// rotation alone, and that is a choice
// to make at the moment of copying.
//
// Shift first: the logical key is "C" under shift,
// which the plain copy below would otherwise take.
if (event.modifiers.control && event.modifiers.shift
&& (event.text == "c" || event.text == "C")) {
root.copy-sheet-open = true;
return accept;
}
if (event.modifiers.control if (event.modifiers.control
&& (event.text == "c" || event.text == "C")) { && (event.text == "c" || event.text == "C")) {
Transfer.copy(); Transfer.copy();
@@ -2262,6 +2208,43 @@ in property <bool> panel-visible: true;
Transfer.paste(); Transfer.paste();
return accept; return accept;
} }
// GESTURE: Export this photograph as the last one was
// where: Develop
// touch: Press Export in the top bar
// pointer: Press Export in the top bar
// keys: Ctrl+Shift+E
// why: Every export runs on the defaults in
// Settings, so "as the last one was" is
// what the button already does. The
// chord is Lightroom's and darktable's,
// kept so hands that learned it there
// need not learn it again.
//
// GESTURE: Choose how to export, then export
// where: Develop
// touch: Open Settings, then Export defaults
// pointer: Open Settings, then Export defaults
// keys: Ctrl+E
// why: The export sheet is the export
// defaults alone with an Export button.
// What is chosen there is kept, so it
// is also what the next Ctrl+Shift+E
// uses.
//
// TRACES: FR-UI-5 | FR-EXP-7
// Both cases of the letter, as for Z: holding
// shift turns "e" into "E". Rust turns away a
// second export while one is running, so the
// chord needs no busy guard of its own.
if (event.modifiers.control
&& (event.text == "e" || event.text == "E")) {
if (event.modifiers.shift) {
root.export-image();
} else {
root.export-options-open();
}
return accept;
}
// GESTURE: Change which group of adjustments is on screen // GESTURE: Change which group of adjustments is on screen
// where: Develop // where: Develop
// touch: Tap a group in the rail down the left // touch: Tap a group in the rail down the left
@@ -2354,17 +2337,26 @@ in property <bool> panel-visible: true;
// touch: Tap a frame in the roll along the // touch: Tap a frame in the roll along the
// foot of the canvas // foot of the canvas
// pointer: Click a frame in the roll // pointer: Click a frame in the roll
// keys: Right arrow or space for the next, // keys: Right arrow, D or space for the
// left arrow for the one before // next; left arrow or A for the one
// before
// why: The edit on screen is saved on the // why: The edit on screen is saved on the
// way out, so stepping through a folder // way out, so stepping through a folder
// is as much a departure as going back // is as much a departure as going back
// to the grid and loses nothing. // to the grid and loses nothing.
if (event.text == Key.RightArrow || event.text == " ") { // A and D as well as the arrows, so the left hand
// steps along the roll while the right stays on
// the mouse. Unmodified only: Ctrl+D and Ctrl+A
// are not this.
if (event.text == Key.RightArrow || event.text == " "
|| (!event.modifiers.control
&& (event.text == "d" || event.text == "D"))) {
root.step-photo(1); root.step-photo(1);
return accept; return accept;
} }
if (event.text == Key.LeftArrow) { if (event.text == Key.LeftArrow
|| (!event.modifiers.control
&& (event.text == "a" || event.text == "A"))) {
root.step-photo(-1); root.step-photo(-1);
return accept; return accept;
} }
@@ -2893,6 +2885,50 @@ in property <bool> panel-visible: true;
dismiss => { root.presets-open = false; } dismiss => { root.presets-open = false; }
} }
// TRACES: FR-EXP-7 | FR-UI-5
// Over the shell for the preset sheet's reasons: both views open it,
// and the develop column is too narrow for the form. What Export does
// depends on which view is underneath — the open photograph, or the
// grid's selection — so the label and the action are chosen here.
if root.export-sheet-open: ExportSheet {
width: 100%;
height: 100%;
error: root.settings-error;
action-label: root.active-view == View.develop
? root.export-label
: (root.export-to-server
? "Export " + Library.library-selected-count + " to the library"
: "Export " + Library.library-selected-count);
can-export: root.active-view == View.develop
? !root.export-busy && root.total > 0
: !Library.library-exporting && Library.library-selected-count > 0;
export => {
root.export-sheet-open = false;
if (root.active-view == View.develop) {
root.export-image();
} else {
Library.library-export-selection();
}
}
dismiss => { root.export-sheet-open = false; }
}
// TRACES: FR-DEV-6 | FR-UI-5
if root.copy-sheet-open: CopySheet {
width: 100%;
height: 100%;
scope-kinds: root.copy-scope-kinds;
scope-empty: root.copy-scope-empty;
summary: Transfer.summary;
can-copy: root.total > 0 && root.load-error == "";
scope-toggled(name) => { root.copy-scope-toggled(name); }
copy => {
root.copy-sheet-open = false;
Transfer.copy();
}
dismiss => { root.copy-sheet-open = false; }
}
OfflinePrompt { OfflinePrompt {
width: 100%; width: 100%;
height: 100%; height: 100%;
+480
View File
@@ -0,0 +1,480 @@
// 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(); }
}
}
}
}
}
+91
View File
@@ -328,3 +328,94 @@ export component PresetSheet inherits Rectangle {
} }
} }
} }
// 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(); }
}
}
}
}
}
+7 -353
View File
@@ -1,6 +1,7 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint";
import { Segmented, TextRow, Check, SliderRow } from "controls.slint"; import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
import { ExportImageOptions, ExportFileOptions } from "export.slint";
import { ScopeChips, ScopeKind } from "presets.slint"; import { ScopeChips, ScopeKind } from "presets.slint";
// Settings: how much disk the app may spend, and what an export defaults to. // 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; in property <int> xmp-conflicts: 0;
callback xmp-reload(); callback xmp-reload();
// --- export -------------------------------------------------------- // The export defaults are drawn from `ExportOptions` (export.slint),
in property <[string]> format-labels; // which the export sheet shares; nothing about them is declared here.
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();
/// A save failed. The page's whole contract is that what it shows is /// A save failed. The page's whole contract is that what it shows is
/// stored, so this cannot be swallowed. /// stored, so this cannot be swallowed.
@@ -785,113 +712,12 @@ export component SettingsPage inherits Rectangle {
PanelHeading { text: "EXPORT DEFAULTS"; } PanelHeading { text: "EXPORT DEFAULTS"; }
Caption { Caption {
text: "What an export starts from. Every one of these " text: "What every export uses. Ctrl+E shows these on their own, "
+ "is still changeable per export."; + "beside an Export button.";
wrap: word-wrap; wrap: word-wrap;
} }
Segmented { ExportImageOptions {}
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); }
}
} }
} }
@@ -1131,179 +957,7 @@ export component SettingsPage inherits Rectangle {
} }
} }
TextRow { ExportFileOptions {}
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); }
}
} }
} }
} }