Lots of claude-driven performance work.
This commit is contained in:
231
Cargo.lock
generated
231
Cargo.lock
generated
@@ -50,6 +50,18 @@ dependencies = [
|
|||||||
"libc",
|
"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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
94
lib/ui/benches/layout_bench.rs
Normal file
94
lib/ui/benches/layout_bench.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
use criterion::{Criterion, criterion_group, criterion_main};
|
||||||
|
use ruin_ui::{
|
||||||
|
Color, Element, FlexDirection, LayoutCache, TextStyle, TextSystem, UiSize,
|
||||||
|
layout_snapshot_with_cache,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn text_style() -> TextStyle {
|
||||||
|
TextStyle::new(14.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_column_tree(n: usize) -> Element {
|
||||||
|
let mut root = Element::new().direction(FlexDirection::Column);
|
||||||
|
for i in 0..n {
|
||||||
|
root = root.child(Element::text(format!("item {i}"), text_style()));
|
||||||
|
}
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static tree — nothing changes between frames. After one warmup frame, all
|
||||||
|
/// subsequent frames should be near-zero cost (all cache hits).
|
||||||
|
fn bench_static_tree(c: &mut Criterion) {
|
||||||
|
let mut text_system = TextSystem::new();
|
||||||
|
let mut layout_cache = LayoutCache::new();
|
||||||
|
let root = make_column_tree(200);
|
||||||
|
let size = UiSize::new(400.0, 4000.0);
|
||||||
|
|
||||||
|
// Warm up cache.
|
||||||
|
layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
|
||||||
|
|
||||||
|
c.bench_function("layout_static_200_nodes", |b| {
|
||||||
|
let mut version = 2u64;
|
||||||
|
b.iter(|| {
|
||||||
|
version += 1;
|
||||||
|
layout_snapshot_with_cache(version, size, &root, &mut text_system, &mut layout_cache)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 200 nodes, one text content changes per frame — only one cache miss expected.
|
||||||
|
fn bench_single_change(c: &mut Criterion) {
|
||||||
|
let mut text_system = TextSystem::new();
|
||||||
|
let mut layout_cache = LayoutCache::new();
|
||||||
|
let size = UiSize::new(400.0, 4000.0);
|
||||||
|
|
||||||
|
// Initial warmup.
|
||||||
|
let root = make_column_tree(200);
|
||||||
|
layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
|
||||||
|
|
||||||
|
c.bench_function("layout_single_change_200_nodes", |b| {
|
||||||
|
let mut counter = 0usize;
|
||||||
|
let mut version = 2u64;
|
||||||
|
b.iter(|| {
|
||||||
|
counter += 1;
|
||||||
|
version += 1;
|
||||||
|
let mut tree = make_column_tree(200);
|
||||||
|
tree.children[100] = Element::text(format!("changed {counter}"), text_style());
|
||||||
|
layout_snapshot_with_cache(version, size, &tree, &mut text_system, &mut layout_cache)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll box with 500 items, 20 visible. Simulates a large list.
|
||||||
|
fn bench_scroll_list(c: &mut Criterion) {
|
||||||
|
let mut text_system = TextSystem::new();
|
||||||
|
let mut layout_cache = LayoutCache::new();
|
||||||
|
let item_height = 32.0;
|
||||||
|
let viewport_height = 640.0;
|
||||||
|
let n = 500;
|
||||||
|
|
||||||
|
let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height);
|
||||||
|
for i in 0..n {
|
||||||
|
scroll_box = scroll_box.child(
|
||||||
|
Element::new()
|
||||||
|
.height(item_height)
|
||||||
|
.child(Element::text(format!("list item {i}"), text_style())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let root = Element::new().child(scroll_box);
|
||||||
|
let size = UiSize::new(400.0, viewport_height);
|
||||||
|
|
||||||
|
// Warm up.
|
||||||
|
layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
|
||||||
|
|
||||||
|
c.bench_function("layout_scroll_list_500_items", |b| {
|
||||||
|
let mut version = 2u64;
|
||||||
|
b.iter(|| {
|
||||||
|
version += 1;
|
||||||
|
layout_snapshot_with_cache(version, size, &root, &mut text_system, &mut layout_cache)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_static_tree, bench_single_change, bench_scroll_list);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::time::Instant;
|
use 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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 16–150ms) 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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user