BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / dialogs.rs

dialogs.rs

dialogs.rs 5.2 KiB Download Raw
1//! One place starts file dialogs and collects the result.
2//!
3//! The native picker runs on a dedicated request thread; `poll` never waits.
4//! [`DialogPoll::Pending`] keeps the editor repainting until an answer arrives.
5//! Scripted backends exercise the same request/result boundary in tests.
6
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DialogPurpose {
11 NewProject,
12 LoadProject,
13 Body(crate::session::Sex),
14 EquipmentModel(usize),
15 EquipmentShiny(usize, usize),
16 ImportEquipment,
17 /// Folder for Preview2d "Exporteer frame" (Tim t1065u).
18 ExportPreview2dFrame,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum DialogPoll {
23 Idle,
24 Pending,
25 Ready {
26 purpose: DialogPurpose,
27 path: Option<PathBuf>,
28 },
29}
30
31pub trait Dialogs {
32 fn start(&mut self, purpose: DialogPurpose);
33 fn poll(&mut self) -> DialogPoll;
34}
35
36/// Remembers one answer per request. Tests use this instead of a window.
37#[derive(Debug, Default)]
38pub struct ScriptedDialogs {
39 queued: Option<DialogPurpose>,
40 answers: Vec<Option<PathBuf>>,
41}
42
43impl ScriptedDialogs {
44 pub fn push(&mut self, path: Option<PathBuf>) {
45 self.answers.push(path);
46 }
47}
48
49impl Dialogs for ScriptedDialogs {
50 fn start(&mut self, purpose: DialogPurpose) {
51 self.queued = Some(purpose);
52 }
53
54 fn poll(&mut self) -> DialogPoll {
55 let Some(purpose) = self.queued.take() else {
56 return DialogPoll::Idle;
57 };
58 let path = if self.answers.is_empty() {
59 None
60 } else {
61 self.answers.remove(0)
62 };
63 DialogPoll::Ready { purpose, path }
64 }
65}
66
67pub struct SystemDialogs {
68 queued: Option<DialogPurpose>,
69 pending: Option<std::sync::mpsc::Receiver<(DialogPurpose, Option<PathBuf>)>>,
70}
71
72impl SystemDialogs {
73 pub fn new() -> Self {
74 Self {
75 queued: None,
76 pending: None,
77 }
78 }
79}
80
81impl Default for SystemDialogs {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl Dialogs for SystemDialogs {
88 fn start(&mut self, purpose: DialogPurpose) {
89 if self.pending.is_none() && self.queued.is_none() {
90 self.queued = Some(purpose);
91 }
92 }
93
94 fn poll(&mut self) -> DialogPoll {
95 if let Some(purpose) = self.queued.take() {
96 let (tx, rx) = std::sync::mpsc::channel();
97 std::thread::spawn(move || {
98 let path = native_pick(&purpose);
99 let _ = tx.send((purpose, path));
100 });
101 self.pending = Some(rx);
102 }
103 if let Some(rx) = &self.pending {
104 match rx.try_recv() {
105 Ok((purpose, path)) => {
106 self.pending = None;
107 DialogPoll::Ready { purpose, path }
108 }
109 Err(std::sync::mpsc::TryRecvError::Empty) => DialogPoll::Pending,
110 Err(_) => {
111 self.pending = None;
112 DialogPoll::Idle
113 }
114 }
115 } else {
116 DialogPoll::Idle
117 }
118 }
119}
120
121fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
122 let dialog = rfd::FileDialog::new();
123 match purpose {
124 DialogPurpose::NewProject => dialog
125 .set_title("Map voor het nieuwe project")
126 .pick_folder(),
127 DialogPurpose::LoadProject => dialog
128 .set_title("Project laden")
129 .add_filter("Paperdoll-project", &["ppd"])
130 .pick_file(),
131 DialogPurpose::Body(_) => dialog
132 .set_title("Lichaamsmodel")
133 .add_filter("Model", &["glb", "gltf", "fbx", "obj"])
134 .pick_file(),
135 DialogPurpose::EquipmentModel(_) => dialog
136 .set_title("Equipment toevoegen")
137 .add_filter("Model", &["obj", "fbx", "glb", "gltf"])
138 .add_filter("glTF/GLB", &["glb", "gltf"])
139 .add_filter("Wavefront OBJ", &["obj"])
140 .add_filter("FBX", &["fbx"])
141 .pick_file(),
142 DialogPurpose::EquipmentShiny(_, _) => dialog
143 .set_title("Shinymap")
144 .add_filter("PNG", &["png"])
145 .pick_file(),
146 DialogPurpose::ImportEquipment => dialog
147 .set_title("Equipment-preset importeren")
148 .add_filter("Paperdoll equipment", &["ppe"])
149 .pick_file(),
150 DialogPurpose::ExportPreview2dFrame => {
151 dialog.set_title("Map voor frame-export").pick_folder()
152 }
153 }
154}
155
156pub fn load_candidate(path: &Path) -> PathBuf {
157 if path.is_dir() {
158 path.join(crate::project::MANIFEST_NAME)
159 } else {
160 path.to_path_buf()
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn scripted_dialogs_return_the_queued_path_from_poll() {
170 let mut dialogs = ScriptedDialogs::default();
171 dialogs.push(Some(PathBuf::from("/tmp/project")));
172 assert!(matches!(dialogs.poll(), DialogPoll::Idle));
173 dialogs.start(DialogPurpose::NewProject);
174 match dialogs.poll() {
175 DialogPoll::Ready { purpose, path } => {
176 assert_eq!(purpose, DialogPurpose::NewProject);
177 assert_eq!(path, Some(PathBuf::from("/tmp/project")));
178 }
179 other => panic!("expected a ready dialog, got {other:?}"),
180 }
181 }
182}