BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / dialogs.rs

dialogs.rs

dialogs.rs 4.9 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}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum DialogPoll {
21 Idle,
22 Pending,
23 Ready {
24 purpose: DialogPurpose,
25 path: Option<PathBuf>,
26 },
27}
28
29pub trait Dialogs {
30 fn start(&mut self, purpose: DialogPurpose);
31 fn poll(&mut self) -> DialogPoll;
32}
33
34/// Remembers one answer per request. Tests use this instead of a window.
35#[derive(Debug, Default)]
36pub struct ScriptedDialogs {
37 queued: Option<DialogPurpose>,
38 answers: Vec<Option<PathBuf>>,
39}
40
41impl ScriptedDialogs {
42 pub fn push(&mut self, path: Option<PathBuf>) {
43 self.answers.push(path);
44 }
45}
46
47impl Dialogs for ScriptedDialogs {
48 fn start(&mut self, purpose: DialogPurpose) {
49 self.queued = Some(purpose);
50 }
51
52 fn poll(&mut self) -> DialogPoll {
53 let Some(purpose) = self.queued.take() else {
54 return DialogPoll::Idle;
55 };
56 let path = if self.answers.is_empty() {
57 None
58 } else {
59 self.answers.remove(0)
60 };
61 DialogPoll::Ready { purpose, path }
62 }
63}
64
65pub struct SystemDialogs {
66 queued: Option<DialogPurpose>,
67 pending: Option<std::sync::mpsc::Receiver<(DialogPurpose, Option<PathBuf>)>>,
68}
69
70impl SystemDialogs {
71 pub fn new() -> Self {
72 Self {
73 queued: None,
74 pending: None,
75 }
76 }
77}
78
79impl Default for SystemDialogs {
80 fn default() -> Self {
81 Self::new()
82 }
83}
84
85impl Dialogs for SystemDialogs {
86 fn start(&mut self, purpose: DialogPurpose) {
87 if self.pending.is_none() && self.queued.is_none() {
88 self.queued = Some(purpose);
89 }
90 }
91
92 fn poll(&mut self) -> DialogPoll {
93 if let Some(purpose) = self.queued.take() {
94 let (tx, rx) = std::sync::mpsc::channel();
95 std::thread::spawn(move || {
96 let path = native_pick(&purpose);
97 let _ = tx.send((purpose, path));
98 });
99 self.pending = Some(rx);
100 }
101 if let Some(rx) = &self.pending {
102 match rx.try_recv() {
103 Ok((purpose, path)) => {
104 self.pending = None;
105 DialogPoll::Ready { purpose, path }
106 }
107 Err(std::sync::mpsc::TryRecvError::Empty) => DialogPoll::Pending,
108 Err(_) => {
109 self.pending = None;
110 DialogPoll::Idle
111 }
112 }
113 } else {
114 DialogPoll::Idle
115 }
116 }
117}
118
119fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
120 let dialog = rfd::FileDialog::new();
121 match purpose {
122 DialogPurpose::NewProject => dialog.set_title("Nieuwe projectmap").pick_folder(),
123 DialogPurpose::LoadProject => dialog
124 .set_title("Project laden")
125 .add_filter("Paperdoll-project", &["ppd"])
126 .pick_file(),
127 DialogPurpose::Body(_) => dialog
128 .set_title("Lichaamsmodel")
129 .add_filter("Model", &["glb", "gltf", "fbx", "obj"])
130 .pick_file(),
131 DialogPurpose::EquipmentModel(_) => dialog
132 .set_title("Rigide equipment toevoegen")
133 .add_filter("Rigide model", &["obj", "fbx"])
134 .add_filter("Wavefront OBJ", &["obj"])
135 .add_filter("FBX", &["fbx"])
136 .pick_file(),
137 DialogPurpose::EquipmentShiny(_, _) => dialog
138 .set_title("Shinymap")
139 .add_filter("PNG", &["png"])
140 .pick_file(),
141 DialogPurpose::ImportEquipment => dialog
142 .set_title("Equipment-preset importeren")
143 .add_filter("Paperdoll equipment", &["ppe"])
144 .pick_file(),
145 }
146}
147
148pub fn load_candidate(path: &Path) -> PathBuf {
149 if path.is_dir() {
150 path.join(crate::project::MANIFEST_NAME)
151 } else {
152 path.to_path_buf()
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn scripted_dialogs_return_the_queued_path_from_poll() {
162 let mut dialogs = ScriptedDialogs::default();
163 dialogs.push(Some(PathBuf::from("/tmp/project")));
164 assert!(matches!(dialogs.poll(), DialogPoll::Idle));
165 dialogs.start(DialogPurpose::NewProject);
166 match dialogs.poll() {
167 DialogPoll::Ready { purpose, path } => {
168 assert_eq!(purpose, DialogPurpose::NewProject);
169 assert_eq!(path, Some(PathBuf::from("/tmp/project")));
170 }
171 other => panic!("expected a ready dialog, got {other:?}"),
172 }
173 }
174}