Skip to main content

telar_platform_headless/
platform.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use platform_core::{
6    Event, EventHandler, MultiSurfacePlatform, Platform, PlatformError, SurfaceId, WindowConfig,
7};
8
9use crate::window::HeadlessWindow;
10
11// AppHandler paces content frames off a real wall clock at 60fps; the run loop waits out this budget before
12// each redraw so the frame actually rasterizes instead of being deferred by the pacing gate.
13const FRAME_BUDGET: Duration = Duration::from_nanos(1_000_000_000 / 60);
14
15/// A shared slot the platform writes the final frame's pixels into. `Platform::run` yields no value, so a
16/// caller that wants the rendered pixels passes one of these via [`HeadlessPlatform::capture_into`] and reads
17/// it after `run` returns.
18pub type FrameSink = Arc<Mutex<Option<Vec<u8>>>>;
19
20/// The multi-surface analogue of [`FrameSink`]: each surface's final frame keyed by its [`SurfaceId`]. Passed
21/// via [`HeadlessPlatform::capture_surfaces_into`] and read after [`MultiSurfacePlatform::run_surfaces`]
22/// returns.
23pub type SurfaceFrameSink = Arc<Mutex<HashMap<SurfaceId, Vec<u8>>>>;
24
25/// A first-class, windowless [`Platform`] backend: it drives the exact same [`EventHandler`] seam as the winit
26/// backend (`on_resume` → scripted `on_event`s → `on_redraw`s → `on_suspend`) against a [`HeadlessWindow`],
27/// with no event loop, GPU swapchain, or display server. Because the handler builds an offscreen renderer for
28/// a headless window, this routes a *real* app end-to-end (event → reactive → layout → render → pixels) and is
29/// both the reference `Platform` impl and a deterministic integration-test harness.
30///
31/// Construct it with the surface size, optionally script input events and a frame count, and optionally
32/// capture the final frame's pixels; then drive it via [`crate::run`-style entry points] — e.g.
33/// `telar::run_with_platform(HeadlessPlatform::new(w, h).capture_into(sink), …)`.
34pub struct HeadlessPlatform {
35    width: u32,
36    height: u32,
37    scale_factor: f64,
38    prefers_dark: Option<bool>,
39    events: Vec<Event>,
40    frames: u32,
41    sink: Option<FrameSink>,
42    surface_sink: Option<SurfaceFrameSink>,
43}
44
45impl HeadlessPlatform {
46    /// A `width`×`height` offscreen surface at scale 1.0, no scripted events, one render frame.
47    pub fn new(width: u32, height: u32) -> Self {
48        Self {
49            width,
50            height,
51            scale_factor: 1.0,
52            prefers_dark: None,
53            events: Vec::new(),
54            frames: 1,
55            sink: None,
56            surface_sink: None,
57        }
58    }
59
60    /// Report a HiDPI scale factor to the app (drives logical-vs-physical sizing).
61    pub fn with_scale_factor(mut self, scale_factor: f64) -> Self {
62        self.scale_factor = scale_factor;
63        self
64    }
65
66    /// Report an OS light/dark preference (`Some(true)` = dark) before the tree mounts.
67    pub fn with_prefers_dark(mut self, prefers_dark: Option<bool>) -> Self {
68        self.prefers_dark = prefers_dark;
69        self
70    }
71
72    /// Scripted input events delivered (in order) after `on_resume`, each as its own loop iteration.
73    pub fn with_events(mut self, events: Vec<Event>) -> Self {
74        self.events = events;
75        self
76    }
77
78    /// How many render frames to drive after the scripted events. Defaults to 1. Use more to let animations or
79    /// multi-pass reactive settling converge before the final pixels are captured.
80    pub fn with_frames(mut self, frames: u32) -> Self {
81        self.frames = frames;
82        self
83    }
84
85    /// Capture the final frame's premultiplied RGBA8 pixels into `sink`, readable after `run` returns.
86    pub fn capture_into(mut self, sink: FrameSink) -> Self {
87        self.sink = Some(sink);
88        self
89    }
90
91    /// Capture each surface's final frame into `sink`, keyed by [`SurfaceId`], readable after
92    /// [`MultiSurfacePlatform::run_surfaces`] returns. Only consulted by the multi-surface path.
93    pub fn capture_surfaces_into(mut self, sink: SurfaceFrameSink) -> Self {
94        self.surface_sink = Some(sink);
95        self
96    }
97}
98
99impl Platform for HeadlessPlatform {
100    type Window = HeadlessWindow;
101
102    fn run<H: EventHandler<HeadlessWindow>>(
103        self,
104        _config: WindowConfig,
105        mut handler: H,
106    ) -> Result<(), PlatformError> {
107        let window = HeadlessWindow::with_options(
108            self.width,
109            self.height,
110            self.scale_factor,
111            self.prefers_dark,
112        );
113
114        // Mirror the winit loop's iteration shape (new_events → dispatch → about_to_wait) so the handler's
115        // reactive batching brackets stay balanced exactly as they do under winit.
116        handler.new_events();
117        let resumed = handler.on_resume(&window);
118        handler.about_to_wait();
119        if !resumed {
120            return Err(PlatformError(
121                "headless on_resume returned false (renderer initialization failed)".to_string(),
122            ));
123        }
124
125        for event in self.events {
126            handler.new_events();
127            handler.on_event(event, &window);
128            handler.about_to_wait();
129        }
130
131        for _ in 0..self.frames.max(1) {
132            handler.new_events();
133            std::thread::sleep(FRAME_BUDGET);
134            handler.on_redraw(&window);
135            handler.about_to_wait();
136        }
137
138        if let Some(sink) = &self.sink
139            && let Some(pixels) = handler.last_frame_rgba()
140        {
141            *sink.lock().unwrap() = Some(pixels);
142        }
143
144        handler.on_suspend();
145        Ok(())
146    }
147}
148
149impl MultiSurfacePlatform for HeadlessPlatform {
150    type Window = HeadlessWindow;
151
152    fn run_surfaces<H, F>(
153        self,
154        surfaces: Vec<(SurfaceId, WindowConfig)>,
155        factory: F,
156    ) -> Result<(), PlatformError>
157    where
158        H: EventHandler<HeadlessWindow> + 'static,
159        F: Fn(SurfaceId) -> H + 'static,
160    {
161        // M3 single-thread multi-surface: every surface shares this thread and one reactive runtime. The
162        // handler factory (see `run_multi_with_platform`) gives each handler its own `Surface` world, which
163        // the handler activates around every lifecycle call — so the surfaces stay isolated without a thread
164        // apiece, and a signal shared between them re-runs each surface's effects under its own context.
165        let frames = self.frames.max(1);
166        let sink = self.surface_sink.clone();
167
168        // Build every handler and window up front, on this thread.
169        let mut states: Vec<(SurfaceId, HeadlessWindow, H)> = Vec::with_capacity(surfaces.len());
170        for (id, config) in surfaces {
171            let window = HeadlessWindow::new(config.width, config.height);
172            states.push((id, window, factory(id)));
173        }
174
175        // Resume each surface; a surface whose renderer fails or whose build panics is dropped, not fatal to
176        // the run (T-4.2 quarantine). The new_events/about_to_wait bracket keeps the reactive batch balanced —
177        // and stays balanced even if the build panics, because about_to_wait's end_batch runs regardless (the
178        // panic is caught) and T-1.3 leaves the shared runtime consistent. Only effective under panic=unwind.
179        states.retain_mut(|(id, window, handler)| {
180            handler.new_events();
181            let resumed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
182                handler.on_resume(window)
183            }));
184            let _ =
185                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler.about_to_wait()));
186            if resumed.is_err() {
187                eprintln!("surface {} panicked during build; unmounting it", id.0);
188            }
189            matches!(resumed, Ok(true))
190        });
191
192        // Drive the scripted frame count: pace once per round (so every surface's frame budget has elapsed and
193        // its redraw actually rasterizes), then redraw every surface. A surface that panics mid-frame is
194        // unmounted so the rest keep rendering.
195        for _ in 0..frames {
196            std::thread::sleep(FRAME_BUDGET);
197            states.retain_mut(|(id, window, handler)| {
198                handler.new_events();
199                let drawn = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
200                    handler.on_redraw(window)
201                }));
202                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
203                    handler.about_to_wait()
204                }));
205                if drawn.is_err() {
206                    eprintln!("surface {} panicked during redraw; unmounting it", id.0);
207                }
208                drawn.is_ok()
209            });
210        }
211
212        if let Some(sink) = &sink {
213            for (id, _, handler) in &mut states {
214                if let Some(pixels) = handler.last_frame_rgba() {
215                    sink.lock().unwrap().insert(*id, pixels);
216                }
217            }
218        }
219
220        for (_, _, handler) in &mut states {
221            handler.on_suspend();
222        }
223        Ok(())
224    }
225}