1use std::collections::VecDeque;
13use std::sync::{Arc, Mutex, PoisonError};
14use std::time::Duration;
15
16use ashlar_control::{Reply, Request};
17use egui::{Event, Rect, ViewportCommand, ViewportId, pos2, vec2};
18use serde_json::Value;
19
20use crate::renderer::Renderer;
21use crate::runtime::Runtime;
22use crate::session::{Document, now};
23
24#[derive(Default)]
26struct Wakes {
27 pending: Vec<(Duration, u64)>,
29}
30
31pub struct Driver<D: Document> {
33 pub runtime: Runtime<D>,
34 ctx: egui::Context,
35 time: f64,
36 wakes: Arc<Mutex<Wakes>>,
37 next: Option<f64>,
39 input: VecDeque<Event>,
40 #[cfg(not(target_arch = "wasm32"))]
42 dropped: Vec<std::path::PathBuf>,
43 screen: Rect,
44 frames: u64,
45 closed: bool,
46 close_requested: bool,
47 screenshots: bool,
49 hidden: bool,
51 pixels_per_point: Option<f32>,
53 renderer: Renderer,
55 recorder: Option<ashlar_ui::motion_gate::MotionRecorder>,
57}
58
59impl<D: Document> Driver<D> {
60 pub fn new(runtime: Runtime<D>) -> Self {
62 Self::with_renderer(runtime, Renderer::none())
63 }
64
65 pub fn with_renderer(runtime: Runtime<D>, renderer: Renderer) -> Self {
68 let ctx = egui::Context::default();
69 let wakes: Arc<Mutex<Wakes>> = Arc::default();
70 let w = Arc::clone(&wakes);
71 ctx.set_request_repaint_callback(move |info| {
72 if info.viewport_id == ViewportId::ROOT {
73 w.lock().unwrap_or_else(PoisonError::into_inner).pending.push((info.delay, info.current_cumulative_pass_nr));
74 }
75 });
76 ashlar_ui::devtools::set_platform_accesskit(&ctx, false);
78 Driver {
79 runtime,
80 ctx,
81 time: 0.0,
82 wakes,
83 next: Some(0.0),
84 input: VecDeque::new(),
85 #[cfg(not(target_arch = "wasm32"))]
86 dropped: Vec::new(),
87 screen: Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0)),
88 frames: 0,
89 closed: false,
90 close_requested: false,
91 screenshots: true,
92 hidden: false,
93 pixels_per_point: None,
94 renderer,
95 recorder: None,
96 }
97 .install_renderer()
98 }
99
100 fn install_renderer(self) -> Self {
101 if let Some(offscreen) = self.renderer.offscreen() {
102 ashlar_ui::devtools::set_offscreen(&self.ctx, Some(offscreen));
103 }
104 self
105 }
106
107 pub fn renderer(&self) -> &Renderer {
109 &self.renderer
110 }
111
112 fn blank(&self) -> Arc<egui::ColorImage> {
114 let ppp = self.ctx.pixels_per_point();
115 let [w, h] = [(self.screen.width() * ppp).round() as usize, (self.screen.height() * ppp).round() as usize];
116 Arc::new(egui::ColorImage::new([w, h], vec![egui::Color32::from_gray(30); w.saturating_mul(h)]))
117 }
118
119 pub fn into_runtime(self) -> Runtime<D> {
121 self.runtime
122 }
123
124 pub fn ctx(&self) -> &egui::Context {
125 &self.ctx
126 }
127
128 pub fn set_screen(&mut self, width: f32, height: f32) {
129 self.screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, height));
130 self.next = Some(self.time);
131 }
132
133 pub fn pixels_per_point(&mut self, ppp: f32) {
137 if ppp.is_finite() && ppp > 0.0 {
138 self.pixels_per_point = Some(ppp.clamp(0.25, 8.0));
139 self.next = Some(self.time);
140 }
141 }
142
143 pub fn time(&self) -> f64 {
145 self.time
146 }
147
148 pub fn frames(&self) -> u64 {
150 self.frames
151 }
152
153 pub fn is_closed(&self) -> bool {
155 self.closed
156 }
157
158 pub fn push_event(&mut self, e: Event) {
160 self.input.push_back(e);
161 }
162
163 pub fn set_screenshots(&mut self, deliver: bool) {
166 self.screenshots = deliver;
167 }
168
169 pub fn set_hidden(&mut self, hidden: bool) {
174 self.hidden = hidden;
175 self.next = Some(self.time);
176 }
177
178 pub fn is_hidden(&self) -> bool {
180 self.hidden
181 }
182
183 pub fn request_close(&mut self) {
185 self.close_requested = true;
186 }
187
188 pub fn native_menu_pick(&mut self, id: &str) {
193 self.runtime.push_native_pick(id);
194 }
195
196 #[cfg(not(target_arch = "wasm32"))]
198 pub fn drop_files(&mut self, paths: impl IntoIterator<Item = impl Into<std::path::PathBuf>>) {
199 self.dropped.extend(paths.into_iter().map(Into::into));
200 }
201
202 pub fn forward_launch(&mut self, forwarded: crate::Forwarded) {
205 self.runtime.forwarded(forwarded);
206 }
207
208 pub fn frame(&mut self) -> egui::FullOutput {
210 if self.hidden {
211 self.logic_only();
212 return egui::FullOutput::default();
213 }
214 let mut raw = egui::RawInput { screen_rect: Some(self.screen), time: Some(self.time), focused: true, ..Default::default() };
215 raw.events.extend(self.input.drain(..));
216 #[cfg(not(target_arch = "wasm32"))]
217 raw.dropped_files.extend(self.dropped.drain(..).map(|p| -> egui::DroppedFileHandle { Arc::new(PathDrop(p)) }));
218 if std::mem::take(&mut self.close_requested) {
219 raw.viewports.entry(ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
220 }
221 if let Some(ppp) = self.pixels_per_point {
222 raw.viewports.entry(ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp);
223 }
224 let ctx = self.ctx.clone();
225 let started = now();
226 self.runtime.raw_input_hook(&ctx, &mut raw);
227 let rt = &mut self.runtime;
228 let mut out = ctx.run_ui(raw, |ui| {
229 rt.logic(ui.ctx());
230 rt.ui(ui);
231 });
232 let passed = now();
233 let shapes = out.shapes.clone();
235 let tess_start = now();
236 let meshes = ctx.tessellate(shapes, out.pixels_per_point);
237 let tess_end = now();
238 drop(meshes);
239 if let (Some(s), Some(p), Some(ts), Some(te)) = (started, passed, tess_start, tess_end) {
240 let (pass, tess) = (p.duration_since(s), te.duration_since(ts));
241 let stats = self.runtime.stats_mut();
242 stats.record_pass(pass);
243 stats.record_tessellate(tess);
244 stats.record_frame_cpu(pass.saturating_add(tess));
245 }
246 self.renderer.textures(&out.textures_delta);
247 out.textures_delta.clear();
248 self.frames = self.frames.saturating_add(1);
249 let done = self.ctx.cumulative_pass_nr();
250 self.next = None;
251 if let Some(vp) = out.viewport_output.get(&ViewportId::ROOT) {
252 if vp.repaint_delay < Duration::MAX {
253 self.next = Some(self.time + vp.repaint_delay.as_secs_f64());
254 }
255 for c in &vp.commands {
256 match c {
257 ViewportCommand::Screenshot(_) if !self.screenshots => {}
258 ViewportCommand::Screenshot(user_data) => {
259 let image = self.blank();
260 self.input.push_back(Event::Screenshot { viewport_id: ViewportId::ROOT, user_data: user_data.clone(), image });
261 }
262 ViewportCommand::Close => self.closed = true,
263 _ => {}
264 }
265 }
266 }
267 self.wakes.lock().unwrap_or_else(PoisonError::into_inner).pending.retain(|(_, nr)| *nr >= done);
269 if let Some(rec) = &self.recorder {
270 rec.frame(&self.ctx, &out);
271 }
272 out
273 }
274
275 pub fn record(&mut self, f: impl FnOnce(&mut Self)) -> ashlar_ui::motion_gate::MotionRecorder {
285 let rec = ashlar_ui::motion_gate::MotionRecorder::install(&self.ctx);
286 self.recorder = Some(rec.clone());
287 f(self);
288 self.recorder = None;
289 ashlar_ui::motion_gate::MotionRecorder::uninstall(&self.ctx);
290 rec
291 }
292
293 fn logic_only(&mut self) {
295 let mut raw = egui::RawInput { screen_rect: Some(self.screen), time: Some(self.time), focused: false, ..Default::default() };
296 let vp = raw.viewports.entry(ViewportId::ROOT).or_default();
297 vp.minimized = Some(true);
298 vp.native_pixels_per_point = self.pixels_per_point;
299 let (shots, rest): (Vec<Event>, Vec<Event>) = self.input.drain(..).partition(|e| matches!(e, Event::Screenshot { .. }));
302 self.input.extend(rest);
303 let done = self.ctx.cumulative_pass_nr();
306 let (rt, time, renderer) = (&mut self.runtime, self.time, &self.renderer);
307 let out = self.ctx.run_logic(&raw, |ctx| {
308 rt.logic(ctx);
309 if let Some(mut out) = rt.hidden_pass(ctx, Some(time), shots) {
310 renderer.textures(&out.textures_delta);
311 out.textures_delta.clear();
312 }
313 });
314 self.frames = self.frames.saturating_add(1);
315 self.next = None;
316 for c in out.viewport_commands.get(&ViewportId::ROOT).into_iter().flatten() {
317 match c {
318 ViewportCommand::Screenshot(user_data) if self.screenshots => {
319 let image = self.blank();
320 self.input.push_back(Event::Screenshot { viewport_id: ViewportId::ROOT, user_data: user_data.clone(), image });
321 self.next = Some(self.time);
322 }
323 ViewportCommand::Focus | ViewportCommand::Minimized(false) | ViewportCommand::Visible(true) => {
324 self.hidden = false;
325 self.next = Some(self.time);
326 }
327 ViewportCommand::Close => self.closed = true,
328 _ => {}
329 }
330 }
331 self.wakes.lock().unwrap_or_else(PoisonError::into_inner).pending.retain(|(_, nr)| *nr >= done);
332 }
333
334 fn due(&self) -> Option<f64> {
336 let wake =
337 self.wakes.lock().unwrap_or_else(PoisonError::into_inner).pending.iter().map(|(d, _)| self.time + d.as_secs_f64()).reduce(f64::min);
338 let input = ((!self.input.is_empty() && !self.hidden) || self.close_requested).then_some(self.time);
340 [self.next, wake, input].into_iter().flatten().reduce(f64::min)
341 }
342
343 pub fn advance(&mut self, secs: f64) -> u64 {
346 let end = self.time + secs.max(0.0);
347 let start = self.frames;
348 let min_dt = 1.0 / 120.0;
349 let mut last_frame = f64::NEG_INFINITY;
350 while !self.closed {
351 let Some(due) = self.due() else { break };
352 let at = due.max(last_frame + min_dt).max(self.time);
353 if at > end {
354 break;
355 }
356 self.time = at;
357 self.frame();
358 last_frame = at;
359 }
360 self.time = self.time.max(end);
361 self.frames - start
362 }
363
364 pub fn settle(&mut self, max: u64) -> u64 {
367 let start = self.frames;
368 let horizon = 0.25;
369 while self.frames - start < max && !self.closed {
370 match self.due() {
371 Some(t) if t <= self.time + horizon => {
372 self.time = t.max(self.time) + 1.0 / 120.0;
373 self.frame();
374 }
375 _ => break,
376 }
377 }
378 self.frames - start
379 }
380
381 pub fn replay(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<usize> {
386 let frames = crate::record::read(path.as_ref())?;
387 for f in &frames {
388 if self.closed {
389 break;
390 }
391 if f.time.is_finite() {
392 self.time = f.time.max(self.time);
393 }
394 if let Some((w, h)) = f.screen.filter(|(w, h)| *w >= 1.0 && *h >= 1.0 && *w <= 100_000.0 && *h <= 100_000.0) {
395 self.set_screen(w, h);
396 }
397 if let Some(ppp) = f.ppp {
398 self.pixels_per_point(ppp);
399 }
400 self.input.extend(f.events.iter().cloned());
401 for (method, params) in &f.requests {
402 let (req, _reply) = Request::new(method.as_str(), params.clone());
403 self.runtime.inject_request(req);
404 }
405 self.frame();
406 }
407 Ok(frames.len())
408 }
409
410 pub fn request(&mut self, method: &str, params: Value) -> Reply {
414 let (req, rx) = Request::new(method, params);
415 self.runtime.inject_request(req);
416 crate::wake::wake(&self.ctx, crate::wake::Reason::CONTROL);
418 self.time += 1.0 / 120.0;
419 self.frame();
420 let limit = self.time + 600.0;
421 for _ in 0..10_000 {
422 if let Ok(r) = rx.try_recv() {
423 return self.mark_renderer(r);
424 }
425 if self.closed {
426 break;
427 }
428 let Some(due) = self.due().filter(|d| *d <= limit) else { break };
429 self.time = due.max(self.time + 1.0 / 120.0);
430 self.frame();
431 }
432 let r = rx.try_recv().unwrap_or_else(|_| Reply::err(format!("`{method}` was not answered, and no frame is pending")));
433 self.mark_renderer(r)
434 }
435
436 fn mark_renderer(&self, mut r: Reply) -> Reply {
438 if let Reply::Ok(Value::Object(o)) = &mut r
439 && o.contains_key("png")
440 {
441 o.insert("renderer".into(), Value::from(self.renderer.last_used()));
442 if let Some((adapter, backend)) = self.renderer.adapter_parts() {
443 o.insert("adapter".into(), Value::from(adapter));
444 o.insert("backend".into(), Value::from(backend));
445 }
446 o.insert("scale".into(), Value::from(self.pixels_per_point.unwrap_or(1.0)));
447 }
448 r
449 }
450
451 pub fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
453 self.request(method, params).into_result().map_err(|(e, _)| e)
454 }
455
456 #[cfg(not(target_arch = "wasm32"))]
460 pub fn run_ui(&mut self, script: &str) -> Result<ashlar_debug::Report, ashlar_debug::ScriptError> {
461 ashlar_debug::run_script(self, "script", script)
462 }
463
464 pub fn scene(&mut self, name: &str) -> Result<(), String> {
467 self.call(ashlar_control::UI_SCENE, serde_json::json!({"name": name}))?;
468 self.settle(60);
469 Ok(())
470 }
471
472 #[cfg(not(target_arch = "wasm32"))]
478 pub fn assert_snapshot(&mut self, name: &str) -> Result<ashlar_debug::SnapshotOutcome, String> {
479 self.check_snapshot(name, &ashlar_debug::Snapshots::from_env(), None)
480 }
481
482 #[cfg(not(target_arch = "wasm32"))]
485 pub fn check_snapshot(
486 &mut self,
487 name: &str,
488 snapshots: &ashlar_debug::Snapshots,
489 target: Option<Value>,
490 ) -> Result<ashlar_debug::SnapshotOutcome, String> {
491 let mut p = serde_json::json!({});
492 if let Some(t) = target {
493 p["target"] = t;
494 }
495 let shot = self.call("ui.screenshot", p)?;
496 let out = snapshots.check(name, &shot)?;
497 if let ashlar_debug::SnapshotOutcome::Skipped(note) = &out {
498 eprintln!("note: {note}");
499 }
500 Ok(out)
501 }
502}
503
504#[cfg(not(target_arch = "wasm32"))]
505impl<D: Document> ashlar_debug::Transport for Driver<D> {
506 fn call(&mut self, method: &str, params: Value) -> Result<Value, ashlar_control::ControlError> {
507 match self.request(method, params) {
508 Reply::Ok(v) => Ok(v),
509 Reply::Err { error, detail } => Err(ashlar_control::ControlError::Remote { error, detail }),
510 }
511 }
512
513 fn pause(&mut self, ms: u64) {
514 self.advance(ms as f64 / 1000.0);
515 }
516}
517
518#[cfg(not(target_arch = "wasm32"))]
520#[derive(Debug)]
521struct PathDrop(std::path::PathBuf);
522
523#[cfg(not(target_arch = "wasm32"))]
524impl egui::DroppedFile for PathDrop {
525 fn path(&self) -> &std::path::Path {
526 &self.0
527 }
528 fn bytes(&self) -> Result<Vec<u8>, String> {
529 std::fs::read(&self.0).map_err(|e| e.to_string())
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::App;
537
538 fn driver() -> Driver<i64> {
539 Driver::new(App::<i64>::new("drivertest", "0.0.0").view(|ui, _cx| drop(ui.label("hello"))).runtime())
540 }
541
542 fn edited(typing: bool) -> Driver<i64> {
544 let app = App::<i64>::new("drivertest", "0.0.0").view(move |ui, _cx| {
545 if typing {
546 let mut text = String::new();
547 ui.text_edit_singleline(&mut text).request_focus();
548 }
549 });
550 let mut d = Driver::new(app.runtime());
551 d.frame();
552 d.runtime.session_mut().edit("bump", |n| *n += 1);
553 d.frame();
554 assert!(d.runtime.session().can_undo());
555 d
556 }
557
558 #[test]
559 fn the_view_sees_who_changed_the_document() {
560 use crate::session::Origin;
561 use std::sync::{Arc, Mutex};
562 let seen: Arc<Mutex<Vec<Option<Origin>>>> = Arc::default();
563 let log = Arc::clone(&seen);
564 let app = App::<i64>::new("drivertest", "0.0.0").view(move |_ui, cx| {
565 log.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(cx.origin());
566 let _ = cx.motion("x", *cx.doc() as f32, ashlar_motion::tokens::SNAP.enter);
567 });
568 let mut d = Driver::new(app.runtime());
569 let last = |d: &mut Driver<i64>| {
570 d.frame();
571 seen.lock().unwrap_or_else(std::sync::PoisonError::into_inner).last().copied().flatten()
572 };
573 assert_eq!(last(&mut d), None);
574 d.runtime.session_mut().edit("bump", |n| *n += 1);
575 assert_eq!(last(&mut d), Some(Origin::User));
576 assert_eq!(last(&mut d), None, "only the frame after a change reports it");
577 d.runtime.session_mut().edit_merged("drag", "k", |n| *n += 1);
578 assert_eq!(last(&mut d), Some(Origin::Drag));
579 let r = d.request("engine.execute", serde_json::json!({"command": "app.undo"}));
580 assert!(r.is_ok(), "{r:?}");
581 assert_eq!(d.runtime.session().origin(), Some(Origin::Agent), "an agent's undo is the agent's change");
582 d.runtime.session_mut().undo();
583 assert_eq!(last(&mut d), Some(Origin::Undo));
584 assert!(Origin::Agent.animates() && Origin::Job.animates() && !Origin::User.animates());
585 }
586
587 #[test]
588 fn native_undo_while_typing_stays_with_the_text_field() {
589 let mut d = edited(true);
590 assert!(d.ctx().egui_wants_keyboard_input(), "the text field has the keyboard");
591 d.native_menu_pick("app.undo");
592 d.frame();
593 assert_eq!(*d.runtime.session().doc(), 1, "⌘Z while typing never reaches app.undo");
594
595 let mut d = edited(false);
596 d.native_menu_pick("app.undo");
597 d.frame();
598 assert_eq!(*d.runtime.session().doc(), 0, "without a text field the pick undoes");
599 }
600
601 #[test]
602 fn native_picks_wait_for_no_dialog() {
603 let mut d = edited(false);
604 d.native_menu_pick("file.new");
606 d.frame();
607 assert!(d.runtime.modal_open(), "the confirm dialog is up");
608 d.native_menu_pick("app.undo");
609 d.frame();
610 d.frame();
611 assert_eq!(*d.runtime.session().doc(), 1, "a pick under the confirm dialog does not run");
612 assert!(d.runtime.modal_open());
613 }
614
615 #[test]
616 fn frames_are_measured_by_phase_at_the_display_scale() {
617 let mut d = driver();
618 d.pixels_per_point(2.0);
619 d.pixels_per_point(f32::NAN);
620 d.pixels_per_point(-1.0);
621 let out = d.frame();
622 assert_eq!(out.pixels_per_point, 2.0);
623 assert!(!out.shapes.is_empty(), "the shapes are still handed back");
624 assert_eq!(d.ctx().pixels_per_point(), 2.0);
625 let s = d.runtime.stats().summary();
626 assert_eq!((s.window, s.pass.window, s.tessellate.window, s.frame_cpu.window), (1, 1, 1, 1));
627 assert!(s.frame_cpu.max_ms >= s.pass.max_ms && s.frame_cpu.max_ms >= s.tessellate.max_ms, "{s:?}");
628 assert!(s.pass.max_ms >= s.max_ms, "the pass holds the UI: {s:?}");
629 }
630
631 fn moving(x: fn(f64) -> f32, busy: fn(f64) -> bool) -> Driver<i64> {
634 use ashlar_ui::automation::{self, Details, Role};
635 let app = App::<i64>::new("drivertest", "0.0.0").view(move |ui, _cx| {
636 let t = ui.input(|i| i.time);
637 let rect = egui::Rect::from_min_size(pos2(x(t), 40.0), vec2(60.0, 20.0));
638 automation::register_fmt(ui, rect, format_args!("box"), "Box", Role::Group, true, Details::default());
639 if busy(t) {
640 ashlar_ui::wake::wake(ui.ctx(), ashlar_ui::wake::Reason::ANIMATION);
641 }
642 });
643 Driver::new(app.runtime())
644 }
645
646 #[test]
647 fn the_motion_gate_catches_a_teleport_and_frames_that_never_stop() {
648 use ashlar_ui::motion_gate::{MotionRules, Rule};
649 let rules = MotionRules::default();
650 let mut d = moving(|t| if t < 0.1 { (t * 240.0) as f32 } else { 300.0 }, |t| t < 0.1);
652 let report = d
653 .record(|d| {
654 d.advance(1.0);
655 })
656 .check(&rules);
657 assert_eq!(report.of(Rule::Pop).count(), 1, "{report}");
658 assert_eq!(report.of(Rule::Idle).count(), 0, "{report}");
659 let mut d = moving(|t| (t.min(0.25) * 240.0) as f32, |t| t < 0.25);
661 let report = d
662 .record(|d| {
663 d.advance(1.0);
664 })
665 .check(&rules);
666 assert!(report.is_clean(), "{report}");
667 let mut d = moving(|_| 10.0, |_| true);
669 let report = d
670 .record(|d| {
671 d.advance(0.5);
672 })
673 .check(&rules);
674 assert_eq!(report.of(Rule::Idle).count(), 1, "{report}");
675 assert!(report.to_string().contains("animation"), "{report}");
676 }
677
678 #[test]
679 fn grey_screenshots_say_so_and_have_the_pixel_size() {
680 let mut d = driver();
681 d.pixels_per_point(2.0);
682 d.set_screen(200.0, 100.0);
683 d.settle(10);
684 assert_eq!(d.renderer().name(), "none");
685 let shot = d.call("ui.screenshot", serde_json::json!({})).unwrap();
686 assert_eq!(shot["renderer"], "none");
687 assert_eq!((shot["width"].as_u64(), shot["height"].as_u64()), (Some(400), Some(200)));
688 assert!(d.call("app.stats", serde_json::json!({})).unwrap().get("renderer").is_none(), "only screenshot replies are marked");
689 }
690}