1use std::{
4 io::{BufRead, BufReader, Write},
5 num::NonZero,
6 path::Path,
7 time::Duration,
8};
9
10use serde::Deserialize;
11use serde_json::{Value, json};
12use tracing::info;
13
14use crate::{
15 NodeId, Px, PxPosition, PxSize,
16 build_tree::build_component_tree,
17 component_tree::{ComponentNodeMetaDatas, ComponentNodeTree, ComputeMode, ComputeParams},
18 context::reset_context_read_dependencies,
19 cursor::{
20 CursorEventContent, CursorState, GestureState, MOUSE_POINTER_ID, PointerChange, PointerId,
21 PressKeyEventType, ScrollDeltaUnit, ScrollEventContent, ScrollEventSource,
22 },
23 dp::set_scale_factor,
24 focus::flush_pending_focus_callbacks,
25 ime_state::ImeState,
26 keyboard_state::KeyboardState,
27 pipeline_context::PipelineContext,
28 render_module::RenderModule,
29 renderer::{
30 MAX_FRAME_SETTLING_RETRIES, RenderCore, TesseraConfig, core::OffscreenReadbackError,
31 frame_settling_can_progress,
32 },
33 runtime::{
34 TesseraRuntime, begin_frame_build_invalidations, begin_frame_clock,
35 clear_persistent_focus_handles, clear_redraw_waker, has_pending_build_invalidations,
36 has_pending_layout_invalidations, has_ready_build_invalidations,
37 prune_persistent_focus_handles, reset_build_invalidations, reset_component_replay_tracking,
38 reset_focus_read_dependencies, reset_frame_clock, reset_layout_dirty_tracking,
39 reset_render_slot_read_dependencies, reset_state_read_dependencies,
40 take_layout_dirty_nodes, tick_frame_nanos_receivers,
41 },
42 time::Instant,
43};
44
45#[cfg(feature = "profiling")]
46use crate::profiler::{
47 FrameMeta, begin_frame as profiler_begin_frame, begin_frame_summary,
48 end_frame as profiler_end_frame, submit_frame_meta, submit_frame_summary,
49};
50
51pub const HEADLESS_ENV: &str = "TESSERA_HEADLESS";
53pub const HEADLESS_WIDTH_ENV: &str = "TESSERA_HEADLESS_WIDTH";
55pub const HEADLESS_HEIGHT_ENV: &str = "TESSERA_HEADLESS_HEIGHT";
57pub const HEADLESS_SCALE_FACTOR_ENV: &str = "TESSERA_HEADLESS_SCALE_FACTOR";
60pub const HEADLESS_FRAME_TIME_MS_ENV: &str = "TESSERA_HEADLESS_FRAME_TIME_MS";
62
63pub fn headless_mode_requested() -> bool {
65 match std::env::var(HEADLESS_ENV) {
66 Ok(value) => {
67 let value = value.trim();
68 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
69 }
70 Err(_) => false,
71 }
72}
73
74#[derive(Debug)]
76pub enum HeadlessError {
77 Io(std::io::Error),
79 Json(serde_json::Error),
81 GpuInit(String),
83 Readback(OffscreenReadbackError),
85}
86
87impl std::fmt::Display for HeadlessError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 Self::Io(err) => write!(f, "headless io error: {err}"),
91 Self::Json(err) => write!(f, "headless protocol error: {err}"),
92 Self::GpuInit(err) => write!(f, "headless gpu init failed: {err}"),
93 Self::Readback(err) => write!(f, "headless readback failed: {err}"),
94 }
95 }
96}
97
98impl std::error::Error for HeadlessError {}
99
100impl From<std::io::Error> for HeadlessError {
101 fn from(err: std::io::Error) -> Self {
102 Self::Io(err)
103 }
104}
105
106impl From<serde_json::Error> for HeadlessError {
107 fn from(err: serde_json::Error) -> Self {
108 Self::Json(err)
109 }
110}
111
112impl From<OffscreenReadbackError> for HeadlessError {
113 fn from(err: OffscreenReadbackError) -> Self {
114 Self::Readback(err)
115 }
116}
117
118#[derive(Clone, Debug)]
120pub struct HeadlessConfig {
121 pub width: u32,
123 pub height: u32,
125 pub scale_factor: f64,
127 pub sample_count: u32,
129 pub max_non_sampler_bindings: u32,
131 pub format: wgpu::TextureFormat,
134 pub frame_time: Duration,
136}
137
138impl Default for HeadlessConfig {
139 fn default() -> Self {
140 Self {
141 width: 800,
142 height: 600,
143 scale_factor: 1.0,
144 sample_count: 1,
145 max_non_sampler_bindings: crate::renderer::DEFAULT_MAX_NON_SAMPLER_BINDINGS,
146 format: wgpu::TextureFormat::Rgba8UnormSrgb,
147 frame_time: Duration::from_nanos(16_666_667),
148 }
149 }
150}
151
152pub struct HeadlessRenderer {
157 session: crate::RuntimeSession,
158 core: RenderCore,
159 entry: Box<dyn Fn()>,
160 cursor: CursorState,
161 keyboard: KeyboardState,
162 ime: ImeState,
163 size: PxSize,
164 physical_size: winit::dpi::PhysicalSize<u32>,
165 frame_index: u64,
166 frame_origin: Instant,
167 current_frame_nanos: u64,
168 frame_time: Duration,
169 diagnostics: crate::component_tree::LayoutFrameDiagnostics,
170 work: crate::FrameWork,
171 work_totals: crate::WorkTotals,
172 frame_clock_diagnostics: crate::FrameClockDiagnostics,
173 frame_graphics_work: crate::GraphicsWorkDiagnostics,
174
175 #[cfg(feature = "profiling")]
176 profile_summary: bool,
177}
178
179impl HeadlessRenderer {
180 pub fn gpu_instance(&self) -> &wgpu::Instance {
182 &self.core.instance
183 }
184
185 pub fn resource_snapshot(&self) -> Result<Value, wgpu::PollError> {
190 let device = self.core.device();
191 device.poll(wgpu::PollType::wait_indefinitely())?;
192 let adapter = device.adapter_info();
193 let byte_counters = matches!(adapter.backend, wgpu::Backend::Vulkan | wgpu::Backend::Dx12);
194 let counters = device.get_internal_counters().hal;
195 let resources = self.core.instance.generate_report();
196 let allocator = device.generate_allocator_report().map(|report| {
197 json!({
198 "allocated_bytes": report.total_allocated_bytes,
199 "reserved_bytes": report.total_reserved_bytes,
200 "allocations": report.allocations.iter().map(|allocation| json!({
201 "name": allocation.name,
202 "bytes": allocation.size,
203 })).collect::<Vec<_>>(),
204 })
205 });
206 Ok(json!({
207 "adapter": adapter.name,
208 "backend": format!("{:?}", adapter.backend),
209 "max_non_sampler_bindings": device.limits().max_non_sampler_bindings,
210 "buffers": resources.as_ref().map(|r| r.hub.buffers.num_kept_from_user),
211 "textures": resources.as_ref().map(|r| r.hub.textures.num_kept_from_user),
212 "texture_views": resources.as_ref().map(|r| r.hub.texture_views.num_kept_from_user),
213 "bind_groups": resources.as_ref().map(|r| r.hub.bind_groups.num_kept_from_user),
214 "render_pipelines": resources.as_ref().map(|r| r.hub.render_pipelines.num_kept_from_user),
215 "compute_pipelines": resources.as_ref().map(|r| r.hub.compute_pipelines.num_kept_from_user),
216 "buffer_bytes": byte_counters.then(|| counters.buffer_memory.read()),
217 "texture_bytes": byte_counters.then(|| counters.texture_memory.read()),
218 "allocator": allocator,
219 "graphics_work": self.graphics_work(),
220 }))
221 }
222
223 pub fn graphics_work(&self) -> crate::GraphicsWorkDiagnostics {
225 self.core.resources().graphics.diagnostics()
226 }
227
228 pub fn frame_graphics_work(&self) -> &crate::GraphicsWorkDiagnostics {
230 &self.frame_graphics_work
231 }
232 pub fn session_id(&self) -> crate::RuntimeSessionId {
234 self.session.identity()
235 }
236
237 pub fn run<R>(&self, work: impl FnOnce() -> R) -> R {
239 self.session.run(work)
240 }
241
242 pub fn new<Output: 'static>(
245 entry: Box<dyn Fn() -> Output>,
246 modules: Vec<Box<dyn RenderModule>>,
247 config: HeadlessConfig,
248 ) -> Result<Self, HeadlessError> {
249 let session = crate::RuntimeSession::for_root();
250 session.activate();
251 let width = config.width.max(1);
252 let height = config.height.max(1);
253 let physical_size = winit::dpi::PhysicalSize::new(width, height);
254
255 let mut core = pollster::block_on(RenderCore::new_offscreen(
256 physical_size,
257 config.format,
258 config.sample_count,
259 config.max_non_sampler_bindings,
260 ));
261
262 let scale_factor = normalize_scale_factor(config.scale_factor);
263 set_scale_factor(scale_factor);
264
265 {
266 let mut context = PipelineContext::new(&mut core);
267 for module in &modules {
268 module.register_pipelines(&mut context);
269 }
270 }
271
272 reset_headless_runtime(physical_size);
273
274 info!(
275 "Headless renderer initialized at {}x{} ({} samples, {:?})",
276 width, height, config.sample_count, config.format
277 );
278
279 Ok(Self {
280 session,
281 core,
282 entry: Box::new(move || {
283 entry();
284 }),
285 cursor: CursorState::default(),
286 keyboard: KeyboardState::default(),
287 ime: ImeState::default(),
288 size: PxSize::new(Px(width as i32), Px(height as i32)),
289 physical_size,
290 frame_index: 0,
291 frame_origin: Instant::now(),
292 current_frame_nanos: 0,
293 frame_time: config.frame_time.max(Duration::from_nanos(1)),
294 diagnostics: Default::default(),
295 work: Default::default(),
296 work_totals: Default::default(),
297 frame_clock_diagnostics: Default::default(),
298 frame_graphics_work: Default::default(),
299
300 #[cfg(feature = "profiling")]
301 profile_summary: false,
302 })
303 }
304
305 pub fn physical_size(&self) -> winit::dpi::PhysicalSize<u32> {
307 let _session = self.session.enter();
308 self.physical_size
309 }
310
311 pub fn frame_index(&self) -> u64 {
313 let _session = self.session.enter();
314 self.frame_index
315 }
316
317 pub fn diagnostics(&self) -> crate::component_tree::LayoutFrameDiagnostics {
319 let _session = self.session.enter();
320 self.diagnostics
321 }
322
323 pub fn inject_pointer(&mut self, change: PointerChange) {
328 let _session = self.session.enter();
329 if let Some(position) = change.position_snapshot() {
330 self.cursor
331 .update_pointer_position(change.pointer_id, position);
332 }
333 self.cursor.push_event(change);
334 }
335
336 pub fn pointer_move(&mut self, pointer_id: PointerId, position: PxPosition) {
338 let _session = self.session.enter();
339 self.cursor.update_pointer_position(pointer_id, position);
340 self.cursor.push_event(PointerChange {
341 timestamp: Instant::now(),
342 pointer_id,
343 content: CursorEventContent::Moved(position),
344 gesture_state: GestureState::TapCandidate,
345 sampled_position: Default::default(),
346 consumed: false,
347 tap_claimed: false,
348 scroll_discrete: None,
349 });
350 }
351
352 pub fn pointer_press(
354 &mut self,
355 pointer_id: PointerId,
356 position: PxPosition,
357 button: PressKeyEventType,
358 ) {
359 let _session = self.session.enter();
360 self.cursor.update_pointer_position(pointer_id, position);
361 self.cursor.push_event(PointerChange {
362 timestamp: Instant::now(),
363 pointer_id,
364 content: CursorEventContent::Pressed(button),
365 gesture_state: GestureState::TapCandidate,
366 sampled_position: Default::default(),
367 consumed: false,
368 tap_claimed: false,
369 scroll_discrete: None,
370 });
371 }
372
373 pub fn pointer_release(
375 &mut self,
376 pointer_id: PointerId,
377 position: PxPosition,
378 button: PressKeyEventType,
379 ) {
380 let _session = self.session.enter();
381 self.cursor.update_pointer_position(pointer_id, position);
382 self.cursor.push_event(PointerChange {
383 timestamp: Instant::now(),
384 pointer_id,
385 content: CursorEventContent::Released(button),
386 gesture_state: GestureState::TapCandidate,
387 sampled_position: Default::default(),
388 consumed: false,
389 tap_claimed: false,
390 scroll_discrete: None,
391 });
392 }
393
394 pub fn pointer_scroll(
396 &mut self,
397 pointer_id: PointerId,
398 position: PxPosition,
399 delta_x: f32,
400 delta_y: f32,
401 ) {
402 let _session = self.session.enter();
403 self.cursor.update_pointer_position(pointer_id, position);
404 self.cursor.push_event(PointerChange {
405 timestamp: Instant::now(),
406 pointer_id,
407 content: CursorEventContent::Scroll(ScrollEventContent {
408 delta_x,
409 delta_y,
410 unit: ScrollDeltaUnit::Pixel,
411 source: ScrollEventSource::Wheel,
412 }),
413 gesture_state: GestureState::TapCandidate,
414 sampled_position: Default::default(),
415 consumed: false,
416 tap_claimed: false,
417 scroll_discrete: None,
418 });
419 }
420
421 pub fn touch_start(&mut self, touch_id: u64, position: PxPosition) {
423 let _session = self.session.enter();
424 self.cursor.handle_touch_start(touch_id, position);
425 }
426
427 pub fn touch_move(&mut self, touch_id: u64, position: PxPosition) {
429 let _session = self.session.enter();
430 if let Some(scroll) = self.cursor.handle_touch_move(touch_id, position) {
431 self.cursor.push_event(scroll);
432 }
433 }
434
435 pub fn touch_end(&mut self, touch_id: u64) {
437 let _session = self.session.enter();
438 self.cursor.handle_touch_end(touch_id);
439 }
440
441 pub fn touch_cancel(&mut self, touch_id: u64) {
443 let _session = self.session.enter();
444 self.cursor.handle_touch_cancel(touch_id);
445 }
446
447 pub fn inject_key_event(&mut self, event: impl Into<crate::KeyboardEvent>) {
449 let _session = self.session.enter();
450 let event = event.into();
451 TesseraRuntime::with_mut(|runtime| {
452 runtime
453 .component_tree
454 .focus_owner_mut()
455 .record_keyboard_input(event.state, Instant::now())
456 });
457 self.keyboard.push_event(event);
458 }
459
460 pub fn press_key(&mut self, key: winit::keyboard::NamedKey) {
462 self.inject_key_event(crate::KeyboardEvent::named(
463 key,
464 winit::event::ElementState::Pressed,
465 ));
466 }
467
468 pub fn set_key_modifiers(&mut self, modifiers: winit::keyboard::ModifiersState) {
470 let _session = self.session.enter();
471 self.keyboard.update_modifiers(modifiers);
472 }
473
474 pub fn inject_ime_event(&mut self, event: winit::event::Ime) {
476 let _session = self.session.enter();
477 self.ime.push_event(event);
478 }
479
480 pub fn inject_text(&mut self, text: &str) {
482 let _session = self.session.enter();
483 self.ime
484 .push_event(winit::event::Ime::Commit(text.to_string()));
485 }
486
487 pub fn step_frame(&mut self) {
489 let _session = self.session.enter();
490 #[cfg(feature = "profiling")]
491 let wait_timer = Instant::now();
492 if self.frame_index.is_multiple_of(2) {
494 self.core
495 .device()
496 .poll(wgpu::PollType::wait_indefinitely())
497 .expect("headless GPU frame completion failed");
498 }
499 #[cfg(feature = "profiling")]
500 let wait_duration = wait_timer.elapsed();
501 TesseraRuntime::with_mut(|runtime| runtime.component_tree.begin_frame_diagnostics());
502 let graphics_before = self.graphics_work();
503 crate::work::begin();
504 self.current_frame_nanos = self
505 .current_frame_nanos
506 .saturating_add(self.frame_time.as_nanos().min(u64::MAX as u128) as u64);
507 let frame_time = self.frame_origin + Duration::from_nanos(self.current_frame_nanos);
508 #[cfg(feature = "profiling")]
509 if self.profile_summary {
510 begin_frame_summary(self.frame_index);
511 } else {
512 profiler_begin_frame(self.frame_index);
513 }
514 #[cfg(feature = "profiling")]
515 let frame_timer = Instant::now();
516 begin_frame_clock(frame_time);
517 begin_frame_build_invalidations();
518 crate::asset::settle();
519 tick_frame_nanos_receivers();
522
523 let mut _build_tree_result = build_component_tree(&self.entry);
524
525 #[cfg(feature = "profiling")]
526 let initial_build_duration = _build_tree_result.duration();
527 #[cfg(feature = "profiling")]
528 let draw_timer = Instant::now();
529 let cursor_position = self.cursor.position();
530 let mut pointer_changes = Some(self.cursor.take_events());
531 let mut keyboard_events = Some(self.keyboard.take_events());
532 let mut ime_events = Some(self.ime.take_events());
533 let modifiers = self.keyboard.modifiers();
534 let screen_size = self.size;
535 let mut retry_focus_move = None;
536 let mut retry_focus_reveal = false;
537 let mut retries = 0;
538 let mut _record_cost = Duration::ZERO;
539 let mut _diagnostics = crate::component_tree::LayoutFrameDiagnostics::default();
540 let graph = loop {
541 let layout_dirty_nodes = take_layout_dirty_nodes();
542 let (graph, _requests, diagnostics, record_cost, next_move, next_reveal, next_keyboard) =
543 TesseraRuntime::with_mut(|runtime| {
544 runtime.component_tree.compute(
545 ComputeParams {
546 screen_size,
547 cursor_position,
548 pointer_changes: pointer_changes.take().unwrap_or_default(),
549 keyboard_events: keyboard_events.take().unwrap_or_default(),
550 ime_events: ime_events.take().unwrap_or_default(),
551 retry_focus_move,
552 retry_focus_reveal,
553 modifiers,
554 layout_dirty_nodes,
555 },
556 ComputeMode::Full,
557 )
558 });
559 _record_cost += record_cost;
560 _diagnostics.accumulate_pass(diagnostics);
561 flush_pending_focus_callbacks();
562 crate::component_tree::flush_pending_globally_positioned_callbacks();
563 let next_keyboard = next_keyboard
564 || TesseraRuntime::with(|runtime| {
565 runtime.component_tree.has_pending_focus_reveal()
566 });
567 let focus_retry = next_move.is_some() || next_reveal || next_keyboard;
568 if !focus_retry && !frame_settling_can_progress(false) {
569 break graph;
570 }
571 if retries >= MAX_FRAME_SETTLING_RETRIES
572 || (focus_retry && !frame_settling_can_progress(next_keyboard))
573 {
574 if next_reveal
575 && (has_pending_build_invalidations() || has_pending_layout_invalidations())
576 {
577 TesseraRuntime::with_mut(|runtime| {
578 runtime
579 .component_tree
580 .focus_owner_mut()
581 .retain_reveal_retry();
582 });
583 }
584 break graph;
585 }
586 retries += 1;
587 retry_focus_move = next_move;
588 retry_focus_reveal = next_reveal;
589 if focus_retry || has_ready_build_invalidations() {
590 _build_tree_result.absorb_retry(build_component_tree(&self.entry));
591 }
592 };
593 self.diagnostics = _diagnostics;
594 prune_persistent_focus_handles();
595 #[cfg(feature = "profiling")]
596 let draw_duration = draw_timer.elapsed().saturating_sub(
597 _build_tree_result
598 .duration()
599 .saturating_sub(initial_build_duration),
600 );
601
602 #[cfg(feature = "profiling")]
603 let render_timer = Instant::now();
604 #[cfg(feature = "debug-dirty-overlay")]
605 self.core.render(graph, &[]);
606 #[cfg(not(feature = "debug-dirty-overlay"))]
607 self.core.render(graph);
608 #[cfg(feature = "profiling")]
609 let render_duration = render_timer.elapsed();
610 self.cursor.frame_cleanup();
611 #[cfg(feature = "profiling")]
612 {
613 let breakdown = self.core.last_render_breakdown();
614 let metadata_timer = tracing::enabled!(tracing::Level::DEBUG).then(Instant::now);
615 let nodes = if self.profile_summary {
616 Vec::new()
617 } else {
618 TesseraRuntime::with_mut(|rt| rt.component_tree.profiler_nodes())
619 };
620 if let Some(metadata_timer) = metadata_timer {
621 tracing::debug!(
622 frame_index = self.frame_index,
623 elapsed_us = metadata_timer.elapsed().as_micros(),
624 "headless_profiler_metadata_cpu"
625 );
626 }
627 let frame_duration = frame_timer.elapsed();
628 #[cfg(not(target_arch = "wasm32"))]
629 let render_gpu_ns = self.core.read_gpu_time().map(|time| time.as_nanos());
630 #[cfg(target_arch = "wasm32")]
631 let render_gpu_ns = None;
632 let metadata = FrameMeta {
633 frame_idx: self.frame_index,
634 build_mode: _build_tree_result.profiler_build_mode(),
635 redraw_reasons: Vec::new(),
636 inter_frame_wait_ns: Some(wait_duration.as_nanos()),
637 partial_replay_nodes: _build_tree_result.partial_replay_nodes(),
638 total_nodes_before_build: _build_tree_result.total_nodes_before_build(),
639 render_time_ns: Some(render_duration.as_nanos()),
640 render_prepare_ns: None,
641 render_acquire_ns: breakdown.map(|timing| timing.acquire.as_nanos()),
642 render_build_passes_ns: breakdown.map(|timing| timing.build_passes.as_nanos()),
643 render_encode_ns: breakdown.map(|timing| timing.encode.as_nanos()),
644 render_submit_ns: breakdown.map(|timing| timing.submit.as_nanos()),
645 render_present_ns: breakdown.map(|timing| timing.present.as_nanos()),
646 render_gpu_ns,
647 build_tree_time_ns: Some(_build_tree_result.duration().as_nanos()),
648 draw_time_ns: Some(draw_duration.as_nanos()),
649 record_time_ns: Some(_record_cost.as_nanos()),
650 frame_total_ns: Some(frame_duration.as_nanos()),
651 layout_diagnostics: Some(_diagnostics),
652 nodes,
653 };
654 if self.profile_summary {
655 submit_frame_summary(metadata);
656 } else {
657 submit_frame_meta(metadata);
658 }
659 profiler_end_frame();
660 }
661
662 self.frame_clock_diagnostics = crate::runtime::frame_clock_diagnostics();
663
664 self.work = crate::work::finish();
665 self.work_totals.record(&self.work);
666 self.frame_graphics_work = self.graphics_work().since(&graphics_before);
667 self.frame_index = self.frame_index.wrapping_add(1);
668 }
669
670 pub fn render_frames(&mut self, frames: u32) {
672 let _session = self.session.enter();
673 for _ in 0..frames.max(1) {
674 self.step_frame();
675 }
676 }
677
678 pub fn render_for_duration(&mut self, duration: Duration) {
680 let _session = self.session.enter();
681 let mut elapsed = Duration::ZERO;
682 loop {
683 self.step_frame();
684 elapsed += self.frame_time;
685 if elapsed >= duration {
686 break;
687 }
688 }
689 }
690
691 pub fn read_pixels(&self) -> Result<Vec<u8>, HeadlessError> {
693 let _session = self.session.enter();
694 Ok(self.core.read_offscreen_rgba()?)
695 }
696
697 pub fn resize(&mut self, width: u32, height: u32) {
699 let _session = self.session.enter();
700 let width = width.max(1);
701 let height = height.max(1);
702 let physical_size = winit::dpi::PhysicalSize::new(width, height);
703 self.physical_size = physical_size;
704 self.size = PxSize::new(Px(width as i32), Px(height as i32));
705 self.core.resize(physical_size);
706 self.core.resize_surface();
707 TesseraRuntime::with_mut(|runtime| runtime.window_size = [width, height]);
708 }
709
710 pub fn work(&self) -> &crate::FrameWork {
712 &self.work
713 }
714
715 pub fn work_totals(&self) -> &crate::WorkTotals {
717 &self.work_totals
718 }
719
720 pub fn frame_clock_diagnostics(&self) -> &crate::FrameClockDiagnostics {
722 &self.frame_clock_diagnostics
723 }
724
725 #[cfg(any(test, feature = "testing"))]
727 pub fn inspect_components(&self) -> crate::testing::ComponentSnapshot {
728 let _session = self.session.enter();
729 crate::testing::ComponentSnapshot::capture()
730 }
731
732 #[cfg(any(test, feature = "testing"))]
734 pub fn inspect_accessibility(&self) -> Option<crate::testing::AccessibilitySnapshot> {
735 self.accessibility_snapshot()
736 .map(|tree| crate::testing::AccessibilitySnapshot::new(tree, self.session.identity()))
737 }
738
739 pub fn snapshot(&self) -> Value {
745 let _session = self.session.enter();
746 TesseraRuntime::with(|runtime| {
747 let tree = runtime.component_tree.tree();
748 let metadatas = runtime.component_tree.metadatas();
749 let root = tree.get_node_id_at(NonZero::new(1).expect("root index is non-zero"));
750 let mut counter = 0u64;
751 let root_value = match root {
752 Some(root) => snapshot_node(tree, metadatas, root, &mut counter),
753 None => Value::Null,
754 };
755 json!({
756 "width": self.physical_size.width,
757 "height": self.physical_size.height,
758 "scale_factor": crate::dp::scale_factor(),
759 "frame_index": self.frame_index,
760 "node_count": counter,
761 "root": root_value,
762 })
763 })
764 }
765
766 pub fn accessibility_snapshot(&self) -> Option<accesskit::TreeUpdate> {
772 let _session = self.session.enter();
773 TesseraRuntime::with_mut(|runtime| runtime.component_tree.accessibility_update(None, true))
774 }
775
776 pub fn dispatch_accessibility_action(&mut self, request: accesskit::ActionRequest) -> bool {
781 let _session = self.session.enter();
782 let handled = TesseraRuntime::with_mut(|runtime| {
783 let (tree, metadatas, focus_owner) =
784 runtime.component_tree.accessibility_dispatch_context();
785 crate::accessibility::dispatch_action(tree, metadatas, focus_owner, request)
786 });
787 flush_pending_focus_callbacks();
788 handled
789 }
790}
791
792fn snapshot_coordinate(value: f32) -> Value {
793 if value.fract() == 0.0 {
794 json!(value as i64)
795 } else {
796 json!(value)
797 }
798}
799
800fn snapshot_node(
801 tree: &ComponentNodeTree,
802 metadatas: &ComponentNodeMetaDatas,
803 node_id: NodeId,
804 counter: &mut u64,
805) -> Value {
806 *counter += 1;
807 let node = tree.get(node_id);
808 let fn_name = node.map_or("<unknown>", |node| node.get().fn_name);
809 let role = node
810 .map(|node| format!("{:?}", node.get().role))
811 .unwrap_or_else(|| "<unknown>".to_string());
812
813 let metadata = metadatas.get(&node_id);
814 let bounds = metadata
815 .and_then(|metadata| {
816 let position = metadata.abs_position?;
817 let size = metadata.computed_data?;
818 let transform = metadata.local_to_window.unwrap_or_else(|| {
819 crate::LocalToWindow::new(1.0, position).expect("Placed origin must be finite")
820 });
821 let bounds = transform.map_rect(crate::FloatRect::from_position_size(
822 PxPosition::ZERO,
823 PxSize::new(size.width, size.height),
824 ));
825 Some(json!({
826 "x": snapshot_coordinate(bounds.x),
827 "y": snapshot_coordinate(bounds.y),
828 "width": snapshot_coordinate(bounds.width),
829 "height": snapshot_coordinate(bounds.height),
830 }))
831 })
832 .unwrap_or(Value::Null);
833
834 let accessibility = metadata
835 .and_then(|metadata| metadata.accessibility.as_ref())
836 .map(|accessibility| {
837 let accessibility = accessibility.snapshot();
838 json!({
839 "role": accessibility.role.map(|role| format!("{role:?}")),
840 "label": accessibility.label,
841 "value": accessibility.value,
842 "key": accessibility.key,
843 "focusable": accessibility.focusable,
844 "focused": accessibility.focused,
845 "disabled": accessibility.disabled,
846 "expanded": accessibility.expanded,
847 "has_popup": accessibility.has_popup.map(|popup| format!("{popup:?}")),
848 "hidden": accessibility.hidden,
849 })
850 })
851 .unwrap_or(Value::Null);
852
853 let children: Vec<Value> = node_id
854 .children(tree)
855 .map(|child| snapshot_node(tree, metadatas, child, counter))
856 .collect();
857
858 json!({
859 "fn_name": fn_name,
860 "role": role,
861 "bounds": bounds,
862 "accessibility": accessibility,
863 "children": children,
864 })
865}
866
867fn reset_headless_runtime(size: winit::dpi::PhysicalSize<u32>) {
868 TesseraRuntime::with_mut(|runtime| {
869 runtime.component_tree.reset();
870 runtime.cursor_icon_request = None;
871 runtime.window_minimized = false;
872 runtime.window_size = [size.width, size.height];
873 });
874 reset_layout_dirty_tracking();
875 reset_component_replay_tracking();
876 reset_focus_read_dependencies();
877 reset_render_slot_read_dependencies();
878 reset_state_read_dependencies();
879 reset_context_read_dependencies();
880 reset_build_invalidations();
881 reset_frame_clock();
882 clear_redraw_waker();
883 clear_persistent_focus_handles();
884 crate::runtime::reset_component_slots();
885}
886
887pub fn run_headless(
893 entry: Box<dyn Fn()>,
894 modules: Vec<Box<dyn RenderModule>>,
895 config: TesseraConfig,
896) -> Result<(), HeadlessError> {
897 init_headless_tracing();
898
899 let headless_config = HeadlessConfig {
900 width: env_u32(HEADLESS_WIDTH_ENV).unwrap_or(800),
901 height: env_u32(HEADLESS_HEIGHT_ENV).unwrap_or(600),
902 scale_factor: std::env::var(HEADLESS_SCALE_FACTOR_ENV)
903 .ok()
904 .and_then(|value| value.trim().parse().ok())
905 .map(normalize_scale_factor)
906 .unwrap_or(1.0),
907 sample_count: config.sample_count.max(1),
908 max_non_sampler_bindings: config.max_non_sampler_bindings,
909 ..HeadlessConfig::default()
910 };
911 let mut frame_time = headless_config.frame_time;
912 if let Some(ms) = env_u32(HEADLESS_FRAME_TIME_MS_ENV) {
913 frame_time = Duration::from_millis(ms.max(1) as u64);
914 }
915
916 let mut renderer = HeadlessRenderer::new(entry, modules, headless_config)?;
917 renderer.frame_time = frame_time;
918 #[cfg(feature = "profiling")]
919 {
920 crate::profiler::set_output_path(crate::renderer::resolve_profiler_output_path(&config));
921 renderer.profile_summary = std::env::var("TESSERA_HEADLESS_PROFILE")
922 .is_ok_and(|value| value.eq_ignore_ascii_case("summary"));
923 }
924
925 let stdout = std::io::stdout();
926 let mut out = stdout.lock();
927 write_line(
928 &mut out,
929 json!({
930 "event": "ready",
931 "width": renderer.physical_size.width,
932 "height": renderer.physical_size.height,
933 "scale_factor": crate::dp::scale_factor(),
934 }),
935 )?;
936
937 let stdin = std::io::stdin();
938 let reader = BufReader::new(stdin.lock());
939 for line in reader.lines() {
940 let line = line?;
941 let line = line.trim();
942 if line.is_empty() {
943 continue;
944 }
945
946 let request = match serde_json::from_str::<WorkerRequest>(line) {
947 Ok(request) => request,
948 Err(err) => {
949 write_line(
950 &mut out,
951 json!({"ok": false, "error": format!("invalid command: {err}")}),
952 )?;
953 continue;
954 }
955 };
956
957 let is_shutdown = matches!(&request, WorkerRequest::Shutdown { .. });
958 let (id, mut response) = handle_request(&mut renderer, request)?;
959 if let Some(map) = response.as_object_mut() {
960 if let Some(id) = id {
961 map.insert("id".to_string(), json!(id));
962 }
963 }
964 write_line(&mut out, response)?;
965
966 if is_shutdown {
967 break;
968 }
969 }
970
971 Ok(())
972}
973
974fn handle_request(
975 renderer: &mut HeadlessRenderer,
976 request: WorkerRequest,
977) -> Result<(Option<u64>, Value), HeadlessError> {
978 match request {
979 WorkerRequest::Input {
980 id,
981 kind,
982 pointer_id,
983 x,
984 y,
985 delta_x,
986 delta_y,
987 button,
988 } => {
989 let pointer_id = pointer_id.unwrap_or(MOUSE_POINTER_ID);
990 let position = PxPosition::from_f32_xy(x.unwrap_or(0.0), y.unwrap_or(0.0));
991 let button = button.unwrap_or_default().to_press_key();
992 match kind {
993 InputKind::Press => renderer.pointer_press(pointer_id, position, button),
994 InputKind::Release => renderer.pointer_release(pointer_id, position, button),
995 InputKind::Move => renderer.pointer_move(pointer_id, position),
996 InputKind::Scroll => renderer.pointer_scroll(
997 pointer_id,
998 position,
999 delta_x.unwrap_or(0.0),
1000 delta_y.unwrap_or(0.0),
1001 ),
1002 }
1003 Ok((id, json!({"ok": true, "kind": kind.as_str()})))
1004 }
1005 WorkerRequest::Text { id, text } => {
1006 renderer.inject_text(&text);
1007 Ok((id, json!({"ok": true, "chars": text.chars().count()})))
1008 }
1009 WorkerRequest::Render {
1010 id,
1011 frames,
1012 duration_ms,
1013 out,
1014 } => {
1015 let rendered = if let Some(duration_ms) = duration_ms {
1016 renderer.render_for_duration(Duration::from_millis(duration_ms.max(1)));
1017 None
1018 } else {
1019 let frames = frames.unwrap_or(1).max(1);
1020 renderer.render_frames(frames);
1021 Some(frames)
1022 };
1023
1024 let size = renderer.physical_size();
1025 let mut response = json!({
1026 "ok": true,
1027 "width": size.width,
1028 "height": size.height,
1029 "frame_index": renderer.frame_index(),
1030 });
1031 if let Some(frames) = rendered {
1032 response["frames"] = json!(frames);
1033 }
1034
1035 if let Some(out_path) = out {
1036 let pixels = renderer.read_pixels()?;
1037 let png = encode_png_rgba(size.width, size.height, &pixels);
1038 std::fs::write(&out_path, &png)?;
1039 response["path"] = json!(out_path);
1040 response["bytes"] = json!(png.len());
1041 }
1042 Ok((id, response))
1043 }
1044 WorkerRequest::Snapshot { id } => {
1045 let snapshot = renderer.snapshot();
1046 Ok((id, json!({"ok": true, "snapshot": snapshot})))
1047 }
1048 WorkerRequest::Resources { id } => {
1049 let response = match renderer.resource_snapshot() {
1050 Ok(resources) => json!({"ok": true, "resources": resources}),
1051 Err(error) => json!({"ok": false, "error": error.to_string()}),
1052 };
1053 Ok((id, response))
1054 }
1055 WorkerRequest::Resize { id, width, height } => {
1056 renderer.resize(width, height);
1057 let size = renderer.physical_size();
1058 Ok((
1059 id,
1060 json!({"ok": true, "width": size.width, "height": size.height}),
1061 ))
1062 }
1063 WorkerRequest::Shutdown { id } => {
1064 let _ = id;
1065 Ok((id, json!({"ok": true, "event": "shutdown"})))
1066 }
1067 }
1068}
1069
1070fn write_line(out: &mut impl Write, value: Value) -> Result<(), HeadlessError> {
1071 let encoded = serde_json::to_string(&value)?;
1072 out.write_all(encoded.as_bytes())?;
1073 out.write_all(b"\n")?;
1074 out.flush()?;
1075 Ok(())
1076}
1077
1078fn env_u32(key: &str) -> Option<u32> {
1079 std::env::var(key)
1080 .ok()
1081 .and_then(|value| value.trim().parse::<u32>().ok())
1082}
1083
1084fn normalize_scale_factor(value: f64) -> f64 {
1085 if value.is_finite() && value > 0.0 {
1086 value
1087 } else {
1088 1.0
1089 }
1090}
1091
1092fn init_headless_tracing() {
1093 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
1094 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("error,tessera_ui=info"));
1095 let _ = tracing_subscriber::fmt()
1097 .with_env_filter(filter)
1098 .with_writer(std::io::stderr)
1099 .try_init();
1100}
1101
1102#[derive(Clone, Copy, Debug, Default, Deserialize)]
1103#[serde(rename_all = "snake_case")]
1104enum InputKind {
1105 #[default]
1106 Press,
1107 Release,
1108 Move,
1109 Scroll,
1110}
1111
1112impl InputKind {
1113 fn as_str(self) -> &'static str {
1114 match self {
1115 Self::Press => "press",
1116 Self::Release => "release",
1117 Self::Move => "move",
1118 Self::Scroll => "scroll",
1119 }
1120 }
1121}
1122
1123#[derive(Clone, Copy, Debug, Default, Deserialize)]
1124#[serde(rename_all = "snake_case")]
1125enum ButtonKind {
1126 #[default]
1127 Left,
1128 Right,
1129 Middle,
1130}
1131
1132impl ButtonKind {
1133 fn to_press_key(self) -> PressKeyEventType {
1134 match self {
1135 Self::Left => PressKeyEventType::Left,
1136 Self::Right => PressKeyEventType::Right,
1137 Self::Middle => PressKeyEventType::Middle,
1138 }
1139 }
1140}
1141
1142#[derive(Debug, Deserialize)]
1143#[serde(tag = "cmd", rename_all = "snake_case")]
1144enum WorkerRequest {
1145 Input {
1146 #[serde(default)]
1147 id: Option<u64>,
1148 kind: InputKind,
1149 #[serde(default)]
1150 pointer_id: Option<u64>,
1151 #[serde(default)]
1152 x: Option<f32>,
1153 #[serde(default)]
1154 y: Option<f32>,
1155 #[serde(default)]
1156 delta_x: Option<f32>,
1157 #[serde(default)]
1158 delta_y: Option<f32>,
1159 #[serde(default)]
1160 button: Option<ButtonKind>,
1161 },
1162 Text {
1163 #[serde(default)]
1164 id: Option<u64>,
1165 text: String,
1166 },
1167 Render {
1168 #[serde(default)]
1169 id: Option<u64>,
1170 #[serde(default)]
1171 frames: Option<u32>,
1172 #[serde(default)]
1173 duration_ms: Option<u64>,
1174 #[serde(default)]
1175 out: Option<String>,
1176 },
1177 Snapshot {
1178 #[serde(default)]
1179 id: Option<u64>,
1180 },
1181 Resources {
1182 #[serde(default)]
1183 id: Option<u64>,
1184 },
1185 Resize {
1186 #[serde(default)]
1187 id: Option<u64>,
1188 width: u32,
1189 height: u32,
1190 },
1191 Shutdown {
1192 #[serde(default)]
1193 id: Option<u64>,
1194 },
1195}
1196
1197pub fn encode_png_rgba(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
1203 let row_bytes = width as usize * 4;
1204 let mut raw = Vec::with_capacity((height as usize) * (1 + row_bytes));
1205 for row in 0..height as usize {
1206 raw.push(0);
1207 let start = row * row_bytes;
1208 raw.extend_from_slice(&pixels[start..start + row_bytes]);
1209 }
1210
1211 let mut out = Vec::with_capacity(raw.len() + 1024);
1212 out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
1213
1214 let mut ihdr = Vec::with_capacity(13);
1215 ihdr.extend_from_slice(&width.to_be_bytes());
1216 ihdr.extend_from_slice(&height.to_be_bytes());
1217 ihdr.push(8); ihdr.push(6); ihdr.push(0); ihdr.push(0); ihdr.push(0); write_png_chunk(&mut out, b"IHDR", &ihdr);
1223 write_png_chunk(&mut out, b"IDAT", &zlib_store(&raw));
1224 write_png_chunk(&mut out, b"IEND", &[]);
1225 out
1226}
1227
1228fn write_png_chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
1229 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1230 out.extend_from_slice(kind);
1231 out.extend_from_slice(data);
1232
1233 let mut crc = Crc32::new();
1234 crc.update(kind);
1235 crc.update(data);
1236 out.extend_from_slice(&crc.finish().to_be_bytes());
1237}
1238
1239fn zlib_store(data: &[u8]) -> Vec<u8> {
1240 let mut out = Vec::with_capacity(data.len() + data.len() / 65_535 * 5 + 16);
1241 out.push(0x78);
1243 out.push(0x01);
1244
1245 if data.is_empty() {
1246 out.extend_from_slice(&[0x01, 0x00, 0x00, 0xFF, 0xFF]);
1247 }
1248
1249 let mut offset = 0;
1250 while offset < data.len() {
1251 let remaining = data.len() - offset;
1252 let block_len = remaining.min(65_535);
1253 let is_final = offset + block_len >= data.len();
1254 out.push(if is_final { 0x01 } else { 0x00 });
1255 let len = block_len as u16;
1256 out.extend_from_slice(&len.to_le_bytes());
1257 out.extend_from_slice(&(!len).to_le_bytes());
1258 out.extend_from_slice(&data[offset..offset + block_len]);
1259 offset += block_len;
1260 }
1261
1262 out.extend_from_slice(&adler32(data).to_be_bytes());
1263 out
1264}
1265
1266fn adler32(data: &[u8]) -> u32 {
1267 const MOD: u32 = 65_521;
1268 let mut a: u32 = 1;
1269 let mut b: u32 = 0;
1270 for &byte in data {
1271 a = (a + byte as u32) % MOD;
1272 b = (b + a) % MOD;
1273 }
1274 (b << 16) | a
1275}
1276
1277struct Crc32 {
1278 table: [u32; 256],
1279 state: u32,
1280}
1281
1282impl Crc32 {
1283 fn new() -> Self {
1284 let mut table = [0u32; 256];
1285 for (index, entry) in table.iter_mut().enumerate() {
1286 let mut value = index as u32;
1287 for _ in 0..8 {
1288 value = if value & 1 != 0 {
1289 0xEDB8_8320 ^ (value >> 1)
1290 } else {
1291 value >> 1
1292 };
1293 }
1294 *entry = value;
1295 }
1296 Self {
1297 table,
1298 state: 0xFFFF_FFFF,
1299 }
1300 }
1301
1302 fn update(&mut self, data: &[u8]) {
1303 for &byte in data {
1304 let index = ((self.state ^ byte as u32) & 0xFF) as usize;
1305 self.state = self.table[index] ^ (self.state >> 8);
1306 }
1307 }
1308
1309 fn finish(self) -> u32 {
1310 self.state ^ 0xFFFF_FFFF
1311 }
1312}
1313
1314pub fn write_png_file(
1316 path: impl AsRef<Path>,
1317 width: u32,
1318 height: u32,
1319 pixels: &[u8],
1320) -> Result<usize, HeadlessError> {
1321 let png = encode_png_rgba(width, height, pixels);
1322 std::fs::write(path, &png)?;
1323 Ok(png.len())
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::*;
1329
1330 #[test]
1331 fn adler32_matches_reference_values() {
1332 let _runtime_session = crate::RuntimeSession::new();
1333 let _runtime_scope = _runtime_session.enter();
1334 assert_eq!(adler32(b""), 1);
1335 assert_eq!(adler32(b"Wikipedia"), 0x11E6_0398);
1336 }
1337
1338 #[test]
1339 fn crc32_matches_reference_value() {
1340 let _runtime_session = crate::RuntimeSession::new();
1341 let _runtime_scope = _runtime_session.enter();
1342 let mut crc = Crc32::new();
1343 crc.update(b"123456789");
1344 assert_eq!(crc.finish(), 0xCBF4_3926);
1345 }
1346
1347 #[test]
1348 fn png_encoder_emits_valid_structure() {
1349 let _runtime_session = crate::RuntimeSession::new();
1350 let _runtime_scope = _runtime_session.enter();
1351 let width = 2;
1352 let height = 1;
1353 let pixels = vec![255, 0, 0, 255, 0, 255, 0, 255];
1354 let png = encode_png_rgba(width, height, &pixels);
1355
1356 assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
1357 assert_eq!(&png[12..16], b"IHDR");
1358 assert_eq!(&png[16..20], &2u32.to_be_bytes());
1359 assert_eq!(&png[20..24], &1u32.to_be_bytes());
1360 assert_eq!(png[png.len() - 8..png.len() - 4], *b"IEND");
1361 assert!(png.windows(4).any(|window| window == b"IDAT"));
1362 }
1363}
1364
1365#[cfg(test)]
1366#[path = "headless_protocol_tests.rs"]
1367mod protocol_tests;