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    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` → `on_redraw`s → `on_suspend`) against a [`HeadlessWindow`], with no event loop, GPU
27/// swapchain, or display server. Because the handler builds an offscreen renderer for a headless window, this
28/// routes a *real* app end-to-end (reactive → layout → render → pixels) and is both the reference `Platform`
29/// impl and a deterministic integration-test harness.
30///
31/// Construct it with the surface size and optionally a frame count and a sink to capture the final frame's
32/// 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    frames: u32,
38    sink: Option<FrameSink>,
39    surface_sink: Option<SurfaceFrameSink>,
40}
41
42impl HeadlessPlatform {
43    /// A `width`×`height` offscreen surface at scale 1.0, one render frame.
44    pub fn new(width: u32, height: u32) -> Self {
45        Self {
46            width,
47            height,
48            frames: 1,
49            sink: None,
50            surface_sink: None,
51        }
52    }
53
54    /// How many render frames to drive. Defaults to 1. Use more to let animations or multi-pass reactive
55    /// settling converge before the final pixels are captured.
56    pub fn with_frames(mut self, frames: u32) -> Self {
57        self.frames = frames;
58        self
59    }
60
61    /// Capture the final frame's premultiplied RGBA8 pixels into `sink`, readable after `run` returns.
62    pub fn capture_into(mut self, sink: FrameSink) -> Self {
63        self.sink = Some(sink);
64        self
65    }
66
67    /// Capture each surface's final frame into `sink`, keyed by [`SurfaceId`], readable after
68    /// [`MultiSurfacePlatform::run_surfaces`] returns. Only consulted by the multi-surface path.
69    pub fn capture_surfaces_into(mut self, sink: SurfaceFrameSink) -> Self {
70        self.surface_sink = Some(sink);
71        self
72    }
73}
74
75impl Platform for HeadlessPlatform {
76    type Window = HeadlessWindow;
77
78    fn run<H: EventHandler<HeadlessWindow>>(
79        self,
80        _config: WindowConfig,
81        mut handler: H,
82    ) -> Result<(), PlatformError> {
83        let window = HeadlessWindow::with_options(self.width, self.height, 1.0, None);
84
85        // Mirror the winit loop's iteration shape (new_events → dispatch → about_to_wait) so the handler's
86        // reactive batching brackets stay balanced exactly as they do under winit.
87        handler.new_events();
88        let resumed = handler.on_resume(&window);
89        handler.about_to_wait();
90        if !resumed {
91            return Err(PlatformError(
92                "headless on_resume returned false (renderer initialization failed)".to_string(),
93            ));
94        }
95
96        for _ in 0..self.frames.max(1) {
97            handler.new_events();
98            std::thread::sleep(FRAME_BUDGET);
99            handler.on_redraw(&window);
100            handler.about_to_wait();
101        }
102
103        if let Some(sink) = &self.sink
104            && let Some(pixels) = handler.last_frame_rgba()
105        {
106            *sink.lock().unwrap() = Some(pixels);
107        }
108
109        handler.on_suspend();
110        Ok(())
111    }
112}
113
114impl MultiSurfacePlatform for HeadlessPlatform {
115    type Window = HeadlessWindow;
116
117    fn run_surfaces<H, F>(
118        self,
119        surfaces: Vec<(SurfaceId, WindowConfig)>,
120        factory: F,
121    ) -> Result<(), PlatformError>
122    where
123        H: EventHandler<HeadlessWindow> + 'static,
124        F: Fn(SurfaceId) -> H + 'static,
125    {
126        // M3 single-thread multi-surface: every surface shares this thread and one reactive runtime. The
127        // handler factory (see `run_multi_with_platform`) gives each handler its own `Surface` world, which
128        // the handler activates around every lifecycle call — so the surfaces stay isolated without a thread
129        // apiece, and a signal shared between them re-runs each surface's effects under its own context.
130        let frames = self.frames.max(1);
131        let sink = self.surface_sink.clone();
132
133        // Build every handler and window up front, on this thread.
134        let mut states: Vec<(SurfaceId, HeadlessWindow, H)> = Vec::with_capacity(surfaces.len());
135        for (id, config) in surfaces {
136            let window = HeadlessWindow::new(config.width, config.height);
137            states.push((id, window, factory(id)));
138        }
139
140        // Resume each surface; a surface whose renderer fails or whose build panics is dropped, not fatal to
141        // the run (T-4.2 quarantine). The new_events/about_to_wait bracket keeps the reactive batch balanced —
142        // and stays balanced even if the build panics, because about_to_wait's end_batch runs regardless (the
143        // panic is caught) and T-1.3 leaves the shared runtime consistent. Only effective under panic=unwind.
144        states.retain_mut(|(id, window, handler)| {
145            handler.new_events();
146            let resumed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
147                handler.on_resume(window)
148            }));
149            let _ =
150                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler.about_to_wait()));
151            if resumed.is_err() {
152                eprintln!("surface {} panicked during build; unmounting it", id.0);
153            }
154            matches!(resumed, Ok(true))
155        });
156
157        // Drive the scripted frame count: pace once per round (so every surface's frame budget has elapsed and
158        // its redraw actually rasterizes), then redraw every surface. A surface that panics mid-frame is
159        // unmounted so the rest keep rendering.
160        for _ in 0..frames {
161            std::thread::sleep(FRAME_BUDGET);
162            states.retain_mut(|(id, window, handler)| {
163                handler.new_events();
164                let drawn = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
165                    handler.on_redraw(window)
166                }));
167                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
168                    handler.about_to_wait()
169                }));
170                if drawn.is_err() {
171                    eprintln!("surface {} panicked during redraw; unmounting it", id.0);
172                }
173                drawn.is_ok()
174            });
175        }
176
177        if let Some(sink) = &sink {
178            for (id, _, handler) in &mut states {
179                if let Some(pixels) = handler.last_frame_rgba() {
180                    sink.lock().unwrap().insert(*id, pixels);
181                }
182            }
183        }
184
185        for (_, _, handler) in &mut states {
186            handler.on_suspend();
187        }
188        Ok(())
189    }
190}