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

12
lib/ui/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "ruin_ui"
version = "0.1.0"
edition = "2024"
[dependencies]
ruin_reactivity = { path = "../reactivity" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = { version = "0.1", default-features = false, features = ["std"] }
[dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] }

View File

@@ -0,0 +1,247 @@
use ruin_reactivity::{Cell, cell};
use ruin_ui::{
Color, PlatformEvent, Point, PreparedText, Rect, SceneSnapshot, UiRuntime, UiSize,
WindowController, WindowSpec, WindowUpdate,
};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt};
fn install_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"info,ruin_runtime::runtime=debug,ruin_ui::platform=debug,ruin_reactivity::effect=debug",
)
});
let fmt_layer = fmt::layer()
.with_target(true)
.with_thread_ids(true)
.with_thread_names(true)
.compact();
let _ = tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.try_init();
}
fn log_platform_event(event: &PlatformEvent) {
match event {
PlatformEvent::Opened { window_id } => {
tracing::info!(event = "window_opened", window_id = window_id.raw());
}
PlatformEvent::Configured {
window_id,
configuration,
} => {
tracing::info!(
event = "window_configured",
window_id = window_id.raw(),
width = configuration.actual_inner_size.width,
height = configuration.actual_inner_size.height,
visible = configuration.visible,
"window configured"
);
}
PlatformEvent::VisibilityChanged { window_id, visible } => {
tracing::info!(
event = "visibility_changed",
window_id = window_id.raw(),
visible,
"window visibility changed"
);
}
PlatformEvent::FramePresented {
window_id,
scene_version,
item_count,
} => {
tracing::info!(
event = "frame_presented",
window_id = window_id.raw(),
scene_version,
item_count,
"headless backend presented scene"
);
}
PlatformEvent::Closed { window_id } => {
tracing::info!(event = "window_closed", window_id = window_id.raw());
}
PlatformEvent::CloseRequested { window_id } => {
tracing::info!(
event = "close_requested",
window_id = window_id.raw(),
"compositor-like close request received"
);
}
}
}
fn attach_window_scene(window: &WindowController) -> (Cell<usize>, ruin_reactivity::EffectHandle) {
let counter = cell(0usize);
let scene_window = window.clone();
let scene_effect = scene_window.attach_scene_effect({
let counter = counter.clone();
move || {
let version = counter.get() as u64 + 1;
let value = counter.get();
let mut scene = SceneSnapshot::new(version, UiSize::new(640.0, 360.0));
scene
.push_quad(
Rect::new(0.0, 0.0, 640.0, 360.0),
Color::rgb(0x12, 0x19, 0x28),
)
.push_quad(
Rect::new(24.0, 24.0, 592.0, 64.0),
Color::rgb(0x2B, 0x3A, 0x67),
)
.push_text(PreparedText::monospace(
format!("RUIN explicit prototype | counter = {value}"),
Point::new(40.0, 64.0),
18.0,
9.0,
Color::rgb(0xFF, 0xFF, 0xFF),
));
scene
}
});
(counter, scene_effect)
}
#[ruin_runtime::async_main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
install_tracing();
tracing::info!(
event = "prototype_start",
"starting explicit-construction UI prototype with a headless backend"
);
let mut ui = UiRuntime::headless();
let window = ui.create_window(
WindowSpec::new("RUIN Explicit Prototype")
.app_id("dev.ruin.prototype")
.requested_inner_size(UiSize::new(640.0, 360.0)),
)?;
let (counter, _scene_effect) = attach_window_scene(&window);
let _ = counter.set(1);
let _ = counter.set(2);
let _ = counter.set(3);
let event = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::Configured { window_id, .. } if *window_id == window.id()
)
})
.await?;
log_platform_event(&event);
let event = ui
.wait_for_event_matching(
|event| matches!(event, PlatformEvent::Opened { window_id } if *window_id == window.id()),
)
.await?;
log_platform_event(&event);
let event = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::FramePresented {
window_id,
scene_version: 4,
..
} if *window_id == window.id()
)
})
.await?;
log_platform_event(&event);
window.update(WindowUpdate::new().visible(false))?;
let event = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::VisibilityChanged {
window_id,
visible: false,
} if *window_id == window.id()
)
})
.await?;
log_platform_event(&event);
window.update(WindowUpdate::new().visible(true))?;
let event = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::VisibilityChanged {
window_id,
visible: true,
} if *window_id == window.id()
)
})
.await?;
log_platform_event(&event);
let _ = counter.set(4);
let event = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::FramePresented {
window_id,
scene_version: 4,
..
} if *window_id == window.id()
)
})
.await?;
log_platform_event(&event);
let event = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::FramePresented {
window_id,
scene_version: 5,
..
} if *window_id == window.id()
)
})
.await?;
log_platform_event(&event);
window.emit_close_requested()?;
let event = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::CloseRequested { window_id } if *window_id == window.id()
)
})
.await?;
log_platform_event(&event);
window.update(WindowUpdate::new().open(false))?;
let event = ui
.wait_for_event_matching(
|event| matches!(event, PlatformEvent::Closed { window_id } if *window_id == window.id()),
)
.await?;
log_platform_event(&event);
ui.shutdown()?;
tracing::info!(
event = "prototype_done",
"explicit-construction UI prototype finished"
);
Ok(())
}

