diff --git a/Cargo.lock b/Cargo.lock index b242a3d..35997ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,39 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbe782a9e7520cc7de2232c957a47f99d3a35e855552677d07a557bc1a3b66ed" +dependencies = [ + "bitflags", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.1", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -222,6 +255,47 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73829a7b5c91198af28a99159b7ae4afbb252fb906159ff7f189f3a2ceaa3df2" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -333,6 +407,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -496,6 +583,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -538,6 +631,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "naga" version = "29.0.0" @@ -558,7 +660,7 @@ dependencies = [ "log", "num-traits", "once_cell", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "thiserror", "unicode-ident", @@ -767,6 +869,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -785,6 +893,27 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -817,6 +946,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "ruin-runtime" version = "0.1.0" @@ -851,6 +986,7 @@ dependencies = [ name = "ruin_ui" version = "0.1.0" dependencies = [ + "cosmic-text", "ruin-runtime", "ruin_reactivity", "tracing", @@ -861,24 +997,51 @@ dependencies = [ name = "ruin_ui_platform_wayland" version = "0.1.0" dependencies = [ + "libc", "raw-window-handle", + "ruin-runtime", "ruin_ui", "wayland-backend", "wayland-client", "wayland-protocols", ] +[[package]] +name = "ruin_ui_reactive_layout_demo" +version = "0.1.0" +dependencies = [ + "ruin-runtime", + "ruin_reactivity", + "ruin_ui", + "ruin_ui_platform_wayland", + "ruin_ui_renderer_wgpu", + "tracing", + "tracing-subscriber", +] + [[package]] name = "ruin_ui_renderer_wgpu" version = "0.1.0" dependencies = [ "bytemuck", + "cosmic-text", "pollster", "raw-window-handle", "ruin_ui", + "tracing", "wgpu", ] +[[package]] +name = "ruin_ui_text_paragraph_demo" +version = "0.1.0" +dependencies = [ + "ruin-runtime", + "ruin_ui", + "ruin_ui_platform_wayland", + "ruin_ui_renderer_wgpu", +] + [[package]] name = "ruin_ui_wayland_demo" version = "0.1.0" @@ -894,6 +1057,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.4" @@ -925,6 +1094,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "serde" version = "1.0.228" @@ -970,6 +1145,26 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -991,6 +1186,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -1006,6 +1207,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "swash" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" +dependencies = [ + "skrifa 0.37.0", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "2.0.117" @@ -1017,6 +1229,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -1055,6 +1276,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -1071,9 +1307,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1104,12 +1352,45 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" @@ -1313,7 +1594,7 @@ dependencies = [ "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror", "wgpu-core-deps-apple", @@ -1562,6 +1843,18 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.8.47" diff --git a/examples/reactive_layout_demo/Cargo.toml b/examples/reactive_layout_demo/Cargo.toml new file mode 100644 index 0000000..cd9af9d --- /dev/null +++ b/examples/reactive_layout_demo/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ruin_ui_reactive_layout_demo" +version = "0.1.0" +edition = "2024" + +[dependencies] +ruin_reactivity = { path = "../../lib/reactivity" } +ruin_runtime = { package = "ruin-runtime", path = "../../lib/runtime" } +ruin_ui = { path = "../../lib/ui" } +ruin_ui_platform_wayland = { path = "../../lib/ui_platform_wayland" } +ruin_ui_renderer_wgpu = { path = "../../lib/ui_renderer_wgpu" } +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = [ + "env-filter", + "fmt", + "std", +] } diff --git a/examples/reactive_layout_demo/src/main.rs b/examples/reactive_layout_demo/src/main.rs new file mode 100644 index 0000000..25d813c --- /dev/null +++ b/examples/reactive_layout_demo/src/main.rs @@ -0,0 +1,593 @@ +use std::cell::RefCell; +use std::error::Error; +use std::fs; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use ruin_reactivity::effect; +use ruin_runtime::channel::{mpsc, oneshot}; +use ruin_runtime::{clear_interval, queue_future, queue_task, set_interval, spawn_worker}; +use ruin_ui::{ + Color, Edges, Element, SceneSnapshot, TextStyle, TextSystem, TextWrap, UiSize, WindowSpec, + layout_scene_with_text_system, +}; +use ruin_ui_platform_wayland::WaylandWindow; +use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer}; + +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt}; + +const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct MemoryTelemetry { + rss_bytes: Option, +} + +fn install_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new( + "info,ruin_runtime::runtime=trace,ruin_ui::platform=trace,ruin_reactivity::effect=trace", + ) + }); + + 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(); +} + +enum WorkerCommand { + ReplaceScene(SceneSnapshot), + Shutdown, +} + +enum WorkerEvent { + ViewportChanged(UiSize), + FramePresented(Instant), + Closed, +} + +struct WorkerState { + window: WaylandWindow, + renderer: WgpuSceneRenderer, + latest_scene: Option, + render_queued: bool, + closed_emitted: bool, + pending_viewport: Option, + viewport_request_in_flight: Option, + event_tx: mpsc::UnboundedSender, +} + +#[ruin_runtime::async_main] +async fn main() { + install_tracing(); + + run_demo() + .await + .expect("reactive layout demo should run successfully"); +} + +async fn run_demo() -> Result<(), Box> { + let initial_size = UiSize::new(960.0, 640.0); + let viewport = ruin_reactivity::cell(initial_size); + let animation_elapsed = ruin_reactivity::cell(Duration::ZERO); + let memory_telemetry = ruin_reactivity::cell(sample_memory_telemetry()); + let version = Rc::new(std::cell::Cell::new(0u64)); + let text_system = Rc::new(RefCell::new(TextSystem::new())); + let animation_epoch = Instant::now(); + let (scene_tx, mut scene_rx) = mpsc::unbounded_channel::(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + let (closed_tx, mut closed_rx) = oneshot::channel::<()>(); + let closed_tx = Rc::new(RefCell::new(Some(closed_tx))); + + let worker = spawn_worker( + move || { + let window = WaylandWindow::open( + WindowSpec::new("Reactive layout demo") + .app_id("dev.ruin.reactive-layout") + .requested_inner_size(initial_size), + ) + .expect("Wayland window should open"); + let renderer = WgpuSceneRenderer::new( + window.surface_target(), + initial_size.width as u32, + initial_size.height as u32, + ) + .expect("wgpu renderer should initialize"); + let state = Rc::new(RefCell::new(WorkerState { + window, + renderer, + latest_scene: None, + render_queued: false, + closed_emitted: false, + pending_viewport: None, + viewport_request_in_flight: None, + event_tx, + })); + + queue_future({ + let state = Rc::clone(&state); + async move { + while let Some(command) = scene_rx.recv().await { + match command { + WorkerCommand::ReplaceScene(scene) => { + { + let mut state_ref = state.borrow_mut(); + state_ref.latest_scene = Some(scene); + state_ref.window.request_redraw(); + } + schedule_worker_render(Rc::clone(&state)); + } + WorkerCommand::Shutdown => break, + } + } + } + }); + + schedule_worker_render(state); + }, + || {}, + ); + + let _scene_effect = effect({ + let viewport = viewport.clone(); + let animation_elapsed = animation_elapsed.clone(); + let memory_telemetry = memory_telemetry.clone(); + let version = Rc::clone(&version); + let text_system = Rc::clone(&text_system); + let scene_tx = scene_tx.clone(); + move || { + let next_version = version.get().wrapping_add(1); + version.set(next_version); + let viewport_size = viewport.get(); + let elapsed = animation_elapsed.get(); + let telemetry = memory_telemetry.get(); + let layout_start = Instant::now(); + let scene = layout_scene_with_text_system( + next_version, + viewport_size, + &build_dashboard_tree(viewport_size, elapsed, telemetry), + &mut text_system.borrow_mut(), + ); + tracing::trace!( + target: "ruin_ui_reactive_layout_demo::perf", + scene_version = next_version, + layout_ms = layout_start.elapsed().as_secs_f64() * 1_000.0, + "built scene" + ); + let _ = scene_tx.send(WorkerCommand::ReplaceScene(scene)); + } + }); + let memory_interval = set_interval(Duration::from_millis(500), { + let memory_telemetry = memory_telemetry.clone(); + move || { + memory_telemetry.set(sample_memory_telemetry()); + } + }); + + queue_future({ + let viewport = viewport.clone(); + let animation_elapsed = animation_elapsed.clone(); + let closed_tx = Rc::clone(&closed_tx); + async move { + while let Some(event) = event_rx.recv().await { + let mut latest_viewport = None; + let mut latest_frame = None; + let mut closed = false; + + match event { + WorkerEvent::ViewportChanged(size) => latest_viewport = Some(size), + WorkerEvent::FramePresented(presented_at) => latest_frame = Some(presented_at), + WorkerEvent::Closed => closed = true, + } + + loop { + match event_rx.try_recv() { + Ok(WorkerEvent::ViewportChanged(size)) => latest_viewport = Some(size), + Ok(WorkerEvent::FramePresented(presented_at)) => { + latest_frame = Some(presented_at); + } + Ok(WorkerEvent::Closed) => { + closed = true; + break; + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + closed = true; + break; + } + } + } + + if let Some(size) = latest_viewport { + viewport.set(size); + } + if let Some(presented_at) = latest_frame { + animation_elapsed.set(presented_at.saturating_duration_since(animation_epoch)); + } + if closed { + if let Some(sender) = closed_tx.borrow_mut().take() { + let _ = sender.send(()); + } + break; + } + } + } + }); + + println!("Opening reactive layout demo window..."); + + let _ = closed_rx.recv().await; + clear_interval(&memory_interval); + let _ = scene_tx.send(WorkerCommand::Shutdown); + let _ = worker; + Ok(()) +} + +fn schedule_worker_render(state: Rc>) { + let should_queue = { + let mut state_ref = state.borrow_mut(); + if state_ref.render_queued { + false + } else { + state_ref.render_queued = true; + true + } + }; + if !should_queue { + return; + } + + queue_task({ + let state = Rc::clone(&state); + move || { + state.borrow_mut().render_queued = false; + render_worker_now(state); + } + }); +} + +fn render_worker_now(state: Rc>) { + let mut state_ref = state.borrow_mut(); + state_ref + .window + .wait_for_events(Duration::from_millis(1)) + .expect("Wayland wait should succeed"); + if !state_ref.window.is_running() { + drop(state_ref); + notify_worker_closed(&state); + return; + } + + let Some(frame) = state_ref.window.prepare_frame() else { + return; + }; + + if frame.resized { + state_ref.renderer.resize(frame.width, frame.height); + state_ref.window.request_redraw(); + let resized_viewport = UiSize::new(frame.width as f32, frame.height as f32); + state_ref.pending_viewport = Some(resized_viewport); + let viewport_to_notify = if state_ref.viewport_request_in_flight.is_none() { + state_ref.viewport_request_in_flight = Some(resized_viewport); + Some(resized_viewport) + } else { + None + }; + let event_tx = state_ref.event_tx.clone(); + drop(state_ref); + if let Some(size) = viewport_to_notify { + let _ = event_tx.send(WorkerEvent::ViewportChanged(size)); + } + schedule_worker_render(state); + return; + } + + let scene = state_ref.latest_scene.clone(); + let mut frame_presented_at = None; + let mut viewport_to_notify = None; + let retry = if let Some(scene) = scene.as_ref() { + let current_viewport = UiSize::new(frame.width as f32, frame.height as f32); + if scene.logical_size != current_viewport { + state_ref.pending_viewport = Some(current_viewport); + if state_ref.viewport_request_in_flight != Some(current_viewport) { + state_ref.viewport_request_in_flight = Some(current_viewport); + viewport_to_notify = Some(current_viewport); + } + false + } else { + match state_ref.renderer.render(scene) { + Ok(()) => { + frame_presented_at = Some(Instant::now()); + if state_ref.pending_viewport == Some(scene.logical_size) { + state_ref.pending_viewport = None; + } + if state_ref.viewport_request_in_flight == Some(scene.logical_size) { + state_ref.viewport_request_in_flight = None; + } + if state_ref.viewport_request_in_flight.is_none() + && let Some(pending_viewport) = state_ref.pending_viewport + && pending_viewport != scene.logical_size + { + state_ref.viewport_request_in_flight = Some(pending_viewport); + viewport_to_notify = Some(pending_viewport); + } + false + } + Err(RenderError::Lost | RenderError::Outdated) => { + state_ref.renderer.resize(frame.width, frame.height); + state_ref.window.request_redraw(); + true + } + Err(RenderError::Timeout | RenderError::Occluded | RenderError::Validation) => { + state_ref.window.request_redraw(); + true + } + } + } + } else { + false + }; + let event_tx = state_ref.event_tx.clone(); + drop(state_ref); + + if let Some(presented_at) = frame_presented_at { + let _ = event_tx.send(WorkerEvent::FramePresented(presented_at)); + } + if let Some(size) = viewport_to_notify { + let _ = event_tx.send(WorkerEvent::ViewportChanged(size)); + } + + if retry { + schedule_worker_render(state); + } +} + +fn notify_worker_closed(state: &Rc>) { + let event_tx = { + let mut state_ref = state.borrow_mut(); + if state_ref.closed_emitted { + return; + } + state_ref.closed_emitted = true; + state_ref.event_tx.clone() + }; + let _ = event_tx.send(WorkerEvent::Closed); +} + +fn build_dashboard_tree( + viewport: UiSize, + animation_elapsed: Duration, + memory_telemetry: MemoryTelemetry, +) -> Element { + let gutter = (viewport.width * 0.02).clamp(12.0, 28.0); + let header_height = (viewport.height * 0.12).clamp(72.0, 112.0); + let sidebar_width = lerp( + 150.0, + 260.0, + triangle_wave_seconds(animation_elapsed, 0.72, 0.0), + ); + let inspector_width = lerp( + 180.0, + 280.0, + triangle_wave_seconds(animation_elapsed, 0.84, 0.24), + ); + let top_card_pulse = lerp( + 1.0, + 1.8, + triangle_wave_seconds(animation_elapsed, 0.48, 0.0), + ); + let bottom_left_pulse = lerp( + 1.0, + 2.1, + triangle_wave_seconds(animation_elapsed, 0.64, 0.12), + ); + let bottom_right_pulse = lerp( + 1.0, + 1.7, + triangle_wave_seconds(animation_elapsed, 0.80, 0.30), + ); + let memory_label = format_memory_telemetry(memory_telemetry); + + Element::column() + .background(Color::rgb(0x10, 0x14, 0x24)) + .padding(Edges::all(gutter)) + .gap(gutter) + .children([ + Element::text( + format!( + "RUIN reactive layout | viewport {:.0} × {:.0} | {memory_label}. Resize the window to watch this block reflow inside the layout tree as the container width changes.", + viewport.width, viewport.height, + ), + TextStyle::new(18.0, Color::rgb(0xF4, 0xF7, 0xFF)) + .with_line_height(24.0) + .with_wrap(TextWrap::Word), + ) + .padding(Edges::all(gutter * 0.8)) + .background(Color::rgb(0x17, 0x22, 0x36)), + Element::row() + .height(header_height) + .padding(Edges::all(gutter * 0.6)) + .gap(gutter * 0.6) + .background(Color::rgb(0x1D, 0x27, 0x42)) + .children([ + Element::new() + .width((viewport.width * 0.16).clamp(120.0, 220.0)) + .background(Color::rgb(0x6D, 0x79, 0xFF)), + Element::new() + .flex(1.0) + .background(Color::rgb(0x2D, 0x3E, 0x68)), + Element::new() + .width((viewport.width * 0.12).clamp(96.0, 180.0)) + .background(Color::rgb(0x39, 0x68, 0xA3)), + ]), + Element::row().flex(1.0).gap(gutter).children([ + Element::column() + .width(sidebar_width) + .gap(gutter * 0.6) + .padding(Edges::all(gutter * 0.6)) + .background(Color::rgb(0x1B, 0x22, 0x36)) + .children([ + Element::new() + .height(54.0) + .background(Color::rgb(0x4A, 0x5A, 0x86)), + Element::new() + .height(54.0) + .background(Color::rgb(0x58, 0x6C, 0xA0)), + Element::new() + .height(54.0) + .background(Color::rgb(0x66, 0x7E, 0xB8)), + Element::new() + .flex(1.0) + .background(Color::rgb(0x2A, 0x33, 0x50)), + ]), + Element::column().flex(1.0).gap(gutter).children([ + Element::row().height(140.0).gap(gutter).children([ + Element::new() + .flex(top_card_pulse) + .background(Color::rgb(0x6A, 0x3D, 0x3D)), + Element::new() + .flex(1.0) + .background(Color::rgb(0x5E, 0x52, 0x2C)), + Element::new() + .flex(1.0 + (top_card_pulse - 1.0) * 0.6) + .background(Color::rgb(0x3A, 0x5E, 0x49)), + ]), + Element::row().flex(1.0).gap(gutter).children([ + Element::column() + .flex(bottom_left_pulse) + .gap(gutter * 0.6) + .padding(Edges::all(gutter * 0.6)) + .background(Color::rgb(0x1B, 0x2E, 0x3B)) + .children([ + Element::new() + .height(64.0) + .background(Color::rgb(0x2F, 0x72, 0x91)), + Element::new() + .flex(1.0) + .background(Color::rgb(0x1F, 0x4B, 0x62)), + ]), + Element::column() + .flex(bottom_right_pulse) + .gap(gutter * 0.6) + .padding(Edges::all(gutter * 0.6)) + .background(Color::rgb(0x2C, 0x1F, 0x38)) + .children([ + Element::new() + .height(64.0) + .background(Color::rgb(0x7B, 0x4D, 0xA6)), + Element::new() + .flex(1.0) + .background(Color::rgb(0x4D, 0x2E, 0x6B)), + ]), + ]), + ]), + Element::column() + .width(inspector_width) + .gap(gutter * 0.6) + .padding(Edges::all(gutter * 0.6)) + .background(Color::rgb(0x1F, 0x23, 0x34)) + .children([ + Element::new() + .height(120.0) + .background(Color::rgb(0x67, 0x4B, 0x2D)), + Element::new() + .height(88.0) + .background(Color::rgb(0x6C, 0x60, 0x38)), + Element::text( + LOREM_IPSUM, + TextStyle::new(15.0, Color::rgb(0xE8, 0xEB, 0xF7)) + .with_line_height(22.0) + .with_wrap(TextWrap::Word), + ) + .flex(1.0) + .padding(Edges::all(gutter * 0.65)) + .background(Color::rgb(0x30, 0x35, 0x48)), + ]), + ]), + ]) +} + +fn triangle_wave_seconds(elapsed: Duration, period_seconds: f32, phase_offset_seconds: f32) -> f32 { + let period = period_seconds.max(f32::EPSILON); + let phase = (elapsed.as_secs_f32() + phase_offset_seconds).rem_euclid(period); + let half = period * 0.5; + if phase <= half { + phase / half + } else { + (period - phase) / half + } +} + +fn lerp(start: f32, end: f32, t: f32) -> f32 { + start + (end - start) * t.clamp(0.0, 1.0) +} + +fn sample_memory_telemetry() -> MemoryTelemetry { + MemoryTelemetry { + rss_bytes: sample_process_rss_bytes(), + } +} + +fn sample_process_rss_bytes() -> Option { + let statm = fs::read_to_string("/proc/self/statm").ok()?; + parse_rss_bytes(&statm, ruin_runtime::page_size()) +} + +fn parse_rss_bytes(statm: &str, page_size: usize) -> Option { + let resident_pages = statm.split_whitespace().nth(1)?.parse::().ok()?; + resident_pages.checked_mul(page_size) +} + +fn format_memory_telemetry(telemetry: MemoryTelemetry) -> String { + match telemetry.rss_bytes { + Some(bytes) => format!("RSS {}", format_mib(bytes)), + None => String::from("RSS unavailable"), + } +} + +fn format_mib(bytes: usize) -> String { + format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0)) +} + +#[cfg(test)] +mod tests { + use super::{MemoryTelemetry, format_memory_telemetry, parse_rss_bytes, triangle_wave_seconds}; + use std::time::Duration; + + #[test] + fn parses_resident_pages_from_statm() { + let rss = parse_rss_bytes("4096 512 0 0 0 0 0\n", 4096); + assert_eq!(rss, Some(2 * 1024 * 1024)); + } + + #[test] + fn formats_memory_telemetry() { + let telemetry = MemoryTelemetry { + rss_bytes: Some(3 * 1024 * 1024), + }; + assert_eq!(format_memory_telemetry(telemetry), "RSS 3.0 MiB"); + assert_eq!( + format_memory_telemetry(MemoryTelemetry::default()), + "RSS unavailable" + ); + } + + #[test] + fn triangle_wave_seconds_tracks_elapsed_time() { + assert_eq!( + triangle_wave_seconds(Duration::from_millis(0), 1.0, 0.0), + 0.0 + ); + assert!((triangle_wave_seconds(Duration::from_millis(500), 1.0, 0.0) - 1.0).abs() < 1e-6); + assert!((triangle_wave_seconds(Duration::from_millis(750), 1.0, 0.0) - 0.5).abs() < 1e-6); + } +} diff --git a/examples/text_paragraph_demo/Cargo.toml b/examples/text_paragraph_demo/Cargo.toml new file mode 100644 index 0000000..431bb26 --- /dev/null +++ b/examples/text_paragraph_demo/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ruin_ui_text_paragraph_demo" +version = "0.1.0" +edition = "2024" + +[dependencies] +ruin_runtime = { package = "ruin-runtime", path = "../../lib/runtime" } +ruin_ui = { path = "../../lib/ui" } +ruin_ui_platform_wayland = { path = "../../lib/ui_platform_wayland" } +ruin_ui_renderer_wgpu = { path = "../../lib/ui_renderer_wgpu" } diff --git a/examples/text_paragraph_demo/src/main.rs b/examples/text_paragraph_demo/src/main.rs new file mode 100644 index 0000000..23f6b0b --- /dev/null +++ b/examples/text_paragraph_demo/src/main.rs @@ -0,0 +1,193 @@ +use std::error::Error; + +use ruin_ui::{ + Color, Edges, Element, SceneSnapshot, TextAlign, TextStyle, TextSystem, UiSize, WindowSpec, + layout_scene_with_text_system, +}; +use ruin_ui_platform_wayland::WaylandWindow; +use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer}; + +const INTRO: &str = "RUIN is exploring a retained layout tree backed by explicit scene building, a dedicated platform thread, and a renderer that can stay simple while the higher-level UI model evolves. This example is intentionally calm: no animated panels, no pulsing widths, just a document-like surface that makes paragraph behavior easier to inspect."; +const BODY_ONE: &str = "Paragraph widgets are the next useful layer above raw text leaves. They should be able to wrap naturally inside containers, respect alignment, clamp to a maximum number of lines when appropriate, and participate in layout without forcing every example to become a custom text experiment. That gets us closer to real application surfaces instead of just proving that glyphs can reach the screen."; +const BODY_TWO: &str = "This demo keeps the overall layout mostly static while still responding to window resizing. The centered title, the body copy, and the clamped sidebar notes all use the same retained layout pipeline, but they exercise different paragraph semantics. It should be a much less exhausting place to look at text than the reactive dashboard stress test."; +const SIDEBAR_NOTE: &str = "Clamped note: the renderer now uses a shared glyph atlas for prepared text, so the remaining debug-build cost mostly comes from paragraph layout and scene building rather than per-frame CPU text compositing."; +const FOOTER: &str = "Next up after this slice: richer paragraph/block rules, then inline style runs, then interactive text editing and selection."; + +#[ruin_runtime::main] +fn main() -> Result<(), Box> { + let mut viewport = UiSize::new(1040.0, 760.0); + let mut version = 1_u64; + let mut text_system = TextSystem::new(); + let mut scene = build_scene(viewport, version, &mut text_system); + + let mut window = WaylandWindow::open( + WindowSpec::new("RUIN paragraph demo") + .app_id("dev.ruin.text-paragraph-demo") + .requested_inner_size(viewport), + )?; + let mut renderer = WgpuSceneRenderer::new( + window.surface_target(), + viewport.width as u32, + viewport.height as u32, + )?; + + window.request_redraw(); + println!("Opening RUIN paragraph demo window..."); + + while window.is_running() { + window.dispatch()?; + if let Some(frame) = window.prepare_frame() { + if frame.resized { + renderer.resize(frame.width, frame.height); + viewport = UiSize::new(frame.width as f32, frame.height as f32); + version = version.wrapping_add(1); + scene = build_scene(viewport, version, &mut text_system); + window.request_redraw(); + } + + 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(()) +} + +fn build_scene(viewport: UiSize, version: u64, text_system: &mut TextSystem) -> SceneSnapshot { + let tree = build_document_tree(viewport); + layout_scene_with_text_system(version, viewport, &tree, text_system) +} + +fn build_document_tree(viewport: UiSize) -> Element { + let gutter = (viewport.width * 0.025).clamp(18.0, 30.0); + let sidebar_width = (viewport.width * 0.28).clamp(220.0, 320.0); + + Element::column() + .background(Color::rgb(0x0F, 0x13, 0x1E)) + .padding(Edges::all(gutter)) + .gap(gutter) + .children([ + Element::column() + .padding(Edges::all(gutter)) + .gap(gutter * 0.45) + .background(Color::rgb(0x16, 0x1D, 0x2B)) + .children([ + Element::paragraph( + "RUIN paragraph demo", + TextStyle::new(34.0, Color::rgb(0xF5, 0xF7, 0xFB)) + .with_line_height(40.0) + .with_align(TextAlign::Center), + ), + Element::paragraph( + INTRO, + TextStyle::new(18.0, Color::rgb(0xC9, 0xD2, 0xE3)) + .with_line_height(28.0) + .with_align(TextAlign::Center), + ), + ]), + Element::row().flex(1.0).gap(gutter).children([ + Element::column() + .flex(1.0) + .gap(gutter) + .children([ + text_card("Why paragraphs matter", BODY_ONE, gutter), + text_card("Calmer inspection surface", BODY_TWO, gutter), + Element::column() + .padding(Edges::all(gutter)) + .gap(gutter * 0.45) + .background(Color::rgb(0x1A, 0x22, 0x31)) + .children([ + Element::paragraph( + "Next direction", + TextStyle::new(20.0, Color::rgb(0xF5, 0xF7, 0xFB)) + .with_line_height(26.0), + ), + Element::paragraph( + FOOTER, + TextStyle::new(17.0, Color::rgb(0xD8, 0xDF, 0xED)) + .with_line_height(26.0), + ), + ]), + ]), + Element::column() + .width(sidebar_width) + .gap(gutter) + .children([ + sidebar_card( + "Centered pull quote", + "“A retained layout tree is only really convincing once text can participate in it naturally.”", + gutter, + Some(TextAlign::Center), + None, + ), + sidebar_card( + "Clamped note", + SIDEBAR_NOTE, + gutter, + None, + Some(4), + ), + sidebar_card( + "Status", + "Static layout, responsive resize, paragraph wrapping, centered headings, and line clamping all share the same UI pipeline now.", + gutter, + Some(TextAlign::End), + None, + ), + ]), + ]), + ]) +} + +fn text_card(title: &str, body: &str, gutter: f32) -> Element { + Element::column() + .padding(Edges::all(gutter)) + .gap(gutter * 0.45) + .background(Color::rgb(0x18, 0x20, 0x2F)) + .children([ + Element::paragraph( + title, + TextStyle::new(24.0, Color::rgb(0xF4, 0xF7, 0xFF)).with_line_height(30.0), + ), + Element::paragraph( + body, + TextStyle::new(18.0, Color::rgb(0xD9, 0xE0, 0xEE)).with_line_height(29.0), + ), + ]) +} + +fn sidebar_card( + title: &str, + body: &str, + gutter: f32, + align: Option, + max_lines: Option, +) -> Element { + let mut body_style = TextStyle::new(16.0, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(25.0); + if let Some(align) = align { + body_style = body_style.with_align(align); + } + if let Some(max_lines) = max_lines { + body_style = body_style.with_max_lines(max_lines); + } + + Element::column() + .padding(Edges::all(gutter * 0.9)) + .gap(gutter * 0.35) + .background(Color::rgb(0x1C, 0x24, 0x34)) + .children([ + Element::paragraph( + title, + TextStyle::new(18.0, Color::rgb(0xF4, 0xF7, 0xFF)).with_line_height(24.0), + ), + Element::paragraph(body, body_style), + ]) +} diff --git a/lib/runtime/src/fd.rs b/lib/runtime/src/fd.rs new file mode 100644 index 0000000..119df28 --- /dev/null +++ b/lib/runtime/src/fd.rs @@ -0,0 +1,105 @@ +//! File-descriptor readiness helpers backed by the runtime driver. + +use std::io; +use std::os::fd::RawFd; + +use crate::op::completion::completion_for_current_thread; +use crate::platform::linux_x86_64::runtime::with_current_driver; +use crate::platform::linux_x86_64::uring::{IORING_OP_POLL_ADD, IoUringCqe}; + +/// Waits until `fd` becomes readable or reports an error/hangup condition. +pub async fn wait_readable(fd: RawFd) -> io::Result<()> { + submit_poll(fd, libc::POLLIN | libc::POLLERR | libc::POLLHUP).await +} + +async fn submit_poll(fd: RawFd, mask: i16) -> io::Result<()> { + let (future, handle) = completion_for_current_thread::>(); + let callback_handle = handle.clone(); + let token = with_current_driver(|driver| { + driver.submit_operation( + move |sqe| { + sqe.opcode = IORING_OP_POLL_ADD; + sqe.fd = fd; + sqe.len = 0; + sqe.op_flags = mask as u32; + }, + move |cqe| { + callback_handle.complete(cqe_to_result(cqe)); + }, + ) + })?; + + handle.set_cancel(move || { + let _ = with_current_driver(|driver| driver.cancel_operation(token)); + }); + + future.await +} + +fn cqe_to_result(cqe: IoUringCqe) -> io::Result<()> { + if cqe.res < 0 { + return Err(io::Error::from_raw_os_error(-cqe.res)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::wait_readable; + use crate::{queue_future, queue_task, run}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn wait_readable_resolves_for_pipe() { + let mut fds = [0; 2]; + let result = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) }; + assert_eq!(result, 0, "pipe2 should succeed"); + let read_fd = fds[0]; + let write_fd = fds[1]; + + let observed = Arc::new(AtomicBool::new(false)); + queue_task({ + let observed = Arc::clone(&observed); + move || { + queue_future(async move { + wait_readable(read_fd) + .await + .expect("pipe read end should become readable"); + observed.store(true, Ordering::SeqCst); + + let mut byte = 0u8; + let read = unsafe { + libc::read( + read_fd, + &mut byte as *mut u8 as *mut libc::c_void, + std::mem::size_of::(), + ) + }; + assert_eq!(read, 1); + unsafe { + libc::close(read_fd); + } + }); + + std::thread::spawn(move || { + let byte = 1u8; + let written = unsafe { + libc::write( + write_fd, + &byte as *const u8 as *const libc::c_void, + std::mem::size_of::(), + ) + }; + assert_eq!(written, 1); + unsafe { + libc::close(write_fd); + } + }); + } + }); + + run(); + assert!(observed.load(Ordering::SeqCst)); + } +} diff --git a/lib/runtime/src/lib.rs b/lib/runtime/src/lib.rs index 306a922..2417077 100644 --- a/lib/runtime/src/lib.rs +++ b/lib/runtime/src/lib.rs @@ -34,6 +34,7 @@ pub(crate) mod trace_targets { } pub mod channel; +pub mod fd; pub mod fs; pub mod net; #[doc(hidden)] diff --git a/lib/runtime/src/platform/linux_x86_64/uring.rs b/lib/runtime/src/platform/linux_x86_64/uring.rs index 801a2f1..8afd726 100644 --- a/lib/runtime/src/platform/linux_x86_64/uring.rs +++ b/lib/runtime/src/platform/linux_x86_64/uring.rs @@ -16,6 +16,7 @@ const IORING_SETUP_CLAMP: u32 = 1 << 4; const IORING_FEAT_SINGLE_MMAP: u32 = 1 << 0; pub(crate) const IORING_OP_FSYNC: u8 = 3; +pub(crate) const IORING_OP_POLL_ADD: u8 = 6; pub(crate) const IORING_OP_TIMEOUT: u8 = 11; pub(crate) const IORING_OP_TIMEOUT_REMOVE: u8 = 12; pub(crate) const IORING_OP_ACCEPT: u8 = 13; diff --git a/lib/ui/Cargo.toml b/lib/ui/Cargo.toml index 5179c83..be2e298 100644 --- a/lib/ui/Cargo.toml +++ b/lib/ui/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +cosmic-text = "0.18.2" ruin_reactivity = { path = "../reactivity" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" } tracing = { version = "0.1", default-features = false, features = ["std"] } diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs new file mode 100644 index 0000000..4f70891 --- /dev/null +++ b/lib/ui/src/layout.rs @@ -0,0 +1,462 @@ +use crate::scene::{Rect, SceneSnapshot, UiSize}; +use crate::text::TextSystem; +use crate::tree::{Edges, Element, FlexDirection}; + +pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot { + let mut text_system = TextSystem::new(); + layout_scene_with_text_system(version, logical_size, root, &mut text_system) +} + +pub fn layout_scene_with_text_system( + version: u64, + logical_size: UiSize, + root: &Element, + text_system: &mut TextSystem, +) -> SceneSnapshot { + let mut scene = SceneSnapshot::new(version, logical_size); + layout_element( + root, + Rect::new( + 0.0, + 0.0, + logical_size.width.max(0.0), + logical_size.height.max(0.0), + ), + &mut scene, + text_system, + ); + scene +} + +fn layout_element( + element: &Element, + rect: Rect, + scene: &mut SceneSnapshot, + text_system: &mut TextSystem, +) { + if rect.size.width <= 0.0 || rect.size.height <= 0.0 { + return; + } + + if let Some(color) = element.style.background { + scene.push_quad(rect, color); + } + + if let Some(text) = element.text_node() { + let content = inset_rect(rect, element.style.padding); + if content.size.width > 0.0 && content.size.height > 0.0 { + scene.push_text(text_system.prepare( + text.text.clone(), + content.origin, + text.style.with_bounds(content.size), + )); + } + return; + } + + if element.children.is_empty() { + return; + } + + let content = inset_rect(rect, element.style.padding); + if content.size.width <= 0.0 || content.size.height <= 0.0 { + return; + } + + let gap_count = element.children.len().saturating_sub(1) as f32; + let total_gap = element.style.gap * gap_count; + let available_main = main_axis_size(content.size, element.style.direction).max(0.0) - total_gap; + let available_main = available_main.max(0.0); + let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0); + + let mut measured_children = Vec::with_capacity(element.children.len()); + let mut fixed_total = 0.0; + let mut flex_total = 0.0; + for child in &element.children { + let cross = child_cross_size(child, element.style.direction) + .unwrap_or(available_cross) + .clamp(0.0, available_cross); + let explicit_main = + child_main_size(child, element.style.direction).map(|main| main.max(0.0)); + let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0; + let measured_main = explicit_main.unwrap_or_else(|| { + if is_flex { + 0.0 + } else { + intrinsic_main_size( + child, + element.style.direction, + cross, + available_main, + text_system, + ) + } + }); + if is_flex { + flex_total += child_flex_weight(child); + } else { + fixed_total += measured_main; + } + measured_children.push(MeasuredChild { + cross, + main: measured_main, + is_flex, + }); + } + + let remaining_main = (available_main - fixed_total).max(0.0); + let mut cursor = main_axis_origin(content, element.style.direction); + + for (child, measured) in element.children.iter().zip(measured_children) { + let child_main = if measured.is_flex { + if flex_total <= 0.0 { + 0.0 + } else { + remaining_main * (child_flex_weight(child) / flex_total) + } + } else { + measured.main + }; + let child_rect = child_rect( + content, + element.style.direction, + cursor, + child_main.max(0.0), + measured.cross, + ); + layout_element(child, child_rect, scene, text_system); + cursor += child_main.max(0.0) + element.style.gap; + } +} + +#[derive(Clone, Copy, Debug)] +struct MeasuredChild { + cross: f32, + main: f32, + is_flex: bool, +} + +fn intrinsic_main_size( + child: &Element, + direction: FlexDirection, + cross_size: f32, + available_main: f32, + text_system: &mut TextSystem, +) -> f32 { + if let Some(text) = child.text_node() { + let constraints = match direction { + FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))), + FlexDirection::Column => (Some(cross_size.max(0.0)), None), + }; + let content = text_system.measure(&text.text, text.style, constraints.0, constraints.1); + let padding = main_axis_padding(child.style.padding, direction); + return main_axis_size(content, direction) + padding; + } + + let available_size = match direction { + FlexDirection::Row => UiSize::new(available_main.max(0.0), cross_size.max(0.0)), + FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)), + }; + main_axis_size( + intrinsic_size(child, available_size, text_system), + direction, + ) +} + +fn intrinsic_size( + element: &Element, + available_size: UiSize, + text_system: &mut TextSystem, +) -> UiSize { + if let Some(text) = element.text_node() { + let measured = text_system.measure( + &text.text, + text.style, + Some(available_size.width.max(0.0)), + Some(available_size.height.max(0.0)), + ); + return UiSize::new( + element.style.width.unwrap_or( + measured.width + element.style.padding.left + element.style.padding.right, + ), + element.style.height.unwrap_or( + measured.height + element.style.padding.top + element.style.padding.bottom, + ), + ); + } + + let explicit_width = element.style.width; + let explicit_height = element.style.height; + let content_width = explicit_width.unwrap_or(available_size.width).max(0.0) + - element.style.padding.left + - element.style.padding.right; + let content_height = explicit_height.unwrap_or(available_size.height).max(0.0) + - element.style.padding.top + - element.style.padding.bottom; + let content_size = UiSize::new(content_width.max(0.0), content_height.max(0.0)); + + if element.children.is_empty() { + return UiSize::new( + explicit_width.unwrap_or(element.style.padding.left + element.style.padding.right), + explicit_height.unwrap_or(element.style.padding.top + element.style.padding.bottom), + ); + } + + let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32; + let (intrinsic_content_width, intrinsic_content_height) = match element.style.direction { + FlexDirection::Column => { + let mut width: f32 = 0.0; + let mut height = gap_total; + for child in &element.children { + if child.style.flex_grow > 0.0 && child.style.height.is_none() { + continue; + } + let child_size = intrinsic_size( + child, + UiSize::new( + child.style.width.unwrap_or(content_size.width), + child.style.height.unwrap_or(content_size.height), + ), + text_system, + ); + width = width.max(child.style.width.unwrap_or(child_size.width)); + height += child.style.height.unwrap_or(child_size.height); + } + (width, height) + } + FlexDirection::Row => { + let mut width = gap_total; + let mut height: f32 = 0.0; + for child in &element.children { + if child.style.flex_grow > 0.0 && child.style.width.is_none() { + continue; + } + let child_size = intrinsic_size( + child, + UiSize::new( + child.style.width.unwrap_or(content_size.width), + child.style.height.unwrap_or(content_size.height), + ), + text_system, + ); + width += child.style.width.unwrap_or(child_size.width); + height = height.max(child.style.height.unwrap_or(child_size.height)); + } + (width, height) + } + }; + + UiSize::new( + explicit_width.unwrap_or( + intrinsic_content_width + element.style.padding.left + element.style.padding.right, + ), + explicit_height.unwrap_or( + intrinsic_content_height + element.style.padding.top + element.style.padding.bottom, + ), + ) +} + +fn inset_rect(rect: Rect, edges: Edges) -> Rect { + let width = (rect.size.width - edges.left - edges.right).max(0.0); + let height = (rect.size.height - edges.top - edges.bottom).max(0.0); + Rect::new( + rect.origin.x + edges.left, + rect.origin.y + edges.top, + width, + height, + ) +} + +fn child_main_size(child: &Element, direction: FlexDirection) -> Option { + match direction { + FlexDirection::Row => child.style.width, + FlexDirection::Column => child.style.height, + } +} + +fn child_cross_size(child: &Element, direction: FlexDirection) -> Option { + match direction { + FlexDirection::Row => child.style.height, + FlexDirection::Column => child.style.width, + } +} + +fn child_flex_weight(child: &Element) -> f32 { + if child.style.flex_grow > 0.0 { + child.style.flex_grow + } else { + 1.0 + } +} + +fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 { + match direction { + FlexDirection::Row => edges.left + edges.right, + FlexDirection::Column => edges.top + edges.bottom, + } +} + +fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 { + match direction { + FlexDirection::Row => size.width, + FlexDirection::Column => size.height, + } +} + +fn cross_axis_size(size: UiSize, direction: FlexDirection) -> f32 { + match direction { + FlexDirection::Row => size.height, + FlexDirection::Column => size.width, + } +} + +fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 { + match direction { + FlexDirection::Row => rect.origin.x, + FlexDirection::Column => rect.origin.y, + } +} + +fn child_rect( + content: Rect, + direction: FlexDirection, + main_origin: f32, + main_size: f32, + cross_size: f32, +) -> Rect { + match direction { + FlexDirection::Row => Rect::new(main_origin, content.origin.y, main_size, cross_size), + FlexDirection::Column => Rect::new(content.origin.x, main_origin, cross_size, main_size), + } +} + +#[cfg(test)] +mod tests { + use super::layout_scene; + use crate::scene::{Color, DisplayItem, Quad, Rect, UiSize}; + use crate::text::{TextStyle, TextWrap}; + use crate::tree::{Edges, Element}; + + #[test] + fn row_layout_apportions_fixed_and_flex_children() { + let root = Element::row() + .padding(Edges::all(10.0)) + .gap(10.0) + .children([ + Element::new() + .width(50.0) + .background(Color::rgb(0xAA, 0x11, 0x11)), + Element::new() + .flex(1.0) + .background(Color::rgb(0x11, 0xAA, 0x11)), + Element::new() + .flex(2.0) + .background(Color::rgb(0x11, 0x11, 0xAA)), + ]); + + let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root); + let quads: Vec = scene + .items + .iter() + .filter_map(|item| match item { + DisplayItem::Quad(quad) => Some(*quad), + _ => None, + }) + .collect(); + + assert_eq!( + quads, + vec![ + Quad::new( + Rect::new(10.0, 10.0, 50.0, 80.0), + Color::rgb(0xAA, 0x11, 0x11) + ), + Quad::new( + Rect::new(70.0, 10.0, 70.0, 80.0), + Color::rgb(0x11, 0xAA, 0x11) + ), + Quad::new( + Rect::new(150.0, 10.0, 140.0, 80.0), + Color::rgb(0x11, 0x11, 0xAA) + ), + ] + ); + } + + #[test] + fn column_layout_reflows_text_and_moves_following_children() { + let root = Element::column() + .padding(Edges::all(10.0)) + .gap(10.0) + .children([ + Element::text( + "RUIN text nodes should reflow inside narrow layout columns instead of acting like overlays.", + TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)) + .with_line_height(20.0) + .with_wrap(TextWrap::Word), + ) + .padding(Edges::all(8.0)) + .background(Color::rgb(0x22, 0x2C, 0x46)), + Element::new() + .height(40.0) + .background(Color::rgb(0x44, 0x55, 0x66)), + ]); + + let scene = layout_scene(1, UiSize::new(160.0, 220.0), &root); + let text = scene + .items + .iter() + .find_map(|item| match item { + DisplayItem::Text(text) => Some(text), + _ => None, + }) + .expect("layout should emit a text display item"); + let quads: Vec = scene + .items + .iter() + .filter_map(|item| match item { + DisplayItem::Quad(quad) => Some(*quad), + _ => None, + }) + .collect(); + + let text_bounds = text.bounds.expect("text layout should provide bounds"); + assert_eq!(text.origin.x, 18.0); + assert_eq!(text.origin.y, 18.0); + assert_eq!(text_bounds.width, 124.0); + assert!(text_bounds.height > 20.0); + assert_eq!(quads.len(), 2); + assert!(quads[1].rect.origin.y > text.origin.y + text_bounds.height); + } + + #[test] + fn column_container_with_text_children_gets_intrinsic_height() { + let root = Element::column().child( + Element::column() + .padding(Edges::all(12.0)) + .background(Color::rgb(0x22, 0x33, 0x44)) + .child(Element::paragraph( + "Paragraph containers should not collapse to zero height anymore.", + TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0), + )), + ); + + let scene = layout_scene(1, UiSize::new(420.0, 240.0), &root); + let quads: Vec = scene + .items + .iter() + .filter_map(|item| match item { + DisplayItem::Quad(quad) => Some(*quad), + _ => None, + }) + .collect(); + + assert!(quads.iter().any(|quad| quad.rect.size.height > 24.0)); + assert!( + scene + .items + .iter() + .any(|item| matches!(item, DisplayItem::Text(_))) + ); + } +} diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index a2c999d..b997bdc 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -10,17 +10,23 @@ pub(crate) mod trace_targets { pub const SCENE: &str = "ruin_ui::scene"; } +mod layout; mod platform; mod runtime; mod scene; +mod text; +mod tree; mod window; +pub use layout::{layout_scene, layout_scene_with_text_system}; 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 text::{TextAlign, TextStyle, TextSystem, TextWrap}; +pub use tree::{Edges, Element, FlexDirection, Style}; pub use window::{ DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate, }; diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 1dec444..82d28bd 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -1,5 +1,6 @@ //! Renderer-oriented scene snapshot types. +use cosmic_text::CacheKey; use tracing::debug; use crate::trace_targets; @@ -92,12 +93,16 @@ pub struct GlyphInstance { pub glyph: String, pub position: Point, pub advance: f32, + pub cache_key: Option, } #[derive(Clone, Debug, PartialEq)] pub struct PreparedText { pub text: String, + pub origin: Point, + pub bounds: Option, pub font_size: f32, + pub line_height: f32, pub color: Color, pub glyphs: Vec, } @@ -118,13 +123,17 @@ impl PreparedText { glyph: ch.to_string(), position: Point::new(x, origin.y), advance, + cache_key: None, }); x += advance; } Self { text, + origin, + bounds: None, font_size, + line_height: font_size, color, glyphs, } diff --git a/lib/ui/src/text.rs b/lib/ui/src/text.rs new file mode 100644 index 0000000..8645981 --- /dev/null +++ b/lib/ui/src/text.rs @@ -0,0 +1,251 @@ +use cosmic_text::{Attrs, Buffer, FontSystem, Metrics, Shaping, Wrap}; + +use crate::{Color, GlyphInstance, Point, PreparedText, UiSize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TextAlign { + Start, + Center, + End, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TextWrap { + None, + Word, +} + +impl TextWrap { + const fn to_cosmic(self) -> Wrap { + match self { + Self::None => Wrap::None, + Self::Word => Wrap::WordOrGlyph, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextStyle { + pub font_size: f32, + pub line_height: f32, + pub color: Color, + pub bounds: Option, + pub wrap: TextWrap, + pub align: TextAlign, + pub max_lines: Option, +} + +impl TextStyle { + pub const fn new(font_size: f32, color: Color) -> Self { + Self { + font_size, + line_height: font_size * 1.2, + color, + bounds: None, + wrap: TextWrap::None, + align: TextAlign::Start, + max_lines: None, + } + } + + pub const fn with_line_height(mut self, line_height: f32) -> Self { + self.line_height = line_height; + self + } + + pub const fn with_bounds(mut self, bounds: UiSize) -> Self { + self.bounds = Some(bounds); + self + } + + pub const fn with_wrap(mut self, wrap: TextWrap) -> Self { + self.wrap = wrap; + self + } + + pub const fn with_align(mut self, align: TextAlign) -> Self { + self.align = align; + self + } + + pub const fn with_max_lines(mut self, max_lines: usize) -> Self { + self.max_lines = Some(max_lines); + self + } +} + +pub struct TextSystem { + font_system: FontSystem, +} + +#[derive(Clone, Debug, PartialEq)] +struct TextLayout { + glyphs: Vec, + size: UiSize, +} + +impl Default for TextSystem { + fn default() -> Self { + Self::new() + } +} + +impl TextSystem { + pub fn new() -> Self { + Self { + font_system: FontSystem::new(), + } + } + + pub fn prepare( + &mut self, + text: impl Into, + origin: Point, + style: TextStyle, + ) -> PreparedText { + let text = text.into(); + let layout = self.layout( + &text, + style, + style.bounds.map(|bounds| bounds.width), + style.bounds.map(|bounds| bounds.height), + ); + let glyphs = layout + .glyphs + .into_iter() + .map(|glyph| GlyphInstance { + glyph: glyph.glyph, + position: Point::new(origin.x + glyph.position.x, origin.y + glyph.position.y), + advance: glyph.advance, + cache_key: glyph.cache_key, + }) + .collect(); + + PreparedText { + text, + origin, + bounds: style.bounds, + font_size: style.font_size, + line_height: style.line_height, + color: style.color, + glyphs, + } + } + + pub fn measure( + &mut self, + text: &str, + style: TextStyle, + width: Option, + height: Option, + ) -> UiSize { + self.layout(text, style, width, height).size + } + + fn layout( + &mut self, + text: &str, + style: TextStyle, + width: Option, + height: Option, + ) -> TextLayout { + let mut buffer = Buffer::new_empty(Metrics::new(style.font_size, style.line_height)); + { + let mut borrowed = buffer.borrow_with(&mut self.font_system); + borrowed.set_wrap(style.wrap.to_cosmic()); + borrowed.set_size(width, height); + borrowed.set_text(text, &Attrs::new(), Shaping::Advanced, None); + } + + let mut measured_width: f32 = 0.0; + let mut measured_height: f32 = 0.0; + let mut glyphs = Vec::new(); + for (line_index, run) in buffer.layout_runs().enumerate() { + if matches!(style.max_lines, Some(max_lines) if line_index >= max_lines) { + break; + } + measured_width = measured_width.max(run.line_w); + measured_height = measured_height.max(run.line_top + run.line_height); + let x_offset = aligned_line_offset(style.align, width, run.line_w); + glyphs.extend(run.glyphs.iter().map(move |glyph| { + let physical = glyph.physical((x_offset, run.line_y), 1.0); + GlyphInstance { + glyph: run.text[glyph.start..glyph.end].to_string(), + position: Point::new(physical.x as f32, physical.y as f32), + advance: glyph.w, + cache_key: Some(physical.cache_key), + } + })); + } + + let measured_width = width.map_or(measured_width, |limit| measured_width.min(limit)); + let measured_height = height.map_or(measured_height, |limit| measured_height.min(limit)); + + TextLayout { + glyphs, + size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)), + } + } +} + +fn aligned_line_offset(align: TextAlign, width: Option, line_width: f32) -> f32 { + let Some(width) = width else { + return 0.0; + }; + let remaining = (width - line_width).max(0.0); + match align { + TextAlign::Start => 0.0, + TextAlign::Center => remaining * 0.5, + TextAlign::End => remaining, + } +} + +#[cfg(test)] +mod tests { + use super::{TextAlign, TextStyle, TextSystem, TextWrap}; + use crate::{Color, Point, UiSize}; + + #[test] + fn max_lines_limits_measured_height() { + let mut text_system = TextSystem::new(); + let style = TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)) + .with_line_height(20.0) + .with_wrap(TextWrap::Word); + let unclamped = text_system.measure( + "alpha beta gamma delta epsilon zeta eta theta iota kappa", + style, + Some(120.0), + None, + ); + let clamped = text_system.measure( + "alpha beta gamma delta epsilon zeta eta theta iota kappa", + style.with_max_lines(2), + Some(120.0), + None, + ); + assert!(unclamped.height > clamped.height); + assert!(clamped.height <= 40.0); + } + + #[test] + fn centered_text_shifts_glyph_positions_within_bounds() { + let mut text_system = TextSystem::new(); + let origin = Point::new(0.0, 0.0); + let start = text_system.prepare( + "title", + origin, + TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF)) + .with_bounds(UiSize::new(200.0, 40.0)), + ); + let centered = text_system.prepare( + "title", + origin, + TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF)) + .with_bounds(UiSize::new(200.0, 40.0)) + .with_align(TextAlign::Center), + ); + assert!(!start.glyphs.is_empty()); + assert!(!centered.glyphs.is_empty()); + assert!(centered.glyphs[0].position.x > start.glyphs[0].position.x); + } +} diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs new file mode 100644 index 0000000..cf1693f --- /dev/null +++ b/lib/ui/src/tree.rs @@ -0,0 +1,187 @@ +use crate::scene::Color; +use crate::text::{TextStyle, TextWrap}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FlexDirection { + Row, + Column, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Edges { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +impl Edges { + pub const ZERO: Self = Self { + top: 0.0, + right: 0.0, + bottom: 0.0, + left: 0.0, + }; + + pub const fn all(value: f32) -> Self { + Self { + top: value, + right: value, + bottom: value, + left: value, + } + } + + pub const fn symmetric(horizontal: f32, vertical: f32) -> Self { + Self { + top: vertical, + right: horizontal, + bottom: vertical, + left: horizontal, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Style { + pub direction: FlexDirection, + pub width: Option, + pub height: Option, + pub flex_grow: f32, + pub gap: f32, + pub padding: Edges, + pub background: Option, +} + +impl Default for Style { + fn default() -> Self { + Self { + direction: FlexDirection::Column, + width: None, + height: None, + flex_grow: 0.0, + gap: 0.0, + padding: Edges::ZERO, + background: None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +enum ElementContent { + Container, + Text(TextNode), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TextNode { + pub text: String, + pub style: TextStyle, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Element { + pub style: Style, + pub children: Vec, + content: ElementContent, +} + +impl Element { + pub fn new() -> Self { + Self { + style: Style::default(), + children: Vec::new(), + content: ElementContent::Container, + } + } + + pub fn text(text: impl Into, style: TextStyle) -> Self { + Self { + style: Style::default(), + children: Vec::new(), + content: ElementContent::Text(TextNode { + text: text.into(), + style, + }), + } + } + + pub fn paragraph(text: impl Into, style: TextStyle) -> Self { + Self::text(text, style.with_wrap(TextWrap::Word)) + } + + pub fn row() -> Self { + Self::new().direction(FlexDirection::Row) + } + + pub fn column() -> Self { + Self::new().direction(FlexDirection::Column) + } + + pub fn direction(mut self, direction: FlexDirection) -> Self { + self.style.direction = direction; + self + } + + pub fn width(mut self, width: f32) -> Self { + self.style.width = Some(width); + self + } + + pub fn height(mut self, height: f32) -> Self { + self.style.height = Some(height); + self + } + + pub fn flex(mut self, flex_grow: f32) -> Self { + self.style.flex_grow = flex_grow.max(0.0); + self + } + + pub fn gap(mut self, gap: f32) -> Self { + self.style.gap = gap.max(0.0); + self + } + + pub fn padding(mut self, padding: Edges) -> Self { + self.style.padding = padding; + self + } + + pub fn background(mut self, color: Color) -> Self { + self.style.background = Some(color); + self + } + + pub fn child(mut self, child: Element) -> Self { + self.assert_container(); + self.children.push(child); + self + } + + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.assert_container(); + self.children.extend(children); + self + } + + pub(crate) fn text_node(&self) -> Option<&TextNode> { + match &self.content { + ElementContent::Text(text) => Some(text), + ElementContent::Container => None, + } + } + + fn assert_container(&self) { + assert!( + matches!(self.content, ElementContent::Container), + "text elements cannot contain children" + ); + } +} + +impl Default for Element { + fn default() -> Self { + Self::new() + } +} diff --git a/lib/ui_platform_wayland/Cargo.toml b/lib/ui_platform_wayland/Cargo.toml index be7a9be..e0a85d3 100644 --- a/lib/ui_platform_wayland/Cargo.toml +++ b/lib/ui_platform_wayland/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +libc = "0.2" raw-window-handle = "0.6" +ruin_runtime = { package = "ruin-runtime", path = "../runtime" } ruin_ui = { path = "../ui" } wayland-backend = { version = "0.3", features = ["client_system"] } wayland-client = "0.31" diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 8b0a6eb..2314d99 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -1,7 +1,10 @@ use std::error::Error; use std::ffi::c_void; +use std::io::ErrorKind; use std::num::NonZeroU32; +use std::os::fd::{AsFd, AsRawFd}; use std::ptr::NonNull; +use std::time::Duration; use raw_window_handle::{ DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, @@ -44,6 +47,12 @@ pub struct FrameRequest { pub resized: bool, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct WaitOutcome { + pub dispatched: usize, + pub external_ready: bool, +} + pub struct WaylandWindow { event_queue: wayland_client::EventQueue, surface_target: WaylandSurfaceTarget, @@ -139,6 +148,127 @@ impl WaylandWindow { Ok(()) } + pub fn dispatch_pending(&mut self) -> Result> { + let dispatched = self.event_queue.dispatch_pending(&mut self.state)?; + self.event_queue.flush()?; + Ok(dispatched) + } + + pub fn wait_for_events(&mut self, timeout: Duration) -> Result> { + Ok(self.wait_for_events_or_fd(None, timeout)?.dispatched) + } + + pub fn poll_fd(&self) -> i32 { + self.state._connection.as_fd().as_raw_fd() + } + + pub fn dispatch_ready(&mut self) -> Result> { + self.event_queue.flush()?; + + let dispatched = self.event_queue.dispatch_pending(&mut self.state)?; + if dispatched > 0 { + return Ok(dispatched); + } + + let Some(read_guard) = self.event_queue.prepare_read() else { + return Ok(self.event_queue.dispatch_pending(&mut self.state)?); + }; + + match read_guard.read() { + Ok(_) => {} + Err(wayland_client::backend::WaylandError::Io(e)) + if e.kind() == ErrorKind::WouldBlock => + { + return Ok(0); + } + Err(error) => return Err(Box::new(error)), + } + + Ok(self.event_queue.dispatch_pending(&mut self.state)?) + } + + pub fn wait_for_events_or_fd( + &mut self, + external_fd: Option, + timeout: Duration, + ) -> Result> { + self.event_queue.flush()?; + + let dispatched = self.event_queue.dispatch_pending(&mut self.state)?; + if dispatched > 0 { + return Ok(WaitOutcome { + dispatched, + external_ready: false, + }); + } + + let Some(read_guard) = self.event_queue.prepare_read() else { + return Ok(WaitOutcome { + dispatched: self.event_queue.dispatch_pending(&mut self.state)?, + external_ready: false, + }); + }; + let fd = read_guard.connection_fd(); + let mut pollfds = [ + libc::pollfd { + fd: fd.as_raw_fd(), + events: libc::POLLIN | libc::POLLERR, + revents: 0, + }, + libc::pollfd { + fd: external_fd.unwrap_or(-1), + events: libc::POLLIN | libc::POLLERR, + revents: 0, + }, + ]; + let pollfd_count = if external_fd.is_some() { 2 } else { 1 }; + let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; + let ready = loop { + let ready = unsafe { libc::poll(pollfds.as_mut_ptr(), pollfd_count, timeout_ms) }; + if ready >= 0 { + break ready; + } + let error = std::io::Error::last_os_error(); + if error.kind() == ErrorKind::Interrupted { + continue; + } + return Err(Box::new(error)); + }; + + if ready == 0 { + drop(read_guard); + return Ok(WaitOutcome::default()); + } + let external_ready = + external_fd.is_some() && (pollfds[1].revents & (libc::POLLIN | libc::POLLERR)) != 0; + let wayland_ready = (pollfds[0].revents & (libc::POLLIN | libc::POLLERR)) != 0; + if !wayland_ready { + drop(read_guard); + return Ok(WaitOutcome { + dispatched: 0, + external_ready, + }); + } + + match read_guard.read() { + Ok(_) => {} + Err(wayland_client::backend::WaylandError::Io(e)) + if e.kind() == ErrorKind::WouldBlock => + { + return Ok(WaitOutcome { + dispatched: 0, + external_ready, + }); + } + Err(error) => return Err(Box::new(error)), + } + + Ok(WaitOutcome { + dispatched: self.event_queue.dispatch_pending(&mut self.state)?, + external_ready, + }) + } + pub fn request_redraw(&mut self) { self.state.request_redraw(); } diff --git a/lib/ui_renderer_wgpu/Cargo.toml b/lib/ui_renderer_wgpu/Cargo.toml index 59b32f8..684f302 100644 --- a/lib/ui_renderer_wgpu/Cargo.toml +++ b/lib/ui_renderer_wgpu/Cargo.toml @@ -5,7 +5,9 @@ edition = "2024" [dependencies] bytemuck = { version = "1", features = ["derive"] } +cosmic-text = "0.18.2" pollster = "0.4" raw-window-handle = "0.6" ruin_ui = { path = "../ui" } +tracing = "0.1" wgpu = "29" diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index 735b0ee..fa8aa08 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -1,8 +1,13 @@ +use std::collections::{HashMap, VecDeque}; use std::error::Error; use bytemuck::{Pod, Zeroable}; +use cosmic_text::{ + Attrs, Buffer, CacheKey, FontSystem, Metrics, Shaping, SwashCache, SwashContent, SwashImage, +}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; -use ruin_ui::{Color, DisplayItem, Rect, SceneSnapshot}; +use ruin_ui::{Color, DisplayItem, Point, PreparedText, Rect, SceneSnapshot, UiSize}; +use tracing::trace; use wgpu::util::DeviceExt; #[repr(C)] @@ -12,8 +17,120 @@ struct Vertex { color: [f32; 4], } +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct TextVertex { + position: [f32; 2], + uv: [f32; 2], +} + +#[derive(Clone, Copy, Debug)] +struct PixelRect { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +impl PixelRect { + fn width(self) -> u32 { + (self.right - self.left).max(0) as u32 + } + + fn height(self) -> u32 { + (self.bottom - self.top).max(0) as u32 + } +} + +struct GlyphBitmap { + rect: PixelRect, + content: SwashContent, + color: Color, + data: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct PreparedGlyphBitmap { + rect: PixelRect, + cache_key: CacheKey, +} + +struct RasterizedText { + origin_offset: Point, + size: UiSize, + pixels: Vec, +} + +struct CachedTextTexture { + _texture: wgpu::Texture, + bind_group: wgpu::BindGroup, + origin_offset: Point, + size: UiSize, +} + +struct GlyphAtlas { + texture: wgpu::Texture, + bind_group: wgpu::BindGroup, + glyphs: HashMap, + cursor_x: u32, + cursor_y: u32, + row_height: u32, +} + +#[derive(Clone, Copy, Debug)] +struct AtlasGlyph { + atlas_rect: AtlasRect, + placement_left: i32, + placement_top: i32, +} + +#[derive(Clone, Copy, Debug)] +struct AtlasRect { + x: u32, + y: u32, + width: u32, + height: u32, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct AtlasGlyphKey { + cache_key: CacheKey, + color: (u8, u8, u8, u8), +} + +struct UploadedText { + bind_group: wgpu::BindGroup, + vertex_buffer: wgpu::Buffer, + vertex_count: u32, +} + +struct UploadedAtlasText { + vertex_buffer: wgpu::Buffer, + vertex_count: u32, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct TextTextureKey { + text: String, + bounds: Option<(u32, u32)>, + font_size_bits: u32, + line_height_bits: u32, + color: (u8, u8, u8, u8), + glyphs: Vec, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct TextTextureGlyph { + local_x_bits: u32, + local_y_bits: u32, + advance_bits: u32, + cache_key: Option, +} + const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4]; +const TEXT_VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] = + wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2]; impl Vertex { const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { @@ -27,6 +144,18 @@ impl Vertex { } } +impl TextVertex { + const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &TEXT_VERTEX_ATTRIBUTES, + }; + + fn layout() -> wgpu::VertexBufferLayout<'static> { + Self::LAYOUT + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RenderError { Lost, @@ -41,9 +170,21 @@ pub struct WgpuSceneRenderer { device: wgpu::Device, queue: wgpu::Queue, config: wgpu::SurfaceConfiguration, - pipeline: wgpu::RenderPipeline, + quad_pipeline: wgpu::RenderPipeline, + text_pipeline: wgpu::RenderPipeline, + text_bind_group_layout: wgpu::BindGroupLayout, + text_sampler: wgpu::Sampler, + font_system: FontSystem, + swash_cache: SwashCache, + text_cache: HashMap, + text_cache_order: VecDeque, + glyph_atlas: GlyphAtlas, } +const MAX_TEXT_CACHE_ENTRIES: usize = 64; +const GLYPH_ATLAS_SIZE: u32 = 2048; +const GLYPH_ATLAS_PADDING: u32 = 1; + impl WgpuSceneRenderer { pub fn new( target: impl HasDisplayHandle + HasWindowHandle + Send + Sync + 'static, @@ -79,8 +220,8 @@ impl WgpuSceneRenderer { }; surface.configure(&device, &config); - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("ruin-ui-renderer-wgpu-shader"), + let quad_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("ruin-ui-renderer-wgpu-quad-shader"), source: wgpu::ShaderSource::Wgsl( r#" struct VertexIn { @@ -110,22 +251,56 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { ), }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("ruin-ui-renderer-wgpu-pipeline-layout"), + let text_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("ruin-ui-renderer-wgpu-text-shader"), + source: wgpu::ShaderSource::Wgsl( + r#" +struct VertexIn { + @location(0) position: vec2, + @location(1) uv: vec2, +}; + +struct VertexOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +@group(0) @binding(0) var text_texture: texture_2d; +@group(0) @binding(1) var text_sampler: sampler; + +@vertex +fn vs_main(input: VertexIn) -> VertexOut { + var out: VertexOut; + out.position = vec4(input.position, 0.0, 1.0); + out.uv = input.uv; + return out; +} + +@fragment +fn fs_main(input: VertexOut) -> @location(0) vec4 { + return textureSample(text_texture, text_sampler, input.uv); +} +"# + .into(), + ), + }); + + let quad_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ruin-ui-renderer-wgpu-quad-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), + let quad_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ruin-ui-renderer-wgpu-quad-pipeline"), + layout: Some(&quad_pipeline_layout), vertex: wgpu::VertexState { - module: &shader, + module: &quad_shader, entry_point: Some("vs_main"), buffers: &[Vertex::layout()], compilation_options: wgpu::PipelineCompilationOptions::default(), }, fragment: Some(wgpu::FragmentState { - module: &shader, + module: &quad_shader, entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { format, @@ -141,12 +316,82 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { cache: None, }); + let text_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("ruin-ui-renderer-wgpu-text-bind-group-layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let text_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ruin-ui-renderer-wgpu-text-pipeline-layout"), + bind_group_layouts: &[Some(&text_bind_group_layout)], + immediate_size: 0, + }); + let text_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ruin-ui-renderer-wgpu-text-pipeline"), + layout: Some(&text_pipeline_layout), + vertex: wgpu::VertexState { + module: &text_shader, + entry_point: Some("vs_main"), + buffers: &[TextVertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &text_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, + }); + + let text_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("ruin-ui-renderer-wgpu-text-sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + ..Default::default() + }); + let glyph_atlas = create_glyph_atlas(&device, &text_bind_group_layout, &text_sampler); + Ok(Self { surface, device, queue, config, - pipeline, + quad_pipeline, + text_pipeline, + text_bind_group_layout, + text_sampler, + font_system: FontSystem::new(), + swash_cache: SwashCache::new(), + text_cache: HashMap::new(), + text_cache_order: VecDeque::new(), + glyph_atlas, }) } @@ -161,6 +406,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { } pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> { + let render_start = std::time::Instant::now(); let vertices = build_vertices(scene); let frame = match self.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) @@ -181,6 +427,21 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { contents: bytemuck::cast_slice(&vertices), usage: wgpu::BufferUsages::VERTEX, }); + let text_prepare_start = std::time::Instant::now(); + let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene); + let uploaded_texts: Vec<_> = scene + .items + .iter() + .filter_map(|item| match item { + DisplayItem::Text(text) + if text.glyphs.iter().any(|glyph| glyph.cache_key.is_none()) => + { + self.prepare_uploaded_text(text, scene.logical_size) + } + _ => None, + }) + .collect(); + let text_prepare_ms = text_prepare_start.elapsed().as_secs_f64() * 1_000.0; let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { @@ -209,15 +470,797 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { multiview_mask: None, }); if !vertices.is_empty() { - pass.set_pipeline(&self.pipeline); + pass.set_pipeline(&self.quad_pipeline); pass.set_vertex_buffer(0, vertex_buffer.slice(..)); pass.draw(0..vertices.len() as u32, 0..1); } + if let Some(atlas_text) = uploaded_atlas_text.as_ref() { + pass.set_pipeline(&self.text_pipeline); + pass.set_bind_group(0, &self.glyph_atlas.bind_group, &[]); + pass.set_vertex_buffer(0, atlas_text.vertex_buffer.slice(..)); + pass.draw(0..atlas_text.vertex_count, 0..1); + } + if !uploaded_texts.is_empty() { + pass.set_pipeline(&self.text_pipeline); + for text in &uploaded_texts { + pass.set_bind_group(0, &text.bind_group, &[]); + pass.set_vertex_buffer(0, text.vertex_buffer.slice(..)); + pass.draw(0..text.vertex_count, 0..1); + } + } } self.queue.submit([encoder.finish()]); frame.present(); + trace!( + target: "ruin_ui_renderer_wgpu::perf", + scene_version = scene.version, + quad_vertices = vertices.len(), + atlas_text_vertices = uploaded_atlas_text + .as_ref() + .map_or(0_u32, |text| text.vertex_count), + fallback_text_batches = uploaded_texts.len(), + text_prepare_ms = text_prepare_ms, + render_ms = render_start.elapsed().as_secs_f64() * 1_000.0, + "rendered scene" + ); Ok(()) } + + fn rasterize_text(&mut self, text: &PreparedText) -> Option { + if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) { + return self.rasterize_prepared_text(text); + } + + let glyphs = self.collect_glyph_bitmaps(text); + let clip = match text.bounds { + Some(bounds) => PixelRect { + left: 0, + top: 0, + right: bounds.width.ceil() as i32, + bottom: bounds.height.ceil() as i32, + }, + None => glyph_union(&glyphs)?, + }; + if clip.width() == 0 || clip.height() == 0 { + return None; + } + + let mut pixels = vec![0u8; clip.width() as usize * clip.height() as usize * 4]; + for glyph in glyphs { + blit_glyph(&mut pixels, clip, &glyph); + } + + Some(RasterizedText { + origin_offset: Point::new(clip.left as f32, clip.top as f32), + size: UiSize::new(clip.width() as f32, clip.height() as f32), + pixels, + }) + } + + fn rasterize_prepared_text(&mut self, text: &PreparedText) -> Option { + let glyphs = self.collect_prepared_glyphs(text); + let clip = match text.bounds { + Some(bounds) => PixelRect { + left: 0, + top: 0, + right: bounds.width.ceil() as i32, + bottom: bounds.height.ceil() as i32, + }, + None => glyph_union_prepared(&glyphs)?, + }; + if clip.width() == 0 || clip.height() == 0 { + return None; + } + + let mut pixels = vec![0u8; clip.width() as usize * clip.height() as usize * 4]; + for glyph in glyphs { + let Some(image) = self + .swash_cache + .get_image(&mut self.font_system, glyph.cache_key) + .as_ref() + else { + continue; + }; + blit_cached_image(&mut pixels, clip, glyph.rect, image, text.color); + } + + Some(RasterizedText { + origin_offset: Point::new(clip.left as f32, clip.top as f32), + size: UiSize::new(clip.width() as f32, clip.height() as f32), + pixels, + }) + } + + fn collect_prepared_glyphs(&mut self, text: &PreparedText) -> Vec { + let mut glyphs = Vec::with_capacity(text.glyphs.len()); + for glyph in &text.glyphs { + let Some(cache_key) = glyph.cache_key else { + continue; + }; + let Some(image) = self + .swash_cache + .get_image(&mut self.font_system, cache_key) + .as_ref() + else { + continue; + }; + let width = image.placement.width as i32; + let height = image.placement.height as i32; + if width == 0 || height == 0 { + continue; + } + + let local_x = (glyph.position.x - text.origin.x).round() as i32; + let local_y = (glyph.position.y - text.origin.y).round() as i32; + glyphs.push(PreparedGlyphBitmap { + rect: PixelRect { + left: local_x + image.placement.left, + top: local_y - image.placement.top, + right: local_x + image.placement.left + width, + bottom: local_y - image.placement.top + height, + }, + cache_key, + }); + } + glyphs + } + + fn collect_glyph_bitmaps(&mut self, text: &PreparedText) -> Vec { + let mut buffer = Buffer::new_empty(Metrics::new(text.font_size, text.line_height)); + { + let mut borrowed = buffer.borrow_with(&mut self.font_system); + borrowed.set_size( + text.bounds.map(|bounds| bounds.width), + text.bounds.map(|bounds| bounds.height), + ); + borrowed.set_text(&text.text, &Attrs::new(), Shaping::Advanced, None); + } + + let mut glyphs = Vec::new(); + for run in buffer.layout_runs() { + for glyph in run.glyphs { + let physical = glyph.physical((0.0, run.line_y), 1.0); + let Some(image) = self + .swash_cache + .get_image(&mut self.font_system, physical.cache_key) + .as_ref() + else { + continue; + }; + let width = image.placement.width as i32; + let height = image.placement.height as i32; + if width == 0 || height == 0 { + continue; + } + glyphs.push(GlyphBitmap { + rect: PixelRect { + left: physical.x + image.placement.left, + top: physical.y - image.placement.top, + right: physical.x + image.placement.left + width, + bottom: physical.y - image.placement.top + height, + }, + content: image.content, + color: glyph.color_opt.map_or(text.color, color_from_cosmic), + data: image.data.clone(), + }); + } + } + glyphs + } + + fn prepare_uploaded_atlas_text(&mut self, scene: &SceneSnapshot) -> Option { + let mut vertices = Vec::new(); + + for item in &scene.items { + let DisplayItem::Text(text) = item else { + continue; + }; + if text.glyphs.iter().any(|glyph| glyph.cache_key.is_none()) { + continue; + } + + let clip_rect = text.bounds.map(|bounds| PixelRect { + left: text.origin.x.floor() as i32, + top: text.origin.y.floor() as i32, + right: (text.origin.x + bounds.width).ceil() as i32, + bottom: (text.origin.y + bounds.height).ceil() as i32, + }); + + for glyph in &text.glyphs { + let Some(cache_key) = glyph.cache_key else { + continue; + }; + let Some(atlas_glyph) = self.ensure_atlas_glyph(cache_key, text.color) else { + continue; + }; + + let glyph_rect = PixelRect { + left: glyph.position.x.round() as i32 + atlas_glyph.placement_left, + top: glyph.position.y.round() as i32 - atlas_glyph.placement_top, + right: glyph.position.x.round() as i32 + + atlas_glyph.placement_left + + atlas_glyph.atlas_rect.width as i32, + bottom: glyph.position.y.round() as i32 - atlas_glyph.placement_top + + atlas_glyph.atlas_rect.height as i32, + }; + push_glyph_vertices( + &mut vertices, + glyph_rect, + atlas_glyph.atlas_rect, + clip_rect, + scene.logical_size, + ); + } + } + + if vertices.is_empty() { + return None; + } + + let vertex_buffer = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("ruin-ui-renderer-wgpu-atlas-text-vertices"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + Some(UploadedAtlasText { + vertex_buffer, + vertex_count: vertices.len() as u32, + }) + } + + fn ensure_atlas_glyph(&mut self, cache_key: CacheKey, color: Color) -> Option { + let key = AtlasGlyphKey { + cache_key, + color: (color.r, color.g, color.b, color.a), + }; + if let Some(glyph) = self.glyph_atlas.glyphs.get(&key) { + return Some(*glyph); + } + + let (placement_left, placement_top, width, height, pixels) = { + let image = self + .swash_cache + .get_image(&mut self.font_system, cache_key) + .as_ref()?; + let width = image.placement.width; + let height = image.placement.height; + if width == 0 || height == 0 { + return None; + } + ( + image.placement.left, + image.placement.top, + width, + height, + rasterize_image_rgba(image, color), + ) + }; + + let atlas_rect = self.reserve_atlas_rect(width, height)?; + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.glyph_atlas.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: atlas_rect.x, + y: atlas_rect.y, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + &pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + + let glyph = AtlasGlyph { + atlas_rect, + placement_left, + placement_top, + }; + self.glyph_atlas.glyphs.insert(key, glyph); + Some(glyph) + } + + fn reserve_atlas_rect(&mut self, width: u32, height: u32) -> Option { + if width == 0 || height == 0 || width > GLYPH_ATLAS_SIZE || height > GLYPH_ATLAS_SIZE { + return None; + } + + if self.glyph_atlas.cursor_x + width > GLYPH_ATLAS_SIZE { + self.glyph_atlas.cursor_x = 0; + self.glyph_atlas.cursor_y = self + .glyph_atlas + .cursor_y + .saturating_add(self.glyph_atlas.row_height); + self.glyph_atlas.row_height = 0; + } + + if self.glyph_atlas.cursor_y + height > GLYPH_ATLAS_SIZE { + self.glyph_atlas = create_glyph_atlas( + &self.device, + &self.text_bind_group_layout, + &self.text_sampler, + ); + } + + if self.glyph_atlas.cursor_x + width > GLYPH_ATLAS_SIZE + || self.glyph_atlas.cursor_y + height > GLYPH_ATLAS_SIZE + { + return None; + } + + let rect = AtlasRect { + x: self.glyph_atlas.cursor_x, + y: self.glyph_atlas.cursor_y, + width, + height, + }; + self.glyph_atlas.cursor_x = self + .glyph_atlas + .cursor_x + .saturating_add(width + GLYPH_ATLAS_PADDING); + self.glyph_atlas.row_height = self + .glyph_atlas + .row_height + .max(height + GLYPH_ATLAS_PADDING); + Some(rect) + } + + fn prepare_uploaded_text( + &mut self, + text: &PreparedText, + logical_size: UiSize, + ) -> Option { + let key = text_texture_key(text); + if !self.text_cache.contains_key(&key) { + let rasterized = self.rasterize_text(text)?; + let cached = self.create_cached_text_texture(&rasterized); + self.text_cache.insert(key.clone(), cached); + } + self.touch_text_cache_entry(&key); + + let cached = self + .text_cache + .get(&key) + .expect("text cache entry should exist after insertion"); + let origin = Point::new( + text.origin.x + cached.origin_offset.x, + text.origin.y + cached.origin_offset.y, + ); + let vertices = build_text_vertices(origin, cached.size, logical_size); + let vertex_buffer = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("ruin-ui-renderer-wgpu-text-vertices"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + Some(UploadedText { + bind_group: cached.bind_group.clone(), + vertex_buffer, + vertex_count: vertices.len() as u32, + }) + } + + fn touch_text_cache_entry(&mut self, key: &TextTextureKey) { + if let Some(position) = self + .text_cache_order + .iter() + .position(|existing| existing == key) + { + self.text_cache_order.remove(position); + } + self.text_cache_order.push_back(key.clone()); + while self.text_cache_order.len() > MAX_TEXT_CACHE_ENTRIES { + let Some(evicted) = self.text_cache_order.pop_front() else { + break; + }; + self.text_cache.remove(&evicted); + } + } + + fn create_cached_text_texture(&self, text: &RasterizedText) -> CachedTextTexture { + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("ruin-ui-renderer-wgpu-texture"), + size: wgpu::Extent3d { + width: text.size.width as u32, + height: text.size.height as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &text.pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(text.size.width as u32 * 4), + rows_per_image: Some(text.size.height as u32), + }, + wgpu::Extent3d { + width: text.size.width as u32, + height: text.size.height as u32, + depth_or_array_layers: 1, + }, + ); + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ruin-ui-renderer-wgpu-text-bind-group"), + layout: &self.text_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.text_sampler), + }, + ], + }); + CachedTextTexture { + _texture: texture, + bind_group, + origin_offset: text.origin_offset, + size: text.size, + } + } +} + +fn create_glyph_atlas( + device: &wgpu::Device, + text_bind_group_layout: &wgpu::BindGroupLayout, + text_sampler: &wgpu::Sampler, +) -> GlyphAtlas { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("ruin-ui-renderer-wgpu-glyph-atlas"), + size: wgpu::Extent3d { + width: GLYPH_ATLAS_SIZE, + height: GLYPH_ATLAS_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ruin-ui-renderer-wgpu-glyph-atlas-bind-group"), + layout: text_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(text_sampler), + }, + ], + }); + GlyphAtlas { + texture, + bind_group, + glyphs: HashMap::new(), + cursor_x: 0, + cursor_y: 0, + row_height: 0, + } +} + +fn color_from_cosmic(color: cosmic_text::Color) -> Color { + Color::rgba(color.r(), color.g(), color.b(), color.a()) +} + +fn rasterize_image_rgba(image: &SwashImage, color: Color) -> Vec { + let width = image.placement.width as usize; + let height = image.placement.height as usize; + let mut pixels = vec![0u8; width * height * 4]; + + for y in 0..height { + for x in 0..width { + let rgba = match image.content { + SwashContent::Mask => { + let alpha = image.data[y * width + x]; + [color.r, color.g, color.b, scale_alpha(color.a, alpha)] + } + SwashContent::Color => { + let index = (y * width + x) * 4; + [ + image.data[index], + image.data[index + 1], + image.data[index + 2], + image.data[index + 3], + ] + } + SwashContent::SubpixelMask => { + let index = (y * width + x) * 3; + let coverage = average3( + image.data[index], + image.data[index + 1], + image.data[index + 2], + ); + [color.r, color.g, color.b, scale_alpha(color.a, coverage)] + } + }; + let dest = (y * width + x) * 4; + pixels[dest..dest + 4].copy_from_slice(&rgba); + } + } + + pixels +} + +fn glyph_union(glyphs: &[GlyphBitmap]) -> Option { + let first = glyphs.first()?; + let mut union = first.rect; + for glyph in &glyphs[1..] { + union.left = union.left.min(glyph.rect.left); + union.top = union.top.min(glyph.rect.top); + union.right = union.right.max(glyph.rect.right); + union.bottom = union.bottom.max(glyph.rect.bottom); + } + Some(union) +} + +fn glyph_union_prepared(glyphs: &[PreparedGlyphBitmap]) -> Option { + let first = glyphs.first()?; + let mut union = first.rect; + for glyph in &glyphs[1..] { + union.left = union.left.min(glyph.rect.left); + union.top = union.top.min(glyph.rect.top); + union.right = union.right.max(glyph.rect.right); + union.bottom = union.bottom.max(glyph.rect.bottom); + } + Some(union) +} + +fn blit_glyph(pixels: &mut [u8], clip: PixelRect, glyph: &GlyphBitmap) { + let width = clip.width() as usize; + let glyph_width = glyph.rect.width() as usize; + let glyph_height = glyph.rect.height() as usize; + if glyph_width == 0 || glyph_height == 0 { + return; + } + + for y in 0..glyph_height { + let dest_y = glyph.rect.top + y as i32; + if dest_y < clip.top || dest_y >= clip.bottom { + continue; + } + for x in 0..glyph_width { + let dest_x = glyph.rect.left + x as i32; + if dest_x < clip.left || dest_x >= clip.right { + continue; + } + let src = match glyph.content { + SwashContent::Mask => { + let alpha = glyph.data[y * glyph_width + x]; + [ + glyph.color.r, + glyph.color.g, + glyph.color.b, + scale_alpha(glyph.color.a, alpha), + ] + } + SwashContent::Color => { + let index = (y * glyph_width + x) * 4; + [ + glyph.data[index], + glyph.data[index + 1], + glyph.data[index + 2], + glyph.data[index + 3], + ] + } + SwashContent::SubpixelMask => { + let index = (y * glyph_width + x) * 3; + let coverage = average3( + glyph.data[index], + glyph.data[index + 1], + glyph.data[index + 2], + ); + [ + glyph.color.r, + glyph.color.g, + glyph.color.b, + scale_alpha(glyph.color.a, coverage), + ] + } + }; + + let dest_index = + (((dest_y - clip.top) as usize * width) + (dest_x - clip.left) as usize) * 4; + blend_rgba(&mut pixels[dest_index..dest_index + 4], src); + } + } +} + +fn blit_cached_image( + pixels: &mut [u8], + clip: PixelRect, + rect: PixelRect, + image: &SwashImage, + color: Color, +) { + let width = clip.width() as usize; + let glyph_width = rect.width() as usize; + let glyph_height = rect.height() as usize; + if glyph_width == 0 || glyph_height == 0 { + return; + } + + for y in 0..glyph_height { + let dest_y = rect.top + y as i32; + if dest_y < clip.top || dest_y >= clip.bottom { + continue; + } + for x in 0..glyph_width { + let dest_x = rect.left + x as i32; + if dest_x < clip.left || dest_x >= clip.right { + continue; + } + let src = match image.content { + SwashContent::Mask => { + let alpha = image.data[y * glyph_width + x]; + [color.r, color.g, color.b, scale_alpha(color.a, alpha)] + } + SwashContent::Color => { + let index = (y * glyph_width + x) * 4; + [ + image.data[index], + image.data[index + 1], + image.data[index + 2], + image.data[index + 3], + ] + } + SwashContent::SubpixelMask => { + let index = (y * glyph_width + x) * 3; + let coverage = average3( + image.data[index], + image.data[index + 1], + image.data[index + 2], + ); + [color.r, color.g, color.b, scale_alpha(color.a, coverage)] + } + }; + + let dest_index = + (((dest_y - clip.top) as usize * width) + (dest_x - clip.left) as usize) * 4; + blend_rgba(&mut pixels[dest_index..dest_index + 4], src); + } + } +} + +fn scale_alpha(color_alpha: u8, coverage: u8) -> u8 { + ((color_alpha as u16 * coverage as u16) / 255) as u8 +} + +fn average3(a: u8, b: u8, c: u8) -> u8 { + ((a as u16 + b as u16 + c as u16) / 3) as u8 +} + +fn blend_rgba(dst: &mut [u8], src: [u8; 4]) { + let src_alpha = src[3] as f32 / 255.0; + if src_alpha <= 0.0 { + return; + } + let dst_alpha = dst[3] as f32 / 255.0; + let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha); + if out_alpha <= 0.0 { + dst.copy_from_slice(&[0, 0, 0, 0]); + return; + } + + for channel in 0..3 { + let src_value = src[channel] as f32 / 255.0; + let dst_value = dst[channel] as f32 / 255.0; + let out = (src_value * src_alpha + dst_value * dst_alpha * (1.0 - src_alpha)) / out_alpha; + dst[channel] = (out * 255.0).round().clamp(0.0, 255.0) as u8; + } + dst[3] = (out_alpha * 255.0).round().clamp(0.0, 255.0) as u8; +} + +fn build_text_vertices(origin: Point, size: UiSize, logical_size: UiSize) -> [TextVertex; 6] { + let left = to_ndc_x(origin.x, logical_size.width.max(1.0)); + let right = to_ndc_x(origin.x + size.width, logical_size.width.max(1.0)); + let top = to_ndc_y(origin.y, logical_size.height.max(1.0)); + let bottom = to_ndc_y(origin.y + size.height, logical_size.height.max(1.0)); + + [ + TextVertex { + position: [left, top], + uv: [0.0, 0.0], + }, + TextVertex { + position: [left, bottom], + uv: [0.0, 1.0], + }, + TextVertex { + position: [right, top], + uv: [1.0, 0.0], + }, + TextVertex { + position: [right, top], + uv: [1.0, 0.0], + }, + TextVertex { + position: [left, bottom], + uv: [0.0, 1.0], + }, + TextVertex { + position: [right, bottom], + uv: [1.0, 1.0], + }, + ] +} + +fn push_glyph_vertices( + vertices: &mut Vec, + glyph_rect: PixelRect, + atlas_rect: AtlasRect, + clip_rect: Option, + logical_size: UiSize, +) { + let Some((dest_rect, uv_rect)) = clipped_glyph_quad(glyph_rect, atlas_rect, clip_rect) else { + return; + }; + + let left = to_ndc_x(dest_rect.left as f32, logical_size.width.max(1.0)); + let right = to_ndc_x(dest_rect.right as f32, logical_size.width.max(1.0)); + let top = to_ndc_y(dest_rect.top as f32, logical_size.height.max(1.0)); + let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0)); + + vertices.extend_from_slice(&[ + TextVertex { + position: [left, top], + uv: [uv_rect.0, uv_rect.1], + }, + TextVertex { + position: [left, bottom], + uv: [uv_rect.0, uv_rect.3], + }, + TextVertex { + position: [right, top], + uv: [uv_rect.2, uv_rect.1], + }, + TextVertex { + position: [right, top], + uv: [uv_rect.2, uv_rect.1], + }, + TextVertex { + position: [left, bottom], + uv: [uv_rect.0, uv_rect.3], + }, + TextVertex { + position: [right, bottom], + uv: [uv_rect.2, uv_rect.3], + }, + ]); } fn build_vertices(scene: &SceneSnapshot) -> Vec { @@ -257,26 +1300,26 @@ fn push_quad_vertices( position: [left, top], color, }, + Vertex { + position: [left, bottom], + color, + }, Vertex { position: [right, top], color, }, Vertex { - position: [right, bottom], - color, - }, - Vertex { - position: [left, top], - color, - }, - Vertex { - position: [right, bottom], + position: [right, top], color, }, Vertex { position: [left, bottom], color, }, + Vertex { + position: [right, bottom], + color, + }, ]); } @@ -288,10 +1331,70 @@ fn to_ndc_y(y: f32, height: f32) -> f32 { 1.0 - (y / height) * 2.0 } +fn clipped_glyph_quad( + glyph_rect: PixelRect, + atlas_rect: AtlasRect, + clip_rect: Option, +) -> Option<(PixelRect, (f32, f32, f32, f32))> { + if glyph_rect.width() == 0 || glyph_rect.height() == 0 { + return None; + } + + let clipped = if let Some(clip) = clip_rect { + PixelRect { + left: glyph_rect.left.max(clip.left), + top: glyph_rect.top.max(clip.top), + right: glyph_rect.right.min(clip.right), + bottom: glyph_rect.bottom.min(clip.bottom), + } + } else { + glyph_rect + }; + + if clipped.width() == 0 || clipped.height() == 0 { + return None; + } + + let u0 = + (atlas_rect.x as f32 + (clipped.left - glyph_rect.left) as f32) / GLYPH_ATLAS_SIZE as f32; + let v0 = + (atlas_rect.y as f32 + (clipped.top - glyph_rect.top) as f32) / GLYPH_ATLAS_SIZE as f32; + let u1 = (atlas_rect.x as f32 + atlas_rect.width as f32 + - (glyph_rect.right - clipped.right) as f32) + / GLYPH_ATLAS_SIZE as f32; + let v1 = (atlas_rect.y as f32 + atlas_rect.height as f32 + - (glyph_rect.bottom - clipped.bottom) as f32) + / GLYPH_ATLAS_SIZE as f32; + + Some((clipped, (u0, v0, u1, v1))) +} + +fn text_texture_key(text: &PreparedText) -> TextTextureKey { + TextTextureKey { + text: text.text.clone(), + bounds: text + .bounds + .map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())), + font_size_bits: text.font_size.to_bits(), + line_height_bits: text.line_height.to_bits(), + color: (text.color.r, text.color.g, text.color.b, text.color.a), + glyphs: text + .glyphs + .iter() + .map(|glyph| TextTextureGlyph { + local_x_bits: (glyph.position.x - text.origin.x).to_bits(), + local_y_bits: (glyph.position.y - text.origin.y).to_bits(), + advance_bits: glyph.advance.to_bits(), + cache_key: glyph.cache_key, + }) + .collect(), + } +} + #[cfg(test)] mod tests { - use super::build_vertices; - use ruin_ui::{Color, PreparedText, Rect, SceneSnapshot, UiSize}; + use super::{blend_rgba, build_vertices, text_texture_key}; + use ruin_ui::{Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot, UiSize}; #[test] fn quad_scenes_expand_to_six_vertices_per_quad() { @@ -306,7 +1409,10 @@ mod tests { ); scene.push_text(PreparedText { text: "ignored".into(), + origin: Point::new(4.0, 8.0), + bounds: None, font_size: 16.0, + line_height: 18.0, color: Color::rgb(0xFF, 0xFF, 0xFF), glyphs: Vec::new(), }); @@ -314,4 +1420,41 @@ mod tests { let vertices = build_vertices(&scene); assert_eq!(vertices.len(), 12); } + + #[test] + fn blend_rgba_composes_source_over_destination() { + let mut dst = [0, 0, 255, 255]; + blend_rgba(&mut dst, [255, 0, 0, 128]); + assert!(dst[0] > 0); + assert!(dst[2] > 0); + assert_eq!(dst[3], 255); + } + + #[test] + fn text_texture_key_ignores_absolute_origin() { + let first = PreparedText { + text: "cache me".into(), + origin: Point::new(20.0, 30.0), + bounds: Some(UiSize::new(120.0, 48.0)), + font_size: 16.0, + line_height: 20.0, + color: Color::rgb(0xEE, 0xEE, 0xEE), + glyphs: vec![GlyphInstance { + glyph: "c".into(), + position: Point::new(24.0, 44.0), + advance: 8.0, + cache_key: None, + }], + }; + let second = PreparedText { + origin: Point::new(60.0, 90.0), + glyphs: vec![GlyphInstance { + position: Point::new(64.0, 104.0), + ..first.glyphs[0].clone() + }], + ..first.clone() + }; + + assert_eq!(text_texture_key(&first), text_texture_key(&second)); + } }