1use egui::{Pos2, Rect, text::LayoutJob};
10
11use super::debugger::{Debugger, LevelMeta, LineKind, StepOutcome};
12use super::parse::{Line, parse_line};
13
14pub enum EditorRequest {
17 Init,
19 Write,
21}
22
23pub struct PointerReadout {
25 pub screen: Option<Pos2>,
26 pub grid: Option<Pos2>,
27}
28
29pub struct ScriptEditor {
30 debugger: Option<Debugger>,
31 text: String,
34 camera: Option<(i32, i32, i32, i32)>,
36 pub id: String,
38 pub title: String,
39 pub instructions: String,
40 pub out_path: String,
41 status: String,
42 mirror: bool,
44}
45
46impl ScriptEditor {
47 pub fn new() -> Self {
48 Self {
49 debugger: None,
50 text: String::new(),
51 camera: None,
52 id: "my-level".into(),
53 title: String::new(),
54 instructions: String::new(),
55 out_path: "recorded_level.kdl".into(),
56 status: String::new(),
57 mirror: false,
58 }
59 }
60
61 pub fn init(&mut self, doc: &crate::document_ng::Document, camera: (i32, i32, i32, i32)) {
64 let mut debugger = Debugger::new(doc);
65 debugger.set_text(&self.text);
66 self.debugger = Some(debugger);
67 self.camera = Some(camera);
68 self.status = "initialized — stepping from the captured state".into();
69 }
70
71 pub fn reinit_after_canvas_edit(&mut self, doc: &crate::document_ng::Document) {
74 if let Some(debugger) = &mut self.debugger {
75 debugger.init(doc);
76 self.status = "canvas edited — re-initialized from the new state".into();
77 self.mirror = true;
78 }
79 }
80
81 pub fn document_cloned(&self) -> Option<crate::document_ng::Document> {
83 self.debugger.as_ref().map(|d| d.document().clone())
84 }
85
86 pub fn take_mirror(&mut self) -> bool {
89 std::mem::take(&mut self.mirror)
90 }
91
92 pub fn emit(&mut self) -> Option<String> {
94 let Some(debugger) = &self.debugger else {
95 self.status = "init first".into();
96 return None;
97 };
98 let meta = LevelMeta {
99 id: self.id.clone(),
100 title: self.title.clone(),
101 instructions: self.instructions.clone(),
102 camera: self.camera,
103 };
104 match debugger.emit_level(&meta) {
105 Ok(text) => {
106 self.status = format!("wrote {}", self.out_path);
107 Some(text)
108 }
109 Err(e) => {
110 self.status = format!("cannot write: {e}");
111 None
112 }
113 }
114 }
115
116 pub fn set_status(&mut self, status: impl Into<String>) {
117 self.status = status.into();
118 }
119
120 pub fn show(&mut self, ctx: &egui::Context, pointer: &PointerReadout) -> Option<EditorRequest> {
121 let mut request = None;
122 egui::Window::new("Script debugger")
123 .id(egui::Id::new("script_debugger"))
124 .default_width(460.0)
125 .default_height(420.0)
126 .resizable(true)
127 .show(ctx, |ui| {
128 request = self.control_bar(ui, pointer);
129 if let Some(write) = self.metadata(ui) {
130 request = Some(write);
131 }
132 ui.separator();
133 self.script_body(ui);
134 if !self.status.is_empty() {
135 ui.separator();
136 ui.label(&self.status);
137 }
138 });
139 request
140 }
141
142 fn control_bar(
143 &mut self,
144 ui: &mut egui::Ui,
145 pointer: &PointerReadout,
146 ) -> Option<EditorRequest> {
147 let mut request = None;
148 ui.horizontal(|ui| {
149 if ui
150 .button("Init")
151 .on_hover_text("Capture the current drawing and camera as the script's baseline")
152 .clicked()
153 {
154 request = Some(EditorRequest::Init);
155 }
156 ui.separator();
157 let armed = self.debugger.is_some();
158 let stepped = ui
159 .add_enabled(armed, egui::Button::new("\u{23ee}"))
160 .on_hover_text("Reset to the initial state")
161 .clicked()
162 .then_some(Transport::Reset)
163 .or_else(|| {
164 ui.add_enabled(armed, egui::Button::new("\u{25c0}"))
165 .on_hover_text("Step backward")
166 .clicked()
167 .then_some(Transport::Back)
168 })
169 .or_else(|| {
170 ui.add_enabled(armed, egui::Button::new("\u{25b6}"))
171 .on_hover_text("Step forward")
172 .clicked()
173 .then_some(Transport::Forward)
174 });
175 if let Some(transport) = stepped {
176 self.transport(transport);
177 }
178 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
179 ui.label(readout_text(pointer));
180 });
181 });
182 request
183 }
184
185 fn transport(&mut self, transport: Transport) {
186 let Some(debugger) = &mut self.debugger else {
187 return;
188 };
189 let outcome = match transport {
190 Transport::Reset => {
191 debugger.reset();
192 StepOutcome::Advanced
193 }
194 Transport::Back => debugger.step_back(),
195 Transport::Forward => debugger.step_forward(),
196 };
197 self.mirror = true;
198 self.status = match outcome {
199 StepOutcome::Advanced | StepOutcome::AtStart => String::new(),
200 StepOutcome::AtEnd => "end of script".into(),
201 StepOutcome::Halted(line) => halt_message(debugger, line),
202 };
203 }
204
205 fn metadata(&mut self, ui: &mut egui::Ui) -> Option<EditorRequest> {
206 let mut request = None;
207 egui::CollapsingHeader::new("Level metadata")
208 .default_open(false)
209 .show(ui, |ui| {
210 egui::Grid::new("script_meta")
211 .num_columns(2)
212 .show(ui, |ui| {
213 ui.label("Id");
214 ui.text_edit_singleline(&mut self.id);
215 ui.end_row();
216 ui.label("Title");
217 ui.text_edit_singleline(&mut self.title);
218 ui.end_row();
219 ui.label("Output");
220 ui.text_edit_singleline(&mut self.out_path);
221 ui.end_row();
222 });
223 ui.label("Instructions");
224 ui.text_edit_multiline(&mut self.instructions);
225 if ui
226 .add_enabled(self.debugger.is_some(), egui::Button::new("Write level"))
227 .clicked()
228 {
229 request = Some(EditorRequest::Write);
232 }
233 });
234 request
235 }
236
237 fn script_body(&mut self, ui: &mut egui::Ui) {
240 let position = self.debugger.as_ref().map(Debugger::position);
241 let runtime_halt = self
242 .debugger
243 .as_ref()
244 .and_then(|d| d.runtime_halt().cloned());
245 let weak = ui.visuals().weak_text_color();
246 let text_color = ui.visuals().text_color();
247 let error = ui.visuals().error_fg_color;
248 let font = egui::TextStyle::Monospace.resolve(ui.style());
249 let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| {
250 let job = layout_script(buf.as_str(), &font, text_color, weak, error);
251 ui.fonts_mut(|f| f.layout_job(job))
252 };
253 egui::ScrollArea::both()
254 .auto_shrink([false, false])
255 .show(ui, |ui| {
256 let output = egui::TextEdit::multiline(&mut self.text)
257 .code_editor()
258 .desired_width(f32::INFINITY)
259 .desired_rows(14)
260 .margin(egui::Margin {
261 left: 22,
262 ..egui::Margin::symmetric(4, 2)
263 })
264 .layouter(&mut layouter)
265 .show(ui);
266 if let Some(line) = position
270 && let Some(row) = output.galley.rows.get(line)
271 {
272 let painter = ui.painter();
273 let y = output.galley_pos.y + row.pos.y + row.rect().height() / 2.0;
274 let left = output.response.rect.left() + 4.0;
275 let row_rect = Rect::from_min_max(
276 egui::pos2(output.response.rect.left(), output.galley_pos.y + row.pos.y),
277 egui::pos2(
278 output.response.rect.right(),
279 output.galley_pos.y + row.pos.y + row.rect().height(),
280 ),
281 );
282 painter.rect_filled(
283 row_rect,
284 0.0,
285 ui.visuals().selection.bg_fill.gamma_multiply(0.25),
286 );
287 painter.add(egui::Shape::convex_polygon(
288 vec![
289 egui::pos2(left, y - 5.0),
290 egui::pos2(left + 8.0, y),
291 egui::pos2(left, y + 5.0),
292 ],
293 ui.visuals().selection.stroke.color,
294 egui::Stroke::NONE,
295 ));
296 }
297 if output.response.changed()
298 && let Some(debugger) = &mut self.debugger
299 {
300 let before = debugger.position();
301 debugger.set_text(&self.text);
302 if debugger.position() != before {
303 self.mirror = true;
304 self.status = "edited an executed line — rewound to it".into();
305 }
306 }
307 });
308 if let Some((line, message)) = runtime_halt {
309 ui.colored_label(
310 ui.visuals().error_fg_color,
311 format!("line {}: {}", line + 1, message),
312 );
313 }
314 }
315}
316
317impl Default for ScriptEditor {
318 fn default() -> Self {
319 Self::new()
320 }
321}
322
323#[derive(Clone, Copy)]
324enum Transport {
325 Reset,
326 Back,
327 Forward,
328}
329
330fn halt_message(debugger: &Debugger, line: usize) -> String {
331 let parse_message = debugger.lines().get(line).and_then(|l| match &l.kind {
332 LineKind::Invalid(e) => Some(e.message.clone()),
333 _ => None,
334 });
335 let message = parse_message
336 .or_else(|| debugger.runtime_halt().map(|(_, m)| m.clone()))
337 .unwrap_or_else(|| "cannot execute this line".into());
338 format!("halted at line {}: {message}", line + 1)
339}
340
341fn readout_text(pointer: &PointerReadout) -> String {
342 let screen = pointer
343 .screen
344 .map_or("\u{2014}".into(), |p| format!("{:.0},{:.0}", p.x, p.y));
345 let grid = pointer
346 .grid
347 .map_or("\u{2014}".into(), |p| format!("{:.1},{:.1}", p.x, p.y));
348 format!("screen {screen} \u{b7} grid {grid}")
349}
350
351fn layout_script(
355 text: &str,
356 font: &egui::FontId,
357 text_color: egui::Color32,
358 weak: egui::Color32,
359 error: egui::Color32,
360) -> LayoutJob {
361 let mut job = LayoutJob {
362 wrap: egui::text::TextWrapping::no_max_width(),
363 ..Default::default()
364 };
365 let plain = egui::TextFormat {
366 font_id: font.clone(),
367 color: text_color,
368 ..Default::default()
369 };
370 let dim = egui::TextFormat {
371 color: weak,
372 ..plain.clone()
373 };
374 let bad = egui::TextFormat {
375 underline: egui::Stroke::new(2.0, error),
376 ..plain.clone()
377 };
378 let mut first = true;
379 for line in text.split('\n') {
380 if !first {
381 job.append("\n", 0.0, plain.clone());
382 }
383 first = false;
384 match parse_line(line) {
385 Ok(Line::Marker { .. }) => job.append(line, 0.0, dim.clone()),
386 Err(e) if !line.is_empty() => {
387 let span = e.span.start.min(line.len())..e.span.end.min(line.len());
388 let (pre, rest) = line.split_at(span.start);
389 let (mid, post) = rest.split_at(span.end - span.start);
390 if mid.is_empty() {
393 job.append(line, 0.0, bad.clone());
394 } else {
395 job.append(pre, 0.0, plain.clone());
396 job.append(mid, 0.0, bad.clone());
397 job.append(post, 0.0, plain.clone());
398 }
399 }
400 _ => job.append(line, 0.0, plain.clone()),
401 }
402 }
403 job
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::widget::drawing::finalize_load;
410
411 #[test]
415 fn the_window_shows_headlessly() {
416 let mut doc = crate::document_ng::schema_convert::from_kdl(
417 "top \"b0\"\n\nblock \"b0\" x=0 y=0 w=24 h=20 {\n title \"sheet\"\n}",
418 "test",
419 )
420 .unwrap();
421 finalize_load(&mut doc);
422
423 let mut editor = ScriptEditor::new();
424 editor.text =
425 "step key=\"k\" en=\"Do it\"\nclick \"tool:new-block\"\nwiggle \"1,1\"".into();
426 editor.init(&doc, (0, 0, 24, 20));
427
428 let ctx = egui::Context::default();
429 ctx.set_fonts(crate::font::build_fonts(
430 crate::preferences::FontChoice::default(),
431 ));
432 let _ = ctx.run_ui(egui::RawInput::default(), |_| {});
433 for _ in 0..3 {
434 let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
435 let request = editor.show(
436 ui.ctx(),
437 &PointerReadout {
438 screen: Some(egui::pos2(100.0, 100.0)),
439 grid: Some(egui::pos2(6.7, 6.7)),
440 },
441 );
442 assert!(request.is_none());
443 });
444 }
445 editor.transport(Transport::Forward);
448 assert!(editor.take_mirror());
449 editor.transport(Transport::Forward);
450 assert!(
451 editor.status.starts_with("halted at line 3"),
452 "{}",
453 editor.status
454 );
455 }
456}