648 lines
20 KiB
Rust
648 lines
20 KiB
Rust
//! 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<PlatformCommand>,
|
|
next_window_id: Arc<AtomicU64>,
|
|
}
|
|
|
|
/// 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<PlatformEvent>,
|
|
_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<PlatformEvent>,
|
|
windows: BTreeMap<WindowId, HeadlessWindow>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct HeadlessWindow {
|
|
spec: WindowSpec,
|
|
lifecycle: WindowLifecycle,
|
|
configuration: WindowConfigured,
|
|
pending_scene: Option<SceneSnapshot>,
|
|
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<PlatformEvent> {
|
|
self.events.recv().await
|
|
}
|
|
}
|
|
|
|
impl PlatformProxy {
|
|
pub fn create_window(&self, spec: WindowSpec) -> Result<WindowId, PlatformClosed> {
|
|
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::<PlatformCommand>();
|
|
let (event_tx, event_rx) = mpsc::unbounded_channel::<PlatformEvent>();
|
|
|
|
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<RefCell<HeadlessState>>, 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<RefCell<HeadlessState>>,
|
|
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<RefCell<HeadlessState>>,
|
|
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<RefCell<HeadlessState>>, 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<RefCell<HeadlessState>>, 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<RefCell<HeadlessState>>, 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<RefCell<HeadlessState>>, 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<RefCell<HeadlessState>>, 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<RefCell<HeadlessState>>, 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::<PlatformEvent>::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());
|
|
}
|
|
}
|