26
lib/ui/src/lib.rs Normal file
View File

@@ -0,0 +1,26 @@
//! Shared explicit-construction UI substrate for RUIN.
//!
//! This crate intentionally starts with explicit Rust data construction rather than a proc-macro
//! authoring layer. The goal is to validate the threading, windowing, and scene handoff model
//! before committing to ergonomic surface syntax. Concrete platform and renderer backends live in
//! sibling crates.
pub(crate) mod trace_targets {
pub const PLATFORM: &str = "ruin_ui::platform";
pub const SCENE: &str = "ruin_ui::scene";
}
mod platform;
mod runtime;
mod scene;
mod window;
pub use platform::{PlatformClosed, PlatformEvent, PlatformProxy, PlatformRuntime, start_headless};
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
pub use scene::{
Color, DisplayItem, GlyphInstance, Point, PreparedText, Quad, Rect, SceneSnapshot, Translation,
UiSize,
};
pub use window::{
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
};

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());
}
}

238
lib/ui/src/runtime.rs Normal file
View File

@@ -0,0 +1,238 @@
//! Explicit-construction UI runtime helpers.
use ruin_reactivity::{EffectHandle, effect};
use crate::platform::{PlatformClosed, PlatformEvent, PlatformProxy, PlatformRuntime};
use crate::scene::SceneSnapshot;
use crate::window::{WindowId, WindowSpec, WindowUpdate};
/// High-level UI-side owner of platform event consumption.
///
/// The headless backend currently co-locates platform and rendering on one worker thread, but this
/// runtime type deliberately treats them as logical subsystems behind an event/snapshot boundary so
/// future backends can split them without rewriting the app-facing control flow.
pub struct UiRuntime {
platform: PlatformRuntime,
}
/// Explicit handle for one declarative window instance.
///
/// The controller owns no native resources directly. Instead, it issues commands to the platform
/// runtime and can host a reactive scene effect that pushes immutable scene snapshots whenever UI
/// state changes.
#[derive(Clone)]
pub struct WindowController {
id: WindowId,
proxy: PlatformProxy,
}
/// Returned when the platform event stream closes before a matching event is observed.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct EventStreamClosed;
impl std::fmt::Display for EventStreamClosed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("platform event stream closed")
}
}
impl std::error::Error for EventStreamClosed {}
impl UiRuntime {
/// Creates a UI runtime backed by the headless prototype backend.
pub fn headless() -> Self {
Self {
platform: PlatformRuntime::headless(),
}
}
/// Returns a cloneable proxy for low-level platform commands.
pub fn proxy(&self) -> PlatformProxy {
self.platform.proxy()
}
/// Creates a new declarative window and returns a controller for it.
pub fn create_window(&self, spec: WindowSpec) -> Result<WindowController, PlatformClosed> {
let id = self.proxy().create_window(spec)?;
Ok(WindowController {
id,
proxy: self.proxy(),
})
}
/// Waits for the next platform event.
pub async fn next_event(&mut self) -> Option<PlatformEvent> {
self.platform.next_event().await
}
/// Waits until an event matches `predicate`.
pub async fn wait_for_event_matching(
&mut self,
mut predicate: impl FnMut(&PlatformEvent) -> bool,
) -> Result<PlatformEvent, EventStreamClosed> {
while let Some(event) = self.next_event().await {
if predicate(&event) {
return Ok(event);
}
}
Err(EventStreamClosed)
}
/// Requests shutdown of the platform runtime.
pub fn shutdown(&self) -> Result<(), PlatformClosed> {
self.proxy().shutdown()
}
}
impl WindowController {
/// Returns the logical window identifier.
pub const fn id(&self) -> WindowId {
self.id
}
/// Applies a window update.
pub fn update(&self, update: WindowUpdate) -> Result<(), PlatformClosed> {
self.proxy.update_window(self.id, update)
}
/// Replaces the latest retained scene for this window.
pub fn replace_scene(&self, scene: SceneSnapshot) -> Result<(), PlatformClosed> {
self.proxy.replace_scene(self.id, scene)
}
/// Emits a close-request event for this window.
pub fn emit_close_requested(&self) -> Result<(), PlatformClosed> {
self.proxy.emit_close_requested(self.id)
}
/// Attaches a reactive effect that rebuilds and replaces the window scene whenever dependent UI
/// state changes.
pub fn attach_scene_effect(
&self,
build_scene: impl Fn() -> SceneSnapshot + 'static,
) -> EffectHandle {
let controller = self.clone();
effect(move || {
let scene = build_scene();
controller
.replace_scene(scene)
.expect("window controller should remain alive while scene effect is attached");
})
}
}
#[cfg(test)]
mod tests {
use super::UiRuntime;
use crate::platform::PlatformEvent;
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::future::Future;
fn run_async_test(future: impl Future<Output = ()> + 'static) {
let main = current_thread_handle();
queue_future(async move {
future.await;
let _ = main.queue_task(|| {});
});
run();
}
#[test]
fn visibility_restore_re_presents_latest_retained_scene() {
run_async_test(async move {
let mut ui = UiRuntime::headless();
let window = ui
.create_window(WindowSpec::new("visibility-policy").visible(true))
.expect("window should be created");
let mut scene = SceneSnapshot::new(1, UiSize::new(320.0, 180.0));
scene
.push_quad(
Rect::new(0.0, 0.0, 320.0, 180.0),
Color::rgb(0x20, 0x22, 0x35),
)
.push_text(PreparedText::monospace(
"retained scene",
Point::new(16.0, 28.0),
16.0,
8.0,
Color::rgb(0xFF, 0xFF, 0xFF),
));
window
.replace_scene(scene)
.expect("scene replacement should queue");
let _ = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::FramePresented {
window_id,
scene_version: 1,
..
} if *window_id == window.id()
)
})
.await
.expect("initial present should occur");
window
.update(WindowUpdate::new().visible(false))
.expect("hide should queue");
let _ = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::VisibilityChanged {
window_id,
visible: false,
} if *window_id == window.id()
)
})
.await
.expect("hide event should occur");
window
.update(WindowUpdate::new().visible(true))
.expect("show should queue");
let _ = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::VisibilityChanged {
window_id,
visible: true,
} if *window_id == window.id()
)
})
.await
.expect("show event should occur");
let event = ui
.wait_for_event_matching(|event| {
matches!(
event,
PlatformEvent::FramePresented {
window_id,
scene_version: 1,
..
} if *window_id == window.id()
)
})
.await
.expect("visibility restore should re-present latest retained scene");
assert!(matches!(
event,
PlatformEvent::FramePresented {
scene_version: 1,
..
}
));
ui.shutdown().expect("shutdown should queue");
});
}
}

