1use 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
10pub const WINDOW_ID: &str = "brep-javascript-editor";
12
13pub 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 trait ScriptHost {
44 fn ready(&self) -> Result<bool, String>;
46 fn run(&self, source: &str) -> Result<String, String>;
48 fn result(&self, id: &str) -> Result<Option<serde_json::Value>, String>;
50 fn halt(&self, id: &str) -> Result<(), String>;
52}
53
54enum Script {
58 Starting { doc: u64, program: String },
59 Running { doc: u64, id: String, token: u64 },
60 Closing { doc: u64, token: u64, rollback: bool, output: String, since: web_time::Instant, idle: u32 },
63}
64
65const CLOSE_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
68
69const CLOSE_IDLE_POLLS: u32 = 5;
75const CLOSE_SETTLE: std::time::Duration = std::time::Duration::from_millis(250);
76
77pub fn program(source: &str) -> String {
86 if source.lines().any(|l| l.starts_with("import ") || l.starts_with("export ")) {
87 return source.to_string();
88 }
89 format!(
90 r#"import {{ brep }} from "@brep/cad";
91export default async function main() {{
92 const commands = [];
93 const stage = c => {{ if (commands.length >= 256) throw Error("too many document commands"); commands.push(c); }};
94 const snapshot = (await brep.document.doc_json({{}})).document;
95 const selection = (await brep.scene.selection({{}})).selection;
96 const document = Object.freeze({{
97 ...snapshot,
98 addFeature: (type, params = {{}}, persistentData = {{}}) => stage(() => brep.features.feature_add(Object.keys(persistentData).length ? {{ type, params, persistent_data: persistentData }} : {{ type, params }})),
99 updateFeature: (id, params) => stage(() => brep.features.feature_set_params({{ id, patch: params }})),
100 deleteFeature: id => stage(() => brep.history.feature_delete({{ id }})),
101 }});
102 const notify = message => console.log(String(message));
103 const cad = Object.freeze({{ document, selection, params: {{}}, notify, brep }});
104 const ui = Object.freeze({{ notify }});
105 const result = await (async function (cad, ui) {{
106{source}
107 }})(cad, ui);
108 for (const command of commands) await command();
109 if (result !== undefined) console.log("Result:", JSON.stringify(result));
110 return null;
111}}
112"#
113 )
114}
115
116fn script_output(result: &serde_json::Value) -> String {
119 let mut lines = vec![match result["state"].as_str() {
120 Some("succeeded") => "Completed.".to_string(),
121 Some("cancelled") => "Cancelled.".to_string(),
122 _ => format!("Failed: {}", result["error"]["message"].as_str().unwrap_or("the script failed")),
123 }];
124 if let Some(log) = result["logText"].as_str().filter(|l| !l.is_empty()) {
125 lines.push(log.to_string());
126 }
127 if !result["output"].is_null() {
128 lines.push(format!("Result: {}", result["output"]));
129 }
130 lines.join("\n")
131}
132
133pub struct JavaScriptEditor {
134 pub open: bool,
135 source: String,
136 output: String,
137 pending: Option<u64>, pub host: Option<Box<dyn ScriptHost>>,
140 script: Option<Script>,
141 importing: bool,
142 hits: HashMap<String, egui::Rect>,
145 rect: Option<egui::Rect>,
147 surface: egui::Rect,
149 exports: Vec<crate::panels::file::SaveRequest>,
152}
153
154
155impl JavaScriptEditor {
156 pub fn running_in(&self, document: u64) -> bool {
157 self.pending == Some(document)
158 }
159
160 pub fn set_source(&mut self, source: String) {
161 self.source = source;
162 }
163
164 pub fn editing_source(ctx: &egui::Context) -> bool {
165 ctx.memory(|memory| memory.has_focus(egui::Id::new("cad-javascript-source")))
166 }
167
168 pub fn new(store: &dyn ModelStore) -> Self {
169 Self {
170 open: false,
171 source: store
172 .read(JAVASCRIPT_DRAFT_KEY)
173 .unwrap_or_else(|| EXAMPLE.into()),
174 output: String::new(),
175 pending: None,
176 host: None,
177 script: None,
178 importing: false,
179 hits: HashMap::new(),
180 exports: Vec::new(),
181 rect: None,
182 surface: egui::Rect::NOTHING,
183 }
184 }
185
186 pub fn state_json(&self) -> String {
190 let rect = |r: egui::Rect| serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]);
191 serde_json::json!({
192 "open": self.open,
193 "rect": self.rect.map(rect),
194 "surface": [self.surface.width(), self.surface.height()],
195 "running": self.pending.is_some(),
196 "output": self.output,
197 })
198 .to_string()
199 }
200
201 pub fn hits_json(&self) -> String {
203 crate::automation::hit_rects::hits_json(&self.hits)
204 }
205
206 fn halt_script(&mut self, docs: &mut Documents, output: String) {
209 match self.script.take() {
210 Some(Script::Starting { .. }) => {
211 self.pending = None;
212 self.output = output;
213 }
214 Some(Script::Running { doc, id, token }) => {
215 if let Some(host) = &self.host {
216 let _ = host.halt(&id);
217 }
218 if let Some(d) = docs.iter_mut().find(|d| d.id() == doc) {
221 d.engine.cancel_run();
222 }
223 self.script = Some(Script::Closing { doc, token, rollback: true, output, since: web_time::Instant::now(), idle: 0 });
224 }
225 other => self.script = other,
226 }
227 }
228
229 fn poll_script(&mut self, ctx: &egui::Context, docs: &mut Documents) {
231 ctx.request_repaint_after(std::time::Duration::from_millis(30));
232 let doc_id = match &self.script {
233 Some(Script::Starting { doc, .. } | Script::Running { doc, .. } | Script::Closing { doc, .. }) => *doc,
234 None => return,
235 };
236 if !docs.iter().any(|d| d.id() == doc_id) {
237 if let (Some(Script::Running { id, .. }), Some(host)) = (&self.script, &self.host) {
238 let _ = host.halt(id);
239 }
240 self.script = None;
241 self.pending = None;
242 self.output = "The script's document was closed.".into();
243 return;
244 }
245 if docs.active_id() != doc_id && matches!(self.script, Some(Script::Starting { .. } | Script::Running { .. })) {
248 return self.halt_script(docs, "Halted: the active document changed while the script ran; its edits were rolled back.".into());
249 }
250 let Some(host) = &self.host else { return };
251 let doc = docs.iter_mut().find(|d| d.id() == doc_id).expect("checked above");
252 match self.script.take() {
253 Some(Script::Starting { doc: d, program }) => match host.ready() {
254 Ok(true) => {
255 let token = doc.engine.history_group_begin();
256 match host.run(&program) {
257 Ok(id) => self.script = Some(Script::Running { doc: d, id, token }),
258 Err(e) => {
259 let _ = doc.engine.history_group_end(token, false);
260 self.pending = None;
261 self.output = e;
262 }
263 }
264 }
265 Ok(false) => self.script = Some(Script::Starting { doc: d, program }),
266 Err(e) => {
267 self.pending = None;
268 self.output = e;
269 }
270 },
271 Some(Script::Running { doc: d, id, token }) => match host.result(&id) {
272 Ok(None) => self.script = Some(Script::Running { doc: d, id, token }),
273 Ok(Some(result)) => {
274 let rollback = result["state"] != "succeeded";
275 let output = script_output(&result);
276 self.script = Some(Script::Closing { doc: d, token, rollback, output, since: web_time::Instant::now(), idle: 0 });
277 }
278 Err(e) => self.script = Some(Script::Closing { doc: d, token, rollback: true, output: e, since: web_time::Instant::now(), idle: 0 }),
279 },
280 Some(Script::Closing { doc: d, token, rollback, output, since, idle }) => {
281 doc.engine.pump();
282 let quiet = !doc.engine.run_pending() && !doc.engine.stage_feature_pending();
283 let idle = if quiet { idle + 1 } else { 0 };
284 let settled = idle >= CLOSE_IDLE_POLLS && since.elapsed() >= CLOSE_SETTLE;
285 if !settled && since.elapsed() < CLOSE_WAIT {
286 self.script = Some(Script::Closing { doc: d, token, rollback, output, since, idle });
287 return;
288 }
289 self.pending = None;
290 self.output = match doc.engine.history_group_end(token, rollback) {
291 Ok(true) => format!("{output}\nIts edits were rolled back."),
292 Ok(false) => output,
293 Err(e) => format!("{output}\n{e}"),
294 };
295 }
296 None => {}
297 }
298 }
299
300 fn hit(&mut self, key: &str, resp: &egui::Response) {
302 self.hits.insert(key.to_string(), resp.interact_rect);
303 }
304
305 pub fn poll(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
306 if self.importing {
307 if let Some(file) = store.take_import() {
308 self.importing = false;
309 match String::from_utf8(file.bytes) {
310 Ok(source) => self.source = source,
311 Err(e) => self.output = format!("Cannot open script: {e}"),
312 }
313 }
314 }
315 if self.script.is_some() {
316 return self.poll_script(ctx, docs);
317 }
318 let Some(id) = self.pending else {
319 return;
320 };
321 let Some(doc) = docs.iter_mut().find(|doc| doc.id() == id) else {
322 self.pending = None;
323 self.output = "The script's document was closed.".into();
324 return;
325 };
326 doc.engine.pump();
327 let status = doc.engine.plugin_action_status();
328 match status["state"].as_str() {
329 Some("complete") => {
330 let lines: Vec<_> = status["result"]["notifications"]
331 .as_array()
332 .into_iter()
333 .flatten()
334 .filter_map(|v| v.as_str())
335 .collect();
336 self.output = format!("Completed.\n{}", lines.join("\n"));
337 self.pending = None;
338 }
339 Some("error") => {
340 self.output = status["error"].as_str().unwrap_or("Script failed").into();
341 self.pending = None;
342 }
343 _ => {
344 ctx.request_repaint_after(std::time::Duration::from_millis(30));
345 }
346 }
347 }
348
349 pub fn take_save_requests(&mut self) -> Vec<crate::panels::file::SaveRequest> {
352 std::mem::take(&mut self.exports)
353 }
354
355 pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
356 self.hits.clear();
357 self.rect = None;
358 self.surface = ctx.content_rect();
359 let mut open = self.open;
360 let shown = egui::Window::new("JavaScript editor").id(egui::Id::new(WINDOW_ID)).open(&mut open)
361 .default_pos(crate::panels::below_chrome(ctx, 16.0, 0.0))
364 .default_size(egui::vec2(720.0, 620.0)).min_size(egui::vec2(420.0, 320.0)).resizable(true).show(ctx, |ui| {
365 ui.label(format!("Run in: {}", docs.active().title()));
366 if let Some(id) = self.pending {
367 if let Some(doc) = docs.iter().find(|doc| doc.id() == id) {
368 ui.horizontal(|ui| {
369 ui.spinner();
370 ui.label(format!("Running in: {}", doc.title()));
371 });
372 }
373 }
374 ui.horizontal(|ui| {
375 let busy = self.pending.is_some() || docs.engine().run_pending()
376 || docs.engine().plugin_action_status()["state"] == "pending";
377 let run = ui.add_enabled(!busy, egui::Button::new("Run script"));
378 self.hit("run", &run);
379 if run.clicked() && self.host.is_some() && !self.source.contains("cad.geometry") {
382 self.pending = Some(docs.active_id());
383 self.script = Some(Script::Starting { doc: docs.active_id(), program: program(&self.source) });
384 self.output = "Running…".into();
385 } else if run.clicked() {
386 match docs.engine_mut().run_javascript(&self.source) {
387 Ok(_) => { self.pending = Some(docs.active_id()); self.output = "Running…".into(); }
388 Err(e) => self.output = e,
389 }
390 }
391 let cancel = ui.add_enabled(self.pending.is_some(), egui::Button::new("Cancel"));
392 self.hit("cancel", &cancel);
393 if cancel.clicked() && self.script.is_some() {
394 self.halt_script(docs, "Cancelled.".into());
395 } else if cancel.clicked() {
396 if let Some(id) = self.pending {
397 if let Some(doc) = docs.iter_mut().find(|doc| doc.id() == id) {
398 doc.engine.cancel_plugin_action();
399 }
400 }
401 }
402 let save = ui.button("Save draft");
403 self.hit("save", &save);
404 if save.clicked() {
405 self.output = match store.write(JAVASCRIPT_DRAFT_KEY, &self.source) {
406 Ok(()) => "Draft saved.".into(), Err(e) => format!("Cannot save draft: {e}"),
407 };
408 }
409 if store.supports_file_interchange() {
415 let open = ui.add_enabled(!self.importing, egui::Button::new("Open .js…"));
416 self.hit("open", &open);
417 if open.clicked() {
418 match store.begin_import_filtered(("JavaScript", &["js"])) {
419 Ok(()) => self.importing = true, Err(e) => self.output = e,
420 }
421 }
422 }
423 let export = ui.button("Export .js…");
424 self.hit("export", &export);
425 if export.clicked() {
426 self.exports.push(crate::panels::file::SaveRequest::text("Export the script", "script.js", self.source.clone()));
427 }
428 });
429 let help = ui.collapsing("CAD API and examples", |ui| {
430 ui.label("Write JavaScript statements. cad.document is a snapshot of the current history; cad.selection contains the current selection.");
431 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' };");
432 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.");
433 if self.host.is_some() {
434 ui.label("Every app tool is a function too: await cad.brep.<group>.<tool>(args), the same functions an agent's run_script gets (typed in the server's brep://cad/sdk.d.ts resource). A program that starts with import { brep } from \"@brep/cad\" and export default async function main() runs as written. A failed or cancelled run is rolled back.");
435 }
436 let example = ui.button("Load box example");
437 if example.clicked() { self.source = EXAMPLE.into(); }
438 example
439 });
440 self.hits.insert("help".into(), help.header_response.interact_rect);
441 if let Some(example) = help.body_returned {
442 self.hits.insert("example".into(), example.interact_rect);
443 }
444 egui::containers::panel::Panel::bottom(egui::Id::new("brep-javascript-output"))
454 .resizable(true)
455 .default_size(160.0)
456 .min_size(48.0)
457 .frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 4)))
458 .show_inside(ui, |ui| {
459 ui.label("Output");
460 let output = egui::ScrollArea::both().id_salt("javascript-output").auto_shrink(false).show(ui, |ui| {
461 ui.add(egui::Label::new(egui::RichText::new(&self.output).monospace()).selectable(true));
462 });
463 self.hits.insert("output".into(), output.inner_rect);
464 });
465 egui::CentralPanel::default().frame(egui::Frame::NONE).show_inside(ui, |ui| {
466 let free = ui.available_height();
473 let row = ui.fonts_mut(|f| f.row_height(&egui::TextStyle::Monospace.resolve(ui.style())));
474 let rows = ((free - 4.0) / row).floor().max(4.0) as usize;
475 let source = egui::ScrollArea::both().id_salt("javascript-source").auto_shrink(false).show(ui, |ui| {
476 ui.add(egui::TextEdit::multiline(&mut self.source).code_editor()
477 .id(egui::Id::new("cad-javascript-source"))
478 .desired_rows(rows).desired_width(f32::INFINITY));
479 });
480 self.hits.insert("source".into(), source.inner_rect);
481 });
482 });
483 if let (Some(shown), true) = (shown, open) {
487 let rect = shown.response.rect;
488 self.rect = Some(rect);
489 self.hits.insert("panel:clip".into(), rect);
494 self.hits.insert("title".into(), egui::Rect::from_center_size(egui::pos2(rect.center().x, rect.min.y + 12.0), egui::Vec2::ZERO));
495 self.hits.insert("grip".into(), egui::Rect::from_center_size(rect.max - egui::vec2(3.0, 3.0), egui::Vec2::ZERO));
496 } else {
497 self.hits.clear();
498 }
499 self.open = open;
500 }
501}