use cranpose_core::compositionLocalOfWithPolicy;
use cranpose_core::CompositionLocal;
use cranpose_core::CompositionLocalProvider;
use cranpose_macros::composable;
use std::cell::RefCell;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
#[derive(thiserror::Error, Debug, Clone)]
pub enum FilePickerError {
#[error("file picker failed: {0}")]
Failed(String),
#[error("reading picked entry failed: {0}")]
ReadFailed(String),
#[error("picked entry is a {actual}, expected a {expected}")]
WrongKind {
actual: &'static str,
expected: &'static str,
},
#[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, Copy, Debug, PartialEq, Eq)]
pub enum PickedKind {
File,
Folder,
}
#[derive(Clone, Debug, Default)]
pub struct FileFilter {
pub label: String,
pub extensions: 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(),
}
}
}
#[derive(Clone, Debug, Default)]
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 trait PickedEntry {
fn name(&self) -> String;
fn kind(&self) -> PickedKind;
fn display_path(&self) -> String;
fn read_bytes(&self) -> PickerFuture<Result<Vec<u8>, FilePickerError>>;
fn list(&self) -> PickerFuture<Result<Vec<PickedEntryRef>, FilePickerError>>;
}
pub type PickedEntryRef = Rc<dyn PickedEntry>;
pub trait FolderStream {
fn take_ready(&self) -> Vec<PickedEntryRef>;
fn is_finished(&self) -> bool;
fn take_error(&self) -> Option<FilePickerError>;
}
pub type FolderStreamRef = Rc<dyn FolderStream>;
fn collect_files(entry: PickedEntryRef) -> PickerFuture<Vec<PickedEntryRef>> {
Box::pin(async move {
match entry.kind() {
PickedKind::File => vec![entry],
PickedKind::Folder => {
let mut files = Vec::new();
if let Ok(children) = entry.list().await {
for child in children {
files.extend(collect_files(child).await);
}
}
files
}
}
})
}
struct ReadyFolderStream {
ready: RefCell<Vec<PickedEntryRef>>,
}
impl FolderStream for ReadyFolderStream {
fn take_ready(&self) -> Vec<PickedEntryRef> {
std::mem::take(&mut self.ready.borrow_mut())
}
fn is_finished(&self) -> bool {
true
}
fn take_error(&self) -> Option<FilePickerError> {
None
}
}
fn eager_folder_stream(
folder: PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>,
) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
Box::pin(async move {
match folder.await? {
None => Ok(None),
Some(entry) => {
let files = collect_files(entry).await;
Ok(Some(Rc::new(ReadyFolderStream {
ready: RefCell::new(files),
}) as FolderStreamRef))
}
}
})
}
pub trait FilePicker {
fn pick_file(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
fn pick_folder(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
fn pick_folder_streaming(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
eager_folder_stream(self.pick_folder(options))
}
}
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<PickedEntryRef>, FilePickerError>> {
if let Some(picker) = registered_platform_file_picker() {
return picker.pick_file(options);
}
builtin_pick(options, PickedKind::File)
}
fn pick_folder(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
if let Some(picker) = registered_platform_file_picker() {
return picker.pick_folder(options);
}
builtin_pick(options, PickedKind::Folder)
}
fn pick_folder_streaming(
&self,
options: FilePickerOptions,
) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
if let Some(picker) = registered_platform_file_picker() {
return picker.pick_folder_streaming(options);
}
eager_folder_stream(builtin_pick(options, PickedKind::Folder))
}
}
#[cfg_attr(
not(any(feature = "file-picker-native", feature = "file-picker-web")),
allow(unused_variables)
)]
fn builtin_pick(
options: FilePickerOptions,
kind: PickedKind,
) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
#[cfg(all(
not(target_arch = "wasm32"),
not(target_os = "android"),
not(target_os = "ios"),
feature = "file-picker-native"
))]
{
return desktop::pick(options, kind);
}
#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
{
return web::pick(options, kind);
}
#[allow(unreachable_code)]
Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
}
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()
})
}
#[allow(non_snake_case)]
#[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();
});
}
#[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"]));
assert_eq!(options.title.as_deref(), Some("Pick audio"));
assert_eq!(options.filters.len(), 1);
assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
}
#[test]
fn default_picker_is_created() {
let picker = default_file_picker();
assert_eq!(Rc::strong_count(&picker), 1);
}
#[test]
fn registered_platform_picker_takes_precedence() {
struct Marker;
impl FilePicker for Marker {
fn pick_file(
&self,
_options: FilePickerOptions,
) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
}
fn pick_folder(
&self,
_options: FilePickerOptions,
) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
}
}
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 result =
pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()));
assert!(matches!(result, Err(FilePickerError::Failed(_))));
clear_platform_file_picker();
}
}