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:
2026-09-26 14:13:53 -04:00
parent 94542371f6
commit 6683c14b40
12 changed files with 612 additions and 47 deletions
+151
View File
@@ -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");
}