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}