Skip to main content

brep_app/
javascript.rs

1//! In-app scratch scripts. Source is saved only on request and never on a model.
2use crate::{
3    document::Documents,
4    store::{ModelStore, JAVASCRIPT_DRAFT_KEY},
5};
6use crate::automation::hit_keys::HitKeyDoc;
7use eframe::egui;
8use std::collections::HashMap;
9
10/// The window's egui id — one name for the panel, the tests and the host.
11pub const WINDOW_ID: &str = "brep-javascript-editor";
12
13/// The hit keys this window publishes under `__brepJavascriptHit`
14/// (see `automation::hit_keys`); `panel:clip` is the key every scrolling pane
15/// publishes for its visible region, so `click_widget` can wheel a row into view.
16pub static HIT_KEYS: &[HitKeyDoc] = &[
17    HitKeyDoc { panel: "javascript", prefix: "panel:clip", meaning: "the JavaScript editor window's OUTER rect — its whole visible region, frame included, so the title and grip handles below count as in view", command: None },
18    HitKeyDoc { panel: "javascript", prefix: "title", meaning: "a point on the window's title bar — a drag from it MOVES the window", command: None },
19    HitKeyDoc { panel: "javascript", prefix: "grip", meaning: "a point on the window's bottom-right resize corner — a drag from it RESIZES the window in both axes, up to the app surface", command: None },
20    HitKeyDoc { panel: "javascript", prefix: "run", meaning: "run the script in the active document", command: None },
21    HitKeyDoc { panel: "javascript", prefix: "cancel", meaning: "cancel the running script", command: None },
22    HitKeyDoc { panel: "javascript", prefix: "save", meaning: "save the source as the local draft", command: None },
23    HitKeyDoc { panel: "javascript", prefix: "open", meaning: "open a .js file into the editor (native; file-interchange stores only)", command: None },
24    HitKeyDoc { panel: "javascript", prefix: "export", meaning: "export the source as script.js through the file dialog's export door: the Save-file chooser on desktop, a download in the browser", command: None },
25    HitKeyDoc { panel: "javascript", prefix: "help", meaning: "the CAD API and examples collapsing header", command: None },
26    HitKeyDoc { panel: "javascript", prefix: "example", meaning: "load the box example into the editor (inside `help`)", command: None },
27    HitKeyDoc { panel: "javascript", prefix: "source", meaning: "the source editor's scroll region (the free space of the window: it grows with the window)", command: None },
28    HitKeyDoc { panel: "javascript", prefix: "output", meaning: "the output's scroll region, at the bottom of the window", command: None },
29];
30
31const EXAMPLE: &str = r#"// Run adds a box to the current document. Undo removes the whole run.
32cad.document.addFeature("P.CU", { sizeX: 30, sizeY: 20, sizeZ: 10 });
33console.log("Created a box");
34ui.notify("Edit its dimensions in the feature history");
35return { featuresBefore: cad.document.features.length };
36"#;
37
38pub struct JavaScriptEditor {
39    pub open: bool,
40    source: String,
41    output: String,
42    pending: Option<u64>, // originating document, retained across tab switches
43    importing: bool,
44    /// This frame's widget rects (egui points) for the automation host; empty
45    /// while the window is closed.
46    hits: HashMap<String, egui::Rect>,
47    /// The window's outer rect this frame, `None` while it is closed.
48    rect: Option<egui::Rect>,
49    /// The surface the window may fill (`ctx.content_rect()`), for the host.
50    surface: egui::Rect,
51    /// "Export .js…" asked: the shell hands these to the file dialog's export
52    /// door ([`Self::take_save_requests`]), which on desktop asks where.
53    exports: Vec<crate::panels::file::SaveRequest>,
54}
55
56
57impl JavaScriptEditor {
58    pub fn running_in(&self, document: u64) -> bool {
59        self.pending == Some(document)
60    }
61
62    pub fn editing_source(ctx: &egui::Context) -> bool {
63        ctx.memory(|memory| memory.has_focus(egui::Id::new("cad-javascript-source")))
64    }
65
66    pub fn new(store: &dyn ModelStore) -> Self {
67        Self {
68            open: false,
69            source: store
70                .read(JAVASCRIPT_DRAFT_KEY)
71                .unwrap_or_else(|| EXAMPLE.into()),
72            output: String::new(),
73            pending: None,
74            importing: false,
75            hits: HashMap::new(),
76            exports: Vec::new(),
77            rect: None,
78            surface: egui::Rect::NOTHING,
79        }
80    }
81
82    /// Logical state for the automation host: `rect` is the window's outer
83    /// rect `[x, y, w, h]` in egui points (null while closed), `surface` the
84    /// screen rect it may fill `[w, h]`.
85    pub fn state_json(&self) -> String {
86        let rect = |r: egui::Rect| serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]);
87        serde_json::json!({
88            "open": self.open,
89            "rect": self.rect.map(rect),
90            "surface": [self.surface.width(), self.surface.height()],
91            "running": self.pending.is_some(),
92            "output": self.output,
93        })
94        .to_string()
95    }
96
97    /// The published widget hit-rects (`[x, y, w, h]`, egui points).
98    pub fn hits_json(&self) -> String {
99        crate::automation::hit_rects::hits_json(&self.hits)
100    }
101
102    /// Record a widget's VISIBLE rect for the host.
103    fn hit(&mut self, key: &str, resp: &egui::Response) {
104        self.hits.insert(key.to_string(), resp.interact_rect);
105    }
106
107    pub fn poll(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
108        if self.importing {
109            if let Some(file) = store.take_import() {
110                self.importing = false;
111                match String::from_utf8(file.bytes) {
112                    Ok(source) => self.source = source,
113                    Err(e) => self.output = format!("Cannot open script: {e}"),
114                }
115            }
116        }
117        let Some(id) = self.pending else {
118            return;
119        };
120        let Some(doc) = docs.iter_mut().find(|doc| doc.id() == id) else {
121            self.pending = None;
122            self.output = "The script's document was closed.".into();
123            return;
124        };
125        doc.engine.pump();
126        let status = doc.engine.plugin_action_status();
127        match status["state"].as_str() {
128            Some("complete") => {
129                let lines: Vec<_> = status["result"]["notifications"]
130                    .as_array()
131                    .into_iter()
132                    .flatten()
133                    .filter_map(|v| v.as_str())
134                    .collect();
135                self.output = format!("Completed.\n{}", lines.join("\n"));
136                self.pending = None;
137            }
138            Some("error") => {
139                self.output = status["error"].as_str().unwrap_or("Script failed").into();
140                self.pending = None;
141            }
142            _ => {
143                ctx.request_repaint_after(std::time::Duration::from_millis(30));
144            }
145        }
146    }
147
148    /// The files "Export .js…" asked to save, once: the shell hands them to
149    /// [`crate::panels::file::FileDialog::request_save_file`].
150    pub fn take_save_requests(&mut self) -> Vec<crate::panels::file::SaveRequest> {
151        std::mem::take(&mut self.exports)
152    }
153
154    pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
155        self.hits.clear();
156        self.rect = None;
157        self.surface = ctx.content_rect();
158        let mut open = self.open;
159        let shown = egui::Window::new("JavaScript editor").id(egui::Id::new(WINDOW_ID)).open(&mut open)
160            .default_size(egui::vec2(720.0, 620.0)).min_size(egui::vec2(420.0, 320.0)).resizable(true).show(ctx, |ui| {
161            ui.label(format!("Run in: {}", docs.active().title()));
162            if let Some(id) = self.pending {
163                if let Some(doc) = docs.iter().find(|doc| doc.id() == id) {
164                    ui.horizontal(|ui| {
165                        ui.spinner();
166                        ui.label(format!("Running in: {}", doc.title()));
167                    });
168                }
169            }
170            ui.horizontal(|ui| {
171                let busy = self.pending.is_some() || docs.engine().run_pending()
172                    || docs.engine().plugin_action_status()["state"] == "pending";
173                let run = ui.add_enabled(!busy, egui::Button::new("Run script"));
174                self.hit("run", &run);
175                if run.clicked() {
176                    match docs.engine_mut().run_javascript(&self.source) {
177                        Ok(_) => { self.pending = Some(docs.active_id()); self.output = "Running…".into(); }
178                        Err(e) => self.output = e,
179                    }
180                }
181                let cancel = ui.add_enabled(self.pending.is_some(), egui::Button::new("Cancel"));
182                self.hit("cancel", &cancel);
183                if cancel.clicked() {
184                    if let Some(id) = self.pending {
185                        if let Some(doc) = docs.iter_mut().find(|doc| doc.id() == id) {
186                            doc.engine.cancel_plugin_action();
187                        }
188                    }
189                }
190                let save = ui.button("Save draft");
191                self.hit("save", &save);
192                if save.clicked() {
193                    self.output = match store.write(JAVASCRIPT_DRAFT_KEY, &self.source) {
194                        Ok(()) => "Draft saved.".into(), Err(e) => format!("Cannot save draft: {e}"),
195                    };
196                }
197                // Open .js… drives the browser's file input, so only where
198                // there is one. Export leaves through the file dialog's export
199                // door on every platform (the Save-file chooser on desktop),
200                // so it is always offered: gated with Open, desktop had no
201                // way to export a script at all.
202                if store.supports_file_interchange() {
203                    let open = ui.add_enabled(!self.importing, egui::Button::new("Open .js…"));
204                    self.hit("open", &open);
205                    if open.clicked() {
206                        match store.begin_import_filtered(("JavaScript", &["js"])) {
207                            Ok(()) => self.importing = true, Err(e) => self.output = e,
208                        }
209                    }
210                }
211                let export = ui.button("Export .js…");
212                self.hit("export", &export);
213                if export.clicked() {
214                    self.exports.push(crate::panels::file::SaveRequest::text("Export the script", "script.js", self.source.clone()));
215                }
216            });
217            let help = ui.collapsing("CAD API and examples", |ui| {
218                ui.label("Write JavaScript statements. cad.document is a snapshot of the current history; cad.selection contains the current selection.");
219                ui.monospace("cad.document.addFeature(type, params)\ncad.document.updateFeature(id, params)\ncad.document.deleteFeature(id)\nconsole.log / info / warn / error(...)\nui.notify(text)\nreturn { any: 'JSON result' };");
220                ui.label("Built-in types include P.CU (sizeX, sizeY, sizeZ), P.S (radius), and P.CY (radius, height). Successful changes form one undo step. Each Run executes the script again.");
221                let example = ui.button("Load box example");
222                if example.clicked() { self.source = EXAMPLE.into(); }
223                example
224            });
225            self.hits.insert("help".into(), help.header_response.interact_rect);
226            if let Some(example) = help.body_returned {
227                self.hits.insert("example".into(), example.interact_rect);
228            }
229            // The rest of the window is the editor over the output. Two panels
230            // rather than two capped scroll areas: egui sizes a window's axis to
231            // its CONTENT (its Resize is not resizable, the content ui merely
232            // gets the dragged size as its max rect), so content that does not
233            // fill the dragged size snaps the window back to the content's
234            // height. Panels fill whatever the window is given, so the window
235            // follows the drag — up to the surface, which egui clamps to.
236            // The output panel keeps its own height (draggable at its top edge)
237            // and the editor takes everything above it.
238            egui::containers::panel::Panel::bottom(egui::Id::new("brep-javascript-output"))
239                .resizable(true)
240                .default_size(160.0)
241                .min_size(48.0)
242                .frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 4)))
243                .show_inside(ui, |ui| {
244                    ui.label("Output");
245                    let output = egui::ScrollArea::both().id_salt("javascript-output").auto_shrink(false).show(ui, |ui| {
246                        ui.add(egui::Label::new(egui::RichText::new(&self.output).monospace()).selectable(true));
247                    });
248                    self.hits.insert("output".into(), output.inner_rect);
249                });
250            egui::CentralPanel::default().frame(egui::Frame::NONE).show_inside(ui, |ui| {
251                // The text box fills the free space, so a click in the empty
252                // area under a short script still lands in the editor. By ROWS:
253                // egui's `TextEdit::min_size` honours only the width, and the
254                // box's height is its rows (or its text, whichever is taller).
255                // Floored, so a box that fills the space never scrolls by the
256                // odd point.
257                let free = ui.available_height();
258                let row = ui.fonts_mut(|f| f.row_height(&egui::TextStyle::Monospace.resolve(ui.style())));
259                let rows = ((free - 4.0) / row).floor().max(4.0) as usize;
260                let source = egui::ScrollArea::both().id_salt("javascript-source").auto_shrink(false).show(ui, |ui| {
261                    ui.add(egui::TextEdit::multiline(&mut self.source).code_editor()
262                        .id(egui::Id::new("cad-javascript-source"))
263                        .desired_rows(rows).desired_width(f32::INFINITY));
264                });
265                self.hits.insert("source".into(), source.inner_rect);
266            });
267        });
268        // Published only while OPEN: egui keeps returning the window through its
269        // fade-out, and a host reading a rect for a window the user just closed
270        // would drag on nothing.
271        if let (Some(shown), true) = (shown, open) {
272            let rect = shown.response.rect;
273            self.rect = Some(rect);
274            // The window's handles, as points egui's own hit zones answer to: the
275            // title bar's centre (a drag moves the window) and a point just inside
276            // the bottom-right corner, within the corner grab radius (a drag
277            // resizes). `panel:clip` is the outer rect so both read as in view.
278            self.hits.insert("panel:clip".into(), rect);
279            self.hits.insert("title".into(), egui::Rect::from_center_size(egui::pos2(rect.center().x, rect.min.y + 12.0), egui::Vec2::ZERO));
280            self.hits.insert("grip".into(), egui::Rect::from_center_size(rect.max - egui::vec2(3.0, 3.0), egui::Vec2::ZERO));
281        } else {
282            self.hits.clear();
283        }
284        self.open = open;
285    }
286}