Early UI work.
This commit is contained in:
1263
Cargo.lock
generated
1263
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["lib/*"]
|
members = ["lib/*", "examples/*"]
|
||||||
|
|||||||
9
examples/wayland_wgpu_demo/Cargo.toml
Normal file
9
examples/wayland_wgpu_demo/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruin_ui_wayland_demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ruin_ui = { path = "../../lib/ui" }
|
||||||
|
ruin_ui_platform_wayland = { path = "../../lib/ui_platform_wayland" }
|
||||||
|
ruin_ui_renderer_wgpu = { path = "../../lib/ui_renderer_wgpu" }
|
||||||
62
examples/wayland_wgpu_demo/src/main.rs
Normal file
62
examples/wayland_wgpu_demo/src/main.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use ruin_ui::{Color, Rect, SceneSnapshot, UiSize, WindowSpec};
|
||||||
|
use ruin_ui_platform_wayland::WaylandWindow;
|
||||||
|
use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer};
|
||||||
|
|
||||||
|
fn build_demo_scene() -> SceneSnapshot {
|
||||||
|
let mut scene = SceneSnapshot::new(1, UiSize::new(800.0, 500.0));
|
||||||
|
scene.push_quad(
|
||||||
|
Rect::new(0.0, 0.0, 800.0, 500.0),
|
||||||
|
Color::rgb(0x10, 0x14, 0x24),
|
||||||
|
);
|
||||||
|
scene.push_quad(
|
||||||
|
Rect::new(32.0, 32.0, 736.0, 72.0),
|
||||||
|
Color::rgb(0x2D, 0x3E, 0x68),
|
||||||
|
);
|
||||||
|
scene.push_quad(
|
||||||
|
Rect::new(32.0, 136.0, 352.0, 300.0),
|
||||||
|
Color::rgb(0x6A, 0x3D, 0x3D),
|
||||||
|
);
|
||||||
|
scene.push_quad(
|
||||||
|
Rect::new(416.0, 136.0, 352.0, 300.0),
|
||||||
|
Color::rgb(0x3A, 0x5E, 0x49),
|
||||||
|
);
|
||||||
|
scene
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let scene = build_demo_scene();
|
||||||
|
let mut window = WaylandWindow::open(
|
||||||
|
WindowSpec::new("RUIN Wayland / wgpu demo")
|
||||||
|
.app_id("dev.ruin.prototype")
|
||||||
|
.requested_inner_size(scene.logical_size),
|
||||||
|
)?;
|
||||||
|
let mut renderer = WgpuSceneRenderer::new(
|
||||||
|
window.surface_target(),
|
||||||
|
scene.logical_size.width as u32,
|
||||||
|
scene.logical_size.height as u32,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!("Opening RUIN Wayland / wgpu demo window...");
|
||||||
|
while window.is_running() {
|
||||||
|
window.dispatch()?;
|
||||||
|
if let Some(frame) = window.prepare_frame() {
|
||||||
|
if frame.resized {
|
||||||
|
renderer.resize(frame.width, frame.height);
|
||||||
|
}
|
||||||
|
match renderer.render(&scene) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(RenderError::Lost | RenderError::Outdated) => {
|
||||||
|
renderer.resize(frame.width, frame.height);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
Err(RenderError::Timeout | RenderError::Occluded | RenderError::Validation) => {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
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