Lots of claude-driven performance work.
This commit is contained in:
231
Cargo.lock
generated
231
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<M: Mountable> MountedApp<M> {
|
||||
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::<InteractionTree>));
|
||||
let bindings = Rc::new(RefCell::new(EventBindings::default()));
|
||||
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
|
||||
@@ -169,6 +171,7 @@ impl<M: Mountable> MountedApp<M> {
|
||||
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<M: Mountable> MountedApp<M> {
|
||||
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<M: Mountable> MountedApp<M> {
|
||||
*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<M: Mountable> MountedApp<M> {
|
||||
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() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
94
lib/ui/benches/layout_bench.rs
Normal file
94
lib/ui/benches/layout_bench.rs
Normal file
@@ -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);
|
||||
@@ -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<Rect>,
|
||||
) -> 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<Vec<HitTarget>> {
|
||||
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<Rect>,
|
||||
) -> Vec<LayoutNode> {
|
||||
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<LayoutCacheKey, CachedLayout>,
|
||||
/// 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<DisplayItem>,
|
||||
}
|
||||
|
||||
#[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<ElementId>) -> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CacheKey>,
|
||||
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<TextLayoutData>` 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<ElementId>,
|
||||
@@ -153,22 +191,69 @@ pub struct PreparedText {
|
||||
pub bounds: Option<UiSize>,
|
||||
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<PreparedTextLine>,
|
||||
pub glyphs: Vec<GlyphInstance>,
|
||||
layout: Arc<TextLayoutData>,
|
||||
}
|
||||
|
||||
/// 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<ElementId>,
|
||||
pub resource: ImageResource,
|
||||
pub rect: Rect,
|
||||
pub uv_rect: (f32, f32, f32, f32),
|
||||
pub struct TextLayoutData {
|
||||
pub lines: Vec<PreparedTextLine>,
|
||||
pub glyphs: Vec<GlyphInstance>,
|
||||
/// 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<ElementId>,
|
||||
text: String,
|
||||
origin: Point,
|
||||
bounds: Option<UiSize>,
|
||||
font_size: f32,
|
||||
line_height: f32,
|
||||
default_color: Color,
|
||||
selectable: bool,
|
||||
selection_style: TextSelectionStyle,
|
||||
layout: Arc<TextLayoutData>,
|
||||
) -> 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<String>,
|
||||
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(),
|
||||
}],
|
||||
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<usize> {
|
||||
@@ -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<Rect> {
|
||||
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<TextLayoutData>` 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<ElementId>,
|
||||
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<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.x.to_bits().hash(state);
|
||||
self.y.to_bits().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hash for UiSize {
|
||||
fn hash<H: std::hash::Hasher>(&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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TextLayoutData>`.
|
||||
/// No external dependencies — evicts the least-recently-used entry on insert
|
||||
/// when at capacity.
|
||||
struct LruTextCache {
|
||||
map: HashMap<u64, (u64, Arc<TextLayoutData>)>, // 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<Arc<TextLayoutData>> {
|
||||
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<TextLayoutData>) {
|
||||
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<u64, TextLayout>,
|
||||
layout_cache: LruTextCache,
|
||||
frame_stats: TextFrameStats,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct TextLayout {
|
||||
lines: Vec<PreparedTextLine>,
|
||||
glyphs: Vec<GlyphInstance>,
|
||||
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<TextLayoutData>` 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<f32>,
|
||||
height: Option<f32>,
|
||||
) -> 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<TextLayoutData>` 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<f32>,
|
||||
height: Option<f32>,
|
||||
) -> TextLayout {
|
||||
) -> Arc<TextLayoutData> {
|
||||
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<usize> {
|
||||
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);
|
||||
// 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<f32>,
|
||||
height: Option<f32>,
|
||||
) -> 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<f32>) -> Option<u32> {
|
||||
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<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.highlight_color.hash(state);
|
||||
self.text_color.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hash for TextStyle {
|
||||
fn hash<H: std::hash::Hasher>(&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(
|
||||
|
||||
@@ -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<H: Hasher>(&self, state: &mut H) {
|
||||
(*self as u8).hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Edges {
|
||||
fn hash<H: Hasher>(&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<H: Hasher>(&self, state: &mut H) {
|
||||
self.width.to_bits().hash(state);
|
||||
self.color.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for CornerRadius {
|
||||
fn hash<H: Hasher>(&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<H: Hasher>(&self, state: &mut H) {
|
||||
(*self as u8).hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for BoxShadow {
|
||||
fn hash<H: Hasher>(&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<H: Hasher>(&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<H: Hasher>(&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<H: Hasher>(&self, state: &mut H) {
|
||||
self.spans.hash(state);
|
||||
self.style.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for ImageNode {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.resource.hash(state);
|
||||
self.fit.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for ScrollBoxNode {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.offset_y.to_bits().hash(state);
|
||||
self.scrollbar.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for ElementContent {
|
||||
fn hash<H: Hasher>(&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<H: Hasher>(&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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UiSize>,
|
||||
event_tx: mpsc::UnboundedSender<PlatformEvent>,
|
||||
internal_tx: mpsc::UnboundedSender<InternalBackendEvent>,
|
||||
/// Pending keyboard-repeat timeout scheduled via [`ruin_runtime::set_timeout`].
|
||||
keyboard_repeat_timer: Option<TimeoutHandle>,
|
||||
/// 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<Instant>,
|
||||
/// 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<wl_data_device::WlDataDevice>,
|
||||
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
|
||||
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
|
||||
/// 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<wp_viewport::WpViewport>,
|
||||
primary_selection_manager:
|
||||
Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>,
|
||||
primary_selection_device: Option<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1>,
|
||||
@@ -300,6 +316,7 @@ impl WaylandWindow {
|
||||
},
|
||||
);
|
||||
let cursor_shape_manager = globals.bind(&qh, 1..=2, ()).ok();
|
||||
let viewporter: Option<wp_viewporter::WpViewporter> = 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<dyn Error>> {
|
||||
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<RefCell<WaylandBackendState>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<InternalBackendEvent>,
|
||||
) -> WindowWorkerHandle {
|
||||
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<WindowWorkerCommand>();
|
||||
|
||||
// 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,35 +1260,42 @@ 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)
|
||||
});
|
||||
},
|
||||
|| {},
|
||||
);
|
||||
WindowWorkerHandle {
|
||||
command_tx,
|
||||
_worker: worker,
|
||||
}
|
||||
}
|
||||
async move {
|
||||
loop {
|
||||
// Consume all pending wakeup bytes before doing work.
|
||||
drain_wakeup(pipe_read_fd);
|
||||
|
||||
fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
let mut reschedule = true;
|
||||
let mut keep_running = 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() {
|
||||
|
||||
// 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);
|
||||
reschedule = false;
|
||||
} else {
|
||||
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,
|
||||
@@ -1226,11 +1318,17 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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",
|
||||
@@ -1239,19 +1337,35 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
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);
|
||||
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(|scene| scene.logical_size != current_viewport)
|
||||
.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;
|
||||
@@ -1261,10 +1375,13 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
}
|
||||
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 {
|
||||
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(),
|
||||
@@ -1276,25 +1393,48 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
"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.window.arm_frame_callback();
|
||||
if state_ref.renderer.render(&preview_scene).is_ok() {
|
||||
state_ref.renderer.render(&preview_scene).is_ok()
|
||||
};
|
||||
if preview_ok {
|
||||
match state_ref.window.flush_connection() {
|
||||
Ok(()) => {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
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,
|
||||
"presented scaled preview scene for current viewport"
|
||||
preview_lag_us,
|
||||
compositor_scaled = viewport_set,
|
||||
"presented preview; waiting for correct scene"
|
||||
);
|
||||
let _ = state_ref.event_tx.send(PlatformEvent::FramePresented {
|
||||
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,
|
||||
@@ -1305,39 +1445,59 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
error = %error,
|
||||
"failed to flush presented preview scene"
|
||||
"failed to flush preview"
|
||||
);
|
||||
state_ref.window.clear_frame_callback();
|
||||
state_ref.window.request_redraw();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state_ref.window.clear_frame_callback();
|
||||
}
|
||||
} 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(()) => {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
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,
|
||||
"presented matching scene"
|
||||
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 {
|
||||
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!(
|
||||
@@ -1353,7 +1513,9 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
}
|
||||
Err(RenderError::Lost | RenderError::Outdated) => {
|
||||
state_ref.window.clear_frame_callback();
|
||||
state_ref.renderer.resize(frame.width, frame.height);
|
||||
state_ref
|
||||
.renderer
|
||||
.resize(frame.width, frame.height);
|
||||
state_ref.window.request_redraw();
|
||||
}
|
||||
Err(
|
||||
@@ -1373,11 +1535,45 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
{
|
||||
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 reschedule {
|
||||
queue_task(move || pump_window_worker(state));
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|| {},
|
||||
);
|
||||
WindowWorkerHandle {
|
||||
command_tx,
|
||||
_worker: worker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1459,6 +1655,8 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> 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);
|
||||
|
||||
@@ -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<f32> {
|
||||
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<f32> {
|
||||
.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<f32> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
glyphs
|
||||
}
|
||||
|
||||
fn prepare_uploaded_atlas_text(&mut self, scene: &SceneSnapshot) -> Option<UploadedAtlasText> {
|
||||
fn prepare_uploaded_atlas_text(
|
||||
&mut self,
|
||||
scene: &SceneSnapshot,
|
||||
perf: &mut AtlasTextPerfStats,
|
||||
) -> Option<UploadedAtlasText> {
|
||||
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<f32> {
|
||||
}
|
||||
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<f32> {
|
||||
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<Rect>) -> &'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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user