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:
+36
-36
File diff suppressed because one or more lines are too long
+44
-14
@@ -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.
|
||||
|
||||
41 gestures, in 4 places.
|
||||
44 gestures, in 4 places.
|
||||
|
||||
## 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1782`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:1701`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1873`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:1792`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1960`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:1879`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2226`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2153`</sub>
|
||||
|
||||
### 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.
|
||||
|
||||
<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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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
|
||||
|
||||
- **Touch** — Tap a frame in the roll along the foot of the canvas
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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
|
||||
|
||||
|
||||
@@ -78,6 +78,27 @@ pub const GESTURES: &[Gesture] = &[
|
||||
pointer: "Press Paste in the top bar",
|
||||
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 {
|
||||
title: "Change which group of adjustments is on screen",
|
||||
section: "Develop",
|
||||
@@ -97,7 +118,7 @@ pub const GESTURES: &[Gesture] = &[
|
||||
section: "Develop",
|
||||
touch: "Tap a frame in the roll along the foot of the canvas",
|
||||
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 {
|
||||
title: "See the photograph before you edited it",
|
||||
|
||||
+5
-5
@@ -1958,7 +1958,7 @@ fn wire_settings_screen(
|
||||
|
||||
{
|
||||
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 };
|
||||
// Opens on the library root rather than on whatever the
|
||||
// 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);
|
||||
window.on_settings_browse_into(move |name| {
|
||||
window.global::<ExportOptions>().on_browse_into(move |name| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let path = {
|
||||
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);
|
||||
window.on_settings_browse_up(move || {
|
||||
window.global::<ExportOptions>().on_browse_up(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let path = {
|
||||
let mut browser = ctl.browser.borrow_mut();
|
||||
@@ -2011,7 +2011,7 @@ fn wire_settings_screen(
|
||||
|
||||
{
|
||||
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 };
|
||||
// The folder being *shown* is the one chosen, matching the
|
||||
// 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());
|
||||
window.on_settings_browse_cancel(move || {
|
||||
window.global::<ExportOptions>().on_browse_cancel(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
ctl.browser.replace(None);
|
||||
settings_ui::render(&w, &ctl);
|
||||
|
||||
+66
-51
@@ -32,7 +32,7 @@ use dr_types::{
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::settings_store::SettingsStore;
|
||||
use crate::{Adjustments, AppWindow, Page};
|
||||
use crate::{Adjustments, AppWindow, ExportOptions, Page};
|
||||
use dr_sync::Connection;
|
||||
|
||||
/// 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
|
||||
// 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.
|
||||
window.set_settings_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_labels(labels(ExportFormat::ALL.iter().map(|f| f.label())));
|
||||
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
|
||||
// vanishes when PNG is picked reads as a bug, where a greyed one explains
|
||||
// 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.set_settings_colour_selected(index_of(&ColourSpace::ALL, &s.export.colour_space));
|
||||
window.global::<ExportOptions>().set_colour_labels(labels(ColourSpace::ALL.iter().map(|c| c.label())));
|
||||
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
|
||||
// typed their own number is still the "Long edge" choice, and equality
|
||||
// against `CHOICES` would light nothing.
|
||||
window.set_settings_sizing_selected(
|
||||
window.global::<ExportOptions>().set_sizing_selected(
|
||||
SizingMode::CHOICES
|
||||
.iter()
|
||||
.position(|m| m.same_mode(s.export.sizing))
|
||||
.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
|
||||
// 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
|
||||
// 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
|
||||
// wide" and "2160 px high", which is the sentence the user is trying to
|
||||
// write anyway.
|
||||
window.set_settings_sizing_unit(
|
||||
window.global::<ExportOptions>().set_sizing_unit(
|
||||
match s.export.sizing {
|
||||
SizingMode::Percentage(_) => "%",
|
||||
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
|
||||
// is the height, and a page that called either of them "Size value" would
|
||||
// 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() {
|
||||
"Width"
|
||||
} else {
|
||||
@@ -297,15 +297,15 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_settings_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_height(s.export.sizing.height().unwrap_or(0) as i32);
|
||||
window.global::<ExportOptions>().set_sizing_has_height(s.export.sizing.height().is_some());
|
||||
// TRACES: FR-EXP-3
|
||||
// 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
|
||||
// stayed selected while the fields said something else would be the page
|
||||
// claiming the export is 4K when it is not.
|
||||
window.set_settings_screen_labels(labels(ScreenSize::ALL.iter().map(|p| p.label)));
|
||||
window.set_settings_screen_selected(
|
||||
window.global::<ExportOptions>().set_screen_labels(labels(ScreenSize::ALL.iter().map(|p| p.label)));
|
||||
window.global::<ExportOptions>().set_screen_selected(
|
||||
ScreenSize::ALL
|
||||
.iter()
|
||||
.position(|p| {
|
||||
@@ -314,42 +314,42 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
|
||||
})
|
||||
.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.set_settings_sharpening_selected(index_of(&OutputSharpening::ALL, &s.export.sharpening));
|
||||
window.global::<ExportOptions>().set_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label())));
|
||||
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.set_settings_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label())));
|
||||
window.set_settings_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision));
|
||||
window.set_settings_strip_location(s.export.strip_location);
|
||||
window.global::<ExportOptions>().set_filename_template(s.export.filename_template.clone().into());
|
||||
window.global::<ExportOptions>().set_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label())));
|
||||
window.global::<ExportOptions>().set_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision));
|
||||
window.global::<ExportOptions>().set_strip_location(s.export.strip_location);
|
||||
|
||||
// `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
|
||||
// broken there rather than absent.
|
||||
let targets = ExportTarget::available();
|
||||
window.set_settings_target_labels(labels(targets.iter().map(|t| t.label())));
|
||||
window.set_settings_target_selected(index_of(targets, &s.export.target));
|
||||
window.set_settings_destination(s.export.active_destination().into());
|
||||
window.global::<ExportOptions>().set_target_labels(labels(targets.iter().map(|t| t.label())));
|
||||
window.global::<ExportOptions>().set_target_selected(index_of(targets, &s.export.target));
|
||||
window.global::<ExportOptions>().set_destination(s.export.active_destination().into());
|
||||
// Derived from the target itself, never from its position in a list whose
|
||||
// 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
|
||||
// placeholder saying which is cheaper than a paragraph under it.
|
||||
// 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
|
||||
// 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 --------------------------------------
|
||||
{
|
||||
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() {
|
||||
Some(b) => {
|
||||
// The root is shown as a word rather than as an empty string,
|
||||
// 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() {
|
||||
"Library root".to_string()
|
||||
} else {
|
||||
@@ -357,9 +357,9 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_settings_browse_loading(b.loading);
|
||||
window.set_settings_browse_at_root(b.parent_path().is_none());
|
||||
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from(
|
||||
window.global::<ExportOptions>().set_browse_loading(b.loading);
|
||||
window.global::<ExportOptions>().set_browse_at_root(b.parent_path().is_none());
|
||||
window.global::<ExportOptions>().set_browse_entries(slint::ModelRc::new(slint::VecModel::from(
|
||||
b.entries
|
||||
.iter()
|
||||
.map(|e| slint::SharedString::from(e.as_str()))
|
||||
@@ -367,10 +367,10 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
|
||||
)));
|
||||
}
|
||||
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(),
|
||||
)));
|
||||
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();
|
||||
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 };
|
||||
if let Some(f) = ExportFormat::ALL.get(i as usize).copied() {
|
||||
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 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 };
|
||||
// Cast before clamping: a negative from the control would wrap to a
|
||||
// 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 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 };
|
||||
if let Some(c) = ColourSpace::ALL.get(i as usize).copied() {
|
||||
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 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 };
|
||||
if let Some(mode) = SizingMode::CHOICES.get(i as usize).copied() {
|
||||
// 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 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 };
|
||||
match text.trim().parse::<u32>() {
|
||||
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 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 };
|
||||
match text.trim().parse::<u32>() {
|
||||
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.
|
||||
let weak = window.as_weak();
|
||||
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 };
|
||||
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));
|
||||
@@ -746,7 +761,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
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 };
|
||||
ctl.edit(|s| s.export.allow_upscaling = on);
|
||||
render(&w, &ctl);
|
||||
@@ -756,7 +771,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
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 };
|
||||
if let Some(x) = OutputSharpening::ALL.get(i as usize).copied() {
|
||||
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 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 };
|
||||
ctl.edit(|s| s.export.filename_template = text.to_string());
|
||||
render(&w, &ctl);
|
||||
@@ -778,7 +793,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
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 };
|
||||
if let Some(c) = CollisionPolicy::ALL.get(i as usize).copied() {
|
||||
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 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 };
|
||||
ctl.edit(|s| s.export.strip_location = on);
|
||||
render(&w, &ctl);
|
||||
@@ -800,7 +815,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
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 };
|
||||
ctl.edit(|s| s.export.set_active_destination(text.to_string()));
|
||||
render(&w, &ctl);
|
||||
@@ -810,7 +825,7 @@ fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
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 };
|
||||
if let Some(&t) = ExportTarget::available().get(i.max(0) as usize) {
|
||||
// Nothing is cleared. Each target keeps its own destination
|
||||
@@ -1021,7 +1036,7 @@ mod tests {
|
||||
fn switching_between_sized_modes_keeps_the_typed_number() {
|
||||
let ctl = controller("sizing");
|
||||
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| {
|
||||
s.export.sizing = match s.export.sizing.value() {
|
||||
Some(v) => SizingMode::ShortEdge(0).with_value(v),
|
||||
|
||||
+147
-111
@@ -14,7 +14,8 @@ import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, Collection
|
||||
MembershipSheet, MembershipRow } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView, Levels } from "histogram.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 { SettingsPage } from "settings.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 { Collections }
|
||||
export { Library }
|
||||
export { ExportOptions }
|
||||
|
||||
/// Which top-level screen is on display. Exactly one at a time — replacing
|
||||
/// 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 <int> settings-group-nav-selected: 0;
|
||||
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: "";
|
||||
|
||||
/// TRACES: FR-CULL-8
|
||||
@@ -649,25 +618,6 @@ export component AppWindow inherits Window {
|
||||
callback settings-face-detector-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();
|
||||
|
||||
/// 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-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
|
||||
/// One photograph along, from the keyboard.
|
||||
///
|
||||
/// `next-image` and `prev-image` walk the files given on the command
|
||||
/// 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
|
||||
/// only a click on the roll moved on. With a library the step is the
|
||||
/// so with a library open the arrows, space, A and D did nothing at all,
|
||||
/// 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.
|
||||
function step-photo(delta: int) {
|
||||
if (Library.library-total > 0) {
|
||||
@@ -955,6 +916,16 @@ in property <bool> panel-visible: true;
|
||||
root.recovery-dismiss();
|
||||
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
|
||||
// offline prompt, so Back has to reach them first — and the
|
||||
// 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-selected: root.settings-group-nav-selected;
|
||||
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;
|
||||
|
||||
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-running: root.activity-running;
|
||||
@@ -2168,6 +2087,14 @@ in property <bool> panel-visible: true;
|
||||
|
||||
key-pressed(event) => {
|
||||
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
|
||||
// Hold backslash to see the unedited original.
|
||||
// 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
|
||||
// button is: a paste with nothing copied is not a
|
||||
// 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
|
||||
&& (event.text == "c" || event.text == "C")) {
|
||||
Transfer.copy();
|
||||
@@ -2262,6 +2208,43 @@ in property <bool> panel-visible: true;
|
||||
Transfer.paste();
|
||||
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
|
||||
// where: Develop
|
||||
// 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
|
||||
// foot of the canvas
|
||||
// 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
|
||||
// why: 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.
|
||||
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);
|
||||
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);
|
||||
return accept;
|
||||
}
|
||||
@@ -2893,6 +2885,50 @@ in property <bool> panel-visible: true;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
|
||||
import { ExportImageOptions, ExportFileOptions } from "export.slint";
|
||||
import { ScopeChips, ScopeKind } from "presets.slint";
|
||||
|
||||
// Settings: how much disk the app may spend, and what an export defaults to.
|
||||
@@ -204,82 +205,8 @@ export component SettingsPage inherits Rectangle {
|
||||
in property <int> xmp-conflicts: 0;
|
||||
callback xmp-reload();
|
||||
|
||||
// --- export --------------------------------------------------------
|
||||
in property <[string]> format-labels;
|
||||
in property <int> format-selected: 0;
|
||||
in property <int> quality: 90;
|
||||
in property <bool> quality-enabled: true;
|
||||
in property <[string]> colour-labels;
|
||||
in property <int> colour-selected: 0;
|
||||
in property <[string]> sizing-labels;
|
||||
in property <int> sizing-selected: 0;
|
||||
in-out property <int> sizing-value: 0;
|
||||
in property <bool> sizing-has-value: false;
|
||||
in property <string> sizing-unit: "px";
|
||||
/// "Size value" for the modes that take one number, "Width" for the box
|
||||
/// modes, where a second field sits below it.
|
||||
in property <string> sizing-value-label: "Size value";
|
||||
in-out property <int> sizing-height: 0;
|
||||
in property <bool> sizing-has-height: false;
|
||||
/// The panels offered as buttons, and which one the numbers match — `-1`
|
||||
/// where they match none, so a preset never stays lit over fields that
|
||||
/// have since been typed over.
|
||||
in property <[string]> screen-labels;
|
||||
in property <int> screen-selected: -1;
|
||||
in property <bool> allow-upscaling: false;
|
||||
in property <[string]> sharpening-labels;
|
||||
in property <int> sharpening-selected: 0;
|
||||
in-out property <string> filename-template;
|
||||
in property <[string]> collision-labels;
|
||||
in property <int> collision-selected: 0;
|
||||
in property <bool> strip-location: true;
|
||||
in-out property <string> destination;
|
||||
// What the destination field means depends on this, so the placeholder
|
||||
// comes from Rust alongside it rather than being written twice here.
|
||||
in property <string> destination-hint;
|
||||
in property <[string]> target-labels;
|
||||
in property <int> target-selected: 0;
|
||||
|
||||
// --- the remote folder picker ---------------------------------------
|
||||
//
|
||||
// The same navigation the launch screen uses to choose a library root,
|
||||
// driven by the same `FolderBrowser` model in Rust. A folder on the
|
||||
// server is not something anyone can be expected to type from memory.
|
||||
in property <bool> browse-open: false;
|
||||
in property <string> browse-path;
|
||||
in property <[string]> browse-entries;
|
||||
in property <bool> browse-loading: false;
|
||||
/// At the library root, so there is nowhere up to go.
|
||||
in property <bool> browse-at-root: true;
|
||||
/// Whether the destination is one that can be walked.
|
||||
///
|
||||
/// A boolean from Rust rather than a test on `target-selected`. The index
|
||||
/// was hardcoded to 1, which was Remote's position while both targets were
|
||||
/// offered — and the moment Android's list narrowed to Remote alone, that
|
||||
/// index became 0 and the button vanished on the one platform where it is
|
||||
/// the *only* way to set a destination. An index into a list whose length
|
||||
/// varies is not a fact about the target.
|
||||
in property <bool> browse-available: false;
|
||||
|
||||
callback format-picked(int);
|
||||
callback quality-changed(int);
|
||||
callback colour-picked(int);
|
||||
callback sizing-picked(int);
|
||||
callback sizing-value-changed(string);
|
||||
callback sizing-height-changed(string);
|
||||
callback screen-picked(int);
|
||||
callback upscaling-toggled(bool);
|
||||
callback sharpening-picked(int);
|
||||
callback template-changed(string);
|
||||
callback collision-picked(int);
|
||||
callback strip-location-toggled(bool);
|
||||
callback destination-changed(string);
|
||||
callback target-picked(int);
|
||||
callback browse-open-picker();
|
||||
callback browse-into(string);
|
||||
callback browse-up();
|
||||
callback browse-confirm();
|
||||
callback browse-cancel();
|
||||
// The export defaults are drawn from `ExportOptions` (export.slint),
|
||||
// which the export sheet shares; nothing about them is declared here.
|
||||
|
||||
/// A save failed. The page's whole contract is that what it shows is
|
||||
/// stored, so this cannot be swallowed.
|
||||
@@ -785,113 +712,12 @@ export component SettingsPage inherits Rectangle {
|
||||
PanelHeading { text: "EXPORT DEFAULTS"; }
|
||||
|
||||
Caption {
|
||||
text: "What an export starts from. Every one of these "
|
||||
+ "is still changeable per export.";
|
||||
text: "What every export uses. Ctrl+E shows these on their own, "
|
||||
+ "beside an Export button.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Segmented {
|
||||
label: "Format";
|
||||
options: root.format-labels;
|
||||
selected: root.format-selected;
|
||||
picked(i) => { root.format-picked(i); }
|
||||
}
|
||||
|
||||
// A bounded number, so it gets the control for one.
|
||||
//
|
||||
// This was a free-text field: the range lived in the
|
||||
// hint and was enforced nowhere, and `to-float()`
|
||||
// answers 0 for anything unparseable — so a typo saved
|
||||
// a quality of 0 and the page then showed the 0 back as
|
||||
// though it had been asked for. The track carries the
|
||||
// range and the box refuses what it cannot read.
|
||||
SliderRow {
|
||||
label: "Quality";
|
||||
// Says why it is greyed rather than leaving the
|
||||
// user to work out that PNG has no quality.
|
||||
hint: root.quality-enabled ? "1 to 100"
|
||||
: "the chosen format is lossless";
|
||||
value: root.quality;
|
||||
// No meaningful neutral: quality has a sensible
|
||||
// default but not a *zero*, and a default marker
|
||||
// partway along a track reads as one.
|
||||
default-value: 1;
|
||||
minimum: 1;
|
||||
maximum: 100;
|
||||
enabled: root.quality-enabled;
|
||||
changed(v) => { root.quality-changed(v); }
|
||||
}
|
||||
|
||||
Segmented {
|
||||
label: "Colour space";
|
||||
hint: "profile embedded on export";
|
||||
options: root.colour-labels;
|
||||
selected: root.colour-selected;
|
||||
picked(i) => { root.colour-picked(i); }
|
||||
}
|
||||
|
||||
Segmented {
|
||||
label: "Size";
|
||||
options: root.sizing-labels;
|
||||
selected: root.sizing-selected;
|
||||
picked(i) => { root.sizing-picked(i); }
|
||||
}
|
||||
|
||||
// Only where the chosen mode carries a number:
|
||||
// "Original" has none, and a field showing 0 beside it
|
||||
// would invite the reading "zero pixels".
|
||||
if root.sizing-has-value: TextRow {
|
||||
label: root.sizing-value-label;
|
||||
text: root.sizing-value;
|
||||
unit: root.sizing-unit;
|
||||
field-width: 90px;
|
||||
accepted(t) => { root.sizing-value-changed(t); }
|
||||
}
|
||||
|
||||
// TRACES: FR-EXP-3
|
||||
// The second axis, for the box modes only.
|
||||
if root.sizing-has-height: TextRow {
|
||||
label: "Height";
|
||||
text: root.sizing-height;
|
||||
unit: "px high";
|
||||
field-width: 90px;
|
||||
accepted(t) => { root.sizing-height-changed(t); }
|
||||
}
|
||||
|
||||
// TRACES: FR-EXP-3
|
||||
// Panel sizes as buttons, because the numbers are the
|
||||
// whole difficulty: a television's art mode accepts one
|
||||
// resolution and rejects everything else, and getting
|
||||
// it by typing four digits twice is a step at which a
|
||||
// photographer discovers they were wrong only after the
|
||||
// upload.
|
||||
//
|
||||
// Alongside the fields rather than instead of them —
|
||||
// the presets are a shortcut to a pair of numbers, not
|
||||
// a replacement for being able to say any pair.
|
||||
if root.sizing-has-height: Segmented {
|
||||
label: "Screen";
|
||||
hint: "fills in both numbers";
|
||||
options: root.screen-labels;
|
||||
selected: root.screen-selected;
|
||||
picked(i) => { root.screen-picked(i); }
|
||||
}
|
||||
|
||||
Check {
|
||||
label: "Allow upscaling";
|
||||
hint: "Off, a request larger than the source exports "
|
||||
+ "at source size rather than failing.";
|
||||
checked: root.allow-upscaling;
|
||||
toggled(on) => { root.upscaling-toggled(on); }
|
||||
}
|
||||
|
||||
Segmented {
|
||||
label: "Output sharpening";
|
||||
hint: "scaled by the resize factor";
|
||||
options: root.sharpening-labels;
|
||||
selected: root.sharpening-selected;
|
||||
picked(i) => { root.sharpening-picked(i); }
|
||||
}
|
||||
ExportImageOptions {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,179 +957,7 @@ export component SettingsPage inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Filename template";
|
||||
hint: "{name} {seq} {date} {dimensions} {preset}";
|
||||
text <=> root.filename-template;
|
||||
field-width: 260px;
|
||||
placeholder: "{name}";
|
||||
accepted(t) => { root.template-changed(t); }
|
||||
}
|
||||
|
||||
Segmented {
|
||||
label: "If the file exists";
|
||||
options: root.collision-labels;
|
||||
selected: root.collision-selected;
|
||||
picked(i) => { root.collision-picked(i); }
|
||||
}
|
||||
|
||||
// Where the file lands, before what it is called: on
|
||||
// Android the answer decides whether an export needs
|
||||
// the Storage Access Framework at all, and on any
|
||||
// platform a server destination is reached over a
|
||||
// network that may not be there.
|
||||
Segmented {
|
||||
label: "Export to";
|
||||
options: root.target-labels;
|
||||
selected: root.target-selected;
|
||||
picked(i) => { root.target-picked(i); }
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Destination";
|
||||
// What an empty field does, not what it was once
|
||||
// going to do: nothing asks, and an export with
|
||||
// no folder is refused and says so in the header.
|
||||
hint: "a folder on this device; exports are refused until one is set";
|
||||
text <=> root.destination;
|
||||
field-width: 320px;
|
||||
placeholder: root.destination-hint;
|
||||
accepted(t) => { root.destination-changed(t); }
|
||||
}
|
||||
|
||||
// Offered only for a server destination. A folder on
|
||||
// this device is chosen by the platform's own dialogue
|
||||
// or typed; a folder on the server can only be found
|
||||
// by walking it, and expecting anyone to recall the
|
||||
// exact spelling of a path three levels down is how a
|
||||
// destination silently becomes a new folder at the
|
||||
// root.
|
||||
if root.browse-available && !root.browse-open: HorizontalLayout {
|
||||
alignment: start;
|
||||
Button {
|
||||
text: "Choose folder…";
|
||||
clicked => { root.browse-open-picker(); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.browse-open: Rectangle {
|
||||
background: Theme.ground;
|
||||
border-radius: Theme.radius;
|
||||
height: picker.preferred-height + 2 * Theme.gap;
|
||||
|
||||
picker := VerticalLayout {
|
||||
x: Theme.gap;
|
||||
y: Theme.gap;
|
||||
width: parent.width - 2 * Theme.gap;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Button {
|
||||
text: "↑ Up";
|
||||
// Disabled rather than hidden at the
|
||||
// root: a control that vanishes moves
|
||||
// everything beside it, and the row
|
||||
// would jump as the user navigates.
|
||||
enabled: !root.browse-at-root;
|
||||
clicked => { root.browse-up(); }
|
||||
}
|
||||
|
||||
Value {
|
||||
text: root.browse-path;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: root.browse-loading ? "Listing…" : "";
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// A fixed height rather than one that grows
|
||||
// with the listing: a folder with sixty
|
||||
// children would otherwise push the rest of
|
||||
// the settings page off the bottom.
|
||||
Rectangle {
|
||||
height: 180px;
|
||||
background: Theme.surface;
|
||||
border-radius: Theme.radius;
|
||||
|
||||
Flickable {
|
||||
x: 4px;
|
||||
y: 4px;
|
||||
width: parent.width - 8px;
|
||||
height: parent.height - 8px;
|
||||
viewport-height: folders.preferred-height;
|
||||
|
||||
folders := VerticalLayout {
|
||||
width: 100%;
|
||||
spacing: 2px;
|
||||
alignment: start;
|
||||
|
||||
if root.browse-entries.length == 0
|
||||
&& !root.browse-loading: Caption {
|
||||
text: "No folders here. "
|
||||
+ "Use this one, or go up.";
|
||||
}
|
||||
|
||||
for name in root.browse-entries: Rectangle {
|
||||
height: 32px;
|
||||
background: touch.has-hover
|
||||
? Theme.surface-raised
|
||||
: transparent;
|
||||
border-radius: Theme.radius;
|
||||
|
||||
Label {
|
||||
x: Theme.gap-sm;
|
||||
text: name;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
width: parent.width - 2 * Theme.gap-sm;
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
clicked => { root.browse-into(name); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: end;
|
||||
|
||||
Button {
|
||||
text: "Cancel";
|
||||
clicked => { root.browse-cancel(); }
|
||||
}
|
||||
|
||||
// Confirms the folder currently *shown*,
|
||||
// not one selected in the list — the same
|
||||
// rule the library picker follows, so
|
||||
// "use this one" means the same thing in
|
||||
// both places.
|
||||
Button {
|
||||
text: "Use this folder";
|
||||
active: true;
|
||||
clicked => { root.browse-confirm(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Check {
|
||||
label: "Strip location and personal metadata";
|
||||
hint: "On. An export is usually the copy that leaves "
|
||||
+ "this machine, and a location embedded in a "
|
||||
+ "published photograph cannot be recalled.";
|
||||
checked: root.strip-location;
|
||||
toggled(on) => { root.strip-location-toggled(on); }
|
||||
}
|
||||
ExportFileOptions {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user