1use egui::{Pos2, Rect};
13
14use crate::canvas::Event;
15use crate::document_ng::Document;
16use crate::grid::GRID_SIZE;
17use crate::schema::model as schema;
18use crate::theme::Theme;
19use crate::tools::names::ToolName;
20
21use super::level::Level;
22use crate::script::lowering::Lowering;
23use crate::script::parse::tool_kdl_name;
24use crate::script::session::Session;
25
26#[derive(Clone, Debug, PartialEq)]
29enum RecStep {
30 ToolClick(ToolName),
31 MoveTo(Pos2),
32 Click { at: Pos2, double: bool },
33 Drag { from: Pos2, to: Pos2 },
34 Type { at: Pos2, text: String },
35}
36
37struct Group {
39 key: String,
40 narration: String,
41 steps: Vec<RecStep>,
42}
43
44#[derive(Clone, Copy, PartialEq, Eq)]
47pub enum EscapeState {
48 Pressed,
49 Idle,
50}
51
52struct EditWatch {
54 rect: Rect,
55 text: String,
56 escape: EscapeState,
57}
58
59pub struct Recorder {
60 pub id: String,
62 pub title: String,
63 pub instructions: String,
64 pub out_path: String,
65 pub next_key: String,
66 pub next_narration: String,
67 pub status: String,
69
70 initial_kdl: Option<String>,
71 groups: Vec<Group>,
72 current: Option<Group>,
73 last_pos: Option<Pos2>,
75 drag_start: Option<Pos2>,
76 edit: Option<EditWatch>,
77}
78
79impl Default for Recorder {
80 fn default() -> Self {
81 Self::new()
82 }
83}
84
85impl Recorder {
86 pub fn new() -> Self {
87 Self {
88 id: "my-level".into(),
89 title: String::new(),
90 instructions: String::new(),
91 out_path: "recorded_level.kdl".into(),
92 next_key: String::new(),
93 next_narration: String::new(),
94 status: String::new(),
95 initial_kdl: None,
96 groups: Vec::new(),
97 current: None,
98 last_pos: None,
99 drag_start: None,
100 edit: None,
101 }
102 }
103
104 pub fn has_initial(&self) -> bool {
105 self.initial_kdl.is_some()
106 }
107
108 fn recording(&self) -> bool {
112 self.initial_kdl.is_some()
113 }
114
115 pub fn capture_initial(&mut self, doc: &Document) {
119 let had_steps = !self.groups.is_empty() || self.current.is_some();
120 self.initial_kdl = Some(schema::Document::from(doc).to_kdl());
121 self.groups.clear();
122 self.current = None;
123 self.last_pos = None;
124 self.drag_start = None;
125 self.edit = None;
126 self.status = if had_steps {
127 "initial re-captured; recorded steps discarded".into()
128 } else {
129 "initial captured — recording".into()
130 };
131 }
132
133 pub fn start_step(&mut self) {
135 self.commit_group();
136 let n = self.groups.len() + 1;
137 let key = if self.next_key.trim().is_empty() {
138 format!("step-{n}")
139 } else {
140 self.next_key.trim().to_owned()
141 };
142 let narration = if self.next_narration.trim().is_empty() {
143 key.clone()
144 } else {
145 self.next_narration.trim().to_owned()
146 };
147 self.current = Some(Group {
148 key,
149 narration,
150 steps: Vec::new(),
151 });
152 self.next_key.clear();
153 self.next_narration.clear();
154 }
155
156 fn commit_group(&mut self) {
157 if let Some(group) = self.current.take()
158 && !group.steps.is_empty()
159 {
160 self.groups.push(group);
161 }
162 }
163
164 fn push(&mut self, step: RecStep) {
168 if self.current.is_none() {
169 self.start_step();
170 }
171 if let Some(group) = &mut self.current {
172 group.steps.push(step);
173 }
174 }
175
176 fn move_to(&mut self, pos: Pos2) {
179 if self
180 .last_pos
181 .is_none_or(|p| (p - pos).length() > GRID_SIZE / 2.0)
182 {
183 self.push(RecStep::MoveTo(pos));
184 }
185 self.last_pos = Some(pos);
186 }
187
188 pub fn observe_event(&mut self, event: Option<Event>) {
190 if !self.recording() {
191 return;
192 }
193 match event {
194 Some(Event::Clicked { pos }) => {
195 self.move_to(pos);
196 self.push(RecStep::Click {
197 at: pos,
198 double: false,
199 });
200 }
201 Some(Event::DoubleClicked { pos }) => {
202 self.move_to(pos);
203 self.push(RecStep::Click {
204 at: pos,
205 double: true,
206 });
207 }
208 Some(Event::DragStarted { pos }) => {
209 self.drag_start = Some(pos);
210 }
211 Some(Event::DragStopped { pos }) => {
212 if let Some(from) = self.drag_start.take() {
213 self.move_to(from);
214 self.push(RecStep::Drag { from, to: pos });
215 self.last_pos = Some(pos);
216 }
217 }
218 Some(Event::HoverAt(_) | Event::Dragging { .. }) | None => {}
221 }
222 }
223
224 pub fn observe_tool(&mut self, tool: ToolName) {
226 if self.recording() && tool_kdl_name(tool).is_some() {
227 self.push(RecStep::ToolClick(tool));
228 }
229 }
230
231 pub fn observe_editor(&mut self, open: Option<(Rect, String)>, escape: EscapeState) {
235 if !self.recording() {
236 return;
237 }
238 match (&mut self.edit, open) {
239 (watch @ None, Some((rect, text))) => {
240 *watch = Some(EditWatch { rect, text, escape });
241 }
242 (Some(watch), Some((rect, text))) => {
243 watch.rect = rect;
244 watch.text = text;
245 watch.escape = escape;
246 }
247 (watch @ Some(_), None) => {
248 let Some(closed) = watch.take() else {
249 return;
250 };
251 if closed.escape == EscapeState::Idle && !closed.text.trim().is_empty() {
252 self.push(RecStep::Type {
253 at: closed.rect.center(),
254 text: closed.text,
255 });
256 }
257 }
258 (None, None) => {}
259 }
260 }
261
262 pub fn finish(
269 &mut self,
270 doc: &Document,
271 ctx: &egui::Context,
272 theme: &Theme,
273 ) -> Option<FinishOutput> {
274 self.commit_group();
275 let Some(initial) = self.initial_kdl.clone() else {
276 self.status = "capture the initial document first".into();
277 return None;
278 };
279 if self.groups.is_empty() {
280 self.status = "nothing recorded".into();
281 return None;
282 }
283 let solution = schema::Document::from(doc);
284 let text = emit(self, &initial, &solution);
285 let mut divergence = None;
286 match Level::parse(Box::leak(text.clone().into_boxed_str())) {
291 Err(e) => self.status = format!("emitted level fails to parse: {e}"),
292 Ok(level) => match replay(&level, ctx, theme) {
293 Ok(replayed) if replayed == solution => {
294 self.status = format!("ok: replay reproduces the solution ({})", self.out_path);
295 }
296 Ok(replayed) => {
297 self.status = format!(
298 "warning: replay does NOT reproduce the solution — diff \
299 {0}.replayed.kdl against the solution, or watch it with \
300 `blockworx --replay {0}`",
301 self.out_path
302 );
303 divergence = Some(replayed.to_kdl());
304 }
305 Err(e) => self.status = format!("replay failed: {e}"),
306 },
307 }
308 Some(FinishOutput {
309 level_text: text,
310 divergence,
311 })
312 }
313}
314
315pub struct FinishOutput {
319 pub level_text: String,
320 pub divergence: Option<String>,
321}
322
323pub enum PanelRequest {
326 CaptureInitial,
327 Finish,
328}
329
330impl Recorder {
331 pub fn panel(&mut self, ctx: &egui::Context) -> Option<PanelRequest> {
334 let mut request = None;
335 egui::Window::new("Tutorial recorder")
336 .id(egui::Id::new("tutorial_recorder"))
337 .default_width(320.0)
338 .show(ctx, |ui| {
339 egui::Grid::new("recorder_meta")
340 .num_columns(2)
341 .show(ui, |ui| {
342 ui.label("Id");
343 ui.text_edit_singleline(&mut self.id);
344 ui.end_row();
345 ui.label("Title");
346 ui.text_edit_singleline(&mut self.title);
347 ui.end_row();
348 ui.label("Output");
349 ui.text_edit_singleline(&mut self.out_path);
350 ui.end_row();
351 });
352 ui.label("Instructions");
353 ui.text_edit_multiline(&mut self.instructions);
354 ui.separator();
355 ui.horizontal(|ui| {
358 if ui.button("Capture initial").clicked() {
359 request = Some(PanelRequest::CaptureInitial);
360 }
361 if self.has_initial() {
362 ui.label("\u{25cf} recording");
363 } else {
364 ui.label("setting up (gestures not recorded)");
365 }
366 });
367 if !self.has_initial() {
368 ui.small("Arrange the level's starting document, then capture it — recording begins there.");
369 }
370 ui.separator();
371 ui.add_enabled_ui(self.has_initial(), |ui| {
372 egui::Grid::new("recorder_marker")
373 .num_columns(2)
374 .show(ui, |ui| {
375 ui.label("Step key");
376 ui.text_edit_singleline(&mut self.next_key);
377 ui.end_row();
378 ui.label("Narration");
379 ui.text_edit_singleline(&mut self.next_narration);
380 ui.end_row();
381 });
382 ui.horizontal(|ui| {
383 if ui.button("Start step").clicked() {
384 self.start_step();
385 }
386 let done = self.groups.len();
387 let current = self.current.as_ref().map_or(0, |g| g.steps.len());
388 ui.label(format!(
389 "{done} step(s) done, {current} action(s) in this one"
390 ));
391 });
392 ui.separator();
393 if ui.button("Finish, validate & write").clicked() {
394 request = Some(PanelRequest::Finish);
395 }
396 });
397 if !self.status.is_empty() {
398 ui.label(&self.status);
399 }
400 });
401 request
402 }
403}
404
405fn replay(level: &Level, ctx: &egui::Context, theme: &Theme) -> Result<schema::Document, String> {
408 let mut session = Session::new(level.initial_kdl, &level.id);
409 let mut lowering = Lowering::new(&level.script);
410 let discard = egui::Painter::new(
411 ctx.clone(),
412 egui::LayerId::new(egui::Order::Background, egui::Id::new("recorder_replay")),
413 Rect::NOTHING,
414 );
415 loop {
416 let projected = session.projected();
417 match lowering.next(&projected) {
418 Ok(Some(frame)) => session.step(frame, theme, &discard),
419 Ok(None) => break,
420 Err(e) => return Err(format!("step {} names a missing object", e.step)),
421 }
422 }
423 let idle = session.idle_frame();
425 session.step(idle, theme, &discard);
426 Ok(session.projected())
427}
428
429fn cells(pos: Pos2) -> (i32, i32) {
431 (
432 (pos.x / GRID_SIZE).round() as i32,
433 (pos.y / GRID_SIZE).round() as i32,
434 )
435}
436
437fn target(pos: Pos2) -> String {
438 let (x, y) = cells(pos);
439 format!("\"{x},{y}\"")
440}
441
442fn glide_secs(from: Option<Pos2>, to: Pos2) -> f32 {
444 let dist = from.map_or(GRID_SIZE * 4.0, |f| (f - to).length());
445 round1((0.4 + dist / 300.0).clamp(0.5, 1.4))
446}
447
448fn round1(x: f32) -> f32 {
449 (x * 10.0).round() / 10.0
450}
451
452fn kdl_quote(s: &str) -> String {
453 let escaped = s
454 .replace('\\', "\\\\")
455 .replace('"', "\\\"")
456 .replace('\n', "\\n");
457 format!("\"{escaped}\"")
458}
459
460fn indent(kdl: &str, by: &str) -> String {
462 kdl.lines()
463 .map(|l| {
464 if l.is_empty() {
465 String::new()
466 } else {
467 format!("{by}{l}")
468 }
469 })
470 .collect::<Vec<_>>()
471 .join("\n")
472}
473
474fn camera(solution: &schema::Document) -> (i32, i32, i32, i32) {
478 if let Some(top) = solution.blocks.iter().find(|b| b.id == solution.top) {
479 let (w, h) = (
480 i32::try_from(top.w).unwrap_or(i32::MAX),
481 i32::try_from(top.h).unwrap_or(i32::MAX),
482 );
483 return (top.x, top.y, w, h);
484 }
485 let xs = || solution.blocks.iter();
486 let x0 = xs().map(|b| b.x).min().unwrap_or(0);
487 let y0 = xs().map(|b| b.y).min().unwrap_or(0);
488 let far = |b: &schema::Block| b.x.saturating_add(i32::try_from(b.w).unwrap_or(i32::MAX));
489 let low = |b: &schema::Block| b.y.saturating_add(i32::try_from(b.h).unwrap_or(i32::MAX));
490 let x1 = xs().map(far).max().unwrap_or(24);
491 let y1 = xs().map(low).max().unwrap_or(20);
492 (x0 - 2, y0 - 2, (x1 - x0) + 4, (y1 - y0) + 4)
493}
494
495fn emit(rec: &Recorder, initial: &str, solution: &schema::Document) -> String {
497 use std::fmt::Write;
498 let mut out = String::new();
499 let title = if rec.title.trim().is_empty() {
500 rec.id.clone()
501 } else {
502 rec.title.trim().to_owned()
503 };
504 let _ = writeln!(
505 out,
506 "level {} title={} {{",
507 kdl_quote(rec.id.trim()),
508 kdl_quote(&title)
509 );
510 let _ = writeln!(
511 out,
512 " instructions {}",
513 kdl_quote(rec.instructions.trim())
514 );
515 let (cx, cy, cw, ch) = camera(solution);
516 let _ = writeln!(out, " camera x={cx} y={cy} w={cw} h={ch}");
517 let _ = writeln!(
518 out,
519 " initial {{\n{}\n }}",
520 indent(initial.trim_end(), " ")
521 );
522 let _ = writeln!(
523 out,
524 " solution {{\n{}\n }}",
525 indent(solution.to_kdl().trim_end(), " ")
526 );
527 let last_group = rec.groups.len().saturating_sub(1);
528 let mut cursor: Option<Pos2> = None;
529 for (gi, group) in rec.groups.iter().enumerate() {
530 let _ = writeln!(
531 out,
532 " step key={} en={} {{",
533 kdl_quote(&group.key),
534 kdl_quote(&group.narration)
535 );
536 for step in &group.steps {
537 match step {
538 RecStep::ToolClick(tool) => {
539 let name = tool_kdl_name(*tool).unwrap_or("select");
541 let _ = writeln!(out, " highlight \"tool:{name}\" secs=1.2");
542 let _ = writeln!(out, " click \"tool:{name}\"");
543 cursor = None;
544 }
545 RecStep::MoveTo(pos) => {
546 let secs = glide_secs(cursor, *pos);
547 let _ = writeln!(out, " move-to {} secs={secs}", target(*pos));
548 cursor = Some(*pos);
549 }
550 RecStep::Click { at, double } => {
551 let node = if *double { "double-click" } else { "click" };
552 let _ = writeln!(out, " {node} {}", target(*at));
553 cursor = Some(*at);
554 }
555 RecStep::Drag { from, to } => {
556 let secs = round1((glide_secs(Some(*from), *to) + 0.2).min(2.0));
557 let _ = writeln!(
558 out,
559 " drag {} {} secs={secs}",
560 target(*from),
561 target(*to)
562 );
563 cursor = Some(*to);
564 }
565 RecStep::Type { at, text } => {
566 let secs = round1((0.4 * text.chars().count() as f32).clamp(0.8, 2.0));
567 let _ = writeln!(
568 out,
569 " type {} text={} secs={secs}",
570 target(*at),
571 kdl_quote(text)
572 );
573 }
574 }
575 }
576 if gi == last_group {
577 let _ = writeln!(out, " pause secs=1.0");
578 }
579 let _ = writeln!(out, " }}");
580 }
581 out.push_str("}\n");
582 out
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use egui::pos2;
589
590 fn px(c: f32) -> f32 {
591 c * GRID_SIZE
592 }
593
594 fn recorder_with_gestures() -> Recorder {
595 let mut rec = Recorder::new();
596 rec.id = "rec-test".into();
597 rec.title = "Recorded".into();
598 rec.instructions = "Do the thing.".into();
599 rec.capture_initial(&Document::default());
600 rec.next_key = "pick-tool".into();
601 rec.next_narration = "Pick the New Block tool".into();
602 rec.start_step();
603 rec.observe_tool(ToolName::NewBlock);
604 rec.next_key = "place".into();
605 rec.next_narration = "Click two corners".into();
606 rec.start_step();
607 rec.observe_event(Some(Event::Clicked {
608 pos: pos2(px(8.0), px(6.0)),
609 }));
610 rec.observe_event(Some(Event::Clicked {
611 pos: pos2(px(16.0), px(13.0)),
612 }));
613 let edit_rect = Rect::from_center_size(pos2(px(12.0), px(14.0)), egui::vec2(60.0, 20.0));
615 rec.observe_editor(Some((edit_rect, "C".into())), EscapeState::Idle);
616 rec.observe_editor(Some((edit_rect, "CPU".into())), EscapeState::Idle);
617 rec.observe_editor(None, EscapeState::Idle);
618 rec
619 }
620
621 #[test]
622 fn gestures_distill_to_the_expected_steps() {
623 let rec = recorder_with_gestures();
624 let groups = &rec.groups;
625 let current = rec.current.as_ref().unwrap();
626 assert_eq!(groups.len(), 1);
627 assert_eq!(groups[0].key, "pick-tool");
628 assert_eq!(
629 groups[0].steps,
630 vec![RecStep::ToolClick(ToolName::NewBlock)]
631 );
632 assert_eq!(current.key, "place");
633 assert_eq!(
634 current.steps,
635 vec![
636 RecStep::MoveTo(pos2(px(8.0), px(6.0))),
637 RecStep::Click {
638 at: pos2(px(8.0), px(6.0)),
639 double: false
640 },
641 RecStep::MoveTo(pos2(px(16.0), px(13.0))),
642 RecStep::Click {
643 at: pos2(px(16.0), px(13.0)),
644 double: false
645 },
646 RecStep::Type {
647 at: pos2(px(12.0), px(14.0)),
648 text: "CPU".into()
649 },
650 ]
651 );
652 }
653
654 #[test]
658 fn setup_gestures_before_the_initial_capture_are_not_recorded() {
659 let mut rec = Recorder::new();
660 rec.observe_tool(ToolName::NewBlock);
661 rec.observe_event(Some(Event::Clicked {
662 pos: pos2(px(4.0), px(4.0)),
663 }));
664 assert!(rec.groups.is_empty() && rec.current.is_none());
665
666 rec.capture_initial(&Document::default());
667 rec.observe_event(Some(Event::Clicked {
668 pos: pos2(px(8.0), px(6.0)),
669 }));
670 assert_eq!(rec.current.as_ref().unwrap().steps.len(), 2);
671
672 rec.capture_initial(&Document::default());
675 assert!(rec.groups.is_empty() && rec.current.is_none());
676 }
677
678 #[test]
679 fn an_escaped_editor_leaves_no_type_step() {
680 let mut rec = Recorder::new();
681 rec.capture_initial(&Document::default());
682 let edit_rect = Rect::from_center_size(pos2(px(4.0), px(4.0)), egui::vec2(60.0, 20.0));
683 rec.observe_editor(Some((edit_rect, "oops".into())), EscapeState::Idle);
684 rec.observe_editor(Some((edit_rect, "oops".into())), EscapeState::Pressed);
685 rec.observe_editor(None, EscapeState::Idle);
686 assert!(rec.current.is_none() && rec.groups.is_empty());
687 }
688
689 #[test]
690 fn a_drag_distills_to_move_and_drag() {
691 let mut rec = Recorder::new();
692 rec.capture_initial(&Document::default());
693 rec.observe_event(Some(Event::DragStarted {
694 pos: pos2(px(10.0), px(6.0)),
695 }));
696 rec.observe_event(Some(Event::Dragging {
697 pos: pos2(px(14.0), px(6.0)),
698 delta: egui::vec2(1.0, 0.0),
699 }));
700 rec.observe_event(Some(Event::DragStopped {
701 pos: pos2(px(18.0), px(6.0)),
702 }));
703 let current = rec.current.as_ref().unwrap();
704 assert_eq!(
705 current.steps,
706 vec![
707 RecStep::MoveTo(pos2(px(10.0), px(6.0))),
708 RecStep::Drag {
709 from: pos2(px(10.0), px(6.0)),
710 to: pos2(px(18.0), px(6.0))
711 },
712 ]
713 );
714 }
715
716 #[test]
720 fn emitted_level_round_trips_through_the_parser() {
721 let mut rec = recorder_with_gestures();
722 rec.commit_group();
723 let initial = "top \"b0\"\n\nblock \"b0\" x=0 y=0 w=24 h=20 {\n title \"sheet\"\n}";
724 let solution = schema::Document::parse_kdl(
725 r#"
726top "b0"
727
728block "b0" x=0 y=0 w=24 h=20 {
729 title "sheet"
730 children "b1"
731}
732
733block "b1" x=8 y=6 w=8 h=7 {
734 title "CPU"
735}
736"#,
737 "rec-test",
738 )
739 .unwrap();
740 let text = emit(&rec, initial, &solution);
741 let level =
742 Level::parse(Box::leak(text.into_boxed_str())).unwrap_or_else(|e| panic!("{e}"));
743 assert_eq!(level.id, "rec-test");
744 assert_eq!(level.steps.len(), 2);
745 assert_eq!(level.steps[0].key, "pick-tool");
746 assert_eq!(level.steps[1].key, "place");
747 assert!(level.initial_kdl.is_some());
748 assert_eq!(
749 level.camera,
750 Rect::from_min_max(
751 crate::script::step::grid_pos(0, 0),
752 crate::script::step::grid_pos(24, 20),
753 )
754 );
755 }
756}