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 (native; file-interchange stores only)", 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>, importing: bool,
44 hits: HashMap<String, egui::Rect>,
47 rect: Option<egui::Rect>,
49 surface: egui::Rect,
51}
52
53
54impl JavaScriptEditor {
55 pub fn running_in(&self, document: u64) -> bool {
56 self.pending == Some(document)
57 }
58
59 pub fn editing_source(ctx: &egui::Context) -> bool {
60 ctx.memory(|memory| memory.has_focus(egui::Id::new("cad-javascript-source")))
61 }
62
63 pub fn new(store: &dyn ModelStore) -> Self {
64 Self {
65 open: false,
66 source: store
67 .read(JAVASCRIPT_DRAFT_KEY)
68 .unwrap_or_else(|| EXAMPLE.into()),
69 output: String::new(),
70 pending: None,
71 importing: false,
72 hits: HashMap::new(),
73 rect: None,
74 surface: egui::Rect::NOTHING,
75 }
76 }
77
78 pub fn state_json(&self) -> String {
82 let rect = |r: egui::Rect| serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]);
83 serde_json::json!({
84 "open": self.open,
85 "rect": self.rect.map(rect),
86 "surface": [self.surface.width(), self.surface.height()],
87 "running": self.pending.is_some(),
88 "output": self.output,
89 })
90 .to_string()
91 }
92
93 pub fn hits_json(&self) -> String {
95 crate::automation::hit_rects::hits_json(&self.hits)
96 }
97
98 fn hit(&mut self, key: &str, resp: &egui::Response) {
100 self.hits.insert(key.to_string(), resp.interact_rect);
101 }
102
103 pub fn poll(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
104 if self.importing {
105 if let Some(file) = store.take_import() {
106 self.importing = false;
107 match String::from_utf8(file.bytes) {
108 Ok(source) => self.source = source,
109 Err(e) => self.output = format!("Cannot open script: {e}"),
110 }
111 }
112 }
113 let Some(id) = self.pending else {
114 return;
115 };
116 let Some(doc) = docs.iter_mut().find(|doc| doc.id() == id) else {
117 self.pending = None;
118 self.output = "The script's document was closed.".into();
119 return;
120 };
121 doc.engine.pump();
122 let status = doc.engine.plugin_action_status();
123 match status["state"].as_str() {
124 Some("complete") => {
125 let lines: Vec<_> = status["result"]["notifications"]
126 .as_array()
127 .into_iter()
128 .flatten()
129 .filter_map(|v| v.as_str())
130 .collect();
131 self.output = format!("Completed.\n{}", lines.join("\n"));
132 self.pending = None;
133 }
134 Some("error") => {
135 self.output = status["error"].as_str().unwrap_or("Script failed").into();
136 self.pending = None;
137 }
138 _ => {
139 ctx.request_repaint_after(std::time::Duration::from_millis(30));
140 }
141 }
142 }
143
144 pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
145 self.hits.clear();
146 self.rect = None;
147 self.surface = ctx.content_rect();
148 let mut open = self.open;
149 let shown = egui::Window::new("JavaScript editor").id(egui::Id::new(WINDOW_ID)).open(&mut open)
150 .default_size(egui::vec2(720.0, 620.0)).min_size(egui::vec2(420.0, 320.0)).resizable(true).show(ctx, |ui| {
151 ui.label(format!("Run in: {}", docs.active().title()));
152 if let Some(id) = self.pending {
153 if let Some(doc) = docs.iter().find(|doc| doc.id() == id) {
154 ui.horizontal(|ui| {
155 ui.spinner();
156 ui.label(format!("Running in: {}", doc.title()));
157 });
158 }
159 }
160 ui.horizontal(|ui| {
161 let busy = self.pending.is_some() || docs.engine().run_pending()
162 || docs.engine().plugin_action_status()["state"] == "pending";
163 let run = ui.add_enabled(!busy, egui::Button::new("Run script"));
164 self.hit("run", &run);
165 if run.clicked() {
166 match docs.engine_mut().run_javascript(&self.source) {
167 Ok(_) => { self.pending = Some(docs.active_id()); self.output = "Running…".into(); }
168 Err(e) => self.output = e,
169 }
170 }
171 let cancel = ui.add_enabled(self.pending.is_some(), egui::Button::new("Cancel"));
172 self.hit("cancel", &cancel);
173 if cancel.clicked() {
174 if let Some(id) = self.pending {
175 if let Some(doc) = docs.iter_mut().find(|doc| doc.id() == id) {
176 doc.engine.cancel_plugin_action();
177 }
178 }
179 }
180 let save = ui.button("Save draft");
181 self.hit("save", &save);
182 if save.clicked() {
183 self.output = match store.write(JAVASCRIPT_DRAFT_KEY, &self.source) {
184 Ok(()) => "Draft saved.".into(), Err(e) => format!("Cannot save draft: {e}"),
185 };
186 }
187 if store.supports_file_interchange() {
188 let open = ui.add_enabled(!self.importing, egui::Button::new("Open .js…"));
189 self.hit("open", &open);
190 if open.clicked() {
191 match store.begin_import_filtered(("JavaScript", &["js"])) {
192 Ok(()) => self.importing = true, Err(e) => self.output = e,
193 }
194 }
195 let export = ui.button("Export .js…");
196 self.hit("export", &export);
197 if export.clicked() {
198 if let Err(e) = store.export_file_named("script.js", &self.source) { self.output = e; }
199 }
200 }
201 });
202 let help = ui.collapsing("CAD API and examples", |ui| {
203 ui.label("Write JavaScript statements. cad.document is a snapshot of the current history; cad.selection contains the current selection.");
204 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' };");
205 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.");
206 let example = ui.button("Load box example");
207 if example.clicked() { self.source = EXAMPLE.into(); }
208 example
209 });
210 self.hits.insert("help".into(), help.header_response.interact_rect);
211 if let Some(example) = help.body_returned {
212 self.hits.insert("example".into(), example.interact_rect);
213 }
214 egui::containers::panel::Panel::bottom(egui::Id::new("brep-javascript-output"))
224 .resizable(true)
225 .default_size(160.0)
226 .min_size(48.0)
227 .frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 4)))
228 .show_inside(ui, |ui| {
229 ui.label("Output");
230 let output = egui::ScrollArea::both().id_salt("javascript-output").auto_shrink(false).show(ui, |ui| {
231 ui.add(egui::Label::new(egui::RichText::new(&self.output).monospace()).selectable(true));
232 });
233 self.hits.insert("output".into(), output.inner_rect);
234 });
235 egui::CentralPanel::default().frame(egui::Frame::NONE).show_inside(ui, |ui| {
236 let free = ui.available_height();
243 let row = ui.fonts_mut(|f| f.row_height(&egui::TextStyle::Monospace.resolve(ui.style())));
244 let rows = ((free - 4.0) / row).floor().max(4.0) as usize;
245 let source = egui::ScrollArea::both().id_salt("javascript-source").auto_shrink(false).show(ui, |ui| {
246 ui.add(egui::TextEdit::multiline(&mut self.source).code_editor()
247 .id(egui::Id::new("cad-javascript-source"))
248 .desired_rows(rows).desired_width(f32::INFINITY));
249 });
250 self.hits.insert("source".into(), source.inner_rect);
251 });
252 });
253 if let (Some(shown), true) = (shown, open) {
257 let rect = shown.response.rect;
258 self.rect = Some(rect);
259 self.hits.insert("panel:clip".into(), rect);
264 self.hits.insert("title".into(), egui::Rect::from_center_size(egui::pos2(rect.center().x, rect.min.y + 12.0), egui::Vec2::ZERO));
265 self.hits.insert("grip".into(), egui::Rect::from_center_size(rect.max - egui::vec2(3.0, 3.0), egui::Vec2::ZERO));
266 } else {
267 self.hits.clear();
268 }
269 self.open = open;
270 }
271}