diff --git a/Cargo.lock b/Cargo.lock index e1faf7b..c963289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -385,6 +407,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -414,6 +447,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.5.0" @@ -1356,6 +1400,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1710,7 +1756,9 @@ dependencies = [ "ndk-context", "png", "pollster", + "raw-window-handle", "reqwest", + "rfd", "rusqlite", "serde_json", "serde_norway", @@ -5668,6 +5716,30 @@ dependencies = [ "zune-jpeg 0.5.15", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rgb" version = "0.8.53" @@ -6286,6 +6358,7 @@ dependencies = [ "num-traits", "once_cell", "pin-weak", + "raw-window-handle", "slint-macros", "unicode-segmentation", "vtable", @@ -7447,8 +7520,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.47.0" @@ -8169,6 +8249,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -8217,13 +8306,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -8251,6 +8357,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -8269,6 +8381,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -8287,12 +8405,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -8311,6 +8441,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -8329,6 +8465,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -8347,6 +8489,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -8365,6 +8513,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winit" version = "0.30.13" @@ -8830,6 +8984,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 1.0.4", "zvariant_derive", "zvariant_utils", diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 9ab2ceb..8af57bc 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -119,7 +119,14 @@ serde_norway = { workspace = true, optional = true } # under `cfg(target_os = "android")`, so this only has to name the feature; # cargo resolves it away entirely on desktop. [target.'cfg(not(target_os = "android"))'.dependencies] -slint = { workspace = true, features = ["backend-winit", "renderer-femtovg-wgpu"] } +slint = { workspace = true, features = ["backend-winit", "renderer-femtovg-wgpu", "raw-window-handle-06"] } +# The platform's own folder and file dialogues (`folder_dialog`): the XDG +# portal on Linux — the one that reaches the user's disk from inside the +# Flatpak, and needs no GTK — and the common item dialogue on Windows. Its +# async-std feature is only the executor ashpd talks D-Bus on; zbus and +# async-io are already in the tree for the keyring. +rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "wayland", "async-std"] } +raw-window-handle = "0.6" # The manual's `file:` URL (`manual::desktop_open`): a Windows path and a # path with a space in it are both URLs only after encoding, and this is the # crate the workspace already encodes URLs with. diff --git a/ui/dr-ui/src/folder_dialog.rs b/ui/dr-ui/src/folder_dialog.rs new file mode 100644 index 0000000..b5c2db0 --- /dev/null +++ b/ui/dr-ui/src/folder_dialog.rs @@ -0,0 +1,151 @@ +//! Asking the platform for a folder or a file, instead of a typed path. +//! +//! A path typed into a field is how a destination silently becomes a new +//! folder somewhere nobody meant: one wrong letter three levels down and the +//! export is written, successfully, to a place the photographer will never +//! look. The platform's own dialogue shows what is there, remembers where the +//! user last was, and offers "New folder" — which is the other half of what +//! was asked for, and which no widget here would do as well. +//! +//! # Which dialogue +//! +//! `rfd`, with the XDG desktop portal on Linux and the common item dialogue on +//! Windows. The portal rather than GTK because it is the one that works inside +//! the Flatpak (where a GTK dialogue would browse the sandbox, not the user's +//! disk) and because it needs no GTK in a Slint application that otherwise has +//! none. It also draws whichever desktop's own chooser is running, so the +//! dialogue looks like the rest of the user's machine. +//! +//! # Not blocking the interface +//! +//! The dialogue is awaited on Slint's event loop with `spawn_local` rather than +//! on a thread: the portal's reply arrives over D-Bus on async-io's reactor, +//! which needs no executor of ours, and the window keeps drawing while the +//! chooser is open. The callback therefore runs on the UI thread, where every +//! caller wants to be anyway. +//! +//! # Android +//! +//! Not here. Android has no filesystem dialogue — only the Storage Access +//! Framework, which hands back a document tree rather than a path — so the +//! screens that need a path hide their button there (`Pickers.local-paths`), +//! and album folders go through the Storage Access Framework instead. + +use std::path::PathBuf; + +#[cfg(not(target_os = "android"))] +use slint::ComponentHandle; +#[cfg(not(target_os = "android"))] +use raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _}; + +use crate::AppWindow; + +/// What to ask for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pick { + /// A directory. + Folder, + /// One file, narrowed to these extensions (without the dot). + File(&'static [&'static str]), +} + +/// Whether this build has a platform dialogue for filesystem paths. +pub const AVAILABLE: bool = cfg!(not(target_os = "android")); + +/// Ask for a path, and call `chosen` with it if the user picks one. +/// +/// Nothing is called on cancel: every caller's answer to "the user closed the +/// dialogue" is to leave things as they were, so there is no second callback +/// to write. `start` opens the dialogue where the current value is, when there +/// is one — changing a destination usually means moving one level, not +/// starting again from the home directory. +#[cfg(not(target_os = "android"))] +pub fn ask( + window: &AppWindow, + title: &str, + pick: Pick, + start: Option<&str>, + chosen: impl FnOnce(PathBuf) + 'static, +) { + let mut dialog = rfd::AsyncFileDialog::new().set_title(title); + // Parented to the window, so the portal or Windows can place it over the + // application and make it modal to it. Where the handle is unavailable + // the dialogue still works; it just opens unparented. + if let Ok(handle) = window.window().window_handle().window_handle() { + if let Ok(display) = window.window().window_handle().display_handle() { + dialog = dialog.set_parent(&Parent(handle, display)); + } + } + if let Some(dir) = start.map(str::trim).filter(|s| !s.is_empty()) { + let dir = PathBuf::from(dir); + // The folder itself for a folder pick; for a file, where the file is. + let dir = if dir.is_dir() { + Some(dir) + } else { + dir.parent().map(PathBuf::from) + }; + if let Some(dir) = dir.filter(|d| d.is_dir()) { + dialog = dialog.set_directory(dir); + } + } + + let result = slint::spawn_local(async move { + let picked = match pick { + Pick::Folder => dialog.pick_folder().await, + Pick::File(extensions) => { + dialog + .add_filter(extensions.join(", "), extensions) + .pick_file() + .await + } + }; + if let Some(handle) = picked { + chosen(handle.path().to_path_buf()); + } + }); + if let Err(e) = result { + log::warn!("could not open the folder dialogue: {e}"); + } +} + +/// The window's handles, in the one shape `rfd::set_parent` takes. +/// +/// Slint hands out the window and the display handle separately, each +/// borrowed from the window; rfd wants one value implementing both. +#[cfg(not(target_os = "android"))] +struct Parent<'a>( + raw_window_handle::WindowHandle<'a>, + raw_window_handle::DisplayHandle<'a>, +); + +#[cfg(not(target_os = "android"))] +impl raw_window_handle::HasWindowHandle for Parent<'_> { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Ok(self.0) + } +} + +#[cfg(not(target_os = "android"))] +impl raw_window_handle::HasDisplayHandle for Parent<'_> { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Ok(self.1) + } +} + +/// No filesystem dialogue on Android; see the module header. The buttons that +/// would call this are hidden there, so reaching it is a wiring mistake, and +/// it says so in the log rather than silently doing nothing. +#[cfg(target_os = "android")] +pub fn ask( + _window: &AppWindow, + title: &str, + _pick: Pick, + _start: Option<&str>, + _chosen: impl FnOnce(PathBuf) + 'static, +) { + log::warn!("{title}: no filesystem dialogue on Android"); +} diff --git a/ui/dr-ui/src/import_ui.rs b/ui/dr-ui/src/import_ui.rs index 47098da..6f2db8c 100644 --- a/ui/dr-ui/src/import_ui.rs +++ b/ui/dr-ui/src/import_ui.rs @@ -394,10 +394,28 @@ where let context = context.clone(); window.on_import_card_path_changed(move |text| { let Some(w) = weak.upgrade() else { return }; - *ctl.card.borrow_mut() = text.to_string(); - ctl.selected.set(-1); - survey(&w, &ctl, &context); - render(&w, &ctl); + set_card(&w, &ctl, &context, text.to_string()); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let context = context.clone(); + window.on_import_choose_card(move || { + let Some(w) = weak.upgrade() else { return }; + let start = ctl.card.borrow().clone(); + let (weak, ctl, context) = (weak.clone(), ctl.clone(), context.clone()); + crate::folder_dialog::ask( + &w, + "Import from", + crate::folder_dialog::Pick::Folder, + Some(&start), + move |path| { + let Some(w) = weak.upgrade() else { return }; + set_card(&w, &ctl, &context, path.to_string_lossy().into_owned()); + }, + ); }); } @@ -482,6 +500,27 @@ fn wire_options(window: &AppWindow, ctl: &Rc) { render(&w, &ctl); }); } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_import_choose_backup(move || { + let Some(w) = weak.upgrade() else { return }; + let start = ctl.settings.settings.borrow().import.backup.trim().to_string(); + let (weak, ctl) = (weak.clone(), ctl.clone()); + crate::folder_dialog::ask( + &w, + "Second copy in", + crate::folder_dialog::Pick::Folder, + Some(&start), + move |path| { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| s.backup = path.to_string_lossy().into_owned()); + render(&w, &ctl); + }, + ); + }); + } } /// Running. @@ -519,6 +558,20 @@ fn wire_running( } } +/// Take a source the user named — typed, or chosen in the dialogue — rather +/// than one from the volume list, and count what is on it. +fn set_card( + w: &AppWindow, + ctl: &Rc, + context: &Rc Option>, + path: String, +) { + *ctl.card.borrow_mut() = path; + ctl.selected.set(-1); + survey(w, ctl, context); + render(w, ctl); +} + /// Count what is on the card, without copying anything. /// /// Runs on the UI thread, which is defensible only because it is a directory diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index 20536ee..79c7397 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -174,16 +174,39 @@ fn wire_use_folder(window: &AppWindow, controller: &Rc) { let ctl = controller.clone(); window.on_launch_use_folder(move |path| { let Some(w) = weak.upgrade() else { return }; - match open_folder_library(&ctl.store, &path) { - Ok(account) => { - log::info!("using folder library at {}", account.endpoint); - ctl.model.borrow_mut().signed_in(account); - } - Err(e) => ctl.model.borrow_mut().fail(e), - } - render(&w, &ctl); + use_folder(&w, &ctl, &path); }); } + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_launch_browse_local_folder(move || { + let Some(w) = weak.upgrade() else { return }; + let start = ctl.model.borrow().folder_path.clone(); + let (weak, ctl) = (weak.clone(), ctl.clone()); + crate::folder_dialog::ask( + &w, + "Library folder", + crate::folder_dialog::Pick::Folder, + Some(&start), + move |path| { + let Some(w) = weak.upgrade() else { return }; + use_folder(&w, &ctl, &path.to_string_lossy()); + }, + ); + }); + } +} + +fn use_folder(w: &AppWindow, ctl: &Rc, path: &str) { + match open_folder_library(&ctl.store, path) { + Ok(account) => { + log::info!("using folder library at {}", account.endpoint); + ctl.model.borrow_mut().signed_in(account); + } + Err(e) => ctl.model.borrow_mut().fail(e), + } + render(w, ctl); } /// Sign out. diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 6fdc1a3..d32fdd3 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -33,6 +33,7 @@ mod display_ui; mod duplicates; mod duplicates_ui; mod export; +mod folder_dialog; pub mod faces; pub use library::render_native; // Generated from the `GESTURE:` comments beside the code that implements each @@ -1388,6 +1389,12 @@ fn init_window(gpu: &Option) -> Result { let window = AppWindow::new()?; log::info!("window built in {} ms", before_window.elapsed().as_millis()); + // Which screens browse for a path and which take one typed: see + // `folder_dialog` for why Android is the exception. + window + .global::() + .set_local_paths(folder_dialog::AVAILABLE); + // TRACES: FR-PLAT-LIN-1 // The name the compositor knows this window by, and the reason the // launcher shows a real icon rather than a grey square. diff --git a/ui/dr-ui/src/presets.rs b/ui/dr-ui/src/presets.rs index d8edc25..77ac8d6 100644 --- a/ui/dr-ui/src/presets.rs +++ b/ui/dr-ui/src/presets.rs @@ -922,6 +922,20 @@ impl SaveError { } } +/// Read Lightroom presets from a file or a folder and say what came of it. +/// +/// Synchronous, deliberately. A preset folder is a few hundred small text +/// files and the read is milliseconds; a worker and a progress bar would be +/// machinery around a wait nobody sees. +fn import_presets(w: &AppWindow, named: &Rc, path: &Path) { + if path.as_os_str().is_empty() { + return; + } + let report = named.import(path); + w.set_preset_import_report(report.into()); + render_named(w, named); +} + /// Push the listing onto the window. pub fn render_named(window: &AppWindow, named: &Rc) { window.set_preset_rows(slint::ModelRc::new(slint::VecModel::from(named.rows()))); @@ -1077,16 +1091,24 @@ pub fn wire_named( let named = named.clone(); window.on_import_presets(move |path| { let Some(w) = weak.upgrade() else { return }; - let path = path.trim(); - if path.is_empty() { - return; - } - // Synchronous, deliberately. A preset folder is a few hundred - // small text files and the read is milliseconds; a worker and a - // progress bar would be machinery around a wait nobody sees. - let report = named.import(Path::new(path)); - w.set_preset_import_report(report.into()); - render_named(&w, &named); + import_presets(&w, &named, Path::new(path.trim())); + }); + } + { + let weak = window.as_weak(); + let named = named.clone(); + window.on_browse_presets(move |file| { + let Some(w) = weak.upgrade() else { return }; + let (title, pick) = if file { + ("Import a Lightroom preset", crate::folder_dialog::Pick::File(&["xmp"])) + } else { + ("Import a folder of Lightroom presets", crate::folder_dialog::Pick::Folder) + }; + let (weak, named) = (weak.clone(), named.clone()); + crate::folder_dialog::ask(&w, title, pick, None, move |path| { + let Some(w) = weak.upgrade() else { return }; + import_presets(&w, &named, &path); + }); }); } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 842b1e0..bc2891a 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -37,6 +37,7 @@ export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Mask export { Collections } export { Library } export { ExportOptions } +export { Pickers } from "pickers.slint"; export { Duplicates, DuplicateRow, DuplicateCopy } export { Scrolling } @@ -301,6 +302,7 @@ export component AppWindow inherits Window { callback launch-sign-in(string); /// The path of a folder library — no account, no credential. callback launch-use-folder(string); + callback launch-browse-local-folder(); /// server, username, app password callback launch-sign-in-direct(string, string, string); callback launch-sign-out(); @@ -532,6 +534,7 @@ export component AppWindow inherits Window { /// Reading Lightroom presets in, and what came of the last attempt. in property preset-import-report; callback import-presets(string); + callback browse-presets(bool); // --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) --- // @@ -619,6 +622,7 @@ export component AppWindow inherits Window { callback import-close(); callback import-volume-picked(int); callback import-card-path-changed(string); + callback import-choose-card(); callback import-refresh-volumes(); callback import-template-changed(string); callback import-mode-picked(int); @@ -626,6 +630,7 @@ export component AppWindow inherits Window { callback import-verify-toggled(bool); callback import-backup-toggled(bool); callback import-backup-path-changed(string); + callback import-choose-backup(); callback import-start(); callback import-stop(); @@ -1132,6 +1137,7 @@ in property panel-visible: true; volume-picked(i) => { root.import-volume-picked(i); } card-path-changed(t) => { root.import-card-path-changed(t); } + choose-card => { root.import-choose-card(); } refresh-volumes() => { root.import-refresh-volumes(); } template-changed(t) => { root.import-template-changed(t); } mode-picked(i) => { root.import-mode-picked(i); } @@ -1139,6 +1145,7 @@ in property panel-visible: true; verify-toggled(on) => { root.import-verify-toggled(on); } backup-toggled(on) => { root.import-backup-toggled(on); } backup-path-changed(t) => { root.import-backup-path-changed(t); } + choose-backup => { root.import-choose-backup(); } begin() => { root.import-start(); } stop() => { root.import-stop(); } close() => { root.import-close(); } @@ -1348,6 +1355,7 @@ in property panel-visible: true; sign-in(server) => { root.launch-sign-in(server); } use-folder(path) => { root.launch-use-folder(path); } + browse-local-folder => { root.launch-browse-local-folder(); } sign-in-direct(server, user, pw) => { root.launch-sign-in-direct(server, user, pw); } @@ -3422,6 +3430,7 @@ in property panel-visible: true; scope-toggled(name) => { root.copy-scope-toggled(name); } import-report: root.preset-import-report; import-presets(path) => { root.import-presets(path); } + browse-presets(file) => { root.browse-presets(file); } save(name) => { root.save-preset(name); } apply(name) => { root.apply-preset(name); } rename(from, to) => { root.rename-preset(from, to); } diff --git a/ui/dr-ui/ui/import.slint b/ui/dr-ui/ui/import.slint index 0b7eebc..eb4b61a 100644 --- a/ui/dr-ui/ui/import.slint +++ b/ui/dr-ui/ui/import.slint @@ -1,6 +1,7 @@ import { Theme } from "theme.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint"; import { Segmented, TextRow, Check, ChoiceChip } from "controls.slint"; +import { PathRow, Pickers } from "pickers.slint"; // TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a // Import: bringing a card into the library. @@ -77,6 +78,8 @@ export component ImportPage inherits Rectangle { callback volume-picked(int); callback card-path-changed(string); + /// Browse for the source with the platform's dialogue. + callback choose-card(); callback refresh-volumes(); // --- what is on it --------------------------------------------------- @@ -113,6 +116,7 @@ export component ImportPage inherits Rectangle { callback verify-toggled(bool); callback backup-toggled(bool); callback backup-path-changed(string); + callback choose-backup(); // --- doing it -------------------------------------------------------- in property running: false; @@ -219,7 +223,9 @@ export component ImportPage inherits Rectangle { PanelHeading { text: "From"; } if root.volume-labels.length == 0: Caption { - text: "No removable volume found. Type where the card is mounted, or plug it in and refresh."; + text: Pickers.local-paths + ? "No removable volume found. Browse to where the card is mounted, or plug it in and refresh." + : "No removable volume found. Type where the card is mounted, or plug it in and refresh."; wrap: word-wrap; } @@ -230,13 +236,17 @@ export component ImportPage inherits Rectangle { clicked => { root.volume-picked(i); } } - TextRow { + // A card mounted somewhere this app does not think to + // look is still a card: the dialogue reaches it, where + // the list above only knows the usual mount points. + PathRow { label: "Location"; - text <=> root.card-path; + path: root.card-path; placeholder: "/run/media/…"; - field-width: 320px; + button-text: "Browse…"; enabled: !root.running; - accepted(t) => { root.card-path-changed(t); } + choose => { root.choose-card(); } + typed(t) => { root.card-path-changed(t); } } HorizontalLayout { @@ -359,13 +369,15 @@ export component ImportPage inherits Rectangle { toggled(v) => { if (!root.running) { root.backup-toggled(v); } } } - if root.backup-on: TextRow { + if root.backup-on: PathRow { label: "Second copy in"; - text <=> root.backup-path; - placeholder: "/mnt/backup/Photos"; - field-width: 320px; + // The toggle stores a single space to mean "on, no + // folder yet" (see `import_ui`), which is not a path. + path: root.backup-path == " " ? "" : root.backup-path; + placeholder: "No folder chosen"; enabled: !root.running; - accepted(t) => { root.backup-path-changed(t); } + choose => { root.choose-backup(); } + typed(t) => { root.backup-path-changed(t); } } } diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index 409b06a..a3e2c7d 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -1,6 +1,7 @@ import { Theme } from "theme.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.slint"; import { Check } from "controls.slint"; +import { Pickers } from "pickers.slint"; // Launch screen: connect an account, or resume a saved one. // @@ -102,6 +103,9 @@ export component LaunchScreen inherits Rectangle { // A folder library: no browser, no credential, no waiting state — the // whole sign-in is checking the directory is there. callback use-folder(string); + /// Browse for the library folder with the platform's dialogue; a folder + /// chosen there is used at once, as a typed one is on Enter. + callback browse-local-folder(); /// server, username, app password callback sign-in-direct(string, string, string); callback sign-out(); @@ -307,17 +311,48 @@ export component LaunchScreen inherits Rectangle { } PanelHeading { text: @tr("FOLDER"); } - folder-input := Field { - label: @tr("Library folder"); + + // Where there is a platform dialogue, the folder is chosen + // in it; the one used last is shown so reopening it is a + // single press. Where there is not (Android), typed. + if Pickers.local-paths && root.folder-path != "": Value { text: root.folder-path; - placeholder: "/home/you/Pictures"; - accepted(path) => { root.use-folder(path); } + overflow: elide; } - FormButton { - text: @tr("Open folder"); - enabled: !root.busy && folder-input.text != ""; - clicked => { root.use-folder(folder-input.text); } + if Pickers.local-paths: HorizontalLayout { + spacing: Theme.gap; + FormButton { + text: root.folder-path != "" ? @tr("Choose another…") : @tr("Choose folder…"); + primary: root.folder-path == ""; + enabled: !root.busy; + horizontal-stretch: 1; + clicked => { root.browse-local-folder(); } + } + if root.folder-path != "": FormButton { + text: @tr("Open folder"); + primary: true; + enabled: !root.busy; + horizontal-stretch: 1; + clicked => { root.use-folder(root.folder-path); } + } + } + + // One block, because the button reads the field: an id + // inside one `if` is invisible to a sibling `if`. + if !Pickers.local-paths: VerticalLayout { + spacing: Theme.gap; + folder-input := Field { + label: @tr("Library folder"); + text: root.folder-path; + placeholder: "/home/you/Pictures"; + accepted(path) => { root.use-folder(path); } + } + FormButton { + text: @tr("Open folder"); + enabled: !root.busy && folder-input.text != ""; + clicked => { root.use-folder(folder-input.text); } + } } Caption { diff --git a/ui/dr-ui/ui/pickers.slint b/ui/dr-ui/ui/pickers.slint new file mode 100644 index 0000000..6b71230 --- /dev/null +++ b/ui/dr-ui/ui/pickers.slint @@ -0,0 +1,72 @@ +// Choosing a folder or a file by pointing at it, not by typing its path. +// +// See `folder_dialog.rs` for why the platform's dialogue rather than a field: +// a typed path is how a destination silently becomes a new folder nobody +// meant, and the dialogue offers "New folder" where this app could not. + +import { Theme } from "theme.slint"; +import { Button, Value } from "widgets.slint"; +import { TextRow, FieldRow } from "controls.slint"; + +// What this build can ask the platform for. Set once from Rust at startup. +export global Pickers { + /// A dialogue that returns filesystem paths: true on the desktop. False on + /// Android, whose only chooser (SAF) returns document trees, not paths — + /// so a screen that needs a path keeps its field there. + in property local-paths: true; +} + +// A path setting: what is chosen now, and the button that changes it. +// +// Read-only where the platform has a dialogue — the value is shown so it is +// clear what the button will change, but it is not a field, because a field is +// the thing this replaced. Where there is no dialogue (Android) it is the +// ordinary typed row it always was. +export component PathRow inherits VerticalLayout { + in property label; + in property hint; + in property path; + /// Shown dimmed when nothing is chosen yet. + in property placeholder; + in property button-text: @tr("Choose…"); + in property enabled: true; + in property field-width: 320px; + + /// The button, where there is a dialogue. + callback choose(); + /// The typed path, where there is not. + callback typed(string); + + spacing: 4px; + + if Pickers.local-paths: FieldRow { label: root.label; hint: root.hint; } + + if Pickers.local-paths: HorizontalLayout { + spacing: Theme.gap-sm; + + Value { + text: root.path != "" ? root.path : root.placeholder; + placeholder: root.path == ""; + overflow: elide; + vertical-alignment: center; + horizontal-stretch: 1; + min-width: 0px; + } + + Button { + text: root.button-text; + enabled: root.enabled; + clicked => { root.choose(); } + } + } + + if !Pickers.local-paths: TextRow { + label: root.label; + hint: root.hint; + text: root.path; + placeholder: root.placeholder; + field-width: root.field-width; + enabled: root.enabled; + accepted(t) => { root.typed(t); } + } +} diff --git a/ui/dr-ui/ui/presets.slint b/ui/dr-ui/ui/presets.slint index 73b8de1..50864df 100644 --- a/ui/dr-ui/ui/presets.slint +++ b/ui/dr-ui/ui/presets.slint @@ -1,6 +1,7 @@ import { Theme } from "theme.slint"; import { Button, Field, Caption, PanelHeading } from "widgets.slint"; import { ChoiceChip } from "controls.slint"; +import { Pickers } from "pickers.slint"; // TRACES: FR-DEV-6 // One kind of edit, and whether a copy carries it. @@ -137,12 +138,13 @@ export component PresetSheet inherits Rectangle { callback scope-toggled(string); /// TRACES: FR-DEV-6 - /// Read Lightroom presets from a typed path, and what came of the last - /// attempt. A typed path rather than a file dialogue because that is the - /// idiom this application already has — the launch screen takes a library - /// the same way, and there is no native picker anywhere in it. + /// Read Lightroom presets from a path, and what came of the last attempt. + /// Chosen in the platform's dialogue where there is one — `true` asks for + /// a single `.xmp` file, `false` for a folder of them — and typed where + /// there is not (Android). in property import-report; callback import-presets(string); + callback browse-presets(bool); background: #000000CC; @@ -314,7 +316,24 @@ export component PresetSheet inherits Rectangle { PanelHeading { text: "IMPORT"; } - Field { + // Two buttons rather than one dialogue, because no platform + // dialogue picks "a file or a folder" in one go: the portal + // and Windows each ask for one kind. + if Pickers.local-paths: HorizontalLayout { + spacing: Theme.gap-sm; + Button { + text: "Folder…"; + horizontal-stretch: 1; + clicked => { root.browse-presets(false); } + } + Button { + text: ".xmp file…"; + horizontal-stretch: 1; + clicked => { root.browse-presets(true); } + } + } + + if !Pickers.local-paths: Field { placeholder: "Path to a .xmp file or folder"; accepted(path) => { root.import-presets(path); } }