Skip to main content

libsurfer/
file_dialog.rs

1use std::future::Future;
2
3#[cfg(not(target_arch = "wasm32"))]
4use camino::Utf8PathBuf;
5use rfd::{AsyncFileDialog, FileHandle};
6use serde::Deserialize;
7use tracing::error;
8#[cfg(all(target_arch = "wasm32", feature = "vscode"))]
9use wasm_bindgen::prelude::*;
10
11use crate::SystemState;
12use crate::async_util::perform_async_work;
13use crate::channels::checked_send_many;
14use crate::message::Message;
15use crate::transactions::TRANSACTIONS_FILE_EXTENSION;
16use crate::wave_source::LoadOptions;
17
18// JS entry points that must be provided by the VS Code extension's webview setup
19// (e.g. inside the SURFER_SETUP_HOOKS block or integration.js).
20//
21// `vscode_show_open_dialog(kind, filters_json)` – asks the extension host to show
22//   a native open-file picker.  `kind` is an opaque tag the host echoes back in
23//   the inject_message it fires once the user confirms:
24//
25//   | kind                       | injected Message                               |
26//   |----------------------------|------------------------------------------------|
27//   | `"waveform_clear"`         | `LoadWaveformFileFromUrl(url, Clear)`          |
28//   | `"waveform_keep_available"`| `LoadWaveformFileFromUrl(url, KeepAvailable)`  |
29//   | `"waveform_keep_all"`      | `LoadWaveformFileFromUrl(url, KeepAll)`        |
30//   | `"command_file"`           | `LoadCommandFileFromUrl(url)`                  |
31//   | `"state_file"`             | `LoadStateFromData(bytes)`                     |
32//
33//   `filters_json` is a JSON array of `{"name":str,"extensions":[str]}` objects.
34//
35#[cfg(all(target_arch = "wasm32", feature = "vscode"))]
36#[wasm_bindgen]
37extern "C" {
38    fn vscode_show_open_dialog(kind: &str, filters_json: &str);
39}
40
41#[derive(Debug, Deserialize)]
42pub enum OpenMode {
43    Open,
44    Switch,
45}
46
47pub(crate) struct FileFilter {
48    name: &'static str,
49    extensions: &'static [&'static str],
50}
51
52static WAVEFORM_FILE_FILTER: FileFilter = FileFilter {
53    name: "Waveform/Transaction-files (*.vcd, *.fst, *.ghw, *.ftr)",
54    extensions: &["vcd", "fst", "ghw", TRANSACTIONS_FILE_EXTENSION],
55};
56
57static COMMAND_FILE_FILTER: FileFilter = FileFilter {
58    name: "Command-file (*.sucl)",
59    extensions: &["sucl"],
60};
61
62pub(crate) static STATE_FILE_FILTER: FileFilter = FileFilter {
63    name: "Surfer state files (*.surf.ron)",
64    extensions: &["surf.ron"],
65};
66
67#[cfg(not(target_arch = "wasm32"))]
68pub(crate) static FST_EXPORT_FILTER: FileFilter = FileFilter {
69    name: "FST files (*.fst)",
70    extensions: &["fst"],
71};
72
73#[cfg(any(
74    target_os = "macos",
75    all(target_arch = "wasm32", not(feature = "vscode"))
76))]
77// Mac OS file dialogs don't support multi-part extensions like `surf.ron`, so we use a different filter for macOS and possibly on WASM.
78pub(crate) static STATE_FILE_FILTER_MACOS: FileFilter = FileFilter {
79    name: "Surfer state files (*.ron)",
80    extensions: &["ron"],
81};
82
83#[cfg(feature = "python")]
84static PYTHON_FILE_FILTER: FileFilter = FileFilter {
85    name: "Python files (*.py)",
86    extensions: &["py"],
87};
88
89impl SystemState {
90    #[cfg(not(target_arch = "wasm32"))]
91    pub(crate) fn file_dialog_open<F>(
92        &mut self,
93        title: &'static str,
94        filter: &'static FileFilter,
95        messages: F,
96    ) where
97        F: FnOnce(Utf8PathBuf) -> Vec<Message> + Send + 'static,
98    {
99        let sender = self.channels.msg_sender.clone();
100
101        perform_async_work(async move {
102            if let Some(file) = create_file_dialog(filter, title).pick_file().await {
103                let path = file.path().to_path_buf();
104                let result = match Utf8PathBuf::from_path_buf(path.clone()) {
105                    Ok(utf8_path) => messages(utf8_path),
106                    Err(_) => {
107                        error!("File path '{}' contains invalid UTF-8", path.display());
108                        vec![]
109                    }
110                };
111                checked_send_many(&sender, result);
112            }
113        });
114    }
115
116    #[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
117    pub(crate) fn file_dialog_open<F>(
118        &mut self,
119        title: &'static str,
120        filter: &'static FileFilter,
121        messages: F,
122    ) where
123        F: FnOnce(Vec<u8>) -> Vec<Message> + 'static,
124    {
125        let sender = self.channels.msg_sender.clone();
126
127        perform_async_work(async move {
128            if let Some(file) = create_file_dialog(filter, title).pick_file().await {
129                checked_send_many(&sender, messages(file.read().await));
130            }
131        });
132    }
133
134    #[cfg(not(target_arch = "wasm32"))]
135    pub(crate) fn file_dialog_save<F, Fut>(
136        &mut self,
137        title: &'static str,
138        filter: &'static FileFilter,
139        default_file_name: Option<String>,
140        messages: F,
141    ) where
142        F: FnOnce(FileHandle) -> Fut + Send + 'static,
143        Fut: Future<Output = Vec<Message>> + Send + 'static,
144    {
145        let sender = self.channels.msg_sender.clone();
146
147        perform_async_work(async move {
148            let mut dialog = create_file_dialog(filter, title);
149            if let Some(file_name) = default_file_name {
150                dialog = dialog.set_file_name(&file_name);
151            }
152            if let Some(file) = dialog.save_file().await {
153                checked_send_many(&sender, messages(file).await);
154            }
155        });
156    }
157
158    #[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
159    pub(crate) fn file_dialog_save<F, Fut>(
160        &mut self,
161        title: &'static str,
162        filter: &'static FileFilter,
163        default_file_name: Option<String>,
164        messages: F,
165    ) where
166        F: FnOnce(FileHandle) -> Fut + 'static,
167        Fut: Future<Output = Vec<Message>> + 'static,
168    {
169        let sender = self.channels.msg_sender.clone();
170
171        perform_async_work(async move {
172            let mut dialog = create_file_dialog(filter, title);
173            if let Some(file_name) = default_file_name {
174                dialog = dialog.set_file_name(&file_name);
175            }
176            if let Some(file) = dialog.save_file().await {
177                checked_send_many(&sender, messages(file).await);
178            }
179        });
180    }
181
182    #[cfg(all(target_arch = "wasm32", feature = "vscode"))]
183    pub(crate) fn open_file_dialog(&mut self, mode: OpenMode) {
184        let load_options: LoadOptions = (mode, self.user.config.behavior.keep_during_reload).into();
185
186        let kind = match load_options {
187            LoadOptions::Clear => "waveform_clear",
188            LoadOptions::KeepAvailable => "waveform_keep_available",
189            LoadOptions::KeepAll => "waveform_keep_all",
190        };
191        vscode_open_dialog_with_filter(kind, &WAVEFORM_FILE_FILTER);
192    }
193
194    #[cfg(not(target_arch = "wasm32"))]
195    pub(crate) fn open_file_dialog(&mut self, mode: OpenMode) {
196        let load_options: LoadOptions = (mode, self.user.config.behavior.keep_during_reload).into();
197
198        let message = move |file: Utf8PathBuf| vec![Message::LoadFile(file, load_options)];
199
200        self.file_dialog_open("Open waveform file", &WAVEFORM_FILE_FILTER, message);
201    }
202
203    #[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
204    pub(crate) fn open_file_dialog(&mut self, mode: OpenMode) {
205        let load_options: LoadOptions = (mode, self.user.config.behavior.keep_during_reload).into();
206
207        let message = move |file: Vec<u8>| vec![Message::LoadFromData(file, load_options)];
208
209        self.file_dialog_open("Open waveform file", &WAVEFORM_FILE_FILTER, message);
210    }
211
212    #[cfg(all(target_arch = "wasm32", feature = "vscode"))]
213    pub(crate) fn open_command_file_dialog(&mut self) {
214        vscode_open_dialog_with_filter("command_file", &COMMAND_FILE_FILTER);
215    }
216
217    #[cfg(not(target_arch = "wasm32"))]
218    pub(crate) fn open_command_file_dialog(&mut self) {
219        let message = move |file: Utf8PathBuf| vec![Message::LoadCommandFile(file)];
220
221        self.file_dialog_open("Open command file", &COMMAND_FILE_FILTER, message);
222    }
223
224    #[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
225    pub(crate) fn open_command_file_dialog(&mut self) {
226        self.file_dialog_open(
227            "Open command file",
228            &COMMAND_FILE_FILTER,
229            |file: Vec<u8>| vec![Message::LoadCommandFromData(file)],
230        );
231    }
232
233    #[cfg(feature = "python")]
234    pub(crate) fn open_python_file_dialog(&mut self) {
235        self.file_dialog_open("Open Python translator file", &PYTHON_FILE_FILTER, |file| {
236            vec![Message::LoadPythonTranslator(file)]
237        });
238    }
239}
240
241#[cfg(not(all(target_arch = "wasm32", feature = "vscode")))]
242#[cfg(not(target_os = "macos"))]
243fn create_file_dialog(filter: &'static FileFilter, title: &'static str) -> AsyncFileDialog {
244    AsyncFileDialog::new()
245        .set_title(title)
246        .add_filter(filter.name, filter.extensions)
247        .add_filter("All files", &["*"])
248}
249
250#[cfg(not(all(target_arch = "wasm32", feature = "vscode")))]
251#[cfg(target_os = "macos")]
252fn create_file_dialog(filter: &'static FileFilter, title: &'static str) -> AsyncFileDialog {
253    AsyncFileDialog::new()
254        .set_title(title)
255        .add_filter(filter.name, &filter.extensions)
256}
257
258/// Serialise a `(name, extensions)` filter pair into the JSON array expected by
259/// `vscode_show_open_dialog`.
260///
261/// Example output: `[{"name":"Waveform files","extensions":["vcd","fst"]}]`
262#[cfg(all(target_arch = "wasm32", feature = "vscode"))]
263pub(crate) fn vscode_open_dialog_with_filter(kind: &str, filter: &'static FileFilter) {
264    let filters_json = filters_to_json(filter);
265    vscode_show_open_dialog(kind, &filters_json);
266}
267
268#[cfg(all(target_arch = "wasm32", feature = "vscode"))]
269fn filters_to_json(filter: &'static FileFilter) -> String {
270    let exts = filter
271        .extensions
272        .iter()
273        .map(|e| format!("{e:?}"))
274        .collect::<Vec<_>>()
275        .join(",");
276    format!("[{{\"name\":{:?},\"extensions\":[{exts}]}}]", filter.name)
277}