fenestra_shell/synthetic.rs
1//! Synthetic event injection for headless testing: agents drive an [`App`]
2//! with scripted input and look at the resulting pixels.
3
4use fenestra_core::{App, InputEvent, KeyInput, Theme};
5use image::RgbaImage;
6
7/// A scripted input event for [`render_app`].
8#[derive(Debug, Clone, PartialEq)]
9pub enum SyntheticEvent {
10 /// Move the pointer to logical coordinates.
11 MouseMove {
12 /// Logical x.
13 x: f32,
14 /// Logical y.
15 y: f32,
16 },
17 /// Press the primary button.
18 MouseDown,
19 /// Release the primary button.
20 MouseUp,
21 /// Press the secondary (right) button.
22 RightDown,
23 /// Release the secondary (right) button.
24 RightUp,
25 /// Drop an OS file at the current pointer position.
26 FileDrop(std::path::PathBuf),
27 /// Press a key.
28 Key(KeyInput),
29 /// Commit text (M5).
30 Text(String),
31 /// Scroll (winit convention: positive `dy` moves content down).
32 Wheel {
33 /// Vertical delta in logical px.
34 dy: f32,
35 },
36 /// Focus next.
37 Tab,
38 /// Focus previous.
39 ShiftTab,
40 /// Modifier keys changed (shift, ctrl, alt, meta).
41 Modifiers(bool, bool, bool, bool),
42}
43
44impl From<&SyntheticEvent> for InputEvent {
45 fn from(ev: &SyntheticEvent) -> Self {
46 match ev {
47 SyntheticEvent::MouseMove { x, y } => Self::PointerMove { x: *x, y: *y },
48 SyntheticEvent::MouseDown => Self::PointerDown,
49 SyntheticEvent::MouseUp => Self::PointerUp,
50 SyntheticEvent::RightDown => Self::RightDown,
51 SyntheticEvent::RightUp => Self::RightUp,
52 SyntheticEvent::FileDrop(p) => Self::FileDrop(p.clone()),
53 SyntheticEvent::Key(k) => Self::Key(*k),
54 SyntheticEvent::Text(s) => Self::Text(s.clone()),
55 SyntheticEvent::Wheel { dy } => Self::Wheel { dy: *dy },
56 SyntheticEvent::Tab => Self::Tab,
57 SyntheticEvent::ShiftTab => Self::ShiftTab,
58 SyntheticEvent::Modifiers(shift, ctrl, alt, meta) => Self::Modifiers {
59 shift: *shift,
60 ctrl: *ctrl,
61 alt: *alt,
62 meta: *meta,
63 },
64 }
65 }
66}
67
68/// Drives an app headlessly: dispatches each event against the current
69/// view, applies the emitted messages, then renders one settle frame.
70/// Deterministic: scale 1.0, reduced motion, embedded fonts only. The
71/// requested size is clamped to the device-supported range (at least 1x1,
72/// at most the maximum texture dimension).
73///
74/// [`App::init`] runs first with a collecting [`Proxy`]; proxied messages
75/// are applied at deterministic points (before each event and before the
76/// settle frame). Messages sent from spawned threads race those drain
77/// points — keep proxy use synchronous in tests.
78///
79/// # Panics
80/// If no compute-capable GPU adapter exists or rendering fails.
81pub fn render_app<A: App>(
82 app: &mut A,
83 events: &[SyntheticEvent],
84 size: (u32, u32),
85 theme: &Theme,
86) -> RgbaImage
87where
88 A::Msg: Send,
89{
90 let mut harness = crate::Harness::new(&mut *app, theme.clone(), size);
91 for ev in events {
92 harness.input(ev.into());
93 }
94 harness.render()
95}