Choose folders in the platform's dialogue, not by typing a path
Every folder the desktop asked for was a text field: the library folder at launch, an import's source and second copy, a preset folder brought over from Lightroom. A typed path is how a destination silently becomes a new folder nobody meant — one wrong letter three levels down and the write succeeds somewhere the photographer will never look — and a field cannot make the folder that is not there yet. They now open the platform's own dialogue through rfd: the XDG desktop portal on Linux, the common item dialogue on Windows. The portal rather than GTK because it reaches the user's files from inside the Flatpak and needs no GTK in a Slint application, and it draws whichever desktop's chooser is running, "New folder" included. It is awaited on Slint's event loop (spawn_local), so the window keeps drawing while it is open, and parented to the window so it opens over it. PathRow shows what is chosen, read-only, beside the button. Android has no filesystem dialogue — only SAF, which returns document trees, not paths — so there the same rows stay typed fields (Pickers.local-paths). The launch screen keeps the folder used last on screen with "Open folder" beside it, so reopening is one press. Presets get two buttons, a folder and a single .xmp file, because no platform dialogue picks "a file or a folder" in one go.
This commit is contained in:
Generated
+156
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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.
|
||||
|
||||
@@ -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::WindowHandle<'_>, 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::DisplayHandle<'_>, 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");
|
||||
}
|
||||
@@ -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<ImportController>) {
|
||||
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<C, F>(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<ImportController>,
|
||||
context: &Rc<impl Fn() -> Option<Context>>,
|
||||
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
|
||||
|
||||
@@ -174,16 +174,39 @@ fn wire_use_folder(window: &AppWindow, controller: &Rc<LaunchController>) {
|
||||
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) {
|
||||
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<LaunchController>, 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);
|
||||
});
|
||||
}
|
||||
render(w, ctl);
|
||||
}
|
||||
|
||||
/// Sign out.
|
||||
|
||||
@@ -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<dr_gpu::GpuContext>) -> Result<AppWindow> {
|
||||
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::<Pickers>()
|
||||
.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.
|
||||
|
||||
+31
-9
@@ -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<NamedPresets>, 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<NamedPresets>) {
|
||||
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;
|
||||
import_presets(&w, &named, Path::new(path.trim()));
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
{
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <string> 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 <bool> 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 <bool> 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 <bool> 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 <bool> 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); }
|
||||
|
||||
+22
-10
@@ -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 <bool> 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); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 +311,49 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
|
||||
PanelHeading { text: @tr("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;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
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 {
|
||||
text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.");
|
||||
|
||||
@@ -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 <bool> 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 <string> label;
|
||||
in property <string> hint;
|
||||
in property <string> path;
|
||||
/// Shown dimmed when nothing is chosen yet.
|
||||
in property <string> placeholder;
|
||||
in property <string> button-text: @tr("Choose…");
|
||||
in property <bool> enabled: true;
|
||||
in property <length> 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); }
|
||||
}
|
||||
}
|
||||
@@ -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 <string> 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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user