Skip to main content

bevy_file_dialog/
lib.rs

1#![warn(missing_docs)]
2
3//! Bevy plugin that allows you to save and load files with file dialogs.
4//!
5//! In order to use it you need to add [`FileDialogPlugin`] to your [`App`] with
6//! at least one or more calls to:
7//! - [`FileDialogPlugin::with_save_file::<T>`]
8//! - [`FileDialogPlugin::with_load_file::<T>`]
9//! - [`FileDialogPlugin::with_pick_directory::<T>`]
10//! - [`FileDialogPlugin::with_pick_file::<T>`]
11//!
12//! these functions can be called as many times as you want, the type parameter
13//! acts as marker that allows you to call:
14//! - [`FileDialog::save_file`]
15//!   - for [`FileDialogPlugin::with_save_file::<T>`]
16//! - [`FileDialog::load_file`]
17//! - [`FileDialog::load_multiple_files`]
18//!   - for [`FileDialogPlugin::with_load_file::<T>`]
19//! - [`FileDialog::pick_directory_path`]
20//! - [`FileDialog::pick_multiple_directory_paths`]
21//!   - for [`FileDialogPlugin::with_pick_directory::<T>`]
22//! - [`FileDialog::pick_file_path`]
23//! - [`FileDialog::pick_multiple_file_paths`]
24//!   - for [`FileDialogPlugin::with_pick_file::<T>`]
25//!
26//! with same type marker and then receive the result in
27//! - [`DialogFileSaved`] ([`EventReader<DialogFileSaved<T>>`])
28//! - [`DialogFileLoaded`] ([`EventReader<DialogFileLoaded<T>>`])
29//! - [`DialogDirectoryPicked`] ([`EventReader<DialogDirectoryPicked<T>>`])
30//! - [`DialogFilePicked`] ([`EventReader<DialogFilePicked<T>>`])
31//!
32//! events
33//!
34//! [`FileDialog`] can be created by calling [`FileDialogExt::dialog`],
35//! [`FileDialogExt`] as an extension trait implemented for [`Commands`]
36//! and is included in `bevy_file_dialog::prelude`:
37//!
38//! ```rust
39//! fn system(mut commands: Commands) {
40//!     commands
41//!         .dialog()
42//!         .set_directory("/")
43//!         .set_title("My Save Dialog")
44//!         .add_filter("Text", &["txt"])
45//!         .save_file::<MySaveDialog>();
46//! }
47//! ```
48//!
49//! When you load multiple files at once with
50//! [`FileDialog::load_multiple_files`], you receive them each as separate event
51//! in [`EventReader<DialogFileLoaded<T>>`] but they are sent as a batch,
52//! meaning you get them all at once.
53//!
54//! The same thing applies to [`FileDialog::pick_multiple_directory_paths`] and
55//! [`EventReader<DialogDirectoryPicked<T>>`] and also
56//! [`FileDialog::pick_multiple_file_paths`] and
57//! [`EventReader<DialogFilePicked<T>>`]
58//!
59//! If you want to be compatible with wasm, do not use any of the `pick_` apis,
60//! they are only for native platforms.
61
62use 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    //! Prelude containing all types you need for saving/loading files with dialogs.
82    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/// Add this plugin to Bevy App to use the `FileDialog` resource in your system
95/// to save/load files.
96#[derive(Default)]
97pub struct FileDialogPlugin(Vec<RegisterIntent>);
98
99type RegisterIntent = Box<dyn Fn(&mut App) + Send + Sync + 'static>;
100
101/// Marker trait saying that data can be saved to file.
102pub trait SaveContents: Send + Sync + 'static {}
103
104/// Marker trait saying that data can be loaded from file.
105pub 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    /// Create new file dialog plugin. Do not forget to call at least one
113    /// `with_save_file`, `with_load_file` or `with_pick_directory` on the plugin to allow you to
114    /// save/load files and pick directories.
115    pub fn new() -> Self {
116        Default::default()
117    }
118
119    /// Allow saving file contents. This allows you to call
120    ///  `dialog().save_file::<T>()` on [`Commands`]. For each `with_save_file` you
121    /// will receive [`DialogFileSaved<T>`] in your systems when `save_file`
122    /// completes.
123    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    /// Allow loading file contents. This allows you to call
139    ///  `dialog().load_file::<T>()` on [`Commands`]. For each `with_load_file` you
140    /// will receive [`DialogFileLoaded<T>`] in your systems when `load_file`
141    /// completes.
142    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/// Event that gets sent when file contents get saved to file system.
191#[derive(Message)]
192pub struct DialogFileSaved<T: SaveContents> {
193    /// Name of saved file.
194    pub file_name: String,
195
196    /// Result of save file system operation.
197    pub result: io::Result<()>,
198
199    /// Path to saved file.
200    ///
201    /// Does not exist in wasm, you can use this on native platforms only.
202    #[cfg(not(target_arch = "wasm32"))]
203    pub path: std::path::PathBuf,
204
205    marker: PhantomData<T>,
206}
207
208/// Event that gets sent when file contents get loaded from file system.
209#[derive(Message)]
210pub struct DialogFileLoaded<T: LoadContents> {
211    /// Name of loaded file.
212    pub file_name: String,
213
214    /// Byte contents of loaded file.
215    pub contents: Vec<u8>,
216
217    /// Path to loaded file.
218    ///
219    /// Does not exist in wasm, you can use this on native platforms only.
220    #[cfg(not(target_arch = "wasm32"))]
221    pub path: std::path::PathBuf,
222
223    marker: PhantomData<T>,
224}
225
226/// Event that gets sent when user closes file load dialog without picking any file.
227#[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/// Event that gets sent when user closes file save dialog without saving any file.
237#[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
259/// File dialog for saving/loading files. You can further customize what can be
260/// saved/loaded and the initial state of dialog with its functions.
261pub struct FileDialog<'w, 's, 'a> {
262    commands: &'a mut Commands<'w, 's>,
263    dialog: AsyncFileDialog,
264}
265
266impl FileDialog<'_, '_, '_> {
267    /// Add file extension filter.
268    ///
269    /// Takes in the name of the filter, and list of extensions
270    ///
271    /// The name of the filter will be displayed on supported platforms:
272    ///   * Windows
273    ///   * Linux
274    ///
275    /// On platforms that don't support filter names, all filters will be merged into one filter
276    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    /// Set starting directory of the dialog. Supported platforms:
282    ///   * Linux ([GTK only](https://github.com/PolyMeilex/rfd/issues/42))
283    ///   * Windows
284    ///   * Mac
285    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    /// Set starting file name of the dialog. Supported platforms:
291    ///  * Windows
292    ///  * Linux
293    ///  * Mac
294    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    /// Set the title of the dialog. Supported platforms:
300    ///  * Windows
301    ///  * Linux
302    ///  * Mac (Only below version 10.11)
303    ///  * WASM32
304    pub fn set_title(mut self, title: impl Into<String>) -> Self {
305        self.dialog = self.dialog.set_title(title);
306        self
307    }
308
309    /// Open save file dialog and save the `contents` to that file. When file
310    /// gets saved, the [`DialogFileSaved<T>`] gets sent. You can get read this event
311    /// with Bevy's [`EventReader<DialogFileSaved<T>>`] system param.
312    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    /// Open pick file dialog and load its contents. When file contents get
349    /// loaded, the [`DialogFileLoaded<T>`] gets sent. You can read this event with
350    /// Bevy's [`EventReader<DialogFileLoaded<T>>`].
351    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    /// Open pick file dialog for multiple files and load contents for all
388    /// selected files. When file contents get loaded, the
389    /// [`DialogFileLoaded<T>`] gets sent for each file. You can read each file
390    /// by reading every event received with with Bevy's
391    /// [`EventReader<DialogFileLoaded<T>>`].
392    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
432/// Extension trait for [`Commands`] that allow you to create dialogs.
433pub trait FileDialogExt<'w, 's> {
434    /// Create dialog for loading/saving files.
435    #[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
448/// A struct to send a WakeUp event to winit when dropped (i.e., when the scope
449/// ends).
450struct 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}