1use std::{cell::RefCell, future::Future, pin::Pin, rc::Rc};
17
18use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
19use cranpose_macros::composable;
20
21use crate::content::{ContentError, ContentFolderRef, ContentHandle, ContentSinkRef};
22
23#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
25pub enum FilePickerError {
26 #[error("file picker failed: {0}")]
28 Failed(String),
29 #[error(transparent)]
31 Content(#[from] ContentError),
32 #[error("{operation} requires cranpose-services feature `{feature}`")]
34 UnsupportedFeature {
35 operation: &'static str,
37 feature: &'static str,
39 },
40 #[error("file picking is not available on this platform")]
42 UnsupportedPlatform,
43}
44
45pub type PickerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
47
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct FileFilter {
51 pub label: String,
53 pub extensions: Vec<String>,
55 pub mime_types: Vec<String>,
59}
60
61impl FileFilter {
62 pub fn new(label: impl Into<String>, extensions: &[&str]) -> Self {
64 Self {
65 label: label.into(),
66 extensions: extensions.iter().map(|ext| (*ext).to_string()).collect(),
67 mime_types: Vec::new(),
68 }
69 }
70
71 pub fn with_mime_types(mut self, mime_types: &[&str]) -> Self {
73 self.mime_types = mime_types.iter().map(|mime| (*mime).to_string()).collect();
74 self
75 }
76}
77
78#[derive(Clone, Debug, Default, PartialEq, Eq)]
80pub struct FilePickerOptions {
81 pub title: Option<String>,
83 pub filters: Vec<FileFilter>,
85}
86
87impl FilePickerOptions {
88 pub fn with_title(mut self, title: impl Into<String>) -> Self {
90 self.title = Some(title.into());
91 self
92 }
93
94 pub fn with_filter(mut self, filter: FileFilter) -> Self {
96 self.filters.push(filter);
97 self
98 }
99
100 pub fn mime_types(&self) -> Vec<String> {
102 self.filters
103 .iter()
104 .flat_map(|filter| filter.mime_types.iter().cloned())
105 .collect()
106 }
107}
108
109#[derive(Clone, Debug, Default, PartialEq, Eq)]
111pub struct SaveDocumentRequest {
112 pub file_name: String,
114 pub mime_type: String,
117 pub title: Option<String>,
119}
120
121impl SaveDocumentRequest {
122 pub fn new(file_name: impl Into<String>, mime_type: impl Into<String>) -> Self {
124 Self {
125 file_name: file_name.into(),
126 mime_type: mime_type.into(),
127 title: None,
128 }
129 }
130
131 pub fn with_title(mut self, title: impl Into<String>) -> Self {
133 self.title = Some(title.into());
134 self
135 }
136}
137
138#[doc(hidden)]
146pub enum RecoveredPick {
147 File(ContentHandle),
149 Files(Vec<ContentHandle>),
151 Folder(ContentFolderRef),
153 WritableFolder(String),
155}
156
157pub trait FilePicker {
161 fn pick_file(
163 &self,
164 options: FilePickerOptions,
165 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>>;
166
167 fn pick_files(
172 &self,
173 options: FilePickerOptions,
174 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
175 let single = self.pick_file(options);
176 Box::pin(async move { Ok(single.await?.into_iter().collect()) })
177 }
178
179 fn pick_folder(
181 &self,
182 options: FilePickerOptions,
183 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>>;
184
185 fn save_document(
188 &self,
189 request: SaveDocumentRequest,
190 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
191 let _ = request;
192 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
193 }
194
195 fn pick_writable_folder(
199 &self,
200 options: FilePickerOptions,
201 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
202 let _ = options;
203 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
204 }
205
206 #[doc(hidden)]
210 fn take_recovered_pick(&self) -> Option<RecoveredPick> {
211 None
212 }
213}
214
215pub type FilePickerRef = Rc<dyn FilePicker>;
217
218thread_local! {
219 static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
220}
221
222pub fn set_platform_file_picker(picker: FilePickerRef) {
228 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
229}
230
231pub fn clear_platform_file_picker() {
233 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
234}
235
236fn registered_platform_file_picker() -> Option<FilePickerRef> {
237 PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
238}
239
240struct PlatformFilePicker;
243
244impl FilePicker for PlatformFilePicker {
245 fn pick_file(
246 &self,
247 options: FilePickerOptions,
248 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
249 match registered_platform_file_picker() {
250 Some(picker) => picker.pick_file(options),
251 None => builtin::pick_file(options),
252 }
253 }
254
255 fn pick_files(
256 &self,
257 options: FilePickerOptions,
258 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
259 match registered_platform_file_picker() {
260 Some(picker) => picker.pick_files(options),
261 None => builtin::pick_files(options),
262 }
263 }
264
265 fn pick_folder(
266 &self,
267 options: FilePickerOptions,
268 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
269 match registered_platform_file_picker() {
270 Some(picker) => picker.pick_folder(options),
271 None => builtin::pick_folder(options),
272 }
273 }
274
275 fn save_document(
276 &self,
277 request: SaveDocumentRequest,
278 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
279 match registered_platform_file_picker() {
280 Some(picker) => picker.save_document(request),
281 None => builtin::save_document(request),
282 }
283 }
284
285 fn pick_writable_folder(
286 &self,
287 options: FilePickerOptions,
288 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
289 match registered_platform_file_picker() {
290 Some(picker) => picker.pick_writable_folder(options),
291 None => builtin::pick_writable_folder(options),
292 }
293 }
294
295 fn take_recovered_pick(&self) -> Option<RecoveredPick> {
296 registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
297 }
298}
299
300pub fn default_file_picker() -> FilePickerRef {
302 Rc::new(PlatformFilePicker)
303}
304
305pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
307 thread_local! {
308 static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
309 }
310
311 LOCAL_FILE_PICKER.with(|cell| {
312 cell.borrow_mut()
313 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
314 .clone()
315 })
316}
317
318#[allow(non_snake_case)]
320#[composable]
321pub fn ProvideFilePicker(content: impl FnOnce()) {
322 let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
323 let picker_local = local_file_picker();
324
325 CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
326 content();
327 });
328}
329
330mod builtin {
331
332 #[cfg(all(
333 not(target_arch = "wasm32"),
334 not(target_os = "android"),
335 not(target_os = "ios"),
336 feature = "file-picker-native"
337 ))]
338 pub(super) use super::desktop::{
339 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
340 };
341 #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
342 pub(super) use super::web::{
343 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
344 };
345
346 #[cfg(not(any(
347 all(
348 not(target_arch = "wasm32"),
349 not(target_os = "android"),
350 not(target_os = "ios"),
351 feature = "file-picker-native"
352 ),
353 all(target_arch = "wasm32", feature = "file-picker-web")
354 )))]
355 mod unsupported {
356 use crate::{
357 content::{ContentFolderRef, ContentHandle, ContentSinkRef},
358 file_picker::{FilePickerError, FilePickerOptions, PickerFuture, SaveDocumentRequest},
359 };
360
361 pub(in crate::file_picker) fn pick_file(
362 _options: FilePickerOptions,
363 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
364 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
365 }
366
367 pub(in crate::file_picker) fn pick_files(
368 _options: FilePickerOptions,
369 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
370 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
371 }
372
373 pub(in crate::file_picker) fn pick_folder(
374 _options: FilePickerOptions,
375 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
376 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
377 }
378
379 pub(in crate::file_picker) fn save_document(
380 _request: SaveDocumentRequest,
381 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
382 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
383 }
384
385 pub(in crate::file_picker) fn pick_writable_folder(
386 _options: FilePickerOptions,
387 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
388 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
389 }
390 }
391
392 #[cfg(not(any(
393 all(
394 not(target_arch = "wasm32"),
395 not(target_os = "android"),
396 not(target_os = "ios"),
397 feature = "file-picker-native"
398 ),
399 all(target_arch = "wasm32", feature = "file-picker-web")
400 )))]
401 pub(super) use unsupported::{
402 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
403 };
404}
405
406#[cfg(all(
407 not(target_arch = "wasm32"),
408 not(target_os = "android"),
409 not(target_os = "ios"),
410 feature = "file-picker-native"
411))]
412mod desktop;
413
414#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
415mod web;
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn options_builder_sets_title_and_filters() {
423 let options = FilePickerOptions::default()
424 .with_title("Pick audio")
425 .with_filter(FileFilter::new("Audio", &["mp3", "flac"]).with_mime_types(&["audio/*"]));
426 assert_eq!(options.title.as_deref(), Some("Pick audio"));
427 assert_eq!(options.filters.len(), 1);
428 assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
429 assert_eq!(options.mime_types(), vec!["audio/*"]);
430 }
431
432 #[test]
433 fn default_picker_is_created() {
434 let picker = default_file_picker();
435 assert_eq!(Rc::strong_count(&picker), 1);
436 }
437
438 struct Marker;
439
440 impl FilePicker for Marker {
441 fn pick_file(
442 &self,
443 _options: FilePickerOptions,
444 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
445 Box::pin(async {
446 Ok(Some(
447 crate::content::BytesContent::named("marker.txt", b"marker".to_vec()).handle(),
448 ))
449 })
450 }
451
452 fn pick_folder(
453 &self,
454 _options: FilePickerOptions,
455 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
456 Box::pin(async { Ok(None) })
457 }
458 }
459
460 #[test]
461 fn registered_platform_picker_takes_precedence() {
462 clear_platform_file_picker();
463 assert!(registered_platform_file_picker().is_none());
464 set_platform_file_picker(Rc::new(Marker));
465 assert!(registered_platform_file_picker().is_some());
466
467 let picked =
468 pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()))
469 .expect("the marker picker resolves")
470 .expect("the marker picker picks a file");
471 assert_eq!(picked.metadata().name, "marker.txt");
472 clear_platform_file_picker();
473 }
474
475 #[test]
476 fn multi_selection_falls_back_to_the_single_chooser() {
477 clear_platform_file_picker();
478 set_platform_file_picker(Rc::new(Marker));
479 let picked =
480 pollster::block_on(default_file_picker().pick_files(FilePickerOptions::default()))
481 .expect("the marker picker resolves");
482 assert_eq!(picked.len(), 1);
483 clear_platform_file_picker();
484 }
485
486 #[test]
487 fn unsupported_operations_report_the_platform_gap() {
488 clear_platform_file_picker();
489 set_platform_file_picker(Rc::new(Marker));
490 let saved = pollster::block_on(
491 default_file_picker().save_document(SaveDocumentRequest::new("a.txt", "text/plain")),
492 );
493 let Err(error) = saved else {
494 panic!("the marker picker offers no save destination");
495 };
496 assert_eq!(error, FilePickerError::UnsupportedPlatform);
497 clear_platform_file_picker();
498 }
499}