Early UI work.
This commit is contained in:
12
lib/ui/Cargo.toml
Normal file
12
lib/ui/Cargo.toml
Normal 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"] }
|
||||
247
lib/ui/examples/explicit_ui_prototype.rs
Normal file
247
lib/ui/examples/explicit_ui_prototype.rs
Normal 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
26
lib/ui/src/lib.rs
Normal 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
647
lib/ui/src/platform.rs
Normal 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
238
lib/ui/src/runtime.rs
Normal 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
210
lib/ui/src/scene.rs
Normal 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
235
lib/ui/src/window.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
lib/ui_platform_wayland/Cargo.toml
Normal file
11
lib/ui_platform_wayland/Cargo.toml
Normal 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"] }
|
||||
271
lib/ui_platform_wayland/src/lib.rs
Normal file
271
lib/ui_platform_wayland/src/lib.rs
Normal 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);
|
||||
}
|
||||
11
lib/ui_renderer_wgpu/Cargo.toml
Normal file
11
lib/ui_renderer_wgpu/Cargo.toml
Normal 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"
|
||||
317
lib/ui_renderer_wgpu/src/lib.rs
Normal file
317
lib/ui_renderer_wgpu/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user