use std::{future::Future, path::PathBuf, pin::Pin, sync::Arc};
use winit::raw_window_handle::{HasDisplayHandle, HasWindowHandle};
mod native;
#[cfg(any(
target_arch = "wasm32",
target_os = "linux",
target_os = "windows",
target_os = "macos"
))]
pub use rfd::FileHandle as PickedFile;
#[cfg(all(
not(target_arch = "wasm32"),
not(any(target_os = "linux", target_os = "windows", target_os = "macos"))
))]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PickedFile(PathBuf);
#[cfg(all(
not(target_arch = "wasm32"),
not(any(target_os = "linux", target_os = "windows", target_os = "macos"))
))]
impl PickedFile {
#[must_use]
pub fn path(&self) -> &std::path::Path {
&self.0
}
#[must_use]
pub fn file_name(&self) -> String {
self.0
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned())
}
}
#[cfg(all(
not(target_arch = "wasm32"),
not(any(target_os = "linux", target_os = "windows", target_os = "macos"))
))]
impl From<PathBuf> for PickedFile {
fn from(path: PathBuf) -> Self {
Self(path)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FilePickerMode {
#[default]
File,
Files,
Folder,
Folders,
Save,
}
impl FilePickerMode {
#[must_use]
pub const fn supported(self) -> bool {
if cfg!(target_arch = "wasm32") {
matches!(self, Self::File | Self::Files)
} else {
cfg!(any(
target_os = "linux",
target_os = "windows",
target_os = "macos"
))
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileFilter {
pub name: String,
pub extensions: Vec<String>,
}
impl FileFilter {
#[must_use]
pub fn new(
name: impl Into<String>,
extensions: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
name: name.into(),
extensions: extensions.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileDialogError {
InvalidOptions(String),
UnsupportedMode(FilePickerMode),
Unavailable(String),
}
impl std::fmt::Display for FileDialogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidOptions(message) | Self::Unavailable(message) => f.write_str(message),
Self::UnsupportedMode(mode) => {
write!(f, "{mode:?} dialogs are unavailable on this platform")
}
}
}
}
impl std::error::Error for FileDialogError {}
pub type FileDialogResult = Result<Option<Vec<PickedFile>>, FileDialogError>;
#[cfg(not(target_arch = "wasm32"))]
pub type FileDialogFuture = Pin<Box<dyn Future<Output = FileDialogResult> + Send>>;
#[cfg(target_arch = "wasm32")]
pub type FileDialogFuture = Pin<Box<dyn Future<Output = FileDialogResult>>>;
pub trait FileDialogBackend: Send + Sync {
fn open(&self, dialog: FileDialog) -> FileDialogFuture;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct NativeFileDialog;
trait Parent: HasWindowHandle + HasDisplayHandle + Send + Sync {}
impl<T: HasWindowHandle + HasDisplayHandle + Send + Sync> Parent for T {}
#[derive(Clone)]
pub struct FileDialog {
pub mode: FilePickerMode,
pub title: String,
pub filters: Vec<FileFilter>,
pub directory: Option<PathBuf>,
pub file_name: Option<String>,
parent: Option<Arc<dyn Parent>>,
}
impl std::fmt::Debug for FileDialog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileDialog")
.field("mode", &self.mode)
.field("title", &self.title)
.field("filters", &self.filters)
.field("directory", &self.directory)
.field("file_name", &self.file_name)
.field("parented", &self.parent.is_some())
.finish()
}
}
impl Default for FileDialog {
fn default() -> Self {
Self::new(FilePickerMode::File)
}
}
impl FileDialog {
#[must_use]
pub fn new(mode: FilePickerMode) -> Self {
Self {
mode,
title: String::new(),
filters: Vec::new(),
directory: None,
file_name: None,
parent: None,
}
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
#[must_use]
pub fn filter(mut self, filter: FileFilter) -> Self {
self.filters.push(filter);
self
}
#[must_use]
pub fn directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.directory = Some(directory.into());
self
}
#[must_use]
pub fn file_name(mut self, name: impl Into<String>) -> Self {
self.file_name = Some(name.into());
self
}
#[must_use]
pub fn parent<W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static>(
mut self,
parent: Arc<W>,
) -> Self {
self.parent = Some(parent);
self
}
pub fn validate(&self) -> Result<(), FileDialogError> {
let invalid = |message: &str| FileDialogError::InvalidOptions(message.into());
if !self.mode.supported() {
return Err(FileDialogError::UnsupportedMode(self.mode));
}
if self.title.contains('\0') {
return Err(invalid("Dialog titles cannot contain NUL"));
}
if let Some(name) = &self.file_name
&& (name.is_empty() || name.contains(['\0', '/', '\\']) || name == "." || name == "..")
{
return Err(invalid(
"The suggested file name must be a name, not a path",
));
}
if let Some(directory) = &self.directory
&& directory.as_os_str().as_encoded_bytes().contains(&0)
{
return Err(invalid("The starting directory cannot contain NUL"));
}
for filter in &self.filters {
if filter.name.trim().is_empty()
|| filter.name.contains('\0')
|| filter.extensions.is_empty()
{
return Err(invalid(
"File filters require a name and at least one extension",
));
}
if filter.extensions.iter().any(|extension| {
extension != "*"
&& (extension.is_empty()
|| extension.starts_with('.')
|| extension.contains(['\0', '/', '\\', '*', '?', ';', ' ', ',']))
}) {
return Err(invalid(
"Use file extensions without dots, spaces, paths or globs; * accepts every file",
));
}
}
Ok(())
}
#[must_use]
pub fn open(self) -> FileDialogFuture {
self.open_with(&NativeFileDialog)
}
#[must_use]
pub fn open_with(self, backend: &dyn FileDialogBackend) -> FileDialogFuture {
if let Err(error) = self.validate() {
return Box::pin(async { Err(error) });
}
backend.open(self)
}
}