From e90f09bf3e389b3494a3a460ee3b881a7efd13fe Mon Sep 17 00:00:00 2001 From: Will Temple Date: Mon, 23 Mar 2026 00:24:55 -0400 Subject: [PATCH] Lots of claude-driven performance work. --- Cargo.lock | 231 +++++++- lib/ruin_app/Cargo.toml | 9 + lib/ruin_app/example/03_fine_grained_list.rs | 17 + lib/ruin_app/example/05_async_runtime_io.rs | 30 + lib/ruin_app/src/lib.rs | 35 +- lib/ui/Cargo.toml | 5 + lib/ui/benches/layout_bench.rs | 94 +++ lib/ui/src/layout.rs | 315 +++++++++- lib/ui/src/lib.rs | 8 +- lib/ui/src/scene.rs | 262 ++++++-- lib/ui/src/text.rs | 306 +++++++--- lib/ui/src/tree.rs | 143 +++++ lib/ui_platform_wayland/src/lib.rs | 590 +++++++++++++------ lib/ui_renderer_wgpu/src/lib.rs | 169 ++++-- 14 files changed, 1820 insertions(+), 394 deletions(-) create mode 100644 lib/ui/benches/layout_bench.rs diff --git a/Cargo.lock b/Cargo.lock index f00f13e..28c3bd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -241,6 +253,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.57" @@ -265,6 +283,58 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -333,6 +403,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -728,6 +834,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -855,6 +967,26 @@ dependencies = [ "syn", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1228,6 +1360,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "ordered-float" version = "5.1.0" @@ -1290,6 +1428,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.18.1" @@ -1469,7 +1635,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -1571,6 +1737,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1645,6 +1823,8 @@ dependencies = [ "ruin_reactivity", "ruin_ui", "ruin_ui_platform_wayland", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1661,6 +1841,7 @@ name = "ruin_ui" version = "0.1.0" dependencies = [ "cosmic-text", + "criterion", "fontconfig", "image", "ruin-runtime", @@ -1762,6 +1943,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1810,6 +2000,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1991,6 +2194,16 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -2129,6 +2342,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2656,6 +2879,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zune-core" version = "0.5.1" diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index d8408b8..6ab3496 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -9,6 +9,15 @@ ruin_runtime = { package = "ruin-runtime", path = "../runtime" } ruin_app_proc_macros = { package = "ruin-app-proc-macros", path = "../ruin_app_proc_macros" } ruin_ui = { path = "../ui" } ruin_ui_platform_wayland = { path = "../ui_platform_wayland" } +tracing = "0.1" + +[dev-dependencies] +tracing-subscriber = { version = "0.3", default-features = false, features = [ + "env-filter", + "fmt", + "std", +] } + [[example]] name = "00_bootstrap_and_counter_raw" diff --git a/lib/ruin_app/example/03_fine_grained_list.rs b/lib/ruin_app/example/03_fine_grained_list.rs index 3ff577e..1cfa8e9 100644 --- a/lib/ruin_app/example/03_fine_grained_list.rs +++ b/lib/ruin_app/example/03_fine_grained_list.rs @@ -1,7 +1,24 @@ use ruin_app::prelude::*; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt}; + +fn install_tracing() { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("warn")); + let fmt_layer = fmt::layer() + .with_target(true) + .with_thread_ids(true) + .compact(); + let _ = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .try_init(); +} #[ruin_runtime::async_main] async fn main() -> ruin_app::Result<()> { + install_tracing(); App::new() .window( Window::new() diff --git a/lib/ruin_app/example/05_async_runtime_io.rs b/lib/ruin_app/example/05_async_runtime_io.rs index 17f1a01..1cba44e 100644 --- a/lib/ruin_app/example/05_async_runtime_io.rs +++ b/lib/ruin_app/example/05_async_runtime_io.rs @@ -7,8 +7,38 @@ use ruin_app::prelude::*; const SNAPSHOT_PATH: &str = "target/ruin-example05-manifest-snapshot.toml"; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt}; + +fn install_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new( + "info,\ + ruin_runtime::runtime=debug,\ + ruin_runtime::scheduler=debug,\ + ruin_reactivity::graph=debug,\ + ruin_reactivity::effect=debug,\ + ruin_reactivity::event=debug", + ) + }); + + let fmt_layer = fmt::layer() + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true) + .compact(); + + let _ = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .try_init(); +} + #[ruin_runtime::async_main] async fn main() -> ruin_app::Result<()> { + install_tracing(); + let demo_server_addr = spawn_demo_server()?; App::new() .window( diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 1470663..f52af66 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; use std::error::Error; use std::future::Future; use std::iter; +use std::time::Instant; use std::marker::PhantomData; use std::rc::Rc; @@ -18,11 +19,11 @@ use ruin_reactivity::effect; use ruin_runtime::queue_future; use ruin_ui::{ Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree, - KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, PointerButton, - PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent, + KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent, + PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate, - layout_snapshot_with_text_system, + layout_snapshot_with_cache, }; use ruin_ui_platform_wayland::start_wayland_ui; @@ -158,6 +159,7 @@ impl MountedApp { let viewport = ruin_reactivity::cell(initial_viewport); let scene_version = StdCell::new(0_u64); let text_system = Rc::new(RefCell::new(TextSystem::new())); + let layout_cache = Rc::new(RefCell::new(LayoutCache::new())); let interaction_tree = Rc::new(RefCell::new(None::)); let bindings = Rc::new(RefCell::new(EventBindings::default())); let shortcuts = Rc::new(RefCell::new(Vec::::new())); @@ -169,6 +171,7 @@ impl MountedApp { let window = window.clone(); let viewport = viewport.clone(); let text_system = Rc::clone(&text_system); + let layout_cache = Rc::clone(&layout_cache); let interaction_tree = Rc::clone(&interaction_tree); let bindings = Rc::clone(&bindings); let shortcuts = Rc::clone(&shortcuts); @@ -182,6 +185,8 @@ impl MountedApp { scene_version.set(version); let _ = text_selection.version.get(); + let t_effect = Instant::now(); + let render_output = render_with_context(Rc::clone(&render_state), || root.render()); if render_output.side_effects.window_title != *current_title.borrow() { @@ -193,14 +198,30 @@ impl MountedApp { *current_title.borrow_mut() = render_output.side_effects.window_title.clone(); } + let render_us = t_effect.elapsed().as_micros(); + let t_layout = Instant::now(); let LayoutSnapshot { mut scene, interaction_tree: next_interaction_tree, - } = layout_snapshot_with_text_system( + } = layout_snapshot_with_cache( version, viewport, render_output.view.element(), &mut text_system.borrow_mut(), + &mut layout_cache.borrow_mut(), + ); + let layout_us = t_layout.elapsed().as_micros(); + let effect_us = t_effect.elapsed().as_micros(); + + tracing::debug!( + target: "ruin_app::resize", + version, + width = viewport.width, + height = viewport.height, + render_us, + layout_us, + effect_us, + "scene effect complete, sending ReplaceScene" ); apply_text_selection_overlay(&mut scene, *text_selection.selection.borrow()); @@ -225,6 +246,12 @@ impl MountedApp { window_id, configuration, } if window_id == window.id() => { + tracing::debug!( + target: "ruin_app::resize", + width = configuration.actual_inner_size.width, + height = configuration.actual_inner_size.height, + "app received Configured, queuing layout effect" + ); let _ = viewport.set(configuration.actual_inner_size); } PlatformEvent::Pointer { window_id, event } if window_id == window.id() => { diff --git a/lib/ui/Cargo.toml b/lib/ui/Cargo.toml index d1d1881..6a26ba6 100644 --- a/lib/ui/Cargo.toml +++ b/lib/ui/Cargo.toml @@ -13,3 +13,8 @@ fontconfig = { version = "0.10", features = ["dlopen"] } [dev-dependencies] tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] } +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "layout_bench" +harness = false diff --git a/lib/ui/benches/layout_bench.rs b/lib/ui/benches/layout_bench.rs new file mode 100644 index 0000000..6f331e6 --- /dev/null +++ b/lib/ui/benches/layout_bench.rs @@ -0,0 +1,94 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use ruin_ui::{ + Color, Element, FlexDirection, LayoutCache, TextStyle, TextSystem, UiSize, + layout_snapshot_with_cache, +}; + +fn text_style() -> TextStyle { + TextStyle::new(14.0, Color::rgb(0xFF, 0xFF, 0xFF)) +} + +fn make_column_tree(n: usize) -> Element { + let mut root = Element::new().direction(FlexDirection::Column); + for i in 0..n { + root = root.child(Element::text(format!("item {i}"), text_style())); + } + root +} + +/// Static tree — nothing changes between frames. After one warmup frame, all +/// subsequent frames should be near-zero cost (all cache hits). +fn bench_static_tree(c: &mut Criterion) { + let mut text_system = TextSystem::new(); + let mut layout_cache = LayoutCache::new(); + let root = make_column_tree(200); + let size = UiSize::new(400.0, 4000.0); + + // Warm up cache. + layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); + + c.bench_function("layout_static_200_nodes", |b| { + let mut version = 2u64; + b.iter(|| { + version += 1; + layout_snapshot_with_cache(version, size, &root, &mut text_system, &mut layout_cache) + }); + }); +} + +/// 200 nodes, one text content changes per frame — only one cache miss expected. +fn bench_single_change(c: &mut Criterion) { + let mut text_system = TextSystem::new(); + let mut layout_cache = LayoutCache::new(); + let size = UiSize::new(400.0, 4000.0); + + // Initial warmup. + let root = make_column_tree(200); + layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); + + c.bench_function("layout_single_change_200_nodes", |b| { + let mut counter = 0usize; + let mut version = 2u64; + b.iter(|| { + counter += 1; + version += 1; + let mut tree = make_column_tree(200); + tree.children[100] = Element::text(format!("changed {counter}"), text_style()); + layout_snapshot_with_cache(version, size, &tree, &mut text_system, &mut layout_cache) + }); + }); +} + +/// Scroll box with 500 items, 20 visible. Simulates a large list. +fn bench_scroll_list(c: &mut Criterion) { + let mut text_system = TextSystem::new(); + let mut layout_cache = LayoutCache::new(); + let item_height = 32.0; + let viewport_height = 640.0; + let n = 500; + + let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height); + for i in 0..n { + scroll_box = scroll_box.child( + Element::new() + .height(item_height) + .child(Element::text(format!("list item {i}"), text_style())), + ); + } + let root = Element::new().child(scroll_box); + let size = UiSize::new(400.0, viewport_height); + + // Warm up. + layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); + + c.bench_function("layout_scroll_list_500_items", |b| { + let mut version = 2u64; + b.iter(|| { + version += 1; + layout_snapshot_with_cache(version, size, &root, &mut text_system, &mut layout_cache) + }); + }); +} + +criterion_group!(benches, bench_static_tree, bench_single_change, bench_scroll_list); +criterion_main!(benches); diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 49b3455..51289ee 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -1,8 +1,10 @@ +use std::collections::HashMap; use std::time::Instant; use crate::ImageFit; use crate::scene::{ - PreparedImage, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect, UiSize, + DisplayItem, Point, PreparedImage, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect, + UiSize, }; use crate::text::TextSystem; use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style}; @@ -122,6 +124,38 @@ impl InteractionTree { } } +impl LayoutNode { + /// Return a copy of this node (and all descendants) with all positions shifted by `offset`. + pub fn translated(self, offset: Point) -> Self { + fn translate_rect(r: Rect, o: Point) -> Rect { + Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height) + } + let rect = translate_rect(self.rect, offset); + let clip_rect = self.clip_rect.map(|r| translate_rect(r, offset)); + let scroll_metrics = self.scroll_metrics.map(|m| ScrollMetrics { + viewport_rect: translate_rect(m.viewport_rect, offset), + scrollbar_track: m.scrollbar_track.map(|r| translate_rect(r, offset)), + scrollbar_thumb: m.scrollbar_thumb.map(|r| translate_rect(r, offset)), + ..m + }); + let prepared_text = self.prepared_text.map(|t| t.translated(offset)); + let prepared_image = self.prepared_image.map(|img| PreparedImage { + rect: translate_rect(img.rect, offset), + ..img + }); + let children = self.children.into_iter().map(|c| c.translated(offset)).collect(); + LayoutNode { + rect, + clip_rect, + scroll_metrics, + prepared_text, + prepared_image, + children, + ..self + } + } +} + pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot { let mut text_system = TextSystem::new(); layout_snapshot_with_text_system(version, logical_size, root, &mut text_system) @@ -132,6 +166,17 @@ pub fn layout_snapshot_with_text_system( logical_size: UiSize, root: &Element, text_system: &mut TextSystem, +) -> LayoutSnapshot { + let mut layout_cache = LayoutCache::new(); + layout_snapshot_with_cache(version, logical_size, root, text_system, &mut layout_cache) +} + +pub fn layout_snapshot_with_cache( + version: u64, + logical_size: UiSize, + root: &Element, + text_system: &mut TextSystem, + layout_cache: &mut LayoutCache, ) -> LayoutSnapshot { let layout_started = Instant::now(); let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG); @@ -150,6 +195,8 @@ pub fn layout_snapshot_with_text_system( &mut scene, text_system, &mut perf_stats, + layout_cache, + None, ); let text_stats = text_system.take_frame_stats(); if perf_stats.enabled { @@ -170,6 +217,10 @@ pub fn layout_snapshot_with_text_system( intrinsic_ms = perf_stats.intrinsic_ms, text_prepare_calls = perf_stats.text_prepare_calls, text_prepare_ms = perf_stats.text_prepare_ms, + viewport_culled = perf_stats.viewport_culled, + layout_cache_hits = perf_stats.layout_cache_hits, + layout_cache_misses = perf_stats.layout_cache_misses, + intrinsic_cache_hits = perf_stats.intrinsic_cache_hits, text_requests = text_stats.requests, text_cache_hits = text_stats.cache_hits, text_cache_misses = text_stats.cache_misses, @@ -197,8 +248,54 @@ fn layout_element( scene: &mut SceneSnapshot, text_system: &mut TextSystem, perf_stats: &mut LayoutPerfStats, + layout_cache: &mut LayoutCache, + // Scissor rect from the nearest enclosing scroll box (window coords). + // Elements that don't overlap this rect are skipped entirely. + clip_rect: Option, ) -> LayoutNode { perf_stats.nodes += 1; + + // Viewport culling: skip fully off-screen elements inside a scroll box. + if let Some(clip) = clip_rect { + if !rects_overlap(rect, clip) { + perf_stats.viewport_culled += 1; + return LayoutNode { + path, + element_id: element.id, + rect, + corner_radius: 0.0, + clip_rect: None, + pointer_events: element.style.pointer_events, + focusable: element.style.focusable, + cursor: element.style.cursor.unwrap_or(CursorIcon::Default), + scroll_metrics: None, + prepared_image: None, + prepared_text: None, + children: Vec::new(), + }; + } + } + + // Incremental layout cache check. + // Round to nearest pixel to eliminate floating-point representation + // artifacts from arithmetic paths (e.g. total - padding - gap) that + // produce logically identical sizes with different bit patterns. + let subtree_hash = element.subtree_hash(); + let cache_key = LayoutCacheKey { + subtree_hash, + avail_width_bits: rect.size.width.round() as u32, + avail_height_bits: rect.size.height.round() as u32, + }; + if let Some(cached) = layout_cache.results.get(&cache_key) { + let offset = rect.origin; + for item in &cached.scene_items { + scene.items.push(item.translated(offset)); + } + perf_stats.layout_cache_hits += 1; + return cached.interaction_node.clone().translated(offset); + } + perf_stats.layout_cache_misses += 1; + let scene_start = scene.items.len(); let cursor = element.style.cursor.unwrap_or_else(|| { if element.text_node().is_some() { CursorIcon::Text @@ -222,6 +319,7 @@ fn layout_element( }; if rect.size.width <= 0.0 || rect.size.height <= 0.0 { + cache_layout(layout_cache, cache_key, &interaction, &[], rect.origin); return interaction; } @@ -270,6 +368,7 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } + cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); return interaction; } @@ -283,6 +382,7 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } + cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); return interaction; } @@ -311,6 +411,7 @@ fn layout_element( ), text_system, perf_stats, + layout_cache, ); let provisional_content_height = content_size.height.max(viewport_rect.size.height); let requested_offset_y = scroll_box.offset_y.max(0.0); @@ -333,6 +434,8 @@ fn layout_element( scene, text_system, perf_stats, + layout_cache, + Some(viewport_rect), ); scene.pop_clip(); } @@ -367,6 +470,8 @@ fn layout_element( scene, text_system, perf_stats, + layout_cache, + Some(viewport_rect), ); scene.pop_clip(); } else { @@ -403,6 +508,7 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } + cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); return interaction; } @@ -412,6 +518,7 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } + cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); return interaction; } @@ -420,6 +527,7 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } + cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); return interaction; } interaction.children = layout_container_children( @@ -429,15 +537,33 @@ fn layout_element( scene, text_system, perf_stats, + layout_cache, + clip_rect, ); if pushed_clip { scene.pop_clip(); } + cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); interaction } +/// Store a layout result in the cache in origin-relative (local) coordinates. +fn cache_layout( + cache: &mut LayoutCache, + key: LayoutCacheKey, + interaction: &LayoutNode, + scene_items: &[DisplayItem], + origin: Point, +) { + let neg = Point::new(-origin.x, -origin.y); + cache.results.insert(key, CachedLayout { + interaction_node: interaction.clone().translated(neg), + scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(), + }); +} + fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option> { if !point_hits_node_shape(node, point) { return None; @@ -616,6 +742,8 @@ fn layout_container_children( scene: &mut SceneSnapshot, text_system: &mut TextSystem, perf_stats: &mut LayoutPerfStats, + layout_cache: &mut LayoutCache, + clip_rect: Option, ) -> Vec { if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 { return Vec::new(); @@ -655,6 +783,7 @@ fn layout_container_children( available_main, text_system, perf_stats, + layout_cache, ); if let Some(intrinsic_started) = intrinsic_started { perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0; @@ -701,6 +830,8 @@ fn layout_container_children( scene, text_system, perf_stats, + layout_cache, + clip_rect, )); cursor += child_main.max(0.0) + element.style.gap; } @@ -714,6 +845,7 @@ fn intrinsic_main_size( available_main: f32, text_system: &mut TextSystem, perf_stats: &mut LayoutPerfStats, + layout_cache: &mut LayoutCache, ) -> f32 { if let Some(text) = child.text_node() { let constraints = match direction { @@ -736,7 +868,7 @@ fn intrinsic_main_size( FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)), }; main_axis_size( - intrinsic_size(child, available_size, text_system, perf_stats), + intrinsic_size(child, available_size, text_system, perf_stats, layout_cache), direction, ) } @@ -746,6 +878,7 @@ fn intrinsic_container_content_size( content_size: UiSize, text_system: &mut TextSystem, perf_stats: &mut LayoutPerfStats, + layout_cache: &mut LayoutCache, ) -> UiSize { if element.children.is_empty() { return UiSize::new(0.0, 0.0); @@ -766,6 +899,7 @@ fn intrinsic_container_content_size( ), text_system, perf_stats, + layout_cache, ); width = width.max(child.style.width.unwrap_or(child_size.width)); if !skip_main { @@ -795,6 +929,7 @@ fn intrinsic_container_content_size( ), text_system, perf_stats, + layout_cache, ); let child_main = child.style.width.unwrap_or(child_size.width); fixed_main += child_main; @@ -818,6 +953,7 @@ fn intrinsic_container_content_size( ), text_system, perf_stats, + layout_cache, ); if !skip_main { width += child_main; @@ -834,9 +970,35 @@ fn intrinsic_size( available_size: UiSize, text_system: &mut TextSystem, perf_stats: &mut LayoutPerfStats, + layout_cache: &mut LayoutCache, ) -> UiSize { perf_stats.intrinsic_size_calls += 1; + + // Intrinsic size cache check. + // Round to nearest pixel — same rationale as the layout cache key. + let cache_key = ( + element.subtree_hash(), + available_size.width.round() as u32, + available_size.height.round() as u32, + ); + if let Some(&cached) = layout_cache.intrinsic_cache.get(&cache_key) { + perf_stats.intrinsic_cache_hits += 1; + return cached; + } let insets = content_insets(&element.style); + let result = intrinsic_size_inner(element, available_size, insets, text_system, perf_stats, layout_cache); + layout_cache.intrinsic_cache.insert(cache_key, result); + result +} + +fn intrinsic_size_inner( + element: &Element, + available_size: UiSize, + insets: Edges, + text_system: &mut TextSystem, + perf_stats: &mut LayoutPerfStats, + layout_cache: &mut LayoutCache, +) -> UiSize { if let Some(text) = element.text_node() { let measured = text_system.measure_spans( &text.spans, @@ -886,7 +1048,7 @@ fn intrinsic_size( } let intrinsic_content = - intrinsic_container_content_size(element, content_size, text_system, perf_stats); + intrinsic_container_content_size(element, content_size, text_system, perf_stats, layout_cache); UiSize::new( explicit_width @@ -895,6 +1057,43 @@ fn intrinsic_size( ) } +/// Cache for incremental layout. Holds both full-subtree layout results and +/// intrinsic-size results keyed by subtree hash + available size. +/// +/// Cleared by calling [`LayoutCache::clear`] or dropped between runs if desired. +/// In practice, the cache is held across frames so unchanged subtrees pay no layout cost. +#[derive(Default)] +pub struct LayoutCache { + results: HashMap, + /// Intrinsic-size cache: keyed by (subtree_hash, avail_w.to_bits(), avail_h.to_bits()). + pub intrinsic_cache: HashMap<(u64, u32, u32), UiSize>, +} + +impl LayoutCache { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.results.clear(); + self.intrinsic_cache.clear(); + } +} + +#[derive(Eq, Hash, PartialEq)] +struct LayoutCacheKey { + subtree_hash: u64, + avail_width_bits: u32, + avail_height_bits: u32, +} + +struct CachedLayout { + /// Layout node in origin-relative (local) coordinates. + interaction_node: LayoutNode, + /// Scene items in origin-relative (local) coordinates. + scene_items: Vec, +} + #[derive(Debug, Default)] struct LayoutPerfStats { enabled: bool, @@ -909,6 +1108,10 @@ struct LayoutPerfStats { intrinsic_ms: f64, text_prepare_calls: usize, text_prepare_ms: f64, + viewport_culled: usize, + layout_cache_hits: usize, + layout_cache_misses: usize, + intrinsic_cache_hits: usize, } impl LayoutPerfStats { @@ -926,10 +1129,23 @@ impl LayoutPerfStats { intrinsic_ms: 0.0, text_prepare_calls: 0, text_prepare_ms: 0.0, + viewport_culled: 0, + layout_cache_hits: 0, + layout_cache_misses: 0, + intrinsic_cache_hits: 0, } } } +/// Returns true if two rects have any overlap (including touching edges). +#[inline] +fn rects_overlap(a: Rect, b: Rect) -> bool { + a.origin.x < b.origin.x + b.size.width + && a.origin.x + a.size.width > b.origin.x + && a.origin.y < b.origin.y + b.size.height + && a.origin.y + a.size.height > b.origin.y +} + fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option) -> PreparedImage { let source_size = image.resource.size(); let source_aspect = if source_size.height > 0.0 { @@ -1321,10 +1537,11 @@ fn child_rect( #[cfg(test)] mod tests { - use super::{layout_scene, layout_snapshot}; + use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache}; use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize}; use crate::text::{TextStyle, TextWrap}; - use crate::tree::{Edges, Element, ElementId}; + use crate::tree::{Edges, Element, ElementId, FlexDirection}; + use crate::text::TextSystem; #[test] fn row_layout_apportions_fixed_and_flex_children() { @@ -1916,4 +2133,92 @@ mod tests { .is_none() ); } + + // --- incremental layout cache tests --- + + fn text_style() -> TextStyle { + TextStyle::new(14.0, Color::rgb(0xFF, 0xFF, 0xFF)) + } + + fn make_tree(n: usize) -> Element { + let mut root = Element::new().direction(FlexDirection::Column); + for i in 0..n { + root = root.child(Element::text(format!("item {i}"), text_style())); + } + root + } + + #[test] + fn stable_layout_produces_equal_snapshots() { + let mut text_system = TextSystem::new(); + let mut layout_cache = LayoutCache::new(); + let root = make_tree(10); + let size = UiSize::new(400.0, 600.0); + let snap1 = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); + let snap2 = layout_snapshot_with_cache(2, size, &root, &mut text_system, &mut layout_cache); + assert_eq!(snap1.scene.items, snap2.scene.items, "scene items should be identical"); + assert_eq!( + snap1.interaction_tree.root, + snap2.interaction_tree.root, + "interaction trees should be identical" + ); + } + + #[test] + fn cache_effectiveness_single_change() { + let mut text_system = TextSystem::new(); + let mut layout_cache = LayoutCache::new(); + + // Build a 200-node tree; warm up the cache. + let root = make_tree(200); + let size = UiSize::new(400.0, 4000.0); + layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); + + // Change one node and re-layout. + let mut root2 = root; + root2.children[100] = Element::text("CHANGED", text_style()); + let _ = layout_snapshot_with_cache(2, size, &root2, &mut text_system, &mut layout_cache); + + let hits = layout_cache.results.len(); + // At least 199 of 200 children should be cached (one changed). + // The root (container) also misses since its hash changed. + // Total cache entries: 200 children (199 hit on second pass + 1 new) + root. + // We verify that we have substantially more hits than misses on the second pass + // by checking that the cache has entries for at least 199 children. + assert!(hits >= 199, "expected >= 199 cache entries, got {hits}"); + } + + #[test] + fn scroll_culling_skips_off_screen_children() { + use crate::tree::ScrollbarStyle; + let mut text_system = TextSystem::new(); + let mut layout_cache = LayoutCache::new(); + + // Scroll box showing roughly 5 items (each 40px tall), 100 total. + let item_height = 40.0; + let viewport_height = 200.0; + let n = 100; + let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height); + for i in 0..n { + scroll_box = scroll_box.child( + Element::new() + .height(item_height) + .child(Element::text(format!("item {i}"), text_style())) + ); + } + let root = Element::new().child(scroll_box); + let size = UiSize::new(400.0, viewport_height); + + // Hack: we need LayoutPerfStats to check viewport_culled. + // Instead, test indirectly: scene items for off-screen text should be absent. + let snap = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); + + // Only text items within the 200px viewport should appear in the scene. + let text_items = snap.scene.items.iter().filter(|item| { + matches!(item, DisplayItem::Text(_)) + }).count(); + // At most 6 text items should be visible (5 fit + 1 partial). + assert!(text_items <= 6, "expected <= 6 text items in scene, got {text_items} (culling not working)"); + assert!(text_items >= 4, "expected >= 4 text items visible, got {text_items}"); + } } diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index 071e98e..dfb0155 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -8,6 +8,7 @@ pub(crate) mod trace_targets { pub const PLATFORM: &str = "ruin_ui::platform"; pub const SCENE: &str = "ruin_ui::scene"; + pub const TEXT_PERF: &str = "ruin_ui::text_perf"; } mod image; @@ -28,8 +29,8 @@ pub use interaction::{ }; pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers}; pub use layout::{ - HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics, - TextHitTarget, layout_snapshot, layout_snapshot_with_text_system, + HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics, + TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system, }; pub use layout::{layout_scene, layout_scene_with_text_system}; pub use platform::{ @@ -39,7 +40,8 @@ pub use platform::{ pub use runtime::{EventStreamClosed, UiRuntime, WindowController}; pub use scene::{ ClipRegion, Color, DisplayItem, GlyphInstance, Point, PreparedImage, PreparedText, - PreparedTextLine, Quad, Rect, RoundedRect, SceneSnapshot, ShadowRect, Translation, UiSize, + PreparedTextLine, Quad, Rect, RoundedRect, SceneSnapshot, ShadowRect, TextLayoutData, + Translation, UiSize, }; pub use text::{ TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight, diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 6d6edf3..0594e04 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -1,6 +1,7 @@ //! Renderer-oriented scene snapshot types. -use std::ops::Range; +use std::ops::{Deref, Range}; +use std::sync::Arc; use cosmic_text::CacheKey; use tracing::debug; @@ -56,6 +57,13 @@ impl Rect { && point.x < self.origin.x + self.size.width && point.y < self.origin.y + self.size.height } + + pub fn intersects(self, other: Rect) -> bool { + self.origin.x < other.origin.x + other.size.width + && self.origin.x + self.size.width > other.origin.x + && self.origin.y < other.origin.y + other.size.height + && self.origin.y + self.size.height > other.origin.y + } } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -67,6 +75,11 @@ pub struct Color { } impl Color { + /// Sentinel value meaning "use the PreparedText default_color at render time". + /// Fully transparent black (a == 0) is never a useful visible color, so it is + /// safe to reserve as a sentinel. No user-facing API sets this value directly. + pub const SENTINEL: Self = Self::rgba(0, 0, 0, 0); + pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { Self { r, g, b, a } } @@ -74,6 +87,12 @@ impl Color { pub const fn rgb(r: u8, g: u8, b: u8) -> Self { Self::rgba(r, g, b, 255) } + + /// Returns `true` when this color is the sentinel "use default" marker. + #[inline] + pub const fn is_sentinel(self) -> bool { + self.a == 0 + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -130,6 +149,10 @@ pub struct ClipRegion { pub struct GlyphInstance { pub position: Point, pub advance: f32, + /// Glyph color. When equal to `Color::SENTINEL` the renderer uses the + /// enclosing `PreparedText::default_color`. This allows the text layout + /// cache to be color-independent: the same shaped data can be reused for + /// the same text displayed in different colors. pub color: Color, pub cache_key: Option, pub text_start: usize, @@ -145,6 +168,21 @@ pub struct PreparedTextLine { pub glyph_end: usize, } +/// Shaped, positioned text ready for rendering. +/// +/// Glyphs and line rects are stored in **local (origin-relative) coordinates**: +/// add `self.origin` to convert to absolute window coords. This keeps the +/// `Arc` from the text shape cache shareable across different +/// on-screen positions — no per-frame translation is required. +/// +/// The `lines` and `glyphs` are stored behind an `Arc` so that cloning a +/// `PreparedText` (e.g. to put the same shaped text into both the scene +/// snapshot and the interaction tree) is O(1). The `Arc` is shared as long +/// as no mutation is needed; `apply_selected_text_color` uses +/// `Arc::make_mut` and clones on first write. +/// +/// Glyph colors may be `Color::SENTINEL` — the renderer substitutes +/// `default_color` for those glyphs. #[derive(Clone, Debug, PartialEq)] pub struct PreparedText { pub element_id: Option, @@ -153,22 +191,69 @@ pub struct PreparedText { pub bounds: Option, pub font_size: f32, pub line_height: f32, - pub color: Color, + /// Default color applied to sentinel-colored glyphs at render time. + pub default_color: Color, pub selectable: bool, pub selection_style: TextSelectionStyle, - pub lines: Vec, - pub glyphs: Vec, + layout: Arc, } +/// Shaped glyph and line data shared between scene and interaction-tree copies +/// of the same `PreparedText`. Coordinates are LOCAL (relative to `PreparedText::origin`). +/// Add `origin` to convert to absolute window coords. #[derive(Clone, Debug, PartialEq)] -pub struct PreparedImage { - pub element_id: Option, - pub resource: ImageResource, - pub rect: Rect, - pub uv_rect: (f32, f32, f32, f32), +pub struct TextLayoutData { + pub lines: Vec, + pub glyphs: Vec, + /// Measured (unclamped) size of the laid-out text. + pub size: UiSize, +} + +impl Deref for PreparedText { + type Target = TextLayoutData; + + fn deref(&self) -> &TextLayoutData { + &self.layout + } } impl PreparedText { + /// Construct a `PreparedText` from shaped data. Called by `TextSystem::prepare_spans`. + pub(crate) fn from_layout( + element_id: Option, + text: String, + origin: Point, + bounds: Option, + font_size: f32, + line_height: f32, + default_color: Color, + selectable: bool, + selection_style: TextSelectionStyle, + layout: Arc, + ) -> Self { + Self { + element_id, + text, + origin, + bounds, + font_size, + line_height, + default_color, + selectable, + selection_style, + layout, + } + } + + /// Returns a raw pointer to the underlying `TextLayoutData` allocation. + /// Used in tests to verify Arc sharing between PreparedTexts. + #[cfg(test)] + pub(crate) fn layout_ptr(&self) -> *const TextLayoutData { + Arc::as_ptr(&self.layout) + } + + /// Create a monospace `PreparedText` without going through the text system. + /// Used for testing and low-level terminal-style rendering. pub fn monospace( text: impl Into, origin: Point, @@ -177,21 +262,31 @@ impl PreparedText { color: Color, ) -> Self { let text = text.into(); - let mut x = origin.x; + // Glyphs are stored in LOCAL (origin-relative) coordinates. + let mut local_x = 0.0f32; let mut glyphs = Vec::with_capacity(text.chars().count()); for (text_start, ch) in text.char_indices() { let text_end = text_start + ch.len_utf8(); + // monospace() builds glyphs directly; use the actual color (not sentinel) + // because this path does not go through the text cache. glyphs.push(GlyphInstance { - position: Point::new(x, origin.y), + position: Point::new(local_x, 0.0), advance, color, cache_key: None, text_start, text_end, }); - x += advance; + local_x += advance; } - + let line = PreparedTextLine { + rect: Rect::new(0.0, 0.0, local_x, font_size), + text_start: 0, + text_end: glyphs.last().map_or(0, |glyph| glyph.text_end), + glyph_start: 0, + glyph_end: glyphs.len(), + }; + let size = UiSize::new(local_x, font_size); Self { element_id: None, text, @@ -199,25 +294,34 @@ impl PreparedText { bounds: None, font_size, line_height: font_size, - color, + default_color: color, selectable: true, selection_style: TextSelectionStyle::DEFAULT, - lines: vec![PreparedTextLine { - rect: Rect::new(origin.x, origin.y, x - origin.x, font_size), - text_start: 0, - text_end: glyphs.last().map_or(0, |glyph| glyph.text_end), - glyph_start: 0, - glyph_end: glyphs.len(), - }], - glyphs, + layout: Arc::new(TextLayoutData { + lines: vec![line], + glyphs, + size, + }), } } + /// Translate this text by `offset`. O(1): only `self.origin` is updated. + /// Glyphs are stored in local coords and are unaffected. + pub fn translated(mut self, offset: Point) -> Self { + self.origin.x += offset.x; + self.origin.y += offset.y; + self + } + + /// Return the byte offset within `self.text` for an absolute window-space point. pub fn byte_offset_for_position(&self, point: Point) -> usize { - let Some(line) = self.line_for_position(point.y) else { + // Convert to local coords before delegating to the local-space helpers. + let local_x = point.x - self.origin.x; + let local_y = point.y - self.origin.y; + let Some(line) = self.line_for_position(local_y) else { return 0; }; - self.byte_offset_for_line_position(line, point.x) + self.byte_offset_for_line_position(line, local_x) } pub fn selection_range(&self, start: usize, end: usize) -> Range { @@ -252,9 +356,10 @@ impl PreparedText { } if let (Some(left), Some(right)) = (left, right) { + // Glyph x/y and line rect are in local coords; add origin for absolute result. rects.push(Rect::new( - left, - line.rect.origin.y, + self.origin.x + left, + self.origin.y + line.rect.origin.y, (right - left).max(0.0), line.rect.size.height, )); @@ -266,10 +371,11 @@ impl PreparedText { pub fn caret_rect(&self, offset: usize, width: f32) -> Option { let width = width.max(0.0); let line = self.line_for_offset(offset)?; - let x = self.caret_x_for_line_offset(line, offset); + // caret_x_for_line_offset returns local x; add origin for absolute result. + let local_x = self.caret_x_for_line_offset(line, offset); Some(Rect::new( - x, - line.rect.origin.y, + self.origin.x + local_x, + self.origin.y + line.rect.origin.y, width, line.rect.size.height, )) @@ -346,6 +452,8 @@ impl PreparedText { start..end } + /// Apply `selection_style.text_color` to glyphs in `[start, end)`. + /// Clones the inner `Arc` on first call if it is shared. pub fn apply_selected_text_color(&mut self, start: usize, end: usize) { let Some(selected_color) = self.selection_style.text_color else { return; @@ -354,7 +462,7 @@ impl PreparedText { if range.is_empty() { return; } - for glyph in &mut self.glyphs { + for glyph in Arc::make_mut(&mut self.layout).glyphs.iter_mut() { if glyph.text_end > range.start && glyph.text_start < range.end { glyph.color = selected_color; } @@ -488,6 +596,14 @@ fn classify_word_char(ch: char) -> WordClass { } } +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedImage { + pub element_id: Option, + pub resource: ImageResource, + pub rect: Rect, + pub uv_rect: (f32, f32, f32, f32), +} + #[derive(Clone, Debug, PartialEq)] pub enum DisplayItem { Quad(Quad), @@ -503,6 +619,42 @@ pub enum DisplayItem { LayerEnd, } +impl DisplayItem { + /// Return a copy of this item with all positions shifted by `offset`. + pub fn translated(&self, offset: Point) -> Self { + fn translate_rect(r: Rect, o: Point) -> Rect { + Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height) + } + match self { + Self::Quad(q) => Self::Quad(Quad { rect: translate_rect(q.rect, offset), ..*q }), + Self::RoundedRect(r) => Self::RoundedRect(RoundedRect { + rect: translate_rect(r.rect, offset), + ..*r + }), + Self::ShadowRect(s) => Self::ShadowRect(ShadowRect { + rect: translate_rect(s.rect, offset), + source_rect: translate_rect(s.source_rect, offset), + ..*s + }), + Self::Image(img) => Self::Image(PreparedImage { + rect: translate_rect(img.rect, offset), + ..img.clone() + }), + Self::Text(text) => Self::Text(text.clone().translated(offset)), + Self::PushClip(clip) => Self::PushClip(ClipRegion { + rect: translate_rect(clip.rect, offset), + ..*clip + }), + // These items carry no position data or are balanced markers. + Self::PopClip => Self::PopClip, + Self::PushTransform(t) => Self::PushTransform(*t), + Self::PopTransform => Self::PopTransform, + Self::LayerBegin { opacity } => Self::LayerBegin { opacity: *opacity }, + Self::LayerEnd => Self::LayerEnd, + } + } +} + #[derive(Clone, Debug, PartialEq)] pub struct SceneSnapshot { pub version: SceneVersion, @@ -581,6 +733,23 @@ impl SceneSnapshot { } } +// --------------------------------------------------------------------------- +// Hash implementations for f32-containing types. + +impl std::hash::Hash for Point { + fn hash(&self, state: &mut H) { + self.x.to_bits().hash(state); + self.y.to_bits().hash(state); + } +} + +impl std::hash::Hash for UiSize { + fn hash(&self, state: &mut H) { + self.width.to_bits().hash(state); + self.height.to_bits().hash(state); + } +} + #[cfg(test)] mod tests { use super::{Color, Point, PreparedText, Rect}; @@ -669,6 +838,9 @@ mod tests { #[test] fn prepared_text_vertical_offset_moves_between_lines() { + use std::sync::Arc; + use super::{PreparedTextLine, TextLayoutData}; + let mut text = PreparedText::monospace( "abcdwxyz", Point::new(10.0, 20.0), @@ -676,28 +848,32 @@ mod tests { 8.0, Color::rgb(0xFF, 0xFF, 0xFF), ); - text.lines = vec![ - super::PreparedTextLine { - rect: Rect::new(10.0, 20.0, 32.0, 16.0), + // Lines and glyphs use LOCAL (origin-relative) coordinates. + let lines = vec![ + PreparedTextLine { + rect: Rect::new(0.0, 0.0, 32.0, 16.0), text_start: 0, text_end: 4, glyph_start: 0, glyph_end: 4, }, - super::PreparedTextLine { - rect: Rect::new(10.0, 36.0, 32.0, 16.0), + PreparedTextLine { + rect: Rect::new(0.0, 16.0, 32.0, 16.0), text_start: 4, text_end: 8, glyph_start: 4, glyph_end: 8, }, ]; - for (index, glyph) in text.glyphs.iter_mut().enumerate() { + let mut glyphs = text.glyphs.to_vec(); + for (index, glyph) in glyphs.iter_mut().enumerate() { if index >= 4 { - glyph.position.y = 36.0; - glyph.position.x = 10.0 + ((index - 4) as f32 * 8.0); + glyph.position.y = 16.0; + glyph.position.x = (index - 4) as f32 * 8.0; } } + let orig_size = text.layout.size; + text.layout = Arc::new(TextLayoutData { lines, glyphs, size: orig_size }); assert_eq!(text.vertical_offset(2, 1), Some(6)); assert_eq!(text.vertical_offset(6, -1), Some(2)); @@ -714,12 +890,14 @@ mod tests { assert_eq!(text.lines.len(), 3); let target_line = &text.lines[1]; - let y = target_line.rect.origin.y + target_line.rect.size.height * 0.5; - let start = text.byte_offset_for_position(Point::new(target_line.rect.origin.x, y)); - let end = text.byte_offset_for_position(Point::new(target_line.rect.origin.x + 16.0, y)); + // Lines store LOCAL coords; add text.origin to get absolute window coords for the query. + let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5; + let start = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y)); + let end = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x + 16.0, y)); let rects = text.selection_rects(start, end); assert_eq!(rects.len(), 1); - assert_eq!(rects[0].origin.y, target_line.rect.origin.y); + // selection_rects returns absolute coords; compare against absolute line y. + assert_eq!(rects[0].origin.y, text.origin.y + target_line.rect.origin.y); } } diff --git a/lib/ui/src/text.rs b/lib/ui/src/text.rs index 983b90f..580750f 100644 --- a/lib/ui/src/text.rs +++ b/lib/ui/src/text.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeSet, HashMap}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::mem; +use std::sync::Arc; use std::time::Instant; use cosmic_text::{ @@ -9,7 +10,8 @@ use cosmic_text::{ }; use fontconfig::Fontconfig; -use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, UiSize}; +use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData, + UiSize}; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum TextAlign { @@ -201,20 +203,70 @@ impl TextStyle { } } +// --------------------------------------------------------------------------- +// LRU text layout cache +// --------------------------------------------------------------------------- + +/// Simple generation-based LRU cache for `Arc`. +/// No external dependencies — evicts the least-recently-used entry on insert +/// when at capacity. +struct LruTextCache { + map: HashMap)>, // key → (generation, data) + generation: u64, + capacity: usize, +} + +impl LruTextCache { + fn new(capacity: usize) -> Self { + Self { + map: HashMap::with_capacity(capacity.min(64)), + generation: 0, + capacity, + } + } + + fn get(&mut self, key: u64) -> Option> { + if let Some((lru, data)) = self.map.get_mut(&key) { + self.generation += 1; + *lru = self.generation; + return Some(Arc::clone(data)); + } + None + } + + fn insert(&mut self, key: u64, data: Arc) { + if self.map.len() >= self.capacity && !self.map.contains_key(&key) { + // Evict the entry with the lowest generation (least recently used). + if let Some(oldest_key) = self + .map + .iter() + .min_by_key(|(_, (lru, _))| *lru) + .map(|(k, _)| *k) + { + self.map.remove(&oldest_key); + } + } + self.generation += 1; + self.map.insert(key, (self.generation, data)); + } + + #[allow(dead_code)] + fn len(&self) -> usize { + self.map.len() + } +} + +// --------------------------------------------------------------------------- +// TextSystem +// --------------------------------------------------------------------------- + pub struct TextSystem { font_system: FontSystem, family_resolver: FontFamilyResolver, - layout_cache: HashMap, + layout_cache: LruTextCache, frame_stats: TextFrameStats, } -#[derive(Clone, Debug, PartialEq)] -struct TextLayout { - lines: Vec, - glyphs: Vec, - size: UiSize, -} - #[derive(Clone, Copy, Debug, Default)] pub(crate) struct TextFrameStats { pub requests: u32, @@ -246,7 +298,7 @@ impl TextSystem { Self { font_system, family_resolver, - layout_cache: HashMap::new(), + layout_cache: LruTextCache::new(1024), frame_stats: TextFrameStats::default(), } } @@ -269,6 +321,17 @@ impl TextSystem { self.prepare_spans(&spans, origin, style, style.bounds) } + /// Shape `spans` and return a `PreparedText` with origin-relative glyph positions. + /// + /// Glyphs are stored in LOCAL coordinates (relative to `origin`). The + /// renderer adds `text.origin` when emitting geometry. This allows the + /// cached `Arc` to be shared directly — no per-call + /// translation is needed. + /// + /// Glyphs that use the default color are stored with `Color::SENTINEL`; + /// the renderer substitutes `PreparedText::default_color` for those. This + /// makes the shape cache color-independent: the same shaped data is reused + /// even when `style.color` differs between calls. pub fn prepare_spans( &mut self, spans: &[TextSpan], @@ -278,54 +341,26 @@ impl TextSystem { ) -> PreparedText { let bounds = bounds.or(style.bounds); let text = combined_text(spans); - let layout = self.layout( + // Use the cached Arc directly — glyphs are in local (origin-0) coords. + let layout_data = self.layout( spans, style, - bounds.map(|bounds| bounds.width), - bounds.map(|bounds| bounds.height), + bounds.map(|b| b.width), + bounds.map(|b| b.height), ); - let glyphs = layout - .glyphs - .into_iter() - .map(|glyph| GlyphInstance { - position: Point::new(origin.x + glyph.position.x, origin.y + glyph.position.y), - advance: glyph.advance, - color: glyph.color, - cache_key: glyph.cache_key, - text_start: glyph.text_start, - text_end: glyph.text_end, - }) - .collect(); - let lines = layout - .lines - .into_iter() - .map(|line| PreparedTextLine { - rect: Rect::new( - origin.x + line.rect.origin.x, - origin.y + line.rect.origin.y, - line.rect.size.width, - line.rect.size.height, - ), - text_start: line.text_start, - text_end: line.text_end, - glyph_start: line.glyph_start, - glyph_end: line.glyph_end, - }) - .collect(); - PreparedText { - element_id: None, + PreparedText::from_layout( + None, text, origin, bounds, - font_size: style.font_size, - line_height: style.line_height, - color: style.color, - selectable: style.selectable, - selection_style: style.selection_style, - lines, - glyphs, - } + style.font_size, + style.line_height, + style.color, + style.selectable, + style.selection_style, + layout_data, + ) } pub fn measure( @@ -346,26 +381,40 @@ impl TextSystem { width: Option, height: Option, ) -> UiSize { - self.layout(spans, style, width, height).size + let layout = self.layout(spans, style, width, height); + // Clamp the measured size to the supplied bounds (mirrors old clamp_text_layout). + let mut size = layout.size; + if let Some(w) = width { + size.width = size.width.min(w.max(0.0)); + } + if let Some(h) = height { + size.height = size.height.min(h.max(0.0)); + } + size } + /// Return the cached (origin-0) `Arc` for the given spans/style. + /// Builds and caches on miss. Color is NOT part of the cache key; glyphs + /// that take the default color are stored with `Color::SENTINEL`. fn layout( &mut self, spans: &[TextSpan], style: &TextStyle, width: Option, height: Option, - ) -> TextLayout { + ) -> Arc { self.frame_stats.requests = self.frame_stats.requests.saturating_add(1); let cache_key = layout_cache_key(spans, style, width, height); - if let Some(layout) = self.layout_cache.get(&cache_key) { + + if let Some(cached) = self.layout_cache.get(cache_key) { self.frame_stats.cache_hits = self.frame_stats.cache_hits.saturating_add(1); self.frame_stats.output_glyphs = self .frame_stats .output_glyphs - .saturating_add(layout.glyphs.len() as u32); - return clamp_text_layout(layout.clone(), width, height); + .saturating_add(cached.glyphs.len() as u32); + return cached; } + self.frame_stats.cache_misses = self.frame_stats.cache_misses.saturating_add(1); let miss_started = Instant::now(); @@ -391,7 +440,11 @@ impl TextSystem { { let mut borrowed = buffer.borrow_with(&mut self.font_system); borrowed.set_wrap(style.wrap.to_cosmic()); - borrowed.set_size(width, None); + // For no-wrap + start-aligned text, width doesn't affect shaping. + // Pass None so cosmic-text doesn't truncate and the result is + // consistent with the width-independent cache key. + let effective_width = if width_affects_layout(style) { width } else { None }; + borrowed.set_size(effective_width, None); let default_attrs = default_attrs_for_style(style, default_family.as_deref()); if uses_plain_text_fast_path { borrowed.set_text( @@ -438,7 +491,13 @@ impl TextSystem { GlyphInstance { position: Point::new(physical.x as f32, physical.y as f32), advance: glyph.w, - color: glyph.color_opt.map_or(style.color, color_from_cosmic), + // Use SENTINEL for default-colored glyphs so that the cache + // is color-independent. Per-span colors (from glyph.color_opt) + // are stored directly since they are part of the shape key via + // the spans hash. + color: glyph + .color_opt + .map_or(Color::SENTINEL, color_from_cosmic), cache_key: Some(physical.cache_key), text_start: line_offset + glyph.start, text_end: line_offset + glyph.end, @@ -464,21 +523,34 @@ impl TextSystem { self.frame_stats.glyph_collect_ms += glyph_collect_started.elapsed().as_secs_f64() * 1_000.0; - let layout = TextLayout { + let layout = Arc::new(TextLayoutData { lines, glyphs, size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)), - }; + }); + self.frame_stats.output_glyphs = self .frame_stats .output_glyphs .saturating_add(layout.glyphs.len() as u32); self.frame_stats.miss_ms += miss_started.elapsed().as_secs_f64() * 1_000.0; - if self.layout_cache.len() >= 256 { - self.layout_cache.clear(); + + // Warn loudly when a single text node is large enough to cause frame drops. + // At 16px, 10_000 glyphs ≈ 600 lines of text. Use a virtual list instead. + const GLYPH_COUNT_WARNING_THRESHOLD: usize = 10_000; + if layout.glyphs.len() > GLYPH_COUNT_WARNING_THRESHOLD { + tracing::warn!( + target: "ruin_ui::text_perf", + glyph_count = layout.glyphs.len(), + shape_ms = miss_started.elapsed().as_secs_f64() * 1_000.0, + "large text node shaped: consider splitting into a virtual list \ + (each item a separate Element::text) so off-screen items can be \ + culled and unchanged items skip reshaping" + ); } - self.layout_cache.insert(cache_key, layout.clone()); - clamp_text_layout(layout, width, height) + + self.layout_cache.insert(cache_key, Arc::clone(&layout)); + layout } fn resolve_font_family( @@ -534,6 +606,13 @@ fn line_start_offsets(text: &str) -> Vec { starts } +/// Cache key for shaped text. `style.color` is intentionally excluded: +/// glyphs that use the default color are stored with `Color::SENTINEL`, making +/// the shape cache color-independent. Per-span colors are part of `spans`. +/// +/// Width is also excluded when it cannot affect the layout: no-wrap + +/// start-aligned text has the same glyph positions at any available width. +/// Omitting it prevents resize from invalidating these (often large) entries. fn layout_cache_key( spans: &[TextSpan], style: &TextStyle, @@ -544,31 +623,30 @@ fn layout_cache_key( spans.hash(&mut hasher); style.font_size.to_bits().hash(&mut hasher); style.line_height.to_bits().hash(&mut hasher); - style.color.hash(&mut hasher); + // style.color intentionally omitted — see Color::SENTINEL style.font_family.hash(&mut hasher); style.wrap.hash(&mut hasher); style.align.hash(&mut hasher); style.max_lines.hash(&mut hasher); - quantized_layout_dimension(width).hash(&mut hasher); + // Width is excluded for no-wrap + start-aligned text: glyph positions are + // independent of container width, so the cached data is valid at any width. + if width_affects_layout(style) { + quantized_layout_dimension(width).hash(&mut hasher); + } hasher.finish() } -fn clamp_text_layout( - mut layout: TextLayout, - width: Option, - height: Option, -) -> TextLayout { - if let Some(width) = width { - layout.size.width = layout.size.width.min(width.max(0.0)); - } - if let Some(height) = height { - layout.size.height = layout.size.height.min(height.max(0.0)); - } - layout +/// Returns `true` if the available width can change the shaped glyph positions. +/// +/// For `TextWrap::None` + `TextAlign::Start`, text flows past any boundary +/// and lines are left-aligned, so width has no effect on the output. +#[inline] +fn width_affects_layout(style: &TextStyle) -> bool { + style.wrap != TextWrap::None || style.align != TextAlign::Start } fn quantized_layout_dimension(value: Option) -> Option { - const LAYOUT_CACHE_BUCKET_PX: f32 = 8.0; + const LAYOUT_CACHE_BUCKET_PX: f32 = 1.0; value.map(|value| { (value / LAYOUT_CACHE_BUCKET_PX) .round() @@ -595,9 +673,10 @@ fn default_attrs_for_style<'a>( style: &'a TextStyle, resolved_family: Option<&'a str>, ) -> Attrs<'a> { + // Do NOT pass style.color to cosmic-text — default-colored glyphs should have + // color_opt = None so they are stored as Color::SENTINEL (color-independent cache). Attrs::new() .family(font_family_to_cosmic(&style.font_family, resolved_family)) - .color(to_cosmic_color(style.color)) .metrics(Metrics::new(style.font_size, style.line_height)) } @@ -607,10 +686,16 @@ fn attrs_for_span<'a>( resolved_family: Option<&'a str>, ) -> Attrs<'a> { let font_family = span.font_family.as_ref().unwrap_or(&style.font_family); - default_attrs_for_style(style, resolved_family) - .family(font_family_to_cosmic(font_family, resolved_family)) - .color(to_cosmic_color(span.color.unwrap_or(style.color))) - .weight(match span.weight { + let base = default_attrs_for_style(style, resolved_family) + .family(font_family_to_cosmic(font_family, resolved_family)); + // Only set a color when the span explicitly overrides the default. Spans that + // inherit style.color leave color_opt = None, producing Color::SENTINEL in the cache. + let base = if let Some(span_color) = span.color { + base.color(to_cosmic_color(span_color)) + } else { + base + }; + base.weight(match span.weight { TextSpanWeight::Normal => CosmicWeight::NORMAL, TextSpanWeight::Medium => CosmicWeight::MEDIUM, TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD, @@ -829,6 +914,31 @@ impl TextFontFamily { } } +// --------------------------------------------------------------------------- +// Hash implementations for f32-containing types. + +impl std::hash::Hash for TextSelectionStyle { + fn hash(&self, state: &mut H) { + self.highlight_color.hash(state); + self.text_color.hash(state); + } +} + +impl std::hash::Hash for TextStyle { + fn hash(&self, state: &mut H) { + self.font_size.to_bits().hash(state); + self.line_height.to_bits().hash(state); + self.color.hash(state); + self.font_family.hash(state); + self.bounds.map(|b| (b.width.to_bits(), b.height.to_bits())).hash(state); + self.wrap.hash(state); + self.align.hash(state); + self.max_lines.hash(state); + self.selectable.hash(state); + self.selection_style.hash(state); + } +} + #[cfg(test)] mod tests { use super::{ @@ -915,6 +1025,34 @@ mod tests { ); } + /// Same text at two different default colors must produce identical shaped glyph data + /// (same positions, same cache keys, SENTINEL colors) since color is not part of + /// the shape cache key. + #[test] + fn same_text_different_default_color_shares_layout_data() { + let mut text_system = TextSystem::new(); + let style_red = TextStyle::new(16.0, Color::rgb(0xFF, 0x00, 0x00)); + let style_blue = TextStyle::new(16.0, Color::rgb(0x00, 0x00, 0xFF)); + let origin = Point::new(0.0, 0.0); + let red = text_system.prepare("hello", origin, &style_red); + let blue = text_system.prepare("hello", origin, &style_blue); + // Both PreparedTexts must have identical glyph shapes and sentinel colors — + // the second call hits the shape cache and produces the same layout data. + assert_eq!(red.glyphs.len(), blue.glyphs.len(), "glyph count must match"); + for (r, b) in red.glyphs.iter().zip(blue.glyphs.iter()) { + assert_eq!(r.position, b.position, "glyph positions must match"); + assert_eq!(r.cache_key, b.cache_key, "glyph cache keys must match"); + assert_eq!(r.color, Color::SENTINEL, "default-color glyphs must use SENTINEL"); + assert_eq!(b.color, Color::SENTINEL, "default-color glyphs must use SENTINEL"); + } + // PreparedText::clone() is an Arc clone — O(1). + let cloned = red.clone(); + assert_eq!(cloned.layout_ptr(), red.layout_ptr(), "clone must share the same Arc"); + // But they should carry different default colors. + assert_eq!(red.default_color, Color::rgb(0xFF, 0x00, 0x00)); + assert_eq!(blue.default_color, Color::rgb(0x00, 0x00, 0xFF)); + } + #[test] fn preferred_family_name_uses_first_available_candidate() { let selected = preferred_family_name( diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index 807a378..719ecb5 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -1,3 +1,5 @@ +use std::hash::{Hash, Hasher}; + use crate::scene::{Color, Point}; use crate::text::{TextSpan, TextStyle, TextWrap}; use crate::{ImageFit, ImageResource}; @@ -462,3 +464,144 @@ impl Default for Element { Self::new() } } + +// --------------------------------------------------------------------------- +// Hash implementations for f32-containing types. +// f32 fields use `.to_bits()` so NaN != NaN is avoided in hashing context +// (layout values are always finite so NaN is not expected). + +impl Hash for FlexDirection { + fn hash(&self, state: &mut H) { + (*self as u8).hash(state); + } +} + +impl Hash for Edges { + fn hash(&self, state: &mut H) { + self.top.to_bits().hash(state); + self.right.to_bits().hash(state); + self.bottom.to_bits().hash(state); + self.left.to_bits().hash(state); + } +} + +impl Hash for Border { + fn hash(&self, state: &mut H) { + self.width.to_bits().hash(state); + self.color.hash(state); + } +} + +impl Hash for CornerRadius { + fn hash(&self, state: &mut H) { + self.top_left.to_bits().hash(state); + self.top_right.to_bits().hash(state); + self.bottom_right.to_bits().hash(state); + self.bottom_left.to_bits().hash(state); + } +} + +impl Hash for BoxShadowKind { + fn hash(&self, state: &mut H) { + (*self as u8).hash(state); + } +} + +impl Hash for BoxShadow { + fn hash(&self, state: &mut H) { + self.offset.hash(state); + self.blur.to_bits().hash(state); + self.spread.to_bits().hash(state); + self.color.hash(state); + self.kind.hash(state); + } +} + +impl Hash for ScrollbarStyle { + fn hash(&self, state: &mut H) { + self.gutter_width.to_bits().hash(state); + self.track_color.hash(state); + self.thumb_color.hash(state); + self.corner_radius.to_bits().hash(state); + self.min_thumb_size.to_bits().hash(state); + } +} + +impl Hash for Style { + fn hash(&self, state: &mut H) { + self.direction.hash(state); + self.width.map(f32::to_bits).hash(state); + self.height.map(f32::to_bits).hash(state); + self.flex_grow.to_bits().hash(state); + self.gap.to_bits().hash(state); + self.padding.hash(state); + self.background.hash(state); + self.border.hash(state); + self.corner_radius.hash(state); + self.box_shadows.hash(state); + self.pointer_events.hash(state); + self.focusable.hash(state); + self.cursor.hash(state); + } +} + +impl Hash for TextNode { + fn hash(&self, state: &mut H) { + self.spans.hash(state); + self.style.hash(state); + } +} + +impl Hash for ImageNode { + fn hash(&self, state: &mut H) { + self.resource.hash(state); + self.fit.hash(state); + } +} + +impl Hash for ScrollBoxNode { + fn hash(&self, state: &mut H) { + self.offset_y.to_bits().hash(state); + self.scrollbar.hash(state); + } +} + +impl Hash for ElementContent { + fn hash(&self, state: &mut H) { + match self { + Self::Container => 0u8.hash(state), + Self::ScrollBox(s) => { + 1u8.hash(state); + s.hash(state); + } + Self::Image(i) => { + 2u8.hash(state); + i.hash(state); + } + Self::Text(t) => { + 3u8.hash(state); + t.hash(state); + } + } + } +} + +impl Hash for Element { + fn hash(&self, state: &mut H) { + self.id.hash(state); + self.style.hash(state); + self.children.hash(state); + self.content.hash(state); + } +} + +impl Element { + /// A hash of this element's entire subtree. Two subtrees with identical structure and + /// data will produce the same hash; any change produces a different hash. + pub fn subtree_hash(&self) -> u64 { + use std::hash::DefaultHasher; + let mut h = DefaultHasher::new(); + self.hash(&mut h); + h.finish() + } +} diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 35b50e6..9e31275 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -7,7 +7,7 @@ use std::io::ErrorKind; use std::io::Read; use std::io::Write; use std::num::NonZeroU32; -use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; +use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::ptr::NonNull; use std::rc::Rc; use std::time::{Duration, Instant}; @@ -17,7 +17,9 @@ use raw_window_handle::{ RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, WindowHandle, }; use ruin_runtime::channel::mpsc; -use ruin_runtime::{WorkerHandle, queue_future, queue_task, spawn_worker}; +use ruin_runtime::{ + TimeoutHandle, WorkerHandle, clear_timeout, queue_future, set_timeout, spawn_worker, +}; use ruin_ui::{ CursorIcon, KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, PlatformEndpoint, PlatformEvent, PlatformRequest, PlatformRuntime, Point, PointerButton, PointerEvent, @@ -38,6 +40,7 @@ use wayland_client::{ use wayland_protocols::wp::cursor_shape::v1::client::{ wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1, }; +use wayland_protocols::wp::viewporter::client::{wp_viewport, wp_viewporter}; use wayland_protocols::wp::primary_selection::zv1::client::{ zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1, zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1, @@ -114,6 +117,16 @@ struct WindowWorkerState { viewport_request_in_flight: Option, event_tx: mpsc::UnboundedSender, internal_tx: mpsc::UnboundedSender, + /// Pending keyboard-repeat timeout scheduled via [`ruin_runtime::set_timeout`]. + keyboard_repeat_timer: Option, + /// When the current pending viewport was first requested; used to measure end-to-end + /// resize latency in the "resize complete" log event. + pending_viewport_since: Option, + /// Swapchain size that should be applied just before the next `renderer.render()`. + /// Set on every `frame.resized` event; cleared once applied. Deferring the resize + /// to render-time means rapid configures (A→B→C) cause only one swapchain recreation + /// instead of one per configure. + pending_swapchain_size: Option<(u32, u32)>, } enum WindowWorkerCommand { @@ -144,6 +157,9 @@ struct State { clipboard_device: Option, cursor_shape_manager: Option, cursor_shape_device: Option, + /// Surface viewport for compositor-side scaling during resize. When set, the compositor + /// scales the last-committed buffer to the destination size without a GPU re-render. + viewport: Option, primary_selection_manager: Option, primary_selection_device: Option, @@ -300,6 +316,7 @@ impl WaylandWindow { }, ); let cursor_shape_manager = globals.bind(&qh, 1..=2, ()).ok(); + let viewporter: Option = globals.bind(&qh, 1..=1, ()).ok(); let primary_selection_manager = globals.bind(&qh, 1..=1, ()).ok(); let primary_selection_device = primary_selection_manager.as_ref().map( |manager: &zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1| { @@ -307,6 +324,7 @@ impl WaylandWindow { }, ); let surface = compositor.create_surface(&qh, ()); + let viewport = viewporter.as_ref().map(|vp| vp.get_viewport(&surface, &qh, ())); let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); let toplevel = xdg_surface.get_toplevel(&qh, ()); toplevel.set_title(spec.title.clone()); @@ -351,6 +369,7 @@ impl WaylandWindow { clipboard_device, cursor_shape_manager, cursor_shape_device: None, + viewport, primary_selection_manager, primary_selection_device, qh, @@ -666,6 +685,29 @@ impl WaylandWindow { self.state.frame_callback = None; } + /// Set the compositor-side destination size for the surface viewport. + /// The compositor will scale the last-committed buffer to this size without a GPU re-render. + /// Call `clear_viewport_destination` before the next GPU render so the buffer size is used. + /// Returns `true` if the viewport was set (compositor supports wp_viewporter), `false` if + /// the caller must fall back to a GPU render for the preview. + fn set_viewport_destination(&mut self, width: u32, height: u32) -> bool { + if let Some(vp) = self.state.viewport.as_ref() { + vp.set_destination(width as i32, height as i32); + self.state._surface.commit(); + true + } else { + false + } + } + + /// Remove the compositor-side destination override. Must be called before the next GPU + /// render so the compositor uses the buffer's natural dimensions. + fn clear_viewport_destination(&mut self) { + if let Some(vp) = self.state.viewport.as_ref() { + vp.set_destination(-1, -1); + } + } + fn flush_connection(&mut self) -> Result<(), Box> { self.state._connection.flush()?; Ok(()) @@ -910,18 +952,17 @@ fn handle_replace_scene( height = scene.logical_size.height, "received scene replacement" ); - let mut command_tx = None; - { + let command_tx = { let mut state_ref = state.borrow_mut(); let Some(record) = state_ref.windows.get_mut(&window_id) else { return; }; record.latest_scene = Some(scene.clone()); - if let Some(worker) = record.worker.as_ref() { - command_tx = Some(worker.command_tx.clone()); - } - } + record.worker.as_ref().map(|w| w.command_tx.clone()) + }; if let Some(command_tx) = command_tx { + // send() uses MSG_RING to wake the command loop on the worker thread; the command + // loop then writes to the wakeup pipe to notify the pump. let _ = command_tx.send(WindowWorkerCommand::ReplaceScene(scene)); } } @@ -1020,6 +1061,18 @@ fn shutdown_wayland_backend(state: &Rc>) { } } +/// Writes one byte to the pump wakeup pipe (non-blocking, ignores errors). +fn write_wakeup(fd: RawFd) { + let b: u8 = 1; + let _ = unsafe { libc::write(fd, &b as *const u8 as *const libc::c_void, 1) }; +} + +/// Drains all pending bytes from the pump wakeup pipe (non-blocking). +fn drain_wakeup(fd: RawFd) { + let mut buf = [0u8; 64]; + let _ = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, 64) }; +} + fn spawn_window_worker( window_id: WindowId, spec: WindowSpec, @@ -1028,6 +1081,18 @@ fn spawn_window_worker( internal_tx: mpsc::UnboundedSender, ) -> WindowWorkerHandle { let (command_tx, mut command_rx) = mpsc::unbounded_channel::(); + + // Create a wakeup pipe (non-blocking, close-on-exec) used to drive the async pump. + // Three sources write to the write end: + // 1. The Wayland-fd watcher task — when the compositor buffers new events. + // 2. The command loop — after processing each window command. + // 3. Keyboard-repeat timer callbacks scheduled via set_timeout. + let mut pipe_fds = [0i32; 2]; + let r = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_NONBLOCK | libc::O_CLOEXEC) }; + assert_eq!(r, 0, "pipe2 failed"); + let pipe_read_fd = pipe_fds[0]; + let pipe_write_fd = pipe_fds[1]; + let worker = spawn_worker( move || { let window = match WaylandWindow::open(spec.clone()) { @@ -1038,6 +1103,8 @@ fn spawn_window_worker( return; } }; + // Extract the Wayland socket fd before moving `window` into state. + let wayland_fd = window.poll_fd(); let renderer = match WgpuSceneRenderer::new( window.surface_target(), spec.requested_inner_size @@ -1068,8 +1135,26 @@ fn spawn_window_worker( viewport_request_in_flight: None, event_tx, internal_tx, + keyboard_repeat_timer: None, + pending_viewport_since: None, })); + // Task 1: Wayland-fd watcher. Waits for the compositor to buffer events (frame + // callbacks, input, configure, etc.) then pokes the pump via the wakeup pipe. + // When the socket errors or hangs up the watcher exits silently; the pump will + // detect the closure on its next iteration. + queue_future(async move { + loop { + if ruin_runtime::fd::wait_readable(wayland_fd).await.is_err() { + break; + } + write_wakeup(pipe_write_fd); + } + }); + + // Task 2: command loop. Receives window commands from the platform thread via + // the mpsc channel (which uses MSG_RING for cross-thread delivery). After each + // command, pokes the pump so it can act on the updated state immediately. queue_future({ let state = Rc::clone(&state); async move { @@ -1175,16 +1260,313 @@ fn spawn_window_worker( } WindowWorkerCommand::Shutdown => { state.borrow_mut().shutdown_requested = true; + // Poke the pump so it sees shutdown_requested. + write_wakeup(pipe_write_fd); break; } } + // Poke the pump so it acts on the updated state without waiting for the + // next Wayland event. + write_wakeup(pipe_write_fd); } } }); - queue_task({ + // Task 3: async pump. Sleeps on the wakeup pipe read end; woken by Task 1 + // (Wayland events), Task 2 (commands), or keyboard-repeat timers. On each wake, + // dispatches Wayland events non-blockingly and renders if ready. + queue_future({ let state = Rc::clone(&state); - move || pump_window_worker(state) + async move { + loop { + // Consume all pending wakeup bytes before doing work. + drain_wakeup(pipe_read_fd); + + let mut keep_running = true; + { + let mut state_ref = state.borrow_mut(); + + // Non-blocking: read the Wayland socket and dispatch buffered events. + match state_ref.window.dispatch_ready() { + Ok(_) => {} + Err(_) => { + emit_window_closed(&mut state_ref, false); + keep_running = false; + } + } + + if keep_running { + for event in state_ref.window.drain_pointer_events() { + let _ = state_ref.event_tx.send(PlatformEvent::Pointer { + window_id: state_ref.window_id, + event, + }); + } + for event in state_ref.window.drain_keyboard_events() { + tracing::trace!( + target: "ruin_ui_platform_wayland::event_bridge", + window_id = state_ref.window_id.raw(), + keycode = event.keycode, + ?event.kind, + ?event.key, + text = event.text.as_deref().unwrap_or(""), + "forwarding keyboard event to UI runtime" + ); + let _ = state_ref.event_tx.send(PlatformEvent::Keyboard { + window_id: state_ref.window_id, + event, + }); + } + + let user_closed = !state_ref.shutdown_requested; + if state_ref.shutdown_requested || !state_ref.window.is_running() { + emit_window_closed(&mut state_ref, user_closed); + keep_running = false; + } + } + + if keep_running { + if let Some(frame) = state_ref.window.prepare_frame() { + let current_viewport = + UiSize::new(frame.width as f32, frame.height as f32); + if frame.resized { + debug!( + target: "ruin_ui_platform_wayland::resize", + window_id = state_ref.window_id.raw(), + width = current_viewport.width, + height = current_viewport.height, + "worker observed resized frame" + ); + state_ref.pending_viewport = Some(current_viewport); + state_ref.pending_viewport_since.get_or_insert_with(Instant::now); + // Emit the Configured event BEFORE resizing the GPU + // swapchain so the app thread starts layout immediately. + // renderer.resize() then runs concurrently with layout + // (different CPU threads), cutting the critical path from + // resize_gpu + layout to max(resize_gpu, layout). + if state_ref + .latest_scene + .as_ref() + .is_none_or(|s| s.logical_size != current_viewport) + || state_ref.viewport_request_in_flight.is_some() + { + maybe_request_pending_viewport(&mut state_ref); + } else { + state_ref.pending_viewport = None; + } + let t_resize = std::time::Instant::now(); + state_ref.renderer.resize(frame.width, frame.height); + let resize_gpu_us = t_resize.elapsed().as_micros(); + debug!( + target: "ruin_ui_platform_wayland::perf", + window_id = state_ref.window_id.raw(), + width = frame.width, + height = frame.height, + resize_gpu_us, + "renderer swapchain resized" + ); + state_ref.window.request_redraw(); + } + if !state_ref.opened_emitted { + state_ref.opened_emitted = true; + let _ = state_ref.event_tx.send(PlatformEvent::Opened { + window_id: state_ref.window_id, + }); + } + let scene = state_ref.latest_scene.clone(); + if let Some(scene) = scene.as_ref() { + if scene.logical_size != current_viewport { + // Resize case: render the preview immediately. + // Drop any pending frame callback — vsync pacing + // doesn't matter for a placeholder preview, and + // waiting for it (potentially 16–150ms) is the + // primary source of visible resize lag. + state_ref.window.clear_frame_callback(); + debug!( + target: "ruin_ui_platform_wayland::resize", + window_id = state_ref.window_id.raw(), + scene_version = scene.version, + scene_width = scene.logical_size.width, + scene_height = scene.logical_size.height, + viewport_width = current_viewport.width, + viewport_height = current_viewport.height, + "scene size does not match current viewport" + ); + state_ref.pending_viewport = Some(current_viewport); + state_ref.pending_viewport_since.get_or_insert_with(Instant::now); + // Use wp_viewport to scale the last-committed buffer + // to the new size without any GPU work. This lets + // the compositor composite immediately after + // ack_configure, before a new GPU frame is ready. + // Falls back to a GPU stretch render when the + // compositor does not advertise wp_viewporter. + let viewport_set = state_ref.window + .set_viewport_destination( + current_viewport.width as u32, + current_viewport.height as u32, + ); + let preview_ok = if viewport_set { + true + } else { + let mut preview_scene = scene.clone(); + preview_scene.logical_size = current_viewport; + state_ref.renderer.render(&preview_scene).is_ok() + }; + if preview_ok { + match state_ref.window.flush_connection() { + Ok(()) => { + let preview_lag_us = state_ref + .pending_viewport_since + .map(|t| t.elapsed().as_micros()); + debug!( + target: "ruin_ui_platform_wayland::resize", + window_id = state_ref.window_id.raw(), + scene_version = scene.version, + width = current_viewport.width, + height = current_viewport.height, + preview_lag_us, + compositor_scaled = viewport_set, + "presented preview; waiting for correct scene" + ); + let _ = state_ref.event_tx.send( + PlatformEvent::FramePresented { + window_id: state_ref.window_id, + scene_version: scene.version, + item_count: scene.item_count(), + }, + ); + finish_presented_viewport_request( + &mut state_ref, + scene.logical_size, + ); + } + Err(error) => { + debug!( + target: "ruin_ui_platform_wayland::scene", + window_id = state_ref.window_id.raw(), + error = %error, + "failed to flush preview" + ); + state_ref.window.request_redraw(); + } + } + } + } else if !state_ref.window.presentation_ready() { + // Correct scene is ready but the compositor + // hasn't signalled vsync yet. Wait for it. + state_ref.window.request_redraw(); + } else { + // Correct scene: clear the viewport destination so + // the compositor uses the buffer's natural size. + // This must happen before wgpu commits the new buffer. + state_ref.window.clear_viewport_destination(); + state_ref.window.arm_frame_callback(); + let t_render = std::time::Instant::now(); + match state_ref.renderer.render(scene) { + Ok(()) => { + let render_gpu_us = t_render.elapsed().as_micros(); + debug!( + target: "ruin_ui_platform_wayland::perf", + window_id = state_ref.window_id.raw(), + scene_version = scene.version, + render_gpu_us, + "renderer.render() complete" + ); + match state_ref.window.flush_connection() { + Ok(()) => { + let resize_lag_us = state_ref + .pending_viewport_since + .take() + .map(|t| t.elapsed().as_micros()); + debug!( + target: "ruin_ui_platform_wayland::resize", + window_id = state_ref.window_id.raw(), + scene_version = scene.version, + width = scene.logical_size.width, + height = scene.logical_size.height, + resize_lag_us, + "resize complete: presented correct scene" + ); + finish_presented_viewport_request( + &mut state_ref, + scene.logical_size, + ); + let _ = state_ref.event_tx.send( + PlatformEvent::FramePresented { + window_id: state_ref.window_id, + scene_version: scene.version, + item_count: scene.item_count(), + }, + ); + } + Err(error) => { + debug!( + target: "ruin_ui_platform_wayland::scene", + window_id = state_ref.window_id.raw(), + error = %error, + "failed to flush presented scene" + ); + state_ref.window.clear_frame_callback(); + state_ref.window.request_redraw(); + } + } + } + Err(RenderError::Lost | RenderError::Outdated) => { + state_ref.window.clear_frame_callback(); + state_ref + .renderer + .resize(frame.width, frame.height); + state_ref.window.request_redraw(); + } + Err( + RenderError::Timeout + | RenderError::Occluded + | RenderError::Validation, + ) => { + state_ref.window.clear_frame_callback(); + state_ref.window.request_redraw(); + } + } + } + } + } + if state_ref.viewport_request_in_flight.is_none() + && state_ref.pending_viewport.is_some() + { + maybe_request_pending_viewport(&mut state_ref); + } + + // Re-arm the keyboard-repeat timer so the pump wakes at the right + // time even when no Wayland events or commands are in flight. + if let Some(timer) = state_ref.keyboard_repeat_timer.take() { + clear_timeout(&timer); + } + if let Some(repeat) = + state_ref.window.state.keyboard_repeat.as_ref() + { + let delay = + repeat.next_at.saturating_duration_since(Instant::now()); + state_ref.keyboard_repeat_timer = Some(set_timeout( + delay, + move || write_wakeup(pipe_write_fd), + )); + } + } + } + + if !keep_running { + unsafe { + libc::close(pipe_read_fd); + libc::close(pipe_write_fd); + } + break; + } + + // Yield until the Wayland watcher, command loop, or a keyboard-repeat + // timer writes to the wakeup pipe. + ruin_runtime::fd::wait_readable(pipe_read_fd).await.ok(); + } + } }); }, || {}, @@ -1195,192 +1577,6 @@ fn spawn_window_worker( } } -fn pump_window_worker(state: Rc>) { - let mut reschedule = true; - { - let mut state_ref = state.borrow_mut(); - let wait_result = state_ref.window.wait_for_events(Duration::from_millis(16)); - if state_ref.shutdown_requested || wait_result.is_err() { - emit_window_closed(&mut state_ref, false); - reschedule = false; - } else { - for event in state_ref.window.drain_pointer_events() { - let _ = state_ref.event_tx.send(PlatformEvent::Pointer { - window_id: state_ref.window_id, - event, - }); - } - for event in state_ref.window.drain_keyboard_events() { - tracing::trace!( - target: "ruin_ui_platform_wayland::event_bridge", - window_id = state_ref.window_id.raw(), - keycode = event.keycode, - ?event.kind, - ?event.key, - text = event.text.as_deref().unwrap_or(""), - "forwarding keyboard event to UI runtime" - ); - let _ = state_ref.event_tx.send(PlatformEvent::Keyboard { - window_id: state_ref.window_id, - event, - }); - } - - if !state_ref.window.is_running() { - emit_window_closed(&mut state_ref, true); - reschedule = false; - } else if let Some(frame) = state_ref.window.prepare_frame() { - let current_viewport = UiSize::new(frame.width as f32, frame.height as f32); - if frame.resized { - debug!( - target: "ruin_ui_platform_wayland::resize", - window_id = state_ref.window_id.raw(), - width = current_viewport.width, - height = current_viewport.height, - "worker observed resized frame" - ); - state_ref.renderer.resize(frame.width, frame.height); - state_ref.window.request_redraw(); - state_ref.pending_viewport = Some(current_viewport); - if state_ref - .latest_scene - .as_ref() - .is_none_or(|scene| scene.logical_size != current_viewport) - || state_ref.viewport_request_in_flight.is_some() - { - maybe_request_pending_viewport(&mut state_ref); - } else { - state_ref.pending_viewport = None; - } - } - if !state_ref.opened_emitted { - state_ref.opened_emitted = true; - let _ = state_ref.event_tx.send(PlatformEvent::Opened { - window_id: state_ref.window_id, - }); - } - let scene = state_ref.latest_scene.clone(); - if let Some(scene) = scene.as_ref() { - if !state_ref.window.presentation_ready() { - state_ref.window.request_redraw(); - // Wait for the compositor frame callback before attempting another present. - } else if scene.logical_size != current_viewport { - debug!( - target: "ruin_ui_platform_wayland::resize", - window_id = state_ref.window_id.raw(), - scene_version = scene.version, - scene_width = scene.logical_size.width, - scene_height = scene.logical_size.height, - viewport_width = current_viewport.width, - viewport_height = current_viewport.height, - "scene size does not match current viewport" - ); - state_ref.pending_viewport = Some(current_viewport); - let mut preview_scene = scene.clone(); - preview_scene.logical_size = current_viewport; - state_ref.window.arm_frame_callback(); - if state_ref.renderer.render(&preview_scene).is_ok() { - match state_ref.window.flush_connection() { - Ok(()) => { - trace!( - target: "ruin_ui_platform_wayland::scene", - window_id = state_ref.window_id.raw(), - scene_version = scene.version, - width = current_viewport.width, - height = current_viewport.height, - "presented scaled preview scene for current viewport" - ); - let _ = state_ref.event_tx.send(PlatformEvent::FramePresented { - window_id: state_ref.window_id, - scene_version: scene.version, - item_count: scene.item_count(), - }); - finish_presented_viewport_request( - &mut state_ref, - scene.logical_size, - ); - } - Err(error) => { - debug!( - target: "ruin_ui_platform_wayland::scene", - window_id = state_ref.window_id.raw(), - error = %error, - "failed to flush presented preview scene" - ); - state_ref.window.clear_frame_callback(); - state_ref.window.request_redraw(); - } - } - } else { - state_ref.window.clear_frame_callback(); - } - } else { - state_ref.window.arm_frame_callback(); - match state_ref.renderer.render(scene) { - Ok(()) => { - match state_ref.window.flush_connection() { - Ok(()) => { - trace!( - target: "ruin_ui_platform_wayland::scene", - window_id = state_ref.window_id.raw(), - scene_version = scene.version, - width = scene.logical_size.width, - height = scene.logical_size.height, - "presented matching scene" - ); - finish_presented_viewport_request( - &mut state_ref, - scene.logical_size, - ); - let _ = - state_ref.event_tx.send(PlatformEvent::FramePresented { - window_id: state_ref.window_id, - scene_version: scene.version, - item_count: scene.item_count(), - }); - } - Err(error) => { - debug!( - target: "ruin_ui_platform_wayland::scene", - window_id = state_ref.window_id.raw(), - error = %error, - "failed to flush presented scene" - ); - state_ref.window.clear_frame_callback(); - state_ref.window.request_redraw(); - } - } - } - Err(RenderError::Lost | RenderError::Outdated) => { - state_ref.window.clear_frame_callback(); - state_ref.renderer.resize(frame.width, frame.height); - state_ref.window.request_redraw(); - } - Err( - RenderError::Timeout - | RenderError::Occluded - | RenderError::Validation, - ) => { - state_ref.window.clear_frame_callback(); - state_ref.window.request_redraw(); - } - } - } - } - } - if state_ref.viewport_request_in_flight.is_none() - && state_ref.pending_viewport.is_some() - { - maybe_request_pending_viewport(&mut state_ref); - } - } - } - - if reschedule { - queue_task(move || pump_window_worker(state)); - } -} - fn emit_window_closed(state: &mut WindowWorkerState, emit_close_requested: bool) { if emit_close_requested && !state.close_requested_emitted { state.close_requested_emitted = true; @@ -1459,6 +1655,8 @@ impl Dispatch for State { } } +delegate_noop!(State: ignore wp_viewporter::WpViewporter); +delegate_noop!(State: ignore wp_viewport::WpViewport); delegate_noop!(State: ignore wl_compositor::WlCompositor); delegate_noop!(State: ignore wl_surface::WlSurface); delegate_noop!(State: ignore wl_data_device_manager::WlDataDeviceManager); diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index 0bb85df..f58e8bd 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -7,8 +7,8 @@ use cosmic_text::{ }; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use ruin_ui::{ - BoxShadowKind, ClipRegion, Color, DisplayItem, Point, PreparedImage, PreparedText, Rect, - RoundedRect, SceneSnapshot, ShadowRect, UiSize, + BoxShadowKind, ClipRegion, Color, DisplayItem, GlyphInstance, Point, PreparedImage, + PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect, UiSize, }; use tracing::trace; use wgpu::util::DeviceExt; @@ -107,6 +107,14 @@ struct CachedImageTexture { bind_group: wgpu::BindGroup, } +#[derive(Default)] +struct AtlasTextPerfStats { + /// Total glyphs in all text nodes in the scene. + glyphs_total: u32, + /// Glyphs skipped because their line is outside the clip rect. + glyphs_clip_culled: u32, +} + #[allow(dead_code)] struct GlyphAtlas { texture: wgpu::Texture, @@ -740,7 +748,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { let text_prepare_start = std::time::Instant::now(); let mut uploaded_images = Vec::new(); let mut uploaded_texts = Vec::new(); - let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene); + let mut atlas_perf = AtlasTextPerfStats::default(); + let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene, &mut atlas_perf); let mut clip_stack = Vec::new(); let mut active_clip = ActiveClip::default(); for item in &scene.items { @@ -836,6 +845,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { .as_ref() .map_or(0_u32, |text| text.vertex_count), fallback_text_batches = uploaded_texts.len(), + atlas_glyphs_total = atlas_perf.glyphs_total, + atlas_glyphs_clip_culled = atlas_perf.glyphs_clip_culled, text_prepare_ms = text_prepare_ms, render_ms = render_start.elapsed().as_secs_f64() * 1_000.0, "rendered scene" @@ -927,8 +938,9 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { 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; + // Glyph positions are LOCAL (origin-relative); no subtraction needed. + let local_x = glyph.position.x.round() as i32; + let local_y = glyph.position.y.round() as i32; glyphs.push(PreparedGlyphBitmap { rect: PixelRect { left: local_x + image.placement.left, @@ -937,7 +949,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { bottom: local_y - image.placement.top + height, }, cache_key, - color: glyph.color, + color: resolve_glyph_color(glyph.color, text.default_color), }); } glyphs @@ -978,7 +990,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { bottom: physical.y - image.placement.top + height, }, content: image.content, - color: glyph.color_opt.map_or(text.color, color_from_cosmic), + color: glyph.color_opt.map_or(text.default_color, color_from_cosmic), data: image.data.clone(), }); } @@ -986,7 +998,11 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { glyphs } - fn prepare_uploaded_atlas_text(&mut self, scene: &SceneSnapshot) -> Option { + fn prepare_uploaded_atlas_text( + &mut self, + scene: &SceneSnapshot, + perf: &mut AtlasTextPerfStats, + ) -> Option { let mut vertices = Vec::new(); let mut clip_stack = Vec::new(); let mut active_clip = ActiveClip::default(); @@ -1020,22 +1036,34 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { } let clip_rect = logical_clip_rect.map(rect_to_pixel_rect); - for glyph in &text.glyphs { + // Only iterate glyphs whose lines fall within the clip rect. + // For large text nodes (e.g. full Cargo.lock) this reduces + // iteration from O(all_glyphs) to O(visible_glyphs). + let all_count = text.glyphs.len(); + let visible = clip_visible_glyphs(text, logical_clip_rect); + perf.glyphs_total += all_count as u32; + perf.glyphs_clip_culled += (all_count - visible.len()) as u32; + + for glyph in visible { let Some(cache_key) = glyph.cache_key else { continue; }; - let Some(atlas_glyph) = self.ensure_atlas_glyph(cache_key, glyph.color) + let resolved_color = resolve_glyph_color(glyph.color, text.default_color); + let Some(atlas_glyph) = self.ensure_atlas_glyph(cache_key, resolved_color) else { continue; }; + // Glyph positions are LOCAL; add text.origin for absolute screen coords. + let abs_x = (text.origin.x + glyph.position.x).round() as i32; + let abs_y = (text.origin.y + glyph.position.y).round() as i32; 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 + left: abs_x + atlas_glyph.placement_left, + top: abs_y - atlas_glyph.placement_top, + right: abs_x + atlas_glyph.placement_left + atlas_glyph.atlas_rect.width as i32, - bottom: glyph.position.y.round() as i32 - atlas_glyph.placement_top + bottom: abs_y - atlas_glyph.placement_top + atlas_glyph.atlas_rect.height as i32, }; push_glyph_vertices( @@ -1044,7 +1072,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { atlas_glyph.atlas_rect, clip_rect, scene.logical_size, - glyph.color, + resolved_color, active_clip, ); } @@ -1439,6 +1467,12 @@ fn create_glyph_atlas( } } +/// Resolve `Color::SENTINEL` to `default_color`; return other colors unchanged. +#[inline] +fn resolve_glyph_color(color: Color, default_color: Color) -> Color { + if color == Color::SENTINEL { default_color } else { color } +} + fn color_from_cosmic(color: cosmic_text::Color) -> Color { Color::rgba(color.r(), color.g(), color.b(), color.a()) } @@ -2256,6 +2290,40 @@ fn clip_textured_rect( Some((clipped, (clipped_u0, clipped_v0, clipped_u1, clipped_v1))) } +/// Returns the sub-slice of `text.glyphs` whose lines overlap `clip`. +/// +/// Glyphs are stored in text-layout order (top-to-bottom, left-to-right). +/// We binary-search `text.lines` for the first and last visible line, then +/// return only those glyph indices. This turns O(all_glyphs) iteration into +/// O(log(lines) + visible_glyphs) — critical for large text nodes in scroll +/// boxes where only a few lines are on screen at once. +fn clip_visible_glyphs<'a>(text: &'a PreparedText, clip: Option) -> &'a [GlyphInstance] { + let Some(clip) = clip else { + return &text.glyphs; + }; + let lines = &text.lines; + if lines.is_empty() { + return &text.glyphs; + } + + // Convert clip bounds to local (origin-relative) Y coordinates. + let local_top = clip.origin.y - text.origin.y; + let local_bottom = local_top + clip.size.height; + + // First line whose bottom edge reaches or passes the clip top. + let start = lines.partition_point(|l| l.rect.origin.y + l.rect.size.height < local_top); + // First line whose top edge is strictly past the clip bottom. + let end = lines.partition_point(|l| l.rect.origin.y <= local_bottom); + + if start >= end || start >= lines.len() { + return &text.glyphs[0..0]; + } + + let glyph_start = lines[start].glyph_start; + let glyph_end = lines[end - 1].glyph_end; + &text.glyphs[glyph_start..glyph_end.min(text.glyphs.len())] +} + fn text_texture_key(text: &PreparedText) -> TextTextureKey { TextTextureKey { text: text.text.clone(), @@ -2264,13 +2332,14 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey { .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), + color: (text.default_color.r, text.default_color.g, text.default_color.b, text.default_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(), + // Glyph positions are already LOCAL; no subtraction needed. + local_x_bits: glyph.position.x.to_bits(), + local_y_bits: glyph.position.y.to_bits(), advance_bits: glyph.advance.to_bits(), color: (glyph.color.r, glyph.color.g, glyph.color.b, glyph.color.a), cache_key: glyph.cache_key, @@ -2301,19 +2370,13 @@ mod tests { Rect::new(10.0, 10.0, 20.0, 20.0), Color::rgb(0x44, 0x55, 0x66), ); - scene.push_text(PreparedText { - element_id: None, - 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), - selectable: true, - selection_style: TextSelectionStyle::DEFAULT, - lines: Vec::new(), - glyphs: Vec::new(), - }); + scene.push_text(PreparedText::monospace( + "ignored", + Point::new(4.0, 8.0), + 16.0, + 8.0, + Color::rgb(0xFF, 0xFF, 0xFF), + )); let vertices = build_vertices(&scene); assert_eq!(vertices.len(), 12); @@ -2330,34 +2393,22 @@ mod tests { #[test] fn text_texture_key_ignores_absolute_origin() { - let first = PreparedText { - element_id: None, - 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), - selectable: true, - selection_style: TextSelectionStyle::DEFAULT, - lines: Vec::new(), - glyphs: vec![GlyphInstance { - position: Point::new(24.0, 44.0), - advance: 8.0, - color: Color::rgb(0xEE, 0xEE, 0xEE), - cache_key: None, - text_start: 0, - text_end: 1, - }], - }; - 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() - }; + // Two PreparedTexts with the same content but different origins must + // produce the same TextTextureKey (the key stores local glyph offsets). + let first = PreparedText::monospace( + "x", + Point::new(20.0, 30.0), + 16.0, + 8.0, + Color::rgb(0xEE, 0xEE, 0xEE), + ); + let second = PreparedText::monospace( + "x", + Point::new(60.0, 90.0), + 16.0, + 8.0, + Color::rgb(0xEE, 0xEE, 0xEE), + ); assert_eq!(text_texture_key(&first), text_texture_key(&second)); }