//! Headless platform/render worker for the explicit-construction prototype. use std::cell::RefCell; use std::collections::BTreeMap; use std::fmt; use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use ruin_runtime::channel::mpsc; use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker}; use tracing::{debug, info}; use crate::scene::{SceneSnapshot, UiSize}; use crate::trace_targets; use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate}; #[derive(Clone)] pub struct PlatformProxy { command_tx: mpsc::UnboundedSender, next_window_id: Arc, } /// Low-level platform/runtime host for the explicit prototype. /// /// The headless backend currently co-locates logical platform and rendering work on one runtime /// worker thread, but the event/snapshot API is intentionally shaped so future backends can split /// them if needed. pub struct PlatformRuntime { proxy: PlatformProxy, events: mpsc::Receiver, _worker: WorkerHandle, } #[derive(Clone, Debug, PartialEq)] pub enum PlatformEvent { Opened { window_id: WindowId, }, Closed { window_id: WindowId, }, VisibilityChanged { window_id: WindowId, visible: bool, }, Configured { window_id: WindowId, configuration: WindowConfigured, }, FramePresented { window_id: WindowId, scene_version: u64, item_count: usize, }, CloseRequested { window_id: WindowId, }, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PlatformClosed; impl fmt::Display for PlatformClosed { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("platform worker is closed") } } impl std::error::Error for PlatformClosed {} #[derive(Clone, Debug)] enum PlatformCommand { CreateWindow { window_id: WindowId, spec: WindowSpec, }, UpdateWindow { window_id: WindowId, update: WindowUpdate, }, ReplaceScene { window_id: WindowId, scene: SceneSnapshot, }, EmitCloseRequested { window_id: WindowId, }, Shutdown, } #[derive(Clone)] struct HeadlessState { events: mpsc::UnboundedSender, windows: BTreeMap, } #[derive(Clone)] struct HeadlessWindow { spec: WindowSpec, lifecycle: WindowLifecycle, configuration: WindowConfigured, pending_scene: Option, render_scheduled: bool, } impl HeadlessWindow { fn new(spec: WindowSpec) -> Self { let actual_inner_size = spec .requested_inner_size .unwrap_or_else(|| UiSize::new(800.0, 600.0)); Self { spec, lifecycle: WindowLifecycle::LogicalClosed, configuration: WindowConfigured { actual_inner_size, scale_factor: 1.0, visible: false, maximized: false, fullscreen: false, }, pending_scene: None, render_scheduled: false, } } } impl PlatformRuntime { pub fn headless() -> Self { start_headless() } pub fn proxy(&self) -> PlatformProxy { self.proxy.clone() } pub async fn next_event(&mut self) -> Option { self.events.recv().await } } impl PlatformProxy { pub fn create_window(&self, spec: WindowSpec) -> Result { let window_id = WindowId::from_raw(self.next_window_id.fetch_add(1, Ordering::Relaxed)); self.send(PlatformCommand::CreateWindow { window_id, spec })?; Ok(window_id) } pub fn update_window( &self, window_id: WindowId, update: WindowUpdate, ) -> Result<(), PlatformClosed> { self.send(PlatformCommand::UpdateWindow { window_id, update }) } pub fn replace_scene( &self, window_id: WindowId, scene: SceneSnapshot, ) -> Result<(), PlatformClosed> { self.send(PlatformCommand::ReplaceScene { window_id, scene }) } pub fn emit_close_requested(&self, window_id: WindowId) -> Result<(), PlatformClosed> { self.send(PlatformCommand::EmitCloseRequested { window_id }) } pub fn shutdown(&self) -> Result<(), PlatformClosed> { self.send(PlatformCommand::Shutdown) } fn send(&self, command: PlatformCommand) -> Result<(), PlatformClosed> { self.command_tx.send(command).map_err(|_| PlatformClosed) } } /// Starts the headless prototype backend. /// /// Policy note: when a window becomes visible again, the backend may re-present the latest retained /// scene for that window even if the scene version itself did not change. Visibility restoration is /// treated as a presentation-affecting state change. pub fn start_headless() -> PlatformRuntime { let (command_tx, mut command_rx) = mpsc::unbounded_channel::(); let (event_tx, event_rx) = mpsc::unbounded_channel::(); let worker = spawn_worker( move || { let state = Rc::new(RefCell::new(HeadlessState { events: event_tx.clone(), windows: BTreeMap::new(), })); queue_future(async move { debug!( target: trace_targets::PLATFORM, event = "worker_started", backend = "headless", "starting headless platform worker" ); while let Some(command) = command_rx.recv().await { match command { PlatformCommand::CreateWindow { window_id, spec } => { handle_create_window(&state, window_id, spec); } PlatformCommand::UpdateWindow { window_id, update } => { handle_update_window(&state, window_id, update); } PlatformCommand::ReplaceScene { window_id, scene } => { handle_replace_scene(&state, window_id, scene); } PlatformCommand::EmitCloseRequested { window_id } => { let sender = state.borrow().events.clone(); let _ = sender.send(PlatformEvent::CloseRequested { window_id }); } PlatformCommand::Shutdown => { debug!( target: trace_targets::PLATFORM, event = "worker_shutdown_requested", "shutting down headless platform worker" ); break; } } } }); }, || { debug!( target: trace_targets::PLATFORM, event = "worker_exited", backend = "headless", "headless platform worker exited" ); }, ); PlatformRuntime { proxy: PlatformProxy { command_tx, next_window_id: Arc::new(AtomicU64::new(1)), }, events: event_rx, _worker: worker, } } fn handle_create_window(state: &Rc>, window_id: WindowId, spec: WindowSpec) { debug!( target: trace_targets::PLATFORM, event = "create_window", window_id = window_id.raw(), title = spec.title.as_str(), open = spec.open, visible = spec.visible, "creating logical window" ); state .borrow_mut() .windows .insert(window_id, HeadlessWindow::new(spec.clone())); if spec.open { begin_open_transition(Rc::clone(state), window_id); } } fn handle_update_window( state: &Rc>, window_id: WindowId, update: WindowUpdate, ) { let mut action = UpdateAction::None; { let mut state_ref = state.borrow_mut(); let Some(window) = state_ref.windows.get_mut(&window_id) else { return; }; let was_open = window.spec.open; let was_visible = window.spec.visible; update.apply_to(&mut window.spec); if let Some(requested_inner_size) = window.spec.requested_inner_size { window.configuration.actual_inner_size = requested_inner_size; } window.configuration.maximized = window.spec.maximized; window.configuration.fullscreen = window.spec.fullscreen; match (was_open, window.spec.open) { (false, true) => action = UpdateAction::Open, (true, false) => action = UpdateAction::Close, _ => {} } if was_open == window.spec.open && was_visible != window.spec.visible { action = UpdateAction::Visibility(window.spec.visible); } } match action { UpdateAction::None => { maybe_schedule_render(Rc::clone(state), window_id); } UpdateAction::Open => begin_open_transition(Rc::clone(state), window_id), UpdateAction::Close => close_window(state, window_id), UpdateAction::Visibility(visible) => set_visibility(state, window_id, visible), } } fn handle_replace_scene( state: &Rc>, window_id: WindowId, scene: SceneSnapshot, ) { #[cfg(debug_assertions)] tracing::trace!( target: trace_targets::PLATFORM, event = "replace_scene", window_id = window_id.raw(), version = scene.version, items = scene.item_count(), "received scene snapshot" ); if let Some(window) = state.borrow_mut().windows.get_mut(&window_id) { window.pending_scene = Some(scene); } else { return; } maybe_schedule_render(Rc::clone(state), window_id); } fn maybe_schedule_render(state: Rc>, window_id: WindowId) { let should_schedule = { let mut state_ref = state.borrow_mut(); let Some(window) = state_ref.windows.get_mut(&window_id) else { return; }; if window.render_scheduled { false } else { window.render_scheduled = true; true } }; if should_schedule { queue_microtask(move || present_latest_scene(&state, window_id)); } } fn begin_open_transition(state: Rc>, window_id: WindowId) { { let mut state_ref = state.borrow_mut(); let Some(window) = state_ref.windows.get_mut(&window_id) else { return; }; window.lifecycle = WindowLifecycle::Opening; } queue_microtask(move || { { let mut state_ref = state.borrow_mut(); let Some(window) = state_ref.windows.get_mut(&window_id) else { return; }; window.lifecycle = WindowLifecycle::AwaitingInitialConfigure; window.configuration.visible = window.spec.visible; window.configuration.maximized = window.spec.maximized; window.configuration.fullscreen = window.spec.fullscreen; window.lifecycle = if window.spec.visible { WindowLifecycle::OpenVisible } else { WindowLifecycle::OpenHidden }; } emit_event( &state, PlatformEvent::Configured { window_id, configuration: state .borrow() .windows .get(&window_id) .expect("window should exist during configure") .configuration, }, ); emit_event(&state, PlatformEvent::Opened { window_id }); maybe_schedule_render(state, window_id); }); } fn close_window(state: &Rc>, window_id: WindowId) { let should_emit = { let mut state_ref = state.borrow_mut(); let Some(window) = state_ref.windows.get_mut(&window_id) else { return; }; if window.lifecycle == WindowLifecycle::LogicalClosed { false } else { window.lifecycle = WindowLifecycle::Closing; window.render_scheduled = false; window.configuration.visible = false; window.lifecycle = WindowLifecycle::LogicalClosed; true } }; if should_emit { emit_event(state, PlatformEvent::Closed { window_id }); } } fn set_visibility(state: &Rc>, window_id: WindowId, visible: bool) { let should_emit = { let mut state_ref = state.borrow_mut(); let Some(window) = state_ref.windows.get_mut(&window_id) else { return; }; if !window.spec.open { false } else { window.configuration.visible = visible; window.lifecycle = if visible { WindowLifecycle::OpenVisible } else { WindowLifecycle::OpenHidden }; true } }; if should_emit { emit_event( state, PlatformEvent::VisibilityChanged { window_id, visible }, ); maybe_schedule_render(Rc::clone(state), window_id); } } fn present_latest_scene(state: &Rc>, window_id: WindowId) { let presentation = { let mut state_ref = state.borrow_mut(); let Some(window) = state_ref.windows.get_mut(&window_id) else { return; }; window.render_scheduled = false; if window.lifecycle != WindowLifecycle::OpenVisible { return; } let Some(scene) = window.pending_scene.clone() else { return; }; Some((scene.version, scene.item_count(), window.spec.title.clone())) }; let Some((scene_version, item_count, title)) = presentation else { return; }; info!( target: trace_targets::PLATFORM, event = "present_scene", backend = "headless", window_id = window_id.raw(), title = title.as_str(), scene_version, item_count, "presenting latest scene snapshot" ); emit_event( state, PlatformEvent::FramePresented { window_id, scene_version, item_count, }, ); } fn emit_event(state: &Rc>, event: PlatformEvent) { let sender = state.borrow().events.clone(); let _ = sender.send(event); } enum UpdateAction { None, Open, Close, Visibility(bool), } #[cfg(test)] mod tests { use super::{PlatformEvent, start_headless}; use crate::scene::{Color, Point, PreparedText, Rect, SceneSnapshot, UiSize}; use crate::window::{WindowSpec, WindowUpdate}; use ruin_runtime::{current_thread_handle, queue_future, run}; use std::sync::{Arc, Mutex}; #[test] fn headless_platform_coalesces_latest_scene_per_window() { let observed = Arc::new(Mutex::new(Vec::::new())); let done = Arc::new(Mutex::new(false)); let main = current_thread_handle(); queue_future({ let observed = Arc::clone(&observed); let done = Arc::clone(&done); async move { let mut platform = start_headless(); let proxy = platform.proxy(); let window_id = proxy .create_window( WindowSpec::new("coalesce").requested_inner_size(UiSize::new(320.0, 180.0)), ) .expect("window should be created"); let mut one = SceneSnapshot::new(1, UiSize::new(320.0, 180.0)); one.push_quad( Rect::new(0.0, 0.0, 320.0, 180.0), Color::rgb(0x22, 0x22, 0x33), ); let mut two = SceneSnapshot::new(2, UiSize::new(320.0, 180.0)); two.push_quad( Rect::new(0.0, 0.0, 320.0, 180.0), Color::rgb(0x33, 0x22, 0x22), ); let mut three = SceneSnapshot::new(3, UiSize::new(320.0, 180.0)); three .push_quad( Rect::new(0.0, 0.0, 320.0, 180.0), Color::rgb(0x22, 0x33, 0x22), ) .push_text(PreparedText::monospace( "v3", Point::new(16.0, 28.0), 16.0, 8.0, Color::rgb(0xFF, 0xFF, 0xFF), )); proxy .replace_scene(window_id, one) .expect("scene v1 should queue"); proxy .replace_scene(window_id, two) .expect("scene v2 should queue"); proxy .replace_scene(window_id, three) .expect("scene v3 should queue"); while let Some(event) = platform.next_event().await { observed.lock().unwrap().push(event.clone()); if let PlatformEvent::FramePresented { window_id: presented, scene_version, .. } = event { assert_eq!(presented, window_id); assert_eq!(scene_version, 3); proxy.shutdown().expect("shutdown should queue"); break; } } while platform.next_event().await.is_some() {} *done.lock().unwrap() = true; let _ = main.queue_task(|| {}); } }); run(); assert!(*done.lock().unwrap()); } #[test] fn open_false_closes_and_reopen_reconfigures() { let counts = Arc::new(Mutex::new((0usize, 0usize, 0usize))); let done = Arc::new(Mutex::new(false)); let main = current_thread_handle(); queue_future({ let counts = Arc::clone(&counts); let done = Arc::clone(&done); async move { let mut platform = start_headless(); let proxy = platform.proxy(); let window_id = proxy .create_window( WindowSpec::new("reopen").requested_inner_size(UiSize::new(640.0, 360.0)), ) .expect("window should be created"); let mut saw_initial_open = false; while let Some(event) = platform.next_event().await { match event { PlatformEvent::Configured { window_id: id, .. } if id == window_id => { counts.lock().unwrap().0 += 1; } PlatformEvent::Opened { window_id: id } if id == window_id => { counts.lock().unwrap().1 += 1; if !saw_initial_open { saw_initial_open = true; proxy .update_window(window_id, WindowUpdate::new().open(false)) .expect("close should queue"); proxy .update_window(window_id, WindowUpdate::new().open(true)) .expect("reopen should queue"); } } PlatformEvent::Closed { window_id: id } if id == window_id => { counts.lock().unwrap().2 += 1; } _ => {} } let (configured, opened, closed) = *counts.lock().unwrap(); if configured >= 2 && opened >= 2 && closed >= 1 { proxy.shutdown().expect("shutdown should queue"); break; } } while platform.next_event().await.is_some() {} *done.lock().unwrap() = true; let _ = main.queue_task(|| {}); } }); run(); let (configured, opened, closed) = *counts.lock().unwrap(); assert_eq!(configured, 2); assert_eq!(opened, 2); assert_eq!(closed, 1); assert!(*done.lock().unwrap()); } }