Files
DarkRoom/ui/dr-ui/src/folder_dialog.rs
T
dtourolle 9b580c3720 Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's
incoming row as a named struct rather than an eight-field tuple, which
clippy's type_complexity refused.
2026-09-26 14:13:54 -04:00

152 lines
5.6 KiB
Rust

//! 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 raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _};
#[cfg(not(target_os = "android"))]
use slint::ComponentHandle;
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");
}