210
lib/ui/src/scene.rs Normal file
View File

@@ -0,0 +1,210 @@
//! Renderer-oriented scene snapshot types.
use tracing::debug;
use crate::trace_targets;
pub type SceneVersion = u64;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Point {
pub x: f32,
pub y: f32,
}
impl Point {
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UiSize {
pub width: f32,
pub height: f32,
}
impl UiSize {
pub const fn new(width: f32, height: f32) -> Self {
Self { width, height }
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rect {
pub origin: Point,
pub size: UiSize,
}
impl Rect {
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
Self {
origin: Point::new(x, y),
size: UiSize::new(width, height),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Color {
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::rgba(r, g, b, 255)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Translation {
pub x: f32,
pub y: f32,
}
impl Translation {
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quad {
pub rect: Rect,
pub color: Color,
}
impl Quad {
pub const fn new(rect: Rect, color: Color) -> Self {
Self { rect, color }
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct GlyphInstance {
pub glyph: String,
pub position: Point,
pub advance: f32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedText {
pub text: String,
pub font_size: f32,
pub color: Color,
pub glyphs: Vec<GlyphInstance>,
}
impl PreparedText {
pub fn monospace(
text: impl Into<String>,
origin: Point,
font_size: f32,
advance: f32,
color: Color,
) -> Self {
let text = text.into();
let mut x = origin.x;
let mut glyphs = Vec::with_capacity(text.chars().count());
for ch in text.chars() {
glyphs.push(GlyphInstance {
glyph: ch.to_string(),
position: Point::new(x, origin.y),
advance,
});
x += advance;
}
Self {
text,
font_size,
color,
glyphs,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum DisplayItem {
Quad(Quad),
Text(PreparedText),
PushClip(Rect),
PopClip,
PushTransform(Translation),
PopTransform,
LayerBegin { opacity: f32 },
LayerEnd,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SceneSnapshot {
pub version: SceneVersion,
pub logical_size: UiSize,
pub items: Vec<DisplayItem>,
}
impl SceneSnapshot {
pub fn new(version: SceneVersion, logical_size: UiSize) -> Self {
debug!(
target: trace_targets::SCENE,
event = "create_scene",
version,
width = logical_size.width,
height = logical_size.height,
"creating scene snapshot"
);
Self {
version,
logical_size,
items: Vec::new(),
}
}
pub fn item_count(&self) -> usize {
self.items.len()
}
pub fn push_item(&mut self, item: DisplayItem) -> &mut Self {
self.items.push(item);
self
}
pub fn push_quad(&mut self, rect: Rect, color: Color) -> &mut Self {
self.push_item(DisplayItem::Quad(Quad::new(rect, color)))
}
pub fn push_text(&mut self, text: PreparedText) -> &mut Self {
self.push_item(DisplayItem::Text(text))
}
pub fn push_clip(&mut self, rect: Rect) -> &mut Self {
self.push_item(DisplayItem::PushClip(rect))
}
pub fn pop_clip(&mut self) -> &mut Self {
self.push_item(DisplayItem::PopClip)
}
pub fn push_transform(&mut self, translation: Translation) -> &mut Self {
self.push_item(DisplayItem::PushTransform(translation))
}
pub fn pop_transform(&mut self) -> &mut Self {
self.push_item(DisplayItem::PopTransform)
}
pub fn begin_layer(&mut self, opacity: f32) -> &mut Self {
self.push_item(DisplayItem::LayerBegin { opacity })
}
pub fn end_layer(&mut self) -> &mut Self {
self.push_item(DisplayItem::LayerEnd)
}
}

235
lib/ui/src/window.rs Normal file
View File

@@ -0,0 +1,235 @@
//! Common window model types.
use crate::scene::UiSize;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WindowId(u64);
impl WindowId {
pub const fn from_raw(raw: u64) -> Self {
Self(raw)
}
pub const fn raw(self) -> u64 {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DecorationMode {
Auto,
Server,
Client,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WindowLifecycle {
LogicalClosed,
Opening,
AwaitingInitialConfigure,
OpenHidden,
OpenVisible,
Closing,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WindowConfigured {
pub actual_inner_size: UiSize,
pub scale_factor: f32,
pub visible: bool,
pub maximized: bool,
pub fullscreen: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct WindowSpec {
pub key: Option<String>,
pub title: String,
pub app_id: Option<String>,
pub open: bool,
pub visible: bool,
pub requested_inner_size: Option<UiSize>,
pub min_inner_size: Option<UiSize>,
pub max_inner_size: Option<UiSize>,
pub decorations: DecorationMode,
pub maximized: bool,
pub fullscreen: bool,
pub resizable: bool,
}
impl WindowSpec {
pub fn new(title: impl Into<String>) -> Self {
Self {
key: None,
title: title.into(),
app_id: None,
open: true,
visible: true,
requested_inner_size: None,
min_inner_size: None,
max_inner_size: None,
decorations: DecorationMode::Auto,
maximized: false,
fullscreen: false,
resizable: true,
}
}
pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
self.app_id = Some(app_id.into());
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn requested_inner_size(mut self, size: UiSize) -> Self {
self.requested_inner_size = Some(size);
self
}
pub fn min_inner_size(mut self, size: UiSize) -> Self {
self.min_inner_size = Some(size);
self
}
pub fn max_inner_size(mut self, size: UiSize) -> Self {
self.max_inner_size = Some(size);
self
}
pub fn decorations(mut self, decorations: DecorationMode) -> Self {
self.decorations = decorations;
self
}
pub fn maximized(mut self, maximized: bool) -> Self {
self.maximized = maximized;
self
}
pub fn fullscreen(mut self, fullscreen: bool) -> Self {
self.fullscreen = fullscreen;
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WindowUpdate {
pub title: Option<String>,
pub open: Option<bool>,
pub visible: Option<bool>,
pub requested_inner_size: Option<Option<UiSize>>,
pub min_inner_size: Option<Option<UiSize>>,
pub max_inner_size: Option<Option<UiSize>>,
pub decorations: Option<DecorationMode>,
pub maximized: Option<bool>,
pub fullscreen: Option<bool>,
pub resizable: Option<bool>,
}
impl WindowUpdate {
pub fn new() -> Self {
Self::default()
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = Some(open);
self
}
pub fn visible(mut self, visible: bool) -> Self {
self.visible = Some(visible);
self
}
pub fn requested_inner_size(mut self, size: Option<UiSize>) -> Self {
self.requested_inner_size = Some(size);
self
}
pub fn min_inner_size(mut self, size: Option<UiSize>) -> Self {
self.min_inner_size = Some(size);
self
}
pub fn max_inner_size(mut self, size: Option<UiSize>) -> Self {
self.max_inner_size = Some(size);
self
}
pub fn decorations(mut self, decorations: DecorationMode) -> Self {
self.decorations = Some(decorations);
self
}
pub fn maximized(mut self, maximized: bool) -> Self {
self.maximized = Some(maximized);
self
}
pub fn fullscreen(mut self, fullscreen: bool) -> Self {
self.fullscreen = Some(fullscreen);
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = Some(resizable);
self
}
pub(crate) fn apply_to(&self, spec: &mut WindowSpec) {
if let Some(title) = &self.title {
spec.title = title.clone();
}
if let Some(open) = self.open {
spec.open = open;
}
if let Some(visible) = self.visible {
spec.visible = visible;
}
if let Some(requested_inner_size) = self.requested_inner_size {
spec.requested_inner_size = requested_inner_size;
}
if let Some(min_inner_size) = self.min_inner_size {
spec.min_inner_size = min_inner_size;
}
if let Some(max_inner_size) = self.max_inner_size {
spec.max_inner_size = max_inner_size;
}
if let Some(decorations) = self.decorations {
spec.decorations = decorations;
}
if let Some(maximized) = self.maximized {
spec.maximized = maximized;
}
if let Some(fullscreen) = self.fullscreen {
spec.fullscreen = fullscreen;
}
if let Some(resizable) = self.resizable {
spec.resizable = resizable;
}
}
}

View File

@@ -0,0 +1,11 @@
[package]
name = "ruin_ui_platform_wayland"
version = "0.1.0"
edition = "2024"
[dependencies]
raw-window-handle = "0.6"
ruin_ui = { path = "../ui" }
wayland-backend = { version = "0.3", features = ["client_system"] }
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }

View File

@@ -0,0 +1,271 @@
use std::error::Error;
use std::ffi::c_void;
use std::num::NonZeroU32;
use std::ptr::NonNull;
use raw_window_handle::{
DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle,
RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, WindowHandle,
};
use ruin_ui::{UiSize, WindowSpec};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{wl_compositor, wl_registry, wl_surface};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
#[derive(Clone)]
pub struct WaylandSurfaceTarget {
connection: Connection,
surface: wl_surface::WlSurface,
}
impl HasDisplayHandle for WaylandSurfaceTarget {
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
let ptr = NonNull::new(self.connection.backend().display_ptr().cast::<c_void>())
.ok_or(HandleError::Unavailable)?;
let raw = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(ptr));
Ok(unsafe { DisplayHandle::borrow_raw(raw) })
}
}
impl HasWindowHandle for WaylandSurfaceTarget {
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
let ptr = NonNull::new(self.surface.id().as_ptr().cast::<c_void>())
.ok_or(HandleError::Unavailable)?;
let raw = RawWindowHandle::Wayland(WaylandWindowHandle::new(ptr));
Ok(unsafe { WindowHandle::borrow_raw(raw) })
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FrameRequest {
pub width: u32,
pub height: u32,
pub resized: bool,
}
pub struct WaylandWindow {
event_queue: wayland_client::EventQueue<State>,
surface_target: WaylandSurfaceTarget,
state: State,
}
struct State {
running: bool,
_connection: Connection,
_compositor: wl_compositor::WlCompositor,
_surface: wl_surface::WlSurface,
_xdg_surface: xdg_surface::XdgSurface,
_toplevel: xdg_toplevel::XdgToplevel,
_wm_base: xdg_wm_base::XdgWmBase,
current_size: (u32, u32),
configured: bool,
pending_size: Option<(u32, u32)>,
needs_redraw: bool,
}
impl State {
fn request_redraw(&mut self) {
self.needs_redraw = true;
}
}
impl WaylandWindow {
pub fn open(spec: WindowSpec) -> Result<Self, Box<dyn Error>> {
let connection = Connection::connect_to_env()?;
let (globals, event_queue) = registry_queue_init::<State>(&connection)?;
let qh = event_queue.handle();
let compositor: wl_compositor::WlCompositor = globals.bind(&qh, 4..=6, ())?;
let wm_base: xdg_wm_base::XdgWmBase = globals.bind(&qh, 1..=6, ())?;
let surface = compositor.create_surface(&qh, ());
let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
let toplevel = xdg_surface.get_toplevel(&qh, ());
toplevel.set_title(spec.title.clone());
if let Some(app_id) = spec.app_id.as_ref() {
toplevel.set_app_id(app_id.clone());
}
apply_size_constraints(&toplevel, &spec);
if spec.maximized {
toplevel.set_maximized();
}
if spec.fullscreen {
toplevel.set_fullscreen(None);
}
surface.commit();
connection.flush()?;
let initial_size = spec
.requested_inner_size
.unwrap_or_else(|| UiSize::new(800.0, 500.0));
let initial_width = initial_size.width.max(1.0).round() as u32;
let initial_height = initial_size.height.max(1.0).round() as u32;
let surface_target = WaylandSurfaceTarget {
connection: connection.clone(),
surface: surface.clone(),
};
Ok(Self {
event_queue,
surface_target,
state: State {
running: true,
_connection: connection,
_compositor: compositor,
_surface: surface,
_xdg_surface: xdg_surface,
_toplevel: toplevel,
_wm_base: wm_base,
current_size: (initial_width, initial_height),
configured: false,
pending_size: None,
needs_redraw: false,
},
})
}
pub fn surface_target(&self) -> WaylandSurfaceTarget {
self.surface_target.clone()
}
pub fn is_running(&self) -> bool {
self.state.running
}
pub fn dispatch(&mut self) -> Result<(), Box<dyn Error>> {
self.event_queue.blocking_dispatch(&mut self.state)?;
Ok(())
}
pub fn request_redraw(&mut self) {
self.state.request_redraw();
}
pub fn prepare_frame(&mut self) -> Option<FrameRequest> {
if !self.state.configured {
return None;
}
if let Some((width, height)) = self.state.pending_size.take() {
self.state.current_size = (width, height);
self.state.needs_redraw = false;
return Some(FrameRequest {
width,
height,
resized: true,
});
}
if self.state.needs_redraw {
self.state.needs_redraw = false;
return Some(FrameRequest {
width: self.state.current_size.0,
height: self.state.current_size.1,
resized: false,
});
}
None
}
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
fn event(
_state: &mut Self,
_proxy: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
}
}
delegate_noop!(State: ignore wl_compositor::WlCompositor);
delegate_noop!(State: ignore wl_surface::WlSurface);
impl Dispatch<xdg_wm_base::XdgWmBase, ()> for State {
fn event(
_state: &mut Self,
wm_base: &xdg_wm_base::XdgWmBase,
event: xdg_wm_base::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
if let xdg_wm_base::Event::Ping { serial } = event {
wm_base.pong(serial);
}
}
}
impl Dispatch<xdg_surface::XdgSurface, ()> for State {
fn event(
state: &mut Self,
xdg_surface: &xdg_surface::XdgSurface,
event: xdg_surface::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
if let xdg_surface::Event::Configure { serial } = event {
xdg_surface.ack_configure(serial);
state.configured = true;
state.request_redraw();
}
}
}
impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
fn event(
state: &mut Self,
_proxy: &xdg_toplevel::XdgToplevel,
event: xdg_toplevel::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
match event {
xdg_toplevel::Event::Close => {
state.running = false;
}
xdg_toplevel::Event::Configure {
width,
height,
states: _,
} => {
let width = NonZeroU32::new(width as u32)
.map(NonZeroU32::get)
.unwrap_or(state.current_size.0);
let height = NonZeroU32::new(height as u32)
.map(NonZeroU32::get)
.unwrap_or(state.current_size.1);
state.pending_size = Some((width, height));
state.request_redraw();
}
_ => {}
}
}
}
fn apply_size_constraints(toplevel: &xdg_toplevel::XdgToplevel, spec: &WindowSpec) {
if spec.resizable {
let min = spec.min_inner_size.unwrap_or_else(|| UiSize::new(0.0, 0.0));
let max = spec.max_inner_size.unwrap_or_else(|| UiSize::new(0.0, 0.0));
toplevel.set_min_size(min.width.round() as i32, min.height.round() as i32);
toplevel.set_max_size(max.width.round() as i32, max.height.round() as i32);
return;
}
let fixed = spec
.requested_inner_size
.or(spec.min_inner_size)
.or(spec.max_inner_size)
.unwrap_or_else(|| UiSize::new(800.0, 500.0));
let width = fixed.width.max(1.0).round() as i32;
let height = fixed.height.max(1.0).round() as i32;
toplevel.set_min_size(width, height);
toplevel.set_max_size(width, height);
}

View File

@@ -0,0 +1,11 @@
[package]
name = "ruin_ui_renderer_wgpu"
version = "0.1.0"
edition = "2024"
[dependencies]
bytemuck = { version = "1", features = ["derive"] }
pollster = "0.4"
raw-window-handle = "0.6"
ruin_ui = { path = "../ui" }
wgpu = "29"

View File

@@ -0,0 +1,317 @@
use std::error::Error;
use bytemuck::{Pod, Zeroable};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use ruin_ui::{Color, DisplayItem, Rect, SceneSnapshot};
use wgpu::util::DeviceExt;
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct Vertex {
position: [f32; 2],
color: [f32; 4],
}
const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4];
impl Vertex {
const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &VERTEX_ATTRIBUTES,
};
fn layout() -> wgpu::VertexBufferLayout<'static> {
Self::LAYOUT
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RenderError {
Lost,
Outdated,
Timeout,
Occluded,
Validation,
}
pub struct WgpuSceneRenderer {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
pipeline: wgpu::RenderPipeline,
}
impl WgpuSceneRenderer {
pub fn new(
target: impl HasDisplayHandle + HasWindowHandle + Send + Sync + 'static,
width: u32,
height: u32,
) -> Result<Self, Box<dyn Error>> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let surface = instance.create_surface(target)?;
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))?;
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))?;
let caps = surface.get_capabilities(&adapter);
let format = caps
.formats
.iter()
.copied()
.find(wgpu::TextureFormat::is_srgb)
.unwrap_or(caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: width.max(1),
height: height.max(1),
present_mode: wgpu::PresentMode::AutoVsync,
desired_maximum_frame_latency: 2,
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
};
surface.configure(&device, &config);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("ruin-ui-renderer-wgpu-shader"),
source: wgpu::ShaderSource::Wgsl(
r#"
struct VertexIn {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
};
struct VertexOut {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs_main(input: VertexIn) -> VertexOut {
var out: VertexOut;
out.position = vec4(input.position, 0.0, 1.0);
out.color = input.color;
return out;
}
@fragment
fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
return input.color;
}
"#
.into(),
),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("ruin-ui-renderer-wgpu-pipeline-layout"),
bind_group_layouts: &[],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("ruin-ui-renderer-wgpu-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[Vertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
Ok(Self {
surface,
device,
queue,
config,
pipeline,
})
}
pub fn size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
}
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width.max(1);
self.config.height = height.max(1);
self.surface.configure(&self.device, &self.config);
}
pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> {
let vertices = build_vertices(scene);
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
wgpu::CurrentSurfaceTexture::Lost => return Err(RenderError::Lost),
wgpu::CurrentSurfaceTexture::Outdated => return Err(RenderError::Outdated),
wgpu::CurrentSurfaceTexture::Timeout => return Err(RenderError::Timeout),
wgpu::CurrentSurfaceTexture::Occluded => return Err(RenderError::Occluded),
wgpu::CurrentSurfaceTexture::Validation => return Err(RenderError::Validation),
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let vertex_buffer = self
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("ruin-ui-renderer-wgpu-vertices"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("ruin-ui-renderer-wgpu-encoder"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("ruin-ui-renderer-wgpu-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.03,
g: 0.04,
b: 0.08,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
if !vertices.is_empty() {
pass.set_pipeline(&self.pipeline);
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
pass.draw(0..vertices.len() as u32, 0..1);
}
}
self.queue.submit([encoder.finish()]);
frame.present();
Ok(())
}
}
fn build_vertices(scene: &SceneSnapshot) -> Vec<Vertex> {
let width = scene.logical_size.width.max(1.0);
let height = scene.logical_size.height.max(1.0);
let mut vertices = Vec::new();
for item in &scene.items {
if let DisplayItem::Quad(quad) = item {
push_quad_vertices(&mut vertices, quad.rect, quad.color, width, height);
}
}
vertices
}
fn push_quad_vertices(
vertices: &mut Vec<Vertex>,
rect: Rect,
color: Color,
width: f32,
height: f32,
) {
let left = to_ndc_x(rect.origin.x, width);
let right = to_ndc_x(rect.origin.x + rect.size.width, width);
let top = to_ndc_y(rect.origin.y, height);
let bottom = to_ndc_y(rect.origin.y + rect.size.height, height);
let color = [
color.r as f32 / 255.0,
color.g as f32 / 255.0,
color.b as f32 / 255.0,
color.a as f32 / 255.0,
];
vertices.extend_from_slice(&[
Vertex {
position: [left, top],
color,
},
Vertex {
position: [right, top],
color,
},
Vertex {
position: [right, bottom],
color,
},
Vertex {
position: [left, top],
color,
},
Vertex {
position: [right, bottom],
color,
},
Vertex {
position: [left, bottom],
color,
},
]);
}
fn to_ndc_x(x: f32, width: f32) -> f32 {
(x / width) * 2.0 - 1.0
}
fn to_ndc_y(y: f32, height: f32) -> f32 {
1.0 - (y / height) * 2.0
}
#[cfg(test)]
mod tests {
use super::build_vertices;
use ruin_ui::{Color, PreparedText, Rect, SceneSnapshot, UiSize};
#[test]
fn quad_scenes_expand_to_six_vertices_per_quad() {
let mut scene = SceneSnapshot::new(1, UiSize::new(100.0, 50.0));
scene.push_quad(
Rect::new(0.0, 0.0, 100.0, 50.0),
Color::rgb(0x11, 0x22, 0x33),
);
scene.push_quad(
Rect::new(10.0, 10.0, 20.0, 20.0),
Color::rgb(0x44, 0x55, 0x66),
);
scene.push_text(PreparedText {
text: "ignored".into(),
font_size: 16.0,
color: Color::rgb(0xFF, 0xFF, 0xFF),
glyphs: Vec::new(),
});
let vertices = build_vertices(&scene);
assert_eq!(vertices.len(), 12);
}
}