From c70f42704c5ae82efcf82b81fe85f872e7f8f824 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sat, 21 Mar 2026 02:25:17 -0400 Subject: [PATCH] Images! --- Cargo.lock | 743 +++++++++++++++++++++++ examples/text_paragraph_demo/src/main.rs | 162 ++--- lib/ui/Cargo.toml | 1 + lib/ui/src/image.rs | 161 +++++ lib/ui/src/interaction.rs | 6 + lib/ui/src/layout.rs | 188 +++++- lib/ui/src/lib.rs | 6 +- lib/ui/src/scene.rs | 14 + lib/ui/src/tree.rs | 39 +- lib/ui_platform_wayland/src/lib.rs | 20 +- lib/ui_renderer_wgpu/src/lib.rs | 188 +++++- 11 files changed, 1430 insertions(+), 98 deletions(-) create mode 100644 lib/ui/src/image.rs diff --git a/Cargo.lock b/Cargo.lock index e6ecdf1..bdde0a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -26,12 +50,44 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -53,6 +109,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -68,12 +167,27 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "block2" version = "0.6.2" @@ -83,6 +197,12 @@ dependencies = [ "objc2", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.20.2" @@ -109,6 +229,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -122,6 +248,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -148,6 +276,21 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -181,6 +324,40 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -221,6 +398,32 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -237,12 +440,66 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -338,6 +595,28 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -515,6 +794,46 @@ dependencies = [ "want", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "indexmap" version = "2.13.0" @@ -525,6 +844,26 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -537,6 +876,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -570,12 +919,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.8.9" @@ -625,6 +990,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "matchers" version = "0.2.0" @@ -634,6 +1008,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.0" @@ -649,6 +1033,26 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "naga" version = "29.0.0" @@ -684,6 +1088,68 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -794,6 +1260,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -812,6 +1290,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "pollster" version = "0.4.0" @@ -833,6 +1324,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -853,6 +1353,40 @@ name = "profiling" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" @@ -872,6 +1406,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -884,6 +1453,56 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -902,6 +1521,26 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.35.0" @@ -955,6 +1594,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "roxmltree" version = "0.20.0" @@ -997,6 +1642,7 @@ version = "0.1.0" dependencies = [ "cosmic-text", "fontconfig", + "image", "ruin-runtime", "ruin_reactivity", "tracing", @@ -1159,6 +1805,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "skrifa" version = "0.37.0" @@ -1215,6 +1876,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1290,6 +1957,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1411,6 +2092,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1426,6 +2118,15 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -1556,6 +2257,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wgpu" version = "29.0.0" @@ -1851,6 +2558,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "xkbcommon" version = "0.8.0" @@ -1874,6 +2587,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yazi" version = "0.2.1" @@ -1916,3 +2635,27 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +dependencies = [ + "zune-core", +] diff --git a/examples/text_paragraph_demo/src/main.rs b/examples/text_paragraph_demo/src/main.rs index 42eefe5..9c81252 100644 --- a/examples/text_paragraph_demo/src/main.rs +++ b/examples/text_paragraph_demo/src/main.rs @@ -4,12 +4,12 @@ use std::time::{Duration, Instant}; use ruin_runtime::{TimeoutHandle, clear_timeout, set_timeout}; use ruin_ui::{ - Color, CursorIcon, DisplayItem, Edges, Element, ElementId, InteractionTree, KeyboardEvent, - KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, PointerButton, PointerEvent, - PointerEventKind, PointerRouter, PreparedText, Quad, RoutedPointerEventKind, SceneSnapshot, - TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight, - TextStyle, TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate, - layout_snapshot_with_text_system, + Color, CursorIcon, DisplayItem, Edges, Element, ElementId, ImageFit, ImageResource, + InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, + PointerButton, PointerEvent, PointerEventKind, PointerRouter, PreparedText, Quad, + RoutedPointerEventKind, SceneSnapshot, TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, + TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, TextWrap, UiSize, WindowController, + WindowSpec, WindowUpdate, layout_snapshot_with_text_system, }; use ruin_ui_platform_wayland::start_wayland_ui; use tracing_subscriber::layer::SubscriberExt; @@ -34,6 +34,7 @@ const SECOND_INPUT_TEXT_ID: ElementId = ElementId::new(107); const INPUT_CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500); const MULTI_CLICK_INTERVAL: Duration = Duration::from_millis(350); const MULTI_CLICK_DISTANCE_SQUARED: f32 = 36.0; +const HERO_IMAGE_ASSET: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ruin.png"); const DEMO_SELECTION_STYLE: TextSelectionStyle = TextSelectionStyle::new(Color::rgba(0x6C, 0x8E, 0xFF, 0xB8)) .with_text_color(Color::rgb(0x0D, 0x14, 0x25)); @@ -192,7 +193,10 @@ fn active_input_selection_bounds( Some(selection_bounds(selection)) } -fn has_active_input_selection(selection: Option, input_field: &InputFieldState) -> bool { +fn has_active_input_selection( + selection: Option, + input_field: &InputFieldState, +) -> bool { active_input_selection_bounds(selection, input_field).is_some() } @@ -348,6 +352,7 @@ fn install_tracing() { #[ruin_runtime::async_main] async fn main() -> Result<(), Box> { install_tracing(); + let hero_image = ImageResource::load_path(HERO_IMAGE_ASSET).await?; let mut ui = start_wayland_ui(); let window = ui.create_window( WindowSpec::new("RUIN paragraph demo") @@ -522,6 +527,7 @@ async fn main() -> Result<(), Box> { viewport, version, hovered_card, + &hero_image, &input_fields, focused_element, &mut text_system, @@ -656,8 +662,7 @@ async fn main() -> Result<(), Box> { &text, ); needs_input_rebuild |= input_outcome.text_changed; - needs_overlay_present |= - input_outcome.caret_changed || input_outcome.selection_changed; + needs_overlay_present |= input_outcome.caret_changed || input_outcome.selection_changed; } if let Some(text) = pending_primary_selection_text { let input_outcome = insert_text_into_focused_input( @@ -715,6 +720,7 @@ async fn main() -> Result<(), Box> { viewport, version, hovered_card, + &hero_image, &input_fields, focused_element, &mut text_system, @@ -771,11 +777,18 @@ fn build_snapshot( viewport: UiSize, version: u64, hovered_card: Option, + hero_image: &ImageResource, input_fields: &[InputFieldState], focused_element: Option, text_system: &mut TextSystem, ) -> LayoutSnapshot { - let tree = build_document_tree(viewport, hovered_card, input_fields, focused_element); + let tree = build_document_tree( + viewport, + hovered_card, + hero_image, + input_fields, + focused_element, + ); layout_snapshot_with_text_system(version, viewport, &tree, text_system) } @@ -1051,17 +1064,14 @@ fn handle_keyboard_input_event( let prepared_text = interaction_tree.text_for_element(text_id); if event.modifiers.shift && let Some(prepared_text) = prepared_text - && let Some(next_caret) = - navigation_target_offset( - prepared_text, - selection - .filter(|current| current.element_id == text_id) - .map(|current| { - selection_navigation_anchor_and_focus(current, &event.key).1 - }) - .unwrap_or(input_fields[input_index].caret), - &event.key, - ) + && let Some(next_caret) = navigation_target_offset( + prepared_text, + selection + .filter(|current| current.element_id == text_id) + .map(|current| selection_navigation_anchor_and_focus(current, &event.key).1) + .unwrap_or(input_fields[input_index].caret), + &event.key, + ) { let anchor = selection .filter(|current| current.element_id == text_id) @@ -1312,10 +1322,6 @@ fn demo_body_style(font_size: f32, color: Color) -> TextStyle { TextStyle::new(font_size, color).with_selection_style(DEMO_SELECTION_STYLE) } -fn demo_unselectable_title(text: impl Into, id: ElementId, style: TextStyle) -> Element { - Element::paragraph(text, style.with_selectable(false)).id(id) -} - fn open_rust_website() { if let Err(error) = Command::new("xdg-open") .arg("https://www.rust-lang.org") @@ -1374,72 +1380,76 @@ fn input_field_element(input_field: &InputFieldState, focused: bool) -> Element fn build_document_tree( viewport: UiSize, hovered_card: Option, + hero_image: &ImageResource, input_fields: &[InputFieldState], focused_element: Option, ) -> Element { let gutter = (viewport.width * 0.025).clamp(18.0, 30.0); let sidebar_width = (viewport.width * 0.28).clamp(220.0, 320.0); + let hero_image_width = (viewport.width * 0.32).clamp(200.0, 320.0); + let hero_image_height = (hero_image_width * 0.34).clamp(72.0, 128.0); Element::column() .background(Color::rgb(0x0F, 0x13, 0x1E)) .padding(Edges::all(gutter)) .gap(gutter) .children([ - Element::column() + Element::row() .padding(Edges::all(gutter)) - .gap(gutter * 0.45) + .gap(gutter) .background(Color::rgb(0x16, 0x1D, 0x2B)) .children([ - demo_unselectable_title( - "RUIN paragraph demo", - HERO_TITLE_ID, - TextStyle::new(34.0, Color::rgb(0xF5, 0xF7, 0xFB)) - .with_line_height(40.0) - .with_align(TextAlign::Center), - ), - Element::rich_paragraph( - [ - TextSpan::new("RUIN is exploring a "), - TextSpan::new("retained") - .weight(TextSpanWeight::Semibold) - .color(Color::rgb(0xF5, 0xD0, 0x74)), - TextSpan::new(" layout tree backed by explicit scene building, a dedicated "), - TextSpan::new("platform") - .weight(TextSpanWeight::Semibold) - .color(Color::rgb(0x7D, 0xD3, 0xFC)), - TextSpan::new(" thread, and a "), - TextSpan::new("renderer") + Element::image(hero_image.clone()) + .id(HERO_TITLE_ID) + .width(hero_image_width) + .height(hero_image_height) + .image_fit(ImageFit::Contain) + .pointer_events(false), + Element::column().flex(1.0).gap(gutter * 0.45).children([ + Element::rich_paragraph( + [ + TextSpan::new("RUIN is exploring a "), + TextSpan::new("retained") + .weight(TextSpanWeight::Semibold) + .color(Color::rgb(0xF5, 0xD0, 0x74)), + TextSpan::new(" layout tree backed by explicit scene building, a dedicated "), + TextSpan::new("platform") + .weight(TextSpanWeight::Semibold) + .color(Color::rgb(0x7D, 0xD3, 0xFC)), + TextSpan::new(" thread, and a "), + TextSpan::new("renderer") + .weight(TextSpanWeight::Bold) + .slant(TextSpanSlant::Oblique) + .family(TextFontFamily::Monospace) + .color(Color::rgb(0xA7, 0xF3, 0xD0)), + TextSpan::new( + " that can stay simple while the higher-level UI model evolves. This example is intentionally calm: no animated panels, no pulsing widths, just a document-like surface that makes paragraph behavior easier to inspect.", + ), + ], + demo_body_style(18.0, Color::rgb(0xC9, 0xD2, 0xE3)) + .with_line_height(28.0) + .with_align(TextAlign::Center), + ) + .id(HERO_BODY_ID), + Element::rich_paragraph( + [TextSpan::new("Visit the Rust website") .weight(TextSpanWeight::Bold) - .slant(TextSpanSlant::Oblique) - .family(TextFontFamily::Monospace) - .color(Color::rgb(0xA7, 0xF3, 0xD0)), - TextSpan::new( - " that can stay simple while the higher-level UI model evolves. This example is intentionally calm: no animated panels, no pulsing widths, just a document-like surface that makes paragraph behavior easier to inspect.", - ), - ], - demo_body_style(18.0, Color::rgb(0xC9, 0xD2, 0xE3)) - .with_line_height(28.0) - .with_align(TextAlign::Center), - ) - .id(HERO_BODY_ID), - Element::rich_paragraph( - [TextSpan::new("Visit the Rust website") - .weight(TextSpanWeight::Bold) - .color(Color::rgb(0x60, 0xA5, 0xFA))], - TextStyle::new(18.0, Color::rgb(0x60, 0xA5, 0xFA)) - .with_line_height(24.0) - .with_selectable(false), - ) - .id(RUST_LINK_ID) - .cursor(CursorIcon::Pointer), - input_field_element( - &input_fields[0], - focused_element == Some(input_fields[0].field_id), - ), - input_field_element( - &input_fields[1], - focused_element == Some(input_fields[1].field_id), - ), + .color(Color::rgb(0x60, 0xA5, 0xFA))], + TextStyle::new(18.0, Color::rgb(0x60, 0xA5, 0xFA)) + .with_line_height(24.0) + .with_selectable(false), + ) + .id(RUST_LINK_ID) + .cursor(CursorIcon::Pointer), + input_field_element( + &input_fields[0], + focused_element == Some(input_fields[0].field_id), + ), + input_field_element( + &input_fields[1], + focused_element == Some(input_fields[1].field_id), + ), + ]), ]), Element::row().flex(1.0).gap(gutter).children([ Element::column() diff --git a/lib/ui/Cargo.toml b/lib/ui/Cargo.toml index 93dd9ea..d1d1881 100644 --- a/lib/ui/Cargo.toml +++ b/lib/ui/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] cosmic-text = "0.18.2" +image = { version = "0.25", default-features = false, features = ["rayon", "avif", "bmp", "dds", "exr", "gif", "hdr", "ico", "jpeg", "png", "pnm", "qoi", "tga", "tiff", "webp"] } ruin_reactivity = { path = "../reactivity" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" } tracing = { version = "0.1", default-features = false, features = ["std"] } diff --git a/lib/ui/src/image.rs b/lib/ui/src/image.rs new file mode 100644 index 0000000..c4c2d73 --- /dev/null +++ b/lib/ui/src/image.rs @@ -0,0 +1,161 @@ +use std::fmt; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use image::ImageReader; +use ruin_runtime::channel::oneshot; + +use crate::UiSize; + +static NEXT_IMAGE_RESOURCE_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ImageFit { + Contain, + Cover, + Fill, +} + +#[derive(Clone)] +pub struct ImageResource { + inner: Arc, +} + +struct ImageResourceInner { + id: u64, + width: u32, + height: u32, + pixels: Arc<[u8]>, +} + +#[derive(Debug)] +pub enum ImageResourceError { + Io(std::io::Error), + Decode(image::ImageError), + DecodeTaskClosed, + InvalidRgbaPixels { width: u32, height: u32, len: usize }, +} + +impl ImageResource { + pub async fn load_path(path: impl AsRef) -> Result { + let bytes = ruin_runtime::fs::read(path.as_ref()) + .await + .map_err(ImageResourceError::Io)?; + Self::decode_encoded_bytes(bytes).await + } + + pub async fn decode_encoded_bytes( + bytes: impl Into>, + ) -> Result { + let bytes = bytes.into(); + let (tx, mut rx) = oneshot::channel(); + std::thread::Builder::new() + .name("ruin-ui-image-decode".to_owned()) + .spawn(move || { + let result = Self::decode_encoded_bytes_sync(&bytes); + let _ = tx.send(result); + }) + .map_err(ImageResourceError::Io)?; + rx.recv() + .await + .map_err(|_| ImageResourceError::DecodeTaskClosed)? + } + + pub fn from_rgba8( + width: u32, + height: u32, + pixels: impl Into>, + ) -> Result { + let pixels = pixels.into(); + let expected_len = width as usize * height as usize * 4; + if pixels.len() != expected_len { + return Err(ImageResourceError::InvalidRgbaPixels { + width, + height, + len: pixels.len(), + }); + } + Ok(Self { + inner: Arc::new(ImageResourceInner { + id: NEXT_IMAGE_RESOURCE_ID.fetch_add(1, Ordering::Relaxed), + width, + height, + pixels: pixels.into(), + }), + }) + } + + fn decode_encoded_bytes_sync(bytes: &[u8]) -> Result { + let image = ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(ImageResourceError::Io)? + .decode() + .map_err(ImageResourceError::Decode)?; + let rgba = image.to_rgba8(); + Self::from_rgba8(rgba.width(), rgba.height(), rgba.into_raw()) + } + + pub fn id(&self) -> u64 { + self.inner.id + } + + pub fn width(&self) -> u32 { + self.inner.width + } + + pub fn height(&self) -> u32 { + self.inner.height + } + + pub fn size(&self) -> UiSize { + UiSize::new(self.inner.width as f32, self.inner.height as f32) + } + + pub fn pixels(&self) -> &[u8] { + &self.inner.pixels + } +} + +impl fmt::Debug for ImageResource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ImageResource") + .field("id", &self.inner.id) + .field("width", &self.inner.width) + .field("height", &self.inner.height) + .finish() + } +} + +impl PartialEq for ImageResource { + fn eq(&self, other: &Self) -> bool { + self.inner.id == other.inner.id + } +} + +impl Eq for ImageResource {} + +impl std::hash::Hash for ImageResource { + fn hash(&self, state: &mut H) { + self.inner.id.hash(state); + } +} + +impl fmt::Display for ImageResourceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "{error}"), + Self::Decode(error) => write!(f, "{error}"), + Self::DecodeTaskClosed => { + f.write_str("image decode task closed before producing a result") + } + Self::InvalidRgbaPixels { width, height, len } => write!( + f, + "expected {} RGBA bytes for {width}x{height} image, got {len}", + *width as usize * *height as usize * 4 + ), + } + } +} + +impl std::error::Error for ImageResourceError {} diff --git a/lib/ui/src/interaction.rs b/lib/ui/src/interaction.rs index 6287ebb..5c34a13 100644 --- a/lib/ui/src/interaction.rs +++ b/lib/ui/src/interaction.rs @@ -167,6 +167,7 @@ mod tests { pointer_events: false, focusable: false, cursor: CursorIcon::Default, + prepared_image: None, prepared_text: None, children: vec![ LayoutNode { @@ -176,6 +177,7 @@ mod tests { pointer_events: true, focusable: false, cursor: CursorIcon::Default, + prepared_image: None, prepared_text: None, children: Vec::new(), }, @@ -186,6 +188,7 @@ mod tests { pointer_events: true, focusable: false, cursor: CursorIcon::Default, + prepared_image: None, prepared_text: None, children: Vec::new(), }, @@ -203,6 +206,7 @@ mod tests { pointer_events: false, focusable: false, cursor: CursorIcon::Default, + prepared_image: None, prepared_text: None, children: vec![LayoutNode { path: LayoutPath::root().child(0), @@ -211,6 +215,7 @@ mod tests { pointer_events: true, focusable: false, cursor: CursorIcon::Default, + prepared_image: None, prepared_text: None, children: vec![LayoutNode { path: LayoutPath::root().child(0).child(0), @@ -219,6 +224,7 @@ mod tests { pointer_events: true, focusable: false, cursor: CursorIcon::Default, + prepared_image: None, prepared_text: None, children: Vec::new(), }], diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index c730439..25759d7 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -1,8 +1,9 @@ use std::time::Instant; -use crate::scene::{PreparedText, Rect, SceneSnapshot, UiSize}; +use crate::ImageFit; +use crate::scene::{PreparedImage, PreparedText, Rect, SceneSnapshot, UiSize}; use crate::text::TextSystem; -use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection}; +use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode}; pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot { let mut text_system = TextSystem::new(); @@ -60,6 +61,7 @@ pub struct LayoutNode { pub pointer_events: bool, pub focusable: bool, pub cursor: CursorIcon, + pub prepared_image: Option, pub prepared_text: Option, pub children: Vec, } @@ -188,6 +190,7 @@ fn layout_element( pointer_events: element.style.pointer_events, focusable: element.style.focusable, cursor, + prepared_image: None, prepared_text: None, children: Vec::new(), }; @@ -223,6 +226,16 @@ fn layout_element( return interaction; } + if let Some(image) = element.image_node() { + let content = inset_rect(rect, element.style.padding); + if content.size.width > 0.0 && content.size.height > 0.0 { + let prepared = prepare_image(image, content, element.id); + scene.push_image(prepared.clone()); + interaction.prepared_image = Some(prepared); + } + return interaction; + } + perf_stats.container_nodes += 1; if element.children.is_empty() { @@ -427,6 +440,11 @@ fn intrinsic_main_size( return main_axis_size(content, direction) + padding; } + if let Some(image) = child.image_node() { + let resolved = resolve_image_element_size(child, image.resource.size()); + return main_axis_size(resolved, direction); + } + let available_size = match direction { FlexDirection::Row => UiSize::new(available_main.max(0.0), cross_size.max(0.0)), FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)), @@ -461,6 +479,14 @@ fn intrinsic_size( ); } + if let Some(image) = element.image_node() { + let resolved = resolve_image_element_size(element, image.resource.size()); + return UiSize::new( + resolved.width + element.style.padding.left + element.style.padding.right, + resolved.height + element.style.padding.top + element.style.padding.bottom, + ); + } + let explicit_width = element.style.width; let explicit_height = element.style.height; let content_width = explicit_width.unwrap_or(available_size.width).max(0.0) @@ -484,9 +510,7 @@ fn intrinsic_size( let mut width: f32 = 0.0; let mut height = gap_total; for child in &element.children { - if child.style.flex_grow > 0.0 && child.style.height.is_none() { - continue; - } + let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none(); let child_size = intrinsic_size( child, UiSize::new( @@ -497,15 +521,23 @@ fn intrinsic_size( perf_stats, ); width = width.max(child.style.width.unwrap_or(child_size.width)); - height += child.style.height.unwrap_or(child_size.height); + if !skip_main { + height += child.style.height.unwrap_or(child_size.height); + } } (width, height) } FlexDirection::Row => { let mut width = gap_total; let mut height: f32 = 0.0; + let mut fixed_main = 0.0; + let mut flex_total = 0.0; + let mut child_main_sizes = Vec::with_capacity(element.children.len()); for child in &element.children { - if child.style.flex_grow > 0.0 && child.style.width.is_none() { + let is_flex = child.style.flex_grow > 0.0 && child.style.width.is_none(); + if is_flex { + flex_total += child_flex_weight(child); + child_main_sizes.push(None); continue; } let child_size = intrinsic_size( @@ -517,7 +549,32 @@ fn intrinsic_size( text_system, perf_stats, ); - width += child.style.width.unwrap_or(child_size.width); + let child_main = child.style.width.unwrap_or(child_size.width); + fixed_main += child_main; + child_main_sizes.push(Some(child_main)); + } + let remaining_main = (content_size.width - gap_total - fixed_main).max(0.0); + for (child, measured_main) in element.children.iter().zip(child_main_sizes.iter()) { + let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none(); + let child_main = measured_main.unwrap_or_else(|| { + if flex_total <= 0.0 { + 0.0 + } else { + remaining_main * (child_flex_weight(child) / flex_total) + } + }); + let child_size = intrinsic_size( + child, + UiSize::new( + child_main, + child.style.height.unwrap_or(content_size.height), + ), + text_system, + perf_stats, + ); + if !skip_main { + width += child_main; + } height = height.max(child.style.height.unwrap_or(child_size.height)); } (width, height) @@ -569,6 +626,67 @@ impl LayoutPerfStats { } } +fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option) -> PreparedImage { + let source_size = image.resource.size(); + let source_aspect = if source_size.height > 0.0 { + source_size.width / source_size.height + } else { + 1.0 + }; + let rect_aspect = if rect.size.height > 0.0 { + rect.size.width / rect.size.height + } else { + source_aspect + }; + + let (draw_rect, uv_rect) = match image.fit { + ImageFit::Fill => (rect, (0.0, 0.0, 1.0, 1.0)), + ImageFit::Contain => { + let scale = (rect.size.width / source_size.width) + .min(rect.size.height / source_size.height) + .max(0.0); + let width = source_size.width * scale; + let height = source_size.height * scale; + let x = rect.origin.x + (rect.size.width - width) * 0.5; + let y = rect.origin.y + (rect.size.height - height) * 0.5; + (Rect::new(x, y, width, height), (0.0, 0.0, 1.0, 1.0)) + } + ImageFit::Cover => { + if rect_aspect > source_aspect { + let visible_height = (source_aspect / rect_aspect).clamp(0.0, 1.0); + let inset = (1.0 - visible_height) * 0.5; + (rect, (0.0, inset, 1.0, 1.0 - inset)) + } else { + let visible_width = (rect_aspect / source_aspect).clamp(0.0, 1.0); + let inset = (1.0 - visible_width) * 0.5; + (rect, (inset, 0.0, 1.0 - inset, 1.0)) + } + } + }; + + PreparedImage { + element_id, + resource: image.resource.clone(), + rect: draw_rect, + uv_rect, + } +} + +fn resolve_image_element_size(element: &Element, intrinsic: UiSize) -> UiSize { + match (element.style.width, element.style.height) { + (Some(width), Some(height)) => UiSize::new(width.max(0.0), height.max(0.0)), + (Some(width), None) if intrinsic.width > 0.0 => UiSize::new( + width.max(0.0), + (width * intrinsic.height / intrinsic.width).max(0.0), + ), + (None, Some(height)) if intrinsic.height > 0.0 => UiSize::new( + (height * intrinsic.width / intrinsic.height).max(0.0), + height.max(0.0), + ), + _ => intrinsic, + } +} + fn inset_rect(rect: Rect, edges: Edges) -> Rect { let width = (rect.size.width - edges.left - edges.right).max(0.0); let height = (rect.size.height - edges.top - edges.bottom).max(0.0); @@ -773,6 +891,60 @@ mod tests { ); } + #[test] + fn row_container_counts_flex_child_height_in_intrinsic_size() { + let row_color = Color::rgb(0x12, 0x24, 0x36); + let root = Element::column().child( + Element::row() + .padding(Edges::all(12.0)) + .gap(16.0) + .background(row_color) + .children([ + Element::new() + .width(120.0) + .height(60.0) + .background(Color::rgb(0x44, 0x55, 0x66)), + Element::column().flex(1.0).child(Element::paragraph( + "A flex child with wrapped text should make the row grow tall enough to contain it.", + TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)) + .with_line_height(24.0) + .with_wrap(TextWrap::Word), + )), + ]), + ); + + let scene = layout_scene(1, UiSize::new(360.0, 320.0), &root); + let row_quad = scene + .items + .iter() + .filter_map(|item| match item { + DisplayItem::Quad(quad) if quad.color == row_color => Some(*quad), + _ => None, + }) + .next() + .expect("row container should emit a background quad"); + let text = scene + .items + .iter() + .find_map(|item| match item { + DisplayItem::Text(text) => Some(text), + _ => None, + }) + .expect("row should emit a text display item"); + let text_bounds = text.bounds.expect("text layout should provide bounds"); + let row_bottom = row_quad.rect.origin.y + row_quad.rect.size.height; + let text_bottom = text.origin.y + text_bounds.height; + + assert!(row_quad.rect.size.height > 84.0); + assert!(row_bottom >= text_bottom); + assert!( + scene + .items + .iter() + .any(|item| matches!(item, DisplayItem::Text(_))) + ); + } + #[test] fn interaction_tree_hit_test_returns_deepest_pointer_target() { let root = Element::column() diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index 5c392ff..263763b 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -10,6 +10,7 @@ pub(crate) mod trace_targets { pub const SCENE: &str = "ruin_ui::scene"; } +mod image; mod interaction; mod keyboard; mod layout; @@ -20,6 +21,7 @@ mod text; mod tree; mod window; +pub use image::{ImageFit, ImageResource, ImageResourceError}; pub use interaction::{ PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent, RoutedPointerEventKind, @@ -36,8 +38,8 @@ pub use platform::{ }; pub use runtime::{EventStreamClosed, UiRuntime, WindowController}; pub use scene::{ - Color, DisplayItem, GlyphInstance, Point, PreparedText, PreparedTextLine, Quad, Rect, - SceneSnapshot, Translation, UiSize, + Color, DisplayItem, GlyphInstance, Point, PreparedImage, PreparedText, PreparedTextLine, Quad, + Rect, SceneSnapshot, Translation, UiSize, }; pub use text::{ TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight, diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 59de791..fae969e 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -5,6 +5,7 @@ use std::ops::Range; use cosmic_text::CacheKey; use tracing::debug; +use crate::ImageResource; use crate::text::TextSelectionStyle; use crate::trace_targets; use crate::tree::ElementId; @@ -133,6 +134,14 @@ pub struct PreparedText { pub glyphs: Vec, } +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedImage { + pub element_id: Option, + pub resource: ImageResource, + pub rect: Rect, + pub uv_rect: (f32, f32, f32, f32), +} + impl PreparedText { pub fn monospace( text: impl Into, @@ -456,6 +465,7 @@ fn classify_word_char(ch: char) -> WordClass { #[derive(Clone, Debug, PartialEq)] pub enum DisplayItem { Quad(Quad), + Image(PreparedImage), Text(PreparedText), PushClip(Rect), PopClip, @@ -506,6 +516,10 @@ impl SceneSnapshot { self.push_item(DisplayItem::Text(text)) } + pub fn push_image(&mut self, image: PreparedImage) -> &mut Self { + self.push_item(DisplayItem::Image(image)) + } + pub fn push_clip(&mut self, rect: Rect) -> &mut Self { self.push_item(DisplayItem::PushClip(rect)) } diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index 474178d..a7e5957 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -1,5 +1,6 @@ use crate::scene::Color; use crate::text::{TextSpan, TextStyle, TextWrap}; +use crate::{ImageFit, ImageResource}; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct ElementId(u64); @@ -96,6 +97,7 @@ impl Default for Style { #[derive(Clone, Debug, PartialEq)] enum ElementContent { Container, + Image(ImageNode), Text(TextNode), } @@ -105,6 +107,12 @@ pub(crate) struct TextNode { pub style: TextStyle, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ImageNode { + pub resource: ImageResource, + pub fit: ImageFit, +} + #[derive(Clone, Debug, PartialEq)] pub struct Element { pub id: Option, @@ -143,6 +151,18 @@ impl Element { Self::text(text, style.with_wrap(TextWrap::Word)) } + pub fn image(resource: ImageResource) -> Self { + Self { + id: None, + style: Style::default(), + children: Vec::new(), + content: ElementContent::Image(ImageNode { + resource, + fit: ImageFit::Contain, + }), + } + } + pub fn rich_paragraph(spans: impl IntoIterator, style: TextStyle) -> Self { Self::spans(spans, style.with_wrap(TextWrap::Word)) } @@ -210,6 +230,14 @@ impl Element { self } + pub fn image_fit(mut self, fit: ImageFit) -> Self { + let ElementContent::Image(image) = &mut self.content else { + panic!("only image elements can set image_fit"); + }; + image.fit = fit; + self + } + pub fn child(mut self, child: Element) -> Self { self.assert_container(); self.children.push(child); @@ -225,14 +253,21 @@ impl Element { pub(crate) fn text_node(&self) -> Option<&TextNode> { match &self.content { ElementContent::Text(text) => Some(text), - ElementContent::Container => None, + ElementContent::Container | ElementContent::Image(_) => None, + } + } + + pub(crate) fn image_node(&self) -> Option<&ImageNode> { + match &self.content { + ElementContent::Image(image) => Some(image), + ElementContent::Container | ElementContent::Text(_) => None, } } fn assert_container(&self) { assert!( matches!(self.content, ElementContent::Container), - "text elements cannot contain children" + "non-container elements cannot contain children" ); } } diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 3995523..a11c4a1 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -294,11 +294,11 @@ impl WaylandWindow { let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=9, ())?; let wm_base: xdg_wm_base::XdgWmBase = globals.bind(&qh, 1..=6, ())?; let clipboard_manager = globals.bind(&qh, 1..=3, ()).ok(); - let clipboard_device = clipboard_manager - .as_ref() - .map(|manager: &wl_data_device_manager::WlDataDeviceManager| { + let clipboard_device = clipboard_manager.as_ref().map( + |manager: &wl_data_device_manager::WlDataDeviceManager| { manager.get_data_device(&seat, &qh, ()) - }); + }, + ); let cursor_shape_manager = globals.bind(&qh, 1..=2, ()).ok(); let primary_selection_manager = globals.bind(&qh, 1..=1, ()).ok(); let primary_selection_device = primary_selection_manager.as_ref().map( @@ -619,7 +619,8 @@ impl WaylandWindow { } pub fn read_primary_selection_text(&mut self) -> Result, Box> { - let preferred_mime = preferred_plain_text_mime(&self.state.primary_selection_offer_mime_types); + let preferred_mime = + preferred_plain_text_mime(&self.state.primary_selection_offer_mime_types); let Some(mime_type) = preferred_mime else { return Ok(self.state.primary_selection_text.clone()); }; @@ -1097,10 +1098,11 @@ fn spawn_window_worker( let mut state_ref = state.borrow_mut(); match state_ref.window.read_clipboard_text() { Ok(Some(text)) => { - let _ = state_ref.event_tx.send(PlatformEvent::ClipboardText { - window_id: state_ref.window_id, - text, - }); + let _ = + state_ref.event_tx.send(PlatformEvent::ClipboardText { + window_id: state_ref.window_id, + text, + }); } Ok(None) => {} Err(error) => { diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index dd9a65c..75db6fa 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -6,7 +6,9 @@ use cosmic_text::{ Attrs, Buffer, CacheKey, FontSystem, Metrics, Shaping, SwashCache, SwashContent, SwashImage, }; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; -use ruin_ui::{Color, DisplayItem, Point, PreparedText, Rect, SceneSnapshot, UiSize}; +use ruin_ui::{ + Color, DisplayItem, Point, PreparedImage, PreparedText, Rect, SceneSnapshot, UiSize, +}; use tracing::trace; use wgpu::util::DeviceExt; @@ -70,6 +72,11 @@ struct CachedTextTexture { size: UiSize, } +struct CachedImageTexture { + _texture: wgpu::Texture, + bind_group: wgpu::BindGroup, +} + struct GlyphAtlas { texture: wgpu::Texture, bind_group: wgpu::BindGroup, @@ -106,6 +113,12 @@ struct UploadedText { vertex_count: u32, } +struct UploadedImage { + bind_group: wgpu::BindGroup, + vertex_buffer: wgpu::Buffer, + vertex_count: u32, +} + struct UploadedAtlasText { vertex_buffer: wgpu::Buffer, vertex_count: u32, @@ -181,10 +194,13 @@ pub struct WgpuSceneRenderer { swash_cache: SwashCache, text_cache: HashMap, text_cache_order: VecDeque, + image_cache: HashMap, + image_cache_order: VecDeque, glyph_atlas: GlyphAtlas, } const MAX_TEXT_CACHE_ENTRIES: usize = 64; +const MAX_IMAGE_CACHE_ENTRIES: usize = 64; const GLYPH_ATLAS_SIZE: u32 = 2048; const GLYPH_ATLAS_PADDING: u32 = 1; @@ -397,6 +413,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { swash_cache: SwashCache::new(), text_cache: HashMap::new(), text_cache_order: VecDeque::new(), + image_cache: HashMap::new(), + image_cache_order: VecDeque::new(), glyph_atlas, }) } @@ -434,6 +452,14 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { usage: wgpu::BufferUsages::VERTEX, }); let text_prepare_start = std::time::Instant::now(); + let uploaded_images: Vec<_> = scene + .items + .iter() + .filter_map(|item| match item { + DisplayItem::Image(image) => self.prepare_uploaded_image(image, scene.logical_size), + _ => None, + }) + .collect(); let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene); let uploaded_texts: Vec<_> = scene .items @@ -480,6 +506,14 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { pass.set_vertex_buffer(0, vertex_buffer.slice(..)); pass.draw(0..vertices.len() as u32, 0..1); } + if !uploaded_images.is_empty() { + pass.set_pipeline(&self.text_pipeline); + for image in &uploaded_images { + pass.set_bind_group(0, &image.bind_group, &[]); + pass.set_vertex_buffer(0, image.vertex_buffer.slice(..)); + pass.draw(0..image.vertex_count, 0..1); + } + } if let Some(atlas_text) = uploaded_atlas_text.as_ref() { pass.set_pipeline(&self.text_pipeline); pass.set_bind_group(0, &self.glyph_atlas.bind_group, &[]); @@ -501,6 +535,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { target: "ruin_ui_renderer_wgpu::perf", scene_version = scene.version, quad_vertices = vertices.len(), + image_batches = uploaded_images.len(), atlas_text_vertices = uploaded_atlas_text .as_ref() .map_or(0_u32, |text| text.vertex_count), @@ -825,6 +860,36 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { Some(rect) } + fn prepare_uploaded_image( + &mut self, + image: &PreparedImage, + logical_size: UiSize, + ) -> Option { + let key = image.resource.id(); + if !self.image_cache.contains_key(&key) { + let cached = self.create_cached_image_texture(image); + self.image_cache.insert(key, cached); + } + self.touch_image_cache_entry(key); + let cached = self + .image_cache + .get(&key) + .expect("image cache entry should exist after insertion"); + let vertices = build_image_vertices(image.rect, image.uv_rect, logical_size); + let vertex_buffer = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("ruin-ui-renderer-wgpu-image-vertices"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + Some(UploadedImage { + bind_group: cached.bind_group.clone(), + vertex_buffer, + vertex_count: vertices.len() as u32, + }) + } + fn prepare_uploaded_text( &mut self, text: &PreparedText, @@ -879,6 +944,23 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { } } + fn touch_image_cache_entry(&mut self, key: u64) { + if let Some(position) = self + .image_cache_order + .iter() + .position(|existing| *existing == key) + { + self.image_cache_order.remove(position); + } + self.image_cache_order.push_back(key); + while self.image_cache_order.len() > MAX_IMAGE_CACHE_ENTRIES { + let Some(evicted) = self.image_cache_order.pop_front() else { + break; + }; + self.image_cache.remove(&evicted); + } + } + fn create_cached_text_texture(&self, text: &RasterizedText) -> CachedTextTexture { let texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("ruin-ui-renderer-wgpu-texture"), @@ -935,6 +1017,61 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { size: text.size, } } + + fn create_cached_image_texture(&self, image: &PreparedImage) -> CachedImageTexture { + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("ruin-ui-renderer-wgpu-image-texture"), + size: wgpu::Extent3d { + width: image.resource.width(), + height: image.resource.height(), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + image.resource.pixels(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(image.resource.width() * 4), + rows_per_image: Some(image.resource.height()), + }, + wgpu::Extent3d { + width: image.resource.width(), + height: image.resource.height(), + depth_or_array_layers: 1, + }, + ); + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ruin-ui-renderer-wgpu-image-bind-group"), + layout: &self.text_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.text_sampler), + }, + ], + }); + CachedImageTexture { + _texture: texture, + bind_group, + } + } } fn create_glyph_atlas( @@ -1234,6 +1371,55 @@ fn build_text_vertices(origin: Point, size: UiSize, logical_size: UiSize) -> [Te ] } +fn build_image_vertices( + rect: Rect, + uv_rect: (f32, f32, f32, f32), + logical_size: UiSize, +) -> [TextVertex; 6] { + let left = to_ndc_x(rect.origin.x, logical_size.width.max(1.0)); + let right = to_ndc_x(rect.origin.x + rect.size.width, logical_size.width.max(1.0)); + let top = to_ndc_y(rect.origin.y, logical_size.height.max(1.0)); + let bottom = to_ndc_y( + rect.origin.y + rect.size.height, + logical_size.height.max(1.0), + ); + let (u0, v0, u1, v1) = uv_rect; + let color = [1.0, 1.0, 1.0, 1.0]; + + [ + TextVertex { + position: [left, top], + uv: [u0, v0], + color, + }, + TextVertex { + position: [left, bottom], + uv: [u0, v1], + color, + }, + TextVertex { + position: [right, top], + uv: [u1, v0], + color, + }, + TextVertex { + position: [right, top], + uv: [u1, v0], + color, + }, + TextVertex { + position: [left, bottom], + uv: [u0, v1], + color, + }, + TextVertex { + position: [right, bottom], + uv: [u1, v1], + color, + }, + ] +} + fn push_glyph_vertices( vertices: &mut Vec, glyph_rect: PixelRect,