Skip to main content

blockworx/script/
window.rs

1//! The script debugger's window (`blockworx --record-tutorial`): a control
2//! bar (Init, debugger transport, live pointer readout) over a plain-text
3//! script editor — one step per line, no wrapping, a marker on the line the
4//! debugger will execute next, invalid spans underlined in red. The window
5//! never touches the app's document: Init and Write round-trip through
6//! [`EditorRequest`], and the app mirrors the debugger's state after any
7//! position change (see [`ScriptEditor::take_mirror`]).
8
9use egui::{Pos2, Rect, text::LayoutJob};
10
11use super::debugger::{Debugger, LevelMeta, LineKind, StepOutcome};
12use super::parse::{Line, parse_line};
13
14/// What the window asks the app to do — the operations that need the live
15/// document or canvas (the window itself owns neither).
16pub enum EditorRequest {
17    /// Capture the current document and camera as the script's baseline.
18    Init,
19    /// Emit the level file and write it to `out_path`.
20    Write,
21}
22
23/// The live pointer, for the readout: screen pixels and grid cells.
24pub struct PointerReadout {
25    pub screen: Option<Pos2>,
26    pub grid: Option<Pos2>,
27}
28
29pub struct ScriptEditor {
30    debugger: Option<Debugger>,
31    /// The editor buffer. Kept here (not in the debugger) so the script can
32    /// be drafted before the first Init.
33    text: String,
34    /// The camera captured at Init (grid cells), emitted with the level.
35    camera: Option<(i32, i32, i32, i32)>,
36    // Level metadata for emission.
37    pub id: String,
38    pub title: String,
39    pub instructions: String,
40    pub out_path: String,
41    status: String,
42    /// The debugger's position changed; the app owes the canvas a mirror.
43    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    /// Capture `doc` (and the camera rect, in cells) as the baseline and
62    /// arm the debugger. Called by the app on [`EditorRequest::Init`].
63    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    /// Re-capture after the author edited the canvas mid-script: the script
72    /// text survives, the position rewinds to the new baseline.
73    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    /// The document at the debugger's position, cloned for mirroring.
82    pub fn document_cloned(&self) -> Option<crate::document_ng::Document> {
83        self.debugger.as_ref().map(|d| d.document().clone())
84    }
85
86    /// Whether the position changed since the last take — the app mirrors
87    /// the debugger's document into the canvas when it did.
88    pub fn take_mirror(&mut self) -> bool {
89        std::mem::take(&mut self.mirror)
90    }
91
92    /// Emit the level file from the current script and metadata.
93    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                    // Emission needs no app state, but the file write does
230                    // (native fs, error reporting) — route via the request.
231                    request = Some(EditorRequest::Write);
232                }
233            });
234        request
235    }
236
237    /// The script editor: monospace, one step per line, no wrapping, with
238    /// invalid spans underlined and the active line marked.
239    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                // The active-line marker: a triangle in the gutter margin on
267                // the line the debugger will execute next, with a faint row
268                // tint. Rows map 1:1 to lines (the layouter never wraps).
269                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
351/// Lay the whole buffer out line by line, monospace, never wrapping: marker
352/// lines dimmed, invalid spans underlined in red. Parsed fresh each layout —
353/// lines are short and the hand-rolled KDL parser is tiny.
354fn 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                // An empty span (e.g. end-of-line) still needs a visible
391                // mark: underline the whole line instead.
392                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    /// The whole window — control bar, metadata, editor with the layouter
412    /// and the active-line marker — runs in a bare context, over a script
413    /// containing a marker line, valid steps, and an invalid line.
414    #[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        // Drive the transport headlessly: forward executes the click, then
446        // halts on the invalid line and mirrors each move.
447        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}