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:
@@ -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