1#![warn(missing_docs)]
2
3use std::io;
63use std::marker::PhantomData;
64use std::path::Path;
65
66use bevy_app::prelude::*;
67use bevy_derive::Deref;
68use bevy_ecs::prelude::*;
69use bevy_tasks::prelude::*;
70use bevy_winit::{EventLoopProxy, EventLoopProxyWrapper, WinitUserEvent};
71use crossbeam_channel::{bounded, Receiver, Sender};
72use rfd::AsyncFileDialog;
73
74#[cfg(not(target_arch = "wasm32"))]
75mod pick;
76
77#[cfg(not(target_arch = "wasm32"))]
78pub use pick::*;
79
80pub mod prelude {
81 pub use crate::{
83 DialogFileLoadCanceled, DialogFileLoaded, DialogFileSaveCanceled, DialogFileSaved,
84 FileDialogExt, FileDialogPlugin,
85 };
86
87 #[cfg(not(target_arch = "wasm32"))]
88 pub use crate::{
89 DialogDirectoryPickCanceled, DialogDirectoryPicked, DialogFilePickCanceled,
90 DialogFilePicked,
91 };
92}
93
94#[derive(Default)]
97pub struct FileDialogPlugin(Vec<RegisterIntent>);
98
99type RegisterIntent = Box<dyn Fn(&mut App) + Send + Sync + 'static>;
100
101pub trait SaveContents: Send + Sync + 'static {}
103
104pub trait LoadContents: Send + Sync + 'static {}
106
107impl<T> SaveContents for T where T: Send + Sync + 'static {}
108
109impl<T> LoadContents for T where T: Send + Sync + 'static {}
110
111impl FileDialogPlugin {
112 pub fn new() -> Self {
116 Default::default()
117 }
118
119 pub fn with_save_file<T: SaveContents>(mut self) -> Self {
124 self.0.push(Box::new(|app| {
125 let (tx, rx) = bounded::<DialogResult<DialogFileSaved<T>>>(1);
126 app.insert_resource(StreamSender(tx));
127 app.insert_resource(StreamReceiver(rx));
128 app.add_message::<DialogFileSaved<T>>();
129 app.add_message::<DialogFileSaveCanceled<T>>();
130 app.add_systems(
131 First,
132 handle_dialog_result::<DialogFileSaved<T>, DialogFileSaveCanceled<T>>,
133 );
134 }));
135 self
136 }
137
138 pub fn with_load_file<T: LoadContents>(mut self) -> Self {
143 self.0.push(Box::new(|app| {
144 let (tx, rx) = bounded::<DialogResult<DialogFileLoaded<T>>>(1);
145 app.insert_resource(StreamSender(tx));
146 app.insert_resource(StreamReceiver(rx));
147 app.add_message::<DialogFileLoaded<T>>();
148 app.add_message::<DialogFileLoadCanceled<T>>();
149 app.add_systems(
150 First,
151 handle_dialog_result::<DialogFileLoaded<T>, DialogFileLoadCanceled<T>>,
152 );
153 }));
154 self
155 }
156}
157
158#[derive(Resource, Deref)]
159struct StreamReceiver<T>(Receiver<T>);
160
161#[derive(Resource, Deref)]
162struct StreamSender<T>(Sender<T>);
163
164enum DialogResult<T> {
165 Single(T),
166 Batch(Vec<T>),
167 Canceled,
168}
169
170fn handle_dialog_result<E: Message, C: Message + Default>(
171 receiver: Res<StreamReceiver<DialogResult<E>>>,
172 mut ev_done: MessageWriter<E>,
173 mut ev_canceled: MessageWriter<C>,
174) {
175 for result in receiver.try_iter() {
176 match result {
177 DialogResult::Single(event) => {
178 ev_done.write(event);
179 }
180 DialogResult::Batch(events) => {
181 ev_done.write_batch(events);
182 }
183 DialogResult::Canceled => {
184 ev_canceled.write_default();
185 }
186 }
187 }
188}
189
190#[derive(Message)]
192pub struct DialogFileSaved<T: SaveContents> {
193 pub file_name: String,
195
196 pub result: io::Result<()>,
198
199 #[cfg(not(target_arch = "wasm32"))]
203 pub path: std::path::PathBuf,
204
205 marker: PhantomData<T>,
206}
207
208#[derive(Message)]
210pub struct DialogFileLoaded<T: LoadContents> {
211 pub file_name: String,
213
214 pub contents: Vec<u8>,
216
217 #[cfg(not(target_arch = "wasm32"))]
221 pub path: std::path::PathBuf,
222
223 marker: PhantomData<T>,
224}
225
226#[derive(Message)]
228pub struct DialogFileLoadCanceled<T: LoadContents>(PhantomData<T>);
229
230impl<T: LoadContents> Default for DialogFileLoadCanceled<T> {
231 fn default() -> Self {
232 Self(Default::default())
233 }
234}
235
236#[derive(Message)]
238pub struct DialogFileSaveCanceled<T: SaveContents>(PhantomData<T>);
239
240impl<T: SaveContents> Default for DialogFileSaveCanceled<T> {
241 fn default() -> Self {
242 Self(Default::default())
243 }
244}
245
246impl Plugin for FileDialogPlugin {
247 fn build(&self, app: &mut App) {
248 assert!(
249 !self.0.is_empty(),
250 "File dialog not initialized, use at least one FileDialogPlugin::with_*"
251 );
252
253 for action in &self.0 {
254 action(app);
255 }
256 }
257}
258
259pub struct FileDialog<'w, 's, 'a> {
262 commands: &'a mut Commands<'w, 's>,
263 dialog: AsyncFileDialog,
264}
265
266impl FileDialog<'_, '_, '_> {
267 pub fn add_filter(mut self, name: impl Into<String>, extensions: &[impl ToString]) -> Self {
277 self.dialog = self.dialog.add_filter(name, extensions);
278 self
279 }
280
281 pub fn set_directory<P: AsRef<Path>>(mut self, path: P) -> Self {
286 self.dialog = self.dialog.set_directory(path);
287 self
288 }
289
290 pub fn set_file_name(mut self, file_name: impl Into<String>) -> Self {
295 self.dialog = self.dialog.set_file_name(file_name);
296 self
297 }
298
299 pub fn set_title(mut self, title: impl Into<String>) -> Self {
305 self.dialog = self.dialog.set_title(title);
306 self
307 }
308
309 pub fn save_file<T: SaveContents>(self, contents: Vec<u8>) {
313 self.commands.queue(|world: &mut World| {
314 let sender = world
315 .get_resource::<StreamSender<DialogResult<DialogFileSaved<T>>>>()
316 .expect("FileDialogPlugin not initialized with 'with_save_file::<T>()'")
317 .0
318 .clone();
319
320 let event_loop_proxy = world
321 .get_resource::<EventLoopProxyWrapper>()
322 .map(|proxy| EventLoopProxy::clone(&**proxy));
323
324 AsyncComputeTaskPool::get()
325 .spawn(async move {
326 let file = self.dialog.save_file().await;
327 let _wake_up = event_loop_proxy.as_ref().map(WakeUpOnDrop);
328
329 let Some(file) = file else {
330 sender.send(DialogResult::Canceled).unwrap();
331 return;
332 };
333
334 let event = DialogFileSaved {
335 file_name: file.file_name(),
336 result: file.write(&contents).await,
337 #[cfg(not(target_arch = "wasm32"))]
338 path: file.path().to_path_buf(),
339 marker: PhantomData,
340 };
341
342 sender.send(DialogResult::Single(event)).unwrap();
343 })
344 .detach();
345 });
346 }
347
348 pub fn load_file<T: LoadContents>(self) {
352 self.commands.queue(|world: &mut World| {
353 let sender = world
354 .get_resource::<StreamSender<DialogResult<DialogFileLoaded<T>>>>()
355 .expect("FileDialogPlugin not initialized with 'with_load_file::<T>()'")
356 .0
357 .clone();
358
359 let event_loop_proxy = world
360 .get_resource::<EventLoopProxyWrapper>()
361 .map(|proxy| EventLoopProxy::clone(&**proxy));
362
363 AsyncComputeTaskPool::get()
364 .spawn(async move {
365 let file = self.dialog.pick_file().await;
366 let _wake_up = event_loop_proxy.as_ref().map(WakeUpOnDrop);
367
368 let Some(file) = file else {
369 sender.send(DialogResult::Canceled).unwrap();
370 return;
371 };
372
373 let event = DialogFileLoaded {
374 file_name: file.file_name(),
375 contents: file.read().await,
376 #[cfg(not(target_arch = "wasm32"))]
377 path: file.path().to_path_buf(),
378 marker: PhantomData,
379 };
380
381 sender.send(DialogResult::Single(event)).unwrap();
382 })
383 .detach();
384 });
385 }
386
387 pub fn load_multiple_files<T: LoadContents>(self) {
393 self.commands.queue(|world: &mut World| {
394 let sender = world
395 .get_resource::<StreamSender<DialogResult<DialogFileLoaded<T>>>>()
396 .expect("FileDialogPlugin not initialized with 'with_load_file::<T>()'")
397 .0
398 .clone();
399
400 let event_loop_proxy = world
401 .get_resource::<EventLoopProxyWrapper>()
402 .map(|proxy| EventLoopProxy::clone(&**proxy));
403
404 AsyncComputeTaskPool::get()
405 .spawn(async move {
406 let files = AsyncFileDialog::new().pick_files().await;
407 let _wake_up = event_loop_proxy.as_ref().map(WakeUpOnDrop);
408
409 let Some(files) = files else {
410 sender.send(DialogResult::Canceled).unwrap();
411 return;
412 };
413
414 let mut events = Vec::new();
415 for file in files {
416 events.push(DialogFileLoaded {
417 file_name: file.file_name(),
418 contents: file.read().await,
419 #[cfg(not(target_arch = "wasm32"))]
420 path: file.path().to_path_buf(),
421 marker: PhantomData,
422 });
423 }
424
425 sender.send(DialogResult::Batch(events)).unwrap();
426 })
427 .detach();
428 });
429 }
430}
431
432pub trait FileDialogExt<'w, 's> {
434 #[must_use]
436 fn dialog<'a>(&'a mut self) -> FileDialog<'w, 's, 'a>;
437}
438
439impl<'w, 's> FileDialogExt<'w, 's> for Commands<'w, 's> {
440 fn dialog<'a>(&'a mut self) -> FileDialog<'w, 's, 'a> {
441 FileDialog {
442 commands: self,
443 dialog: AsyncFileDialog::new(),
444 }
445 }
446}
447
448struct WakeUpOnDrop<'a>(&'a EventLoopProxy<WinitUserEvent>);
451
452impl Drop for WakeUpOnDrop<'_> {
453 fn drop(&mut self) {
454 self.0.send_event(WinitUserEvent::WakeUp).unwrap();
455 }
456}