Skip to main content

libsurfer/
file_dialog.rs

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