//! One place starts file dialogs and collects the result. //! //! GTK dialogs block on the UI thread, so `poll` runs them there. A backend //! that returns before the choice is ready reports [`DialogPoll::Pending`] //! and the editor keeps asking. use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum DialogPurpose { NewProject, LoadProject, Body(crate::session::Sex), EquipmentModel(usize), EquipmentShiny(usize, usize), ImportEquipment, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum DialogPoll { Idle, Pending, Ready { purpose: DialogPurpose, path: Option, }, } pub trait Dialogs { fn start(&mut self, purpose: DialogPurpose); fn poll(&mut self) -> DialogPoll; } /// Remembers one answer per request. Tests use this instead of a window. #[derive(Debug, Default)] pub struct ScriptedDialogs { queued: Option, answers: Vec>, } impl ScriptedDialogs { pub fn push(&mut self, path: Option) { self.answers.push(path); } } impl Dialogs for ScriptedDialogs { fn start(&mut self, purpose: DialogPurpose) { self.queued = Some(purpose); } fn poll(&mut self) -> DialogPoll { let Some(purpose) = self.queued.take() else { return DialogPoll::Idle; }; let path = if self.answers.is_empty() { None } else { self.answers.remove(0) }; DialogPoll::Ready { purpose, path } } } pub struct SystemDialogs { queued: Option, pending: Option)>>, } impl SystemDialogs { pub fn new() -> Self { Self { queued: None, pending: None, } } } impl Default for SystemDialogs { fn default() -> Self { Self::new() } } impl Dialogs for SystemDialogs { fn start(&mut self, purpose: DialogPurpose) { if self.pending.is_none() && self.queued.is_none() { self.queued = Some(purpose); } } fn poll(&mut self) -> DialogPoll { if let Some(purpose) = self.queued.take() { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let path = native_pick(&purpose); let _ = tx.send((purpose, path)); }); self.pending = Some(rx); } if let Some(rx) = &self.pending { match rx.try_recv() { Ok((purpose, path)) => { self.pending = None; DialogPoll::Ready { purpose, path } } Err(std::sync::mpsc::TryRecvError::Empty) => DialogPoll::Pending, Err(_) => { self.pending = None; DialogPoll::Idle } } } else { DialogPoll::Idle } } } fn native_pick(purpose: &DialogPurpose) -> Option { let dialog = rfd::FileDialog::new(); match purpose { DialogPurpose::NewProject => dialog.set_title("Nieuwe projectmap").pick_folder(), DialogPurpose::LoadProject => dialog .set_title("Project laden") .add_filter("Paperdoll-project", &["ppd"]) .pick_file(), DialogPurpose::Body(_) => dialog .set_title("Lichaamsmodel") .add_filter("Model", &["glb", "gltf", "fbx", "obj"]) .pick_file(), DialogPurpose::EquipmentModel(_) => dialog .set_title("Rigide equipment toevoegen") .add_filter("Wavefront OBJ", &["obj"]) .pick_file(), DialogPurpose::EquipmentShiny(_, _) => dialog .set_title("Shinymap") .add_filter("PNG", &["png"]) .pick_file(), DialogPurpose::ImportEquipment => dialog .set_title("Equipment-preset importeren") .add_filter("Paperdoll equipment", &["ppe"]) .pick_file(), } } pub fn load_candidate(path: &Path) -> PathBuf { if path.is_dir() { path.join(crate::project::MANIFEST_NAME) } else { path.to_path_buf() } } #[cfg(test)] mod tests { use super::*; #[test] fn scripted_dialogs_return_the_queued_path_from_poll() { let mut dialogs = ScriptedDialogs::default(); dialogs.push(Some(PathBuf::from("/tmp/project"))); assert!(matches!(dialogs.poll(), DialogPoll::Idle)); dialogs.start(DialogPurpose::NewProject); match dialogs.poll() { DialogPoll::Ready { purpose, path } => { assert_eq!(purpose, DialogPurpose::NewProject); assert_eq!(path, Some(PathBuf::from("/tmp/project"))); } other => panic!("expected a ready dialog, got {other:?}"), } } }