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}