Skip to main content

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 std::sync::{Arc, Mutex, PoisonError};
5
6use fenestra_core::{App, FrameState, InputEvent, KeyInput, Proxy, Theme, build_frame, dispatch};
7use image::RgbaImage;
8
9use crate::element_render::with_fonts;
10use crate::with_headless;
11
12/// A scripted input event for [`render_app`].
13#[derive(Debug, Clone, PartialEq)]
14pub enum SyntheticEvent {
15    /// Move the pointer to logical coordinates.
16    MouseMove {
17        /// Logical x.
18        x: f32,
19        /// Logical y.
20        y: f32,
21    },
22    /// Press the primary button.
23    MouseDown,
24    /// Release the primary button.
25    MouseUp,
26    /// Press the secondary (right) button.
27    RightDown,
28    /// Release the secondary (right) button.
29    RightUp,
30    /// Drop an OS file at the current pointer position.
31    FileDrop(std::path::PathBuf),
32    /// Press a key.
33    Key(KeyInput),
34    /// Commit text (M5).
35    Text(String),
36    /// Scroll (winit convention: positive `dy` moves content down).
37    Wheel {
38        /// Vertical delta in logical px.
39        dy: f32,
40    },
41    /// Focus next.
42    Tab,
43    /// Focus previous.
44    ShiftTab,
45}
46
47impl From<&SyntheticEvent> for InputEvent {
48    fn from(ev: &SyntheticEvent) -> Self {
49        match ev {
50            SyntheticEvent::MouseMove { x, y } => Self::PointerMove { x: *x, y: *y },
51            SyntheticEvent::MouseDown => Self::PointerDown,
52            SyntheticEvent::MouseUp => Self::PointerUp,
53            SyntheticEvent::RightDown => Self::RightDown,
54            SyntheticEvent::RightUp => Self::RightUp,
55            SyntheticEvent::FileDrop(p) => Self::FileDrop(p.clone()),
56            SyntheticEvent::Key(k) => Self::Key(*k),
57            SyntheticEvent::Text(s) => Self::Text(s.clone()),
58            SyntheticEvent::Wheel { dy } => Self::Wheel { dy: *dy },
59            SyntheticEvent::Tab => Self::Tab,
60            SyntheticEvent::ShiftTab => Self::ShiftTab,
61        }
62    }
63}
64
65/// Drives an app headlessly: dispatches each event against the current
66/// view, applies the emitted messages, then renders one settle frame.
67/// Deterministic: scale 1.0, reduced motion, embedded fonts only. The
68/// requested size is clamped to the device-supported range (at least 1x1,
69/// at most the maximum texture dimension).
70///
71/// [`App::init`] runs first with a collecting [`Proxy`]; proxied messages
72/// are applied at deterministic points (before each event and before the
73/// settle frame). Messages sent from spawned threads race those drain
74/// points — keep proxy use synchronous in tests.
75///
76/// # Panics
77/// If no compute-capable GPU adapter exists or rendering fails.
78pub fn render_app<A: App>(
79    app: &mut A,
80    events: &[SyntheticEvent],
81    size: (u32, u32),
82    theme: &Theme,
83) -> RgbaImage
84where
85    A::Msg: Send,
86{
87    // Clamp before layout so the frames and the texture agree on the size.
88    let size =
89        with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
90    let pending: Arc<Mutex<Vec<A::Msg>>> = Arc::new(Mutex::new(Vec::new()));
91    let sink = Arc::clone(&pending);
92    app.init(Proxy::new(move |msg| {
93        sink.lock()
94            .unwrap_or_else(PoisonError::into_inner)
95            .push(msg);
96    }));
97    fn drain<A: App>(app: &mut A, pending: &Mutex<Vec<A::Msg>>) {
98        let msgs = std::mem::take(&mut *pending.lock().unwrap_or_else(PoisonError::into_inner));
99        for msg in msgs {
100            app.update(msg);
101        }
102    }
103    let mut state = FrameState::new();
104    state.reduced_motion = true;
105    #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
106    let logical = (size.0 as f32, size.1 as f32);
107
108    let scene = with_fonts(|fonts| {
109        for ev in events {
110            drain(app, &pending);
111            let view = app.view();
112            let frame = build_frame(&view, theme, fonts, &mut state, logical, 1.0);
113            let result = dispatch(&view, &frame, &mut state, fonts, ev.into());
114            for msg in result.msgs {
115                app.update(msg);
116            }
117        }
118        // Apply late proxied messages, then one settle frame.
119        drain(app, &pending);
120        let view = app.view();
121        let frame = build_frame(&view, theme, fonts, &mut state, logical, 1.0);
122        frame.paint(fonts, &mut state)
123    });
124    with_headless(|headless| headless.render(&scene, size.0, size.1, theme.bg))
125        .expect("headless renderer unavailable")
126        .expect("headless render failed")
127}