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)]
139pub enum RecoveredPick {
140 File(ContentHandle),
141 Files(Vec<ContentHandle>),
142 Folder(ContentFolderRef),
143 WritableFolder(String),
144}
145
146pub trait FilePicker {
150 fn pick_file(
152 &self,
153 options: FilePickerOptions,
154 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>>;
155
156 fn pick_files(
161 &self,
162 options: FilePickerOptions,
163 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
164 let single = self.pick_file(options);
165 Box::pin(async move { Ok(single.await?.into_iter().collect()) })
166 }
167
168 fn pick_folder(
170 &self,
171 options: FilePickerOptions,
172 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>>;
173
174 fn save_document(
177 &self,
178 request: SaveDocumentRequest,
179 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
180 let _ = request;
181 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
182 }
183
184 fn pick_writable_folder(
188 &self,
189 options: FilePickerOptions,
190 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
191 let _ = options;
192 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
193 }
194
195 #[doc(hidden)]
196 fn take_recovered_pick(&self) -> Option<RecoveredPick> {
197 None
198 }
199}
200
201pub type FilePickerRef = Rc<dyn FilePicker>;
203
204thread_local! {
205 static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
206}
207
208pub fn set_platform_file_picker(picker: FilePickerRef) {
214 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
215}
216
217pub fn clear_platform_file_picker() {
219 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
220}
221
222fn registered_platform_file_picker() -> Option<FilePickerRef> {
223 PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
224}
225
226struct PlatformFilePicker;
227
228impl FilePicker for PlatformFilePicker {
229 fn pick_file(
230 &self,
231 options: FilePickerOptions,
232 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
233 match registered_platform_file_picker() {
234 Some(picker) => picker.pick_file(options),
235 None => builtin::pick_file(options),
236 }
237 }
238
239 fn pick_files(
240 &self,
241 options: FilePickerOptions,
242 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
243 match registered_platform_file_picker() {
244 Some(picker) => picker.pick_files(options),
245 None => builtin::pick_files(options),
246 }
247 }
248
249 fn pick_folder(
250 &self,
251 options: FilePickerOptions,
252 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
253 match registered_platform_file_picker() {
254 Some(picker) => picker.pick_folder(options),
255 None => builtin::pick_folder(options),
256 }
257 }
258
259 fn save_document(
260 &self,
261 request: SaveDocumentRequest,
262 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
263 match registered_platform_file_picker() {
264 Some(picker) => picker.save_document(request),
265 None => builtin::save_document(request),
266 }
267 }
268
269 fn pick_writable_folder(
270 &self,
271 options: FilePickerOptions,
272 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
273 match registered_platform_file_picker() {
274 Some(picker) => picker.pick_writable_folder(options),
275 None => builtin::pick_writable_folder(options),
276 }
277 }
278
279 fn take_recovered_pick(&self) -> Option<RecoveredPick> {
280 registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
281 }
282}
283
284pub fn default_file_picker() -> FilePickerRef {
286 Rc::new(PlatformFilePicker)
287}
288
289pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
291 thread_local! {
292 static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
293 }
294
295 LOCAL_FILE_PICKER.with(|cell| {
296 cell.borrow_mut()
297 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
298 .clone()
299 })
300}
301
302#[allow(non_snake_case)]
304#[composable]
305pub fn ProvideFilePicker(content: impl FnOnce()) {
306 let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
307 let picker_local = local_file_picker();
308
309 CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
310 content();
311 });
312}
313
314mod builtin {
315
316 #[cfg(all(
317 not(target_arch = "wasm32"),
318 not(target_os = "android"),
319 not(target_os = "ios"),
320 feature = "file-picker-native"
321 ))]
322 pub(super) use super::desktop::{
323 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
324 };
325 #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
326 pub(super) use super::web::{
327 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
328 };
329
330 #[cfg(not(any(
331 all(
332 not(target_arch = "wasm32"),
333 not(target_os = "android"),
334 not(target_os = "ios"),
335 feature = "file-picker-native"
336 ),
337 all(target_arch = "wasm32", feature = "file-picker-web")
338 )))]
339 mod unsupported {
340 use crate::{
341 content::{ContentFolderRef, ContentHandle, ContentSinkRef},
342 file_picker::{FilePickerError, FilePickerOptions, PickerFuture, SaveDocumentRequest},
343 };
344
345 pub(in crate::file_picker) fn pick_file(
346 _options: FilePickerOptions,
347 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
348 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
349 }
350
351 pub(in crate::file_picker) fn pick_files(
352 _options: FilePickerOptions,
353 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
354 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
355 }
356
357 pub(in crate::file_picker) fn pick_folder(
358 _options: FilePickerOptions,
359 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
360 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
361 }
362
363 pub(in crate::file_picker) fn save_document(
364 _request: SaveDocumentRequest,
365 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
366 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
367 }
368
369 pub(in crate::file_picker) fn pick_writable_folder(
370 _options: FilePickerOptions,
371 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
372 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
373 }
374 }
375
376 #[cfg(not(any(
377 all(
378 not(target_arch = "wasm32"),
379 not(target_os = "android"),
380 not(target_os = "ios"),
381 feature = "file-picker-native"
382 ),
383 all(target_arch = "wasm32", feature = "file-picker-web")
384 )))]
385 pub(super) use unsupported::{
386 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
387 };
388}
389
390#[cfg(all(
391 not(target_arch = "wasm32"),
392 not(target_os = "android"),
393 not(target_os = "ios"),
394 feature = "file-picker-native"
395))]
396mod desktop;
397
398#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
399mod web;
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn options_builder_sets_title_and_filters() {
407 let options = FilePickerOptions::default()
408 .with_title("Pick audio")
409 .with_filter(FileFilter::new("Audio", &["mp3", "flac"]).with_mime_types(&["audio/*"]));
410 assert_eq!(options.title.as_deref(), Some("Pick audio"));
411 assert_eq!(options.filters.len(), 1);
412 assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
413 assert_eq!(options.mime_types(), vec!["audio/*"]);
414 }
415
416 #[test]
417 fn default_picker_is_created() {
418 let picker = default_file_picker();
419 assert_eq!(Rc::strong_count(&picker), 1);
420 }
421
422 struct Marker;
423
424 impl FilePicker for Marker {
425 fn pick_file(
426 &self,
427 _options: FilePickerOptions,
428 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
429 Box::pin(async {
430 Ok(Some(
431 crate::content::BytesContent::named("marker.txt", b"marker".to_vec()).handle(),
432 ))
433 })
434 }
435
436 fn pick_folder(
437 &self,
438 _options: FilePickerOptions,
439 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
440 Box::pin(async { Ok(None) })
441 }
442 }
443
444 #[test]
445 fn registered_platform_picker_takes_precedence() {
446 clear_platform_file_picker();
447 assert!(registered_platform_file_picker().is_none());
448 set_platform_file_picker(Rc::new(Marker));
449 assert!(registered_platform_file_picker().is_some());
450
451 let picked =
452 pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()))
453 .expect("the marker picker resolves")
454 .expect("the marker picker picks a file");
455 assert_eq!(picked.metadata().name, "marker.txt");
456 clear_platform_file_picker();
457 }
458
459 #[test]
460 fn multi_selection_falls_back_to_the_single_chooser() {
461 clear_platform_file_picker();
462 set_platform_file_picker(Rc::new(Marker));
463 let picked =
464 pollster::block_on(default_file_picker().pick_files(FilePickerOptions::default()))
465 .expect("the marker picker resolves");
466 assert_eq!(picked.len(), 1);
467 clear_platform_file_picker();
468 }
469
470 #[test]
471 fn unsupported_operations_report_the_platform_gap() {
472 clear_platform_file_picker();
473 set_platform_file_picker(Rc::new(Marker));
474 let saved = pollster::block_on(
475 default_file_picker().save_document(SaveDocumentRequest::new("a.txt", "text/plain")),
476 );
477 let Err(error) = saved else {
478 panic!("the marker picker offers no save destination");
479 };
480 assert_eq!(error, FilePickerError::UnsupportedPlatform);
481 clear_platform_file_picker();
482 }
483}