Lots of claude-driven performance work.

This commit is contained in:
2026-03-23 00:24:55 -04:00
parent 497af9151d
commit e90f09bf3e
14 changed files with 1820 additions and 394 deletions

231
Cargo.lock generated
View File

@@ -50,6 +50,18 @@ dependencies = [
"libc", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -241,6 +253,12 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.57" version = "1.2.57"
@@ -265,6 +283,58 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.13.1" version = "0.13.1"
@@ -333,6 +403,42 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.6" version = "0.8.6"
@@ -728,6 +834,12 @@ dependencies = [
"foldhash 0.2.0", "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]] [[package]]
name = "hexf-parse" name = "hexf-parse"
version = "0.2.1" version = "0.2.1"
@@ -855,6 +967,26 @@ dependencies = [
"syn", "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]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@@ -1228,6 +1360,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]] [[package]]
name = "ordered-float" name = "ordered-float"
version = "5.1.0" version = "5.1.0"
@@ -1290,6 +1428,34 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "png" name = "png"
version = "0.18.1" version = "0.18.1"
@@ -1469,7 +1635,7 @@ dependencies = [
"built", "built",
"cfg-if", "cfg-if",
"interpolate_name", "interpolate_name",
"itertools", "itertools 0.14.0",
"libc", "libc",
"libfuzzer-sys", "libfuzzer-sys",
"log", "log",
@@ -1571,6 +1737,18 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@@ -1645,6 +1823,8 @@ dependencies = [
"ruin_reactivity", "ruin_reactivity",
"ruin_ui", "ruin_ui",
"ruin_ui_platform_wayland", "ruin_ui_platform_wayland",
"tracing",
"tracing-subscriber",
] ]
[[package]] [[package]]
@@ -1661,6 +1841,7 @@ name = "ruin_ui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cosmic-text", "cosmic-text",
"criterion",
"fontconfig", "fontconfig",
"image", "image",
"ruin-runtime", "ruin-runtime",
@@ -1762,6 +1943,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 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]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@@ -1810,6 +2000,19 @@ dependencies = [
"syn", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -1991,6 +2194,16 @@ dependencies = [
"zune-jpeg", "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]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.11.0" version = "1.11.0"
@@ -2129,6 +2342,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -2656,6 +2879,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.1" version = "0.5.1"

View File

@@ -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_app_proc_macros = { package = "ruin-app-proc-macros", path = "../ruin_app_proc_macros" }
ruin_ui = { path = "../ui" } ruin_ui = { path = "../ui" }
ruin_ui_platform_wayland = { path = "../ui_platform_wayland" } 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]] [[example]]
name = "00_bootstrap_and_counter_raw" name = "00_bootstrap_and_counter_raw"

View File

@@ -1,7 +1,24 @@
use ruin_app::prelude::*; 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] #[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> { async fn main() -> ruin_app::Result<()> {
install_tracing();
App::new() App::new()
.window( .window(
Window::new() Window::new()

View File

@@ -7,8 +7,38 @@ use ruin_app::prelude::*;
const SNAPSHOT_PATH: &str = "target/ruin-example05-manifest-snapshot.toml"; 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] #[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> { async fn main() -> ruin_app::Result<()> {
install_tracing();
let demo_server_addr = spawn_demo_server()?; let demo_server_addr = spawn_demo_server()?;
App::new() App::new()
.window( .window(

View File

@@ -11,6 +11,7 @@ use std::collections::HashMap;
use std::error::Error; use std::error::Error;
use std::future::Future; use std::future::Future;
use std::iter; use std::iter;
use std::time::Instant;
use std::marker::PhantomData; use std::marker::PhantomData;
use std::rc::Rc; use std::rc::Rc;
@@ -18,11 +19,11 @@ use ruin_reactivity::effect;
use ruin_runtime::queue_future; use ruin_runtime::queue_future;
use ruin_ui::{ use ruin_ui::{
Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree, Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree,
KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, PointerButton, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent,
PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent,
RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan, TextSpanWeight, TextStyle,
TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate, TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate,
layout_snapshot_with_text_system, layout_snapshot_with_cache,
}; };
use ruin_ui_platform_wayland::start_wayland_ui; 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 viewport = ruin_reactivity::cell(initial_viewport);
let scene_version = StdCell::new(0_u64); let scene_version = StdCell::new(0_u64);
let text_system = Rc::new(RefCell::new(TextSystem::new())); 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 interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
let bindings = Rc::new(RefCell::new(EventBindings::default())); let bindings = Rc::new(RefCell::new(EventBindings::default()));
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new())); let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
@@ -169,6 +171,7 @@ impl<M: Mountable> MountedApp<M> {
let window = window.clone(); let window = window.clone();
let viewport = viewport.clone(); let viewport = viewport.clone();
let text_system = Rc::clone(&text_system); let text_system = Rc::clone(&text_system);
let layout_cache = Rc::clone(&layout_cache);
let interaction_tree = Rc::clone(&interaction_tree); let interaction_tree = Rc::clone(&interaction_tree);
let bindings = Rc::clone(&bindings); let bindings = Rc::clone(&bindings);
let shortcuts = Rc::clone(&shortcuts); let shortcuts = Rc::clone(&shortcuts);
@@ -182,6 +185,8 @@ impl<M: Mountable> MountedApp<M> {
scene_version.set(version); scene_version.set(version);
let _ = text_selection.version.get(); let _ = text_selection.version.get();
let t_effect = Instant::now();
let render_output = render_with_context(Rc::clone(&render_state), || root.render()); let render_output = render_with_context(Rc::clone(&render_state), || root.render());
if render_output.side_effects.window_title != *current_title.borrow() { 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(); *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 { let LayoutSnapshot {
mut scene, mut scene,
interaction_tree: next_interaction_tree, interaction_tree: next_interaction_tree,
} = layout_snapshot_with_text_system( } = layout_snapshot_with_cache(
version, version,
viewport, viewport,
render_output.view.element(), render_output.view.element(),
&mut text_system.borrow_mut(), &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()); apply_text_selection_overlay(&mut scene, *text_selection.selection.borrow());
@@ -225,6 +246,12 @@ impl<M: Mountable> MountedApp<M> {
window_id, window_id,
configuration, configuration,
} if window_id == window.id() => { } 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); let _ = viewport.set(configuration.actual_inner_size);
} }
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => { PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {

View File

@@ -13,3 +13,8 @@ fontconfig = { version = "0.10", features = ["dlopen"] }
[dev-dependencies] [dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] } 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

View 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);

View File

@@ -1,8 +1,10 @@
use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
use crate::ImageFit; use crate::ImageFit;
use crate::scene::{ 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::text::TextSystem;
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style}; 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 { pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
let mut text_system = TextSystem::new(); let mut text_system = TextSystem::new();
layout_snapshot_with_text_system(version, logical_size, root, &mut text_system) 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, logical_size: UiSize,
root: &Element, root: &Element,
text_system: &mut TextSystem, 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 { ) -> LayoutSnapshot {
let layout_started = Instant::now(); let layout_started = Instant::now();
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG); 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, &mut scene,
text_system, text_system,
&mut perf_stats, &mut perf_stats,
layout_cache,
None,
); );
let text_stats = text_system.take_frame_stats(); let text_stats = text_system.take_frame_stats();
if perf_stats.enabled { if perf_stats.enabled {
@@ -170,6 +217,10 @@ pub fn layout_snapshot_with_text_system(
intrinsic_ms = perf_stats.intrinsic_ms, intrinsic_ms = perf_stats.intrinsic_ms,
text_prepare_calls = perf_stats.text_prepare_calls, text_prepare_calls = perf_stats.text_prepare_calls,
text_prepare_ms = perf_stats.text_prepare_ms, 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_requests = text_stats.requests,
text_cache_hits = text_stats.cache_hits, text_cache_hits = text_stats.cache_hits,
text_cache_misses = text_stats.cache_misses, text_cache_misses = text_stats.cache_misses,
@@ -197,8 +248,54 @@ fn layout_element(
scene: &mut SceneSnapshot, scene: &mut SceneSnapshot,
text_system: &mut TextSystem, text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats, 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 { ) -> LayoutNode {
perf_stats.nodes += 1; 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(|| { let cursor = element.style.cursor.unwrap_or_else(|| {
if element.text_node().is_some() { if element.text_node().is_some() {
CursorIcon::Text CursorIcon::Text
@@ -222,6 +319,7 @@ fn layout_element(
}; };
if rect.size.width <= 0.0 || rect.size.height <= 0.0 { if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
cache_layout(layout_cache, cache_key, &interaction, &[], rect.origin);
return interaction; return interaction;
} }
@@ -270,6 +368,7 @@ fn layout_element(
if pushed_clip { if pushed_clip {
scene.pop_clip(); scene.pop_clip();
} }
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction; return interaction;
} }
@@ -283,6 +382,7 @@ fn layout_element(
if pushed_clip { if pushed_clip {
scene.pop_clip(); scene.pop_clip();
} }
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction; return interaction;
} }
@@ -311,6 +411,7 @@ fn layout_element(
), ),
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
); );
let provisional_content_height = content_size.height.max(viewport_rect.size.height); let provisional_content_height = content_size.height.max(viewport_rect.size.height);
let requested_offset_y = scroll_box.offset_y.max(0.0); let requested_offset_y = scroll_box.offset_y.max(0.0);
@@ -333,6 +434,8 @@ fn layout_element(
scene, scene,
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
Some(viewport_rect),
); );
scene.pop_clip(); scene.pop_clip();
} }
@@ -367,6 +470,8 @@ fn layout_element(
scene, scene,
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
Some(viewport_rect),
); );
scene.pop_clip(); scene.pop_clip();
} else { } else {
@@ -403,6 +508,7 @@ fn layout_element(
if pushed_clip { if pushed_clip {
scene.pop_clip(); scene.pop_clip();
} }
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction; return interaction;
} }
@@ -412,6 +518,7 @@ fn layout_element(
if pushed_clip { if pushed_clip {
scene.pop_clip(); scene.pop_clip();
} }
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction; return interaction;
} }
@@ -420,6 +527,7 @@ fn layout_element(
if pushed_clip { if pushed_clip {
scene.pop_clip(); scene.pop_clip();
} }
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction; return interaction;
} }
interaction.children = layout_container_children( interaction.children = layout_container_children(
@@ -429,15 +537,33 @@ fn layout_element(
scene, scene,
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
clip_rect,
); );
if pushed_clip { if pushed_clip {
scene.pop_clip(); scene.pop_clip();
} }
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
interaction 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>> { fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<HitTarget>> {
if !point_hits_node_shape(node, point) { if !point_hits_node_shape(node, point) {
return None; return None;
@@ -616,6 +742,8 @@ fn layout_container_children(
scene: &mut SceneSnapshot, scene: &mut SceneSnapshot,
text_system: &mut TextSystem, text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats, perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
clip_rect: Option<Rect>,
) -> Vec<LayoutNode> { ) -> Vec<LayoutNode> {
if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 { if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
return Vec::new(); return Vec::new();
@@ -655,6 +783,7 @@ fn layout_container_children(
available_main, available_main,
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
); );
if let Some(intrinsic_started) = intrinsic_started { if let Some(intrinsic_started) = intrinsic_started {
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0; perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
@@ -701,6 +830,8 @@ fn layout_container_children(
scene, scene,
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
clip_rect,
)); ));
cursor += child_main.max(0.0) + element.style.gap; cursor += child_main.max(0.0) + element.style.gap;
} }
@@ -714,6 +845,7 @@ fn intrinsic_main_size(
available_main: f32, available_main: f32,
text_system: &mut TextSystem, text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats, perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> f32 { ) -> f32 {
if let Some(text) = child.text_node() { if let Some(text) = child.text_node() {
let constraints = match direction { 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)), FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)),
}; };
main_axis_size( main_axis_size(
intrinsic_size(child, available_size, text_system, perf_stats), intrinsic_size(child, available_size, text_system, perf_stats, layout_cache),
direction, direction,
) )
} }
@@ -746,6 +878,7 @@ fn intrinsic_container_content_size(
content_size: UiSize, content_size: UiSize,
text_system: &mut TextSystem, text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats, perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> UiSize { ) -> UiSize {
if element.children.is_empty() { if element.children.is_empty() {
return UiSize::new(0.0, 0.0); return UiSize::new(0.0, 0.0);
@@ -766,6 +899,7 @@ fn intrinsic_container_content_size(
), ),
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
); );
width = width.max(child.style.width.unwrap_or(child_size.width)); width = width.max(child.style.width.unwrap_or(child_size.width));
if !skip_main { if !skip_main {
@@ -795,6 +929,7 @@ fn intrinsic_container_content_size(
), ),
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
); );
let child_main = child.style.width.unwrap_or(child_size.width); let child_main = child.style.width.unwrap_or(child_size.width);
fixed_main += child_main; fixed_main += child_main;
@@ -818,6 +953,7 @@ fn intrinsic_container_content_size(
), ),
text_system, text_system,
perf_stats, perf_stats,
layout_cache,
); );
if !skip_main { if !skip_main {
width += child_main; width += child_main;
@@ -834,9 +970,35 @@ fn intrinsic_size(
available_size: UiSize, available_size: UiSize,
text_system: &mut TextSystem, text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats, perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> UiSize { ) -> UiSize {
perf_stats.intrinsic_size_calls += 1; 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 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() { if let Some(text) = element.text_node() {
let measured = text_system.measure_spans( let measured = text_system.measure_spans(
&text.spans, &text.spans,
@@ -886,7 +1048,7 @@ fn intrinsic_size(
} }
let intrinsic_content = 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( UiSize::new(
explicit_width 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)] #[derive(Debug, Default)]
struct LayoutPerfStats { struct LayoutPerfStats {
enabled: bool, enabled: bool,
@@ -909,6 +1108,10 @@ struct LayoutPerfStats {
intrinsic_ms: f64, intrinsic_ms: f64,
text_prepare_calls: usize, text_prepare_calls: usize,
text_prepare_ms: f64, text_prepare_ms: f64,
viewport_culled: usize,
layout_cache_hits: usize,
layout_cache_misses: usize,
intrinsic_cache_hits: usize,
} }
impl LayoutPerfStats { impl LayoutPerfStats {
@@ -926,10 +1129,23 @@ impl LayoutPerfStats {
intrinsic_ms: 0.0, intrinsic_ms: 0.0,
text_prepare_calls: 0, text_prepare_calls: 0,
text_prepare_ms: 0.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 { fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
let source_size = image.resource.size(); let source_size = image.resource.size();
let source_aspect = if source_size.height > 0.0 { let source_aspect = if source_size.height > 0.0 {
@@ -1321,10 +1537,11 @@ fn child_rect(
#[cfg(test)] #[cfg(test)]
mod tests { 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::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::{TextStyle, TextWrap}; use crate::text::{TextStyle, TextWrap};
use crate::tree::{Edges, Element, ElementId}; use crate::tree::{Edges, Element, ElementId, FlexDirection};
use crate::text::TextSystem;
#[test] #[test]
fn row_layout_apportions_fixed_and_flex_children() { fn row_layout_apportions_fixed_and_flex_children() {
@@ -1916,4 +2133,92 @@ mod tests {
.is_none() .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}");
}
} }

View File

@@ -8,6 +8,7 @@
pub(crate) mod trace_targets { pub(crate) mod trace_targets {
pub const PLATFORM: &str = "ruin_ui::platform"; pub const PLATFORM: &str = "ruin_ui::platform";
pub const SCENE: &str = "ruin_ui::scene"; pub const SCENE: &str = "ruin_ui::scene";
pub const TEXT_PERF: &str = "ruin_ui::text_perf";
} }
mod image; mod image;
@@ -28,8 +29,8 @@ pub use interaction::{
}; };
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers}; pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
pub use layout::{ pub use layout::{
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics, HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
TextHitTarget, layout_snapshot, layout_snapshot_with_text_system, TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system,
}; };
pub use layout::{layout_scene, layout_scene_with_text_system}; pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{ pub use platform::{
@@ -39,7 +40,8 @@ pub use platform::{
pub use runtime::{EventStreamClosed, UiRuntime, WindowController}; pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
pub use scene::{ pub use scene::{
ClipRegion, Color, DisplayItem, GlyphInstance, Point, PreparedImage, PreparedText, 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::{ pub use text::{
TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight, TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight,

View File

@@ -1,6 +1,7 @@
//! Renderer-oriented scene snapshot types. //! Renderer-oriented scene snapshot types.
use std::ops::Range; use std::ops::{Deref, Range};
use std::sync::Arc;
use cosmic_text::CacheKey; use cosmic_text::CacheKey;
use tracing::debug; use tracing::debug;
@@ -56,6 +57,13 @@ impl Rect {
&& point.x < self.origin.x + self.size.width && point.x < self.origin.x + self.size.width
&& point.y < self.origin.y + self.size.height && 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)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -67,6 +75,11 @@ pub struct Color {
} }
impl 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 { pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a } Self { r, g, b, a }
} }
@@ -74,6 +87,12 @@ impl Color {
pub const fn rgb(r: u8, g: u8, b: u8) -> Self { pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::rgba(r, g, b, 255) 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)] #[derive(Clone, Copy, Debug, PartialEq)]
@@ -130,6 +149,10 @@ pub struct ClipRegion {
pub struct GlyphInstance { pub struct GlyphInstance {
pub position: Point, pub position: Point,
pub advance: f32, 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 color: Color,
pub cache_key: Option<CacheKey>, pub cache_key: Option<CacheKey>,
pub text_start: usize, pub text_start: usize,
@@ -145,6 +168,21 @@ pub struct PreparedTextLine {
pub glyph_end: usize, 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)] #[derive(Clone, Debug, PartialEq)]
pub struct PreparedText { pub struct PreparedText {
pub element_id: Option<ElementId>, pub element_id: Option<ElementId>,
@@ -153,22 +191,69 @@ pub struct PreparedText {
pub bounds: Option<UiSize>, pub bounds: Option<UiSize>,
pub font_size: f32, pub font_size: f32,
pub line_height: 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 selectable: bool,
pub selection_style: TextSelectionStyle, pub selection_style: TextSelectionStyle,
pub lines: Vec<PreparedTextLine>, layout: Arc<TextLayoutData>,
pub glyphs: Vec<GlyphInstance>,
} }
/// 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)] #[derive(Clone, Debug, PartialEq)]
pub struct PreparedImage { pub struct TextLayoutData {
pub element_id: Option<ElementId>, pub lines: Vec<PreparedTextLine>,
pub resource: ImageResource, pub glyphs: Vec<GlyphInstance>,
pub rect: Rect, /// Measured (unclamped) size of the laid-out text.
pub uv_rect: (f32, f32, f32, f32), pub size: UiSize,
}
impl Deref for PreparedText {
type Target = TextLayoutData;
fn deref(&self) -> &TextLayoutData {
&self.layout
}
} }
impl PreparedText { 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( pub fn monospace(
text: impl Into<String>, text: impl Into<String>,
origin: Point, origin: Point,
@@ -177,21 +262,31 @@ impl PreparedText {
color: Color, color: Color,
) -> Self { ) -> Self {
let text = text.into(); 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()); let mut glyphs = Vec::with_capacity(text.chars().count());
for (text_start, ch) in text.char_indices() { for (text_start, ch) in text.char_indices() {
let text_end = text_start + ch.len_utf8(); 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 { glyphs.push(GlyphInstance {
position: Point::new(x, origin.y), position: Point::new(local_x, 0.0),
advance, advance,
color, color,
cache_key: None, cache_key: None,
text_start, text_start,
text_end, 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 { Self {
element_id: None, element_id: None,
text, text,
@@ -199,25 +294,34 @@ impl PreparedText {
bounds: None, bounds: None,
font_size, font_size,
line_height: font_size, line_height: font_size,
color, default_color: color,
selectable: true, selectable: true,
selection_style: TextSelectionStyle::DEFAULT, selection_style: TextSelectionStyle::DEFAULT,
lines: vec![PreparedTextLine { layout: Arc::new(TextLayoutData {
rect: Rect::new(origin.x, origin.y, x - origin.x, font_size), lines: vec![line],
text_start: 0,
text_end: glyphs.last().map_or(0, |glyph| glyph.text_end),
glyph_start: 0,
glyph_end: glyphs.len(),
}],
glyphs, 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 { 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; 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> { 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) { 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( rects.push(Rect::new(
left, self.origin.x + left,
line.rect.origin.y, self.origin.y + line.rect.origin.y,
(right - left).max(0.0), (right - left).max(0.0),
line.rect.size.height, line.rect.size.height,
)); ));
@@ -266,10 +371,11 @@ impl PreparedText {
pub fn caret_rect(&self, offset: usize, width: f32) -> Option<Rect> { pub fn caret_rect(&self, offset: usize, width: f32) -> Option<Rect> {
let width = width.max(0.0); let width = width.max(0.0);
let line = self.line_for_offset(offset)?; 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( Some(Rect::new(
x, self.origin.x + local_x,
line.rect.origin.y, self.origin.y + line.rect.origin.y,
width, width,
line.rect.size.height, line.rect.size.height,
)) ))
@@ -346,6 +452,8 @@ impl PreparedText {
start..end 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) { pub fn apply_selected_text_color(&mut self, start: usize, end: usize) {
let Some(selected_color) = self.selection_style.text_color else { let Some(selected_color) = self.selection_style.text_color else {
return; return;
@@ -354,7 +462,7 @@ impl PreparedText {
if range.is_empty() { if range.is_empty() {
return; 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 { if glyph.text_end > range.start && glyph.text_start < range.end {
glyph.color = selected_color; 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)] #[derive(Clone, Debug, PartialEq)]
pub enum DisplayItem { pub enum DisplayItem {
Quad(Quad), Quad(Quad),
@@ -503,6 +619,42 @@ pub enum DisplayItem {
LayerEnd, 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)] #[derive(Clone, Debug, PartialEq)]
pub struct SceneSnapshot { pub struct SceneSnapshot {
pub version: SceneVersion, 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)] #[cfg(test)]
mod tests { mod tests {
use super::{Color, Point, PreparedText, Rect}; use super::{Color, Point, PreparedText, Rect};
@@ -669,6 +838,9 @@ mod tests {
#[test] #[test]
fn prepared_text_vertical_offset_moves_between_lines() { fn prepared_text_vertical_offset_moves_between_lines() {
use std::sync::Arc;
use super::{PreparedTextLine, TextLayoutData};
let mut text = PreparedText::monospace( let mut text = PreparedText::monospace(
"abcdwxyz", "abcdwxyz",
Point::new(10.0, 20.0), Point::new(10.0, 20.0),
@@ -676,28 +848,32 @@ mod tests {
8.0, 8.0,
Color::rgb(0xFF, 0xFF, 0xFF), Color::rgb(0xFF, 0xFF, 0xFF),
); );
text.lines = vec![ // Lines and glyphs use LOCAL (origin-relative) coordinates.
super::PreparedTextLine { let lines = vec![
rect: Rect::new(10.0, 20.0, 32.0, 16.0), PreparedTextLine {
rect: Rect::new(0.0, 0.0, 32.0, 16.0),
text_start: 0, text_start: 0,
text_end: 4, text_end: 4,
glyph_start: 0, glyph_start: 0,
glyph_end: 4, glyph_end: 4,
}, },
super::PreparedTextLine { PreparedTextLine {
rect: Rect::new(10.0, 36.0, 32.0, 16.0), rect: Rect::new(0.0, 16.0, 32.0, 16.0),
text_start: 4, text_start: 4,
text_end: 8, text_end: 8,
glyph_start: 4, glyph_start: 4,
glyph_end: 8, 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 { if index >= 4 {
glyph.position.y = 36.0; glyph.position.y = 16.0;
glyph.position.x = 10.0 + ((index - 4) as f32 * 8.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(2, 1), Some(6));
assert_eq!(text.vertical_offset(6, -1), Some(2)); assert_eq!(text.vertical_offset(6, -1), Some(2));
@@ -714,12 +890,14 @@ mod tests {
assert_eq!(text.lines.len(), 3); assert_eq!(text.lines.len(), 3);
let target_line = &text.lines[1]; let target_line = &text.lines[1];
let y = target_line.rect.origin.y + target_line.rect.size.height * 0.5; // Lines store LOCAL coords; add text.origin to get absolute window coords for the query.
let start = text.byte_offset_for_position(Point::new(target_line.rect.origin.x, y)); let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5;
let end = text.byte_offset_for_position(Point::new(target_line.rect.origin.x + 16.0, y)); 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); let rects = text.selection_rects(start, end);
assert_eq!(rects.len(), 1); 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);
} }
} }

View File

@@ -1,6 +1,7 @@
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap};
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::mem; use std::mem;
use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use cosmic_text::{ use cosmic_text::{
@@ -9,7 +10,8 @@ use cosmic_text::{
}; };
use fontconfig::Fontconfig; 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)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TextAlign { 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 { pub struct TextSystem {
font_system: FontSystem, font_system: FontSystem,
family_resolver: FontFamilyResolver, family_resolver: FontFamilyResolver,
layout_cache: HashMap<u64, TextLayout>, layout_cache: LruTextCache,
frame_stats: TextFrameStats, frame_stats: TextFrameStats,
} }
#[derive(Clone, Debug, PartialEq)]
struct TextLayout {
lines: Vec<PreparedTextLine>,
glyphs: Vec<GlyphInstance>,
size: UiSize,
}
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub(crate) struct TextFrameStats { pub(crate) struct TextFrameStats {
pub requests: u32, pub requests: u32,
@@ -246,7 +298,7 @@ impl TextSystem {
Self { Self {
font_system, font_system,
family_resolver, family_resolver,
layout_cache: HashMap::new(), layout_cache: LruTextCache::new(1024),
frame_stats: TextFrameStats::default(), frame_stats: TextFrameStats::default(),
} }
} }
@@ -269,6 +321,17 @@ impl TextSystem {
self.prepare_spans(&spans, origin, style, style.bounds) 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( pub fn prepare_spans(
&mut self, &mut self,
spans: &[TextSpan], spans: &[TextSpan],
@@ -278,54 +341,26 @@ impl TextSystem {
) -> PreparedText { ) -> PreparedText {
let bounds = bounds.or(style.bounds); let bounds = bounds.or(style.bounds);
let text = combined_text(spans); 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, spans,
style, style,
bounds.map(|bounds| bounds.width), bounds.map(|b| b.width),
bounds.map(|bounds| bounds.height), 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 { PreparedText::from_layout(
element_id: None, None,
text, text,
origin, origin,
bounds, bounds,
font_size: style.font_size, style.font_size,
line_height: style.line_height, style.line_height,
color: style.color, style.color,
selectable: style.selectable, style.selectable,
selection_style: style.selection_style, style.selection_style,
lines, layout_data,
glyphs, )
}
} }
pub fn measure( pub fn measure(
@@ -346,26 +381,40 @@ impl TextSystem {
width: Option<f32>, width: Option<f32>,
height: Option<f32>, height: Option<f32>,
) -> UiSize { ) -> 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( fn layout(
&mut self, &mut self,
spans: &[TextSpan], spans: &[TextSpan],
style: &TextStyle, style: &TextStyle,
width: Option<f32>, width: Option<f32>,
height: Option<f32>, height: Option<f32>,
) -> TextLayout { ) -> Arc<TextLayoutData> {
self.frame_stats.requests = self.frame_stats.requests.saturating_add(1); self.frame_stats.requests = self.frame_stats.requests.saturating_add(1);
let cache_key = layout_cache_key(spans, style, width, height); 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.cache_hits = self.frame_stats.cache_hits.saturating_add(1);
self.frame_stats.output_glyphs = self self.frame_stats.output_glyphs = self
.frame_stats .frame_stats
.output_glyphs .output_glyphs
.saturating_add(layout.glyphs.len() as u32); .saturating_add(cached.glyphs.len() as u32);
return clamp_text_layout(layout.clone(), width, height); return cached;
} }
self.frame_stats.cache_misses = self.frame_stats.cache_misses.saturating_add(1); self.frame_stats.cache_misses = self.frame_stats.cache_misses.saturating_add(1);
let miss_started = Instant::now(); let miss_started = Instant::now();
@@ -391,7 +440,11 @@ impl TextSystem {
{ {
let mut borrowed = buffer.borrow_with(&mut self.font_system); let mut borrowed = buffer.borrow_with(&mut self.font_system);
borrowed.set_wrap(style.wrap.to_cosmic()); 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()); let default_attrs = default_attrs_for_style(style, default_family.as_deref());
if uses_plain_text_fast_path { if uses_plain_text_fast_path {
borrowed.set_text( borrowed.set_text(
@@ -438,7 +491,13 @@ impl TextSystem {
GlyphInstance { GlyphInstance {
position: Point::new(physical.x as f32, physical.y as f32), position: Point::new(physical.x as f32, physical.y as f32),
advance: glyph.w, 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), cache_key: Some(physical.cache_key),
text_start: line_offset + glyph.start, text_start: line_offset + glyph.start,
text_end: line_offset + glyph.end, text_end: line_offset + glyph.end,
@@ -464,21 +523,34 @@ impl TextSystem {
self.frame_stats.glyph_collect_ms += self.frame_stats.glyph_collect_ms +=
glyph_collect_started.elapsed().as_secs_f64() * 1_000.0; glyph_collect_started.elapsed().as_secs_f64() * 1_000.0;
let layout = TextLayout { let layout = Arc::new(TextLayoutData {
lines, lines,
glyphs, glyphs,
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)), size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),
}; });
self.frame_stats.output_glyphs = self self.frame_stats.output_glyphs = self
.frame_stats .frame_stats
.output_glyphs .output_glyphs
.saturating_add(layout.glyphs.len() as u32); .saturating_add(layout.glyphs.len() as u32);
self.frame_stats.miss_ms += miss_started.elapsed().as_secs_f64() * 1_000.0; 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( fn resolve_font_family(
@@ -534,6 +606,13 @@ fn line_start_offsets(text: &str) -> Vec<usize> {
starts 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( fn layout_cache_key(
spans: &[TextSpan], spans: &[TextSpan],
style: &TextStyle, style: &TextStyle,
@@ -544,31 +623,30 @@ fn layout_cache_key(
spans.hash(&mut hasher); spans.hash(&mut hasher);
style.font_size.to_bits().hash(&mut hasher); style.font_size.to_bits().hash(&mut hasher);
style.line_height.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.font_family.hash(&mut hasher);
style.wrap.hash(&mut hasher); style.wrap.hash(&mut hasher);
style.align.hash(&mut hasher); style.align.hash(&mut hasher);
style.max_lines.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); quantized_layout_dimension(width).hash(&mut hasher);
}
hasher.finish() hasher.finish()
} }
fn clamp_text_layout( /// Returns `true` if the available width can change the shaped glyph positions.
mut layout: TextLayout, ///
width: Option<f32>, /// For `TextWrap::None` + `TextAlign::Start`, text flows past any boundary
height: Option<f32>, /// and lines are left-aligned, so width has no effect on the output.
) -> TextLayout { #[inline]
if let Some(width) = width { fn width_affects_layout(style: &TextStyle) -> bool {
layout.size.width = layout.size.width.min(width.max(0.0)); style.wrap != TextWrap::None || style.align != TextAlign::Start
}
if let Some(height) = height {
layout.size.height = layout.size.height.min(height.max(0.0));
}
layout
} }
fn quantized_layout_dimension(value: Option<f32>) -> Option<u32> { 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.map(|value| {
(value / LAYOUT_CACHE_BUCKET_PX) (value / LAYOUT_CACHE_BUCKET_PX)
.round() .round()
@@ -595,9 +673,10 @@ fn default_attrs_for_style<'a>(
style: &'a TextStyle, style: &'a TextStyle,
resolved_family: Option<&'a str>, resolved_family: Option<&'a str>,
) -> Attrs<'a> { ) -> 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() Attrs::new()
.family(font_family_to_cosmic(&style.font_family, resolved_family)) .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)) .metrics(Metrics::new(style.font_size, style.line_height))
} }
@@ -607,10 +686,16 @@ fn attrs_for_span<'a>(
resolved_family: Option<&'a str>, resolved_family: Option<&'a str>,
) -> Attrs<'a> { ) -> Attrs<'a> {
let font_family = span.font_family.as_ref().unwrap_or(&style.font_family); let font_family = span.font_family.as_ref().unwrap_or(&style.font_family);
default_attrs_for_style(style, resolved_family) let base = default_attrs_for_style(style, resolved_family)
.family(font_family_to_cosmic(font_family, resolved_family)) .family(font_family_to_cosmic(font_family, resolved_family));
.color(to_cosmic_color(span.color.unwrap_or(style.color))) // Only set a color when the span explicitly overrides the default. Spans that
.weight(match span.weight { // 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::Normal => CosmicWeight::NORMAL,
TextSpanWeight::Medium => CosmicWeight::MEDIUM, TextSpanWeight::Medium => CosmicWeight::MEDIUM,
TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD, 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)] #[cfg(test)]
mod tests { mod tests {
use super::{ 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] #[test]
fn preferred_family_name_uses_first_available_candidate() { fn preferred_family_name_uses_first_available_candidate() {
let selected = preferred_family_name( let selected = preferred_family_name(

View File

@@ -1,3 +1,5 @@
use std::hash::{Hash, Hasher};
use crate::scene::{Color, Point}; use crate::scene::{Color, Point};
use crate::text::{TextSpan, TextStyle, TextWrap}; use crate::text::{TextSpan, TextStyle, TextWrap};
use crate::{ImageFit, ImageResource}; use crate::{ImageFit, ImageResource};
@@ -462,3 +464,144 @@ impl Default for Element {
Self::new() 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()
}
}

View File

@@ -7,7 +7,7 @@ use std::io::ErrorKind;
use std::io::Read; use std::io::Read;
use std::io::Write; use std::io::Write;
use std::num::NonZeroU32; 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::ptr::NonNull;
use std::rc::Rc; use std::rc::Rc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -17,7 +17,9 @@ use raw_window_handle::{
RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, WindowHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, WindowHandle,
}; };
use ruin_runtime::channel::mpsc; 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::{ use ruin_ui::{
CursorIcon, KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, PlatformEndpoint, CursorIcon, KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, PlatformEndpoint,
PlatformEvent, PlatformRequest, PlatformRuntime, Point, PointerButton, PointerEvent, PlatformEvent, PlatformRequest, PlatformRuntime, Point, PointerButton, PointerEvent,
@@ -38,6 +40,7 @@ use wayland_client::{
use wayland_protocols::wp::cursor_shape::v1::client::{ use wayland_protocols::wp::cursor_shape::v1::client::{
wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1, 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::{ use wayland_protocols::wp::primary_selection::zv1::client::{
zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1, zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1,
zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1, zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1,
@@ -114,6 +117,16 @@ struct WindowWorkerState {
viewport_request_in_flight: Option<UiSize>, viewport_request_in_flight: Option<UiSize>,
event_tx: mpsc::UnboundedSender<PlatformEvent>, event_tx: mpsc::UnboundedSender<PlatformEvent>,
internal_tx: mpsc::UnboundedSender<InternalBackendEvent>, 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 { enum WindowWorkerCommand {
@@ -144,6 +157,9 @@ struct State {
clipboard_device: Option<wl_data_device::WlDataDevice>, clipboard_device: Option<wl_data_device::WlDataDevice>,
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>, cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>, 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: primary_selection_manager:
Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>, Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>,
primary_selection_device: Option<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1>, 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 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_manager = globals.bind(&qh, 1..=1, ()).ok();
let primary_selection_device = primary_selection_manager.as_ref().map( let primary_selection_device = primary_selection_manager.as_ref().map(
|manager: &zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1| { |manager: &zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1| {
@@ -307,6 +324,7 @@ impl WaylandWindow {
}, },
); );
let surface = compositor.create_surface(&qh, ()); 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 xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
let toplevel = xdg_surface.get_toplevel(&qh, ()); let toplevel = xdg_surface.get_toplevel(&qh, ());
toplevel.set_title(spec.title.clone()); toplevel.set_title(spec.title.clone());
@@ -351,6 +369,7 @@ impl WaylandWindow {
clipboard_device, clipboard_device,
cursor_shape_manager, cursor_shape_manager,
cursor_shape_device: None, cursor_shape_device: None,
viewport,
primary_selection_manager, primary_selection_manager,
primary_selection_device, primary_selection_device,
qh, qh,
@@ -666,6 +685,29 @@ impl WaylandWindow {
self.state.frame_callback = None; 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>> { fn flush_connection(&mut self) -> Result<(), Box<dyn Error>> {
self.state._connection.flush()?; self.state._connection.flush()?;
Ok(()) Ok(())
@@ -910,18 +952,17 @@ fn handle_replace_scene(
height = scene.logical_size.height, height = scene.logical_size.height,
"received scene replacement" "received scene replacement"
); );
let mut command_tx = None; let command_tx = {
{
let mut state_ref = state.borrow_mut(); let mut state_ref = state.borrow_mut();
let Some(record) = state_ref.windows.get_mut(&window_id) else { let Some(record) = state_ref.windows.get_mut(&window_id) else {
return; return;
}; };
record.latest_scene = Some(scene.clone()); record.latest_scene = Some(scene.clone());
if let Some(worker) = record.worker.as_ref() { record.worker.as_ref().map(|w| w.command_tx.clone())
command_tx = Some(worker.command_tx.clone()); };
}
}
if let Some(command_tx) = command_tx { 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)); 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( fn spawn_window_worker(
window_id: WindowId, window_id: WindowId,
spec: WindowSpec, spec: WindowSpec,
@@ -1028,6 +1081,18 @@ fn spawn_window_worker(
internal_tx: mpsc::UnboundedSender<InternalBackendEvent>, internal_tx: mpsc::UnboundedSender<InternalBackendEvent>,
) -> WindowWorkerHandle { ) -> WindowWorkerHandle {
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<WindowWorkerCommand>(); 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( let worker = spawn_worker(
move || { move || {
let window = match WaylandWindow::open(spec.clone()) { let window = match WaylandWindow::open(spec.clone()) {
@@ -1038,6 +1103,8 @@ fn spawn_window_worker(
return; return;
} }
}; };
// Extract the Wayland socket fd before moving `window` into state.
let wayland_fd = window.poll_fd();
let renderer = match WgpuSceneRenderer::new( let renderer = match WgpuSceneRenderer::new(
window.surface_target(), window.surface_target(),
spec.requested_inner_size spec.requested_inner_size
@@ -1068,8 +1135,26 @@ fn spawn_window_worker(
viewport_request_in_flight: None, viewport_request_in_flight: None,
event_tx, event_tx,
internal_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({ queue_future({
let state = Rc::clone(&state); let state = Rc::clone(&state);
async move { async move {
@@ -1175,35 +1260,42 @@ fn spawn_window_worker(
} }
WindowWorkerCommand::Shutdown => { WindowWorkerCommand::Shutdown => {
state.borrow_mut().shutdown_requested = true; state.borrow_mut().shutdown_requested = true;
// Poke the pump so it sees shutdown_requested.
write_wakeup(pipe_write_fd);
break; 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); let state = Rc::clone(&state);
move || pump_window_worker(state) async move {
}); loop {
}, // Consume all pending wakeup bytes before doing work.
|| {}, drain_wakeup(pipe_read_fd);
);
WindowWorkerHandle {
command_tx,
_worker: worker,
}
}
fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) { let mut keep_running = true;
let mut reschedule = true;
{ {
let mut state_ref = state.borrow_mut(); 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); emit_window_closed(&mut state_ref, false);
reschedule = false; keep_running = false;
} else { }
}
if keep_running {
for event in state_ref.window.drain_pointer_events() { for event in state_ref.window.drain_pointer_events() {
let _ = state_ref.event_tx.send(PlatformEvent::Pointer { let _ = state_ref.event_tx.send(PlatformEvent::Pointer {
window_id: state_ref.window_id, window_id: state_ref.window_id,
@@ -1226,11 +1318,17 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
}); });
} }
if !state_ref.window.is_running() { let user_closed = !state_ref.shutdown_requested;
emit_window_closed(&mut state_ref, true); if state_ref.shutdown_requested || !state_ref.window.is_running() {
reschedule = false; emit_window_closed(&mut state_ref, user_closed);
} else if let Some(frame) = state_ref.window.prepare_frame() { keep_running = false;
let current_viewport = UiSize::new(frame.width as f32, frame.height as f32); }
}
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 { if frame.resized {
debug!( debug!(
target: "ruin_ui_platform_wayland::resize", target: "ruin_ui_platform_wayland::resize",
@@ -1239,19 +1337,35 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
height = current_viewport.height, height = current_viewport.height,
"worker observed resized frame" "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 = 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 if state_ref
.latest_scene .latest_scene
.as_ref() .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() || state_ref.viewport_request_in_flight.is_some()
{ {
maybe_request_pending_viewport(&mut state_ref); maybe_request_pending_viewport(&mut state_ref);
} else { } else {
state_ref.pending_viewport = None; 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 { if !state_ref.opened_emitted {
state_ref.opened_emitted = true; state_ref.opened_emitted = true;
@@ -1261,10 +1375,13 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
} }
let scene = state_ref.latest_scene.clone(); let scene = state_ref.latest_scene.clone();
if let Some(scene) = scene.as_ref() { if let Some(scene) = scene.as_ref() {
if !state_ref.window.presentation_ready() { if scene.logical_size != current_viewport {
state_ref.window.request_redraw(); // Resize case: render the preview immediately.
// Wait for the compositor frame callback before attempting another present. // Drop any pending frame callback — vsync pacing
} else if scene.logical_size != current_viewport { // doesn't matter for a placeholder preview, and
// waiting for it (potentially 16150ms) is the
// primary source of visible resize lag.
state_ref.window.clear_frame_callback();
debug!( debug!(
target: "ruin_ui_platform_wayland::resize", target: "ruin_ui_platform_wayland::resize",
window_id = state_ref.window_id.raw(), 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" "scene size does not match current viewport"
); );
state_ref.pending_viewport = Some(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(); let mut preview_scene = scene.clone();
preview_scene.logical_size = current_viewport; preview_scene.logical_size = current_viewport;
state_ref.window.arm_frame_callback(); state_ref.renderer.render(&preview_scene).is_ok()
if state_ref.renderer.render(&preview_scene).is_ok() { };
if preview_ok {
match state_ref.window.flush_connection() { match state_ref.window.flush_connection() {
Ok(()) => { Ok(()) => {
trace!( let preview_lag_us = state_ref
target: "ruin_ui_platform_wayland::scene", .pending_viewport_since
.map(|t| t.elapsed().as_micros());
debug!(
target: "ruin_ui_platform_wayland::resize",
window_id = state_ref.window_id.raw(), window_id = state_ref.window_id.raw(),
scene_version = scene.version, scene_version = scene.version,
width = current_viewport.width, width = current_viewport.width,
height = current_viewport.height, 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, window_id: state_ref.window_id,
scene_version: scene.version, scene_version: scene.version,
item_count: scene.item_count(), item_count: scene.item_count(),
}); },
);
finish_presented_viewport_request( finish_presented_viewport_request(
&mut state_ref, &mut state_ref,
scene.logical_size, scene.logical_size,
@@ -1305,39 +1445,59 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
target: "ruin_ui_platform_wayland::scene", target: "ruin_ui_platform_wayland::scene",
window_id = state_ref.window_id.raw(), window_id = state_ref.window_id.raw(),
error = %error, error = %error,
"failed to flush presented preview scene" "failed to flush preview"
); );
state_ref.window.clear_frame_callback();
state_ref.window.request_redraw(); 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 { } 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(); state_ref.window.arm_frame_callback();
let t_render = std::time::Instant::now();
match state_ref.renderer.render(scene) { match state_ref.renderer.render(scene) {
Ok(()) => { 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() { match state_ref.window.flush_connection() {
Ok(()) => { Ok(()) => {
trace!( let resize_lag_us = state_ref
target: "ruin_ui_platform_wayland::scene", .pending_viewport_since
.take()
.map(|t| t.elapsed().as_micros());
debug!(
target: "ruin_ui_platform_wayland::resize",
window_id = state_ref.window_id.raw(), window_id = state_ref.window_id.raw(),
scene_version = scene.version, scene_version = scene.version,
width = scene.logical_size.width, width = scene.logical_size.width,
height = scene.logical_size.height, height = scene.logical_size.height,
"presented matching scene" resize_lag_us,
"resize complete: presented correct scene"
); );
finish_presented_viewport_request( finish_presented_viewport_request(
&mut state_ref, &mut state_ref,
scene.logical_size, scene.logical_size,
); );
let _ = let _ = state_ref.event_tx.send(
state_ref.event_tx.send(PlatformEvent::FramePresented { PlatformEvent::FramePresented {
window_id: state_ref.window_id, window_id: state_ref.window_id,
scene_version: scene.version, scene_version: scene.version,
item_count: scene.item_count(), item_count: scene.item_count(),
}); },
);
} }
Err(error) => { Err(error) => {
debug!( debug!(
@@ -1353,7 +1513,9 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
} }
Err(RenderError::Lost | RenderError::Outdated) => { Err(RenderError::Lost | RenderError::Outdated) => {
state_ref.window.clear_frame_callback(); 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(); state_ref.window.request_redraw();
} }
Err( Err(
@@ -1373,11 +1535,45 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
{ {
maybe_request_pending_viewport(&mut state_ref); 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 { if !keep_running {
queue_task(move || pump_window_worker(state)); 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_compositor::WlCompositor);
delegate_noop!(State: ignore wl_surface::WlSurface); delegate_noop!(State: ignore wl_surface::WlSurface);
delegate_noop!(State: ignore wl_data_device_manager::WlDataDeviceManager); delegate_noop!(State: ignore wl_data_device_manager::WlDataDeviceManager);

View File

@@ -7,8 +7,8 @@ use cosmic_text::{
}; };
use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use ruin_ui::{ use ruin_ui::{
BoxShadowKind, ClipRegion, Color, DisplayItem, Point, PreparedImage, PreparedText, Rect, BoxShadowKind, ClipRegion, Color, DisplayItem, GlyphInstance, Point, PreparedImage,
RoundedRect, SceneSnapshot, ShadowRect, UiSize, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect, UiSize,
}; };
use tracing::trace; use tracing::trace;
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
@@ -107,6 +107,14 @@ struct CachedImageTexture {
bind_group: wgpu::BindGroup, 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)] #[allow(dead_code)]
struct GlyphAtlas { struct GlyphAtlas {
texture: wgpu::Texture, 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 text_prepare_start = std::time::Instant::now();
let mut uploaded_images = Vec::new(); let mut uploaded_images = Vec::new();
let mut uploaded_texts = 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 clip_stack = Vec::new();
let mut active_clip = ActiveClip::default(); let mut active_clip = ActiveClip::default();
for item in &scene.items { for item in &scene.items {
@@ -836,6 +845,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
.as_ref() .as_ref()
.map_or(0_u32, |text| text.vertex_count), .map_or(0_u32, |text| text.vertex_count),
fallback_text_batches = uploaded_texts.len(), 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, text_prepare_ms = text_prepare_ms,
render_ms = render_start.elapsed().as_secs_f64() * 1_000.0, render_ms = render_start.elapsed().as_secs_f64() * 1_000.0,
"rendered scene" "rendered scene"
@@ -927,8 +938,9 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
continue; continue;
} }
let local_x = (glyph.position.x - text.origin.x).round() as i32; // Glyph positions are LOCAL (origin-relative); no subtraction needed.
let local_y = (glyph.position.y - text.origin.y).round() as i32; let local_x = glyph.position.x.round() as i32;
let local_y = glyph.position.y.round() as i32;
glyphs.push(PreparedGlyphBitmap { glyphs.push(PreparedGlyphBitmap {
rect: PixelRect { rect: PixelRect {
left: local_x + image.placement.left, 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, bottom: local_y - image.placement.top + height,
}, },
cache_key, cache_key,
color: glyph.color, color: resolve_glyph_color(glyph.color, text.default_color),
}); });
} }
glyphs glyphs
@@ -978,7 +990,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
bottom: physical.y - image.placement.top + height, bottom: physical.y - image.placement.top + height,
}, },
content: image.content, 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(), data: image.data.clone(),
}); });
} }
@@ -986,7 +998,11 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
glyphs 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 vertices = Vec::new();
let mut clip_stack = Vec::new(); let mut clip_stack = Vec::new();
let mut active_clip = ActiveClip::default(); 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); 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 { let Some(cache_key) = glyph.cache_key else {
continue; 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 { else {
continue; 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 { let glyph_rect = PixelRect {
left: glyph.position.x.round() as i32 + atlas_glyph.placement_left, left: abs_x + atlas_glyph.placement_left,
top: glyph.position.y.round() as i32 - atlas_glyph.placement_top, top: abs_y - atlas_glyph.placement_top,
right: glyph.position.x.round() as i32 right: abs_x
+ atlas_glyph.placement_left + atlas_glyph.placement_left
+ atlas_glyph.atlas_rect.width as i32, + 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, + atlas_glyph.atlas_rect.height as i32,
}; };
push_glyph_vertices( push_glyph_vertices(
@@ -1044,7 +1072,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
atlas_glyph.atlas_rect, atlas_glyph.atlas_rect,
clip_rect, clip_rect,
scene.logical_size, scene.logical_size,
glyph.color, resolved_color,
active_clip, 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 { fn color_from_cosmic(color: cosmic_text::Color) -> Color {
Color::rgba(color.r(), color.g(), color.b(), color.a()) 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))) 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 { fn text_texture_key(text: &PreparedText) -> TextTextureKey {
TextTextureKey { TextTextureKey {
text: text.text.clone(), 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())), .map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())),
font_size_bits: text.font_size.to_bits(), font_size_bits: text.font_size.to_bits(),
line_height_bits: text.line_height.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: text
.glyphs .glyphs
.iter() .iter()
.map(|glyph| TextTextureGlyph { .map(|glyph| TextTextureGlyph {
local_x_bits: (glyph.position.x - text.origin.x).to_bits(), // Glyph positions are already LOCAL; no subtraction needed.
local_y_bits: (glyph.position.y - text.origin.y).to_bits(), local_x_bits: glyph.position.x.to_bits(),
local_y_bits: glyph.position.y.to_bits(),
advance_bits: glyph.advance.to_bits(), advance_bits: glyph.advance.to_bits(),
color: (glyph.color.r, glyph.color.g, glyph.color.b, glyph.color.a), color: (glyph.color.r, glyph.color.g, glyph.color.b, glyph.color.a),
cache_key: glyph.cache_key, cache_key: glyph.cache_key,
@@ -2301,19 +2370,13 @@ mod tests {
Rect::new(10.0, 10.0, 20.0, 20.0), Rect::new(10.0, 10.0, 20.0, 20.0),
Color::rgb(0x44, 0x55, 0x66), Color::rgb(0x44, 0x55, 0x66),
); );
scene.push_text(PreparedText { scene.push_text(PreparedText::monospace(
element_id: None, "ignored",
text: "ignored".into(), Point::new(4.0, 8.0),
origin: Point::new(4.0, 8.0), 16.0,
bounds: None, 8.0,
font_size: 16.0, Color::rgb(0xFF, 0xFF, 0xFF),
line_height: 18.0, ));
color: Color::rgb(0xFF, 0xFF, 0xFF),
selectable: true,
selection_style: TextSelectionStyle::DEFAULT,
lines: Vec::new(),
glyphs: Vec::new(),
});
let vertices = build_vertices(&scene); let vertices = build_vertices(&scene);
assert_eq!(vertices.len(), 12); assert_eq!(vertices.len(), 12);
@@ -2330,34 +2393,22 @@ mod tests {
#[test] #[test]
fn text_texture_key_ignores_absolute_origin() { fn text_texture_key_ignores_absolute_origin() {
let first = PreparedText { // Two PreparedTexts with the same content but different origins must
element_id: None, // produce the same TextTextureKey (the key stores local glyph offsets).
text: "cache me".into(), let first = PreparedText::monospace(
origin: Point::new(20.0, 30.0), "x",
bounds: Some(UiSize::new(120.0, 48.0)), Point::new(20.0, 30.0),
font_size: 16.0, 16.0,
line_height: 20.0, 8.0,
color: Color::rgb(0xEE, 0xEE, 0xEE), Color::rgb(0xEE, 0xEE, 0xEE),
selectable: true, );
selection_style: TextSelectionStyle::DEFAULT, let second = PreparedText::monospace(
lines: Vec::new(), "x",
glyphs: vec![GlyphInstance { Point::new(60.0, 90.0),
position: Point::new(24.0, 44.0), 16.0,
advance: 8.0, 8.0,
color: Color::rgb(0xEE, 0xEE, 0xEE), 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()
};
assert_eq!(text_texture_key(&first), text_texture_key(&second)); assert_eq!(text_texture_key(&first), text_texture_key(&second));
} }