1use std::any::Any;
46use std::cell::{Cell, RefCell};
47use std::collections::{HashMap, VecDeque};
48use std::path::PathBuf;
49use std::rc::Rc;
50use std::sync::Arc;
51
52use teksilo_core::raw_handle::ParentHandle;
53use teksilo_core::widget::EventContext;
54use teksilo_core::window::TeksiloWindowId;
55
56#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
63pub struct RequestId(u64);
64
65#[derive(Debug, Clone)]
71pub enum FileDialogResult {
72 File(Option<PathBuf>),
75
76 Files(Vec<PathBuf>),
78
79 Folder(Option<PathBuf>),
81
82 Saved(Option<PathBuf>),
84
85 Error(String),
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98enum DialogKind {
99 PickFile,
100 PickFiles,
101 PickFolder,
102 SaveFile,
103}
104
105#[derive(Debug, Clone)]
107pub struct FileFilter {
108 pub label: String,
110 pub extensions: Vec<String>,
112}
113
114#[derive(Debug, Clone)]
121pub struct FileDialogRequest {
122 kind: DialogKind,
123 title: Option<String>,
124 starting_dir: Option<PathBuf>,
125 default_file_name: Option<String>,
126 filters: Vec<FileFilter>,
127 parent: Option<ParentHandle>,
128}
129
130impl FileDialogRequest {
131 fn new(kind: DialogKind) -> Self {
132 Self {
133 kind,
134 title: None,
135 starting_dir: None,
136 default_file_name: None,
137 filters: Vec::new(),
138 parent: None,
139 }
140 }
141
142 pub fn pick_file() -> Self {
144 Self::new(DialogKind::PickFile)
145 }
146
147 pub fn pick_files() -> Self {
149 Self::new(DialogKind::PickFiles)
150 }
151
152 pub fn pick_folder() -> Self {
154 Self::new(DialogKind::PickFolder)
155 }
156
157 pub fn save_file() -> Self {
159 Self::new(DialogKind::SaveFile)
160 }
161
162 #[must_use]
164 pub fn title(mut self, t: impl Into<String>) -> Self {
165 self.title = Some(t.into());
166 self
167 }
168
169 #[must_use]
171 pub fn starting_dir(mut self, p: impl Into<PathBuf>) -> Self {
172 self.starting_dir = Some(p.into());
173 self
174 }
175
176 #[must_use]
180 pub fn default_file_name(mut self, n: impl Into<String>) -> Self {
181 self.default_file_name = Some(n.into());
182 self
183 }
184
185 #[must_use]
189 pub fn add_filter(mut self, label: impl Into<String>, extensions: &[&str]) -> Self {
190 self.filters.push(FileFilter {
191 label: label.into(),
192 extensions: extensions.iter().map(|e| (*e).to_string()).collect(),
193 });
194 self
195 }
196
197 #[must_use]
202 pub fn with_parent(mut self, p: ParentHandle) -> Self {
203 self.parent = Some(p);
204 self
205 }
206
207 pub fn validate(&self) -> Result<(), String> {
213 for f in &self.filters {
214 if f.extensions.is_empty() {
215 return Err(format!("filter {:?} has no extensions", f.label));
216 }
217 for ext in &f.extensions {
218 if ext.is_empty() {
219 return Err(format!("filter {:?} has an empty extension", f.label));
220 }
221 if ext.starts_with('.') {
222 return Err(format!(
223 "filter {:?} extension {ext:?} must not start with a dot",
224 f.label
225 ));
226 }
227 if ext
228 .chars()
229 .any(|c| c.is_whitespace() || c == '/' || c == '\\')
230 {
231 return Err(format!(
232 "filter {:?} extension {ext:?} contains whitespace or path separator",
233 f.label
234 ));
235 }
236 }
237 }
238 Ok(())
239 }
240
241 #[allow(dead_code)]
244 fn kind(&self) -> DialogKind {
245 self.kind
246 }
247}
248
249pub struct FileDialogEventPayload {
257 pub request_id: RequestId,
259 pub window_id_owner: TeksiloWindowId,
262 pub result: FileDialogResult,
264}
265
266pub trait FileDialogBackend {
278 fn dispatch(
285 &mut self,
286 request_id: RequestId,
287 window_id: TeksiloWindowId,
288 request: FileDialogRequest,
289 poster: Arc<dyn teksilo_core::AppEventPoster>,
290 );
291}
292
293type ResultCallback = Box<dyn FnOnce(FileDialogResult, &mut EventContext)>;
299
300struct PendingCallback {
301 window_id: TeksiloWindowId,
302 callback: ResultCallback,
303}
304
305struct FileDialogState {
306 backend: RefCell<Box<dyn FileDialogBackend>>,
307 pending: RefCell<HashMap<RequestId, PendingCallback>>,
308 next_id: Cell<u64>,
309}
310
311#[derive(Clone)]
317pub struct FileDialogHandle {
318 inner: Rc<FileDialogState>,
319}
320
321impl FileDialogHandle {
322 pub fn new<B: FileDialogBackend + 'static>(backend: B) -> Self {
324 Self {
325 inner: Rc::new(FileDialogState {
326 backend: RefCell::new(Box::new(backend)),
327 pending: RefCell::new(HashMap::new()),
328 next_id: Cell::new(1),
329 }),
330 }
331 }
332
333 pub fn submit(
343 &self,
344 window_id: TeksiloWindowId,
345 request: FileDialogRequest,
346 poster: Arc<dyn teksilo_core::AppEventPoster>,
347 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
348 ) -> Result<RequestId, String> {
349 request.validate()?;
350 let id = self.alloc_id();
351 self.inner.pending.borrow_mut().insert(
352 id,
353 PendingCallback {
354 window_id,
355 callback: Box::new(on_result),
356 },
357 );
358 self.inner
359 .backend
360 .borrow_mut()
361 .dispatch(id, window_id, request, poster);
362 Ok(id)
363 }
364
365 pub fn deliver(&self, payload: FileDialogEventPayload, ctx: &mut EventContext) {
370 let entry = self.inner.pending.borrow_mut().remove(&payload.request_id);
371 let Some(pending) = entry else {
372 return;
373 };
374 if pending.window_id != payload.window_id_owner {
375 return;
379 }
380 (pending.callback)(payload.result, ctx);
381 }
382
383 pub fn purge_window(&self, window_id: TeksiloWindowId) {
388 self.inner
389 .pending
390 .borrow_mut()
391 .retain(|_, p| p.window_id != window_id);
392 }
393
394 pub fn pending_count(&self) -> usize {
396 self.inner.pending.borrow().len()
397 }
398
399 fn alloc_id(&self) -> RequestId {
400 let n = self.inner.next_id.get();
401 self.inner.next_id.set(n.wrapping_add(1));
402 RequestId(n)
403 }
404}
405
406impl std::fmt::Debug for FileDialogHandle {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 f.debug_struct("FileDialogHandle")
409 .field("pending", &self.inner.pending.borrow().len())
410 .finish_non_exhaustive()
411 }
412}
413
414pub trait EventContextFileDialogExt {
451 fn pick_file(
458 &mut self,
459 request: FileDialogRequest,
460 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
461 ) -> Result<RequestId, String>;
462
463 fn pick_files(
466 &mut self,
467 request: FileDialogRequest,
468 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
469 ) -> Result<RequestId, String>;
470
471 fn pick_folder(
474 &mut self,
475 request: FileDialogRequest,
476 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
477 ) -> Result<RequestId, String>;
478
479 fn save_file(
482 &mut self,
483 request: FileDialogRequest,
484 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
485 ) -> Result<RequestId, String>;
486}
487
488impl EventContextFileDialogExt for EventContext<'_> {
489 fn pick_file(
490 &mut self,
491 mut request: FileDialogRequest,
492 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
493 ) -> Result<RequestId, String> {
494 request.kind = DialogKind::PickFile;
495 submit_via_ctx(self, request, on_result)
496 }
497
498 fn pick_files(
499 &mut self,
500 mut request: FileDialogRequest,
501 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
502 ) -> Result<RequestId, String> {
503 request.kind = DialogKind::PickFiles;
504 submit_via_ctx(self, request, on_result)
505 }
506
507 fn pick_folder(
508 &mut self,
509 mut request: FileDialogRequest,
510 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
511 ) -> Result<RequestId, String> {
512 request.kind = DialogKind::PickFolder;
513 submit_via_ctx(self, request, on_result)
514 }
515
516 fn save_file(
517 &mut self,
518 mut request: FileDialogRequest,
519 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
520 ) -> Result<RequestId, String> {
521 request.kind = DialogKind::SaveFile;
522 submit_via_ctx(self, request, on_result)
523 }
524}
525
526fn submit_via_ctx(
527 ctx: &mut EventContext,
528 mut request: FileDialogRequest,
529 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
530) -> Result<RequestId, String> {
531 let window_id = ctx.window().map(|w| w.id()).ok_or_else(|| {
532 "EventContext has no window — file dialog needs a parent window".to_string()
533 })?;
534
535 #[cfg(target_os = "macos")]
539 ctx.focus_window(window_id);
540
541 if request.parent.is_none()
542 && let Some(parent) = ctx.parent_window_handle()
543 {
544 request = request.with_parent(parent);
545 }
546
547 let handle = ctx
548 .app_state::<FileDialogHandle>()
549 .ok_or_else(|| {
550 "FileDialogHandle not installed in app-state — call \
551 TeksiloAppBuilder::install_file_dialog (or app_state(...)) at startup"
552 .to_string()
553 })?
554 .clone();
555 let poster = ctx
556 .poster()
557 .ok_or_else(|| {
558 "AppEventPoster not installed — file dialog needs a way to post \
559 results back to the UI loop"
560 .to_string()
561 })?
562 .clone();
563
564 handle.submit(window_id, request, poster, on_result)
565}
566
567pub struct MemoryFileDialog {
577 scripted: VecDeque<FileDialogResult>,
578}
579
580impl MemoryFileDialog {
581 pub fn new() -> Self {
584 Self {
585 scripted: VecDeque::new(),
586 }
587 }
588
589 pub fn enqueue(&mut self, r: FileDialogResult) {
592 self.scripted.push_back(r);
593 }
594}
595
596impl Default for MemoryFileDialog {
597 fn default() -> Self {
598 Self::new()
599 }
600}
601
602impl FileDialogBackend for MemoryFileDialog {
603 fn dispatch(
604 &mut self,
605 request_id: RequestId,
606 window_id: TeksiloWindowId,
607 _request: FileDialogRequest,
608 poster: Arc<dyn teksilo_core::AppEventPoster>,
609 ) {
610 let result = self.scripted.pop_front().unwrap_or_else(|| {
611 FileDialogResult::Error("MemoryFileDialog: no scripted result enqueued".into())
612 });
613 let payload = FileDialogEventPayload {
614 request_id,
615 window_id_owner: window_id,
616 result,
617 };
618 poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
619 }
620}
621
622#[cfg(feature = "rfd-backend")]
627mod rfd_backend {
628 use super::*;
629
630 pub struct RfdAsyncBackend;
643
644 impl RfdAsyncBackend {
645 pub fn new() -> Self {
646 Self
647 }
648 }
649
650 impl Default for RfdAsyncBackend {
651 fn default() -> Self {
652 Self::new()
653 }
654 }
655
656 impl FileDialogBackend for RfdAsyncBackend {
657 fn dispatch(
658 &mut self,
659 request_id: RequestId,
660 window_id: TeksiloWindowId,
661 request: FileDialogRequest,
662 poster: Arc<dyn teksilo_core::AppEventPoster>,
663 ) {
664 let mut dialog = rfd::AsyncFileDialog::new();
665 if let Some(t) = request.title.as_ref() {
666 dialog = dialog.set_title(t);
667 }
668 if let Some(d) = request.starting_dir.as_ref() {
669 dialog = dialog.set_directory(d);
670 }
671 if let Some(n) = request.default_file_name.as_ref() {
672 dialog = dialog.set_file_name(n);
673 }
674 for f in &request.filters {
675 let exts: Vec<&str> = f.extensions.iter().map(String::as_str).collect();
676 dialog = dialog.add_filter(&f.label, &exts);
677 }
678 if let Some(parent) = request.parent.as_ref() {
679 dialog = dialog.set_parent(parent);
683 }
684
685 let kind = request.kind();
686 spawn_dialog_task(async move {
687 let result = match kind {
688 DialogKind::PickFile => FileDialogResult::File(
689 dialog.pick_file().await.map(|h| h.path().to_path_buf()),
690 ),
691 DialogKind::PickFiles => FileDialogResult::Files(
692 dialog
693 .pick_files()
694 .await
695 .unwrap_or_default()
696 .into_iter()
697 .map(|h| h.path().to_path_buf())
698 .collect(),
699 ),
700 DialogKind::PickFolder => FileDialogResult::Folder(
701 dialog.pick_folder().await.map(|h| h.path().to_path_buf()),
702 ),
703 DialogKind::SaveFile => FileDialogResult::Saved(
704 dialog.save_file().await.map(|h| h.path().to_path_buf()),
705 ),
706 };
707 let payload = FileDialogEventPayload {
708 request_id,
709 window_id_owner: window_id,
710 result,
711 };
712 poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
713 });
714 }
715 }
716
717 fn spawn_dialog_task<F>(f: F)
718 where
719 F: std::future::Future<Output = ()> + Send + 'static,
720 {
721 async_std::task::spawn(f);
725 }
726}
727
728#[cfg(feature = "rfd-backend")]
729pub use rfd_backend::RfdAsyncBackend;
730
731#[cfg(test)]
736mod tests {
737 use super::*;
738 use std::any::Any;
739 use std::sync::Mutex;
740 use teksilo_core::AppEventPoster;
741
742 struct CapturingPoster {
746 captured: Mutex<Vec<Box<dyn Any + Send>>>,
747 }
748
749 impl CapturingPoster {
750 fn new() -> Arc<Self> {
751 Arc::new(Self {
752 captured: Mutex::new(Vec::new()),
753 })
754 }
755
756 fn drain(&self) -> Vec<Box<dyn Any + Send>> {
757 std::mem::take(&mut *self.captured.lock().unwrap())
758 }
759 }
760
761 impl AppEventPoster for CapturingPoster {
762 fn post_subscription_event(
763 &self,
764 _sub_id: teksilo_core::SubscriptionId,
765 _event: Box<dyn Any + Send>,
766 ) {
767 }
768
769 fn post_external(&self, payload: Box<dyn Any + Send>) {
770 self.captured.lock().unwrap().push(payload);
771 }
772 }
773
774 fn teksilo_id(n: u64) -> TeksiloWindowId {
775 TeksiloWindowId::new(n)
776 }
777
778 #[test]
779 fn validate_rejects_empty_extension_list() {
780 let req = FileDialogRequest::pick_file().add_filter("Images", &[]);
781 assert!(req.validate().is_err());
782 }
783
784 #[test]
785 fn validate_rejects_leading_dot() {
786 let req = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
787 assert!(req.validate().is_err());
788 }
789
790 #[test]
791 fn validate_rejects_whitespace_extension() {
792 let req = FileDialogRequest::pick_file().add_filter("Images", &["png ", "jpg"]);
793 assert!(req.validate().is_err());
794 }
795
796 #[test]
797 fn validate_accepts_clean_filters() {
798 let req = FileDialogRequest::pick_file()
799 .title("Open")
800 .add_filter("Images", &["png", "jpg", "JPG"]);
801 assert!(req.validate().is_ok());
802 }
803
804 #[test]
805 fn memory_backend_pops_scripted_in_order() {
806 let mut mock = MemoryFileDialog::new();
807 mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/a.txt"))));
808 mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/b.txt"))));
809 let handle = FileDialogHandle::new(mock);
810 let cap = CapturingPoster::new();
811 let poster: Arc<dyn AppEventPoster> = cap.clone();
812
813 let _ = handle
814 .submit(
815 teksilo_id(1),
816 FileDialogRequest::pick_file(),
817 poster.clone(),
818 |_, _| {},
819 )
820 .unwrap();
821 let _ = handle
822 .submit(
823 teksilo_id(1),
824 FileDialogRequest::pick_file(),
825 poster.clone(),
826 |_, _| {},
827 )
828 .unwrap();
829
830 assert_eq!(handle.pending_count(), 2);
832 let posted = cap.drain();
833 assert_eq!(posted.len(), 2);
834 for p in posted {
835 let typed = p.downcast::<FileDialogEventPayload>().unwrap();
836 match typed.result {
837 FileDialogResult::File(Some(_)) => {}
838 _ => panic!("expected File(Some)"),
839 }
840 }
841 }
842
843 #[test]
844 fn purge_drops_callbacks_for_matching_window() {
845 let mut mock = MemoryFileDialog::new();
846 mock.enqueue(FileDialogResult::File(None));
847 mock.enqueue(FileDialogResult::File(None));
848 let handle = FileDialogHandle::new(mock);
849 let cap = CapturingPoster::new();
850 let poster: Arc<dyn AppEventPoster> = cap.clone();
851
852 let _ = handle
853 .submit(
854 teksilo_id(7),
855 FileDialogRequest::pick_file(),
856 poster.clone(),
857 |_, _| {},
858 )
859 .unwrap();
860 let _ = handle
861 .submit(
862 teksilo_id(8),
863 FileDialogRequest::pick_file(),
864 poster.clone(),
865 |_, _| {},
866 )
867 .unwrap();
868 assert_eq!(handle.pending_count(), 2);
869
870 handle.purge_window(teksilo_id(7));
871 assert_eq!(handle.pending_count(), 1);
872 handle.purge_window(teksilo_id(8));
873 assert_eq!(handle.pending_count(), 0);
874 }
875
876 #[test]
877 fn submit_validates_before_dispatch() {
878 let mock = MemoryFileDialog::new();
879 let handle = FileDialogHandle::new(mock);
880 let cap = CapturingPoster::new();
881 let poster: Arc<dyn AppEventPoster> = cap.clone();
882 let bad = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
884 assert!(
885 handle
886 .submit(teksilo_id(1), bad, poster, |_, _| {})
887 .is_err()
888 );
889 assert_eq!(handle.pending_count(), 0);
890 assert_eq!(cap.drain().len(), 0);
892 }
893
894 #[test]
895 fn payload_round_trips_through_capturing_poster() {
896 let mut mock = MemoryFileDialog::new();
897 mock.enqueue(FileDialogResult::Folder(Some(PathBuf::from("/home/u"))));
898 let handle = FileDialogHandle::new(mock);
899 let cap = CapturingPoster::new();
900 let poster: Arc<dyn AppEventPoster> = cap.clone();
901
902 let req_id = handle
903 .submit(
904 teksilo_id(42),
905 FileDialogRequest::pick_folder(),
906 poster,
907 |_, _| {},
908 )
909 .unwrap();
910
911 let mut posted = cap.drain();
912 assert_eq!(posted.len(), 1);
913 let payload = posted
914 .pop()
915 .unwrap()
916 .downcast::<FileDialogEventPayload>()
917 .expect("payload type matches");
918 assert_eq!(payload.request_id, req_id);
919 assert_eq!(payload.window_id_owner, teksilo_id(42));
920 match &payload.result {
921 FileDialogResult::Folder(Some(p)) => assert_eq!(p, &PathBuf::from("/home/u")),
922 other => panic!("unexpected result: {other:?}"),
923 }
924 }
925
926 #[test]
927 fn deliver_after_purge_is_silent() {
928 use std::cell::Cell as StdCell;
932 use teksilo_core::WidgetTree;
933
934 let mut mock = MemoryFileDialog::new();
935 mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/x"))));
936 let handle = FileDialogHandle::new(mock);
937 let cap = CapturingPoster::new();
938 let poster: Arc<dyn AppEventPoster> = cap.clone();
939
940 let fired = Rc::new(StdCell::new(false));
941 let fired_clone = fired.clone();
942
943 let req_id = handle
944 .submit(
945 teksilo_id(5),
946 FileDialogRequest::pick_file(),
947 poster,
948 move |_, _| fired_clone.set(true),
949 )
950 .unwrap();
951
952 handle.purge_window(teksilo_id(5));
954
955 let mut posted = cap.drain();
958 let payload = *posted
959 .pop()
960 .unwrap()
961 .downcast::<FileDialogEventPayload>()
962 .unwrap();
963 assert_eq!(payload.request_id, req_id);
964
965 let mut tree = WidgetTree::new();
966 let mut noop = teksilo_core::NoopWindowOps;
967 tree.run_with_event_context(&mut noop, |ctx| {
968 handle.deliver(payload, ctx);
969 });
970
971 assert!(!fired.get(), "callback must not fire after purge");
972 }
973
974 #[test]
975 fn deliver_invokes_callback_with_result() {
976 use std::cell::Cell as StdCell;
977 use teksilo_core::WidgetTree;
978
979 let mut mock = MemoryFileDialog::new();
980 mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/y.txt"))));
981 let handle = FileDialogHandle::new(mock);
982 let cap = CapturingPoster::new();
983 let poster: Arc<dyn AppEventPoster> = cap.clone();
984
985 let captured: Rc<RefCell<Option<PathBuf>>> = Rc::new(RefCell::new(None));
986 let captured_clone = captured.clone();
987 let _ = StdCell::new(0);
989
990 let _ = handle
991 .submit(
992 teksilo_id(11),
993 FileDialogRequest::pick_file(),
994 poster,
995 move |result, _| {
996 if let FileDialogResult::File(Some(p)) = result {
997 *captured_clone.borrow_mut() = Some(p);
998 }
999 },
1000 )
1001 .unwrap();
1002
1003 let payload = *cap
1004 .drain()
1005 .pop()
1006 .unwrap()
1007 .downcast::<FileDialogEventPayload>()
1008 .unwrap();
1009 let mut tree = WidgetTree::new();
1010 let mut noop = teksilo_core::NoopWindowOps;
1011 tree.run_with_event_context(&mut noop, |ctx| handle.deliver(payload, ctx));
1012
1013 assert_eq!(*captured.borrow(), Some(PathBuf::from("/tmp/y.txt")));
1014 assert_eq!(handle.pending_count(), 0);
1016 }
1017}