Early UI work.

This commit is contained in:
2026-03-20 16:46:18 -04:00
parent 9ab1167fef
commit 39ede248cf
15 changed files with 3560 additions and 1 deletions

647
lib/ui/src/platform.rs Normal file
View File

@@ -0,0 +1,647 @@
//! 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());
}
}