Add Lightroom's export and copy shortcuts to develop

Ctrl+E opens an export sheet: the export defaults on their own, over the
photograph, with an Export button. Ctrl+Shift+E exports straight away on
those defaults. There is no per-export copy of the settings, so what is
chosen in the sheet is saved as it is on the settings page, and the next
Ctrl+Shift+E uses it.

To make that one set of controls in two places, the export options move
out of the settings page into export.slint: an `ExportOptions` global
that Rust writes once, and two panels that read it. The window no longer
forwards forty `settings-*` properties to the page.

Ctrl+Shift+C opens a copy sheet with the edit-kind chips the preset
sheet already uses and a Copy button, which is how a paste leaves each
photograph's crop and rotation alone (Compose off). A and D step along
the roll beside the arrows. While either sheet is up the develop keys
stand down, so A cannot change the photograph behind the form, and
Escape closes it.
This commit is contained in:
2026-09-24 05:11:16 +02:00
parent 41486bd59b
commit bddf3250c5
9 changed files with 898 additions and 571 deletions
+22 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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),