use std::{cell::RefCell, future::Future, pin::Pin, rc::Rc};
use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
use cranpose_macros::composable;
use crate::content::{ContentError, ContentFolderRef, ContentHandle, ContentSinkRef};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum FilePickerError {
#[error("file picker failed: {0}")]
Failed(String),
#[error(transparent)]
Content(#[from] ContentError),
#[error("{operation} requires cranpose-services feature `{feature}`")]
UnsupportedFeature {
operation: &'static str,
feature: &'static str,
},
#[error("file picking is not available on this platform")]
UnsupportedPlatform,
}
pub type PickerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FileFilter {
pub label: String,
pub extensions: Vec<String>,
pub mime_types: Vec<String>,
}
impl FileFilter {
pub fn new(label: impl Into<String>, extensions: &[&str]) -> Self {
Self {
label: label.into(),
extensions: extensions.iter().map(|ext| (*ext).to_string()).collect(),
mime_types: Vec::new(),
}
}
pub fn with_mime_types(mut self, mime_types: &[&str]) -> Self {
self.mime_types = mime_types.iter().map(|mime| (*mime).to_string()).collect();
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FilePickerOptions {
pub title: Option<String>,
pub filters: Vec<FileFilter>,
}
impl FilePickerOptions {
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_filter(mut self, filter: FileFilter) -> Self {
self.filters.push(filter);
self
}
pub fn mime_types(&self) -> Vec<String> {
self.filters
.iter()
.flat_map(|filter| filter.mime_types.iter().cloned())
.collect()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SaveDocumentRequest {
pub file_name: String,
pub mime_type: String,
pub title: Option<String>,
}
impl SaveDocumentRequest {
pub fn new(file_name: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self {
file_name: file_name.into(),
mime_type: mime_type.into(),
title: None,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
}
#[doc(hidden)]
pub enum RecoveredPick {
File(ContentHandle),
Files(Vec<ContentHandle>),
Folder(ContentFolderRef),
WritableFolder(String),
}
pub trait FilePicker {
fn pick_file(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>>;
fn pick_files(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
let single = self.pick_file(options);
Box::pin(async move { Ok(single.await?.into_iter().collect()) })
}
fn pick_folder(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>>;
fn save_document(
&self,
request: SaveDocumentRequest,
) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
let _ = request;
Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
}
fn pick_writable_folder(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<String>, FilePickerError>> {
let _ = options;
Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
}
#[doc(hidden)]
fn take_recovered_pick(&self) -> Option<RecoveredPick> {
None
}
}
pub type FilePickerRef = Rc<dyn FilePicker>;
thread_local! {
static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
}
pub fn set_platform_file_picker(picker: FilePickerRef) {
PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
}
pub fn clear_platform_file_picker() {
PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
}
fn registered_platform_file_picker() -> Option<FilePickerRef> {
PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
}
struct PlatformFilePicker;
impl FilePicker for PlatformFilePicker {
fn pick_file(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
match registered_platform_file_picker() {
Some(picker) => picker.pick_file(options),
None => builtin::pick_file(options),
}
}
fn pick_files(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
match registered_platform_file_picker() {
Some(picker) => picker.pick_files(options),
None => builtin::pick_files(options),
}
}
fn pick_folder(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
match registered_platform_file_picker() {
Some(picker) => picker.pick_folder(options),
None => builtin::pick_folder(options),
}
}
fn save_document(
&self,
request: SaveDocumentRequest,
) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
match registered_platform_file_picker() {
Some(picker) => picker.save_document(request),
None => builtin::save_document(request),
}
}
fn pick_writable_folder(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<String>, FilePickerError>> {
match registered_platform_file_picker() {
Some(picker) => picker.pick_writable_folder(options),
None => builtin::pick_writable_folder(options),
}
}
fn take_recovered_pick(&self) -> Option<RecoveredPick> {
registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
}
}
pub fn default_file_picker() -> FilePickerRef {
Rc::new(PlatformFilePicker)
}
pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
thread_local! {
static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
}
LOCAL_FILE_PICKER.with(|cell| {
cell.borrow_mut()
.get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
.clone()
})
}
#[composable]
pub fn ProvideFilePicker(content: impl FnOnce()) {
let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
let picker_local = local_file_picker();
CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
content();
});
}
mod builtin {
#[cfg(all(
not(target_arch = "wasm32"),
not(target_os = "android"),
not(target_os = "ios"),
feature = "file-picker-native"
))]
pub(super) use super::desktop::{
pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
};
#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
pub(super) use super::web::{
pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
};
#[cfg(not(any(
all(
not(target_arch = "wasm32"),
not(target_os = "android"),
not(target_os = "ios"),
feature = "file-picker-native"
),
all(target_arch = "wasm32", feature = "file-picker-web")
)))]
mod unsupported {
use crate::{
content::{ContentFolderRef, ContentHandle, ContentSinkRef},
file_picker::{FilePickerError, FilePickerOptions, PickerFuture, SaveDocumentRequest},
};
pub(in crate::file_picker) fn pick_file(
_options: FilePickerOptions,
) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
}
pub(in crate::file_picker) fn pick_files(
_options: FilePickerOptions,
) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
}
pub(in crate::file_picker) fn pick_folder(
_options: FilePickerOptions,
) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
}
pub(in crate::file_picker) fn save_document(
_request: SaveDocumentRequest,
) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
}
pub(in crate::file_picker) fn pick_writable_folder(
_options: FilePickerOptions,
) -> PickerFuture<Result<Option<String>, FilePickerError>> {
Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
}
}
#[cfg(not(any(
all(
not(target_arch = "wasm32"),
not(target_os = "android"),
not(target_os = "ios"),
feature = "file-picker-native"
),
all(target_arch = "wasm32", feature = "file-picker-web")
)))]
pub(super) use unsupported::{
pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
};
}
#[cfg(all(
not(target_arch = "wasm32"),
not(target_os = "android"),
not(target_os = "ios"),
feature = "file-picker-native"
))]
mod desktop;
#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
mod web;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_builder_sets_title_and_filters() {
let options = FilePickerOptions::default()
.with_title("Pick audio")
.with_filter(FileFilter::new("Audio", &["mp3", "flac"]).with_mime_types(&["audio/*"]));
assert_eq!(options.title.as_deref(), Some("Pick audio"));
assert_eq!(options.filters.len(), 1);
assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
assert_eq!(options.mime_types(), vec!["audio/*"]);
}
#[test]
fn default_picker_is_created() {
let picker = default_file_picker();
assert_eq!(Rc::strong_count(&picker), 1);
}
struct Marker;
impl FilePicker for Marker {
fn pick_file(
&self,
_options: FilePickerOptions,
) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
Box::pin(async {
Ok(Some(
crate::content::BytesContent::named("marker.txt", b"marker".to_vec()).handle(),
))
})
}
fn pick_folder(
&self,
_options: FilePickerOptions,
) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
Box::pin(async { Ok(None) })
}
}
#[test]
fn registered_platform_picker_takes_precedence() {
clear_platform_file_picker();
assert!(registered_platform_file_picker().is_none());
set_platform_file_picker(Rc::new(Marker));
assert!(registered_platform_file_picker().is_some());
let picked =
pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()))
.expect("the marker picker resolves")
.expect("the marker picker picks a file");
assert_eq!(picked.metadata().name, "marker.txt");
clear_platform_file_picker();
}
#[test]
fn multi_selection_falls_back_to_the_single_chooser() {
clear_platform_file_picker();
set_platform_file_picker(Rc::new(Marker));
let picked =
pollster::block_on(default_file_picker().pick_files(FilePickerOptions::default()))
.expect("the marker picker resolves");
assert_eq!(picked.len(), 1);
clear_platform_file_picker();
}
#[test]
fn unsupported_operations_report_the_platform_gap() {
clear_platform_file_picker();
set_platform_file_picker(Rc::new(Marker));
let saved = pollster::block_on(
default_file_picker().save_document(SaveDocumentRequest::new("a.txt", "text/plain")),
);
let Err(error) = saved else {
panic!("the marker picker offers no save destination");
};
assert_eq!(error, FilePickerError::UnsupportedPlatform);
clear_platform_file_picker();
}
}