1use std::cell::{Cell, RefCell};
2use std::collections::HashMap;
3
4pub struct FileFilter {
5 pub name: String,
6 pub extensions: Vec<String>,
7}
8
9pub enum FileError {
10 NotFound(String),
11 ReadError(String),
12 WriteError(String),
13}
14
15impl std::fmt::Display for FileError {
16 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 match self {
18 FileError::NotFound(path) => write!(formatter, "File not found: {}", path),
19 FileError::ReadError(message) => write!(formatter, "Read error: {}", message),
20 FileError::WriteError(message) => write!(formatter, "Write error: {}", message),
21 }
22 }
23}
24
25pub struct LoadedFile {
26 pub name: String,
27 pub bytes: Vec<u8>,
28}
29
30thread_local! {
31 static COMPLETED_LOADS: RefCell<HashMap<u64, LoadedFile>> = RefCell::new(HashMap::new());
32 static NEXT_LOAD_ID: Cell<u64> = const { Cell::new(1) };
33}
34
35fn allocate_load_id() -> u64 {
36 NEXT_LOAD_ID.with(|cell| {
37 let id = cell.get();
38 cell.set(id + 1);
39 id
40 })
41}
42
43fn store_completed_load(id: u64, file: LoadedFile) {
44 COMPLETED_LOADS.with(|cell| {
45 cell.borrow_mut().insert(id, file);
46 });
47}
48
49#[derive(Clone, Copy)]
50pub struct PendingFileLoad {
51 id: u64,
52}
53
54impl PendingFileLoad {
55 pub fn empty() -> Self {
56 Self {
57 id: allocate_load_id(),
58 }
59 }
60
61 pub fn ready(file: LoadedFile) -> Self {
62 let id = allocate_load_id();
63 store_completed_load(id, file);
64 Self { id }
65 }
66
67 pub fn is_ready(&self) -> bool {
68 COMPLETED_LOADS.with(|cell| cell.borrow().contains_key(&self.id))
69 }
70
71 pub fn take(&self) -> Option<LoadedFile> {
72 COMPLETED_LOADS.with(|cell| cell.borrow_mut().remove(&self.id))
73 }
74}
75
76#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
77pub fn pick_file(filters: &[FileFilter]) -> Option<std::path::PathBuf> {
78 let mut dialog = rfd::FileDialog::new();
79 for filter in filters {
80 let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
81 dialog = dialog.add_filter(&filter.name, &extensions);
82 }
83 dialog.pick_file()
84}
85
86#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
87pub fn pick_folder() -> Option<std::path::PathBuf> {
88 rfd::FileDialog::new().pick_folder()
89}
90
91#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
92pub fn save_file_dialog(
93 filters: &[FileFilter],
94 default_filename: Option<&str>,
95) -> Option<std::path::PathBuf> {
96 let mut dialog = rfd::FileDialog::new();
97 if let Some(filename) = default_filename {
98 dialog = dialog.set_file_name(filename);
99 }
100 for filter in filters {
101 let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
102 dialog = dialog.add_filter(&filter.name, &extensions);
103 }
104 dialog.save_file()
105}
106
107#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
108pub fn read_file(path: &std::path::Path) -> Result<Vec<u8>, FileError> {
109 if !path.exists() {
110 return Err(FileError::NotFound(path.display().to_string()));
111 }
112 std::fs::read(path).map_err(|error| FileError::ReadError(error.to_string()))
113}
114
115#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
116pub fn write_file(path: &std::path::Path, bytes: &[u8]) -> Result<(), FileError> {
117 if let Some(parent) = path.parent() {
118 std::fs::create_dir_all(parent)
119 .map_err(|error| FileError::WriteError(error.to_string()))?;
120 }
121 std::fs::write(path, bytes).map_err(|error| FileError::WriteError(error.to_string()))
122}
123
124#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
125pub fn save_file(filename: &str, data: &[u8], filters: &[FileFilter]) -> Result<(), FileError> {
126 let mut dialog = rfd::FileDialog::new().set_file_name(filename);
127 for filter in filters {
128 let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
129 dialog = dialog.add_filter(&filter.name, &extensions);
130 }
131 match dialog.save_file() {
132 Some(path) => write_file(&path, data),
133 None => Ok(()),
134 }
135}
136
137#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
138pub fn request_file_load(filters: &[FileFilter]) -> PendingFileLoad {
139 let mut dialog = rfd::FileDialog::new();
140 for filter in filters {
141 let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
142 dialog = dialog.add_filter(&filter.name, &extensions);
143 }
144 match dialog.pick_file() {
145 Some(path) => {
146 let name = path
147 .file_name()
148 .map(|os_name| os_name.to_string_lossy().to_string())
149 .unwrap_or_default();
150 match std::fs::read(&path) {
151 Ok(bytes) => PendingFileLoad::ready(LoadedFile { name, bytes }),
152 Err(_) => PendingFileLoad::empty(),
153 }
154 }
155 None => PendingFileLoad::empty(),
156 }
157}
158
159#[cfg(not(target_arch = "wasm32"))]
160pub fn open_directory(path: &std::path::Path) {
161 let directory = if path.is_dir() {
162 path
163 } else {
164 path.parent().unwrap_or(path)
165 };
166
167 #[cfg(target_os = "windows")]
168 {
169 let _ = std::process::Command::new("explorer")
170 .arg(directory)
171 .spawn();
172 }
173
174 #[cfg(target_os = "macos")]
175 {
176 let _ = std::process::Command::new("open").arg(directory).spawn();
177 }
178
179 #[cfg(target_os = "linux")]
180 {
181 let _ = std::process::Command::new("xdg-open")
182 .arg(directory)
183 .spawn();
184 }
185}
186
187#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
188pub fn save_file(filename: &str, data: &[u8], _filters: &[FileFilter]) -> Result<(), FileError> {
189 use wasm_bindgen::JsCast;
190
191 let window =
192 web_sys::window().ok_or_else(|| FileError::WriteError("No window object".to_string()))?;
193 let document = window
194 .document()
195 .ok_or_else(|| FileError::WriteError("No document object".to_string()))?;
196
197 let uint8_array = js_sys::Uint8Array::from(data);
198 let parts = js_sys::Array::new();
199 parts.push(&uint8_array);
200
201 let options = web_sys::BlobPropertyBag::new();
202 options.set_type("application/octet-stream");
203
204 let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)
205 .map_err(|_| FileError::WriteError("Failed to create Blob".to_string()))?;
206
207 let url = web_sys::Url::create_object_url_with_blob(&blob)
208 .map_err(|_| FileError::WriteError("Failed to create object URL".to_string()))?;
209
210 let anchor: web_sys::HtmlAnchorElement = document
211 .create_element("a")
212 .map_err(|_| FileError::WriteError("Failed to create anchor element".to_string()))?
213 .dyn_into()
214 .map_err(|_| FileError::WriteError("Failed to cast to anchor".to_string()))?;
215
216 anchor.set_href(&url);
217 anchor.set_download(filename);
218 anchor.click();
219
220 let _ = web_sys::Url::revoke_object_url(&url);
221 Ok(())
222}
223
224#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
225pub fn request_file_load(filters: &[FileFilter]) -> PendingFileLoad {
226 use wasm_bindgen::JsCast;
227 use wasm_bindgen::prelude::*;
228
229 let pending = PendingFileLoad::empty();
230 let pending_id = pending.id;
231
232 let Some(window) = web_sys::window() else {
233 return pending;
234 };
235 let Some(document) = window.document() else {
236 return pending;
237 };
238 let Some(input) = document
239 .create_element("input")
240 .ok()
241 .and_then(|element| element.dyn_into::<web_sys::HtmlInputElement>().ok())
242 else {
243 return pending;
244 };
245
246 input.set_type("file");
247
248 let accept: String = filters
249 .iter()
250 .flat_map(|filter| {
251 filter
252 .extensions
253 .iter()
254 .map(|extension| format!(".{}", extension))
255 })
256 .collect::<Vec<_>>()
257 .join(",");
258 if !accept.is_empty() {
259 input.set_accept(&accept);
260 }
261
262 let input_for_closure = input.clone();
263 let closure = Closure::once(Box::new(move |_event: web_sys::Event| {
264 let captured_input = input_for_closure;
265
266 wasm_bindgen_futures::spawn_local(async move {
267 if let Some(files) = captured_input.files()
268 && let Some(file) = files.get(0)
269 {
270 let name = file.name();
271 let array_buffer_promise = file.array_buffer();
272 if let Ok(array_buffer) =
273 wasm_bindgen_futures::JsFuture::from(array_buffer_promise).await
274 {
275 let uint8_array = js_sys::Uint8Array::new(&array_buffer);
276 let bytes = uint8_array.to_vec();
277 store_completed_load(pending_id, LoadedFile { name, bytes });
278 }
279 }
280 });
281 }) as Box<dyn FnOnce(_)>);
282
283 input.set_onchange(Some(closure.as_ref().unchecked_ref()));
284 closure.forget();
285 input.click();
286
287 pending
288}