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.
152 lines
5.6 KiB
Rust
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");
|
|
}
|