cranpose_services/
file_picker.rs1use cranpose_core::compositionLocalOfWithPolicy;
17use cranpose_core::CompositionLocal;
18use cranpose_core::CompositionLocalProvider;
19use cranpose_macros::composable;
20use std::cell::RefCell;
21use std::future::Future;
22use std::pin::Pin;
23use std::rc::Rc;
24
25#[derive(thiserror::Error, Debug, Clone)]
27pub enum FilePickerError {
28 #[error("file picker failed: {0}")]
30 Failed(String),
31 #[error("reading picked entry failed: {0}")]
33 ReadFailed(String),
34 #[error("picked entry is a {actual}, expected a {expected}")]
36 WrongKind {
37 actual: &'static str,
39 expected: &'static str,
41 },
42 #[error("{operation} requires cranpose-services feature `{feature}`")]
44 UnsupportedFeature {
45 operation: &'static str,
47 feature: &'static str,
49 },
50 #[error("file picking is not available on this platform")]
52 UnsupportedPlatform,
53}
54
55pub type PickerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum PickedKind {
61 File,
63 Folder,
65}
66
67#[derive(Clone, Debug, Default)]
69pub struct FileFilter {
70 pub label: String,
72 pub extensions: Vec<String>,
74}
75
76impl FileFilter {
77 pub fn new(label: impl Into<String>, extensions: &[&str]) -> Self {
79 Self {
80 label: label.into(),
81 extensions: extensions.iter().map(|ext| (*ext).to_string()).collect(),
82 }
83 }
84}
85
86#[derive(Clone, Debug, Default)]
88pub struct FilePickerOptions {
89 pub title: Option<String>,
91 pub filters: Vec<FileFilter>,
93}
94
95impl FilePickerOptions {
96 pub fn with_title(mut self, title: impl Into<String>) -> Self {
98 self.title = Some(title.into());
99 self
100 }
101
102 pub fn with_filter(mut self, filter: FileFilter) -> Self {
104 self.filters.push(filter);
105 self
106 }
107}
108
109pub trait PickedEntry {
118 fn name(&self) -> String;
120
121 fn kind(&self) -> PickedKind;
123
124 fn display_path(&self) -> String;
127
128 fn read_bytes(&self) -> PickerFuture<Result<Vec<u8>, FilePickerError>>;
130
131 fn list(&self) -> PickerFuture<Result<Vec<PickedEntryRef>, FilePickerError>>;
133}
134
135pub type PickedEntryRef = Rc<dyn PickedEntry>;
137
138pub trait FilePicker {
140 fn pick_file(
142 &self,
143 options: FilePickerOptions,
144 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
145
146 fn pick_folder(
148 &self,
149 options: FilePickerOptions,
150 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
151}
152
153pub type FilePickerRef = Rc<dyn FilePicker>;
155
156thread_local! {
157 static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
158}
159
160pub fn set_platform_file_picker(picker: FilePickerRef) {
166 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
167}
168
169pub fn clear_platform_file_picker() {
171 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
172}
173
174fn registered_platform_file_picker() -> Option<FilePickerRef> {
175 PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
176}
177
178struct PlatformFilePicker;
181
182impl FilePicker for PlatformFilePicker {
183 fn pick_file(
184 &self,
185 options: FilePickerOptions,
186 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
187 if let Some(picker) = registered_platform_file_picker() {
188 return picker.pick_file(options);
189 }
190 builtin_pick(options, PickedKind::File)
191 }
192
193 fn pick_folder(
194 &self,
195 options: FilePickerOptions,
196 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
197 if let Some(picker) = registered_platform_file_picker() {
198 return picker.pick_folder(options);
199 }
200 builtin_pick(options, PickedKind::Folder)
201 }
202}
203
204#[cfg_attr(
205 not(any(feature = "file-picker-native", feature = "file-picker-web")),
206 allow(unused_variables)
207)]
208fn builtin_pick(
209 options: FilePickerOptions,
210 kind: PickedKind,
211) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
212 #[cfg(all(
213 not(target_arch = "wasm32"),
214 not(target_os = "android"),
215 not(target_os = "ios"),
216 feature = "file-picker-native"
217 ))]
218 {
219 return desktop::pick(options, kind);
220 }
221
222 #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
223 {
224 return web::pick(options, kind);
225 }
226
227 #[allow(unreachable_code)]
228 Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
229}
230
231pub fn default_file_picker() -> FilePickerRef {
233 Rc::new(PlatformFilePicker)
234}
235
236pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
238 thread_local! {
239 static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
240 }
241
242 LOCAL_FILE_PICKER.with(|cell| {
243 cell.borrow_mut()
244 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
245 .clone()
246 })
247}
248
249#[allow(non_snake_case)]
251#[composable]
252pub fn ProvideFilePicker(content: impl FnOnce()) {
253 let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
254 let picker_local = local_file_picker();
255
256 CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
257 content();
258 });
259}
260
261#[cfg(all(
262 not(target_arch = "wasm32"),
263 not(target_os = "android"),
264 not(target_os = "ios"),
265 feature = "file-picker-native"
266))]
267mod desktop;
268
269#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
270mod web;
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn options_builder_sets_title_and_filters() {
278 let options = FilePickerOptions::default()
279 .with_title("Pick audio")
280 .with_filter(FileFilter::new("Audio", &["mp3", "flac"]));
281 assert_eq!(options.title.as_deref(), Some("Pick audio"));
282 assert_eq!(options.filters.len(), 1);
283 assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
284 }
285
286 #[test]
287 fn default_picker_is_created() {
288 let picker = default_file_picker();
289 assert_eq!(Rc::strong_count(&picker), 1);
290 }
291
292 #[test]
293 fn registered_platform_picker_takes_precedence() {
294 struct Marker;
295 impl FilePicker for Marker {
296 fn pick_file(
297 &self,
298 _options: FilePickerOptions,
299 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
300 Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
301 }
302 fn pick_folder(
303 &self,
304 _options: FilePickerOptions,
305 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
306 Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
307 }
308 }
309
310 clear_platform_file_picker();
311 assert!(registered_platform_file_picker().is_none());
312 set_platform_file_picker(Rc::new(Marker));
313 assert!(registered_platform_file_picker().is_some());
314
315 let result =
316 pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()));
317 assert!(matches!(result, Err(FilePickerError::Failed(_))));
318 clear_platform_file_picker();
319 }
320}