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 {
450 fn pick_file(
457 &mut self,
458 request: FileDialogRequest,
459 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
460 ) -> Result<RequestId, String>;
461
462 fn pick_files(
465 &mut self,
466 request: FileDialogRequest,
467 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
468 ) -> Result<RequestId, String>;
469
470 fn pick_folder(
473 &mut self,
474 request: FileDialogRequest,
475 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
476 ) -> Result<RequestId, String>;
477
478 fn save_file(
481 &mut self,
482 request: FileDialogRequest,
483 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
484 ) -> Result<RequestId, String>;
485}
486
487impl EventContextFileDialogExt for EventContext<'_> {
488 fn pick_file(
489 &mut self,
490 mut request: FileDialogRequest,
491 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
492 ) -> Result<RequestId, String> {
493 request.kind = DialogKind::PickFile;
494 submit_via_ctx(self, request, on_result)
495 }
496
497 fn pick_files(
498 &mut self,
499 mut request: FileDialogRequest,
500 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
501 ) -> Result<RequestId, String> {
502 request.kind = DialogKind::PickFiles;
503 submit_via_ctx(self, request, on_result)
504 }
505
506 fn pick_folder(
507 &mut self,
508 mut request: FileDialogRequest,
509 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
510 ) -> Result<RequestId, String> {
511 request.kind = DialogKind::PickFolder;
512 submit_via_ctx(self, request, on_result)
513 }
514
515 fn save_file(
516 &mut self,
517 mut request: FileDialogRequest,
518 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
519 ) -> Result<RequestId, String> {
520 request.kind = DialogKind::SaveFile;
521 submit_via_ctx(self, request, on_result)
522 }
523}
524
525fn submit_via_ctx(
526 ctx: &mut EventContext,
527 mut request: FileDialogRequest,
528 on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
529) -> Result<RequestId, String> {
530 let window_id = ctx.window().map(|w| w.id()).ok_or_else(|| {
531 "EventContext has no window — file dialog needs a parent window".to_string()
532 })?;
533
534 #[cfg(target_os = "macos")]
538 ctx.focus_window(window_id);
539
540 if request.parent.is_none()
541 && let Some(parent) = ctx.parent_window_handle()
542 {
543 request = request.with_parent(parent);
544 }
545
546 let handle = ctx
547 .app_state::<FileDialogHandle>()
548 .ok_or_else(|| {
549 "FileDialogHandle not installed in app-state — call \
550 TeksiloAppBuilder::install_file_dialog (or app_state(...)) at startup"
551 .to_string()
552 })?
553 .clone();
554 let poster = ctx
555 .poster()
556 .ok_or_else(|| {
557 "AppEventPoster not installed — file dialog needs a way to post \
558 results back to the UI loop"
559 .to_string()
560 })?
561 .clone();
562
563 handle.submit(window_id, request, poster, on_result)
564}
565
566pub struct MemoryFileDialog {
576 scripted: VecDeque<FileDialogResult>,
577}
578
579impl MemoryFileDialog {
580 pub fn new() -> Self {
583 Self {
584 scripted: VecDeque::new(),
585 }
586 }
587
588 pub fn enqueue(&mut self, r: FileDialogResult) {
591 self.scripted.push_back(r);
592 }
593}
594
595impl Default for MemoryFileDialog {
596 fn default() -> Self {
597 Self::new()
598 }
599}
600
601impl FileDialogBackend for MemoryFileDialog {
602 fn dispatch(
603 &mut self,
604 request_id: RequestId,
605 window_id: TeksiloWindowId,
606 _request: FileDialogRequest,
607 poster: Arc<dyn teksilo_core::AppEventPoster>,
608 ) {
609 let result = self.scripted.pop_front().unwrap_or_else(|| {
610 FileDialogResult::Error("MemoryFileDialog: no scripted result enqueued".into())
611 });
612 let payload = FileDialogEventPayload {
613 request_id,
614 window_id_owner: window_id,
615 result,
616 };
617 poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
618 }
619}
620
621#[cfg(feature = "rfd-backend")]
626mod rfd_backend {
627 use super::*;
628
629 pub struct RfdAsyncBackend;
642
643 impl RfdAsyncBackend {
644 pub fn new() -> Self {
645 Self
646 }
647 }
648
649 impl Default for RfdAsyncBackend {
650 fn default() -> Self {
651 Self::new()
652 }
653 }
654
655 impl FileDialogBackend for RfdAsyncBackend {
656 fn dispatch(
657 &mut self,
658 request_id: RequestId,
659 window_id: TeksiloWindowId,
660 request: FileDialogRequest,
661 poster: Arc<dyn teksilo_core::AppEventPoster>,
662 ) {
663 let mut dialog = rfd::AsyncFileDialog::new();
664 if let Some(t) = request.title.as_ref() {
665 dialog = dialog.set_title(t);
666 }
667 if let Some(d) = request.starting_dir.as_ref() {
668 dialog = dialog.set_directory(d);
669 }
670 if let Some(n) = request.default_file_name.as_ref() {
671 dialog = dialog.set_file_name(n);
672 }
673 for f in &request.filters {
674 let exts: Vec<&str> = f.extensions.iter().map(String::as_str).collect();
675 dialog = dialog.add_filter(&f.label, &exts);
676 }
677 if let Some(parent) = request.parent.as_ref() {
678 dialog = dialog.set_parent(parent);
682 }
683
684 let kind = request.kind();
685 spawn_dialog_task(async move {
686 let result = match kind {
687 DialogKind::PickFile => FileDialogResult::File(
688 dialog.pick_file().await.map(|h| h.path().to_path_buf()),
689 ),
690 DialogKind::PickFiles => FileDialogResult::Files(
691 dialog
692 .pick_files()
693 .await
694 .unwrap_or_default()
695 .into_iter()
696 .map(|h| h.path().to_path_buf())
697 .collect(),
698 ),
699 DialogKind::PickFolder => FileDialogResult::Folder(
700 dialog.pick_folder().await.map(|h| h.path().to_path_buf()),
701 ),
702 DialogKind::SaveFile => FileDialogResult::Saved(
703 dialog.save_file().await.map(|h| h.path().to_path_buf()),
704 ),
705 };
706 let payload = FileDialogEventPayload {
707 request_id,
708 window_id_owner: window_id,
709 result,
710 };
711 poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
712 });
713 }
714 }
715
716 fn spawn_dialog_task<F>(f: F)
717 where
718 F: std::future::Future<Output = ()> + Send + 'static,
719 {
720 async_std::task::spawn(f);
724 }
725}
726
727#[cfg(feature = "rfd-backend")]
728pub use rfd_backend::RfdAsyncBackend;
729
730#[cfg(test)]
735mod tests {
736 use super::*;
737 use std::any::Any;
738 use std::sync::Mutex;
739 use teksilo_core::AppEventPoster;
740
741 struct CapturingPoster {
745 captured: Mutex<Vec<Box<dyn Any + Send>>>,
746 }
747
748 impl CapturingPoster {
749 fn new() -> Arc<Self> {
750 Arc::new(Self {
751 captured: Mutex::new(Vec::new()),
752 })
753 }
754
755 fn drain(&self) -> Vec<Box<dyn Any + Send>> {
756 std::mem::take(&mut *self.captured.lock().unwrap())
757 }
758 }
759
760 impl AppEventPoster for CapturingPoster {
761 fn post_subscription_event(
762 &self,
763 _sub_id: teksilo_core::SubscriptionId,
764 _event: Box<dyn Any + Send>,
765 ) {
766 }
767
768 fn post_external(&self, payload: Box<dyn Any + Send>) {
769 self.captured.lock().unwrap().push(payload);
770 }
771 }
772
773 fn teksilo_id(n: u64) -> TeksiloWindowId {
774 TeksiloWindowId::new(n)
775 }
776
777 #[test]
778 fn validate_rejects_empty_extension_list() {
779 let req = FileDialogRequest::pick_file().add_filter("Images", &[]);
780 assert!(req.validate().is_err());
781 }
782
783 #[test]
784 fn validate_rejects_leading_dot() {
785 let req = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
786 assert!(req.validate().is_err());
787 }
788
789 #[test]
790 fn validate_rejects_whitespace_extension() {
791 let req = FileDialogRequest::pick_file().add_filter("Images", &["png ", "jpg"]);
792 assert!(req.validate().is_err());
793 }
794
795 #[test]
796 fn validate_accepts_clean_filters() {
797 let req = FileDialogRequest::pick_file()
798 .title("Open")
799 .add_filter("Images", &["png", "jpg", "JPG"]);
800 assert!(req.validate().is_ok());
801 }
802
803 #[test]
804 fn memory_backend_pops_scripted_in_order() {
805 let mut mock = MemoryFileDialog::new();
806 mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/a.txt"))));
807 mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/b.txt"))));
808 let handle = FileDialogHandle::new(mock);
809 let cap = CapturingPoster::new();
810 let poster: Arc<dyn AppEventPoster> = cap.clone();
811
812 let _ = handle
813 .submit(
814 teksilo_id(1),
815 FileDialogRequest::pick_file(),
816 poster.clone(),
817 |_, _| {},
818 )
819 .unwrap();
820 let _ = handle
821 .submit(
822 teksilo_id(1),
823 FileDialogRequest::pick_file(),
824 poster.clone(),
825 |_, _| {},
826 )
827 .unwrap();
828
829 assert_eq!(handle.pending_count(), 2);
831 let posted = cap.drain();
832 assert_eq!(posted.len(), 2);
833 for p in posted {
834 let typed = p.downcast::<FileDialogEventPayload>().unwrap();
835 match typed.result {
836 FileDialogResult::File(Some(_)) => {}
837 _ => panic!("expected File(Some)"),
838 }
839 }
840 }
841
842 #[test]
843 fn purge_drops_callbacks_for_matching_window() {
844 let mut mock = MemoryFileDialog::new();
845 mock.enqueue(FileDialogResult::File(None));
846 mock.enqueue(FileDialogResult::File(None));
847 let handle = FileDialogHandle::new(mock);
848 let cap = CapturingPoster::new();
849 let poster: Arc<dyn AppEventPoster> = cap.clone();
850
851 let _ = handle
852 .submit(
853 teksilo_id(7),
854 FileDialogRequest::pick_file(),
855 poster.clone(),
856 |_, _| {},
857 )
858 .unwrap();
859 let _ = handle
860 .submit(
861 teksilo_id(8),
862 FileDialogRequest::pick_file(),
863 poster.clone(),
864 |_, _| {},
865 )
866 .unwrap();
867 assert_eq!(handle.pending_count(), 2);
868
869 handle.purge_window(teksilo_id(7));
870 assert_eq!(handle.pending_count(), 1);
871 handle.purge_window(teksilo_id(8));
872 assert_eq!(handle.pending_count(), 0);
873 }
874
875 #[test]
876 fn submit_validates_before_dispatch() {
877 let mock = MemoryFileDialog::new();
878 let handle = FileDialogHandle::new(mock);
879 let cap = CapturingPoster::new();
880 let poster: Arc<dyn AppEventPoster> = cap.clone();
881 let bad = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
883 assert!(
884 handle
885 .submit(teksilo_id(1), bad, poster, |_, _| {})
886 .is_err()
887 );
888 assert_eq!(handle.pending_count(), 0);
889 assert_eq!(cap.drain().len(), 0);
891 }
892
893 #[test]
894 fn payload_round_trips_through_capturing_poster() {
895 let mut mock = MemoryFileDialog::new();
896 mock.enqueue(FileDialogResult::Folder(Some(PathBuf::from("/home/u"))));
897 let handle = FileDialogHandle::new(mock);
898 let cap = CapturingPoster::new();
899 let poster: Arc<dyn AppEventPoster> = cap.clone();
900
901 let req_id = handle
902 .submit(
903 teksilo_id(42),
904 FileDialogRequest::pick_folder(),
905 poster,
906 |_, _| {},
907 )
908 .unwrap();
909
910 let mut posted = cap.drain();
911 assert_eq!(posted.len(), 1);
912 let payload = posted
913 .pop()
914 .unwrap()
915 .downcast::<FileDialogEventPayload>()
916 .expect("payload type matches");
917 assert_eq!(payload.request_id, req_id);
918 assert_eq!(payload.window_id_owner, teksilo_id(42));
919 match &payload.result {
920 FileDialogResult::Folder(Some(p)) => assert_eq!(p, &PathBuf::from("/home/u")),
921 other => panic!("unexpected result: {other:?}"),
922 }
923 }
924
925 #[test]
926 fn deliver_after_purge_is_silent() {
927 use std::cell::Cell as StdCell;
931 use teksilo_core::WidgetTree;
932
933 let mut mock = MemoryFileDialog::new();
934 mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/x"))));
935 let handle = FileDialogHandle::new(mock);
936 let cap = CapturingPoster::new();
937 let poster: Arc<dyn AppEventPoster> = cap.clone();
938
939 let fired = Rc::new(StdCell::new(false));
940 let fired_clone = fired.clone();
941
942 let req_id = handle
943 .submit(
944 teksilo_id(5),
945 FileDialogRequest::pick_file(),
946 poster,
947 move |_, _| fired_clone.set(true),
948 )
949 .unwrap();
950
951 handle.purge_window(teksilo_id(5));
953
954 let mut posted = cap.drain();
957 let payload = *posted
958 .pop()
959 .unwrap()
960 .downcast::<FileDialogEventPayload>()
961 .unwrap();
962 assert_eq!(payload.request_id, req_id);
963
964 let mut tree = WidgetTree::new();
965 let mut noop = teksilo_core::NoopWindowOps;
966 tree.run_with_event_context(&mut noop, |ctx| {
967 handle.deliver(payload, ctx);
968 });
969
970 assert!(!fired.get(), "callback must not fire after purge");
971 }
972
973 #[test]
974 fn deliver_invokes_callback_with_result() {
975 use std::cell::Cell as StdCell;
976 use teksilo_core::WidgetTree;
977
978 let mut mock = MemoryFileDialog::new();
979 mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/y.txt"))));
980 let handle = FileDialogHandle::new(mock);
981 let cap = CapturingPoster::new();
982 let poster: Arc<dyn AppEventPoster> = cap.clone();
983
984 let captured: Rc<RefCell<Option<PathBuf>>> = Rc::new(RefCell::new(None));
985 let captured_clone = captured.clone();
986 let _ = StdCell::new(0);
988
989 let _ = handle
990 .submit(
991 teksilo_id(11),
992 FileDialogRequest::pick_file(),
993 poster,
994 move |result, _| {
995 if let FileDialogResult::File(Some(p)) = result {
996 *captured_clone.borrow_mut() = Some(p);
997 }
998 },
999 )
1000 .unwrap();
1001
1002 let payload = *cap
1003 .drain()
1004 .pop()
1005 .unwrap()
1006 .downcast::<FileDialogEventPayload>()
1007 .unwrap();
1008 let mut tree = WidgetTree::new();
1009 let mut noop = teksilo_core::NoopWindowOps;
1010 tree.run_with_event_context(&mut noop, |ctx| handle.deliver(payload, ctx));
1011
1012 assert_eq!(*captured.borrow(), Some(PathBuf::from("/tmp/y.txt")));
1013 assert_eq!(handle.pending_count(), 0);
1015 }
1016}