diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 9d1e6e522..000000000 --- a/Cargo.lock +++ /dev/null @@ -1,7960 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "adler32" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "aliasable" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" - -[[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 = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android-activity" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" -dependencies = [ - "android-properties", - "bitflags 2.11.0", - "cc", - "cesu8", - "jni", - "jni-sys", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "num_enum", - "thiserror 1.0.69", -] - -[[package]] -name = "android-properties" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "anyrender" -version = "0.7.0" -source = "git+https://github.com/jrmoulton/anyrender?rev=e38ca6c35f45#e38ca6c35f45a2b1f0b1e3ece0e85b1319eab1cd" -dependencies = [ - "kurbo", - "peniko", - "raw-window-handle", -] - -[[package]] -name = "anyrender_skia" -version = "0.4.0" -source = "git+https://github.com/jrmoulton/anyrender?rev=e38ca6c35f45#e38ca6c35f45a2b1f0b1e3ece0e85b1319eab1cd" -dependencies = [ - "anyrender", - "ash", - "ash-window", - "debug_timer", - "gl", - "glutin", - "hashbrown 0.16.1", - "kurbo", - "oaty", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-metal 0.3.2", - "objc2-quartz-core 0.3.2", - "objc2-ui-kit", - "peniko", - "raw-window-handle", - "skia-safe", -] - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "archery" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" - -[[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 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -dependencies = [ - "libloading", -] - -[[package]] -name = "ash-window" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" -dependencies = [ - "ash", - "raw-window-handle", - "raw-window-metal", -] - -[[package]] -name = "async-resource" -version = "0.2.0" -dependencies = [ - "floem", - "open", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -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 0.4.2", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "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 8.0.0", - "num-rational 0.4.2", - "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 = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.11.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2 1.0.106", - "quote 1.0.45", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.117", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitmaps" -version = "3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" - -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2 0.6.4", -] - -[[package]] -name = "borsh" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" -dependencies = [ - "cfg_aliases", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.11.0", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.11.0", - "polling", - "rustix", - "slab", - "tracing", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop", - "rustix", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "cgl" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" -dependencies = [ - "libc", -] - -[[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 = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[package]] -name = "clap" -version = "4.5.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.5.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" -dependencies = [ - "anstyle", - "clap_lex", -] - -[[package]] -name = "clap_lex" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" - -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - -[[package]] -name = "cocoa" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation", - "core-foundation 0.9.4", - "core-graphics", - "foreign-types 0.5.0", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "libc", - "objc", -] - -[[package]] -name = "codespan-reporting" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - -[[package]] -name = "color" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18ef4657441fb193b65f34dc39b3781f0dfec23d3bd94d0eeb4e88cde421edb" -dependencies = [ - "serde", -] - -[[package]] -name = "color_palette" -version = "0.2.0" -dependencies = [ - "floem", - "palette", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "confy" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29222b549d4e3ded127989d523da9e928918d0d0d7f7c1690b439d0d538bae9" -dependencies = [ - "directories", - "serde", - "thiserror 2.0.18", - "toml 0.8.2", -] - -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "console_log" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" -dependencies = [ - "log", - "web-sys", -] - -[[package]] -name = "context" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "copypasta" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" -dependencies = [ - "clipboard-win", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "smithay-clipboard", - "x11-clipboard", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", - "libc", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cosmic-text" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" -dependencies = [ - "bitflags 2.11.0", - "fontdb 0.16.2", - "log", - "rangemap", - "rustc-hash 1.1.0", - "rustybuzz 0.14.1", - "self_cell", - "smol_str 0.2.2", - "swash", - "sys-locale", - "ttf-parser 0.21.1", - "unicode-bidi", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", -] - -[[package]] -name = "counter" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "counter-simple" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "criterion" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" -dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools 0.13.0", - "num-traits", - "oorandom", - "page_size", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" -dependencies = [ - "cast", - "itertools 0.13.0", -] - -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[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-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "ctor-lite" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300" - -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -dependencies = [ - "serde", -] - -[[package]] -name = "custom_keymap" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - -[[package]] -name = "debug_timer" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c" - -[[package]] -name = "deflate" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707b6a7b384888a70c8d2e8650b3e60170dfc6a67bb4aa67b6dfca57af4bedb4" -dependencies = [ - "adler32", - "byteorder", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "libc", - "objc2 0.6.4", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - -[[package]] -name = "dpi" -version = "0.1.2" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "serde", -] - -[[package]] -name = "drm" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b71449a23fe79542d6527ca572844b2016abf9573c49e43144d546b1735aec" -dependencies = [ - "bitflags 2.11.0", - "bytemuck", - "bytemuck_derive", - "drm-ffi", - "drm-fourcc", - "libc", - "rustix", -] - -[[package]] -name = "drm-ffi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" -dependencies = [ - "drm-sys", - "rustix", -] - -[[package]] -name = "drm-fourcc" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" - -[[package]] -name = "drm-sys" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" -dependencies = [ - "libc", - "linux-raw-sys 0.9.4", -] - -[[package]] -name = "dyn-container" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "editor" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "educe" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" -dependencies = [ - "enum-ordinalize", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "enum-ordinalize" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" -dependencies = [ - "enum-ordinalize-derive", -] - -[[package]] -name = "enum-ordinalize-derive" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[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 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - -[[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 = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[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 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "files" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[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 = "flight_booker" -version = "0.2.0" -dependencies = [ - "floem", - "strum", - "time", -] - -[[package]] -name = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" - -[[package]] -name = "floem" -version = "0.2.0" -dependencies = [ - "bitflags 2.11.0", - "clipboard-win", - "copypasta", - "criterion", - "crossbeam", - "dispatch2", - "dpi 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", - "educe", - "floem-editor-core", - "floem-winit", - "floem_reactive", - "floem_renderer", - "floem_skia_renderer", - "floem_tiny_skia_renderer", - "floem_vello_renderer", - "floem_vger_renderer", - "fluent-bundle", - "futures", - "image 0.25.9", - "imbl", - "indexmap", - "lapce-xi-rope", - "muda", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "ouroboros", - "parking_lot", - "parley", - "paste", - "peniko", - "raw-window-handle", - "rfd", - "rustc-hash 2.1.1", - "serde", - "serde_json", - "sha2", - "slotmap", - "smallvec", - "strum", - "strum_macros", - "sys-locale", - "taffy", - "tokio", - "ui-events", - "ui-events-floem-winit", - "understory_box_tree", - "understory_event_state", - "understory_focus", - "understory_index", - "understory_virtual_list", - "unic-langid", - "unicode-segmentation", - "wasm-bindgen-futures", - "web-time", - "wgpu", -] - -[[package]] -name = "floem-editor-core" -version = "0.2.0" -dependencies = [ - "bitflags 2.11.0", - "itertools 0.14.0", - "lapce-xi-rope", - "memchr", - "serde", - "strum", - "strum_macros", - "ui-events", -] - -[[package]] -name = "floem-test" -version = "0.2.0" -dependencies = [ - "floem", - "serial_test", - "ui-events", -] - -[[package]] -name = "floem-vger" -version = "0.3.2" -source = "git+https://github.com/lapce/vger-rs?rev=54ab8135d9d870a4ac2b84fc6b9846b3b03ec919#54ab8135d9d870a4ac2b84fc6b9846b3b03ec919" -dependencies = [ - "euclid", - "fontdue", - "linebender_resource_handle", - "rect_packer", - "wgpu", -] - -[[package]] -name = "floem-winit" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "bitflags 2.11.0", - "cfg_aliases", - "cursor-icon", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-android", - "floem-winit-appkit", - "floem-winit-common", - "floem-winit-core", - "floem-winit-orbital", - "floem-winit-uikit", - "floem-winit-wayland", - "floem-winit-web", - "floem-winit-win32", - "floem-winit-x11", - "libc", - "raw-window-handle", - "rustix", - "serde", - "smol_str 0.3.6", - "tracing", -] - -[[package]] -name = "floem-winit-android" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "android-activity", - "bitflags 2.11.0", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-core", - "ndk", - "raw-window-handle", - "smol_str 0.3.6", - "tracing", -] - -[[package]] -name = "floem-winit-appkit" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "dispatch2", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-common", - "floem-winit-core", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-video", - "objc2-foundation 0.3.2", - "raw-window-handle", - "smol_str 0.3.6", - "tracing", -] - -[[package]] -name = "floem-winit-common" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "floem-winit-core", - "memmap2", - "objc2 0.6.4", - "objc2-core-foundation", - "smol_str 0.3.6", - "tracing", - "x11-dl", - "xkbcommon-dl", -] - -[[package]] -name = "floem-winit-core" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "bitflags 2.11.0", - "cursor-icon", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "keyboard-types 0.8.3", - "raw-window-handle", - "serde", - "smol_str 0.3.6", - "web-time", -] - -[[package]] -name = "floem-winit-orbital" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "bitflags 2.11.0", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-core", - "orbclient", - "raw-window-handle", - "redox_syscall 0.5.18", - "smol_str 0.3.6", - "tracing", -] - -[[package]] -name = "floem-winit-uikit" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "dispatch2", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-common", - "floem-winit-core", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-ui-kit", - "raw-window-handle", - "serde", - "smol_str 0.3.6", - "tracing", -] - -[[package]] -name = "floem-winit-wayland" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "ahash", - "bitflags 2.11.0", - "calloop", - "cursor-icon", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-common", - "floem-winit-core", - "libc", - "memmap2", - "raw-window-handle", - "rustix", - "sctk-adwaita", - "smithay-client-toolkit", - "smol_str 0.3.6", - "tracing", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", -] - -[[package]] -name = "floem-winit-web" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "atomic-waker", - "bitflags 2.11.0", - "concurrent-queue", - "cursor-icon", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-core", - "js-sys", - "pin-project", - "raw-window-handle", - "smol_str 0.3.6", - "tracing", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "web-time", -] - -[[package]] -name = "floem-winit-win32" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "bitflags 2.11.0", - "cursor-icon", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-core", - "raw-window-handle", - "smol_str 0.3.6", - "tracing", - "unicode-segmentation", - "windows-sys 0.59.0", -] - -[[package]] -name = "floem-winit-x11" -version = "25.10.0" -source = "git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1#133268de675a4fda70816d78a7a1435eb972fea1" -dependencies = [ - "bitflags 2.11.0", - "bytemuck", - "calloop", - "cursor-icon", - "dpi 0.1.2 (git+https://github.com/lapce/winit?rev=133268de675a4fda70816d78a7a1435eb972fea1)", - "floem-winit-common", - "floem-winit-core", - "libc", - "percent-encoding", - "raw-window-handle", - "rustix", - "smol_str 0.3.6", - "tracing", - "x11-dl", - "x11rb", - "xkbcommon-dl", -] - -[[package]] -name = "floem_reactive" -version = "0.2.0" -dependencies = [ - "parking_lot", - "smallvec", -] - -[[package]] -name = "floem_renderer" -version = "0.2.0" -dependencies = [ - "crossbeam", - "floem-winit", - "fontique", - "futures", - "parking_lot", - "parley", - "peniko", - "resvg", - "wasm-bindgen-futures", - "wgpu", -] - -[[package]] -name = "floem_skia_renderer" -version = "0.2.0" -dependencies = [ - "anyhow", - "anyrender", - "anyrender_skia", - "floem-winit", - "floem_renderer", - "kurbo", - "parley", - "peniko", - "raw-window-handle", - "resvg", -] - -[[package]] -name = "floem_tiny_skia_renderer" -version = "0.2.0" -dependencies = [ - "anyhow", - "floem_renderer", - "parley", - "peniko", - "raw-window-handle", - "resvg", - "rustc-hash 2.1.1", - "softbuffer", - "swash", -] - -[[package]] -name = "floem_vello_renderer" -version = "0.2.0" -dependencies = [ - "anyhow", - "floem_renderer", - "parley", - "peniko", - "vello", - "vello_svg", - "wgpu", -] - -[[package]] -name = "floem_vger_renderer" -version = "0.2.0" -dependencies = [ - "anyhow", - "floem-vger", - "floem_renderer", - "image 0.25.9", - "parley", - "peniko", - "resvg", - "swash", - "wgpu", -] - -[[package]] -name = "fluent-bundle" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" -dependencies = [ - "fluent-langneg", - "fluent-syntax", - "intl-memoizer", - "intl_pluralrules", - "rustc-hash 2.1.1", - "self_cell", - "smallvec", - "unic-langid", -] - -[[package]] -name = "fluent-langneg" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" -dependencies = [ - "unic-langid", -] - -[[package]] -name = "fluent-syntax" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" -dependencies = [ - "memchr", - "thiserror 2.0.18", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "font-types" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "font-types" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4d2d0cf79d38430cc9dc9aadec84774bff2e1ba30ae2bf6c16cfce9385a23" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "fontconfig-parser" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" -dependencies = [ - "roxmltree 0.20.0", -] - -[[package]] -name = "fontdb" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser 0.20.0", -] - -[[package]] -name = "fontdb" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser 0.25.1", -] - -[[package]] -name = "fontdue" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" -dependencies = [ - "hashbrown 0.15.5", - "ttf-parser 0.21.1", -] - -[[package]] -name = "fontique" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bbc252c93499b6d3635d692f892a637db0dbb130ce9b32bf20b28e0dcc470b" -dependencies = [ - "bytemuck", - "hashbrown 0.16.1", - "icu_locale_core", - "linebender_resource_handle", - "memmap2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-text", - "objc2-foundation 0.3.2", - "read-fonts 0.35.0", - "roxmltree 0.21.1", - "smallvec", - "windows", - "windows-core", - "yeslogic-fontconfig-sys", -] - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[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 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gif" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471d90201b3b223f3451cd4ad53e34295f16a1df17b1edf3736d47761c3981af" -dependencies = [ - "color_quant", - "lzw", -] - -[[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 = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "gl" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.11.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "glow" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" -dependencies = [ - "bitflags 2.11.0", - "cfg_aliases", - "cgl", - "dispatch2", - "glutin_egl_sys", - "glutin_glx_sys", - "glutin_wgl_sys", - "libloading", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "raw-window-handle", - "wayland-sys", - "windows-sys 0.52.0", - "x11-dl", -] - -[[package]] -name = "glutin_egl_sys" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" -dependencies = [ - "gl_generator", - "windows-sys 0.52.0", -] - -[[package]] -name = "glutin_glx_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" -dependencies = [ - "gl_generator", - "x11-dl", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.11.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "gpu-allocator" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" -dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "windows", -] - -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.11.0", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "grid" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e2d4c0a8296178d8802098410ca05d86b17a10bb5ab559b3fb404c1f948220" - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "guillotiere" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" -dependencies = [ - "euclid", - "svg_fmt", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "handle_window_close" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "harfrust" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" -dependencies = [ - "bitflags 2.11.0", - "bytemuck", - "core_maths", - "read-fonts 0.35.0", - "smallvec", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebdff791af04e30089bde8ad2a632b86af433b40c04db8d70ad4b21487db7a6a" -dependencies = [ - "byteorder", - "gif 0.10.3", - "jpeg-decoder", - "lzw", - "num-derive 0.2.5", - "num-iter", - "num-rational 0.1.43", - "num-traits", - "png 0.12.0", - "scoped_threadpool", -] - -[[package]] -name = "image" -version = "0.25.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif 0.14.1", - "image-webp", - "moxcms", - "num-traits", - "png 0.18.1", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", -] - -[[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 = "imagesize" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" - -[[package]] -name = "imbl" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e525189e5f603908d0c6e0d402cb5de9c4b2c8866151fabc4ebd771ed2630a2e" -dependencies = [ - "archery", - "bitmaps", - "imbl-sized-chunks", - "rand_core 0.9.5", - "rand_xoshiro", - "version_check", - "wide", -] - -[[package]] -name = "imbl-sized-chunks" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" -dependencies = [ - "bitmaps", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "inflate" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb29978cc5797bd8dcc8e5bf7de604891df2a8dc576973d71a281e916db2ff" -dependencies = [ - "adler32", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "intl-memoizer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" -dependencies = [ - "type-map", - "unic-langid", -] - -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -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 0.3.4", - "libc", -] - -[[package]] -name = "jpeg-decoder" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" -dependencies = [ - "rayon", -] - -[[package]] -name = "js-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.11.0", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "keyboard-types" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" -dependencies = [ - "bitflags 2.11.0", - "serde", -] - -[[package]] -name = "keyboard_handler" -version = "0.2.0" -dependencies = [ - "floem", -] - -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", - "pkg-config", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "kurbo" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" -dependencies = [ - "arrayvec", - "euclid", - "serde", - "smallvec", -] - -[[package]] -name = "lapce-xi-rope" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc91b8df26aaa2700656a7c1a5b0aeb587aca512a44618974f79673dbe793ba" -dependencies = [ - "bytecount", - "memchr", - "regex", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "layout" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[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.182" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" -dependencies = [ - "bitflags 2.11.0", - "libc", - "plain", - "redox_syscall 0.7.3", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linebender_resource_handle" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" -dependencies = [ - "serde", - "serde_bytes", -] - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "localization" -version = "0.2.0" -dependencies = [ - "floem", -] - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -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 = "lzw" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084" - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "metal" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" -dependencies = [ - "bitflags 2.11.0", - "block", - "core-graphics-types 0.2.0", - "foreign-types 0.5.0", - "log", - "objc", - "paste", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[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 = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "moxcms" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "muda" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" -dependencies = [ - "crossbeam-channel", - "dpi 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", - "gtk", - "keyboard-types 0.7.0", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "png 0.17.16", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "naga" -version = "27.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" -dependencies = [ - "arrayvec", - "bit-set", - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.16.1", - "hexf-parse", - "indexmap", - "libm", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv", - "thiserror 2.0.18", - "unicode-ident", -] - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "navigation-stack-mobile" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.11.0", - "jni-sys", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -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 = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[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 = "nsvg" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfa50149c05ca80b01c6a30452084a98d96279f911df8b6840bd18b068cc120" -dependencies = [ - "cc", - "image 0.19.0", -] - -[[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-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-derive" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eafd0b45c5537c3ba526f79d3e75120036502bebacbb3f3220914067ce39dbf2" -dependencies = [ - "proc-macro2 0.4.30", - "quote 0.6.13", - "syn 0.15.44", -] - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[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-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.1.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfff0773e8a07fb033d726b9ff1327466709820788e5298afce4d752965ff1e" -dependencies = [ - "autocfg", - "num-integer", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_enum" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "oaty" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd7890d4a4c9205ee57a5e200658e78ed7d51c9cd48d0f7066596a19cc20e5d2" -dependencies = [ - "font-types 0.10.1", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "libc", - "objc2 0.5.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", - "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "libc", - "objc2 0.6.4", - "objc2-cloud-kit", - "objc2-core-data 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image 0.3.2", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "dispatch2", - "objc2 0.6.4", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.11.0", - "dispatch2", - "libc", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "libc", - "objc2 0.5.2", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.11.0", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.11.0", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "onig" -version = "6.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" -dependencies = [ - "bitflags 2.11.0", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "orbclient" -version = "0.3.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" -dependencies = [ - "libc", - "libredox", -] - -[[package]] -name = "ordered-float" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ouroboros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" -dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" -dependencies = [ - "heck 0.4.1", - "proc-macro2 1.0.106", - "proc-macro2-diagnostics", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser 0.25.1", -] - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "palette" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" -dependencies = [ - "approx", - "fast-srgb8", - "palette_derive", - "phf", -] - -[[package]] -name = "palette_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" -dependencies = [ - "by_address", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "pan-zoom" -version = "0.2.0" -dependencies = [ - "floem", -] - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "parley" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada5338c3a9794af7342e6f765b6e78740db37378aced034d7bf72c96b94ed94" -dependencies = [ - "fontique", - "harfrust", - "hashbrown 0.16.1", - "linebender_resource_handle", - "skrifa 0.37.0", - "swash", -] - -[[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 = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "peniko" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2b6aadb221872732e87d465213e9be5af2849b0e8cc5300a8ba98fffa2e00a" -dependencies = [ - "color", - "kurbo", - "linebender_resource_handle", - "serde", - "smallvec", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pico-args" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" - -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64 0.22.1", - "indexmap", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - -[[package]] -name = "png" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f54b9600d584d3b8a739e1662a595fab051329eff43f20e7d8cc22872962145b" -dependencies = [ - "bitflags 1.3.2", - "deflate", - "inflate", - "num-iter", -] - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.11.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2 1.0.106", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.3+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "version_check", -] - -[[package]] -name = "proc-macro2" -version = "0.4.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" -dependencies = [ - "unicode-xid 0.1.0", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", - "version_check", - "yansi", -] - -[[package]] -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 1.0.45", - "syn 2.0.117", -] - -[[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" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "0.6.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" -dependencies = [ - "proc-macro2 0.4.30", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2 1.0.106", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[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 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_xoshiro" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "range-alloc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - -[[package]] -name = "rangemap" -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 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive 0.4.2", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "raw-window-metal" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" -dependencies = [ - "cocoa", - "core-graphics", - "objc", - "raw-window-handle", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" -dependencies = [ - "bytemuck", - "core_maths", - "font-types 0.10.1", -] - -[[package]] -name = "read-fonts" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" -dependencies = [ - "bytemuck", - "font-types 0.11.0", -] - -[[package]] -name = "rect_packer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8ffb4dfda4b01cc420847665dc480760d596ce186f2772a66ed32fe9acb1c45" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "hyper", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - -[[package]] -name = "responsive" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "resvg" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" -dependencies = [ - "gif 0.14.1", - "image-webp", - "log", - "pico-args", - "rgb", - "svgtypes", - "tiny-skia", - "usvg", - "zune-jpeg 0.5.12", -] - -[[package]] -name = "rfd" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" -dependencies = [ - "block2 0.6.2", - "dispatch2", - "js-sys", - "libc", - "log", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "percent-encoding", - "pollster", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "web-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - -[[package]] -name = "roxmltree" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" -dependencies = [ - "memchr", -] - -[[package]] -name = "rusqlite" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" -dependencies = [ - "bitflags 2.11.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rustybuzz" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" -dependencies = [ - "bitflags 2.11.0", - "bytemuck", - "libm", - "smallvec", - "ttf-parser 0.21.1", - "unicode-bidi-mirroring 0.2.0", - "unicode-ccc 0.2.0", - "unicode-properties", - "unicode-script", -] - -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.11.0", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser 0.25.1", - "unicode-bidi-mirroring 0.4.0", - "unicode-ccc 0.4.0", - "unicode-properties", - "unicode-script", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scoped_threadpool" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sctk-adwaita" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd3accc0f3f4bbaf2c9e1957a030dc582028130c67660d44c0a0345a22ca69b" -dependencies = [ - "ab_glyph", - "log", - "memmap2", - "smithay-client-toolkit", - "tiny-skia", -] - -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serial_test" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" -dependencies = [ - "futures-executor", - "futures-util", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "signpost" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acde4ef7ee97ad3bac7c4f73ee22ddb71067aa1d427a22efa76284de48eada41" - -[[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 1.0.45", -] - -[[package]] -name = "simplecss" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" -dependencies = [ - "log", -] - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "skia-bindings" -version = "0.91.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2dd9bbd87993cbe4c0caa5fa6ca69c7d2ed38dcf5cd967b200392840c3bc666" -dependencies = [ - "bindgen", - "cc", - "flate2", - "heck 0.5.0", - "pkg-config", - "regex", - "serde_json", - "tar", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "skia-safe" -version = "0.91.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c13e5913d17403753fd8cc2c15402e8d9ba8278d1b82a12aa7264a63f9c6325" -dependencies = [ - "bitflags 2.11.0", - "skia-bindings", -] - -[[package]] -name = "skrifa" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" -dependencies = [ - "bytemuck", - "read-fonts 0.35.0", -] - -[[package]] -name = "skrifa" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" -dependencies = [ - "bytemuck", - "read-fonts 0.37.0", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slider-types" -version = "0.2.0" -dependencies = [ - "floem", -] - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.11.0", - "calloop", - "calloop-wayland-source", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix", - "thiserror 2.0.18", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit", - "wayland-backend", -] - -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" - -[[package]] -name = "smol_str" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" -dependencies = [ - "borsh", - "serde_core", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -dependencies = [ - "as-raw-xcb-connection", - "bytemuck", - "drm", - "fastrand", - "js-sys", - "memmap2", - "ndk", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "raw-window-handle", - "redox_syscall 0.5.18", - "rustix", - "tiny-xlib", - "tracing", - "wasm-bindgen", - "wayland-backend", - "wayland-client", - "wayland-sys", - "web-sys", - "windows-sys 0.61.2", - "x11rb", -] - -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stacks" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -dependencies = [ - "float-cmp", -] - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck 0.5.0", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "svg_fmt" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" - -[[package]] -name = "svgtypes" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" -dependencies = [ - "kurbo", - "siphasher", -] - -[[package]] -name = "swash" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" -dependencies = [ - "skrifa 0.37.0", - "yazi", - "zeno", -] - -[[package]] -name = "syn" -version = "0.15.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" -dependencies = [ - "proc-macro2 0.4.30", - "quote 0.6.13", - "unicode-xid 0.1.0", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2 1.0.106", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "syntax-editor" -version = "0.1.0" -dependencies = [ - "floem", - "syntect", -] - -[[package]] -name = "syntect" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" -dependencies = [ - "bincode", - "flate2", - "fnv", - "once_cell", - "onig", - "plist", - "regex-syntax", - "serde", - "serde_derive", - "serde_json", - "thiserror 2.0.18", - "walkdir", - "yaml-rust", -] - -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "libc", -] - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", -] - -[[package]] -name = "taffy" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" -dependencies = [ - "arrayvec", - "grid", - "serde", - "slotmap", -] - -[[package]] -name = "tar" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tempfile" -version = "3.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "themes" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "tiff" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg 0.4.21", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "timer" -version = "0.2.0" -dependencies = [ - "floem", -] - -[[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png 0.17.16", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tiny-xlib" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" -dependencies = [ - "as-raw-xcb-connection", - "ctor-lite", - "libloading", - "pkg-config", - "tracing", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "todo-complex" -version = "0.1.0" -dependencies = [ - "confy", - "floem", - "rusqlite", - "serde", -] - -[[package]] -name = "tokio" -version = "1.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-timer" -version = "0.2.0" -dependencies = [ - "floem", - "tokio", - "tokio-stream", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.14", -] - -[[package]] -name = "toml_datetime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.0.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.25.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0a07913e63758bc95142d9863a5a45173b71515e68b690cad70cf99c3255ce1" -dependencies = [ - "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", - "toml_parser", - "winnow 0.7.14", -] - -[[package]] -name = "toml_parser" -version = "1.0.9+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" -dependencies = [ - "winnow 0.7.14", -] - -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "ttf-parser" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" - -[[package]] -name = "ttf-parser" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" - -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - -[[package]] -name = "type-map" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" -dependencies = [ - "rustc-hash 2.1.1", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "ui-events" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e29e3e199888d594edd191c5917a2d5c1893b8bf20076c2bf4df7a40a2ee71" -dependencies = [ - "dpi 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", - "keyboard-types 0.8.3", - "kurbo", -] - -[[package]] -name = "ui-events-floem-winit" -version = "0.2.0" -dependencies = [ - "dpi 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", - "floem-winit", - "ui-events", - "web-time", -] - -[[package]] -name = "understory_box_tree" -version = "0.0.1" -source = "git+https://github.com/jrmoulton/understory?rev=2c2abb8c4bd9#2c2abb8c4bd9bcca1168e51cbc614d8cfd2d3910" -dependencies = [ - "bitflags 2.11.0", - "kurbo", - "understory_index", -] - -[[package]] -name = "understory_event_state" -version = "0.1.0" -source = "git+https://github.com/jrmoulton/understory?rev=2c2abb8c4bd9#2c2abb8c4bd9bcca1168e51cbc614d8cfd2d3910" -dependencies = [ - "kurbo", -] - -[[package]] -name = "understory_focus" -version = "0.1.0" -source = "git+https://github.com/jrmoulton/understory?rev=2c2abb8c4bd9#2c2abb8c4bd9bcca1168e51cbc614d8cfd2d3910" -dependencies = [ - "kurbo", -] - -[[package]] -name = "understory_index" -version = "0.0.1" -source = "git+https://github.com/jrmoulton/understory?rev=2c2abb8c4bd9#2c2abb8c4bd9bcca1168e51cbc614d8cfd2d3910" -dependencies = [ - "hashbrown 0.16.1", - "smallvec", -] - -[[package]] -name = "understory_virtual_list" -version = "0.1.0" -source = "git+https://github.com/jrmoulton/understory?rev=2c2abb8c4bd9#2c2abb8c4bd9bcca1168e51cbc614d8cfd2d3910" - -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-bidi-mirroring" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" - -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-vo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "usvg" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" -dependencies = [ - "base64 0.22.1", - "data-url", - "flate2", - "fontdb 0.23.0", - "imagesize", - "kurbo", - "log", - "pico-args", - "roxmltree 0.21.1", - "rustybuzz 0.20.1", - "simplecss", - "siphasher", - "strict-num", - "svgtypes", - "tiny-skia-path", - "unicode-bidi", - "unicode-script", - "unicode-vo", - "xmlwriter", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[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 = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "vello" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72fef40773530322d5c2ffe3c1107e9874bd8239ac137d1c2b6c1edad695146e" -dependencies = [ - "bytemuck", - "futures-intrusive", - "log", - "peniko", - "png 0.17.16", - "skrifa 0.40.0", - "static_assertions", - "thiserror 2.0.18", - "vello_encoding", - "vello_shaders", - "wgpu", -] - -[[package]] -name = "vello_encoding" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24c91203ec4b483440614a9a5c7c2d991932af72c5349659a63ec49476f0b79c" -dependencies = [ - "bytemuck", - "guillotiere", - "peniko", - "skrifa 0.40.0", - "smallvec", -] - -[[package]] -name = "vello_shaders" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a765d44d4bd354146e44f9a860f4e92effd91a97302549be9e47f0a18d8128c" -dependencies = [ - "bytemuck", - "log", - "naga", - "thiserror 2.0.18", - "vello_encoding", -] - -[[package]] -name = "vello_svg" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79dfa03829a8fb45ad458a05bd533d399d2259d042cdd78f391b290dbbdf1bd3" -dependencies = [ - "image 0.25.9", - "thiserror 2.0.18", - "usvg", - "vello", -] - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "view-transition" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "virtual_list" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[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 = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" -dependencies = [ - "quote 1.0.45", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" -dependencies = [ - "bumpalo", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wayland-backend" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" -dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" -dependencies = [ - "bitflags 2.11.0", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.11.0", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" -dependencies = [ - "rustix", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" -dependencies = [ - "bitflags 2.11.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.11.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791c58fdeec5406aa37169dd815327d1e47f334219b523444bc26d70ceb4c34e" -dependencies = [ - "bitflags 2.11.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" -dependencies = [ - "bitflags 2.11.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" -dependencies = [ - "bitflags 2.11.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" -dependencies = [ - "proc-macro2 1.0.106", - "quick-xml", - "quote 1.0.45", -] - -[[package]] -name = "wayland-sys" -version = "0.31.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" -dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webgpu" -version = "0.2.0" -dependencies = [ - "console_error_panic_hook", - "console_log", - "cosmic-text", - "floem", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "wgpu" -version = "27.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" -dependencies = [ - "arrayvec", - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "document-features", - "hashbrown 0.16.1", - "js-sys", - "log", - "naga", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "27.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" -dependencies = [ - "arrayvec", - "bit-set", - "bit-vec", - "bitflags 2.11.0", - "bytemuck", - "cfg_aliases", - "document-features", - "hashbrown 0.16.1", - "indexmap", - "log", - "naga", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 2.0.18", - "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core-deps-apple" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "27.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags 2.11.0", - "block", - "bytemuck", - "cfg-if", - "cfg_aliases", - "core-graphics-types 0.2.0", - "glow", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-allocator", - "gpu-descriptor", - "hashbrown 0.16.1", - "js-sys", - "khronos-egl", - "libc", - "libloading", - "log", - "metal", - "naga", - "ndk-sys", - "objc", - "once_cell", - "ordered-float", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "profiling", - "range-alloc", - "raw-window-handle", - "renderdoc-sys", - "smallvec", - "thiserror 2.0.18", - "wasm-bindgen", - "web-sys", - "wgpu-types", - "windows", - "windows-core", -] - -[[package]] -name = "wgpu-types" -version = "27.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" -dependencies = [ - "bitflags 2.11.0", - "bytemuck", - "js-sys", - "log", - "thiserror 2.0.18", - "web-sys", -] - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - -[[package]] -name = "widget-gallery" -version = "0.1.0" -dependencies = [ - "files", - "floem", - "signpost", - "strum", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "window-icon" -version = "0.1.0" -dependencies = [ - "floem", - "image 0.25.9", - "nsvg", -] - -[[package]] -name = "window-scale" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "window-size" -version = "0.1.0" -dependencies = [ - "floem", -] - -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", - "windows-strings", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid 0.2.6", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "x11-clipboard" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" -dependencies = [ - "libc", - "x11rb", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "as-raw-xcb-connection", - "gethostname", - "libc", - "libloading", - "once_cell", - "rustix", - "x11rb-protocol", - "xcursor", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "xcursor" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" - -[[package]] -name = "xkbcommon-dl" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" -dependencies = [ - "bitflags 2.11.0", - "dlib", - "log", - "once_cell", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - -[[package]] -name = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yazi" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" - -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zeno" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" - -[[package]] -name = "zerocopy" -version = "0.8.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - -[[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.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" -dependencies = [ - "zune-core 0.5.1", -] diff --git a/Cargo.toml b/Cargo.toml index 5b03934fb..6eca875ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,6 @@ [workspace] resolver = "3" members = [ - "renderer", - "skia", - "vello", - "vger", - "tiny_skia", "reactive", "editor-core", "examples/*", @@ -14,11 +9,6 @@ members = [ ] default-members = [ ".", - "renderer", - "skia", - "vello", - "vger", - "tiny_skia", "reactive", "editor-core", "ui-events-winit", @@ -55,10 +45,10 @@ strum_macros = { version = "0.28" } # Image format features are defined via new features image = { version = "0.25", default-features = false } imbl = "7.0" -resvg = { version = "0.46" } +tiny-skia = { version = "0.11.4" } raw-window-handle = "0.6.0" unicode-segmentation = "1.10" -wgpu = { version = "27.0" } +wgpu = { version = "28.0" } parking_lot = { version = "0.12" } swash = { version = "0.2" } muda = { version = "0.17.1", default-features = false } @@ -67,6 +57,10 @@ ui-events = { version = "0.3", features = ["kurbo"] } ui-events-winit = { path = "./ui-events-winit", package = "ui-events-floem-winit" } dpi = { version = "0.1.2", default-features = false } rustc-hash = "2.1" +imaging = { git = "https://github.com/forest-rs/imaging", rev = "ce53c4bceb10fbb653cb17bd4504ddae223ae169", package = "imaging" } +imaging_wgpu = { git = "https://github.com/forest-rs/imaging", rev = "ce53c4bceb10fbb653cb17bd4504ddae223ae169", package = "imaging_wgpu", features = ["wgpu-28"] } +svg_imaging = { git = "https://github.com/forest-rs/imaging", rev = "ce53c4bceb10fbb653cb17bd4504ddae223ae169", package = "svg_imaging" } +understory_frame_pacing = { git = "https://github.com/jrmoulton/understory", rev = "404febe54512", package = "understory_frame_pacing" } [dependencies] slotmap = "1.0" @@ -74,6 +68,9 @@ sha2 = "0.10" bitflags = "2.9" indexmap = "2" rustc-hash.workspace = true +imaging = { workspace = true, features = ["std"] } +imaging_wgpu = { workspace = true } +svg_imaging.workspace = true smallvec = "1.15" educe = "0.6" taffy = { version = "0.9.2", features = ["grid"] } @@ -84,25 +81,32 @@ unicode-segmentation = { workspace = true } peniko = { workspace = true } imbl.workspace = true parley = { workspace = true } +fontique = { workspace = true } serde = { workspace = true, optional = true } serde_json = { workspace = true } lapce-xi-rope = { workspace = true, optional = true } strum = { workspace = true, optional = true } strum_macros = { workspace = true, optional = true } paste = "1.0" -floem_renderer = { path = "renderer", version = "0.2.0" } -floem_skia_renderer = { path = "skia", version = "0.2.0", optional = true } -floem_vello_renderer = { path = "vello", version = "0.2.0", optional = true } -floem_vger_renderer = { path = "vger", version = "0.2.0", optional = true } -floem_tiny_skia_renderer = { path = "tiny_skia", version = "0.2.0" } +floem_vger_renderer = { path = "../vger-rs", package = "floem-vger", optional = true } +imaging_vello = { git = "https://github.com/forest-rs/imaging", rev = "ce53c4bceb10fbb653cb17bd4504ddae223ae169", package = "imaging_vello", optional = true } +imaging_skia = { git = "https://github.com/forest-rs/imaging", rev = "ce53c4bceb10fbb653cb17bd4504ddae223ae169", package = "imaging_skia", optional = true, features = ["gpu"] } +imaging_tiny_skia = { git = "https://github.com/forest-rs/imaging", rev = "ce53c4bceb10fbb653cb17bd4504ddae223ae169", package = "imaging_tiny_skia", optional = true } +imaging_vello_cpu = { git = "https://github.com/forest-rs/imaging", rev = "ce53c4bceb10fbb653cb17bd4504ddae223ae169", package = "imaging_vello_cpu", optional = true } +imaging_vello_hybrid = { git = "https://github.com/forest-rs/imaging", rev = "ce53c4bceb10fbb653cb17bd4504ddae223ae169", package = "imaging_vello_hybrid", optional = true } floem_reactive = { path = "reactive", version = "0.2.0" } floem-editor-core = { path = "editor-core", version = "0.2.0", optional = true } +subduction_core = { git = "https://github.com/forest-rs/subduction", rev = "c684827b4a4e", package = "subduction_core", features = ["std"] } +subduction_render = { git = "https://github.com/forest-rs/subduction", rev = "c684827b4a4e", package = "subduction_render", features = ["std"] } +subduction = { git = "https://github.com/forest-rs/subduction", rev = "c684827b4a4e", package = "subduction" } copypasta = { version = "0.10", default-features = false, features = [ "wayland", "x11", ] } parking_lot = { workspace = true } +swash = { workspace = true, features = ["scale"] } image = { workspace = true } +softbuffer = "0.4.1" winit = { workspace = true } futures = { version = "0.3.31" } crossbeam = { version = "0.8", optional = true } @@ -114,11 +118,19 @@ fluent-bundle = { version = "0.16", optional = true } unic-langid = { version = "0.9", optional = true } sys-locale = { version = "0.3.2", optional = true } ouroboros = { version = "0.18", optional = true } -understory_box_tree = { git = "https://github.com/jrmoulton/understory", rev = "2c2abb8c4bd9", package = "understory_box_tree" } -understory_event_state = { git = "https://github.com/jrmoulton/understory", rev = "2c2abb8c4bd9", package = "understory_event_state" } -understory_index = { git = "https://github.com/jrmoulton/understory", rev = "2c2abb8c4bd9", package = "understory_index" } -understory_focus = { git = "https://github.com/jrmoulton/understory", rev = "2c2abb8c4bd9", package = "understory_focus" } -understory_virtual_list = { git = "https://github.com/jrmoulton/understory", rev = "2c2abb8c4bd9", package = "understory_virtual_list" } +understory_box_tree = { git = "https://github.com/jrmoulton/understory", rev = "404febe54512", package = "understory_box_tree" } +understory_event_state = { git = "https://github.com/jrmoulton/understory", rev = "404febe54512", package = "understory_event_state" } +understory_index = { git = "https://github.com/jrmoulton/understory", rev = "404febe54512", package = "understory_index" } +understory_focus = { git = "https://github.com/jrmoulton/understory", rev = "404febe54512", package = "understory_focus" } +understory_virtual_list = { git = "https://github.com/jrmoulton/understory", rev = "404febe54512", package = "understory_virtual_list" } +understory_view2d = { git = "https://github.com/jrmoulton/understory", rev = "404febe54512", package = "understory_view2d" } +understory_frame_pacing.workspace = true + +# understory_box_tree = { path = "../understory/understory_box_tree", package = "understory_box_tree" } +# understory_event_state = { path = "../understory/understory_event_state", package = "understory_event_state" } +# understory_index = { path = "../understory/understory_index", package = "understory_index" } +# understory_focus = { path = "../understory/understory_focus", package = "understory_focus" } +# understory_virtual_list = { path = "../understory/understory_virtual_list", package = "understory_virtual_list" } [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] muda = { workspace = true } @@ -133,23 +145,37 @@ wgpu = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] clipboard-win = "5.4" +subduction_backend_windows = { git = "https://github.com/forest-rs/subduction", rev = "c684827b4a4e", package = "subduction_backend_windows" } [target.'cfg(target_os = "macos")'.dependencies] +block = "0.1" +metal = "0.33" objc2 = { version = "0.6", default-features = false } objc2-foundation = { version = "0.3", default-features = false } objc2-app-kit = { version = "0.3", features = [ "NSApplication", "NSResponder", "NSView", + "objc2-quartz-core", ] } dispatch2 = {version = "0.3.0"} +subduction_backend_apple = { git = "https://github.com/forest-rs/subduction", rev = "c684827b4a4e", package = "subduction_backend_apple", default-features = false, features = ["appkit", "ca-display-link", "cametal-display-link", "cv-display-link", "wgpu"] } [features] -default = ["editor", "default-image-formats", "vger", "serde"] -skia = ["dep:floem_skia_renderer"] -vello = ["dep:floem_vello_renderer"] +default = [ + "editor", + "default-image-formats", + "serde", + "vger", + "vello-cpu" +] +vello = ["dep:imaging_vello"] vger = ["dep:floem_vger_renderer"] -# tiny-skia = ["dep:floem_tiny_skia_renderer"] +vello-hybrid = ["dep:imaging_vello_hybrid"] +vello-cpu = ["dep:imaging_vello_cpu"] +skia = ["dep:imaging_skia"] +skia-cpu = ["dep:imaging_skia"] +tiny-skia = ["dep:imaging_tiny_skia"] serde = [ "dep:serde", "winit/serde", @@ -184,14 +210,27 @@ image-tiff = ["image/tiff"] image-webp = ["image/webp"] tokio = ["dep:tokio"] # rfd (file dialog) async runtime -crossbeam = ["dep:crossbeam", "floem_renderer/crossbeam"] +crossbeam = ["dep:crossbeam"] localization = ["dep:fluent-bundle", "dep:unic-langid", "dep:sys-locale", "dep:ouroboros"] [dev-dependencies] criterion = { version = "0.8.1", features = ["html_reports"] } -floem_renderer = { path = "renderer" } peniko = { workspace = true } +[patch.crates-io] +kurbo = { git = "https://github.com/linebender/kurbo.git", rev = "6836244be0b280ff68728f100fca0a5fcf62638a", default-features = false } +# naga = { path = "../wgpu/naga" } +# wgpu = { path = "../wgpu/wgpu" } +# wgpu-core = { path = "../wgpu/wgpu-core" } +# wgpu-hal = { path = "../wgpu/wgpu-hal" } +# wgpu-macros = { path = "../wgpu/wgpu-macros" } +# wgpu-types = { path = "../wgpu/wgpu-types" } +vello_common = { path = "../vello/sparse_strips/vello_common" } +vello_hybrid = { path = "../vello/sparse_strips/vello_hybrid" } + +[patch."https://github.com/forest-rs/imaging"] +glifo = { git = "https://github.com/linebender/vello", rev = "1cf944ed46500194dcc6ee40392a626e0ec4c8a0", package = "glifo" } + [[bench]] name = "event_dispatch" harness = false diff --git a/README.md b/README.md index 370bb8c25..cb28ed836 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ fn counter_view() -> impl IntoView { Inspired by [Xilem](https://github.com/linebender/xilem), [Leptos](https://github.com/leptos-rs/leptos) and [rui](https://github.com/audulus/rui), Floem aims to be a high performance declarative UI library with a highly ergonomic API. -- **Cross-platform**: Floem supports Windows, macOS and Linux with rendering using [wgpu](https://github.com/gfx-rs/wgpu) via [vger](https://github.com/audulus/vger-rs) or [vello](https://github.com/linebender/xilem), or via AnyRender's GPU-backed [Skia](https://github.com/dioxuslabs/anyrender) renderer. In case a GPU is unavailable, a CPU renderer powered by [tiny-skia](https://github.com/RazrFalcon/tiny-skia) will be used. +- **Cross-platform**: Floem supports Windows, macOS and Linux with rendering using [wgpu](https://github.com/gfx-rs/wgpu) via [vger](https://github.com/audulus/vger-rs) or [vello](https://github.com/linebender/xilem), via the Ganesh-backed `skia` renderer, or via the raster `skia-cpu` and [tiny-skia](https://github.com/RazrFalcon/tiny-skia) CPU paths. - **Fine-grained reactivity**: The entire library is built around reactive primitives inspired by [leptos_reactive](https://crates.io/crates/leptos_reactive). The reactive "signals" allow you to keep your UI up-to-date with minimal effort, all while maintaining very high performance. - **Performance**: The view tree is constructed only once, safeguarding you from accidentally creating a bottleneck in a view generation function that slows down your entire application. Floem also provides tools to help you write efficient UI code, such as a [virtual list](https://github.com/lapce/floem/tree/main/examples/virtual_list). - **Flexbox & Grid layout**: Using [Taffy](https://crates.io/crates/taffy), the library provides the Flexbox and Grid layout systems, which can be applied to any View node. diff --git a/benches/painting.rs b/benches/painting.rs index 4bb0673c3..7885695a7 100644 --- a/benches/painting.rs +++ b/benches/painting.rs @@ -11,12 +11,12 @@ use std::hint::black_box; -use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; use floem::headless::{HeadlessHarness, TestRoot}; use floem::prelude::*; use floem::style::FlexWrap; use floem::unit::Pct; -use floem::views::{Container, Decorators, Empty, Stack}; +use floem::views::{Container, Decorators, Empty, Label, ScrollExt, Stack}; // ============================================================================= // View tree creation helpers @@ -105,6 +105,29 @@ fn create_wide_tree_depth2(width: usize) -> impl IntoView { Stack::from_iter(children).style(|s| s.size(100.0, 100.0)) } +/// Create a large vertically scrollable list of labels. +fn create_scroll_label_list(n: usize) -> impl IntoView { + let rows: Vec<_> = (0..n) + .map(|i| { + Label::new(format!( + "Row {i:05} | benchmark label content for retained scroll repaint" + )) + .style(|s| { + s.width_full() + .height(24.0) + .padding_horiz(6.0) + .padding_vert(2.0) + }) + .into_any() + }) + .collect(); + + Stack::from_iter(rows) + .style(|s| s.flex_col()) + .scroll() + .style(|s| s.size(1000.0, 240.0)) +} + // ============================================================================= // Flat tree painting benchmarks // ============================================================================= @@ -285,6 +308,79 @@ fn bench_repaint_after_change(c: &mut Criterion) { group.finish(); } +// ============================================================================= +// Scroll repaint benchmarks +// ============================================================================= + +fn bench_scroll_repaint(c: &mut Criterion) { + let mut group = c.benchmark_group("scroll_repaint"); + + for n in [100, 1_000].iter() { + group.bench_with_input(BenchmarkId::new("initial_paint", n), n, |b, &n| { + b.iter_batched( + || { + let root = TestRoot::new(); + let view = create_scroll_label_list(n); + HeadlessHarness::new_with_size(root, view, 1000.0, 240.0) + }, + |mut harness| { + harness.paint(); + black_box(()); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_with_input( + BenchmarkId::new("first_repaint_after_scroll", n), + n, + |b, &n| { + b.iter_batched( + || { + let root = TestRoot::new(); + let view = create_scroll_label_list(n); + let mut harness = HeadlessHarness::new_with_size(root, view, 1000.0, 240.0); + harness.paint(); + harness + }, + |mut harness| { + harness.scroll_down(160.0, 120.0, 48.0); + harness.paint(); + black_box(()); + }, + BatchSize::SmallInput, + ); + }, + ); + + group.bench_with_input( + BenchmarkId::new("steady_repaint_after_scroll", n), + n, + |b, &n| { + let root = TestRoot::new(); + let view = create_scroll_label_list(n); + let mut harness = HeadlessHarness::new_with_size(root, view, 320.0, 240.0); + harness.rebuild(); + harness.paint(); + let mut scroll_down = true; + + b.iter(|| { + if scroll_down { + harness.scroll_down(160.0, 120.0, 48.0); + } else { + harness.scroll_up(160.0, 120.0, 48.0); + } + scroll_down = !scroll_down; + harness.paint(); + black_box(()); + }); + }, + ); + } + + group.finish(); +} + criterion_group!( benches, bench_paint_flat_tree, @@ -293,6 +389,7 @@ criterion_group!( bench_paint_wide_tree, bench_paint_order_tracking, bench_repaint_after_change, + bench_scroll_repaint, ); criterion_main!(benches); diff --git a/docs/external-surfaces-compositor-plan.md b/docs/external-surfaces-compositor-plan.md new file mode 100644 index 000000000..032989564 --- /dev/null +++ b/docs/external-surfaces-compositor-plan.md @@ -0,0 +1,689 @@ +# External Surfaces and Compositor Integration + +## Goal + +Add OS-composited and compositor-managed external surfaces to Floem without +making the feature video-specific. + +Video is the motivating use case because smooth playback requires frame timing, +visibility, presentation feedback, and low-copy texture/image presentation. +However, the primitive should also support other external content such as native +controls, web views, camera previews, maps, embedded renderers, and GPU texture +producers. + +The core design principle is: + +> An external surface is ordered like a draw command, but presented like a +> compositor layer. + +## Non-Goals + +- Floem core should not implement video cadence, A/V sync, decoder policy, or + frame-selection algorithms. +- `imaging` should not learn about OS compositor layers. +- Paint should not create native resources. +- Video worker threads should not mutate Floem view, display-list, or compositor + state directly. + +## High-Level Data Flow + +```text +View tree + -> style/layout/box tree + -> retained display list + -> composition lowering + -> compositor diff + -> subduction LayerStore diff + -> platform presenter + -> present + -> frame outcome feedback +``` + +Normal rendering remains the fast path: + +```text +No external surfaces: + retained display list -> imaging sink -> window surface +``` + +When external surfaces are present: + +```text +Has external surfaces: + retained display list + -> ordered CompositionItems + -> render normal drawing chunks into compositor surfaces + -> map external surfaces into compositor layers + -> compositor/platform present +``` + +## Core Concepts + +### ExternalSurface + +`ExternalSurface` is a stable handle to a window-local content slot. + +It has identity and content, but no paint position by itself. + +```rust +pub struct ExternalSurface { + id: ExternalSurfaceId, +} + +pub struct ExternalSurfaceConfig { + pub kind: ExternalSurfaceKind, + pub alpha_mode: AlphaMode, + pub color_space: Option, + pub preferred_format: Option, +} + +pub enum ExternalSurfaceKind { + NativeTexture, + WgpuTexture, + CpuImageFallback, +} +``` + +Creation should happen outside paint, for example during view construction or +view setup: + +```rust +let surface = create_external_surface(ExternalSurfaceConfig::video()); +``` + +### Paint Placement + +Paint places an existing external surface at a precise paint-order position. + +```rust +impl PaintCx<'_> { + pub fn draw_external_surface( + &mut self, + surface: &ExternalSurface, + rect: Rect, + options: ExternalSurfacePaintOptions, + ); +} + +pub struct ExternalSurfacePaintOptions { + pub opacity: f32, + pub hit_test: bool, +} +``` + +Example: + +```rust +impl View for VideoView { + fn paint(&mut self, cx: &mut PaintCx) { + cx.draw_external_surface( + &self.surface, + cx.layout_rect_local, + ExternalSurfacePaintOptions::default(), + ); + } +} +``` + +### Content + +The compositor should only need texture/image-like content. + +```rust +pub enum ExternalSurfaceContent { + Empty, + Texture(ExternalTexture), + Image(ImageData), +} +``` + +No `submit_video_frame` API is needed. Video is responsible for selecting, +decoding, and uploading/wrapping frames into one of the supported content forms. + +## Display List Integration + +External surfaces should be represented in the retained display list at the same +ordering granularity as a single draw. + +Conceptually: + +```rust +enum FloemDisplayCommand { + PushContext(ContextId), + PopContext, + PushClip(ClipId), + PopClip, + PushGroup(GroupId), + PopGroup, + Draw(DrawId), + ExternalSurface(ExternalSurfaceId), +} +``` + +This does not require changing `imaging::record::Scene`. `imaging::Scene` +remains the drawing language. Floem's display list owns paint order and can +retain external surface commands alongside normal drawing. + +The invariant is: + +> `ExternalSurface` is a paint-order barrier. + +That means: + +- normal draw commands before it may be grouped together +- normal draw commands after it may be grouped together +- normal draw commands must never be reordered across it +- active clips/groups/context annotations at the command apply to the external + surface placement +- compositor layer order must match lowered display-list order exactly + +## Composition Lowering + +The display-list lowering pass walks retained paint order and emits +composition items. + +```rust +pub struct CompositionPlan { + pub items: Vec, +} + +pub enum CompositionItem { + Scene(SceneLayer), + ExternalSurface(ExternalSurfaceLayer), +} + +pub struct SceneLayer { + pub key: CompositionKey, + pub scene: imaging::record::Scene, + pub bounds: Rect, + pub transform: Affine, + pub clip: Option, + pub opacity: f32, +} + +pub struct ExternalSurfaceLayer { + pub key: CompositionKey, + pub surface_id: ExternalSurfaceId, + pub rect: Rect, + pub transform: Affine, + pub clip: Option, + pub opacity: f32, +} +``` + +Lowering algorithm: + +```rust +for command in display_order { + match command { + normal_imaging_command => { + current_scene_chunk.push(command); + } + ExternalSurface(surface_id) => { + flush_current_scene_chunk_as_scene_layer(); + emit_external_surface_layer(surface_id, active_state); + } + } +} + +flush_current_scene_chunk_as_scene_layer(); +``` + +Example: + +```text +Draw(background) +ExternalSurface(video) +Draw(caption) +``` + +lowers to: + +```text +Layer 0: imaging chunk containing background +Layer 1: external video surface +Layer 2: imaging chunk containing caption +``` + +## Compositor Diff + +Compositor integration should be diff-driven. Floem should not rebuild the +native layer tree every frame. + +There are two diffs: + +- Floem composition diff: `CompositionPlan` to stable compositor layers. +- Subduction layer diff: `LayerStore::evaluate()` to platform presenter + changes. + +Subduction already owns the second part: + +```rust +let changes = layer_store.evaluate(); +presenter.apply(&layer_store, &changes); +``` + +Floem needs the first part. + +```rust +struct WindowCompositor { + layer_store: subduction_core::layer::LayerStore, + layers_by_key: HashMap, + previous_order: Vec, + scene_surfaces: HashMap, + external_surfaces: HashMap, +} +``` + +Stable keys are required to avoid destroy/recreate churn. + +```rust +pub enum CompositionKey { + SceneChunk { + owner: ElementId, + stage: PaintStage, + chunk_index: u32, + }, + ExternalSurface(ExternalSurfaceId), +} +``` + +Apply flow: + +```rust +fn apply_plan(&mut self, plan: CompositionPlan) { + remove_layers_not_in_new_plan(); + create_layers_for_new_items(); + update_changed_layer_properties(); + reorder_layers_to_match_plan(); + + let changes = self.layer_store.evaluate(); + self.presenter.apply(&self.layer_store, &changes); +} +``` + +Layer properties should be updated only when changed: + +```rust +if old.bounds != new.bounds { + layer_store.set_bounds(layer, new.bounds); +} +if old.transform != new.transform { + layer_store.set_transform(layer, new.transform); +} +if old.clip != new.clip { + layer_store.set_clip(layer, new.clip); +} +if old.opacity != new.opacity { + layer_store.set_opacity(layer, new.opacity); +} +if old.surface != new.surface { + layer_store.set_content(layer, Some(surface_id)); +} +``` + +The compositor should have one root layer per window, with composition items as +children ordered by paint order: + +```text +window compositor root + child 0: scene chunk + child 1: external video surface + child 2: overlay scene chunk +``` + +If subduction does not expose an efficient `set_child_order` API, add one or +implement reordering by remove/reinsert as an initial implementation. + +## External Surface Registry + +`ExternalSurface` communicates with the compositor through a window-local +registry, not by directly calling platform presenter APIs. + +```rust +struct ExternalSurfaceEntry { + config: ExternalSurfaceConfig, + content: ExternalSurfaceContent, + dirty: ExternalSurfaceDirty, + last_outcome: Option, +} +``` + +Producer-side APIs update this registry: + +```rust +impl ExternalSurface { + pub fn submit_texture(&self, texture: ExternalTexture); + pub fn submit_image(&self, image: ImageData); + pub fn clear(&self); + pub fn request_frame(&self); +} +``` + +Internally: + +```text +submit_texture() + -> update ExternalSurfaceEntry.content + -> mark surface content dirty + -> request frame/render +``` + +The compositor consumes the registry during plan application: + +```rust +match registry.content(surface_id) { + ExternalSurfaceContent::Empty => hide_layer(), + ExternalSurfaceContent::Texture(texture) => bind_texture_to_layer(texture), + ExternalSurfaceContent::Image(image) => upload_or_render_image_to_layer(image), +} +``` + +## Threaded Producers + +Video decoding/upload may run on another thread. That thread must not touch +Floem display-list or compositor state directly. + +Use a sendable surface handle: + +```rust +#[derive(Clone)] +pub struct ExternalSurfaceHandle { + id: ExternalSurfaceId, + proxy: AppUpdateProxy, +} + +impl ExternalSurfaceHandle { + pub fn submit_texture(&self, texture: ExternalTexture); + pub fn submit_image(&self, image: ImageData); + pub fn clear(&self); + pub fn request_frame(&self); +} +``` + +Those methods post app/window events: + +```rust +enum AppUpdateEvent { + ExternalSurfaceContent { + surface_id: ExternalSurfaceId, + content: ExternalSurfaceContent, + }, + ExternalSurfaceRequestFrame { + surface_id: ExternalSurfaceId, + }, +} +``` + +UI thread handling: + +```text +receive ExternalSurfaceContent + -> registry[surface_id].content = content + -> mark dirty + -> request frame +``` + +## Video Presentation Model + +Project Butter recommends that visible video presentation be pulled by the +compositor/frame pipeline, not pushed by a video thread. + +In Floem terms: + +```text +video decode/upload thread + -> maintain queue of ready textures/images + +Floem frame pipeline + -> finds visible external surface placement + -> gives video provider presentation interval + -> provider selects best ready texture/image + -> compositor presents selected content + -> provider receives outcome feedback +``` + +The provider API should look like: + +```rust +pub trait ExternalSurfaceProvider { + fn update_current_content( + &mut self, + args: ExternalSurfaceFrameArgs, + ) -> ExternalSurfaceFrameUpdate; + + fn current_content(&self) -> Option; + + fn release_current_content(&mut self, outcome: ExternalSurfaceOutcome); +} + +pub struct ExternalSurfaceFrameArgs { + pub surface_id: ExternalSurfaceId, + pub interval: PresentationInterval, + pub visible: bool, + pub rect: Rect, + pub size_px: Size, + pub previous_outcome: Option, +} + +pub struct ExternalSurfaceFrameUpdate { + pub content_changed: bool, + pub request_next_frame: bool, +} + +pub struct ExternalSurfaceOutcome { + pub surface_id: ExternalSurfaceId, + pub frame_index: u64, + pub visible: bool, + pub outcome: FrameOutcome, +} +``` + +Video selection policy stays outside Floem core: + +```rust +impl ExternalSurfaceProvider for VideoSurface { + fn update_current_content( + &mut self, + args: ExternalSurfaceFrameArgs, + ) -> ExternalSurfaceFrameUpdate { + self.current = self.algorithm.select_texture( + args.interval.deadline_min, + args.interval.deadline_max, + args.interval.predicted_present, + args.previous_outcome, + &self.uploaded_frames, + ); + + ExternalSurfaceFrameUpdate { + content_changed: self.current.is_some(), + request_next_frame: self.is_playing(), + } + } + + fn current_content(&self) -> Option { + self.current.clone().map(ExternalSurfaceContent::Texture) + } + + fn release_current_content(&mut self, outcome: ExternalSurfaceOutcome) { + self.algorithm.observe(outcome); + } +} +``` + +The algorithm may implement Project Butter's priority order: + +1. cadence-based selection when stable +2. coverage-based selection for the presentation interval +3. drift-based selection when no frame overlaps the interval + +Floem core only supplies timing, visibility, placement, and outcome feedback. +If a provider returns `request_next_frame`, Floem keeps scheduling frame work so +visible video is pulled by the compositor clock instead of by a sleeping video +thread. +If a provider returns `content_changed` and `current_content()` returns `None`, +Floem clears the surface content. + +Provider pulls are not ordinary paint invalidation. They are frame-scheduler +work: + +- With a display-link/subduction frame clock, each external tick may advance the + provider pull if the window has external-surface frame work. +- With the heuristic frame clock, provider pulls arm a paced update wake. +- If only provider content changes, Floem applies a compositor diff and does + not rerender the main window surface. +- If retained Floem content above an external surface changes, Floem rerenders + only the promoted compositor-owned scene layer when the window prefix before + the first external surface is unchanged. +- Promoted compositor-owned scene layers are rendered through the same renderer + backend instance as the main window. For the threaded window renderer this is + the existing render worker; GPU results are blitted into the layer surface, + and CPU results are uploaded as RGBA and then blitted. +- If the prefix before the first external surface changes, the main window + surface must be rerendered because that content lives in the window surface. + +## Background Rendering + +Begin-frame callbacks are only reliable for visible/active surfaces. Video still +needs a lower-frequency background path so media state advances without full +compositor-rate work. + +Core should expose: + +```rust +pub enum ExternalSurfaceFrameMode { + Visible, + Background, +} +``` + +In background mode: + +- callbacks can be timer-driven +- `background_rendering` is true +- the provider may select/update content, but the result may not be displayed +- cadence-sensitive algorithms should treat the interval as lower-confidence + +The initial background cadence can be conservative, with an explicit opt-in for +clients that need higher-frequency invisible pulls, such as texture-copy clients. + +## Subduction Fit + +Subduction should own native/compositor layer application: + +- `LayerStore` stores ordered compositor layers. +- `FrameChanges` describes layer diffs. +- `Presenter::apply()` maps diffs to platform layers. + +Floem should own: + +- display-list paint order +- external surface registry +- composition lowering +- scene chunk rendering into compositor surfaces +- mapping composition keys to subduction layers +- frame timing and outcome delivery to providers + +Platform paths: + +- Apple: `LayerPresenter` / `MetalLayerPresenter` +- Wayland: subsurface presenter +- Windows: DirectComposition-style presenter when available +- fallback: `subduction_backend_wgpu::WgpuPresenter` + +## GPU Resource Ownership + +External surfaces that Floem owns or renders into must use the same WGPU +`Instance`, `Adapter`, `Device`, and `Queue` as the window renderer. Creating a +separate device for compositor-owned scene layers is incorrect because it breaks +resource sharing assumptions and can choose a different adapter from the app. + +Floem therefore exposes `AppConfig::gpu_resources(GpuResources)` for embedding +apps that already own WGPU state. If no resources are supplied, Floem requests +them once and shares that `GpuResources` through: + +- the main window renderer +- compositor-owned scene surfaces above/between external surfaces +- `WindowEvent::GpuResourcesReady(GpuResources)` / `WindowGpuResourcesReady` + for producers that should start after Floem has acquired the window GPU + context +- subduction external surface targets created through + `SubductionWgpuSurface::create_target_with_gpu_resources` + +The standalone `create_target` path exists only as a convenience for independent +producers. Video integrations that need zero-copy/shared-resource behavior +should use the app/Floem `GpuResources` path. + +## Implementation Plan + +Status: + +- Phases 1-4 are implemented as the current vertical slice. +- Phase 5 is implemented for visible compositor-pulled surfaces: providers are + registered on `ExternalSurface`, called after display-list lowering with the + current `PresentationInterval`, may update content, may request the next + compositor pull, and receive frame outcome feedback. +- Phase 5 background mode is still pending. +- Phase 6 remains outside Floem core: a video crate/view should implement the + provider and own cadence/coverage/drift frame selection. + +### Phase 1: Internal Display-List Command + +- Add retained external surface placement commands to Floem display-list stages. +- Add `PaintCx::draw_external_surface`. +- Keep `imaging::Scene` unchanged. +- Add tests proving exact order with draw commands before and after an external + surface. + +### Phase 2: Composition Lowering + +- Add `CompositionPlan` and `CompositionItem`. +- Lower retained display-list order into scene chunks and external surface + items. +- Preserve active transform, clip, group opacity/composite state where supported. +- Keep existing direct-render fast path when no external surfaces exist. + +### Phase 3: WindowCompositor Diff + +- Add `WindowCompositor`. +- Map stable `CompositionKey`s to subduction `LayerId`s. +- Diff create/remove/update/reorder against the previous plan. +- Render scene chunks into compositor-owned surfaces/textures. +- Apply `LayerStore::evaluate()` and `Presenter::apply()`. + +### Phase 4: External Surface Registry + +- Add window-local registry for `ExternalSurfaceEntry`. +- Add UI-thread `ExternalSurface` and sendable `ExternalSurfaceHandle`. +- Route cross-thread content updates through app/window events. +- Support `Texture`, `Image`, and `Empty` content. + +### Phase 5: Provider Pull API + +- Add `ExternalSurfaceProvider`. +- During frame prepare/lowering, call providers for visible placements with + `PresentationInterval`. +- Call outcome feedback after draw/present. +- Let providers request the next compositor pull. +- Keep provider pulls separate from ordinary paint invalidation. +- Only rerender the main window surface when the scene prefix before the first + external surface changes. +- Add background rendering mode. + +### Phase 6: Video Prototype + +- Build a video surface provider outside core. +- Decode/upload frames on a worker thread into a ready queue. +- Select ready textures on the UI/frame path using presentation interval. +- Start with coverage/drift selection, then add cadence estimation. + +## Open Questions + +- How much of clip/group/composite state can native OS layers preserve exactly? +- Should unsupported clip/group states force a fallback texture-compositor path? +- Does subduction need an explicit `set_child_order` API? +- What is the minimum useful `ExternalTexture` abstraction across wgpu, native + handles, and platform video textures? +- Should one `ExternalSurfaceId` be allowed to appear multiple times in one + frame, or should V1 enforce one visible placement per surface? +- What should the exact background rendering cadence and opt-in policy be? diff --git a/docs/frame-render-schedule-fix.md b/docs/frame-render-schedule-fix.md new file mode 100644 index 000000000..c7b08b2b7 --- /dev/null +++ b/docs/frame-render-schedule-fix.md @@ -0,0 +1,17 @@ +We need to do a massive simplification of frame rendering and presenting especially in the window +and app handles. Currently there are a ton of methods that do similar or exactly the same thing and +there are also many methods like "render_if" having any "if" in the name of a method is a huge code +smell. We should be using matklads advice of lifting if's and pushing for's down (I'm sure you are +familiar with that blog post). We need to remove the methods that do similar things and instead +focus on having single methods that do things, conditioning the calling of that function using if +conditions and separating conflicting concepts. So separate concepts are: paint/scene, present frame +(there should not be a "present_ready_frame" there should just be present frame where we pass in a +frame or soemthing. this is where we really need to work on the model and data flow). painting +should never call present and present should not call paint. We also need to do a similar cleanup in +the frame clock for removing duplicate methods, separating concepts and data flow and pushing if's +up. + +We should think about this considering the event loop, update processing, how scheduling interacts +with the update loop, if we need to add other update types, if present should be an update type, +data flow, where does data live, how do we make invalid states unrepresentable. We should probably +update the framme schedule design doc. diff --git a/docs/frame-scheduling-redesign.md b/docs/frame-scheduling-redesign.md new file mode 100644 index 000000000..cdb3e2e29 --- /dev/null +++ b/docs/frame-scheduling-redesign.md @@ -0,0 +1,1142 @@ +# Frame Scheduling Redesign + +## Goal + +Make Floem's scheduling model correct by separating four different concepts that are currently entangled: + +1. Animation scheduling +2. Draw/frame scheduling +3. Timer scheduling +4. Update scheduling + +Correctness is the primary goal. Lower main-thread blocking, especially around swapchain acquisition, is a direct consequence of getting the frame model right. + +This document proposes major architectural and API changes. It intentionally prefers a clean model over source compatibility. + +## Problem Statement + +Today Floem mixes: + +- immediate update requests via `UpdateMessage` +- next-frame requests via `WindowState::scheduled_updates` +- wall-clock timers in `ApplicationHandle::timers` +- animation callbacks via `exec_after_animation_frame`, which are implemented as timers +- redraw pacing via `paced_redraw_timers`, which are also implemented as timers + +These mechanisms are not equivalent, but they are routed through overlapping infrastructure. That causes correctness problems: + +- There is no single authoritative per-window frame lifecycle. +- Animation callbacks are not real begin-frame callbacks. +- Redraw pacing is not a first-class clock. It is a special timer. +- `scheduled_updates` is a lossy, untyped "do this later" queue. +- Views can bypass pacing and directly request redraw. +- GPU rendering acquires the surface texture too early, so main-thread work can block on swapchain availability before the frame has even been built. + +## Existing Model + +### Immediate update path + +Views call APIs like: + +- `request_style` +- `request_layout` +- `request_box_tree_update` +- `request_box_tree_commit` +- `request_paint` + +These enqueue `UpdateMessage`s. `WindowHandle::process_update_messages` converts them into `WindowState` dirtiness. + +This is the "do it as soon as the next app update runs" path. + +### Scheduled update path + +Other code calls: + +- `schedule_style` +- `schedule_layout` +- `schedule_box_tree_commit` +- `schedule_paint` + +These push `FrameUpdate`s into `WindowState::scheduled_updates`. + +Later, `WindowHandle::process_scheduled_updates` converts them back into the same dirtiness state used by immediate requests. + +This is the "do it later" path, but it does not correspond to a principled frame phase. It is just another queue feeding the same work. + +### Timer path + +`ApplicationHandle` owns a global timer table. `exec_after` creates a timer with a wall-clock deadline. `exec_after_animation_frame` also creates a timer, but sets its deadline to `now + frame_duration_for_window`. + +So "timer" and "next frame" are not actually separate abstractions in the runtime. + +### Draw scheduling path + +After update processing, `ApplicationHandle` decides whether a window needs a frame. If so, it either: + +- directly calls `window.request_redraw()`, or +- arms a paced redraw timer that later wakes the update loop + +This means draw scheduling lives above the window, but draw dirtiness lives inside the window. The coordination is heuristic rather than contractual. + +## Conceptual Separation We Need + +These need to become distinct systems. + +### 1. Update scheduling + +Purpose: + +- process messages +- flush reactive work +- compute style/layout/box tree dirtiness + +This is state convergence. It should not itself imply a draw deadline or an animation clock. + +### 2. Animation scheduling + +Purpose: + +- advance semantic time for transitions, animations, drag settle effects, cursor blink if frame-tied, and user `requestAnimationFrame` callbacks + +This should be driven by a begin-frame opportunity, not by wall-clock timers pretending to be frames. + +### 3. Timer scheduling + +Purpose: + +- fire wall-clock deadlines +- wake the app when time-based work becomes eligible + +This is independent of display refresh. Timers may cause state changes that mark visual work dirty, but a timer is not a frame. + +### 4. Draw/frame scheduling + +Purpose: + +- decide when to ask the platform for a frame +- deliver begin-frame callbacks +- choose commit deadlines +- acquire the output target only when ready to submit +- observe present feedback + +This should be a first-class per-window subsystem. + +## Required Invariants + +The redesign should enforce these invariants. + +### Invariant 1: one authoritative per-window frame coordinator + +Each window must have exactly one owner for: + +- whether a frame is needed +- whether a frame is already requested +- whether a frame callback is in flight +- the current predicted present time and deadline +- the last presentation feedback + +No other code path should directly call `window.request_redraw()` except through this coordinator. + +### Invariant 2: wall-clock timers never masquerade as begin-frame + +`set_timeout` and `set_interval` are wall-clock mechanisms. +`request_animation_frame` is a frame-clock mechanism. + +They may wake the same outer event loop, but they must remain distinct in semantics and storage. + +### Invariant 3: frame callbacks run from begin-frame + +Animation callbacks must receive a semantic frame time from a real frame opportunity. They should not be synthesized by a timer using a guessed monitor interval. + +### Invariant 4: updates mark pending work, not redraw directly + +Style/layout/paint APIs should only mark work pending. They should not schedule redraw themselves. The frame coordinator decides when a platform redraw should be requested. + +### Invariant 5: surface acquisition is late + +Swapchain or present-target acquisition must happen after: + +- updates are converged +- animation has been advanced +- scene / render plan has been built + +The output target should be acquired only when the frame is actually ready to composite/submit. + +### Invariant 6: presentation feedback informs pacing + +Heuristics based on `last_presented_at` are acceptable only as a fallback. When platform feedback exists, pacing should be driven by predicted present time and actual presentation feedback. + +### Invariant 7: external frame signals replace synthetic begin-frame wakeups, not late present pacing + +When a backend provides authoritative frame opportunities, Floem must treat those +signals as the only source of begin-frame/update advancement. + +That means: + +- do not synthesize timer-driven "update" wakeups between backend frame signals +- do let backend frame signals trigger scheduling reevaluation +- do keep late present pacing when a frame is already prepared, if the backend or + frame clock provides a meaningful present deadline + +Operationally: + +- `FrameTick`/display-link style events are for advancing deferred next-frame + work and begin-frame callbacks +- `FrameReady` only changes pipeline readiness state; it does not itself define + presentation timing +- the scheduler may still arm a timed late-present wakeup for a ready frame +- app-level event plumbing should only feed state changes back into scheduling; + it should not invent a separate per-platform presentation policy + +## Proposed Architecture + +Introduce a first-class `FrameCoordinator` per window. + +The `FrameCoordinator` is Floem-owned, but its timing source and pacing model should be designed so that `subduction` can drive it directly. + +Concretely: + +- Floem owns update convergence, style/layout, view state, scene generation, and renderer integration. +- `subduction` should own frame ticks, present hints, frame planning, and present feedback adaptation. +- The coordinator is the bridge between those worlds. + +```rust +pub struct FrameCoordinator { + phase: FramePhase, + frame_requested: bool, + frame_in_flight: bool, + begin_frame_callbacks: Vec, + pending_work: PendingWork, + timer_state: TimerState, + pacing: PacingState, + presentation: PresentationState, +} +``` + +In the long-term target, `pacing` is not a Floem-invented heuristic type. It is effectively a thin wrapper around subduction's: + +- `FrameTick` +- `PresentHints` +- `FramePlan` +- `PresentFeedback` +- `Scheduler` + +### `FramePhase` + +```rust +pub enum FramePhase { + Idle, + BeginFrame, + Animate, + Update, + LayoutAndPrePaint, + BuildRenderPlan, + AcquireTarget, + CompositeAndPresent, +} +``` + +This is primarily for internal correctness and assertions. + +### `PendingWork` and `NextFrameWork` + +The distinction between: + +- dirty in the current convergence cycle +- dirty at the next frame opportunity + +is real and necessary. + +Floem currently runs update work in a loop until quiescent. Because of that, simply "marking dirty" is not enough for animations and other frame-driven work: some work must be deferred so it does not get pulled back into the same update loop iteration. + +The redesign should preserve that distinction, but model it explicitly. + +Replace `scheduled_updates: Vec` with two typed structures: + +```rust +pub struct PendingWork { + pub needs_message_flush: bool, + pub style_dirty: FxHashMap, + pub needs_layout: bool, + pub needs_box_tree_from_layout: bool, + pub views_needing_box_tree_update: FxHashSet, + pub needs_box_tree_commit: bool, + pub dirty_paint_elements: FxHashSet, + pub pending_damage_rects: Vec, +} + +pub struct NextFrameWork { + pub style_dirty: FxHashMap, + pub needs_layout: bool, + pub needs_box_tree_from_layout: bool, + pub views_needing_box_tree_update: FxHashSet, + pub needs_box_tree_commit: bool, + pub dirty_paint_elements: FxHashSet, + pub begin_frame_callbacks: Vec, +} +``` + +`PendingWork` is eligible in the current update loop. +`NextFrameWork` is promoted into `PendingWork` only at the next begin-frame boundary. + +This preserves the semantics of "schedule for next frame" without treating it as an unstructured queue. + +### Why this split matters + +If an active animation calls "schedule style for next frame", that must not just set `style_dirty` immediately or the current update loop will continue stepping the animation in the same turn until it converges or hits a guardrail. + +So the correct model is not: + +- one dirty bitset + +It is: + +- `PendingWork`: current-frame convergence +- `NextFrameWork`: deferred until the next frame opportunity + +### API shape for deferral + +That means Floem still needs two classes of APIs: + +- request now +- request next frame + +but they should be explicit about phase semantics. + +Suggested names: + +```rust +pub fn request_style_now(&mut self, id: ViewId, reason: StyleReason); +pub fn request_style_next_frame(&mut self, id: ViewId, reason: StyleReason); +``` + +and similarly for layout / box-tree / paint where needed. + +Public view-facing API does not necessarily need to expose both families, but internally the engine absolutely does. + +The important change is that "next frame" should mean: + +- place work in `NextFrameWork` +- ensure the frame coordinator requests a future frame opportunity + +not: + +- push an opaque `FrameUpdate` into a shared vector + +### Promotion rule + +At begin-frame: + +1. move `NextFrameWork` into `PendingWork` +2. run begin-frame callbacks +3. animate +4. converge updates until quiescent + +That gives correct "one step per frame opportunity" behavior. + +### `TimerState` + +Wall-clock timers remain their own structure: + +```rust +pub struct TimerState { + pub timers: BTreeMap>, + pub entries: HashMap, +} + +pub enum TimerKind { + Timeout, + Interval, +} +``` + +This state can stay application-global or move per window plus a small app-global table for non-window timers. + +### `PacingState` + +Pacing should stop being "redraw timer". It should instead track the current frame model: + +```rust +pub struct PacingState { + pub last_tick: Option, + pub last_feedback: Option, + pub scheduler: FrameScheduler, + pub visibility: VisibilityState, +} +``` + +The naming here intentionally aligns with the subduction model. + +The intended end state is: + +- `FrameTick`, `PresentHints`, `FramePlan`, and `PresentFeedback` come from `subduction` types directly, or from a local compatibility layer that matches them exactly. +- `FrameScheduler` is subduction's scheduler or a thin adapter around it. + +In other words, this is where subduction fits in to drive timing. + +### `PresentationState` + +```rust +pub struct PresentationState { + pub target_state: PresentTargetState, + pub last_submission: Option, + pub output_size_px: (u32, u32), +} +``` + +This owns output target lifecycle and feedback, not general update logic. + +## Proposed Frame Lifecycle + +Each window frame should follow this sequence: + +1. `BeginFrame` +2. `Animate` +3. `Update` +4. `LayoutAndPrePaint` +5. `BuildRenderPlan` +6. `AcquireTarget` +7. `CompositeAndPresent` +8. `ObserveFeedback` + +### Operational scheduling rules + +The frame lifecycle above also needs explicit policy for when work starts and when a ready frame is actually presented. + +#### Start visual work early + +Once a window has frame work and can render: + +- process input and state updates immediately +- promote next-frame work immediately +- run style, layout, box-tree, and scene building immediately +- submit the frame to the renderer thread immediately + +Do not intentionally hold style/layout/scene work until late in the interval. The late part of the frame is for presentation, not for starting the expensive main-thread work. + +#### Present late + +Once a frame is ready: + +- present it as soon as practical +- but do not block the main thread on early surface acquisition +- if the output target is not expected to be acquirable yet, wait and wake near the present opportunity + +In effect: + +- build early +- acquire late +- present as soon as the target should be available + +#### Prefer the newest frame before the deadline + +If frame `N` finishes after its preferred presentation deadline and newer work for frame `N+1` already exists: + +1. keep frame `N` as a fallback +2. immediately start preparing and rendering frame `N+1` +3. arm a wakeup for the next present opportunity +4. if frame `N+1` becomes ready before that present opportunity, present `N+1` +5. otherwise present the older ready frame `N` + +This avoids wasting the main thread on an already-late frame when a fresher frame can still win, while still preserving a fallback if the newer frame also misses. + +#### Example scenarios + +Scenario: previous frame was presented, window is idle, then input arrives. + +- handle the input event +- immediately run style/layout/paint preparation +- immediately submit offscreen rendering +- when the renderer signals readiness, present as soon as the target should be available + +Scenario: a frame becomes ready early, before the surface should be acquired. + +- keep the frame ready +- do not block the main thread in `get_current_texture()` or equivalent +- wake near the expected present point and present then + +Scenario: a frame becomes ready late, after its preferred deadline, and new input already dirtied the next frame. + +- do not rush to present the stale frame immediately +- start the newer frame first +- keep the stale frame as fallback +- present whichever ready frame is newest at the present decision point + +### 1. BeginFrame + +Input: + +- frame tick from the backend or redraw callback +- predicted present time if known +- previous actual present if known + +Actions: + +- compute present hints +- plan semantic frame time and commit deadline +- store current frame plan + +Long-term, this phase should be driven by subduction: + +- platform backend produces `FrameTick` +- subduction computes `PresentHints` +- subduction scheduler produces `FramePlan` +- Floem consumes the result + +### 2. Animate + +Actions: + +- run `requestAnimationFrame` callbacks for this window +- advance transitions and animations using `FramePlan::semantic_time` +- mark any resulting style/layout/paint dirtiness + +This is where `exec_after_animation_frame` semantics belong. + +### 3. Update + +Actions: + +- process update messages +- drain runtime/reactive work up to policy +- apply timer-fired state changes + +This phase is about converging application state. It should not acquire any present target. + +### 4. LayoutAndPrePaint + +Actions: + +- style +- layout +- box-tree update +- box-tree commit +- produce final visual tree state +- compute paint damage / render plan invalidation + +### 5. BuildRenderPlan + +Actions: + +- produce a retained scene or explicit render plan +- prepare offscreen surfaces or layer targets + +This is the key place where Floem should move away from rendering directly into the window target. + +### 6. AcquireTarget + +Actions: + +- acquire swapchain image or CPU present buffer only now + +This is the main correction for the current GPU path. + +### 7. CompositeAndPresent + +Actions: + +- composite offscreen results into the acquired output target +- submit +- present + +### 8. ObserveFeedback + +Actions: + +- create feedback object from submission timestamps and actual/predicted present info +- feed the scheduler +- clear `frame_requested` / update `frame_in_flight` + +## API Changes + +### `action.rs` + +Current: + +- `exec_after` +- `exec_after_animation_frame` + +Proposed: + +```rust +pub fn set_timeout(duration: Duration, f: impl FnOnce(TimeoutId) + 'static) -> TimeoutId; +pub fn set_interval(duration: Duration, f: impl FnMut(IntervalId) + 'static) -> IntervalId; +pub fn cancel_timeout(id: TimeoutId); +pub fn cancel_interval(id: IntervalId); + +pub fn request_animation_frame(f: impl FnOnce(FrameTime) + 'static) -> AnimationFrameId; +pub fn cancel_animation_frame(id: AnimationFrameId); +``` + +Notes: + +- `request_animation_frame` should be window-scoped and invalid outside a window context. +- The callback should receive semantic frame time, not a timer token. +- `exec_after_animation_frame` should be deprecated and removed. + +### `view` / `window_state` request APIs + +Keep request APIs, but redefine semantics: + +- `request_style` +- `request_layout` +- `request_box_tree_update` +- `request_box_tree_commit` +- `request_paint` + +These only mark `PendingWork`. They do not trigger redraw directly. + +Remove: + +- `schedule_style` +- `schedule_layout` +- `schedule_box_tree_commit` +- `schedule_paint` +- `WindowHandle::schedule_repaint` + +Replace their use sites with either: + +- immediate dirtiness, if the work belongs to the current convergence cycle +- explicit `*_next_frame` APIs, if the work must happen on the next frame boundary +- `request_animation_frame`, if the work is a begin-frame callback rather than deferred dirtiness + +### `WindowHandle` + +Current `WindowHandle` combines: + +- state convergence +- input dispatch +- render timing accumulation +- painting +- redraw triggering + +Split it into clearer responsibilities: + +```rust +pub struct WindowHandle { + pub window: Arc, + pub state: WindowState, + pub frame: FrameCoordinator, + pub renderer: WindowRendererHost, + pub input: InputRouter, +} +``` + +`WindowHandle` can still own them, but code should stop mixing their responsibilities. + +### `ApplicationHandle` + +Current `ApplicationHandle` owns: + +- global timers +- paced redraw timers +- control flow +- window iteration budget + +Proposed: + +- keep app-global timer wakeup scheduling +- remove `paced_redraw_timers` +- make control flow derive from: + - earliest wall-clock timer + - whether any window has a pending frame deadline or queued frame callback + +`ApplicationHandle` should not own frame pacing policy anymore. It should only multiplex wakeups from: + +- wall-clock timers +- per-window frame coordinators + +The per-window frame coordinators are where the subduction-backed timing model plugs in. + +The app should ask each window for: + +```rust +pub enum WakeupRequest { + None, + At(Instant), + Immediate, +} +``` + +Then the app computes the earliest wakeup across all windows plus timers. + +## Renderer Changes + +The renderer boundary must change if we want correct late acquisition. + +### Current renderer boundary + +Current GPU rendering API effectively means: + +1. acquire window surface texture +2. render content into it +3. present + +That is wrong for minimizing main-thread stalls. + +### Proposed renderer boundary + +Change the rendering model to: + +1. build a render plan or scene +2. render into offscreen targets +3. acquire output target +4. composite/blit into output target +5. present + +Suggested trait split: + +```rust +pub trait SceneRenderer { + type PreparedFrame; + + fn resize(&mut self, width: u32, height: u32); + fn prepare_frame(&mut self, scene: &Scene, size: Size) -> Self::PreparedFrame; +} + +pub trait WindowPresenter { + fn acquire_target(&mut self) -> Result; + fn present_prepared( + &mut self, + prepared: &PreparedFrameHandle, + target: PresentTarget, + ) -> PresentResult; +} +``` + +In practice, a combined type may implement both traits, but the phase split matters. + +The important semantic rule is that these are separate operations, even if one concrete type owns +both: + +- `prepare_frame` records scene state or renders into an offscreen frame +- `present_frame` takes an already-prepared frame and submits it to the window + +There should not be a `present_ready_frame`-style API that hides internal renderer state in the +method name. "Ready" is a state in the caller's model, not a separate concept in the presenter API. + +Similarly, the window/app orchestration API should read as: + +```rust +window.prepare_frame(); +window.render_frame(); +window.present_frame(); +``` + +with ordinary `if` statements at the call sites deciding which step is needed on a given turn. + +### Frame clock boundary + +The frame clock should also avoid paired methods that encode the same concept twice. + +Bad shape: + +```rust +mark_frame_prepared(); +clear_prepared_frame(); +redraw_deadline(); +ready_frame_redraw_deadline(); +``` + +Preferred shape: + +```rust +set_frame_prepared(bool); +needs_frame_prepare(has_next_frame_work: bool); +redraw_deadline(has_ready_frame: bool); +``` + +That keeps "prepared" and "ready-to-present" as explicit state passed into a single method instead +of being split across near-duplicate APIs. + +### GPU path + +Preferred design: + +- render all app content into an offscreen texture or a retained set of layer textures +- only then call `get_current_texture()` +- perform a final composite pass into the surface texture + +This minimizes the time between surface acquisition and present. + +### CPU path + +Current CPU path is already much closer: + +- render into scratch RGBA image +- acquire softbuffer buffer +- copy and present + +This can be generalized behind the same presenter interface. + +## Video-Capable Core Plan + +Floem core should not implement video playback policy or frame-selection algorithms. + +Those belong in a video widget or media subsystem built on top of core. + +What core must do is provide the timing, frame, visibility, and presentation primitives that such a widget would need. The Chrome "Project Butter" design is still useful here, but as a guide for what interfaces core must expose, not for what algorithms core must own. + +### Core lessons to adopt + +#### 1. Core must support pull-from-frame behavior + +A video widget should be able to choose a frame from the begin-frame/render path instead of being forced into a push model. + +Core therefore needs a frame pipeline that can: + +- deliver begin-frame opportunities +- expose the current presentation interval +- let widgets prepare content for that interval + +#### 2. Core must expose interval semantics + +Video widgets need more than "current time". + +Core should expose a frame interval object like: + +```rust +pub struct PresentationInterval { + pub deadline_min: Instant, + pub deadline_max: Instant, + pub predicted_present: Option, + pub background_rendering: bool, +} +``` + +This should come from the frame coordinator, derived from subduction timing. + +#### 3. Core must expose visibility-aware frame modes + +Core should make it possible for a widget to know whether it is in: + +- normal visible begin-frame-driven mode +- background / fallback timer-driven mode + +Core owns that scheduling mode switch. A video widget can then decide what playback behavior to use. + +#### 4. Core must expose frame outcome feedback + +A video widget needs to know whether the frame it prepared: + +- was considered for draw +- actually drew +- likely missed deadline + +Core should expose: + +```rust +pub struct FrameOutcome { + pub draw_attempted: bool, + pub draw_completed: bool, + pub missed_deadline: Option, +} +``` + +That lets a widget implement cadence/coverage/drift or any other policy outside core. + +### Core responsibilities for video enablement + +Floem core should provide: + +- begin-frame callbacks +- subduction-driven frame timing and deadlines +- `PresentationInterval` +- visibility/background frame mode signaling +- `FrameOutcome` +- late-acquire presentation so widgets can prepare content before output-target acquisition + +Floem core should not provide: + +- cadence estimators +- coverage/drift frame choice +- A/V sync policy +- decoder/media-clock policy + +### Required hooks for a future video widget + +At minimum, core should make a future widget able to do this: + +1. receive begin-frame with interval data +2. select or synthesize a frame for that interval +3. attach that frame to the scene/render plan +4. learn whether it actually got drawn +5. fall back to background cadence when no visible begin-frame arrives + +### Required migration work in core + +#### Phase V1: define timing/output primitives + +- add `PresentationInterval` +- add `FrameOutcome` +- expose begin-frame callbacks with interval data + +#### Phase V2: add visibility-aware scheduling modes + +- visible widgets get begin-frame-driven opportunities +- invisible widgets can opt into background timer-driven opportunities + +#### Phase V3: preserve pull-based content preparation + +- ensure widgets can prepare frame content during the frame pipeline +- ensure output-target acquisition remains late + +#### Phase V4: add telemetry hooks + +Core should expose enough instrumentation that a future video widget can record: + +- missed-frame counts +- draw suppression +- frame deadlines +- actual-present feedback when available + +## Blink-Inspired Model + +The important Blink idea to borrow is not specific class names. It is the separation between: + +- begin-frame delivery +- semantic frame time +- lifecycle/update phases +- commit deadlines +- actual presentation feedback + +One concrete policy point matters here: Blink does not treat begin-frame/update work as a second +late wakeup that is independently paced right before present. The frame opportunity arrives, main +thread lifecycle work runs against that opportunity, and only the final presentation step stays +late. + +Floem should adopt the same logical split: + +- a frame source delivers opportunities +- a scheduler plans work against deadlines +- lifecycle work converges state +- rendering builds output independent of the final present target +- presentation happens late + +That means Floem should not maintain a separate "prepare deadline" timer. Once a window has a frame +opportunity and renderable work, it should promote next-frame work, run begin-frame callbacks, and +build/render as early as practical. The only paced wakeup should be the late present/redraw step. + +## Subduction Fit + +Subduction is a strong fit for the timing and presentation architecture. + +### What maps cleanly + +Subduction already has: + +- `FrameTick` +- `PresentHints` +- `FramePlan` +- `PresentFeedback` +- `Scheduler` +- `Presenter` + +Those concepts map almost exactly to what Floem needs. + +Floem should strongly consider adopting this model directly instead of reinventing a parallel one. + +### Exact ownership split + +This is the intended division of responsibility. + +#### Floem owns + +- update/message convergence +- reactive draining policy +- style/layout/box-tree work +- visual dirtiness tracking +- scene building / render plan construction +- renderer backend integration +- window-local bridge logic in `FrameCoordinator` + +#### Subduction owns + +- frame opportunity delivery model +- timing confidence model +- predicted present / commit deadline calculation +- semantic frame planning +- presentation feedback processing +- pacing adaptation logic + +#### Boundary + +The boundary between them should look like: + +```rust +pub trait FrameClockBackend { + fn poll_tick(&mut self) -> Option; + fn compute_present_hints(&self, tick: &FrameTick) -> PresentHints; + fn observe_feedback(&mut self, feedback: &PresentFeedback); +} +``` + +Floem's `FrameCoordinator` would then: + +1. receive a `FrameTick` +2. obtain `PresentHints` +3. ask the scheduler for a `FramePlan` +4. run Floem lifecycle/update/render phases +5. submit/present +6. feed `PresentFeedback` back + +That is the concrete place where subduction drives timing. + +### Recommended use of subduction + +#### Timing and scheduling + +Use subduction's timing model directly or mirror it closely. + +This is the highest-confidence adoption area. + +This is not just inspiration. It should be the intended driver for: + +- `request_animation_frame` +- frame deadlines +- pacing adaptation +- actual-present feedback handling + +#### GPU presenter + +A `subduction_backend_wgpu` style presenter is a good fit for Floem if Floem moves to: + +- offscreen layer or surface textures +- final composition into the window target + +This would let Floem separate scene generation from final output submission. + +#### CPU presenter + +This likely requires new work upstream or in Floem: + +- a CPU presenter abstraction +- likely a software surface presenter backend analogous to softbuffer + +So subduction is not yet a full replacement for all current present paths unless that work is added. + +#### Platform backends + +If the upstream checkout has the Windows backend, that strengthens the case for converging on subduction's platform timing/presenter abstractions. + +Even then, Floem should treat subduction as: + +- the timing and presenter substrate + +not necessarily: + +- the complete rendering engine + +## Migration Plan + +### Phase 0: stop making it worse + +- forbid new direct `request_redraw()` call sites outside the frame coordinator +- forbid new uses of `exec_after_animation_frame` +- stop adding new `schedule_*` call sites + +### Phase 1: introduce frame coordinator types + +- add `FrameCoordinator` +- add `PendingWork` +- add a `request_animation_frame` API +- route redraw requests through the coordinator +- add a frame-clock abstraction whose target implementation is subduction-backed + +No renderer changes yet. + +### Phase 2: remove scheduled update queue + +- delete `WindowState::scheduled_updates` +- convert all `schedule_*` users +- move transition/animation re-arming to `request_animation_frame` + +### Phase 3: late-acquire rendering + +- split scene building from present target acquisition +- change the GPU renderer to offscreen-first +- keep CPU on scratch-image-first + +This is the phase that directly addresses swapchain blocking. + +### Phase 4: adopt present feedback-driven pacing + +- replace heuristic redraw deadline logic with frame-plan / feedback logic +- make subduction the timing engine behind the frame coordinator +- use subduction `FrameTick` / `PresentHints` / `Scheduler` / `PresentFeedback` +- keep heuristic fallback only for platforms that do not yet have the backend wired + +### Phase 5: presenter unification + +- unify CPU and GPU present under a `WindowPresenter` abstraction +- optionally converge on subduction presenters where feasible + +## Concrete Type Sketch + +This is one possible shape for the new APIs. + +```rust +pub struct FrameTime { + pub semantic_time: Instant, + pub predicted_present: Option, + pub commit_deadline: Instant, + pub frame_index: u64, +} + +pub struct WindowFramePlan { + pub time: FrameTime, + pub should_render: bool, +} + +pub struct PendingWork { + pub style_dirty: FxHashMap, + pub needs_layout: bool, + pub needs_box_tree_update: bool, + pub views_needing_box_tree_update: FxHashSet, + pub needs_box_tree_commit: bool, + pub dirty_paint_elements: FxHashSet, + pub pending_damage_rects: Vec, +} + +pub struct FrameCoordinator { + pub pending_work: PendingWork, + pub raf_callbacks: Vec<(AnimationFrameId, Box)>, + pub frame_requested: bool, + pub current_plan: Option, + pub scheduler: SchedulerLike, +} + +impl FrameCoordinator { + pub fn mark_visual_dirty(&mut self); + pub fn request_animation_frame(&mut self, cb: Box) + -> AnimationFrameId; + pub fn on_begin_frame(&mut self, tick: FrameTick) -> WindowFramePlan; + pub fn should_request_platform_frame(&self) -> bool; +} +``` + +## Expected Benefits + +- Correct separation of semantics +- Fewer accidental feedback loops between updates, timers, and drawing +- Better visibility handling +- Better pacing under real presentation feedback +- Less main-thread blocking on swapchain acquisition +- Cleaner platform abstraction boundary +- Easier integration with subduction-style backends + +## Non-Goals + +- Preserving current internal API shape +- Minimizing refactor size +- Keeping `schedule_*` semantics for compatibility + +Those would work against the core goal here. + +## Recommended Immediate Next Steps + +1. Introduce `request_animation_frame` and convert transition re-arming to use it. +2. Add a per-window `FrameCoordinator` that becomes the only owner of redraw requests. +3. Remove direct redraw calls from update code paths. +4. Redesign the GPU renderer boundary so `get_current_texture()` happens after scene preparation. +5. Evaluate whether to adopt subduction's scheduler types directly in-tree or via a dependency. diff --git a/docs/window-ui-thread-split.md b/docs/window-ui-thread-split.md new file mode 100644 index 000000000..c919a6540 --- /dev/null +++ b/docs/window-ui-thread-split.md @@ -0,0 +1,173 @@ +# Window/UI Thread Split Plan + +## Goal + +Keep the main thread available for platform events and compositor commits while moving the maximum practical amount of UI state and UI work to a dedicated UI thread. + +The main thread must remain able to commit compositor work even when style/layout/event work is expensive. In particular, compositor and external surfaces that explicitly opt into presenting without the Core Animation transaction must not be blocked behind UI style/layout work. + +## Target Ownership + +### Main Thread + +The main thread owns only platform and compositor-runtime state: + +- `winit::Window`, `WindowId`, monitor/scale/visibility/occlusion state needed by the platform. +- Frame source, frame tick receipt, timers, event-loop control flow, and deadline timers. +- `GpuResources`, scene renderer pool handles, and GPU polling. +- Subduction `LayerHost`, `LayerStore`, pending layer changes, pending scene publications, pending scene render jobs, and layer-host commit feedback. +- Compositor commit scheduling and present feedback routing. +- Platform side effects requested by UI code: cursor, IME, window title, menus, drag window, resize window, visibility, capture, etc. +- Independent compositor/external surface paths that can present without transaction. + +### UI Thread + +The UI thread owns view and reactive state: + +- `WindowState` after main-thread compositor runtime state is extracted from it. +- Root view id, root view scope, `VIEW_STORAGE`, and per-window reactive work. +- Event routing through `GlobalEventCx`. +- Update message processing. +- Style, layout, box tree update/commit, and retained display-list recording. +- Composition plan production from the display list. +- Begin-frame callbacks and animation sampling. +- UI-side compositor surface placement/provider decisions. + +The main thread should treat UI view ids as opaque ids. It should not borrow view state or route events directly after the split. + +## Boundary Types + +The split should be expressed with narrow message/submission types before adding a real thread: + +- `UiWindowDriver`: owns UI state and exposes the synchronous version of the future UI-thread API. +- `UiWindowEvent`: main-to-UI event input after platform reduction. +- `UiFrameRequest`: main-to-UI frame request containing timing, size/scale/theme changes, and demand. +- `UiSceneSubmission`: UI-to-main frame output containing the lowered composition plan and enough scene data for main to schedule render-pool work without borrowing UI state. +- `PlatformRequest`: UI-to-main side effects such as cursor, IME, title, menu, drag/resize window, visibility, and capture requests. +- `CompositorRuntime`: main-owned runtime split out of current `WindowCompositor`. + +## Required Refactors + +### 1. Establish the UI Boundary In-Process + +Introduce `UiWindowDriver` and move direct `WindowState` ownership behind it while still running on the main thread. This should be mechanical and behavior-preserving. + +Success criteria: + +- `WindowHandle` has an explicit UI-owned field instead of directly owning bare `WindowState`. +- Existing behavior and tests still pass. +- New direct `WindowState` access goes through the UI boundary, making future diffs easier to review. + +### 2. Move Platform Side Effects Out of UI Update Processing + +`process_update_messages` currently mutates both UI state and platform window state. Split those effects into `PlatformRequest`s. + +Examples: + +- `SetWindowTitle` +- `SetImeAllowed` +- `SetImeCursorArea` +- `ShowContextMenu` +- `WindowMenu` +- `DragWindow` +- `DragResizeWindow` +- `ToggleWindowMaximized` +- `SetWindowMaximized` +- `MinimizeWindow` +- `WindowVisible` +- `CaptureMetalFrame` +- `ToggleHud` if the HUD remains main-compositor-facing + +Success criteria: + +- UI processing can run without an owned `winit::Window`. +- Main applies platform requests after a UI turn. + +### 3. Split `WindowCompositor` + +Current `WindowCompositor` is mixed. It both consumes UI-derived composition plans and owns platform/subduction runtime state. + +Split into: + +- UI-produced `CompositionPlan` and compositor-surface placement metadata. +- Main-owned `CompositorRuntime` that owns `LayerHost`, `LayerStore`, surface ids, pending render/publication state, and commit logic. + +Success criteria: + +- `WindowState` no longer owns `LayerHost` or pending compositor commit/publication state. +- Main can commit independent compositor-surface content without entering UI state. + +### 4. Add a Synchronous Adapter + +Before using a real thread, make `WindowHandle` talk to `UiWindowDriver` through the same methods that will become channel messages. + +Success criteria: + +- Main/UI ordering is explicit. +- Stale scene submissions can be dropped by generation. +- Resize and monitor changes carry generation ids. + +### 5. Move UI Driver to a Dedicated Thread + +Once the boundary compiles cleanly, move `UiWindowDriver` to a dedicated thread. The thread owns `VIEW_STORAGE` and the reactive runtime state for the window. + +Success criteria: + +- Main thread receives events, forwards UI events/frame requests, and keeps compositor commits responsive. +- UI thread produces scene submissions asynchronously. +- Main can keep servicing `present_without_transaction` surfaces while UI is busy. + +## Review Checklist While Working + +- Does this code need `winit::Window` or Core Animation/Subduction calls? If not, it should trend toward UI-owned. +- Does this code borrow `VIEW_STORAGE`, route events, or mutate style/layout/paint state? If yes, it should trend toward UI-owned. +- Does this code commit, publish, or attach compositor resources? If yes, it should remain main-owned. +- Can a slow style/layout pass block an external surface that opted into present-without-transaction? If yes, the ownership is still wrong. +- Is there a synchronous call from main to UI in a path that must stay responsive? If yes, it needs a queued message or stale-submission handling. + +## Current Progress + +### Done + +- Added `WindowUiDriver` as the explicit UI-owned in-process state owner. +- Moved bare `WindowState` ownership behind `WindowUiDriver`. +- Moved root view id and reactive scope behind `WindowUiDriver`. +- Added `PlatformRequest` plumbing for low-risk main-thread side effects. +- Moved `compositor.apply_plan(...)` out of `GlobalPaintCx`; paint now updates the `CompositionPlan`, and `WindowHandle` applies that plan to the compositor runtime. +- Added `UiSceneSubmission` as the in-process scene-submission boundary for compositor consumption. +- Moved `WindowCompositor` out of `WindowState`; `WindowHandle` now owns it as `compositor_runtime`. +- Added `CompositorRuntime` as the main-thread ownership name for the staged compositor runtime. +- Moved `WindowCompositorSurfaces` out of `WindowState`; `WindowHandle` now owns compositor-surface providers/content/frame-pull state. +- Changed compositor-surface user events to update main-owned compositor-surface state through `WindowHandle` methods. +- Changed frame rendering/commit paths to pass `UiSceneSubmission` explicitly into compositor work. +- Moved style/layout/box-tree frame preparation bodies onto `WindowUiDriver`; `WindowHandle` now delegates those UI-owned phases. +- Moved next-frame promotion and begin-frame callback execution behind `WindowUiDriver`. +- Moved UI dirty/prepare predicates (`needs_style`, layout/box-tree checks, pending box-tree updates, current prepare work) behind `WindowUiDriver`. +- Added a `WindowUiDriver::route_window_event` boundary so normal window-event routing no longer happens open-coded in `WindowHandle`. +- Added `UiFrameStatus` so compositor-critical frame paths consume a UI snapshot instead of borrowing `WindowState` fields directly. +- Moved `UpdateMessage` processing into `WindowUiDriver`; `WindowHandle` now receives only `UiUpdateOutcome` and `PlatformRequest`s for main-thread side effects. +- Removed the old proxy/deferred context-menu path after replacing it with `PlatformRequest`. +- Routed these update messages through `PlatformRequest`: + - `DragWindow` + - `FocusWindow` + - `DragResizeWindow` + - `ToggleWindowMaximized` + - `SetWindowMaximized` + - `MinimizeWindow` + - `SetWindowDelta` + - `SetWindowTitle` + - `ShowContextMenu` + - `WindowMenu` + - `SetImeAllowed` + - `SetImeCursorArea` + - `Inspect` + - `CaptureMetalFrame` + - `WindowVisible` + +### Next + +- Expand `UiSceneSubmission` so the UI side lowers the display list, packages scene-render work, and submits it to the render pool/compositor boundary; main should not pull `CompositionPlan` or view state from UI. +- Continue splitting `WindowCompositor` internals so render-job scheduling can be driven by the UI scene submission while layer-host commits stay main-owned. +- Move UI event/update driving behind `WindowUiDriver` methods instead of keeping those methods on `WindowHandle`. +- Add the real UI-thread worker/proxy now that frame/update loops have explicit UI outputs. +- Keep `ViewId`, root scope, and app view construction exclusively on the UI side. The remaining `root_id_for_legacy_tracking` access is transitional and must not become part of the threaded API. diff --git a/examples/async-resource/src/main.rs b/examples/async-resource/src/main.rs index 2069a28b6..9625354f8 100644 --- a/examples/async-resource/src/main.rs +++ b/examples/async-resource/src/main.rs @@ -186,7 +186,7 @@ fn user_display(user_resource: Resource>) -> impl IntoView { let user = &user_with_avatar.user; let avatar_data = user_with_avatar.avatar_data.clone(); - let avatar = img(move || avatar_data.clone()) + let avatar = Image::new(move || avatar_data.clone()) .style(|s| s.size(60.0, 60.0)) .clip() .style(|s| { diff --git a/examples/context/src/main.rs b/examples/context/src/main.rs index d303fa880..d387ae3a4 100644 --- a/examples/context/src/main.rs +++ b/examples/context/src/main.rs @@ -1,5 +1,4 @@ use floem::{ - action::inspect, prelude::*, reactive::{Context, Scope}, }; @@ -55,11 +54,6 @@ fn app_view() -> impl IntoView { .justify_center() .row_gap(5) }) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) } fn main() { diff --git a/examples/counter-simple/Cargo.toml b/examples/counter-simple/Cargo.toml index 9df02ffbd..5e0680e91 100644 --- a/examples/counter-simple/Cargo.toml +++ b/examples/counter-simple/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] -floem = { path = "../..", features = ["vello"]} +floem = { path = "../..", features = ["skia", "editor"], default-features = false} diff --git a/examples/counter/src/main.rs b/examples/counter/src/main.rs index d1bd71095..a7fa0de00 100644 --- a/examples/counter/src/main.rs +++ b/examples/counter/src/main.rs @@ -1,4 +1,4 @@ -use floem::{action::inspect, prelude::*, unit::UnitExt}; +use floem::{prelude::*, unit::UnitExt}; fn app_view() -> impl IntoView { let counter = RwSignal::new(0); @@ -68,11 +68,6 @@ fn app_view() -> impl IntoView { .items_center() .justify_center() }) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) } fn main() { diff --git a/examples/editor/src/main.rs b/examples/editor/src/main.rs index 0599e80a7..f995f2d64 100644 --- a/examples/editor/src/main.rs +++ b/examples/editor/src/main.rs @@ -61,11 +61,6 @@ fn app_view() -> impl IntoView { .style(|s| s.width_full().flex_row().items_center().justify_center()), )) .style(|s| s.size_full().flex_col().items_center().justify_center()) - .on_event_stop(el::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if let Key::Named(NamedKey::F11) = key { - floem::action::inspect(); - } - }) } fn main() { diff --git a/examples/external-surface-cube/Cargo.toml b/examples/external-surface-cube/Cargo.toml new file mode 100644 index 000000000..79dd0fbe0 --- /dev/null +++ b/examples/external-surface-cube/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "external-surface-cube" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +bytemuck = { version = "1.21", features = ["derive"] } +floem = { path = "../..", default-features = false, features = ["editor", "skia"] } +subduction_core = { git = "https://github.com/forest-rs/subduction", rev = "c684827b4a4e", package = "subduction_core" } +subduction = { git = "https://github.com/forest-rs/subduction", rev = "c684827b4a4e", package = "subduction" } +wgpu.workspace = true diff --git a/examples/external-surface-cube/src/main.rs b/examples/external-surface-cube/src/main.rs new file mode 100644 index 000000000..bfaf36842 --- /dev/null +++ b/examples/external-surface-cube/src/main.rs @@ -0,0 +1,801 @@ +use std::time::Instant; + +use bytemuck::{Pod, Zeroable}; +use floem::{ + Application, Brush, Composite, CompositorSurfaceProducer, CompositorSurfaceProducerConfig, + Filter, FrameRatePreference, GpuResources, ImageBrush, LayerFilter, ShaderSource, + ShaderUniform, SurfaceImage, + action::set_animation_frame_callback, + context::{LayoutChanged, PaintCx}, + group_ref, + imaging::{ClipRef, GeometryRef, ImagingSceneSink, PaintSink, Painter}, + kurbo::{Affine, Point, Rect, Size, Vec2}, + peniko::Color, + prelude::*, + reactive::{RwSignal, SyncRwSignal}, + style::{ObjectFit, ObjectPosition, Pct, Position}, + text::{Alignment, Attrs, AttrsList, FontWeight, TextLayout}, + window::{WindowConfig, WindowId}, +}; +use wgpu::util::DeviceExt; + +const CUBE_SIZE: u32 = 640; +const CUBE_TARGET_FPS: f64 = 120.0; + +fn cube_text_brush_transform(layout_size: Size, pct: Pct) -> Affine { + if layout_size.width <= 0.0 || layout_size.height <= 0.0 { + return Affine::IDENTITY; + } + + let source_size = Size::new(f64::from(CUBE_SIZE), f64::from(CUBE_SIZE)); + let target_scale = pct.0 / 100.0; + let target_size = Size::new( + layout_size.width * target_scale, + layout_size.height * target_scale, + ); + let target_origin = Point::new( + (layout_size.width - target_size.width) * 0.5, + (layout_size.height - target_size.height) * 0.5, + ); + + Affine::translate(target_origin.to_vec2()) + * Affine::scale_non_uniform( + target_size.width / source_size.width, + target_size.height / source_size.height, + ) +} + +fn app_view(window_id: WindowId) -> impl IntoView { + let rotation_speed = SyncRwSignal::new_sync(1.0_f64); + let cube_frame_rate = FrameRatePreference::at_most(CUBE_TARGET_FPS).unwrap(); + let cube_producer = CompositorSurfaceProducer::new( + window_id, + Size::new(f64::from(CUBE_SIZE), f64::from(CUBE_SIZE)), + CompositorSurfaceProducerConfig { + frame_rate: cube_frame_rate, + ..CompositorSurfaceProducerConfig::default() + }, + ); + let cube_image = cube_producer.image(); + let text_layout = RwSignal::new(LayoutChanged::default()); + + Stack::vertical(( + "Compositor Surface as Image Brush".style(|s| { + s.font_size(30.0) + .font_weight(FontWeight::BOLD) + .color(Color::from_rgb8(246, 241, 226)) + }), + "The cube is rendered by a compositor-surface producer into a Floem/Subduction-owned wgpu texture. Floem can publish that compositor-owned frame as a native layer, or sample the same frame through imaging as an external image brush. This example uses the cube texture as an image brush for the large glyph fill, then flattens the surrounding clip, blur, and checkerboard layer filter into one ordered render pass when direct layer promotion is not legal." + .style(move |s| { + s.font_size(14.0) + .line_height(1.35) + .text_wrap() + .text_align(Alignment::Center) + // .max_width(760.0) + .width_full() + // .color(Color::from_rgb8(155, 169, 177)) + .color(cube_image) + .text_brush_transform(cube_text_brush_transform(text_layout.get().new_content_box.size(), 200.pct())) + }) + .on_event_stop(LayoutChanged::listener(), move |_cx, change| { + text_layout.set(*change); + }), + Stack::horizontal(( + "Rotation speed".style(|s| { + s.font_size(13.0) + .color(Color::from_rgb8(205, 214, 208)) + .width(104.0) + }), + slider::Slider::new_ranged(move || rotation_speed.get(), 0.0..=3.0) + .step(0.05) + .on_event_stop(slider::SliderChanged::listener(), move |_cx, state| { + rotation_speed.set(state.value); + }) + .style(|s| s.width(260.0).height(14.0)), + Label::derived(move || format!("{:.2}x", rotation_speed.get())).style(|s| { + s.font_size(13.0) + .color(Color::from_rgb8(205, 214, 208)) + .width(48.0) + }), + )) + .style(|s| { + s.items_center() + .gap(12.0) + .margin_top(16.0) + .margin_bottom(2.0) + }), + cube_canvas(cube_image, rotation_speed), + )) + .style(move |s| { + s.width_full() + .height_full() + .items_center() + .justify_center() + .padding(36.0) + .background(Color::from_rgb8(11, 15, 17)) + }) + .on_event_stop( + listener::WindowGpuResourcesReady, + move |_cx, gpu_resources| { + if let Err(err) = start_cube_target( + cube_producer.clone(), + gpu_resources.clone(), + rotation_speed, + ) { + eprintln!("compositor-surface-cube: failed to start producer target: {err}"); + } + }, + ) +} + +fn cube_canvas(cube_image: SurfaceImage, animation_speed: SyncRwSignal) -> impl IntoView { + let shimmer_uniforms = ShaderUniform::new([0.0_f32; 4]); + let source_uniforms = ShaderUniform::new([0.0_f32; 4]); + drive_shimmer_uniforms(shimmer_uniforms.clone(), animation_speed); + drive_shimmer_uniforms(source_uniforms.clone(), animation_speed); + let main_canvas = canvas(move |cx, size| { + let layout = CubeCanvasLayout::new(size); + layout.paint_source_panel(cx, source_uniforms.clone()); + layout.paint_filtered_panel(cx, cube_image, shimmer_uniforms.clone()); + }) + .style(|s| { + s.width(780.0) + .height(520.0) + .margin_top(22.0) + .position(Position::Relative) + .border_radius(32.0) + .border(1.0) + .border_color(Color::from_rgba8(241, 219, 167, 55)) + }); + ( + main_canvas, + Image::new(cube_image).style(|s| { + s.width(150) + .height(300) + .object_position(ObjectPosition::Center) + .object_fit(ObjectFit::Contain) + .border(3) + .border_color(css::RED) + }), + ) +} + +struct CubeCanvasLayout { + canvas: Rect, + filtered_panel: Rect, + label: Rect, + label_text: Rect, +} + +impl CubeCanvasLayout { + fn new(size: Size) -> Self { + let canvas = Rect::ZERO.with_size(size); + let cube = centered_rect(size, Size::new(440.0, 330.0)); + let filtered_panel = Rect::new(72.0, 76.0, size.width - 72.0, size.height - 76.0); + let label = Rect::new( + cube.x0 - 18.0, + cube.y0 + 138.0, + cube.x1 + 18.0, + cube.y0 + 192.0, + ); + let label_text = Rect::new(label.x0, label.y0 + 6.0, label.x1, label.y1 - 6.0); + + Self { + canvas, + filtered_panel, + label, + label_text, + } + } + + fn paint_source_panel(&self, cx: &mut PaintCx<'_>, uniforms: ShaderUniform<[f32; 4]>) { + let source = source_panel_shader(uniforms); + cx.painter + .fill( + self.canvas.to_rounded_rect(32.0), + Brush::Image(ImageBrush::from(source.image(self.canvas.size()))), + ) + .draw(); + } + + fn paint_filtered_panel( + &self, + cx: &mut PaintCx<'_>, + cube_image: SurfaceImage, + shimmer_uniforms: ShaderUniform<[f32; 4]>, + ) { + let filters = [ + checkerboard_filter().into(), + shimmer_filter(shimmer_uniforms).into(), + ]; + let effective_scale = cx.effective_scale; + cx.painter.with_group( + group_ref().with_filters(&filters).with_clip(ClipRef::Fill { + transform: Affine::IDENTITY, + shape: GeometryRef::RoundedRect(self.canvas.to_rounded_rect(32.0)), + fill_rule: floem::peniko::Fill::NonZero, + }), + move |p| { + p.fill( + self.filtered_panel.to_rounded_rect(30.0), + Color::from_rgb8(25, 47, 50), + ) + .draw(); + + p.fill( + self.label.to_rounded_rect(20.0), + Color::from_rgba8(247, 226, 164, 230), + ) + .draw(); + self.paint_texture_text(p, effective_scale, cube_image); + }, + ); + } + + fn paint_texture_text( + &self, + painter: &mut Painter<'_, S, Filter, Composite, Brush>, + effective_scale: f64, + cube_image: SurfaceImage, + ) where + S: PaintSink + ImagingSceneSink, + { + let mut text = TextLayout::new_with_text( + "TEXTURE BRUSH", + AttrsList::new( + Attrs::new() + .font_size(34.0) + .weight(FontWeight::BOLD) + .brush(cube_image), + ), + Some(Alignment::Center), + ); + text.set_size( + self.label_text.width() as f32, + self.label_text.height() as f32, + ); + + let (min_y, max_y) = text.centering_bounds_y().unwrap_or((0.0, 0.0)); + let text_height = f64::from(max_y - min_y); + let origin = Point::new( + self.label_text.x0, + self.label_text.y0 + ((self.label_text.height() - text_height) * 0.5) + - f64::from(min_y), + ); + text.draw_with_painter_with_brush_transform( + painter.as_dyn(), + origin, + Vec2::ZERO, + effective_scale, + Some(cube_text_brush_transform(self.label_text.size(), 100.pct())), + ); + } +} + +fn source_panel_shader(uniforms: ShaderUniform<[f32; 4]>) -> ShaderSource { + ShaderSource::wgsl( + r#" +let seconds = bitcast(args.data.x); +let drift = vec2(seconds * 34.0, seconds * -21.0); +let wave = vec2( + sin(position.y * 0.027 + seconds * 1.7), + cos(position.x * 0.023 - seconds * 1.3), +) * 15.0; +let twist_center = vec2(frame.target_width, frame.target_height) * 0.5; +let from_center = position - twist_center; +let radius = length(from_center); +let angle = sin(radius * 0.017 - seconds * 1.9) * 0.22; +let warped = vec2( + from_center.x * cos(angle) - from_center.y * sin(angle), + from_center.x * sin(angle) + from_center.y * cos(angle), +) + twist_center + drift + wave; +let cell = floor(warped / vec2(22.0, 22.0)); +let checker = (cell.x + cell.y) - 2.0 * floor((cell.x + cell.y) * 0.5); +let stripe = 0.5 + 0.5 * sin((warped.x + warped.y) * 0.035 + seconds * 2.4); +let base = mix(vec3(0.08, 0.20, 0.22), vec3(0.13, 0.34, 0.36), checker); +let glow = 0.5 + 0.5 * sin(radius * 0.025 - seconds * 3.1); +return vec4(base + stripe * 0.045 + glow * 0.025, 0.58); +"#, + ) + .with_label("cube source shader panel") + .with_uniforms(uniforms) + .with_color_space(subduction::wgpu::SurfaceColorSpace::ExtendedLinearSrgb) +} + +fn checkerboard_filter() -> LayerFilter { + LayerFilter::wgsl( + r#" +let sampled = textureSample(input_texture, input_sampler, uv); +let cell = floor(position / vec2(28.0, 28.0)); +let checker = (cell.x + cell.y) - 2.0 * floor((cell.x + cell.y) * 0.5); +let tint_a = vec3(1.12, 0.94, 0.72); +let tint_b = vec3(0.72, 1.03, 1.12); +let tint = mix(tint_a, tint_b, checker); +return vec4(sampled.rgb * tint, sampled.a); +"#, + ) + .with_label("cube checkerboard layer filter") + .with_color_space(subduction::wgpu::SurfaceColorSpace::ExtendedLinearSrgb) +} + +fn shimmer_filter(uniforms: ShaderUniform<[f32; 4]>) -> LayerFilter { + LayerFilter::wgsl( + r#" +let sampled = textureSample(input_texture, input_sampler, uv); +let seconds = bitcast(args.data.x); +let wave = 0.5 + 0.5 * sin(position.x * 0.025 + position.y * 0.018 + seconds * 2.6); +let edge = smoothstep(0.76, 1.0, wave); +let highlight = vec3(0.95, 0.58, 0.20) * edge * 0.32; +return vec4(sampled.rgb + highlight, sampled.a); +"#, + ) + .with_label("cube animated shimmer") + .with_uniforms(uniforms) + .with_color_space(subduction::wgpu::SurfaceColorSpace::ExtendedLinearSrgb) +} + +fn drive_shimmer_uniforms(uniforms: ShaderUniform<[f32; 4]>, animation_speed: SyncRwSignal) { + let mut last_present = None; + let mut animation_seconds = 0.0_f32; + set_animation_frame_callback( + FrameRatePreference::at_most(CUBE_TARGET_FPS).unwrap(), + move |frame_time| { + let present = frame_time + .interval + .predicted_present + .unwrap_or(frame_time.now); + let delta_seconds = last_present + .map(|last| present.saturating_duration_since(last).as_secs_f32()) + .unwrap_or(0.0) + .min(0.25); + last_present = Some(present); + animation_seconds += delta_seconds * animation_speed.get_untracked() as f32; + let seconds = animation_seconds; + uniforms.set([seconds, (seconds * 1.7).sin(), (seconds * 1.3).cos(), 0.0]); + }, + ); +} + +fn centered_rect(container: Size, size: Size) -> Rect { + let origin = Point::new( + (container.width - size.width).max(0.0) * 0.5, + (container.height - size.height).max(0.0) * 0.5 + 16.0, + ); + Rect::from_origin_size(origin, size) +} + +fn start_cube_target( + producer: CompositorSurfaceProducer, + gpu_resources: GpuResources, + rotation_speed: SyncRwSignal, +) -> Result<(), String> { + let mut renderer = CubeRenderer::new( + gpu_resources.clone(), + CUBE_SIZE, + CUBE_SIZE, + wgpu::TextureFormat::Bgra8Unorm, + )?; + let animation_origin = Instant::now(); + let mut last_animation_time = animation_origin; + let mut rotation_seconds = 0.0_f32; + producer.set_frame_callback(move |cx| { + let lease = match cx.acquire_target() { + Ok(lease) => lease, + Err(subduction::wgpu::SurfaceFrameError::NoTargetAvailable) => { + cx.skip(subduction::wgpu::SurfaceSkipReason::ProducerBusy); + return Ok(()); + } + Err(err) => return Err(err), + }; + let completion_tx = cx.completion_sender(); + let frame_time = cx.frame_time(); + let animation_time = frame_time + .interval + .predicted_present + .unwrap_or(frame_time.now); + let delta_seconds = animation_time + .saturating_duration_since(last_animation_time) + .as_secs_f32() + .min(0.25); + last_animation_time = animation_time; + let rotation_speed = rotation_speed.get_untracked() as f32; + rotation_seconds += delta_seconds * rotation_speed; + match renderer.render(rotation_seconds, lease) { + Ok(completion) => { + let _ = completion_tx.send(completion); + } + Err(err) => { + eprintln!("compositor-surface-cube: {err}"); + cx.skip(subduction::wgpu::SurfaceSkipReason::ProducerDropped); + return Ok(()); + } + } + Ok(()) + }); + Ok(()) +} + +struct CubeRenderer { + gpu_resources: GpuResources, + pipeline: wgpu::RenderPipeline, + vertex_buffer: wgpu::Buffer, + index_buffer: wgpu::Buffer, + index_count: u32, + uniform_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, + depth: wgpu::TextureView, + width: u32, + height: u32, +} + +impl CubeRenderer { + fn new( + gpu_resources: GpuResources, + width: u32, + height: u32, + target_format: wgpu::TextureFormat, + ) -> Result { + let device = &gpu_resources.device; + let depth = create_depth_texture(device, width, height); + let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cube uniform buffer"), + contents: bytemuck::bytes_of(&identity_matrix()), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("cube bind group layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("cube bind group"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("cube shader"), + source: wgpu::ShaderSource::Wgsl(CUBE_SHADER.into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("cube pipeline layout"), + bind_group_layouts: &[&bind_group_layout], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("cube pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[Vertex::layout()], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: target_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24Plus, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cube vertex buffer"), + contents: bytemuck::cast_slice(VERTICES), + usage: wgpu::BufferUsages::VERTEX, + }); + let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cube index buffer"), + contents: bytemuck::cast_slice(INDICES), + usage: wgpu::BufferUsages::INDEX, + }); + + Ok(Self { + gpu_resources, + pipeline, + vertex_buffer, + index_buffer, + index_count: INDICES.len() as u32, + uniform_buffer, + bind_group, + depth, + width, + height, + }) + } + + fn render( + &mut self, + rotation_seconds: f32, + frame: subduction::wgpu::SurfaceFrameLease, + ) -> Result { + if self.width != frame.size.width || self.height != frame.size.height { + self.width = frame.size.width.max(1); + self.height = frame.size.height.max(1); + self.depth = create_depth_texture(&self.gpu_resources.device, self.width, self.height); + } + let aspect = self.width as f32 / self.height as f32; + let model = mul( + rotation_y(rotation_seconds * 0.9), + rotation_x(rotation_seconds * 0.55), + ); + let view = translation(0.0, 0.0, -4.8); + let projection = perspective(45_f32.to_radians(), aspect, 0.1, 100.0); + let mvp = mul(projection, mul(view, model)); + self.gpu_resources + .queue + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&mvp)); + + let mut encoder = + self.gpu_resources + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("cube encoder"), + }); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("cube render pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &frame.view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16); + pass.draw_indexed(0..self.index_count, 0, 0..1); + } + + self.gpu_resources.queue.submit(Some(encoder.finish())); + let _ = self.gpu_resources.device.poll(wgpu::PollType::Poll); + Ok(frame.submit()) + } +} + +fn create_depth_texture(device: &wgpu::Device, width: u32, height: u32) -> wgpu::TextureView { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some("cube depth texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth24Plus, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }) + .create_view(&wgpu::TextureViewDescriptor { + label: Some("cube depth texture view"), + ..Default::default() + }) +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Vertex { + position: [f32; 3], + color: [f32; 3], +} + +impl Vertex { + fn layout() -> wgpu::VertexBufferLayout<'static> { + wgpu::VertexBufferLayout { + array_stride: size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 1, + format: wgpu::VertexFormat::Float32x3, + }, + ], + } + } +} + +const VERTICES: &[Vertex] = &[ + Vertex { + position: [-1.0, -1.0, 1.0], + color: [0.95, 0.32, 0.22], + }, + Vertex { + position: [1.0, -1.0, 1.0], + color: [1.0, 0.72, 0.25], + }, + Vertex { + position: [1.0, 1.0, 1.0], + color: [0.25, 0.75, 1.0], + }, + Vertex { + position: [-1.0, 1.0, 1.0], + color: [0.37, 0.95, 0.62], + }, + Vertex { + position: [-1.0, -1.0, -1.0], + color: [0.92, 0.27, 0.65], + }, + Vertex { + position: [1.0, -1.0, -1.0], + color: [0.37, 0.45, 1.0], + }, + Vertex { + position: [1.0, 1.0, -1.0], + color: [0.70, 0.86, 0.26], + }, + Vertex { + position: [-1.0, 1.0, -1.0], + color: [0.95, 0.95, 0.98], + }, +]; + +const INDICES: &[u16] = &[ + 0, 1, 2, 0, 2, 3, 1, 5, 6, 1, 6, 2, 5, 4, 7, 5, 7, 6, 4, 0, 3, 4, 3, 7, 3, 2, 6, 3, 6, 7, 4, 5, + 1, 4, 1, 0, +]; + +const CUBE_SHADER: &str = r#" +struct Uniforms { + mvp: mat4x4, +}; + +@group(0) @binding(0) +var uniforms: Uniforms; + +struct VertexIn { + @location(0) position: vec3, + @location(1) color: vec3, +}; + +struct VertexOut { + @builtin(position) position: vec4, + @location(0) color: vec3, +}; + +@vertex +fn vs_main(vertex: VertexIn) -> VertexOut { + var out: VertexOut; + out.position = uniforms.mvp * vec4(vertex.position, 1.0); + out.color = vertex.color; + return out; +} + +@fragment +fn fs_main(vertex: VertexOut) -> @location(0) vec4 { + return vec4(vertex.color, 1.0); +} +"#; + +type Mat4 = [[f32; 4]; 4]; + +fn identity_matrix() -> Mat4 { + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] +} + +fn translation(x: f32, y: f32, z: f32) -> Mat4 { + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [x, y, z, 1.0], + ] +} + +fn rotation_x(radians: f32) -> Mat4 { + let (sin, cos) = radians.sin_cos(); + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, cos, sin, 0.0], + [0.0, -sin, cos, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] +} + +fn rotation_y(radians: f32) -> Mat4 { + let (sin, cos) = radians.sin_cos(); + [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] +} + +fn perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> Mat4 { + let f = 1.0 / (fovy * 0.5).tan(); + [ + [f / aspect, 0.0, 0.0, 0.0], + [0.0, f, 0.0, 0.0], + [0.0, 0.0, far / (near - far), -1.0], + [0.0, 0.0, (near * far) / (near - far), 0.0], + ] +} + +fn mul(a: Mat4, b: Mat4) -> Mat4 { + let mut out = [[0.0; 4]; 4]; + for col in 0..4 { + for row in 0..4 { + out[col][row] = a[0][row] * b[col][0] + + a[1][row] * b[col][1] + + a[2][row] * b[col][2] + + a[3][row] * b[col][3]; + } + } + out +} + +fn main() { + Application::new() + .window( + app_view, + Some( + WindowConfig::default() + .size(Size::new(1200.0, 660.0)) + .title("Compositor Surface Cube"), + ), + ) + .run(); +} diff --git a/examples/files/Cargo.toml b/examples/files/Cargo.toml index 1da1aa7a9..736eb51d2 100644 --- a/examples/files/Cargo.toml +++ b/examples/files/Cargo.toml @@ -4,4 +4,14 @@ version = "0.1.0" edition = "2021" [dependencies] -floem = { path = "../.." } +floem = { path = "../..", default-features = false, features = [ + "editor", + "default-image-formats", + "serde", +] } + +[features] +default = ["vello-cpu"] +vello-cpu = ["floem/vello-cpu"] +vello = ["floem/vello"] +vello-hybrid = ["floem/vello-hybrid"] diff --git a/examples/handle_window_close/src/main.rs b/examples/handle_window_close/src/main.rs index a15414cf1..701500361 100644 --- a/examples/handle_window_close/src/main.rs +++ b/examples/handle_window_close/src/main.rs @@ -10,17 +10,10 @@ use floem::{ new_window, prelude::{palette::css, *}, reactive::{RwSignal, SignalGet}, - ui_events::keyboard::{Key, KeyboardEvent, Modifiers}, views::{Button, Empty, Label, Overlay, Stack}, window::{WindowConfig, WindowId}, }; -const OS_MOD: Modifiers = if cfg!(target_os = "macos") { - Modifiers::META -} else { - Modifiers::CONTROL -}; - fn confirm_overlay(window_id: WindowId, show_confirm: RwSignal) -> Overlay { let backdrop = Empty::new() .style(|s| { @@ -77,14 +70,6 @@ fn closable_window(window_id: WindowId, content: V) -> im cx.prevent_default(); show_confirm.set(true); }) - .on_event_stop( - listener::KeyUp, - move |_cx, KeyboardEvent { modifiers, key, .. }| { - if *key == Key::Character("w".into()) && modifiers.contains(OS_MOD) { - floem::request_close_window(window_id); - } - }, - ) } fn second_window_view(window_id: WindowId) -> impl IntoView { diff --git a/examples/layout/src/draggable_sidebar.rs b/examples/layout/src/draggable_sidebar.rs index 5261c9d44..4b96c79d3 100644 --- a/examples/layout/src/draggable_sidebar.rs +++ b/examples/layout/src/draggable_sidebar.rs @@ -56,9 +56,4 @@ pub fn draggable_sidebar_view() -> impl IntoView { s.handle_color(Brush::Solid(dragger_color)) .active(|s| s.handle_color(Brush::Solid(active_dragger_color))) }) - .on_event_stop(el::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if let Key::Named(NamedKey::F11) = key { - floem::action::inspect(); - } - }) } diff --git a/examples/layout/src/holy_grail.rs b/examples/layout/src/holy_grail.rs index 204f91ceb..a6161ece6 100644 --- a/examples/layout/src/holy_grail.rs +++ b/examples/layout/src/holy_grail.rs @@ -76,11 +76,5 @@ pub fn holy_grail_view() -> impl IntoView { .width_full() }); - Stack::vertical((top_bar, content)) - .style(|s| s.width_full().height_full()) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - floem::action::inspect(); - } - }) + Stack::vertical((top_bar, content)).style(|s| s.width_full().height_full()) } diff --git a/examples/layout/src/left_sidebar.rs b/examples/layout/src/left_sidebar.rs index 7744583eb..27c1782d9 100644 --- a/examples/layout/src/left_sidebar.rs +++ b/examples/layout/src/left_sidebar.rs @@ -59,11 +59,5 @@ pub fn left_sidebar_view() -> impl IntoView { .width_full() }); - Stack::vertical((top_bar, content)) - .style(|s| s.width_full().height_full()) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - floem::action::inspect(); - } - }) + Stack::vertical((top_bar, content)).style(|s| s.width_full().height_full()) } diff --git a/examples/layout/src/main.rs b/examples/layout/src/main.rs index 7af176aa0..4638c4001 100644 --- a/examples/layout/src/main.rs +++ b/examples/layout/src/main.rs @@ -93,11 +93,6 @@ fn app_view() -> impl IntoView { .padding(10.0) .row_gap(10.0) }) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if let Key::Named(NamedKey::F11) = key { - floem::action::inspect(); - } - }) .window_title(|| String::from("Layout examples")) } diff --git a/examples/layout/src/right_sidebar.rs b/examples/layout/src/right_sidebar.rs index 2ecedd7f7..26527d6da 100644 --- a/examples/layout/src/right_sidebar.rs +++ b/examples/layout/src/right_sidebar.rs @@ -59,11 +59,5 @@ pub fn right_sidebar_view() -> impl IntoView { .width_full() }); - Stack::vertical((top_bar, content)) - .style(|s| s.width_full().height_full()) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - floem::action::inspect(); - } - }) + Stack::vertical((top_bar, content)).style(|s| s.width_full().height_full()) } diff --git a/examples/layout/src/tab_navigation.rs b/examples/layout/src/tab_navigation.rs index d9713f40a..c3910d0a1 100644 --- a/examples/layout/src/tab_navigation.rs +++ b/examples/layout/src/tab_navigation.rs @@ -94,11 +94,5 @@ pub fn tab_navigation_view() -> impl IntoView { .container() .style(|s| s.size_full()); - Stack::vertical((tabs_bar, main_content)) - .style(|s| s.width_full().height_full()) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - floem::action::inspect(); - } - }) + Stack::vertical((tabs_bar, main_content)).style(|s| s.width_full().height_full()) } diff --git a/examples/localization/Cargo.toml b/examples/localization/Cargo.toml index 6a669d847..4298c922a 100644 --- a/examples/localization/Cargo.toml +++ b/examples/localization/Cargo.toml @@ -5,4 +5,4 @@ license.workspace = true version.workspace = true [dependencies] -floem = { path = "../..", features = ["vello", "localization"] } +floem = { path = "../..", features = ["localization"] } diff --git a/examples/localization/src/main.rs b/examples/localization/src/main.rs index a82ec9032..c312ad374 100644 --- a/examples/localization/src/main.rs +++ b/examples/localization/src/main.rs @@ -1,4 +1,4 @@ -use floem::{action::inspect, prelude::*}; +use floem::prelude::*; use localization::*; fn main() { @@ -55,21 +55,15 @@ fn counter_view() -> impl IntoView { )) .style(|s| s.size_full().items_center().justify_center().gap(10.)); - Stack::vertical((lang_tabs, value_controls)) - .style(move |s| { - s.size_full() - .items_center() - .justify_center() - .custom(|ls: L10nCustomStyle| { - ls.bundle(localizations.clone()) - .apply_opt(langauge.get(), |ls, locale| { - ls.locale(locale.parse::().unwrap()) - }) - }) - }) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) + Stack::vertical((lang_tabs, value_controls)).style(move |s| { + s.size_full() + .items_center() + .justify_center() + .custom(|ls: L10nCustomStyle| { + ls.bundle(localizations.clone()) + .apply_opt(langauge.get(), |ls, locale| { + ls.locale(locale.parse::().unwrap()) + }) + }) + }) } diff --git a/examples/navigation-stack-mobile/Cargo.toml b/examples/navigation-stack-mobile/Cargo.toml index 49cad61d7..e910fa4f0 100644 --- a/examples/navigation-stack-mobile/Cargo.toml +++ b/examples/navigation-stack-mobile/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] -floem = { path = "../..", features = ["vello"] } +floem = { path = "../.." } diff --git a/examples/navigation-stack-mobile/src/main.rs b/examples/navigation-stack-mobile/src/main.rs index 8688fb37f..545684a51 100644 --- a/examples/navigation-stack-mobile/src/main.rs +++ b/examples/navigation-stack-mobile/src/main.rs @@ -1,6 +1,6 @@ use floem::{ AnyView, Application, LazyView, - action::{exec_after_animation_frame, set_window_scale}, + action::request_animation_frame, event::listener, kurbo::Size, new_window, @@ -36,12 +36,6 @@ const HOME_SVG: &str = r#" static PAYMENT_ATTEMPTS: AtomicU64 = AtomicU64::new(0); -const OS_MOD: Modifiers = if cfg!(target_os = "macos") { - Modifiers::META -} else { - Modifiers::CONTROL -}; - #[derive(Clone, Copy)] struct Spot { id: &'static str, @@ -646,13 +640,11 @@ fn main() { .run(); } -fn app_view(window_id: WindowId) -> impl IntoView { +fn app_view(_window_id: WindowId) -> impl IntoView { let router = Router::new(); ROUTER.with(|current| { current.replace(Some(router)); }); - let mut window_scale = RwSignal::new(1.0); - let nav = NavigationStack::new(router.path) .style(|s| s.width_full().max_width(500.)) .container() @@ -677,36 +669,6 @@ fn app_view(window_id: WindowId) -> impl IntoView { }) .window_title(|| "Navigation Stack Mobile".to_owned()) .debug_name("app") - .on_event_stop( - el::KeyUp, - move |_cx, KeyboardEvent { modifiers, key, .. }| { - if *key == Key::Named(NamedKey::F11) { - floem::action::inspect(); - } else if *key == Key::Character("q".into()) && modifiers.contains(OS_MOD) { - floem::quit_app(); - } else if *key == Key::Character("w".into()) && modifiers.contains(OS_MOD) { - floem::close_window(window_id); - } - }, - ) - .on_event_stop( - el::KeyDown, - move |_, KeyboardEvent { key, modifiers, .. }| match key { - Key::Character(ch) if (ch == "=" || ch == "+") && modifiers.contains(OS_MOD) => { - window_scale *= 1.1; - set_window_scale(window_scale.get()); - } - Key::Character(ch) if ch == "-" && *modifiers == OS_MOD => { - window_scale /= 1.1; - set_window_scale(window_scale.get()); - } - Key::Character(ch) if ch == "0" && *modifiers == OS_MOD => { - window_scale.set(1.0); - set_window_scale(window_scale.get()); - } - _ => {} - }, - ) } fn home_screen() -> impl IntoView { @@ -837,7 +799,7 @@ fn quick_action_sheet(button_id: ViewId) -> impl IntoView { if visible { let sheet_id = sheet.id(); - exec_after_animation_frame(move |_| { + request_animation_frame(move |_| { sheet_id.request_focus(); }); } diff --git a/examples/pan-zoom/src/main.rs b/examples/pan-zoom/src/main.rs index 61330de74..a452976f9 100644 --- a/examples/pan-zoom/src/main.rs +++ b/examples/pan-zoom/src/main.rs @@ -1,4 +1,4 @@ -use floem::{action::inspect, kurbo, prelude::*, reactive::Effect}; +use floem::{kurbo, prelude::*, reactive::Effect}; mod pan_zoom_view; @@ -24,7 +24,7 @@ fn child_view() -> impl IntoView { .style(|s| s.border(1.0).border_radius(8.0).height(64.0).width(64.0)), Stack::horizontal(( Stack::vertical(( - img(move || ferris_png.to_vec()).style(|s| s.width(69.0).height(45.9)), + Image::new(move || ferris_png.to_vec()).style(|s| s.width(69.0).height(45.9)), "PNG".style(|s| s.justify_center()), )), Stack::vertical(( @@ -56,11 +56,6 @@ fn app_view() -> impl IntoView { pan_zoom_view(view_transform.get(), child.style(|s| s.size_full())) .style(|s| s.width_full().height_full().background(palette::css::BLACK)) .on_pan_zoom(move |affine| view_transform.set(affine)) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) } fn main() { diff --git a/examples/pan-zoom/src/pan_zoom_view.rs b/examples/pan-zoom/src/pan_zoom_view.rs index b8391916d..fdabc6693 100644 --- a/examples/pan-zoom/src/pan_zoom_view.rs +++ b/examples/pan-zoom/src/pan_zoom_view.rs @@ -2,7 +2,7 @@ use std::{collections::VecDeque, time::Duration}; use floem::{ IntoView, View, - action::exec_after_animation_frame, + action::request_animation_frame, event::{Event, EventPropagation, PointerScrollEventExt}, kurbo::{self, Vec2}, ui_events::pointer::{PointerButtonEvent, PointerEvent, PointerUpdate}, @@ -178,7 +178,7 @@ impl PanZoomView { fn schedule_update(&mut self) { let id = self.id(); - exec_after_animation_frame(move |_| { + request_animation_frame(move |_| { id.update_state(Box::new(())); }); } diff --git a/examples/responsive/src/main.rs b/examples/responsive/src/main.rs index 8a5982928..313b16647 100644 --- a/examples/responsive/src/main.rs +++ b/examples/responsive/src/main.rs @@ -1,5 +1,4 @@ use floem::{ - action::inspect, prelude::*, reactive::{SignalGet, SignalUpdate}, responsive::{range, ScreenSize}, @@ -74,11 +73,6 @@ fn app_view() -> impl IntoView { .items_center() .padding(20) }) - .on_event_stop(el::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) } fn main() { diff --git a/examples/stacks/src/lib.rs b/examples/stacks/src/lib.rs index b5f6b71c3..177a49867 100644 --- a/examples/stacks/src/lib.rs +++ b/examples/stacks/src/lib.rs @@ -1,4 +1,4 @@ -use floem::{action::inspect, prelude::*}; +use floem::prelude::*; mod dyn_stack; mod stack; @@ -45,9 +45,4 @@ pub fn stacks_view() -> impl IntoView { (basic_stack, stack_from_iter, dyn_stack, virtual_stack) .h_stack() .style(|s| s.flex().margin(20).width_full().height_full().col_gap(10)) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) } diff --git a/examples/syntax-editor/src/main.rs b/examples/syntax-editor/src/main.rs index fd4bc5ffb..9ec1c0a96 100644 --- a/examples/syntax-editor/src/main.rs +++ b/examples/syntax-editor/src/main.rs @@ -1,5 +1,4 @@ use floem::{ - action::inspect, prelude::*, text::{Attrs, AttrsList, FamilyOwned, FontWeight, FontWidth}, views::editor::{ @@ -233,11 +232,6 @@ mod tests { .style(|s| s.width_full().flex_row().items_center().justify_center()), )) .style(|s| s.size_full().flex_col().items_center().justify_center()) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) } fn main() { diff --git a/examples/themes/src/main.rs b/examples/themes/src/main.rs index bf6175c7d..99eac0e07 100644 --- a/examples/themes/src/main.rs +++ b/examples/themes/src/main.rs @@ -143,11 +143,6 @@ fn app_view() -> impl IntoView { .justify_center() }) .window_title(|| "Themes Example".to_string()) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if let Key::Named(NamedKey::F11) = key { - floem::action::inspect(); - } - }) } fn main() { diff --git a/examples/todo-complex/Cargo.toml b/examples/todo-complex/Cargo.toml index b18f94524..6b1f1b4ca 100644 --- a/examples/todo-complex/Cargo.toml +++ b/examples/todo-complex/Cargo.toml @@ -8,7 +8,6 @@ confy = "1.0.0" floem = { path = "../..", features = [ "editor", "serde", - "vello", ], default-features = false } rusqlite = { version = "0.37.0", features = ["bundled"] } serde = { workspace = true } diff --git a/examples/todo-complex/src/app_config.rs b/examples/todo-complex/src/app_config.rs index 4b07d4637..576a299ff 100644 --- a/examples/todo-complex/src/app_config.rs +++ b/examples/todo-complex/src/app_config.rs @@ -1,6 +1,5 @@ use floem::{ action::set_window_scale, - event::EventPropagation, kurbo::{Point, Size}, prelude::*, reactive::{Context, UpdaterEffect}, @@ -9,8 +8,6 @@ use floem::{ }; use serde::{Deserialize, Serialize}; -use crate::OS_MOD; - #[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Debug)] pub enum AppTheme { FollowSystem, @@ -75,38 +72,6 @@ pub fn launch_with_track(app_view: impl FnOnce() -> V + ' move |_| { set_window_scale(app_config.with(|c| c.window_scale)); app_view() - .on_event( - el::KeyDown, - move |_, KeyboardEvent { key, modifiers, .. }| match key { - Key::Character(ch) - if (ch == "=" || ch == "+") && modifiers.contains(OS_MOD) => - { - app_config.update(|ac| { - ac.window_scale *= 1.1; - floem::action::set_window_scale(ac.window_scale); - }); - EventPropagation::Stop - } - - Key::Character(ch) if ch == "-" && *modifiers == OS_MOD => { - app_config.update(|ac| { - ac.window_scale /= 1.1; - floem::action::set_window_scale(ac.window_scale); - }); - EventPropagation::Stop - } - - Key::Character(ch) if ch == "0" && *modifiers == OS_MOD => { - app_config.update(|ac| { - ac.window_scale = 1.; - floem::action::set_window_scale(ac.window_scale); - }); - EventPropagation::Stop - } - - _ => EventPropagation::Continue, - }, - ) .on_event_stop(listener::WindowMoved, move |_cx, position| { app_config.update(|val| { val.position = *position; diff --git a/examples/todo-complex/src/main.rs b/examples/todo-complex/src/main.rs index c1ddfce48..0915fb5b9 100644 --- a/examples/todo-complex/src/main.rs +++ b/examples/todo-complex/src/main.rs @@ -1,10 +1,7 @@ use std::time::Instant; use floem::{ - action::{exec_after, inspect}, - event::EventPropagation, - prelude::*, - style::CustomStylable, + action::exec_after, event::EventPropagation, prelude::*, style::CustomStylable, ui_events::keyboard::Modifiers, }; use todo::TodoState; @@ -57,11 +54,6 @@ fn app_view() -> impl IntoView { .action(move || { AppCommand::Escape.execute(); }) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) .on_event( el::KeyDown, |_, KeyboardEvent { key, modifiers, .. }| match key { diff --git a/examples/tokio-timer/src/main.rs b/examples/tokio-timer/src/main.rs index 6c74015e1..6f0a45652 100644 --- a/examples/tokio-timer/src/main.rs +++ b/examples/tokio-timer/src/main.rs @@ -1,7 +1,6 @@ use std::time::Duration; use floem::{ - action::inspect, prelude::*, reactive::{DerivedRwSignal, Memo}, receiver_signal::StreamSignal, @@ -78,11 +77,6 @@ fn app_view() -> impl IntoView { .items_center() .justify_center() }) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) } /// A slider with a thin bar instead of the default thick bar. diff --git a/examples/view-transition/Cargo.toml b/examples/view-transition/Cargo.toml index b02a666b2..52fdf0dfc 100644 --- a/examples/view-transition/Cargo.toml +++ b/examples/view-transition/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] -floem = { path = "../..", features = ["vello"] } +floem = { path = "../.." } diff --git a/examples/widget-gallery/Cargo.toml b/examples/widget-gallery/Cargo.toml index c7698529f..70359fd73 100644 --- a/examples/widget-gallery/Cargo.toml +++ b/examples/widget-gallery/Cargo.toml @@ -4,14 +4,22 @@ version = "0.1.0" edition = "2024" [dependencies] -floem = { path = "../..", features = ["skia"] } +floem = { path = "../..", default-features = false, features = [ + "editor", + "default-image-formats", + "serde", +] } strum = { workspace = true } -files = { path = "../files/", optional = true } +files = { path = "../files/", optional = true, default-features = false } -[target.'cfg(target_os = "macos")'.dependencies] -signpost = "*" [features] -default = ["full"] -vello = ["floem/vello"] +default = ["full", "skia" ] +vello = ["floem/vello", "files?/vello"] +vger = ["floem/vger"] +skia = ["floem/skia"] +skia-cpu = ["floem/skia-cpu"] +vello-cpu = ["floem/vello-cpu"] +tiny-skia = ["floem/tiny-skia"] +vello-hybrid = ["floem/vello-hybrid", "files?/vello-hybrid"] full = ["dep:files"] diff --git a/examples/widget-gallery/src/animation.rs b/examples/widget-gallery/src/animation.rs index fab5a631e..bc57c395d 100644 --- a/examples/widget-gallery/src/animation.rs +++ b/examples/widget-gallery/src/animation.rs @@ -44,8 +44,8 @@ pub fn animation_view() -> impl IntoView { .animation(move |_| { animation .get() - .pause(move || pause.track()) - .resume(move || resume.track()) + // .pause(move || pause.track()) + // .resume(move || resume.track()) .delay(3.seconds()) }) .on_event_stop(listener::PointerEnter, move |_, _| { diff --git a/examples/widget-gallery/src/canvas.rs b/examples/widget-gallery/src/canvas.rs index bdf5077fd..02b44e23f 100644 --- a/examples/widget-gallery/src/canvas.rs +++ b/examples/widget-gallery/src/canvas.rs @@ -1,13 +1,21 @@ -use std::{cell::RefCell, rc::Rc, time::Instant}; +use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration, time::Instant}; +use floem::imaging::record::Scene; +use floem::imaging::{Brush as ImagingBrush, Painter, SceneImage}; +use floem::kurbo::Affine; +use floem::peniko::color::Oklch; +use floem::{Brush, Gradient, Image, ImageBrush}; use floem::{ ViewId, context::{EventCx, LayoutChanged, LayoutChangedListener, PaintCx}, - easing::Spring, - event::{CustomEvent, Event, EventPropagation}, - kurbo::{Affine, Circle, Point, Rect, Shape, Size, Stroke}, + easing::{Linear, Spring}, + event::{ + CustomEvent, DragConfig, DragEvent, DragSourceEvent, Event, EventPropagation, + PointerCaptureEvent, + }, + kurbo::{Circle, Point, Rect, Shape, Size, Stroke}, peniko::{ - Gradient, Mix, + Compose, Extend, ImageQuality, Mix, color::{ AlphaColor, ColorSpaceTag::{self, LinearSrgb}, @@ -23,6 +31,24 @@ use palette::css; use crate::form::{form, form_item}; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct CheckerboardCacheKey { + light_color_bits: [u32; 4], + dark_color_bits: [u32; 4], +} + +fn checkerboard_cache_key(light_color: Color, dark_color: Color) -> CheckerboardCacheKey { + CheckerboardCacheKey { + light_color_bits: light_color.components.map(f32::to_bits), + dark_color_bits: dark_color.components.map(f32::to_bits), + } +} + +thread_local! { + static CHECKERBOARD_TILE_IMAGE_CACHE: RefCell> = + RefCell::new(HashMap::new()); +} + pub fn canvas_view() -> impl IntoView { let rounded = RwSignal::new(true); let color = RwSignal::new(css::AQUA); @@ -48,15 +74,17 @@ pub fn canvas_view() -> impl IntoView { rounded.track(); let now = Instant::now(); if border_radius.borrow_mut().step(&now) { - cx.window_state.schedule_paint(cx.target_id.owning_id()); + cx.window_state.schedule_paint(cx.target_id); } - cx.fill( - &Rect::ZERO - .with_size(size) - .to_rounded_rect(border_radius.borrow().get()), - color.get(), - 0., - ); + let brush = Brush::Solid(color.get()); + cx.painter + .fill( + Rect::ZERO + .with_size(size) + .to_rounded_rect(border_radius.borrow().get()), + &brush, + ) + .draw(); }) .style(|s| s.size(300, 100)), Button::new("toggle rounded corners") @@ -90,9 +118,10 @@ fn color_picker(color: RwSignal) -> impl IntoView { let base_color = color.get(); - draw_transparency_checkerboard(cx, size, &rect_path); + draw_checkerboard(cx, size, &rect_path, 8.0, css::WHITE, css::LIGHT_GRAY); - cx.fill(&rect_path, base_color, 0.); + let brush = Brush::Solid(base_color); + cx.painter.fill(rect_path, &brush).draw(); }) .style(move |s| s.height_full().aspect_ratio(1.).border(1).border_radius(8)) .debug_name("final color"), @@ -116,33 +145,123 @@ fn two_d_picker(color: RwSignal) -> impl IntoView { .debug_name("2d picker") } -fn draw_transparency_checkerboard(cx: &mut PaintCx, size: Size, clip_path: &impl Shape) { - cx.push_layer(Mix::Normal, 1.0, Affine::IDENTITY, clip_path); - - let cell_size = 8.0; - let dark_color = css::LIGHT_GRAY; - let light_color = css::WHITE; +fn draw_checkerboard( + cx: &mut PaintCx, + size: Size, + clip_path: &impl Shape, + cell_size: f64, + light_color: Color, + dark_color: Color, +) { + cx.painter.with_fill_clip(clip_path.to_path(0.1), |p| { + let brush = checkerboard_tile_brush(light_color, dark_color); + p.fill(Rect::ZERO.with_size(size), &brush) + .brush_transform( + Some(Affine::scale(cell_size)), // scale the 2x2 tile to cell_size px per square + ) + .draw(); + }); +} - let cols = (size.width / cell_size).ceil() as usize; - let rows = (size.height / cell_size).ceil() as usize; +fn checkerboard_tile_brush(light_color: Color, dark_color: Color) -> ImageBrush { + let image = cached_checkerboard_tile_image(light_color, dark_color); + ImageBrush::new(Image::Scene(image.into())) + .with_extend(Extend::Repeat) + .with_quality(ImageQuality::Low) +} - for row in 0..rows { - for col in 0..cols { - let is_dark = (row + col) % 2 == 0; - let color = if is_dark { dark_color } else { light_color }; +fn cached_checkerboard_tile_image(light_color: Color, dark_color: Color) -> SceneImage { + let key = checkerboard_cache_key(light_color, dark_color); + CHECKERBOARD_TILE_IMAGE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + cache + .entry(key) + .or_insert_with(|| build_checkerboard_tile_image(light_color, dark_color)) + .clone() + }) +} - let rect = Rect::new( - col as f64 * cell_size, - row as f64 * cell_size, - (col + 1) as f64 * cell_size, - (row + 1) as f64 * cell_size, - ); +fn build_checkerboard_tile_image(light_color: Color, dark_color: Color) -> SceneImage { + let mut scene = Scene::new(); + let mut painter = Painter::new(&mut scene); + let dark_brush = ImagingBrush::Solid(dark_color); + let light_brush = ImagingBrush::Solid(light_color); + + // Fill whole 2x2 with light + painter + .fill(Rect::new(0.0, 0.0, 2.0, 2.0), &light_brush) + .draw(); + // Top-left dark + painter + .fill(Rect::new(0.0, 0.0, 1.0, 1.0), &dark_brush) + .draw(); + // Bottom-right dark + painter + .fill(Rect::new(1.0, 1.0, 2.0, 2.0), &dark_brush) + .draw(); + + SceneImage::new(scene, 2, 2) +} - cx.fill(&rect, color, 0.0); - } +fn build_sat_value_field(size: Size, hue: f32) -> Rc { + let rect_path = Rect::ZERO.with_size(size).to_rounded_rect(8.); + let mut scene = Scene::new(); + let mut p = Painter::new(&mut scene); + { + let white = ImagingBrush::Solid(css::WHITE); + p.fill(rect_path, &white).draw(); + + let sat_gradient: ImagingBrush = + floem::peniko::Gradient::new_linear(Point::ZERO, Point::new(size.width, 0.0)) + .with_stops([ + (0.0, css::WHITE), + (1.0, AlphaColor::::new([hue, 100., 50., 1.]).convert()), + ]) + .with_interpolation_cs(ColorSpaceTag::LinearSrgb) + .into(); + p.fill(rect_path, &sat_gradient).draw(); + + let val_gradient: ImagingBrush = + floem::peniko::Gradient::new_linear(Point::ZERO, Point::new(0.0, size.height)) + .with_stops([(0.0, Color::from_rgba8(0, 0, 0, 0)), (1.0, css::BLACK)]) + .with_interpolation_cs(ColorSpaceTag::LinearSrgb) + .into(); + let group = floem::imaging::GroupRef::new() + .with_clip(floem::imaging::ClipRef::fill(rect_path)) + .with_composite(floem::imaging::Composite::new( + floem::peniko::BlendMode { + mix: Mix::Multiply, + compose: Compose::SrcOver, + }, + 1.0, + )); + p.with_group(group, |painter| { + painter.fill(rect_path, &val_gradient).draw(); + }); } + Rc::new(scene) +} - cx.pop_layer(); +fn build_hue_picker_gradient(size: Size) -> Rc { + let rect_path = Rect::ZERO.with_size(size).to_rounded_rect(8.); + let mut scene = Scene::new(); + let mut p = Painter::new(&mut scene); + { + let hue_gradient: ImagingBrush = floem::peniko::Gradient::new_linear( + Point::new(0.0, size.height / 2.0), + Point::new(size.width, size.height / 2.0), + ) + .with_stops([ + (0.0, AlphaColor::::new([0.7, 0.3, 0.0, 1.0])), + (1.0, AlphaColor::::new([0.7, 0.3, 360.0, 1.0])), + ]) + .with_hue_direction(floem::peniko::color::HueDirection::Longer) + .with_interpolation_cs(ColorSpaceTag::Oklch) + .into(); + + p.fill(rect_path, &hue_gradient).draw(); + } + Rc::new(scene) } pub struct SatValuePicker { @@ -151,7 +270,8 @@ pub struct SatValuePicker { current_color: AlphaColor, on_change: Option>, point: Point, - track: bool, + retained_draw: Option>, + retained_draw_hue_bits: Option, } impl SatValuePicker { pub fn new(color: impl Fn() -> Color + 'static) -> Self { @@ -164,7 +284,8 @@ impl SatValuePicker { current_color: color.convert(), on_change: None, point: Point::ZERO, - track: false, + retained_draw: None, + retained_draw_hue_bits: None, } } @@ -195,6 +316,14 @@ impl SatValuePicker { fn post_layout(&mut self, new_layout: &LayoutChanged) { self.size = new_layout.new_box.size(); + self.retained_draw = None; + } + + fn set_from_point(&mut self, point: Point) -> Color { + self.id.request_paint(); + self.current_color = self.position_to_hwb(point); + self.point = point; + self.current_color.convert() } } impl View for SatValuePicker { @@ -205,6 +334,7 @@ impl View for SatValuePicker { fn update(&mut self, _cx: &mut floem::context::UpdateCx, state: Box) { if let Ok(color) = state.downcast::() { self.current_color = color.convert(); + self.id.request_paint(); } } @@ -212,36 +342,35 @@ impl View for SatValuePicker { if let Some(new_layout) = LayoutChangedListener::extract(&cx.event) { self.post_layout(new_layout); } - if let Some(on_change) = &self.on_change { - match &cx.event { - Event::Pointer(PointerEvent::Down(PointerButtonEvent { - state, pointer, .. - })) => { - self.current_color = self.position_to_hwb(state.logical_point()); - self.point = state.logical_point(); - on_change(self.current_color.convert()); - self.track = true; - if let Some(pointer_id) = pointer.pointer_id { - cx.request_pointer_capture(pointer_id); - } - } - Event::Pointer(PointerEvent::Up(PointerButtonEvent { state, .. })) => { - self.current_color = self.position_to_hwb(state.logical_point()); - self.point = state.logical_point(); - on_change(self.current_color.convert()); - self.track = false; - } - Event::Pointer(PointerEvent::Move(pu)) => { - if self.track { - self.current_color = self.position_to_hwb(pu.current.logical_point()); - self.point = pu.current.logical_point(); - on_change(self.current_color.convert()); - } - } - _ => { - return EventPropagation::Continue; + let updated_color = match &cx.event { + Event::Pointer(PointerEvent::Down(PointerButtonEvent { state, pointer, .. })) => { + if let Some(pointer_id) = pointer.pointer_id { + cx.window_state.set_pointer_capture(pointer_id, self.id); } + Some(self.set_from_point(state.logical_point())) } + Event::PointerCapture(PointerCaptureEvent::Gained(drag)) => { + cx.start_drag(*drag, DragConfig::new(0., Duration::ZERO, Linear), false); + None + } + Event::Drag(DragEvent::Source(DragSourceEvent::Move(dme))) => { + Some(self.set_from_point(dme.current_state.logical_point())) + } + Event::Drag(DragEvent::Source(DragSourceEvent::End(de))) => { + Some(self.set_from_point(de.current_state.logical_point())) + } + Event::Drag(DragEvent::Source(DragSourceEvent::Cancel(dce))) => { + Some(self.set_from_point(dce.current_state.logical_point())) + } + _ => { + return EventPropagation::Continue; + } + }; + + if let Some(color) = updated_color + && let Some(on_change) = &self.on_change + { + on_change(color); } EventPropagation::Stop } @@ -250,32 +379,16 @@ impl View for SatValuePicker { let size = self.size; let rect_path = Rect::ZERO.with_size(size).to_rounded_rect(8.); let hue = self.current_color.components[0]; + let hue_bits = hue.to_bits(); - // base - cx.fill(&rect_path, css::WHITE, 0.); - - // saturation gradient - let sat_gradient = Gradient::new_linear(Point::ZERO, Point::new(size.width, 0.0)) - .with_stops([ - (0.0, css::WHITE), - (1.0, AlphaColor::::new([hue, 100., 50., 1.]).convert()), - ]) - .with_interpolation_cs(ColorSpaceTag::LinearSrgb); - - cx.fill(&rect_path, &sat_gradient, 0.); - - // value gradient - cx.push_layer(Mix::Multiply, 1.0, Affine::IDENTITY, &rect_path); - - let val_gradient = Gradient::new_linear(Point::ZERO, Point::new(0.0, size.height)) - .with_stops([(0.0, Color::from_rgba8(0, 0, 0, 0)), (1.0, css::BLACK)]) - .with_interpolation_cs(ColorSpaceTag::LinearSrgb); - - cx.fill(&rect_path, &val_gradient, 0.); - - cx.pop_layer(); + if self.retained_draw.is_none() || self.retained_draw_hue_bits != Some(hue_bits) { + self.retained_draw = Some(build_sat_value_field(size, hue)); + self.retained_draw_hue_bits = Some(hue_bits); + } - cx.clip(&rect_path); + if let Some(scene) = self.retained_draw.as_ref() { + cx.painter.as_imaging_painter().replay(scene.as_ref()); + } if size.width > 0.0 && size.height > 0.0 { // Larger indicator @@ -286,25 +399,26 @@ impl View for SatValuePicker { let inner = Circle::new(self.point, inner_radius); // fill center with the selected color - cx.fill(&inner, self.current_color.convert::(), 0.); - - // white outline for contrast - cx.stroke(&outer, css::WHITE, &Stroke::new(2.0)); - - // subtle black inner stroke so it works on light backgrounds - cx.stroke(&inner, css::BLACK, &Stroke::new(1.0)); + let inner_fill = Brush::Solid(self.current_color.convert::()); + let white = Brush::Solid(css::WHITE); + let black = Brush::Solid(css::BLACK); + cx.painter.with_fill_clip(rect_path, |p| { + p.fill(inner.to_path(0.1), &inner_fill).draw(); + p.stroke(outer.to_path(0.1), &Stroke::new(2.0), &white) + .draw(); + p.stroke(inner.to_path(0.1), &Stroke::new(1.0), &black) + .draw(); + }); } - - cx.clear_clip(); } } pub struct HuePicker { id: ViewId, size: Size, - current_color: AlphaColor, + current_color: AlphaColor, on_change: Option>, - track: bool, + retained_draw: Option>, } impl HuePicker { @@ -317,18 +431,18 @@ impl HuePicker { size: Size::ZERO, current_color: color.convert(), on_change: None, - track: false, + retained_draw: None, } } - fn position_to_hsl(&self, pos: Point) -> AlphaColor { - let hue = (pos.x / self.size.width * 360.0).clamp(0.0, 360.0); - - let saturation = self.current_color.components[1]; - let value = self.current_color.components[2]; - let alpha = self.current_color.components[3]; - - AlphaColor::::new([hue as f32, saturation, value, alpha]) + fn position_to_oklch(&self, pos: Point) -> AlphaColor { + let hue = (pos.x / self.size.width * 360.0).clamp(0.0, 360.0) as f32; + AlphaColor::::new([ + self.current_color.components[0], // preserve L + self.current_color.components[1], // preserve C + hue, + self.current_color.components[3], // preserve alpha + ]) } pub fn on_change(mut self, on_change: impl Fn(Color) + 'static) -> Self { @@ -338,6 +452,13 @@ impl HuePicker { fn post_layout(&mut self, new_layout: &LayoutChanged) { self.size = new_layout.new_box.size(); + self.retained_draw = None; + } + + fn set_from_point(&mut self, point: Point) -> Color { + self.id.request_paint(); + self.current_color = self.position_to_oklch(point); + self.current_color.convert() } } @@ -349,6 +470,7 @@ impl View for HuePicker { fn update(&mut self, _cx: &mut floem::context::UpdateCx, state: Box) { if let Ok(color) = state.downcast::() { self.current_color = color.convert(); + self.id.request_paint(); } } @@ -356,33 +478,35 @@ impl View for HuePicker { if let Some(new_layout) = LayoutChangedListener::extract(&cx.event) { self.post_layout(new_layout); } - if let Some(on_change) = &self.on_change { - match &cx.event { - Event::Pointer(PointerEvent::Down(PointerButtonEvent { - state, pointer, .. - })) => { - self.current_color = self.position_to_hsl(state.logical_point()); - on_change(self.current_color.convert()); - self.track = true; - if let Some(pointer_id) = pointer.pointer_id { - cx.request_pointer_capture(pointer_id); - } - } - Event::Pointer(PointerEvent::Up(PointerButtonEvent { state, .. })) => { - self.current_color = self.position_to_hsl(state.logical_point()); - on_change(self.current_color.convert()); - self.track = false; - } - Event::Pointer(PointerEvent::Move(pu)) => { - if self.track { - self.current_color = self.position_to_hsl(pu.current.logical_point()); - on_change(self.current_color.convert()); - } - } - _ => { - return EventPropagation::Continue; + let updated_color = match &cx.event { + Event::Pointer(PointerEvent::Down(PointerButtonEvent { state, pointer, .. })) => { + if let Some(pointer_id) = pointer.pointer_id { + cx.window_state.set_pointer_capture(pointer_id, self.id); } + Some(self.set_from_point(state.logical_point())) + } + Event::PointerCapture(PointerCaptureEvent::Gained(drag)) => { + cx.start_drag(*drag, DragConfig::new(0., Duration::ZERO, Linear), false); + None + } + Event::Drag(DragEvent::Source(DragSourceEvent::Move(dme))) => { + Some(self.set_from_point(dme.current_state.logical_point())) + } + Event::Drag(DragEvent::Source(DragSourceEvent::End(de))) => { + Some(self.set_from_point(de.current_state.logical_point())) } + Event::Drag(DragEvent::Source(DragSourceEvent::Cancel(dce))) => { + Some(self.set_from_point(dce.current_state.logical_point())) + } + _ => { + return EventPropagation::Continue; + } + }; + + if let Some(color) = updated_color + && let Some(on_change) = &self.on_change + { + on_change(color); } EventPropagation::Stop } @@ -390,20 +514,16 @@ impl View for HuePicker { fn paint(&mut self, cx: &mut PaintCx) { let size = self.size; let rect_path = Rect::ZERO.with_size(size).to_rounded_rect(8.); - let hue_gradient = Gradient::new_linear( - Point::new(0.0, size.height / 2.0), - Point::new(size.width, size.height / 2.0), - ) - .with_stops([ - (0.0, AlphaColor::::new([0.0, 100.0, 50.0, 1.0])), - (1.0, AlphaColor::::new([360.0, 100.0, 50.0, 1.0])), - ]) - .with_hue_direction(floem::peniko::color::HueDirection::Longer) - .with_interpolation_cs(ColorSpaceTag::Hsl); - cx.fill(&rect_path, &hue_gradient, 0.); + if self.retained_draw.is_none() { + self.retained_draw = Some(build_hue_picker_gradient(size)); + } + + if let Some(scene) = self.retained_draw.as_ref() { + cx.painter.as_imaging_painter().replay(scene.as_ref()); + } if size.width > 0.0 { - let hue = self.current_color.components[0]; + let hue = self.current_color.components[2]; let x_pos = hue as f64 / 360. * size.width; let indicator_width = 2.0; @@ -414,10 +534,12 @@ impl View for HuePicker { size.height, ); - cx.clip(&rect_path); - cx.stroke(&indicator_rect, css::WHITE, &Stroke::new(2.0)); - cx.fill(&indicator_rect, css::BLACK, 0.); - cx.clear_clip(); + let white = Brush::Solid(css::WHITE); + let black = Brush::Solid(css::BLACK); + cx.painter.with_fill_clip(rect_path, |p| { + p.stroke(indicator_rect, &Stroke::new(2.0), &white).draw(); + p.fill(indicator_rect, &black).draw(); + }); } } } @@ -427,7 +549,6 @@ pub struct OpacityPicker { size: Size, current_color: Color, on_change: Option>, - track: bool, } impl OpacityPicker { @@ -440,7 +561,6 @@ impl OpacityPicker { size: Size::ZERO, current_color: color, on_change: None, - track: false, } } @@ -458,6 +578,12 @@ impl OpacityPicker { fn post_layout(&mut self, layout_changed: &LayoutChanged) { self.size = layout_changed.new_box.size(); } + + fn set_from_point(&mut self, point: Point) -> Color { + self.id.request_paint(); + self.current_color = self.position_to_alpha(point); + self.current_color + } } impl View for OpacityPicker { @@ -468,6 +594,7 @@ impl View for OpacityPicker { fn update(&mut self, _cx: &mut floem::context::UpdateCx, state: Box) { if let Ok(color) = state.downcast::() { self.current_color = *color; + self.id.request_paint(); } } @@ -475,33 +602,35 @@ impl View for OpacityPicker { if let Some(new_layout) = LayoutChangedListener::extract(&cx.event) { self.post_layout(new_layout); } - if let Some(on_change) = &self.on_change { - match &cx.event { - Event::Pointer(PointerEvent::Down(PointerButtonEvent { - state, pointer, .. - })) => { - self.current_color = self.position_to_alpha(state.logical_point()); - on_change(self.current_color); - self.track = true; - if let Some(pointer_id) = pointer.pointer_id { - cx.request_pointer_capture(pointer_id); - } - } - Event::Pointer(PointerEvent::Up(PointerButtonEvent { state, .. })) => { - self.current_color = self.position_to_alpha(state.logical_point()); - on_change(self.current_color); - self.track = false; - } - Event::Pointer(PointerEvent::Move(pu)) => { - if self.track { - self.current_color = self.position_to_alpha(pu.current.logical_point()); - on_change(self.current_color); - } - } - _ => { - return EventPropagation::Continue; + let updated_color = match &cx.event { + Event::Pointer(PointerEvent::Down(PointerButtonEvent { state, pointer, .. })) => { + if let Some(pointer_id) = pointer.pointer_id { + cx.window_state.set_pointer_capture(pointer_id, self.id); } + Some(self.set_from_point(state.logical_point())) + } + Event::PointerCapture(PointerCaptureEvent::Gained(drag)) => { + cx.start_drag(*drag, DragConfig::new(0., Duration::ZERO, Linear), false); + None } + Event::Drag(DragEvent::Source(DragSourceEvent::Move(dme))) => { + Some(self.set_from_point(dme.current_state.logical_point())) + } + Event::Drag(DragEvent::Source(DragSourceEvent::End(de))) => { + Some(self.set_from_point(de.current_state.logical_point())) + } + Event::Drag(DragEvent::Source(DragSourceEvent::Cancel(dce))) => { + Some(self.set_from_point(dce.current_state.logical_point())) + } + _ => { + return EventPropagation::Continue; + } + }; + + if let Some(color) = updated_color + && let Some(on_change) = &self.on_change + { + on_change(color); } EventPropagation::Stop } @@ -510,9 +639,9 @@ impl View for OpacityPicker { let size = self.size; let rect_path = Rect::ZERO.with_size(size).to_rounded_rect(8.); - draw_transparency_checkerboard(cx, size, &rect_path); + draw_checkerboard(cx, size, &rect_path, 8.0, css::WHITE, css::LIGHT_GRAY); - let opacity_gradient = Gradient::new_linear( + let opacity_gradient: Brush = Gradient::new_linear( Point::new(0.0, size.height / 2.0), Point::new(size.width, size.height / 2.0), ) @@ -520,26 +649,29 @@ impl View for OpacityPicker { (0.0, self.current_color.with_alpha(0.0)), (1.0, self.current_color.with_alpha(1.0)), ]) - .with_interpolation_cs(LinearSrgb); - - cx.fill(&rect_path, &opacity_gradient, 0.); - - if size.width > 0.0 { - let alpha = self.current_color.components[3]; - let x_pos = alpha as f64 * size.width; - - let indicator_width = 2.0; - let indicator_rect = Rect::new( - x_pos - indicator_width / 2.0, - 0.0, - x_pos + indicator_width / 2.0, - size.height, - ); - - cx.clip(&rect_path); - cx.stroke(&indicator_rect, css::WHITE, &Stroke::new(2.0)); - cx.fill(&indicator_rect, css::BLACK, 0.); - cx.clear_clip(); - } + .with_interpolation_cs(LinearSrgb) + .into(); + + cx.painter.with_fill_clip(rect_path, |p| { + p.fill(rect_path, &opacity_gradient).draw(); + + if size.width > 0.0 { + let alpha = self.current_color.components[3]; + let x_pos = alpha as f64 * size.width; + + let indicator_width = 2.0; + let indicator_rect = Rect::new( + x_pos - indicator_width / 2.0, + 0.0, + x_pos + indicator_width / 2.0, + size.height, + ); + + let white = Brush::Solid(css::WHITE); + let black = Brush::Solid(css::BLACK); + p.stroke(indicator_rect, &Stroke::new(2.0), &white).draw(); + p.fill(indicator_rect, &black).draw(); + } + }); } } diff --git a/examples/widget-gallery/src/draggable.rs b/examples/widget-gallery/src/draggable.rs index eb4a628d1..1f3f65ff9 100644 --- a/examples/widget-gallery/src/draggable.rs +++ b/examples/widget-gallery/src/draggable.rs @@ -1,7 +1,8 @@ use floem::{ easing, event::DragConfig, - peniko::{Brush, color::Oklab}, + imaging::Brush, + peniko::color::Oklab, prelude::{palette::css, *}, style::{Background, CursorStyle, TextColor}, style_class, @@ -167,7 +168,11 @@ pub fn draggable_view() -> impl IntoView { b.and_then(|b| { if let Brush::Solid(c) = b { let l = c.convert::().components[0]; - Some(if l < 0.5 { css::WHITE } else { css::BLACK }) + Some(Brush::Solid(if l < 0.5 { + css::WHITE + } else { + css::BLACK + })) } else { None } diff --git a/examples/widget-gallery/src/dropped_file.rs b/examples/widget-gallery/src/dropped_file.rs index 35eba41f6..c8b06841c 100644 --- a/examples/widget-gallery/src/dropped_file.rs +++ b/examples/widget-gallery/src/dropped_file.rs @@ -1,10 +1,8 @@ use crate::form::{form, form_item}; use floem::{ - action::inspect, dropped_file::FileDragDropped, prelude::{palette::css, *}, theme::HoverTargetClass, - ui_events::keyboard::{Key, NamedKey}, }; pub fn dropped_file_view() -> impl IntoView { @@ -20,11 +18,6 @@ pub fn dropped_file_view() -> impl IntoView { .items_center() .justify_center() }) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) .on_event_stop( el::FileDragDrop, move |_cx, e @ FileDragDropped { paths, .. }| { diff --git a/examples/widget-gallery/src/embolden.rs b/examples/widget-gallery/src/embolden.rs new file mode 100644 index 000000000..21a951a0f --- /dev/null +++ b/examples/widget-gallery/src/embolden.rs @@ -0,0 +1,68 @@ +use floem::{kurbo::Vec2, prelude::*, style::FontEmbolden, unit::Pct}; + +use crate::form::{form, form_item}; + +const MAX_EMBOLDEN_PX: f64 = 1.5; +const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non neque ut nibh aliquet volutpat. Integer bibendum, velit et interdum tincidunt, lectus arcu feugiat nisl, vitae tempus sem arcu ac enim. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi pretium, lacus sed feugiat malesuada, justo orci volutpat nibh, vitae sollicitudin justo mauris non ipsum. Donec id est sed eros sagittis congue. Integer posuere, nibh in laoreet viverra, risus mauris sodales purus, id porttitor sapien lorem eget odio.\n\nSuspendisse potenti. Praesent nec mi quis mauris lacinia porttitor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Fusce vitae orci consequat, pretium ligula et, malesuada ipsum. Curabitur non turpis nisl. Duis non magna id neque ullamcorper tristique. Nulla facilisi. Mauris commodo sem et lectus tincidunt, in ultrices enim bibendum. Quisque lacinia volutpat augue, non interdum metus bibendum ut.\n\nAliquam erat volutpat. Vestibulum sodales tincidunt magna, at pharetra enim viverra nec. Nam feugiat, risus a efficitur luctus, lectus justo suscipit lacus, id pulvinar nibh mauris ac purus. Cras sit amet dui sed neque accumsan commodo. In nec luctus ipsum. Phasellus viverra felis non elit sagittis, at hendrerit risus dignissim. Donec faucibus orci nec lectus volutpat, eget vulputate ipsum molestie."; + +fn slider_to_px(slider_pct: Pct) -> f64 { + slider_pct.0 * MAX_EMBOLDEN_PX / 100.0 +} + +fn amount_label(name: &'static str, amount: impl Fn() -> f64 + 'static) -> impl IntoView { + Label::derived(move || format!("{name}: {:.3}px", amount())) +} + +pub fn embolden_view() -> impl IntoView { + let x_pct = RwSignal::new(Pct(50.0)); + let y_pct = RwSignal::new(Pct(50.0)); + + let sample = Label::new(LOREM_IPSUM) + .style(move |s| { + s.font_size(18.0) + .line_height(1.45) + .text_wrap() + .width(720.0) + .padding(20.0) + .border(1.0) + .border_radius(10.0) + .set( + FontEmbolden, + Vec2::new(slider_to_px(x_pct.get()), slider_to_px(y_pct.get())), + ) + }) + .scroll() + .style(|s| s.height(340.0).width_full()); + + form(( + form_item( + "X Amount:", + ( + slider::Slider::new_rw(x_pct).style(|s| s.width(200).height_full()), + amount_label("X", move || slider_to_px(x_pct.get())), + ) + .h_stack() + .style(|s| s.gap(12).items_center()), + ), + form_item( + "Y Amount:", + ( + slider::Slider::new_rw(y_pct).style(|s| s.width(200).height_full()), + amount_label("Y", move || slider_to_px(y_pct.get())), + ) + .h_stack() + .style(|s| s.gap(12).items_center()), + ), + form_item( + "Combined:", + Label::derived(move || { + let embolden = Vec2::new(slider_to_px(x_pct.get()), slider_to_px(y_pct.get())); + format!( + "FontEmbolden = Vec2::new({:.3}, {:.3}) px", + embolden.x, embolden.y + ) + }), + ), + form_item("Sample:", sample), + )) +} diff --git a/examples/widget-gallery/src/images.rs b/examples/widget-gallery/src/images.rs index ed7e0e1eb..709f5c064 100644 --- a/examples/widget-gallery/src/images.rs +++ b/examples/widget-gallery/src/images.rs @@ -8,36 +8,41 @@ use crate::form::{form, form_item}; pub fn img_view() -> impl IntoView { let ferris_png = include_bytes!("./../assets/ferris.png"); + let ferris_png_data = Image::image_data(ferris_png); let ferris_svg = include_str!("./../assets/ferris.svg"); let svg_str = r##" "##; let sunflower = include_bytes!("./../assets/sunflower.jpg"); + let sunflower_data = Image::image_data(sunflower); form(( form_item( "PNG:", - img(move || ferris_png.to_vec()).style(|s| s.aspect_ratio(1.5)), + Image::new(ferris_png_data.clone()).style(|s| s.aspect_ratio(1.5)), ), form_item( "PNG(resized):", - img(move || ferris_png.to_vec()).style(|s| s.width(230.pt()).height(153.pt())), + Image::new(ferris_png_data.clone()).style(|s| s.width(230.pt()).height(153.pt())), ), form_item( "SVG(from file):", svg(ferris_svg).style(|s| s.unset_color().width(230.pt())), ), - form_item("Image Fit", object_fit_position_picker(ferris_png)), + form_item( + "Image Fit", + object_fit_position_picker(ferris_png_data.clone()), + ), form_item("SVG(from string):", svg(svg_str).style(|s| s.width(100))), - form_item("JPG:", img(move || sunflower.to_vec())), + form_item("JPG:", Image::new(sunflower_data.clone())), form_item( "JPG(resized):", - img(move || sunflower.to_vec()).style(|s| s.width(320.pt()).height(490.pt())), + Image::new(sunflower_data.clone()).style(|s| s.width(320.pt()).height(490.pt())), ), )) } -fn object_fit_position_picker(image: &'static [u8]) -> impl IntoView { +fn object_fit_position_picker(image_data: impl ImgSource) -> impl IntoView { let object_fit = RwSignal::new(ObjectFit::Cover); let object_position = RwSignal::new(ObjectPosition::Center); @@ -128,7 +133,7 @@ fn object_fit_position_picker(image: &'static [u8]) -> impl IntoView { )) .style(|s| s.gap(10).items_start()); - let preview = img(move || image.to_vec()).style(move |s| { + let preview = Image::new(image_data).style(move |s| { s.object_fit(object_fit.get()) .object_position(object_position.get()) .size(300, 300) diff --git a/examples/widget-gallery/src/labels.rs b/examples/widget-gallery/src/labels.rs index ec31220a3..d63289168 100644 --- a/examples/widget-gallery/src/labels.rs +++ b/examples/widget-gallery/src/labels.rs @@ -1,4 +1,5 @@ use floem::{ + kurbo::Rect, prelude::*, text::{Alignment, FontStyle, FontWeight}, theme::StyleThemeExt, @@ -7,6 +8,19 @@ use floem::{ use crate::form::{form, form_item}; pub fn label_view() -> impl IntoView { + let new_box = canvas(|cx, size| { + let rect = Rect::from_origin_size((0., 0.), size); + // let rect = kurbo::expand( + // rect.to_path(0.01), + // Vec2::new(100., 50.), + // kurbo::Join::Bevel, + // 100., + // 0.01, + // ); + cx.painter.fill(rect, css::RED).draw(); + }) + .style(|s| s.size(100, 100)); + form(( form_item( "Simple Label:", @@ -33,5 +47,6 @@ pub fn label_view() -> impl IntoView { .text_align(Alignment::Center) }), ), + form_item("canvas box", new_box), )) } diff --git a/examples/widget-gallery/src/main.rs b/examples/widget-gallery/src/main.rs index d6d36bdae..19a1a0d2d 100644 --- a/examples/widget-gallery/src/main.rs +++ b/examples/widget-gallery/src/main.rs @@ -7,6 +7,7 @@ pub mod context_menu; pub mod draggable; pub mod dropdown; pub mod dropped_file; +pub mod embolden; pub mod form; pub mod images; pub mod inputs; @@ -22,21 +23,13 @@ use floem::{ action::{set_theme, set_window_menu, toggle_global_theme, toggle_window_theme}, kurbo::Size, menu::*, - muda::{AboutMetadataBuilder, PredefinedMenuItem}, new_window, prelude::{palette::css, *}, style::{Background, CursorStyle, CustomStylable, Transition}, theme::StyleThemeExt, - ui_events::keyboard::{Key, KeyboardEvent, Modifiers, NamedKey}, window::{Theme, WindowConfig, WindowId}, }; -pub const OS_MOD: Modifiers = if cfg!(target_os = "macos") { - Modifiers::META -} else { - Modifiers::CONTROL -}; - fn app_view(window_id: WindowId) -> impl IntoView { let tabs: Vec<&'static str> = vec![ "Label", @@ -55,6 +48,7 @@ fn app_view(window_id: WindowId) -> impl IntoView { "Rich Text", "Clipboard", "Animation", + "Embolden", "Draggable", "Dropped File", "Files", @@ -77,6 +71,7 @@ fn app_view(window_id: WindowId) -> impl IntoView { "Slider" => slider::slider_view().into_any(), "Dropdown" => dropdown::dropdown_view().into_any(), "Animation" => animation::animation_view().into_any(), + "Embolden" => embolden::embolden_view().into_any(), "Draggable" => draggable::draggable_view().into_any(), "Dropped File" => dropped_file::dropped_file_view().into_any(), #[cfg(feature = "full")] @@ -131,8 +126,6 @@ fn app_view(window_id: WindowId) -> impl IntoView { .class(LabelClass, |s| s.selectable(false)) }); - let inspector = Button::new("Open Inspector").action(floem::action::inspect); - let new_window_button = Button::new("Open In Window").action(move || { let name = tabs.with(|tabs| tabs.get(active_tab.get().unwrap_or(0)).copied()); new_window( @@ -149,7 +142,7 @@ fn app_view(window_id: WindowId) -> impl IntoView { ); }); - let left_side_bar = Stack::vertical((side_tab_bar, new_window_button, inspector)) + let left_side_bar = Stack::vertical((side_tab_bar, new_window_button)) .debug_name("Left Side Bar") .style(|s| s.height_full().row_gap(5.0)); @@ -165,7 +158,12 @@ fn app_view(window_id: WindowId) -> impl IntoView { let tab = tab.scroll().style(|s| s.size_full()); let floem_logo = svg(include_str!("../assets/floem.svg")) - .style(|s| s.unset_color().size_full().size(50, 50)) + .style(|s| { + s.unset_color() + .size_full() + .size(50, 50) + .set(SvgCache, false) + }) .overlay() .style(|s| { s.absolute() @@ -207,32 +205,26 @@ fn app_view(window_id: WindowId) -> impl IntoView { }; let view_submenu = |m: SubMenu| { - m.item("Inspector", |i| { - i.action(|| { - floem::action::inspect(); + m.submenu("Navigate to Widget", widget_submenu) + .separator() + .item("Next Tab", |i| { + i.action(move || { + let current = active_tab.get().unwrap_or(0); + let tab_count = tabs.get().len(); + active_tab.set(Some((current + 1) % tab_count)); + }) }) - }) - .separator() - .submenu("Navigate to Widget", widget_submenu) - .separator() - .item("Next Tab", |i| { - i.action(move || { - let current = active_tab.get().unwrap_or(0); - let tab_count = tabs.get().len(); - active_tab.set(Some((current + 1) % tab_count)); + .item("Previous Tab", |i| { + i.action(move || { + let current = active_tab.get().unwrap_or(0); + let tab_count = tabs.get().len(); + active_tab.set(if current == 0 { + Some(tab_count - 1) + } else { + Some(current - 1) + }); + }) }) - }) - .item("Previous Tab", |i| { - i.action(move || { - let current = active_tab.get().unwrap_or(0); - let tab_count = tabs.get().len(); - active_tab.set(if current == 0 { - Some(tab_count - 1) - } else { - Some(current - 1) - }); - }) - }) }; let theme_submenu = |m: SubMenu| { @@ -314,40 +306,7 @@ fn app_view(window_id: WindowId) -> impl IntoView { }), ); - let mut window_scale = RwSignal::new(1.); - - view.on_event_stop( - listener::KeyUp, - move |_cx, KeyboardEvent { modifiers, key, .. }| { - if *key == Key::Named(NamedKey::F11) { - floem::action::inspect(); - } else if *key == Key::Character("q".into()) && modifiers.contains(OS_MOD) { - floem::quit_app(); - } else if *key == Key::Character("w".into()) && modifiers.contains(OS_MOD) { - floem::close_window(window_id); - } - }, - ) - .on_event_stop( - el::KeyDown, - move |_, KeyboardEvent { key, modifiers, .. }| match key { - Key::Character(ch) if (ch == "=" || ch == "+") && modifiers.contains(OS_MOD) => { - window_scale *= 1.1; - floem::action::set_window_scale(window_scale.get()); - } - - Key::Character(ch) if ch == "-" && *modifiers == OS_MOD => { - window_scale /= 1.1; - floem::action::set_window_scale(window_scale.get()); - } - - Key::Character(ch) if ch == "0" && *modifiers == OS_MOD => { - window_scale.set(1.); - floem::action::set_window_scale(window_scale.get()); - } - _ => {} - }, - ) + view } fn main() { diff --git a/examples/widget-gallery/src/tabs.rs b/examples/widget-gallery/src/tabs.rs index 2ad632860..0966110d0 100644 --- a/examples/widget-gallery/src/tabs.rs +++ b/examples/widget-gallery/src/tabs.rs @@ -1,4 +1,4 @@ -use floem::peniko::Brush; +use floem::imaging::Brush; use floem::prelude::palette::css; use floem::prelude::*; use floem::reactive::Effect; diff --git a/examples/widget-gallery/src/texteditor.rs b/examples/widget-gallery/src/texteditor.rs index b5e220ce4..d5fc2a5a2 100644 --- a/examples/widget-gallery/src/texteditor.rs +++ b/examples/widget-gallery/src/texteditor.rs @@ -1,5 +1,4 @@ use floem::{ - action::inspect, prelude::*, views::editor::{ command::{Command, CommandExecuted}, @@ -73,9 +72,4 @@ pub fn editor_view() -> impl IntoView { .items_center() .justify_center() }) - .on_event_stop(el::KeyUp, |_, KeyboardEvent { key, .. }| { - if *key == Key::Named(NamedKey::F11) { - inspect(); - } - }) } diff --git a/examples/window-icon/src/main.rs b/examples/window-icon/src/main.rs index d0ddd78cd..04eec7226 100644 --- a/examples/window-icon/src/main.rs +++ b/examples/window-icon/src/main.rs @@ -49,11 +49,6 @@ fn app_view() -> impl IntoView { .height_full() .row_gap(10.0) }) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if let Key::Named(NamedKey::F11) = key { - floem::action::inspect(); - } - }) } fn main() { diff --git a/examples/window-scale/src/main.rs b/examples/window-scale/src/main.rs index ccc3d718c..c2cc16560 100644 --- a/examples/window-scale/src/main.rs +++ b/examples/window-scale/src/main.rs @@ -69,29 +69,23 @@ fn app_view() -> impl IntoView { let scale_buttons = Stack::horizontal((zoom_in_button, zoom_out_button, zoom_reset_button)) .style(|s| s.absolute().inset_top(0).inset_right(0)); - Stack::vertical((value_label, counter_buttons, scale_buttons)) - .style(|s| { - s.size(100.pct(), 100.pct()) - .items_center() - .justify_center() - .class(Button, |s| { - s.border(1.0) - .border_radius(10.0) - .padding(10.0) - .focus_visible(|s| s.border(2.).border_color(palette::css::BLUE)) - .disabled(|s| s.background(palette::css::LIGHT_GRAY)) - .hover(|s| s.background(palette::css::LIGHT_GREEN)) - .active(|s| { - s.color(palette::css::WHITE) - .background(palette::css::DARK_GREEN) - }) - }) - }) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if let Key::Named(NamedKey::F11) = key { - floem::action::inspect(); - } - }) + Stack::vertical((value_label, counter_buttons, scale_buttons)).style(|s| { + s.size(100.pct(), 100.pct()) + .items_center() + .justify_center() + .class(Button, |s| { + s.border(1.0) + .border_radius(10.0) + .padding(10.0) + .focus_visible(|s| s.border(2.).border_color(palette::css::BLUE)) + .disabled(|s| s.background(palette::css::LIGHT_GRAY)) + .hover(|s| s.background(palette::css::LIGHT_GREEN)) + .active(|s| { + s.color(palette::css::WHITE) + .background(palette::css::DARK_GREEN) + }) + }) + }) } fn main() { diff --git a/examples/window-size/src/main.rs b/examples/window-size/src/main.rs index f646e8649..ab8075a77 100644 --- a/examples/window-size/src/main.rs +++ b/examples/window-size/src/main.rs @@ -14,11 +14,6 @@ fn app_view() -> impl IntoView { .height_full() .row_gap(10.0) }) - .on_event_stop(listener::KeyUp, move |_cx, KeyboardEvent { key, .. }| { - if let Key::Named(NamedKey::F11) = key { - floem::action::inspect(); - } - }) .on_event_stop( LayoutChanged::listener(), move |_cx, LayoutChanged { new_box, .. }| size.set(new_box.size()), diff --git a/reactive/src/effect.rs b/reactive/src/effect.rs index 5344ca179..a55a415a1 100644 --- a/reactive/src/effect.rs +++ b/reactive/src/effect.rs @@ -1,4 +1,6 @@ -use std::{any::Any, cell::RefCell, collections::HashSet, marker::PhantomData, mem, rc::Rc}; +use std::{any::Any, cell::RefCell, collections::HashSet, marker::PhantomData, rc::Rc}; + +use smallvec::SmallVec; use crate::{ id::Id, @@ -17,7 +19,7 @@ pub trait EffectTrait: Any { fn id(&self) -> Id; fn run(&self) -> bool; fn add_observer(&self, id: Id); - fn clear_observers(&self) -> HashSet; + fn clear_observers(&self) -> SmallVec<[Id; 4]>; fn priority(&self) -> EffectPriority { EffectPriority::Normal } @@ -342,8 +344,8 @@ where self.observers.borrow_mut().insert(id); } - fn clear_observers(&self) -> HashSet { - mem::take(&mut *self.observers.borrow_mut()) + fn clear_observers(&self) -> SmallVec<[Id; 4]> { + self.observers.borrow_mut().drain().collect() } fn as_any(&self) -> &dyn Any { @@ -377,8 +379,8 @@ where self.observers.borrow_mut().insert(id); } - fn clear_observers(&self) -> HashSet { - mem::take(&mut *self.observers.borrow_mut()) + fn clear_observers(&self) -> SmallVec<[Id; 4]> { + self.observers.borrow_mut().drain().collect() } fn as_any(&self) -> &dyn Any { @@ -419,19 +421,24 @@ impl SignalTracker { /// Updates the tracking function used for [SignalTracker]. pub fn track(&self, f: impl FnOnce() -> T) -> T { Runtime::assert_ui_thread(); - // Clear any previous tracking by disposing the old effect - self.id.dispose(); - let prev_effect = RUNTIME.with(|runtime| runtime.current_effect.borrow_mut().take()); - - let tracking_effect: Rc = Rc::new(TrackingEffect { - id: self.id, - observers: RefCell::new(HashSet::default()), - on_change: self.on_change.clone(), + let tracking_effect = RUNTIME.with(|runtime| { + if let Some(effect) = runtime.get_effect(self.id) { + self.id.dispose_children(); + observer_clean_up(&effect); + effect + } else { + let tracking_effect: Rc = Rc::new(TrackingEffect { + id: self.id, + observers: RefCell::new(HashSet::default()), + on_change: self.on_change.clone(), + }); + runtime.register_effect(&tracking_effect); + tracking_effect + } }); RUNTIME.with(|runtime| { - runtime.register_effect(&tracking_effect); *runtime.current_effect.borrow_mut() = Some(tracking_effect.clone()); }); @@ -469,8 +476,8 @@ impl EffectTrait for TrackingEffect { self.observers.borrow_mut().insert(id); } - fn clear_observers(&self) -> HashSet { - mem::take(&mut *self.observers.borrow_mut()) + fn clear_observers(&self) -> SmallVec<[Id; 4]> { + self.observers.borrow_mut().drain().collect() } fn as_any(&self) -> &dyn Any { diff --git a/reactive/src/id.rs b/reactive/src/id.rs index ec56c858c..4057b4a1d 100644 --- a/reactive/src/id.rs +++ b/reactive/src/id.rs @@ -102,7 +102,7 @@ impl Id { fn cleanup_signal(signal: Option) { if let Some(signal) = signal { - for effect_id in signal.subscriber_ids() { + for effect_id in signal.take_subscriber_ids() { // Drop any effect that was subscribed to this signal so it can't linger // with dangling dependencies. effect_id.dispose(); diff --git a/reactive/src/memo.rs b/reactive/src/memo.rs index 605fd7049..b40144321 100644 --- a/reactive/src/memo.rs +++ b/reactive/src/memo.rs @@ -6,6 +6,8 @@ use std::{ rc::Rc, }; +use smallvec::SmallVec; + use crate::{ SignalGet, SignalWith, effect::{EffectPriority, EffectTrait, observer_clean_up}, @@ -291,8 +293,8 @@ where self.observers.borrow_mut().insert(id); } - fn clear_observers(&self) -> HashSet { - std::mem::take(&mut *self.observers.borrow_mut()) + fn clear_observers(&self) -> SmallVec<[Id; 4]> { + self.observers.borrow_mut().drain().collect() } fn priority(&self) -> EffectPriority { diff --git a/reactive/src/runtime.rs b/reactive/src/runtime.rs index 598d3939f..daa71698e 100644 --- a/reactive/src/runtime.rs +++ b/reactive/src/runtime.rs @@ -5,7 +5,7 @@ use std::{ collections::{HashMap, HashSet}, rc::Rc, sync::{ - OnceLock, + LazyLock, Mutex, OnceLock, atomic::{AtomicBool, Ordering}, }, thread::{self, ThreadId}, @@ -27,7 +27,8 @@ thread_local! { pub static RUNTIME: Runtime = Runtime::new(); } -static UI_THREAD_ID: OnceLock = OnceLock::new(); +static UI_THREAD_IDS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); #[cfg(debug_assertions)] static UI_THREAD_SET_LOCATION: OnceLock<&'static std::panic::Location<'static>> = OnceLock::new(); static ENFORCE_UI_THREAD: AtomicBool = AtomicBool::new(false); @@ -73,16 +74,7 @@ impl Runtime { #[cfg_attr(debug_assertions, track_caller)] pub fn init_on_ui_thread() { let current = thread::current().id(); - match UI_THREAD_ID.set(current) { - Ok(_) => {} - Err(_) => { - assert_eq!( - UI_THREAD_ID.get(), - Some(¤t), - "UI thread id already set to a different thread" - ); - } - } + UI_THREAD_IDS.lock().unwrap().insert(current); #[cfg(debug_assertions)] { let caller = std::panic::Location::caller(); @@ -98,38 +90,29 @@ impl Runtime { } let current = thread::current().id(); - match UI_THREAD_ID.get() { - Some(ui_id) => { - if *ui_id != current { - #[cfg(debug_assertions)] - { - let caller = std::panic::Location::caller(); - let set_at = UI_THREAD_SET_LOCATION.get(); - let set_info = set_at - .map(|loc| format!(" (set at {}:{})", loc.file(), loc.line())) - .unwrap_or_default(); - panic!( - "Unsync runtime access from non-UI thread\n expected UI thread: {:?}{}\n current thread: {:?}\n caller: {}:{}", - ui_id, - set_info, - current, - caller.file(), - caller.line(), - ); - } - #[cfg(not(debug_assertions))] - { - assert_eq!( - *ui_id, current, - "Unsync runtime access from non-UI thread: expected {:?}, got {:?}", - ui_id, current - ); - } - } + let is_ui_thread = UI_THREAD_IDS.lock().unwrap().contains(¤t); + if !is_ui_thread { + #[cfg(debug_assertions)] + { + let caller = std::panic::Location::caller(); + let set_at = UI_THREAD_SET_LOCATION.get(); + let set_info = set_at + .map(|loc| format!(" (first set at {}:{})", loc.file(), loc.line())) + .unwrap_or_default(); + panic!( + "Unsync runtime access from non-UI thread\n expected one of registered UI threads{}\n current thread: {:?}\n caller: {}:{}", + set_info, + current, + caller.file(), + caller.line(), + ); } - None => { - // Once enforcement is on, first access defines the UI thread. - let _ = UI_THREAD_ID.set(current); + #[cfg(not(debug_assertions))] + { + panic!( + "Unsync runtime access from non-UI thread: current thread {:?} is not a registered UI thread", + current + ); } } } @@ -138,10 +121,10 @@ impl Runtime { if !ENFORCE_UI_THREAD.load(Ordering::Relaxed) { true } else { - UI_THREAD_ID - .get() - .map(|id| *id == thread::current().id()) - .unwrap_or(false) + UI_THREAD_IDS + .lock() + .unwrap() + .contains(&thread::current().id()) } } diff --git a/reactive/src/signal.rs b/reactive/src/signal.rs index da14d9781..822a713b5 100644 --- a/reactive/src/signal.rs +++ b/reactive/src/signal.rs @@ -14,6 +14,7 @@ use std::cell::Cell; use std::panic::Location; use parking_lot::{Mutex, MutexGuard}; +use smallvec::SmallVec; use crate::{ SignalGet, SignalUpdate, @@ -617,12 +618,16 @@ impl SignalState { result } - pub(crate) fn subscriber_ids(&self) -> HashSet { + pub(crate) fn subscriber_ids(&self) -> SmallVec<[Id; 3]> { self.subscribers.lock().iter().copied().collect() } + pub(crate) fn take_subscriber_ids(&self) -> SmallVec<[Id; 3]> { + self.subscribers.lock().drain().collect() + } + pub(crate) fn run_effects(&self) { - let ids: smallvec::SmallVec<[_; 3]> = self.subscriber_ids().into_iter().collect(); + let ids = self.subscriber_ids(); let on_ui_thread = Runtime::is_ui_thread(); if !on_ui_thread { diff --git a/reactive/tests/ui_thread_assert.rs b/reactive/tests/ui_thread_assert.rs index 54a7fbfde..dbdba4413 100644 --- a/reactive/tests/ui_thread_assert.rs +++ b/reactive/tests/ui_thread_assert.rs @@ -51,4 +51,11 @@ fn assert_ui_thread_reports_caller_and_set_location() { "assert_ui_thread should panic off UI thread" ); } + + std::thread::spawn(|| { + Runtime::init_on_ui_thread(); + Runtime::assert_ui_thread(); + }) + .join() + .expect("registered UI thread should be accepted"); } diff --git a/renderer/Cargo.toml b/renderer/Cargo.toml deleted file mode 100644 index 4bd8c0c8b..000000000 --- a/renderer/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "floem_renderer" -version.workspace = true -edition.workspace = true -repository = "https://github.com/lapce/floem" -description = "A native Rust UI library with fine-grained reactivity" -license.workspace = true - -[dependencies] -parking_lot = { workspace = true } -peniko = { workspace = true } -resvg = { workspace = true } -parley = { workspace = true } -fontique = { workspace = true } -winit = { workspace = true } -wgpu = { workspace = true } -crossbeam = { version = "0.8", optional = true } -futures = "0.3.32" - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen-futures = { version = "0.4" } - -[features] -crossbeam = ["dep:crossbeam"] diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs deleted file mode 100644 index b8ed20640..000000000 --- a/renderer/src/lib.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Rendering abstraction layer for the Floem UI framework. -//! -//! This crate defines the [`Renderer`] trait that all Floem rendering backends must -//! implement, along with supporting types for passing images and SVGs to the renderer. -//! Floem ships with three backend implementations: -//! -//! - **Vello** (`floem_vello_renderer`) — GPU-accelerated renderer using the Vello scene graph (default). -//! - **Skia** (`floem_skia_renderer`) — GPU-accelerated renderer using AnyRender's Skia backend. -//! - **Vger** (`floem_vger_renderer`) — GPU-accelerated renderer using the Vger library. -//! - **tiny-skia** (`floem_tiny_skia_renderer`) — CPU software renderer using tiny-skia. -//! -//! # Modules -//! -//! - [`text`] — Text layout, shaping, and font management built on [Parley](https://docs.rs/parley). -//! - [`gpu_resources`] — Asynchronous wgpu adapter/device acquisition for GPU backends. -//! -//! # Re-exports -//! -//! [`tiny_skia`] and [`usvg`] are re-exported from [`resvg`](https://docs.rs/resvg) so that -//! renderer backends and downstream crates can use consistent versions of these libraries -//! without adding them as direct dependencies. - -pub mod text; -use peniko::{ - BlendMode, BrushRef, - kurbo::{Affine, Point, Rect, Shape, Stroke}, -}; -pub use resvg::tiny_skia; -pub use resvg::usvg; - -pub mod gpu_resources; - -/// A reference to a parsed SVG tree paired with a cache key. -/// -/// # Example -/// -/// ```no_run -/// use floem_renderer::{Svg, usvg}; -/// -/// let svg_text = r#" -/// -/// "#; -/// -/// let tree = usvg::Tree::from_str(svg_text, &usvg::Options::default()).unwrap(); -/// let hash = b"my-icon-cache-key"; -/// -/// let svg = Svg { -/// tree: &tree, -/// hash: hash.as_slice(), -/// }; -/// ``` -pub struct Svg<'a> { - /// The parsed SVG document. - pub tree: &'a usvg::Tree, - /// An opaque byte slice used as a cache key by the renderer. - pub hash: &'a [u8], -} - -/// A raster image paired with a cache key. -/// -/// # Example -/// -/// ```no_run -/// use floem_renderer::Img; -/// -/// # fn make_image_brush() -> peniko::ImageBrush { todo!() } -/// let brush = make_image_brush(); -/// let hash = b"photo-abc123"; -/// -/// let img = Img { -/// img: brush, -/// hash: hash.as_slice(), -/// }; -/// ``` -pub struct Img<'a> { - /// The image data as a [`peniko::ImageBrush`]. - pub img: peniko::ImageBrush, - /// An opaque byte slice used as a cache key by the renderer. - pub hash: &'a [u8], -} - -/// The core rendering trait that every Floem backend must implement. -/// -/// A frame is bracketed by [`begin`](Renderer::begin) and [`finish`](Renderer::finish). -/// Between those calls the framework issues drawing commands — fills, strokes, text, -/// images, SVGs — which the backend records or executes immediately depending on its -/// architecture. -/// -/// The typical call sequence within a single frame looks like: -/// -/// ```text -/// renderer.begin(capture); -/// renderer.set_transform(..); -/// renderer.fill(..); // background -/// renderer.clip(..); // restrict drawing area -/// renderer.draw_text_lines(..); // labels, editors -/// renderer.draw_svg(..); // icons -/// renderer.draw_img(..); // photos -/// renderer.stroke(..); // borders -/// renderer.clear_clip(); -/// renderer.finish(); -/// ``` -pub trait Renderer { - /// Begin a new frame. - /// - /// Must be called exactly once before any drawing commands. - /// When `capture` is `true` the renderer should record the frame into an - /// off-screen buffer so that [`finish`](Renderer::finish) can return it as - /// an [`ImageBrush`](peniko::ImageBrush). This is used by the Floem - /// inspector to take snapshots. - fn begin(&mut self, capture: bool); - - /// Set the current affine transform in device/render-target coordinates. - /// - /// All subsequent drawing commands are transformed by `transform` until it - /// is changed again. The framework provides this as the final transform for - /// the current visual node, including window scaling, so backends should not - /// apply an additional global window-scale multiply to ordinary geometry. - /// - /// Raster-backed operations such as glyph and SVG caching may still derive - /// a rasterization scale from this transform to choose an appropriate pixel - /// resolution. - fn set_transform(&mut self, transform: Affine); - - /// Set the z-index for subsequent draw commands. - /// - /// Not all backends honour this — Vello and tiny-skia rely on painter's - /// order instead. - fn set_z_index(&mut self, z_index: i32); - - /// Clip all subsequent drawing to the interior of `shape`. - /// - /// The clip remains in effect until [`clear_clip`](Renderer::clear_clip) is - /// called. On the Vello backend clipping is implemented via - /// [`push_layer`](Renderer::push_layer) / [`pop_layer`](Renderer::pop_layer) - /// instead, so this method may be a no-op there. - fn clip(&mut self, shape: &impl Shape); - - /// Remove the current clip region, allowing drawing to the full surface. - fn clear_clip(&mut self); - - /// Stroke the outline of a [`Shape`]. - /// - /// The `brush` defines the color or gradient and `stroke` controls the - /// line width, join style, dash pattern, etc. - fn stroke<'b, 's>( - &mut self, - shape: &impl Shape, - brush: impl Into>, - stroke: &'s Stroke, - ); - - /// Fill the interior of a [`Shape`] using the [non-zero fill rule]. - /// - /// When `blur_radius` is greater than zero the fill is drawn with a - /// Gaussian blur, which is used for box shadows. - /// - /// [non-zero fill rule]: https://en.wikipedia.org/wiki/Nonzero-rule - fn fill<'b>(&mut self, path: &impl Shape, brush: impl Into>, blur_radius: f64); - - /// Push a compositing layer onto the layer stack. - /// - /// Drawing commands issued after this call are composited into the layer. - /// Call [`pop_layer`](Renderer::pop_layer) to flatten the layer back into - /// the parent using the specified `blend` mode and `alpha`. - /// - /// The `clip` shape restricts drawing within the layer, and `transform` is - /// applied to the layer contents. - fn push_layer( - &mut self, - blend: impl Into, - alpha: f32, - transform: Affine, - clip: &impl Shape, - ); - - /// Pop the topmost compositing layer pushed by [`push_layer`](Renderer::push_layer). - /// - /// The layer contents are composited into the parent surface using the - /// blend mode and alpha that were specified when the layer was pushed. - fn pop_layer(&mut self); - - /// Draw a single glyph run. - fn draw_glyphs<'a>( - &mut self, - origin: Point, - props: &text::GlyphRunProps<'a>, - glyphs: impl Iterator + 'a, - ); - - /// Draw an SVG image inside `rect`. - /// - /// When `brush` is `Some`, the SVG is rendered as a mask and filled with - /// the given brush — this is how Floem applies a color override to icons. - fn draw_svg<'b>(&mut self, svg: Svg<'b>, rect: Rect, brush: Option>>); - - /// Draw a raster image inside `rect`. - /// - /// The image is scaled to fit the destination rectangle. - fn draw_img(&mut self, img: Img<'_>, rect: Rect); - - /// Finish the current frame and present it. - /// - /// If the frame was started with `capture = true`, the rendered content is - /// returned as an [`ImageBrush`](peniko::ImageBrush). Otherwise returns - /// `None` after presenting the frame to the screen. - fn finish(&mut self) -> Option; - - /// Return a human-readable string identifying the renderer backend. - /// - /// Used by the Floem inspector. Implementations typically return a string - /// like `"name: Vello\ninfo: …"`. - fn debug_info(&self) -> String { - "Unknown".into() - } -} diff --git a/renderer/src/text/mod.rs b/renderer/src/text/mod.rs deleted file mode 100644 index 65bca17fa..000000000 --- a/renderer/src/text/mod.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Text layout, shaping, and font management for Floem. -//! -//! This module provides the renderer-facing text vocabulary built on -//! [Parley](https://docs.rs/parley). -//! -//! # Re-exports -//! -//! [`FontStyle`] and [`FontWidth`] come from [`fontique`](https://docs.rs/fontique). - -mod attrs; - -use peniko::{ - BrushRef, Fill, FontData, StyleRef, - kurbo::{Affine, Point}, -}; - -pub use attrs::{Attrs, AttrsList, AttrsOwned, FamilyOwned, LineHeightValue}; -pub use fontique::{FontStyle, FontWeight, FontWidth}; -pub use parley::layout::Glyph; - -// --- Brush type for Parley --- - -/// A brush wrapper that satisfies Parley's `Brush` trait bound. -/// -/// Parley requires its brush type to implement `Default + Clone`. This newtype -/// wraps [`peniko::Color`] and provides a `Default` of opaque black. -/// It is used internally to parameterise [`parley::layout::Layout`] -/// and is not typically constructed by application code. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct TextBrush(pub peniko::Color); - -impl Default for TextBrush { - fn default() -> Self { - TextBrush(peniko::Color::from_rgba8(0, 0, 0, 255)) - } -} - -impl From for TextBrush { - fn from(c: peniko::Color) -> Self { - TextBrush(c) - } -} - -impl From for peniko::Color { - fn from(b: TextBrush) -> Self { - b.0 - } -} - -/// Variable font design-space coordinate. -pub type NormalizedCoord = i16; - -/// Rendering properties shared by a glyph run. -#[derive(Clone, Debug)] -pub struct GlyphRunProps<'a> { - pub font: FontData, - pub font_size: f32, - pub hint: bool, - pub normalized_coords: &'a [NormalizedCoord], - pub style: StyleRef<'a>, - pub brush: BrushRef<'a>, - pub brush_alpha: f32, - pub transform: Affine, - pub glyph_transform: Option, -} - -impl<'a> GlyphRunProps<'a> { - pub fn new(font: &FontData) -> Self { - Self { - font: font.clone(), - font_size: 16.0, - hint: false, - normalized_coords: &[], - style: Fill::NonZero.into(), - brush: peniko::color::palette::css::BLACK.into(), - brush_alpha: 1.0, - transform: Affine::IDENTITY, - glyph_transform: None, - } - } - - pub fn font(mut self, font: &FontData) -> Self { - self.font = font.clone(); - self - } - - pub fn font_size(mut self, font_size: f32) -> Self { - self.font_size = font_size; - self - } - - pub fn hint(mut self, hint: bool) -> Self { - self.hint = hint; - self - } - - pub fn normalized_coords(mut self, normalized_coords: &'a [NormalizedCoord]) -> Self { - self.normalized_coords = normalized_coords; - self - } - - pub fn style(mut self, style: impl Into>) -> Self { - self.style = style.into(); - self - } - - pub fn brush(mut self, brush: impl Into>) -> Self { - self.brush = brush.into(); - self - } - - pub fn brush_alpha(mut self, brush_alpha: f32) -> Self { - self.brush_alpha = brush_alpha; - self - } - - pub fn transform(mut self, transform: Affine) -> Self { - self.transform = transform; - self - } - - pub fn glyph_transform(mut self, glyph_transform: Option) -> Self { - self.glyph_transform = glyph_transform; - self - } -} - -pub trait GlyphDrawer { - fn draw_glyphs<'a>( - &mut self, - origin: Point, - props: &GlyphRunProps<'a>, - glyphs: impl Iterator + 'a, - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - // -- TextBrush -- - - #[test] - fn text_brush_default_is_opaque_black() { - let b = TextBrush::default(); - let c: peniko::Color = b.into(); - assert_eq!(c, peniko::Color::from_rgba8(0, 0, 0, 255)); - } - - #[test] - fn text_glyphs_props_default_is_usable() { - let font = FontData::new(peniko::Blob::new(std::sync::Arc::new([])), 0); - let props = GlyphRunProps::new(&font); - assert_eq!(props.font, font); - assert_eq!(props.font_size, 16.0); - assert_eq!(props.brush_alpha, 1.0); - assert_eq!(props.transform, Affine::IDENTITY); - assert!(props.normalized_coords.is_empty()); - } -} diff --git a/skia/Cargo.toml b/skia/Cargo.toml deleted file mode 100644 index 5f2e9d9c5..000000000 --- a/skia/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "floem_skia_renderer" -version.workspace = true -edition.workspace = true -repository = "https://github.com/lapce/floem" -description = "A native Rust UI library with fine-grained reactivity" -license.workspace = true - -[dependencies] -anyhow = "1.0" -# anyrender = {path = "../../anyrender/crates/anyrender", package = "anyrender"} -# anyrender_skia = { path = "../../anyrender/crates/anyrender_skia", package = "anyrender_skia" } -anyrender = { git = "https://github.com/jrmoulton/anyrender", rev = "e38ca6c35f45" } -anyrender_skia = { git = "https://github.com/jrmoulton/anyrender", rev = "e38ca6c35f45" } -floem_renderer = { path = "../renderer", version = "0.2.0" } -kurbo = "0.13" -parley = { workspace = true } -peniko = { workspace = true } -raw-window-handle = { workspace = true } -resvg = { workspace = true } -winit = { workspace = true } diff --git a/skia/src/lib.rs b/skia/src/lib.rs deleted file mode 100644 index 6be843968..000000000 --- a/skia/src/lib.rs +++ /dev/null @@ -1,523 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use anyhow::{Result, anyhow}; -use anyrender::{ImageRenderer as _, WindowRenderer as _}; -use anyrender_skia::{SkiaImageRenderer, SkiaWindowRenderer}; -use floem_renderer::text::{Glyph, GlyphRunProps}; -use floem_renderer::{Img, Renderer, Svg}; -use peniko::kurbo::{Affine, BezPath, Point, Rect, Shape, Size, Stroke}; -use peniko::{ - Blob, Brush, BrushRef, Compose, Fill, ImageAlphaType, ImageData, ImageFormat, Mix, Style, -}; -use raw_window_handle::{ - DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle, -}; -use resvg::tiny_skia::{Pixmap, Transform}; -use winit::window::Window; - -const PATH_TOLERANCE: f64 = 0.1; - -enum Command { - Stroke { - shape: BezPath, - brush: Brush, - stroke: Stroke, - transform: Affine, - }, - Fill { - shape: BezPath, - brush: Brush, - transform: Affine, - }, - BoxShadow { - rect: Rect, - brush: peniko::Color, - radius: f64, - std_dev: f64, - transform: Affine, - }, - PushLayer { - blend: peniko::BlendMode, - alpha: f32, - transform: Affine, - clip: BezPath, - }, - PushClip { - transform: Affine, - clip: BezPath, - }, - PopLayer, - Glyphs { - font: peniko::FontData, - font_size: f32, - hint: bool, - normalized_coords: Vec, - style: Style, - brush: Brush, - brush_alpha: f32, - transform: Affine, - glyph_transform: Option, - glyphs: Vec, - }, - Image { - image: peniko::ImageBrush, - rect: Rect, - transform: Affine, - }, - Svg { - image: peniko::ImageBrush, - rect: Rect, - transform: Affine, - brush: Option, - }, -} - -pub struct SkiaRenderer { - window_renderer: SkiaWindowRenderer, - capture_renderer: SkiaImageRenderer, - commands: Vec, - svg_cache: HashMap<(Vec, u32, u32), peniko::ImageBrush>, - size: Size, - transform: Affine, - capture: bool, -} - -#[derive(Clone)] -struct AnyrenderWindow(Arc); - -impl HasWindowHandle for AnyrenderWindow { - fn window_handle(&self) -> std::result::Result, HandleError> { - self.0.window_handle() - } -} - -impl HasDisplayHandle for AnyrenderWindow { - fn display_handle(&self) -> std::result::Result, HandleError> { - self.0.display_handle() - } -} - -impl SkiaRenderer { - pub fn new( - window: Arc, - width: u32, - height: u32, - scale: f64, - _font_embolden: f32, - ) -> Result { - let width = width.max(1); - let height = height.max(1); - - let mut window_renderer = SkiaWindowRenderer::new(); - let handle: Arc = Arc::new(AnyrenderWindow(window)); - window_renderer.resume(handle, width, height); - - let mut capture_renderer = SkiaImageRenderer::new(width, height); - capture_renderer.reset(); - - Ok(Self { - window_renderer, - capture_renderer, - commands: Vec::new(), - svg_cache: HashMap::new(), - size: Size::new(width as f64, height as f64), - transform: Affine::scale(scale), - capture: false, - }) - } - - pub fn resize(&mut self, width: u32, height: u32, _scale: f64) { - let width = width.max(1); - let height = height.max(1); - self.size = Size::new(width as f64, height as f64); - self.window_renderer.set_size(width, height); - self.capture_renderer.resize(width, height); - } - - pub const fn set_scale(&mut self, _scale: f64) {} - - pub const fn size(&self) -> Size { - self.size - } - - fn current_scale(&self) -> (f64, f64) { - let coeffs = self.transform.as_coeffs(); - let scale_x = coeffs[0].hypot(coeffs[1]); - let scale_y = coeffs[2].hypot(coeffs[3]); - (scale_x, scale_y) - } - - fn replay(commands: &[Command], scene: &mut S) { - for command in commands { - match command { - Command::Stroke { - shape, - brush, - stroke, - transform, - } => { - scene.stroke( - stroke, - *transform, - anyrender::PaintRef::from(BrushRef::from(brush)), - None, - shape, - ); - } - Command::Fill { - shape, - brush, - transform, - } => { - scene.fill( - Fill::NonZero, - *transform, - anyrender::PaintRef::from(BrushRef::from(brush)), - None, - shape, - ); - } - Command::BoxShadow { - rect, - brush, - radius, - std_dev, - transform, - } => { - scene.draw_box_shadow(*transform, *rect, *brush, *radius, *std_dev); - } - Command::PushLayer { - blend, - alpha, - transform, - clip, - } => { - scene.push_layer(*blend, *alpha, *transform, clip); - } - Command::PushClip { transform, clip } => { - scene.push_clip_layer(*transform, clip); - } - Command::PopLayer => { - scene.pop_layer(); - } - Command::Glyphs { - font, - font_size, - hint, - normalized_coords, - style, - brush, - brush_alpha, - transform, - glyph_transform, - glyphs, - } => { - scene.draw_glyphs( - font, - *font_size, - *hint, - normalized_coords, - style, - anyrender::PaintRef::from(BrushRef::from(brush)), - *brush_alpha, - *transform, - *glyph_transform, - glyphs.iter().copied(), - ); - } - Command::Image { - image, - rect, - transform, - } => { - let image_transform = image_transform(*transform, *rect, image); - scene.fill( - Fill::NonZero, - image_transform, - image.as_ref(), - None, - &Rect::new( - 0.0, - 0.0, - image.image.width as f64, - image.image.height as f64, - ), - ); - } - Command::Svg { - image, - rect, - transform, - brush, - } => { - let image_transform = image_transform(*transform, *rect, image); - let src_rect = Rect::new( - 0.0, - 0.0, - image.image.width as f64, - image.image.height as f64, - ); - - if let Some(brush) = brush { - scene.push_layer( - peniko::BlendMode::default(), - 1.0, - image_transform, - &src_rect, - ); - scene.fill( - Fill::NonZero, - image_transform, - image.as_ref(), - None, - &src_rect, - ); - scene.push_layer( - peniko::BlendMode { - mix: Mix::Normal, - compose: Compose::SrcIn, - }, - 1.0, - image_transform, - &src_rect, - ); - scene.fill( - Fill::NonZero, - image_transform, - anyrender::PaintRef::from(BrushRef::from(brush)), - None, - &src_rect, - ); - scene.pop_layer(); - scene.pop_layer(); - } else { - scene.fill( - Fill::NonZero, - image_transform, - image.as_ref(), - None, - &src_rect, - ); - } - } - } - } - } - - fn cached_svg_image( - &mut self, - svg: Svg<'_>, - width: u32, - height: u32, - ) -> Result { - let key = (svg.hash.to_vec(), width, height); - if let Some(image) = self.svg_cache.get(&key) { - return Ok(image.clone()); - } - - let mut pixmap = - Pixmap::new(width, height).ok_or_else(|| anyhow!("failed to allocate svg pixmap"))?; - let transform = Transform::from_scale( - width as f32 / svg.tree.size().width(), - height as f32 / svg.tree.size().height(), - ); - resvg::render(svg.tree, transform, &mut pixmap.as_mut()); - let image = image_brush_from_rgba(width, height, pixmap.take()); - self.svg_cache.insert(key, image.clone()); - Ok(image) - } -} - -impl Renderer for SkiaRenderer { - fn begin(&mut self, capture: bool) { - self.commands.clear(); - self.transform = Affine::IDENTITY; - self.capture = capture; - } - - fn set_transform(&mut self, transform: Affine) { - self.transform = transform; - } - - fn set_z_index(&mut self, _z_index: i32) {} - - fn clip(&mut self, shape: &impl Shape) { - self.commands.push(Command::PushClip { - transform: self.transform, - clip: shape.to_path(PATH_TOLERANCE), - }); - } - - fn clear_clip(&mut self) { - self.commands.push(Command::PopLayer); - } - - fn stroke<'b, 's>( - &mut self, - shape: &impl Shape, - brush: impl Into>, - stroke: &'s Stroke, - ) { - self.commands.push(Command::Stroke { - shape: shape.to_path(PATH_TOLERANCE), - brush: brush.into().to_owned(), - stroke: stroke.clone(), - transform: self.transform, - }); - } - - fn fill<'b>(&mut self, path: &impl Shape, brush: impl Into>, blur_radius: f64) { - let brush = brush.into(); - - if blur_radius > 0.0 - && let BrushRef::Solid(color) = brush - { - if let Some(rounded) = path.as_rounded_rect() { - let radii = rounded.radii(); - if radii.top_left == radii.top_right - && radii.top_left == radii.bottom_left - && radii.top_left == radii.bottom_right - { - self.commands.push(Command::BoxShadow { - rect: rounded.rect(), - brush: color, - radius: radii.top_left, - std_dev: blur_radius, - transform: self.transform, - }); - return; - } - } else if let Some(rect) = path.as_rect() { - self.commands.push(Command::BoxShadow { - rect, - brush: color, - radius: 0.0, - std_dev: blur_radius, - transform: self.transform, - }); - return; - } - } - - self.commands.push(Command::Fill { - shape: path.to_path(PATH_TOLERANCE), - brush: brush.to_owned(), - transform: self.transform, - }); - } - - fn push_layer( - &mut self, - blend: impl Into, - alpha: f32, - transform: Affine, - clip: &impl Shape, - ) { - self.commands.push(Command::PushLayer { - blend: blend.into(), - alpha, - transform: self.transform * transform, - clip: clip.to_path(PATH_TOLERANCE), - }); - } - - fn pop_layer(&mut self) { - self.commands.push(Command::PopLayer); - } - - fn draw_glyphs<'a>( - &mut self, - origin: Point, - props: &GlyphRunProps<'a>, - glyphs: impl Iterator + 'a, - ) { - let transform = self.transform * Affine::translate((origin.x, origin.y)) * props.transform; - self.commands.push(Command::Glyphs { - font: props.font.clone(), - font_size: props.font_size, - hint: props.hint, - normalized_coords: props.normalized_coords.to_vec(), - style: props.style.to_owned(), - brush: props.brush.to_owned(), - brush_alpha: props.brush_alpha, - transform, - glyph_transform: props.glyph_transform, - glyphs: glyphs - .map(|glyph| anyrender::Glyph { - id: glyph.id, - x: glyph.x, - y: glyph.y, - }) - .collect(), - }); - } - - fn draw_svg<'b>(&mut self, svg: Svg<'b>, rect: Rect, brush: Option>>) { - let (scale_x, scale_y) = self.current_scale(); - let width = (rect.width() * scale_x.abs()).round().max(1.0) as u32; - let height = (rect.height() * scale_y.abs()).round().max(1.0) as u32; - let image = match self.cached_svg_image(svg, width, height) { - Ok(image) => image, - Err(_) => return, - }; - - self.commands.push(Command::Svg { - image, - rect, - transform: self.transform, - brush: brush.map(|brush| brush.into().to_owned()), - }); - } - - fn draw_img(&mut self, img: Img<'_>, rect: Rect) { - self.commands.push(Command::Image { - image: img.img, - rect, - transform: self.transform, - }); - } - - fn finish(&mut self) -> Option { - if self.capture { - let commands = std::mem::take(&mut self.commands); - let mut buffer = vec![0; self.size.width as usize * self.size.height as usize * 4]; - self.capture_renderer.reset(); - self.capture_renderer - .render(|scene| Self::replay(&commands, scene), &mut buffer); - return Some(image_brush_from_rgba( - self.size.width as u32, - self.size.height as u32, - buffer, - )); - } - - let commands = std::mem::take(&mut self.commands); - self.window_renderer - .render(|scene| Self::replay(&commands, scene)); - None - } - - fn debug_info(&self) -> String { - "name: Skia\ninfo: AnyRender Skia".to_string() - } -} - -fn image_transform(transform: Affine, rect: Rect, image: &peniko::ImageBrush) -> Affine { - transform - .pre_scale_non_uniform( - rect.width().max(1.0) / image.image.width as f64, - rect.height().max(1.0) / image.image.height as f64, - ) - .pre_translate((rect.min_x(), rect.min_y()).into()) -} - -fn image_brush_from_rgba(width: u32, height: u32, data: Vec) -> peniko::ImageBrush { - peniko::ImageBrush::new(ImageData { - data: Blob::new(Arc::new(data)), - format: ImageFormat::Rgba8, - alpha_type: ImageAlphaType::AlphaPremultiplied, - width, - height, - }) -} diff --git a/src/action.rs b/src/action.rs index 920bb2bf8..f6e3a11e2 100644 --- a/src/action.rs +++ b/src/action.rs @@ -10,14 +10,14 @@ use std::sync::atomic::AtomicU64; use floem_reactive::{SignalWith, UpdaterEffect}; use peniko::kurbo::{Point, Size, Vec2}; -use winit::window::WindowId; -use winit::window::{ResizeDirection, Theme}; +use winit::window::{ResizeDirection, Theme, WindowId}; use crate::IntoView; use crate::platform::{Duration, Instant}; use crate::{ app::{AppUpdateEvent, add_app_update_event}, + frame::{FrameCallbackRepeat, FrameRatePreference, FrameTime}, message::{UPDATE_MESSAGES, UpdateMessage}, platform::menu::Menu, view::View, @@ -81,6 +81,18 @@ pub fn inspect() { add_update_message(UpdateMessage::Inspect); } +/// Capture the next Metal frame for this window. +/// +/// This is only supported on macOS. On other platforms this action is a no-op. +pub fn capture_metal() { + add_update_message(UpdateMessage::CaptureMetalFrame); +} + +/// Toggle the compact performance HUD for the current window. +pub fn toggle_hud() { + add_update_message(UpdateMessage::ToggleHud); +} + /// Set the **global** app theme in all windows. /// /// Toggles both floem and window themes. @@ -124,14 +136,21 @@ pub fn current_theme() -> Option { pub(crate) struct Timer { pub(crate) token: TimerToken, - pub(crate) action: Box, + pub(crate) action: TimerAction, pub(crate) deadline: Instant, - pub(crate) is_animation: bool, - pub(crate) window_id: Option, + pub(crate) sequence: u64, +} + +pub(crate) enum TimerAction { + Main(Box), + Ui { + window_id: WindowId, + root: ViewId, + action: Box, + }, } /// A token associated with a timer. -// TODO: what is this for? #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)] pub struct TimerToken(u64); @@ -141,7 +160,7 @@ impl TimerToken { /// Create a new token. pub fn next() -> TimerToken { - static TIMER_COUNTER: AtomicU64 = AtomicU64::new(0); + static TIMER_COUNTER: AtomicU64 = AtomicU64::new(1); TimerToken(TIMER_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) } @@ -161,61 +180,129 @@ impl TimerToken { } } +/// A token for a registered animation-frame callback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)] +pub struct AnimationFrameCallbackToken { + id: u64, + root: ViewId, +} + +impl AnimationFrameCallbackToken { + fn next(root: ViewId) -> Self { + static CALLBACK_COUNTER: AtomicU64 = AtomicU64::new(1); + Self { + id: CALLBACK_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + root, + } + } + + /// Cancel this animation-frame callback. + pub fn cancel(self) { + let _ = UPDATE_MESSAGES.try_with(|msgs| { + msgs.borrow_mut() + .entry(self.root) + .or_default() + .push(UpdateMessage::CancelAnimationFrameCallback { token: self.id }); + }); + if let Some(window_id) = self.root.window_id() { + crate::window::tracking::force_window_repaint(&window_id); + } + } +} + /// Execute a callback after a specified duration. +/// +/// This must be called on Floem's main UI thread. pub fn exec_after(duration: Duration, action: impl FnOnce(TimerToken) + 'static) -> TimerToken { let view = get_current_view(); - let action = move |token| { - let current_view = get_current_view(); - set_current_view(view.root()); - action(token); - set_current_view(current_view); - }; + let root = view.root(); + let window_id = view + .window_id() + .expect("timers must be scheduled from a view attached to a window"); let token = TimerToken::next(); - let deadline = Instant::now() + duration; add_app_update_event(AppUpdateEvent::RequestTimer { timer: Timer { token, - action: Box::new(action), - deadline, - is_animation: false, - window_id: None, + action: TimerAction::Ui { + window_id, + root, + action: Box::new(action), + }, + deadline: Instant::now() + duration, + sequence: token.into_raw(), }, }); token } -/// Execute a callback on the next animation frame, synchronized with the display's refresh rate. +/// Register an animation-frame callback for the current window. /// -/// Returns a [`TimerToken`] that can be used to cancel the callback before it fires. -/// Returns [`TimerToken::INVALID`] if called outside of a window context. -pub fn exec_after_animation_frame(action: impl FnOnce(TimerToken) + 'static) -> TimerToken { +/// `frame_rate` is a presentation-cadence preference, not a wall-clock timer +/// and not a guarantee that every callback will produce a presented frame. +/// Floem aligns callbacks to the window's frame source and may delay, coalesce, +/// or skip opportunities when the window, compositor, or dependent layers +/// cannot present at the requested cadence. +pub fn schedule_animation_frame_callback( + frame_rate: FrameRatePreference, + repeat: FrameCallbackRepeat, + mut action: impl FnMut(FrameTime) + 'static, +) -> AnimationFrameCallbackToken { let view = get_current_view(); - let Some(window_id) = view.window_id() else { - return TimerToken::INVALID; - }; + let root = view.root(); + let token = AnimationFrameCallbackToken::next(root); - let action = move |token| { - let current_view = get_current_view(); - set_current_view(view.root()); - action(token); - set_current_view(current_view); + let callback = move |frame_time| { + set_current_view(root); + action(frame_time); }; - let token = TimerToken::next(); - add_app_update_event(AppUpdateEvent::RequestAnimationTimer { - timer: Timer { - token, - action: Box::new(action), - deadline: Instant::now(), // overridden by handler using monitor refresh rate - is_animation: true, - window_id: Some(window_id), - }, - window_id, + add_update_message(UpdateMessage::SetAnimationFrameCallback { + token: token.id, + frame_rate, + repeat, + callback: Box::new(callback), }); + if let Some(window_id) = view.window_id() { + crate::window::tracking::force_window_repaint(&window_id); + } token } +/// Register a repeating animation-frame callback for the current window. +/// +/// The callback runs at each eligible begin-frame opportunity until the +/// returned [`AnimationFrameCallbackToken`] is cancelled. +pub fn set_animation_frame_callback( + frame_rate: FrameRatePreference, + action: impl FnMut(FrameTime) + 'static, +) -> AnimationFrameCallbackToken { + schedule_animation_frame_callback(frame_rate, FrameCallbackRepeat::every_frame(), action) +} + +/// Execute a callback once at the next eligible begin-frame opportunity. +/// +/// The returned token may be used to cancel the callback before it runs. +pub fn request_animation_frame( + action: impl FnOnce(FrameTime) + 'static, +) -> AnimationFrameCallbackToken { + request_animation_frame_with_preference(FrameRatePreference::full(), action) +} + +/// Execute a callback once at the next eligible begin-frame opportunity. +pub fn request_animation_frame_with_preference( + frame_rate: FrameRatePreference, + action: impl FnOnce(FrameTime) + 'static, +) -> AnimationFrameCallbackToken { + let mut action = Some(action); + + schedule_animation_frame_callback(frame_rate, FrameCallbackRepeat::none(), move |frame_time| { + if let Some(action) = action.take() { + action(frame_time); + } + }) +} + /// Debounce an action. /// /// This tracks a signal and checks if the inner value has changed by checking it's hash and will diff --git a/src/animate/mod.rs b/src/animate/mod.rs index 0eeb87ad6..bac914256 100644 --- a/src/animate/mod.rs +++ b/src/animate/mod.rs @@ -768,10 +768,18 @@ impl Animation { self.transition(AnimStateCommand::Start) } + pub(crate) fn start_mut_at(&mut self, now: Instant) { + self.transition_at(AnimStateCommand::Start, now) + } + pub(crate) fn reverse_mut(&mut self) { self.transition(AnimStateCommand::Reverse) } + pub(crate) fn reverse_mut_at(&mut self, now: Instant) { + self.transition_at(AnimStateCommand::Reverse, now) + } + #[allow(unused)] pub(crate) fn stop_mut(&mut self) { self.transition(AnimStateCommand::Stop) @@ -792,6 +800,12 @@ impl Animation { /// Returns the current amount of time that has elapsed since the animation started. pub fn elapsed(&self) -> Option { + self.elapsed_at(Instant::now()) + } + + /// Returns the current amount of time that has elapsed since the animation + /// started, evaluated at `now`. + pub fn elapsed_at(&self, now: Instant) -> Option { match &self.state { AnimState::Idle => None, AnimState::Stopped => None, @@ -803,7 +817,7 @@ impl Animation { started_on, elapsed, } => { - let duration = Instant::now() - *started_on; + let duration = now - *started_on; Some(*elapsed + duration) } AnimState::PassFinished { elapsed, .. } => Some(*elapsed), @@ -814,6 +828,11 @@ impl Animation { /// Advance the animation. pub fn advance(&mut self) { + self.advance_at(Instant::now()); + } + + /// Advance the animation as if the current frame is targeting `now`. + pub fn advance_at(&mut self, now: Instant) { let use_delay = self.use_delay(); match &mut self.state { AnimState::Idle => { @@ -824,7 +843,6 @@ impl Animation { started_on, elapsed, } => { - let now = Instant::now(); let duration = now - *started_on; let og_elapsed = *elapsed; *elapsed = duration; @@ -857,7 +875,6 @@ impl Animation { started_on, elapsed, } => { - let now = Instant::now(); let duration = now - *started_on; *elapsed = duration; @@ -879,7 +896,7 @@ impl Animation { self.reverse_once.set(true); } self.state = AnimState::PassInProgress { - started_on: Instant::now(), + started_on: now, elapsed: Duration::ZERO, } } @@ -898,7 +915,7 @@ impl Animation { } } else { self.state = AnimState::PassInProgress { - started_on: Instant::now(), + started_on: now, elapsed: Duration::ZERO, } } @@ -921,16 +938,20 @@ impl Animation { } pub(crate) fn transition(&mut self, command: AnimStateCommand) { + self.transition_at(command, Instant::now()); + } + + pub(crate) fn transition_at(&mut self, command: AnimStateCommand, now: Instant) { match command { AnimStateCommand::Pause => { self.state = AnimState::Paused { - elapsed: self.elapsed(), + elapsed: self.elapsed_at(now), } } AnimStateCommand::Resume => { if let AnimState::Paused { elapsed } = &self.state { self.state = AnimState::PassInProgress { - started_on: Instant::now(), + started_on: now, elapsed: elapsed.unwrap_or(Duration::ZERO), } } @@ -940,7 +961,7 @@ impl Animation { Rc::make_mut(&mut self.folded_style.map).clear(); self.repeat_count = 0; self.state = AnimState::PassInProgress { - started_on: Instant::now(), + started_on: now, elapsed: Duration::ZERO, } } @@ -949,7 +970,7 @@ impl Animation { Rc::make_mut(&mut self.folded_style.map).clear(); self.repeat_count = 0; self.state = AnimState::PassInProgress { - started_on: Instant::now(), + started_on: now, elapsed: Duration::ZERO, } } @@ -961,11 +982,16 @@ impl Animation { } /// Get the total time the animation has been running as a percent (0. - 1.) + #[allow(dead_code)] pub(crate) fn total_time_percent(&self) -> f64 { + self.total_time_percent_at(Instant::now()) + } + + pub(crate) fn total_time_percent_at(&self, now: Instant) -> f64 { if self.duration == Duration::ZERO { return 0.; } - let mut elapsed = self.elapsed().unwrap_or(Duration::ZERO); + let mut elapsed = self.elapsed_at(now).unwrap_or(Duration::ZERO); if self.use_delay() { elapsed = elapsed.saturating_sub(self.delay); } @@ -1073,6 +1099,12 @@ impl Animation { /// While advancing, this function can mutably apply it's animated props to a style. pub fn animate_into(&mut self, computed_style: &mut Style) { + self.animate_into_at(computed_style, Instant::now()); + } + + /// Apply animated properties to `computed_style` for a frame targeting + /// `now`. + pub fn animate_into_at(&mut self, computed_style: &mut Style, now: Instant) { // TODO: OPTIMIZE. I've tried to make this efficient, but it would be good to work this over for eficiency because it is called on every frame during an animation. // Some work is repeated and could be improved. @@ -1087,7 +1119,7 @@ impl Animation { let local_percents: Vec<_> = self .props_in_ext_progress .iter() - .map(|(p, (l, u))| (*p, self.get_local_percent(l.id, u.id))) + .map(|(p, (l, u))| (*p, self.get_local_percent_at(l.id, u.id, now))) .collect(); self.props_in_ext_progress.retain(|p, (_l, u)| { @@ -1113,7 +1145,7 @@ impl Animation { } } - let percent = self.total_time_percent(); + let percent = self.total_time_percent_at(now); let frame_target = (self.max_key_frame_num as f64 * percent).round() as u16; let props = self.cache.prop_map.keys(); @@ -1127,7 +1159,7 @@ impl Animation { else { continue; }; - let local_percent = self.get_local_percent(prev.id, target.id); + let local_percent = self.get_local_percent_at(prev.id, target.id, now); let easing = target.easing.clone(); // TODO: Find a better way to find when an animation should enter ext mode rather than just starting to check after 97%. // this could miss getting a prop into ext mode @@ -1156,7 +1188,17 @@ impl Animation { } /// For a given pair of frame ids, find where the full animation progress is within the subrange of the frame id pair. + #[allow(dead_code)] pub(crate) fn get_local_percent(&self, prev_frame: u16, target_frame: u16) -> f64 { + self.get_local_percent_at(prev_frame, target_frame, Instant::now()) + } + + pub(crate) fn get_local_percent_at( + &self, + prev_frame: u16, + target_frame: u16, + now: Instant, + ) -> f64 { // undo the frame change that get current key_frame props does so that low is actually lower let (low_frame, high_frame) = if self.is_reversing() { (target_frame as f64, prev_frame as f64) @@ -1167,7 +1209,7 @@ impl Animation { let low_frame_percent = low_frame / total_num_frames; let high_frame_percent = high_frame / total_num_frames; let keyframe_range = (high_frame_percent.max(0.001) - low_frame_percent.max(0.001)).abs(); - let total_time_percent = self.total_time_percent(); + let total_time_percent = self.total_time_percent_at(now); let local = (total_time_percent - low_frame_percent) / keyframe_range; if self.is_reversing() { diff --git a/src/app/handle.rs b/src/app/handle.rs index 04e78bdcc..0b36ec890 100644 --- a/src/app/handle.rs +++ b/src/app/handle.rs @@ -1,5 +1,5 @@ +use crate::gpu_resources::GpuResources; use dpi::PhysicalPosition; -use floem_renderer::gpu_resources::GpuResources; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use ui_events_winit::WindowEventTranslation; @@ -9,9 +9,15 @@ use web_time::Instant; #[cfg(target_arch = "wasm32")] use wgpu::web_sys; -use floem_reactive::{Runtime, SignalUpdate}; +use floem_reactive::Runtime; use peniko::kurbo::{Point, Size}; -use std::{collections::HashMap, rc::Rc, time::Duration}; +use std::{collections::HashMap, time::Duration}; +use ui_events::{ + ScrollDelta, + pointer::{ + PointerEvent, PointerGesture, PointerGestureEvent, PointerScrollEvent, PointerUpdate, + }, +}; use winit::{ dpi::{LogicalPosition, LogicalSize}, event::WindowEvent, @@ -19,12 +25,10 @@ use winit::{ window::{Theme, WindowId}, }; -use super::{APP_UPDATE_EVENTS, AppConfig, AppEventCallback, AppUpdateEvent, UserEvent}; -#[cfg(not(feature = "skia"))] -use crate::context::PaintState; +use super::{AppConfig, AppEventCallback, AppUpdateEvent, UserEvent, drain_app_update_events}; use crate::{ AppEvent, Application, - action::{Timer, TimerToken}, + action::{Timer, TimerAction, TimerToken}, dropped_file, event::dropped_file::FileDragEvent, ext_event::EXT_EVENT_HANDLER, @@ -32,38 +36,418 @@ use crate::{ Capture, profiler::{Profile, ProfileEvent}, }, + paint::{PaintState, renderer::SharedSceneFragmentRendererPool}, view::View, - window::{WindowConfig, handle::WindowHandle, id::process_window_updates}, + window::{ + WindowConfig, + handle::{FrameSchedule, WindowHandle}, + id::process_window_updates, + }, }; -struct PendingContextMenu { +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum PointerCoalesceKind { + Move, + Scroll, + Pinch, + Rotate, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct PointerCoalesceKey { window_id: WindowId, - menu: super::MenuWrapper, - pos: Option, + pointer_id: Option, + kind: PointerCoalesceKind, +} + +struct PendingPointerEvent { + event: PointerEvent, +} + +struct PendingFrameTick { + tick: subduction_core::timing::FrameTick, + token: TimerToken, + deadline: Instant, +} + +fn pointer_coalesce_key(window_id: WindowId, event: &PointerEvent) -> Option { + let (pointer, kind) = match event { + PointerEvent::Move(PointerUpdate { pointer, .. }) => (pointer, PointerCoalesceKind::Move), + PointerEvent::Scroll(PointerScrollEvent { pointer, .. }) => { + (pointer, PointerCoalesceKind::Scroll) + } + PointerEvent::Gesture(PointerGestureEvent { + pointer, + gesture: PointerGesture::Pinch(_), + .. + }) => (pointer, PointerCoalesceKind::Pinch), + PointerEvent::Gesture(PointerGestureEvent { + pointer, + gesture: PointerGesture::Rotate(_), + .. + }) => (pointer, PointerCoalesceKind::Rotate), + PointerEvent::Down(_) + | PointerEvent::Up(_) + | PointerEvent::Cancel(_) + | PointerEvent::Enter(_) + | PointerEvent::Leave(_) => return None, + }; + Some(PointerCoalesceKey { + window_id, + pointer_id: pointer.pointer_id, + kind, + }) +} + +fn coalesce_pointer_events(pending: &mut PointerEvent, next: PointerEvent) { + match (pending, next) { + ( + PointerEvent::Move(PointerUpdate { + current, coalesced, .. + }), + PointerEvent::Move(mut next), + ) => { + coalesced.push(current.clone()); + coalesced.append(&mut next.coalesced); + *current = next.current; + } + ( + PointerEvent::Scroll(PointerScrollEvent { delta, state, .. }), + PointerEvent::Scroll(next), + ) => { + *delta = add_scroll_delta(*delta, next.delta); + *state = next.state; + } + ( + PointerEvent::Gesture(PointerGestureEvent { gesture, state, .. }), + PointerEvent::Gesture(next), + ) => { + *gesture = add_pointer_gesture(gesture.clone(), next.gesture); + *state = next.state; + } + (pending, next) => { + *pending = next; + } + } +} + +fn add_scroll_delta(a: ScrollDelta, b: ScrollDelta) -> ScrollDelta { + match (a, b) { + (ScrollDelta::LineDelta(ax, ay), ScrollDelta::LineDelta(bx, by)) => { + ScrollDelta::LineDelta(ax + bx, ay + by) + } + (ScrollDelta::PixelDelta(a), ScrollDelta::PixelDelta(b)) => { + ScrollDelta::PixelDelta(PhysicalPosition::new(a.x + b.x, a.y + b.y)) + } + (_, b) => b, + } +} + +fn add_pointer_gesture(a: PointerGesture, b: PointerGesture) -> PointerGesture { + match (a, b) { + (PointerGesture::Pinch(a), PointerGesture::Pinch(b)) => PointerGesture::Pinch(a + b), + (PointerGesture::Rotate(a), PointerGesture::Rotate(b)) => PointerGesture::Rotate(a + b), + (_, b) => b, + } +} + +#[cfg(target_os = "macos")] +#[derive(Default)] +struct TimingDiagnostics { + enabled: bool, + last_report: Option, + last_tick_index: Option, + tick_count: u64, + tick_gap_count: u64, + tick_gap_frames: u64, + max_tick_gap: u64, + frame_ready_count: u64, + present_wake_armed: u64, + present_wake_due_now: u64, + present_timer_fired: u64, + present_attempts: u64, + present_success: u64, + max_present_timer_late_us: u128, +} + +#[cfg(target_os = "macos")] +impl TimingDiagnostics { + fn new() -> Self { + Self { + enabled: std::env::var_os("FLOEM_SUBDUCTION_TIMING_DIAG").is_some(), + ..Self::default() + } + } + + fn record_tick(&mut self, frame_index: u64) { + if !self.enabled { + return; + } + if let Some(last) = self.last_tick_index { + let gap = frame_index.saturating_sub(last); + if gap > 1 { + self.tick_gap_count = self.tick_gap_count.saturating_add(1); + self.tick_gap_frames = self.tick_gap_frames.saturating_add(gap - 1); + self.max_tick_gap = self.max_tick_gap.max(gap); + } + } + self.last_tick_index = Some(frame_index); + self.tick_count = self.tick_count.saturating_add(1); + } + + fn maybe_report(&mut self) { + if !self.enabled { + return; + } + let now = Instant::now(); + let Some(last_report) = self.last_report else { + self.last_report = Some(now); + return; + }; + if now.duration_since(last_report) < Duration::from_secs(1) { + return; + } + crate::floem_debug_log!( + "subduction timing: ticks={} tick_gaps={} missing_ticks={} max_tick_gap={} frame_ready={} present_armed={} present_due_now={} present_timer={} present_attempts={} present_ok={} max_present_timer_late={:.3}ms", + self.tick_count, + self.tick_gap_count, + self.tick_gap_frames, + self.max_tick_gap, + self.frame_ready_count, + self.present_wake_armed, + self.present_wake_due_now, + self.present_timer_fired, + self.present_attempts, + self.present_success, + self.max_present_timer_late_us as f64 / 1000.0, + ); + *self = Self { + enabled: true, + last_report: Some(now), + last_tick_index: self.last_tick_index, + ..Self::default() + }; + } } pub(crate) struct ApplicationHandle { window_handles: HashMap, - timers: HashMap, - animating_windows: std::collections::HashSet, - pending_context_menus: Vec, + next_output_id: u32, + timers: Vec, + pending_frame_ticks: HashMap, + pointer_coalesce_until: HashMap, + pending_pointer_events: HashMap, pub(crate) event_listener: Option>, pub(crate) gpu_resources: Option, + pub(crate) scene_renderer_pool: SharedSceneFragmentRendererPool, pub(crate) config: AppConfig, + #[cfg(target_os = "macos")] + timing_diag: TimingDiagnostics, } impl ApplicationHandle { - const UPDATE_BUDGET: Duration = Duration::from_millis(4); - pub(crate) fn new(config: AppConfig) -> Self { + let gpu_resources = config.gpu_resources.clone(); Self { window_handles: HashMap::new(), - timers: HashMap::new(), - animating_windows: std::collections::HashSet::new(), - pending_context_menus: Vec::new(), + next_output_id: 0, + timers: Vec::new(), + pending_frame_ticks: HashMap::new(), + pointer_coalesce_until: HashMap::new(), + pending_pointer_events: HashMap::new(), event_listener: None, - gpu_resources: None, + gpu_resources, + scene_renderer_pool: SharedSceneFragmentRendererPool::default(), config, + #[cfg(target_os = "macos")] + timing_diag: TimingDiagnostics::new(), + } + } + + fn finalize_presented_profile_frame(handle: &mut WindowHandle, event: Option) { + let queued_events = handle.take_profile_events(); + let timing = handle.take_last_timing_report(); + let Some(profile) = handle.profile.as_mut() else { + return; + }; + + profile.current.events.extend(queued_events); + if let Some(event) = event { + profile.current.events.push(event); + } + if timing.is_some() { + profile.current.timing = timing; + profile.next_frame(); + } + } + + /// Applies non-render scheduling produced by a frame tick. + /// + /// Rendering is not driven from this method. Frame ticks are the render + /// opportunity; the returned schedule only carries side effects that the + /// app loop owns, currently pointer-event coalescing while scene work is + /// deferred to the tick's submit deadline. + fn apply_window_frame_schedule( + &mut self, + window_id: WindowId, + schedule: FrameSchedule, + event_loop: &dyn ActiveEventLoop, + ) { + self.update_pointer_coalesce_deadline(window_id, schedule.coalesce_input_until); + + if let Some(commit) = schedule.compositor_commit_deadline { + let token = commit.token; + let target_deadline = commit.deadline; + let action = move |_| { + crate::Application::send_proxy_event(UserEvent::CompositorCommitDeadline { + window_id, + generation: commit.generation, + token, + }); + }; + self.request_timer( + Timer { + token, + action: TimerAction::Main(Box::new(action)), + deadline: target_deadline, + sequence: token.into_raw(), + }, + event_loop, + ); + } + } + + fn update_pointer_coalesce_deadline(&mut self, window_id: WindowId, deadline: Option) { + match deadline { + Some(deadline) if deadline > Instant::now() => { + self.pointer_coalesce_until.insert(window_id, deadline); + } + _ => { + self.pointer_coalesce_until.remove(&window_id); + } + } + } + + fn schedule_frame_tick( + &mut self, + window_id: WindowId, + tick: subduction_core::timing::FrameTick, + event_loop: &dyn ActiveEventLoop, + ) { + let Some(handle) = self.window_handles.get_mut(&window_id) else { + return; + }; + handle.record_profile_instant("VSync", Instant::now()); + let deadline = handle.schedule_frame_tick_work(tick); + if let Some(pending) = self.pending_frame_ticks.get_mut(&window_id) { + pending.tick = tick; + pending.deadline = deadline; + let token = pending.token; + if let Some(timer) = self.timers.iter_mut().find(|timer| timer.token == token) { + timer.deadline = deadline; + } + self.timers + .sort_by_key(|timer| (timer.deadline, timer.sequence)); + self.fire_timer(event_loop); + return; + } + let token = TimerToken::next(); + self.pending_frame_ticks.insert( + window_id, + PendingFrameTick { + tick, + token, + deadline, + }, + ); + let action = move |_| { + crate::Application::send_proxy_event(UserEvent::DeferredFrameTick { + window_id, + tick, + token, + }); + }; + self.request_timer( + Timer { + token, + action: TimerAction::Main(Box::new(action)), + deadline, + sequence: token.into_raw(), + }, + event_loop, + ); + } + + fn process_deferred_frame_tick( + &mut self, + window_id: WindowId, + _tick: subduction_core::timing::FrameTick, + token: TimerToken, + event_loop: &dyn ActiveEventLoop, + ) { + let Some(pending) = self.pending_frame_ticks.get(&window_id) else { + return; + }; + if pending.token != token { + return; + } + let Some(pending) = self.pending_frame_ticks.remove(&window_id) else { + return; + }; + let tick = pending.tick; + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.record_deferred_frame_timer_lateness( + Instant::now().saturating_duration_since(pending.deadline), + ); + } + + self.process_frame_tick_now(window_id, tick, event_loop); + } + + fn process_pending_frame_tick_now( + &mut self, + window_id: WindowId, + event_loop: &dyn ActiveEventLoop, + ) -> bool { + let Some(pending) = self.pending_frame_ticks.remove(&window_id) else { + return false; + }; + self.remove_timer(pending.token, event_loop); + self.process_frame_tick_now(window_id, pending.tick, event_loop); + true + } + + fn process_frame_tick_now( + &mut self, + window_id: WindowId, + tick: subduction_core::timing::FrameTick, + event_loop: &dyn ActiveEventLoop, + ) { + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.refresh_frame_source_target(); + handle.poll_gpu_callbacks(); + } + + // Frame ticks are the handoff point for continuous input that arrived + // while the previous compositor frame was active. Deferring to the + // latest safe start time lets resize/input events that arrived after + // the tick join this frame before scene work begins. + self.flush_coalesced_pointer_events(window_id); + Application::clear_update_posted(); + self.drain_app_update_events(event_loop); + if Runtime::has_pending_work() { + Runtime::drain_pending_work(); + } + + if let Some(handle) = self.window_handles.get_mut(&window_id) { + let schedule = handle.process_frame_tick(tick); + handle.refresh_frame_activity(); + let has_frame_work = handle.has_frame_work(); + let _ = handle; + self.apply_window_frame_schedule(window_id, schedule, event_loop); + if has_frame_work { + self.request_update(); + } } } @@ -87,54 +471,256 @@ impl ApplicationHandle { }); } } - #[cfg(not(feature = "skia"))] UserEvent::GpuResourcesUpdate { window_id } => { let handle = self.window_handles.get_mut(&window_id).unwrap(); - if let PaintState::PendingGpuResources { - window, - rx, - font_embolden, - renderer, - } = &handle.paint_state - { - let (gpu_resources, surface) = rx.recv().unwrap().unwrap(); - let renderer = crate::paint::Renderer::new( - window.clone(), - gpu_resources.clone(), - surface, - handle.window_state.effective_scale(), - renderer.size(), - *font_embolden, - ); + if let PaintState::PendingGpuResources { rx } = &handle.paint_state { + let (gpu_resources, surface_caps) = rx.recv().unwrap().unwrap(); + let cx = crate::paint::renderer::NewRendererCx { + gpu_resources: Some(gpu_resources.clone()), + surface_caps: Some(surface_caps), + transparent: handle.transparent, + scale: handle.ui.effective_scale(), + size: handle.ui.root_physical_size(), + maximum_drawable_count: handle.maximum_drawable_count, + }; + self.scene_renderer_pool + .init_if_needed(&self.config.renderer_chooser, cx); self.gpu_resources = Some(gpu_resources); - handle.paint_state = PaintState::Initialized { renderer }; + handle.paint_state = PaintState::Initialized; handle.gpu_resources = self.gpu_resources.clone(); handle.init_renderer(); + if let Some(gpu_resources) = handle.gpu_resources.clone() { + handle.ui.route_gpu_resources_ready(gpu_resources); + } } else { panic!("Sent a gpu resource update after it had already been initialized"); } } - UserEvent::ShowContextMenu { + UserEvent::WindowPaint { window_id } => { + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.ui.request_root_paint(); + handle.refresh_frame_activity(); + } + self.request_update(); + } + UserEvent::CompositorSurfaceContent { window_id, - menu, - pos, + surface_id, + content, } => { - self.pending_context_menus.push(PendingContextMenu { - window_id, - menu, - pos, - }); + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.set_compositor_surface_content(surface_id, content); + handle.refresh_frame_activity(); + } + self.request_update(); + } + UserEvent::CompositorSurfaceRequestFrame { + window_id, + surface_id, + } => { + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.request_compositor_surface_frame(surface_id); + handle.refresh_frame_activity(); + } + self.request_update(); + } + UserEvent::CompositorSurfaceFrameRatePreference { + window_id, + surface_id, + frame_rate, + } => { + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.set_compositor_surface_frame_rate_preference(surface_id, frame_rate); + handle.refresh_frame_activity(); + } + self.request_update(); + } + UserEvent::CompositorSurfacePresentsWithTransaction { + window_id, + surface_id, + presents_with_transaction, + } => { + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.set_compositor_surface_presents_with_transaction( + surface_id, + presents_with_transaction, + ); + handle.refresh_frame_activity(); + } + self.request_update(); + } + UserEvent::CompositorSurfaceProvider { + window_id, + surface_id, + provider, + } => { + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.set_compositor_surface_provider(surface_id, provider); + handle.refresh_frame_activity(); + } + self.request_update(); + } + UserEvent::SceneFragmentReady { + window_id, + key, + signature, + kind, + rendered, + worker_index, + render_start, + render_end, + gpu_end, + } => { + let mut coalesce_deadline = None; + if let Some(handle) = self.window_handles.get_mut(&window_id) { + if handle.complete_compositor_scene_render( + key, + signature, + kind, + rendered, + worker_index, + render_start, + render_end, + gpu_end, + ) { + handle.refresh_frame_activity(); + } + coalesce_deadline = handle.pointer_coalesce_deadline(); + } + self.update_pointer_coalesce_deadline(window_id, coalesce_deadline); + self.request_update(); + } + UserEvent::SceneEffectReady { + window_id, + key, + signature, + gpu_end, + } => { + let mut coalesce_deadline = None; + if let Some(handle) = self.window_handles.get_mut(&window_id) { + if handle.complete_compositor_scene_effect(key, signature, gpu_end) { + handle.refresh_frame_activity(); + } + coalesce_deadline = handle.pointer_coalesce_deadline(); + } + self.update_pointer_coalesce_deadline(window_id, coalesce_deadline); + self.request_update(); + } + UserEvent::LayerHostCommit { + window_id, + committed_at, + } => { + let mut coalesce_deadline = None; + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.handle_layer_host_commit(committed_at); + handle.refresh_frame_activity(); + coalesce_deadline = handle.pointer_coalesce_deadline(); + } + self.update_pointer_coalesce_deadline(window_id, coalesce_deadline); + self.request_update(); + } + UserEvent::CompositorCommitDeadline { + window_id, + generation, + token, + } => { + let mut coalesce_deadline = None; + if let Some(handle) = self.window_handles.get_mut(&window_id) { + if handle.handle_compositor_commit_deadline(generation, token) { + handle.refresh_frame_activity(); + } + coalesce_deadline = handle.pointer_coalesce_deadline(); + } + self.update_pointer_coalesce_deadline(window_id, coalesce_deadline); + self.request_update(); + } + UserEvent::FrameTick { window_id, tick } => { + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem frame pacing app tick window={:?} tick={} predicted={:?} refresh={:?}", + window_id, + tick.frame_index, + tick.predicted_present, + tick.refresh_interval, + ); + } + #[cfg(target_os = "macos")] + self.timing_diag.record_tick(tick.frame_index); + #[cfg(target_os = "macos")] + self.timing_diag.maybe_report(); + self.schedule_frame_tick(window_id, tick, event_loop); + } + UserEvent::DeferredFrameTick { + window_id, + tick, + token, + } => { + self.process_deferred_frame_tick(window_id, tick, token, event_loop); + } + UserEvent::InspectorCaptureWindow { + window_id, + inspector_window_id, + } => { + let capture = self.capture_window(window_id); + if let Some(capture) = capture + && let Some(inspector) = self.window_handles.get(&inspector_window_id) + { + inspector.ui.set_inspector_capture(capture); + } + } + UserEvent::InspectorProfileWindow { + window_id, + inspector_window_id, + stop, + } => { + if stop { + let profile = self.window_handles.get_mut(&window_id).and_then(|handle| { + handle.profile.take().map(|mut profile| { + profile.current.events.extend(handle.take_profile_events()); + if profile.current.timing.is_none() { + profile.current.timing = handle.pending_profile_timing(); + } + handle.ui.set_profile_events_enabled(false); + if !profile.current.events.is_empty() + || profile.current.timing.is_some() + { + profile.next_frame(); + } + profile + }) + }); + if let Some(profile) = profile + && let Some(inspector) = self.window_handles.get(&inspector_window_id) + { + inspector.ui.set_inspector_profile(profile); + } + } else if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.ui.set_profile_events_enabled(true); + handle.ui.clear_profile_events(); + handle.profile = Some(Profile::default()); + } } } } pub(crate) fn handle_update_event(&mut self, event_loop: &dyn ActiveEventLoop) { Application::clear_update_posted(); + self.drain_app_update_events(event_loop); - let events = APP_UPDATE_EVENTS.with(|events| { - let mut events = events.borrow_mut(); - std::mem::take(&mut *events) - }); + if Runtime::has_pending_work() { + Runtime::drain_pending_work(); + } + + self.drain_window_update_messages(event_loop); + + if Runtime::has_pending_work() { + self.request_update(); + } + self.update_control_flow(event_loop); + } + + fn drain_app_update_events(&mut self, event_loop: &dyn ActiveEventLoop) { + let events = drain_app_update_events(); for event in events { match event { @@ -149,59 +735,28 @@ impl ApplicationHandle { } AppUpdateEvent::RequestCloseWindow { window_id } => { if let Some(handle) = self.window_handles.get_mut(&window_id) { - handle.event(crate::event::Event::Window( - crate::event::WindowEvent::CloseRequested, - )); + handle.ui.route_close_requested(); } } AppUpdateEvent::RequestTimer { timer } => { self.request_timer(timer, event_loop); } - AppUpdateEvent::RequestAnimationTimer { - mut timer, - window_id, - } => { - if !self.window_can_render(&window_id) { - continue; - } - timer.deadline = Instant::now() + self.frame_duration_for_window(&window_id); - self.request_timer(timer, event_loop); - } - AppUpdateEvent::AnimationFrame(animate, window_id) => { - if animate && self.window_can_render(&window_id) { - self.animating_windows.insert(window_id); - } else { - self.animating_windows.remove(&window_id); - } - self.update_control_flow(event_loop); - } AppUpdateEvent::CancelTimer { timer } => { - self.remove_timer(&timer, event_loop); + self.remove_timer(timer, event_loop); } - AppUpdateEvent::CaptureWindow { window_id, capture } => { - capture.set(self.capture_window(window_id).map(Rc::new)); - } - AppUpdateEvent::ProfileWindow { - window_id, - end_profile, - } => { - let handle = self.window_handles.get_mut(&window_id); - if let Some(handle) = handle { - if let Some(profile) = end_profile { - profile.set(handle.profile.take().map(|mut profile| { - profile.next_frame(); - Rc::new(profile) - })); - } else { - handle.profile = Some(Profile::default()); - } + AppUpdateEvent::CaptureMetalFrame { window_id } => { + #[cfg(target_os = "macos")] + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.capture_next_metal_frame(); } + #[cfg(not(target_os = "macos"))] + let _ = window_id; } #[cfg(not(target_arch = "wasm32"))] AppUpdateEvent::MenuAction { action_id } => { for (_, handle) in self.window_handles.iter_mut() { - if handle.window_state.context_menu.contains_key(&action_id) - || handle.window_menu_actions.contains_key(&action_id) + if handle.ui.has_context_menu_action(&action_id) + || handle.ui.has_window_menu_action(&action_id) { handle.menu_action(&action_id); break; @@ -211,7 +766,7 @@ impl ApplicationHandle { #[cfg(target_arch = "wasm32")] AppUpdateEvent::MenuAction { action_id } => { for (_, handle) in self.window_handles.iter_mut() { - if handle.window_state.context_menu.contains_key(&action_id) { + if handle.ui.has_context_menu_action(&action_id) { handle.menu_action(&action_id); break; } @@ -220,27 +775,11 @@ impl ApplicationHandle { AppUpdateEvent::ThemeChanged { theme } => { self.config.global_theme_override = Some(theme); for window_handle in self.window_handles.values_mut() { - window_handle.window_state.light_dark_theme = theme; window_handle.set_theme(Some(theme), false); } } } } - - let start = Instant::now(); - let mut any_work_remaining = - self.handle_updates_for_all_windows_budgeted(start, Self::UPDATE_BUDGET); - - if start.elapsed() < Self::UPDATE_BUDGET && Runtime::has_pending_work() { - Runtime::drain_pending_work(); - if Runtime::has_pending_work() { - any_work_remaining = true; - } - } - - if any_work_remaining { - self.request_update(); - } } pub(crate) fn handle_window_event( @@ -249,13 +788,15 @@ impl ApplicationHandle { event: WindowEvent, event_loop: &dyn ActiveEventLoop, ) { - let is_redraw = matches!(event, WindowEvent::RedrawRequested); - let window_handle = match self.window_handles.get_mut(&window_id) { - Some(window_handle) => window_handle, - None => return, - }; + if !self.window_handles.contains_key(&window_id) { + return; + } - let start = window_handle.profile.is_some().then(|| { + let has_profile = self + .window_handles + .get(&window_id) + .is_some_and(|window_handle| window_handle.profile.is_some()); + let start = has_profile.then(|| { let name = match event { WindowEvent::ActivationTokenDone { .. } => "ActivationTokenDone", WindowEvent::SurfaceResized(..) => "Resized", @@ -285,48 +826,112 @@ impl ApplicationHandle { WindowEvent::DragLeft { .. } => "DragLeft", WindowEvent::DragMoved { .. } => "DragMoved", }; - ( - name, - Instant::now(), - matches!(event, WindowEvent::RedrawRequested), - ) + (name, Instant::now(), false) }); - let event_scale = window_handle.window_state.effective_scale(); + let event_scale = self + .window_handles + .get(&window_id) + .map(|window_handle| window_handle.ui.effective_scale()) + .unwrap_or(1.0); + let discrete_input = matches!( + &event, + WindowEvent::KeyboardInput { + is_synthetic: false, + .. + } | WindowEvent::Ime(_) + | WindowEvent::DoubleTapGesture { .. } + | WindowEvent::PointerEntered { .. } + | WindowEvent::PointerLeft { .. } + | WindowEvent::PointerButton { .. } + | WindowEvent::TouchpadPressure { .. } + ); + let continuous_input = matches!( + &event, + WindowEvent::MouseWheel { .. } + | WindowEvent::PinchGesture { .. } + | WindowEvent::PanGesture { .. } + | WindowEvent::RotationGesture { .. } + | WindowEvent::PointerMoved { .. } + ); + if let Some(window_handle) = self.window_handles.get_mut(&window_id) { + if discrete_input { + window_handle.note_discrete_input_frame_demand(); + } + if continuous_input { + window_handle.note_continuous_input_frame_demand(); + } + } - match window_handle.event_reducer.reduce(event_scale, &event) { + let translation = self + .window_handles + .get_mut(&window_id) + .and_then(|window_handle| window_handle.event_reducer.reduce(event_scale, &event)); + match translation { Some(WindowEventTranslation::Keyboard(ke)) => { if let WindowEvent::KeyboardInput { is_synthetic, .. } = event && !is_synthetic { - window_handle.key_event(ke) + if let Some(window_handle) = self.window_handles.get_mut(&window_id) { + window_handle.key_event(ke); + } } } Some(WindowEventTranslation::Pointer(pe)) => { - window_handle.pointer_event(pe); + if self.should_coalesce_pointer_event(window_id, &pe) { + self.coalesce_pointer_event(window_id, pe); + return; + } + self.flush_coalesced_pointer_events(window_id); + if let Some(window_handle) = self.window_handles.get_mut(&window_id) { + window_handle.pointer_event(pe); + } } None => {} } + let frame_presented = false; + let mut process_resize_frame_immediately = false; + let Some(window_handle) = self.window_handles.get_mut(&window_id) else { + return; + }; + match event { WindowEvent::ActivationTokenDone { .. } => {} WindowEvent::SurfaceResized(size) => { - window_handle.refresh_live_resize(); - let size: LogicalSize = size.to_logical(window_handle.window_state.os_scale); + let surface_size = size; + let size: LogicalSize = surface_size.to_logical(window_handle.ui.os_scale()); let size = Size::new(size.width, size.height); + if std::env::var_os("FLOEM_RESIZE_DIAG").is_some() { + crate::floem_debug_log!( + "floem resize event t={:?} physical={}x{} logical={:.2}x{:.2} scale={:.3}", + Instant::now(), + surface_size.width, + surface_size.height, + size.width, + size.height, + window_handle.ui.effective_scale(), + ); + } window_handle.size(size); + self.request_update(); + process_resize_frame_immediately = true; } WindowEvent::Moved(position) => { let position: LogicalPosition = - position.to_logical(window_handle.window_state.os_scale); + position.to_logical(window_handle.ui.os_scale()); let point = Point::new(position.x, position.y); window_handle.position(point); + window_handle.refresh_frame_source_target(); } WindowEvent::CloseRequested => { if let Some(handle) = self.window_handles.get_mut(&window_id) { - handle.event(crate::event::Event::Window( - crate::event::WindowEvent::CloseRequested, - )); + handle.ui.route_close_requested(); + } + if crate::app::take_close_window_event_count(window_id) > 0 { + self.close_window(window_id, event_loop); + self.update_control_flow(event_loop); + return; } } WindowEvent::Destroyed => { @@ -377,19 +982,21 @@ impl ApplicationHandle { WindowEvent::TouchpadPressure { .. } => {} WindowEvent::ScaleFactorChanged { scale_factor, .. } => { window_handle.os_scale(scale_factor); + window_handle.refresh_frame_source_target(); } WindowEvent::ThemeChanged(theme) => { window_handle.set_theme(Some(theme), true); } WindowEvent::Occluded(occluded) => { window_handle.set_occluded(occluded); - if occluded { - self.animating_windows.remove(&window_id); + if !occluded { + window_handle.refresh_frame_activity(); + if window_handle.has_frame_work() { + self.request_update(); + } } } - WindowEvent::RedrawRequested => { - window_handle.render_frame(); - } + WindowEvent::RedrawRequested => {} WindowEvent::PanGesture { .. } => {} WindowEvent::DoubleTapGesture { .. } => {} WindowEvent::RotationGesture { .. } => {} @@ -406,32 +1013,115 @@ impl ApplicationHandle { //already handled by the ui-events reducer } } - - if let Some((name, start, new_frame)) = start { + if let Some((name, start, _new_frame)) = start { let end = Instant::now(); if let Some(window_handle) = self.window_handles.get_mut(&window_id) { - let profile = window_handle.profile.as_mut().unwrap(); + let event = ProfileEvent { + start, + end, + name: name.to_string(), + depth: 0, + }; + if frame_presented { + Self::finalize_presented_profile_frame(window_handle, Some(event)); + } else { + let queued_events = window_handle.take_profile_events(); + if let Some(profile) = window_handle.profile.as_mut() { + profile.current.events.extend(queued_events); + profile.current.events.push(event); + } + } + } + } + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.refresh_frame_source_target(); + } + if process_resize_frame_immediately + && self.process_pending_frame_tick_now(window_id, event_loop) + { + self.update_control_flow(event_loop); + return; + } + if frame_presented { + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.refresh_frame_activity(); + } + self.update_control_flow(event_loop); + return; + } + if let Some(handle) = self.window_handles.get_mut(&window_id) { + handle.refresh_frame_activity(); + } + self.process_window_frame_from_event(window_id, event_loop); + self.update_control_flow(event_loop); + } - profile - .current - .events - .push(ProfileEvent { start, end, name }); + fn process_window_frame_from_event( + &mut self, + window_id: WindowId, + event_loop: &dyn ActiveEventLoop, + ) { + let _ = event_loop; + if !self.is_coalescing_pointer_events(window_id) { + self.flush_coalesced_pointer_events(window_id); + } - if new_frame { - profile.next_frame(); - } + let Some(()) = self.window_handles.get_mut(&window_id).map(|handle| { + handle.process_update_messages_only(); + handle.refresh_frame_activity(); + }) else { + return; + }; + } + + fn should_coalesce_pointer_event(&self, window_id: WindowId, event: &PointerEvent) -> bool { + self.is_coalescing_pointer_events(window_id) + && pointer_coalesce_key(window_id, event).is_some() + } + + fn is_coalescing_pointer_events(&self, window_id: WindowId) -> bool { + self.pointer_coalesce_until + .get(&window_id) + .is_some_and(|until| Instant::now() < *until) + } + + fn coalesce_pointer_event(&mut self, window_id: WindowId, event: PointerEvent) { + let Some(key) = pointer_coalesce_key(window_id, &event) else { + return; + }; + self.pending_pointer_events + .entry(key) + .and_modify(|pending| coalesce_pointer_events(&mut pending.event, event.clone())) + .or_insert(PendingPointerEvent { event }); + self.request_update(); + } + + fn flush_coalesced_pointer_events(&mut self, window_id: WindowId) { + let mut pending = Vec::new(); + self.pending_pointer_events.retain(|key, event| { + if key.window_id == window_id { + pending.push(event.event.clone()); + false + } else { + true } + }); + if pending.is_empty() { + return; } - if !is_redraw { - self.request_update(); + let Some(handle) = self.window_handles.get_mut(&window_id) else { + return; + }; + for event in pending { + handle.pointer_event(event); } } pub(crate) fn new_window( &mut self, event_loop: &dyn ActiveEventLoop, - view_fn: Box Box>, + view_fn: Box Box + Send>, override_theme: Option, #[allow(unused_variables)] WindowConfig { size, @@ -453,7 +1143,8 @@ impl ApplicationHandle { mac_os_config, win_os_config, web_config, - font_embolden, + maximum_drawable_count, + surface_pool_retained_extras, }: WindowConfig, ) { let logical_size = size.map(|size| LogicalSize::new(size.width, size.height)); @@ -629,20 +1320,30 @@ impl ApplicationHandle { } } let window_id = window.id(); + let output_id = self.next_output_id; + self.next_output_id = self.next_output_id.saturating_add(1); let window_handle = WindowHandle::new( window, + output_id, self.gpu_resources.clone(), + self.config.renderer_chooser.clone(), + self.scene_renderer_pool.clone(), self.config.wgpu_features, self.config.wgpu_backends, view_fn, transparent, apply_default_theme, - font_embolden, + maximum_drawable_count, + surface_pool_retained_extras, ); self.window_handles.insert(window_id, window_handle); } fn close_window(&mut self, window_id: WindowId, event_loop: &dyn ActiveEventLoop) { + let _ = event_loop; + self.pointer_coalesce_until.remove(&window_id); + self.pending_pointer_events + .retain(|key, _| key.window_id != window_id); if let Some(handle) = self.window_handles.get_mut(&window_id) { handle.destroy(); } @@ -671,110 +1372,40 @@ impl ApplicationHandle { Application::request_update(); } - fn handle_updates_for_all_windows_budgeted( - &mut self, - start: Instant, - budget: Duration, - ) -> bool { - let mut any_work_remaining = false; - + fn drain_window_update_messages(&mut self, event_loop: &dyn ActiveEventLoop) { for (window_id, handle) in self.window_handles.iter_mut() { - handle.process_scheduled_updates(); - let done = handle.process_update_budgeted(start, budget); - if !done { - any_work_remaining = true; - } + handle.process_update_messages_only(); + handle.refresh_frame_activity(); - if handle.window_state.request_paint && handle.can_render_now() { - handle.window.request_redraw(); - let frame_interval = handle - .window - .current_monitor() - .and_then(|m| m.current_video_mode()) - .and_then(|v| v.refresh_rate_millihertz()) - .map(|mhz| Duration::from_nanos(1_000_000_000_000 / mhz.get() as u64)) - .unwrap_or(Duration::from_millis(8)); - handle.render_frame_if_due(frame_interval); - } - - if !done || start.elapsed() >= budget { - any_work_remaining = true; - break; - } - - // Keep window updates in the same update phase but bound by the same budget. while process_window_updates(window_id) { - if start.elapsed() >= budget { - any_work_remaining = true; - break; - } - } - if start.elapsed() >= budget { - any_work_remaining = true; - break; + handle.process_update_messages_only(); + handle.refresh_frame_activity(); } } - any_work_remaining - } - - fn frame_duration_for_window(&self, window_id: &winit::window::WindowId) -> Duration { - self.window_handles - .get(window_id) - .and_then(|h| h.window.current_monitor()) - .and_then(|m| m.current_video_mode()) - .and_then(|v| v.refresh_rate_millihertz()) - .map(|mhz| Duration::from_nanos(1_000_000_000_000 / mhz.get() as u64)) - .unwrap_or(Duration::from_millis(8)) - } - - fn window_can_render(&self, window_id: &winit::window::WindowId) -> bool { - self.window_handles - .get(window_id) - .map(|h| h.can_render_now()) - .unwrap_or(false) + let _ = event_loop; } fn update_control_flow(&self, event_loop: &dyn ActiveEventLoop) { - let timer_deadline = self.timers.values().map(|t| t.deadline).min(); - - let animation_deadline = if self.animating_windows.is_empty() { - None + if let Some(deadline) = self.timers.first().map(|timer| timer.deadline) { + event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); } else { - let now = Instant::now(); - self.animating_windows - .iter() - .filter(|wid| self.window_can_render(wid)) - .map(|wid| now + self.frame_duration_for_window(wid)) - .min() - }; - - match (timer_deadline, animation_deadline) { - (Some(t), Some(a)) => { - event_loop.set_control_flow(ControlFlow::WaitUntil(t.min(a))); - } - (Some(t), None) => { - event_loop.set_control_flow(ControlFlow::WaitUntil(t)); - } - (None, Some(a)) => { - event_loop.set_control_flow(ControlFlow::WaitUntil(a)); - } - (None, None) => { - event_loop.set_control_flow(ControlFlow::Wait); - } + event_loop.set_control_flow(ControlFlow::Wait); } } fn request_timer(&mut self, timer: Timer, event_loop: &dyn ActiveEventLoop) { - self.timers.insert(timer.token, timer); + self.timers.push(timer); + self.timers + .sort_by_key(|timer| (timer.deadline, timer.sequence)); self.fire_timer(event_loop); } - fn remove_timer(&mut self, timer: &TimerToken, event_loop: &dyn ActiveEventLoop) { - self.timers.remove(timer); - if self.timers.is_empty() { - event_loop.set_control_flow(ControlFlow::Wait); - } + fn remove_timer(&mut self, timer: TimerToken, event_loop: &dyn ActiveEventLoop) { + self.timers.retain(|entry| entry.token != timer); + self.timers + .sort_by_key(|timer| (timer.deadline, timer.sequence)); + self.fire_timer(event_loop); } fn fire_timer(&mut self, event_loop: &dyn ActiveEventLoop) { @@ -783,53 +1414,45 @@ impl ApplicationHandle { return; } - let deadline = self.timers.values().map(|timer| timer.deadline).min(); - if let Some(deadline) = deadline { + if let Some(deadline) = self.timers.first().map(|timer| timer.deadline) { event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); } } pub(crate) fn handle_timer(&mut self, event_loop: &dyn ActiveEventLoop) { let now = Instant::now(); - let tokens: Vec = self + if self .timers - .iter() - .filter_map(|(token, timer)| { - if timer.deadline <= now { - Some(*token) - } else { - None - } - }) - .collect(); - if !tokens.is_empty() { - for token in tokens { - if let Some(mut timer) = self.timers.remove(&token) { - if timer.is_animation - && timer - .window_id - .is_some_and(|window_id| !self.window_can_render(&window_id)) - { - // Keep animation timers dormant while hidden/occluded. - timer.deadline = now + Duration::from_millis(100); - self.timers.insert(token, timer); - continue; + .first() + .is_some_and(|timer| timer.deadline <= now) + { + let mut any_timer_fired = false; + while self + .timers + .first() + .is_some_and(|timer| timer.deadline <= now) + { + let timer = self.timers.remove(0); + let token = timer.token; + match timer.action { + TimerAction::Main(action) => action(token), + TimerAction::Ui { + window_id, + root, + action, + } => { + if let Some(handle) = self.window_handles.get(&window_id) { + handle.ui.run_timer_action(root, token, action); + } } - (timer.action)(token); } + any_timer_fired = true; } - self.request_update(); - } - self.fire_timer(event_loop); - } - - pub(crate) fn flush_deferred_context_menus(&mut self) { - let pending = std::mem::take(&mut self.pending_context_menus); - for item in pending { - if let Some(handle) = self.window_handles.get_mut(&item.window_id) { - handle.show_context_menu(item.menu.0, item.pos); + if any_timer_fired { + self.request_update(); } } + self.fire_timer(event_loop); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index f21c58df8..55f5caea5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,19 +3,17 @@ pub(crate) mod delegate; pub(crate) mod handle; use std::{ - cell::RefCell, - rc::Rc, + sync::Arc, sync::atomic::{AtomicBool, Ordering}, }; use crate::platform::menu_types::MenuId; #[cfg(feature = "crossbeam")] use crossbeam::channel::{Receiver, Sender, unbounded as channel}; -use peniko::kurbo::Point; #[cfg(not(feature = "crossbeam"))] use std::sync::mpsc::{Receiver, Sender, channel}; -use floem_reactive::{Runtime, WriteSignal}; +use floem_reactive::Runtime; use parking_lot::Mutex; use raw_window_handle::HasDisplayHandle; use winit::{ @@ -27,28 +25,51 @@ use winit::{ use crate::{ action::{Timer, TimerToken}, - inspector::{Capture, profiler::Profile}, + compositor_surface::{ + CompositorSurfaceContent, CompositorSurfaceId, CompositorSurfaceProviderHandle, + }, + paint::composition::CompositionKey, platform::clipboard::Clipboard, view::IntoView, - window::{WindowConfig, WindowCreation}, + window::{WindowConfig, WindowCreation, compositor::SceneRenderSignature}, }; use handle::ApplicationHandle; +use subduction_core::timing::FrameTick; pub(crate) type AppEventCallback = dyn Fn(AppEvent); static EVENT_LOOP_PROXY: Mutex)>> = Mutex::new(None); static APP_UPDATE_POSTED: AtomicBool = AtomicBool::new(false); -thread_local! { - pub(crate) static APP_UPDATE_EVENTS: RefCell> = Default::default(); -} +static APP_UPDATE_EVENTS: Mutex> = Mutex::new(Vec::new()); + +struct UncheckedAppUpdateEvent(AppUpdateEvent); + +// App update events are delivered to the main application loop. Some legacy +// variants still carry UI-thread-local callbacks; those variants need to be +// retired as the thread split continues. +unsafe impl Send for UncheckedAppUpdateEvent {} -#[derive(Debug)] pub struct AppConfig { pub(crate) exit_on_close: bool, pub(crate) wgpu_features: wgpu::Features, pub(crate) wgpu_backends: Option, + pub(crate) gpu_resources: Option, pub(crate) global_theme_override: Option, + pub(crate) renderer_chooser: crate::paint::renderer::RendererChooser, +} + +impl std::fmt::Debug for AppConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppConfig") + .field("exit_on_close", &self.exit_on_close) + .field("wgpu_features", &self.wgpu_features) + .field("wgpu_backends", &self.wgpu_backends) + .field("gpu_resources", &self.gpu_resources) + .field("global_theme_override", &self.global_theme_override) + .field("renderer_chooser", &"") + .finish() + } } impl Default for AppConfig { @@ -57,7 +78,9 @@ impl Default for AppConfig { exit_on_close: !cfg!(target_os = "macos"), wgpu_features: wgpu::Features::default(), wgpu_backends: None, + gpu_resources: None, global_theme_override: None, + renderer_chooser: crate::paint::renderer::default_renderer(), } } } @@ -77,12 +100,38 @@ impl AppConfig { self } + /// Uses an existing WGPU instance, adapter, device, and queue for Floem. + /// + /// Floem-created window surfaces and compositor-owned compositor surfaces + /// will be configured against these resources instead of requesting a + /// separate WGPU context. + #[inline] + pub fn gpu_resources(mut self, gpu_resources: crate::gpu_resources::GpuResources) -> Self { + self.gpu_resources = Some(gpu_resources); + self + } + /// Sets the global theme. #[inline] pub fn set_global_theme(mut self, theme: Theme) -> Self { self.global_theme_override = Some(theme); self } + + /// Override the application-wide renderer chooser. + #[inline] + pub fn renderer_chooser( + mut self, + renderer_chooser: impl Fn( + crate::paint::renderer::NewRendererCx, + ) -> crate::paint::renderer::RendererSpec + + Send + + Sync + + 'static, + ) -> Self { + self.renderer_chooser = Arc::new(renderer_chooser); + self + } } /// Initializes and runs an application with a single window. @@ -98,7 +147,7 @@ impl AppConfig { /// /// To build an application and windows with more configuration, see [`Application`]. #[cfg_attr(debug_assertions, track_caller)] -pub fn launch(app_view: impl FnOnce() -> V + 'static) { +pub fn launch(app_view: impl FnOnce() -> V + Send + 'static) { Application::new().window(move |_| app_view(), None).run() } @@ -107,15 +156,6 @@ pub enum AppEvent { Reopen { has_visible_windows: bool }, } -pub(crate) struct MenuWrapper(pub(crate) muda::Menu); -// SAFETY: these unsafe wappers are needed so that we can send the muda memu. -// The muda menu internally uses RC on a String ID and it's Vec of children. -// This unsafe wrapper is memory safe but the race condition could potentially (unlikely) -// lead to bad reference counts and leaked memory. -// I think this is fine for this case. -unsafe impl Send for MenuWrapper {} -unsafe impl Sync for MenuWrapper {} - pub(crate) enum UserEvent { AppUpdate, Idle, @@ -124,80 +164,123 @@ pub(crate) enum UserEvent { Reopen { has_visible_windows: bool, }, - #[cfg(not(feature = "skia"))] GpuResourcesUpdate { window_id: WindowId, }, - ShowContextMenu { + WindowPaint { window_id: WindowId, - menu: MenuWrapper, - pos: Option, }, -} - -#[allow(clippy::large_enum_variant)] -pub(crate) enum AppUpdateEvent { - NewWindow { - window_creation: WindowCreation, + CompositorSurfaceContent { + window_id: WindowId, + surface_id: CompositorSurfaceId, + content: CompositorSurfaceContent, }, - CloseWindow { + CompositorSurfaceRequestFrame { window_id: WindowId, + surface_id: CompositorSurfaceId, }, - RequestCloseWindow { + CompositorSurfaceFrameRatePreference { window_id: WindowId, + surface_id: CompositorSurfaceId, + frame_rate: crate::frame::FrameRatePreference, }, - CaptureWindow { + CompositorSurfacePresentsWithTransaction { window_id: WindowId, - capture: WriteSignal>>, + surface_id: CompositorSurfaceId, + presents_with_transaction: bool, }, - ProfileWindow { + CompositorSurfaceProvider { window_id: WindowId, - end_profile: Option>>>, + surface_id: CompositorSurfaceId, + provider: CompositorSurfaceProviderHandle, }, - RequestTimer { - timer: Timer, + SceneFragmentReady { + window_id: WindowId, + key: CompositionKey, + signature: SceneRenderSignature, + kind: crate::paint::renderer::SceneFragmentRenderKind, + rendered: bool, + worker_index: usize, + render_start: crate::platform::Instant, + render_end: crate::platform::Instant, + gpu_end: crate::platform::Instant, }, - RequestAnimationTimer { - timer: Timer, + SceneEffectReady { window_id: WindowId, + key: CompositionKey, + signature: SceneRenderSignature, + gpu_end: crate::platform::Instant, }, - CancelTimer { - timer: TimerToken, + LayerHostCommit { + window_id: WindowId, + committed_at: crate::platform::Instant, + }, + CompositorCommitDeadline { + window_id: WindowId, + generation: u64, + token: TimerToken, + }, + FrameTick { + window_id: WindowId, + tick: FrameTick, + }, + DeferredFrameTick { + window_id: WindowId, + tick: FrameTick, + token: TimerToken, }, - MenuAction { - action_id: MenuId, + InspectorCaptureWindow { + window_id: WindowId, + inspector_window_id: WindowId, }, - ThemeChanged { - theme: Theme, + InspectorProfileWindow { + window_id: WindowId, + inspector_window_id: WindowId, + stop: bool, }, - AnimationFrame(bool, WindowId), +} + +#[allow(clippy::large_enum_variant)] +pub(crate) enum AppUpdateEvent { + NewWindow { window_creation: WindowCreation }, + CloseWindow { window_id: WindowId }, + RequestCloseWindow { window_id: WindowId }, + CaptureMetalFrame { window_id: WindowId }, + RequestTimer { timer: Timer }, + CancelTimer { timer: TimerToken }, + MenuAction { action_id: MenuId }, + ThemeChanged { theme: Theme }, } pub(crate) fn add_app_update_event(event: AppUpdateEvent) { - APP_UPDATE_EVENTS.with(|events| { - events.borrow_mut().push(event); - }); + APP_UPDATE_EVENTS + .lock() + .push(UncheckedAppUpdateEvent(event)); Application::request_update(); } +pub(crate) fn drain_app_update_events() -> Vec { + APP_UPDATE_EVENTS + .lock() + .drain(..) + .map(|event| event.0) + .collect() +} + /// Drain the pending app update events and return how many of them were /// `CloseWindow` for the given `window_id`. This is intended for testing /// the `handle_default_behaviors` close logic. #[doc(hidden)] pub fn take_close_window_event_count(window_id: WindowId) -> usize { - APP_UPDATE_EVENTS.with(|events| { - let mut events = events.borrow_mut(); - let count = events - .iter() - .filter( - |e| matches!(e, AppUpdateEvent::CloseWindow { window_id: id } if *id == window_id), - ) - .count(); - events.retain( - |e| !matches!(e, AppUpdateEvent::CloseWindow { window_id: id } if *id == window_id), - ); - count - }) + let mut events = APP_UPDATE_EVENTS.lock(); + let count = events + .iter() + .filter(|e| matches!(e.0, AppUpdateEvent::CloseWindow { window_id: id } if id == window_id)) + .count(); + events.retain( + |e| !matches!(e.0, AppUpdateEvent::CloseWindow { window_id: id } if id == window_id), + ); + count } /// Floem top level application @@ -240,9 +323,7 @@ impl ApplicationHandler for Application { fn proxy_wake_up(&mut self, event_loop: &dyn ActiveEventLoop) { self.handle.handle_timer(event_loop); - for event in self.receiver.try_iter() { - self.handle.handle_user_event(event_loop, event); - } + self.drain_proxy_events(event_loop); if Runtime::has_pending_work() { self.handle.request_update(); } @@ -255,7 +336,6 @@ impl ApplicationHandler for Application { } fn about_to_wait(&mut self, event_loop: &dyn ActiveEventLoop) { - self.handle.flush_deferred_context_menus(); self.handle.handle_timer(event_loop); if Runtime::has_pending_work() { self.handle.request_update(); @@ -302,6 +382,12 @@ impl Application { self } + fn drain_proxy_events(&mut self, event_loop: &dyn ActiveEventLoop) { + for event in self.receiver.try_iter() { + self.handle.handle_user_event(event_loop, event); + } + } + /// Create a new window for the application, if you want multiple windows, /// just chain more window method to the builder. /// @@ -311,7 +397,7 @@ impl Application { /// `WindowConfig::default()`. pub fn window( mut self, - app_view: impl FnOnce(WindowId) -> V + 'static, + app_view: impl FnOnce(WindowId) -> V + Send + 'static, config: Option, ) -> Self { self.initial_windows.push(WindowCreation { diff --git a/src/box_tree.rs b/src/box_tree.rs index beb9e8506..00366df42 100644 --- a/src/box_tree.rs +++ b/src/box_tree.rs @@ -1,8 +1,14 @@ use std::ops::{Deref, DerefMut}; +use peniko::kurbo::{Rect, RoundedRect}; use rustc_hash::FxHashMap; use crate::ViewId; +use crate::action::add_update_message; +use crate::frame::FrameRatePreference; +use crate::message::UpdateMessage; +use crate::paint::display_list::TransformClass; +use crate::view::VIEW_STORAGE; /// A visual identifier that represents a rectangle in the box tree. /// @@ -59,6 +65,20 @@ impl ElementId { pub fn is_view(&self) -> bool { self.2 } + + pub fn set_local_clip(&self, clip: Option) { + let box_tree = VIEW_STORAGE.with_borrow(|s| s.existing_box_tree(self.owning_id())); + if box_tree.borrow_mut().set_local_clip(self.0, clip) { + add_update_message(UpdateMessage::RequestPaint(*self)); + } + } + + pub fn set_local_bounds(&self, rect: Rect) { + let box_tree = VIEW_STORAGE.with_borrow(|s| s.existing_box_tree(self.owning_id())); + if box_tree.borrow_mut().set_local_bounds(self.0, rect) { + add_update_message(UpdateMessage::RequestPaint(*self)); + } + } } /// Per-element focus navigation metadata kept in the box tree. @@ -158,10 +178,13 @@ impl FocusNavMeta { /// /// Keeps the `ElementId` used by hit-testing/event routing plus optional /// navigation hints for high-quality keyboard focus behavior. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct ElementMeta { pub element_id: ElementId, pub focus: FocusNavMeta, + pub(crate) retained_transform_boundary: Option, + pub(crate) wants_layer: bool, + pub(crate) layer_frame_rate: Option, } impl ElementMeta { @@ -178,6 +201,9 @@ impl ElementMeta { autofocus: false, enabled: true, }, + retained_transform_boundary: None, + wants_layer: false, + layer_frame_rate: None, } } } @@ -242,6 +268,80 @@ impl BoxTree { true } + pub(crate) fn set_retained_transform_boundary( + &mut self, + id: understory_box_tree::NodeId, + boundary: Option, + ) -> bool { + let Some(mut meta) = self.element_meta(id) else { + return false; + }; + meta.retained_transform_boundary = boundary; + self.metadata.insert(id, meta); + true + } + + pub(crate) fn wants_layer(&self, id: understory_box_tree::NodeId) -> bool { + self.element_meta(id).is_some_and(|meta| meta.wants_layer) + } + + pub(crate) fn set_wants_layer( + &mut self, + id: understory_box_tree::NodeId, + wants_layer: bool, + ) -> bool { + let Some(mut meta) = self.element_meta(id) else { + return false; + }; + if meta.wants_layer == wants_layer { + return false; + } + meta.wants_layer = wants_layer; + self.metadata.insert(id, meta); + true + } + + pub(crate) fn layer_frame_rate( + &self, + id: understory_box_tree::NodeId, + ) -> Option { + self.element_meta(id).and_then(|meta| meta.layer_frame_rate) + } + + pub(crate) fn containing_layer_frame_rate( + &self, + id: ElementId, + ) -> Option<(ElementId, FrameRatePreference)> { + let mut current = Some(id.0); + while let Some(node_id) = current { + if let Some(meta) = self.element_meta(node_id) + && meta.wants_layer + && let Some(frame_rate) = meta.layer_frame_rate + { + return Some((meta.element_id, frame_rate)); + } + current = self.parent_of(node_id); + } + None + } + + pub(crate) fn set_layer_frame_rate( + &mut self, + id: understory_box_tree::NodeId, + layer_frame_rate: FrameRatePreference, + ) -> bool { + let Some(mut meta) = self.element_meta(id) else { + return false; + }; + let layer_frame_rate = (!layer_frame_rate.is_full()).then_some(layer_frame_rate); + if meta.layer_frame_rate == layer_frame_rate { + return false; + } + meta.layer_frame_rate = layer_frame_rate; + self.metadata.insert(id, meta); + true + } + /// Returns `true` when this node can receive focus by any means. #[inline] pub fn is_focusable(&self, id: understory_box_tree::NodeId) -> bool { @@ -303,6 +403,16 @@ impl BoxTree { } self.tree.remove(id); } + + pub fn element_ids_for_view(&self, view_id: ViewId) -> Vec { + self.metadata + .values() + .filter_map(|meta| { + (meta.element_id.owning_id() == view_id && self.tree.is_alive(meta.element_id.0)) + .then_some(meta.element_id) + }) + .collect() + } } impl Default for BoxTree { diff --git a/src/compositor_surface.rs b/src/compositor_surface.rs new file mode 100644 index 000000000..e76bdb5c4 --- /dev/null +++ b/src/compositor_surface.rs @@ -0,0 +1,1171 @@ +//! Compositor-owned image surfaces. +//! +//! This module models compositor content that still behaves like an image in +//! the Floem display list. A [`SurfaceView`] is the paint-facing +//! identity used to create intrinsic surface image payloads. A +//! [`CompositorSurfaceProducer`] is the producer-facing handle that renders +//! frames for that image into Subduction-owned wgpu targets. +//! +//! During display-list lowering, Floem chooses how to use the image for the +//! current paint state. If the placement can be directly composed, the image +//! can become a platform compositor layer. If a clip, mask, filter, effect, +//! opacity group, or other paint state requires renderer participation, Floem +//! can sample the same submitted content through Imaging and flatten it into +//! an intermediate pass. +//! +//! Use this API when produced content should be placed by the view tree and +//! must remain correct under normal Floem paint operations. Use +//! [`crate::external_surface::ExternalSurface`] when content is submitted +//! directly by external code instead of through Floem-owned frame opportunities. + +use rustc_hash::FxHashMap; +use std::{ + any::Any, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + mpsc, + }, + time::{Duration, Instant}, +}; + +use peniko::{ + ImageAlphaType, ImageData, + kurbo::{Rect, Size}, +}; +use winit::window::WindowId; + +use crate::{ + Application, + app::UserEvent, + frame::{FrameOutcome, FrameRatePreference, PresentationInterval}, + gpu_resources::GpuResources, +}; + +static NEXT_SURFACE_SLOT_ID: AtomicU64 = AtomicU64::new(1); + +/// Stable window-local identity for submitted surface content. +/// +/// A surface slot is the shared content identity behind both compositor-surface +/// producers and direct external surfaces. It is not itself a platform layer +/// or a producer; it is the slot Floem uses to connect submitted frames to +/// [`SurfaceView`] images during display-list lowering and composition. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SurfaceSlotId(u64); + +impl SurfaceSlotId { + pub(crate) fn next() -> Self { + Self(NEXT_SURFACE_SLOT_ID.fetch_add(1, Ordering::Relaxed)) + } + + #[must_use] + pub fn get(self) -> u64 { + self.0 + } + + #[cfg(test)] + pub(crate) fn test_new(value: u64) -> Self { + Self(value) + } +} + +/// Backwards-compatible name for older compositor-surface APIs. +/// +/// New code should use [`SurfaceSlotId`], because the same slot identity is +/// used by compositor-surface producers and external surfaces. +pub type CompositorSurfaceId = SurfaceSlotId; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct SurfaceImageKey { + image_id: crate::effects::SurfaceImageId, + width: u32, + height: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RegisteredSurfaceImage { + pub slot_id: SurfaceSlotId, + pub source_size: Size, +} + +/// Window-local registry for compositor surface image brush payloads. +/// +/// Floem records surface images as Floem-owned image values first. During +/// display-list lowering the window state registers each `(image, resolved +/// size)` pair here and receives an `imaging::ExternalImage` id for +/// renderer-facing scene commands. Separate image handles can reference the +/// same producer surface without sharing source-size state. Composition +/// planning resolves those ids through this side table instead of relying on +/// bit patterns inside `ExternalImageId`. +#[derive(Debug)] +pub(crate) struct SurfaceImageRegistry { + next_id: u64, + by_key: FxHashMap, + by_id: FxHashMap, + by_registered_id: FxHashMap, +} + +impl Default for SurfaceImageRegistry { + fn default() -> Self { + Self { + next_id: 1, + by_key: FxHashMap::default(), + by_id: FxHashMap::default(), + by_registered_id: FxHashMap::default(), + } + } +} + +impl SurfaceImageRegistry { + pub(crate) fn register( + &mut self, + image: &crate::effects::SurfaceImage, + source_size: Size, + ) -> imaging::ExternalImage { + let key = SurfaceImageKey { + image_id: image.image_id, + width: source_size.width.ceil().max(1.0) as u32, + height: source_size.height.ceil().max(1.0) as u32, + }; + let id = if let Some(id) = self.by_key.get(&key).copied() { + id + } else { + let id = imaging::ExternalImageId(self.next_id); + self.next_id = self.next_id.saturating_add(1); + self.by_key.insert(key, id); + self.by_id.insert(id, key); + id + }; + self.by_registered_id.insert( + id, + RegisteredSurfaceImage { + slot_id: image.slot_id, + source_size, + }, + ); + imaging::ExternalImage::new( + id, + key.width, + key.height, + ImageAlphaType::AlphaPremultiplied, + ) + } + + pub(crate) fn resolve_registered( + &self, + image_id: imaging::ExternalImageId, + ) -> Option { + if let Some(registered) = self.by_registered_id.get(&image_id) { + return Some(*registered); + } + #[cfg(test)] + { + Some(RegisteredSurfaceImage { + slot_id: SurfaceSlotId(image_id.0), + source_size: Size::new(0.0, 0.0), + }) + } + #[cfg(not(test))] + { + None + } + } +} + +/// Paint-facing identity for compositor-produced image content. +/// +/// `SurfaceView` is the consumer side of the API. It gives the view +/// tree a stable submitted-content identity, but it does not render frames +/// itself. Use [`SurfaceView::image`] to create the intrinsic Floem image +/// payload that an `ImageBrush` paints. Use [`crate::effects::Image::view`] or +/// [`crate::effects::SurfaceImage::view`] when a placement needs an explicit +/// image-view source size. +/// +/// The same surface can be viewed through more than one image handle. Each +/// [`SurfaceView::image`] call creates a distinct image handle; clones of that +/// handle preserve identity. +/// At composition time each placement may either be promoted to a compositor +/// layer or sampled by the renderer, depending on the surrounding display-list +/// state. +/// +/// A view can come from either a [`CompositorSurfaceProducer`] or an +/// [`crate::external_surface::ExternalSurface`]. The producer/external handle +/// owns frame production and presentation policy; `SurfaceView` only describes +/// how that submitted content is used as an image in paint. +#[derive(Clone, Copy, Debug)] +pub struct SurfaceView { + slot_id: SurfaceSlotId, + window_id: WindowId, + intrinsic_size: Size, +} + +impl SurfaceView { + #[must_use] + pub(crate) fn new(window_id: WindowId, slot_id: SurfaceSlotId, intrinsic_size: Size) -> Self { + Self { + slot_id, + window_id, + intrinsic_size, + } + } + + #[must_use] + pub fn slot_id(&self) -> SurfaceSlotId { + self.slot_id + } + + #[must_use] + pub fn id(&self) -> SurfaceSlotId { + self.slot_id + } + + /// Creates a Floem image handle for this surface. + /// + /// The returned image has the view's intrinsic source size. Use paint + /// transforms or brush transforms to place/sample it differently. + #[must_use] + pub fn image(&self) -> crate::effects::SurfaceImage { + crate::effects::SurfaceImage::new(self.slot_id, self.intrinsic_size) + } + + #[must_use] + pub fn window_id(&self) -> WindowId { + self.window_id + } + + #[must_use] + pub fn handle(&self) -> CompositorSurfaceHandle { + CompositorSurfaceHandle { + id: self.slot_id, + window_id: self.window_id, + } + } +} + +/// Configuration for a wgpu-backed [`CompositorSurfaceProducer`]. +#[derive(Clone, Debug)] +pub struct CompositorSurfaceProducerConfig { + /// Subduction target configuration for leased wgpu frame targets. + /// + /// This controls target allocation policy such as maximum frame latency and + /// backend format preferences. It does not control where the produced image + /// is placed in the Floem scene. + pub surface: subduction::wgpu::ExternalSurfaceConfig, + /// Alpha interpretation for completed frames when they are published or + /// sampled. + pub alpha_mode: CompositorSurfaceAlphaMode, + /// Frame-rate preference for producer callbacks. + /// + /// This is a pacing preference for producer frame opportunities. + /// + /// Fixed-rate displays round capped values down to stable divisors of the + /// refresh rate. For example, 60 fps on fixed 75 Hz becomes 37.5 fps; 60 fps + /// on fixed 120 Hz becomes 60 fps. Variable-refresh displays may use an + /// in-range request directly, so 60 fps on a 48-75 Hz VRR display can remain + /// 60 fps. Out-of-range requests fall back to the nearest supported + /// display-friendly cadence. + /// + /// The actual visible rate can still be lower when frame work is skipped, + /// coalesced, waiting on a transaction-presented dependent layer, or waiting + /// on compositor/GPU completion. + pub frame_rate: FrameRatePreference, +} + +impl Default for CompositorSurfaceProducerConfig { + fn default() -> Self { + Self { + surface: subduction::wgpu::ExternalSurfaceConfig::default(), + alpha_mode: CompositorSurfaceAlphaMode::Premultiplied, + frame_rate: FrameRatePreference::full(), + } + } +} + +/// Producer-side frame source for a [`SurfaceView`]. +/// +/// A producer supplies rendered frames for one [`SurfaceView`] +/// identity that Floem owns and places in the scene. Create one with +/// [`CompositorSurfaceProducer::new`]: the returned +/// [`SurfaceView`] is painted by the view tree, while the producer +/// receives frame opportunities and leases wgpu targets for the renderer. +/// Multiple `ImageBrush` placements can reference that same image identity; +/// they do not create separate producers. +/// +/// The producer is not a view and does not affect paint order directly. Keep it +/// with the renderer or state object that can service frame callbacks. +/// +/// The producer callback receives frame opportunities when the current +/// compositor plan needs new content for that image identity. A typical +/// callback reads [`CompositorSurfaceFrameCx::frame_time`], acquires a +/// Subduction-managed wgpu target with +/// [`CompositorSurfaceFrameCx::acquire_target`], renders on the caller's worker +/// or queue, sends the resulting +/// [`subduction::wgpu::SurfaceFrameCompletion`] through +/// [`CompositorSurfaceFrameCx::completion_sender`], and returns. Transaction +/// behavior is configured on the producer with +/// [`CompositorSurfaceProducer::presents_with_transaction`] and applies until +/// changed again. +/// +/// Use this when Floem owns placement and fallback behavior, but another +/// renderer owns the contents. Use [`crate::external_surface::ExternalSurface`] +/// when the producer owns already-compositable content and unsupported +/// submissions should be rejected synchronously. +#[derive(Clone)] +pub struct CompositorSurfaceProducer { + view: SurfaceView, + handle: CompositorSurfaceHandle, + config: CompositorSurfaceProducerConfig, + target_size: Arc>, + callback: Arc>>, + completions: Arc>>, + completion_tx: mpsc::Sender, + in_flight: Arc>, +} + +impl std::fmt::Debug for CompositorSurfaceProducer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CompositorSurfaceProducer") + .field("id", &self.handle.id()) + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +type CompositorSurfaceProducerCallback = Box< + dyn FnMut(&mut CompositorSurfaceFrameCx) -> Result<(), subduction::wgpu::SurfaceFrameError> + + Send, +>; + +impl CompositorSurfaceProducer { + /// Creates a producer that renders into a fixed physical-pixel wgpu target. + /// + /// `size` is the producer's target texture size in physical pixels, not a + /// logical paint size. Floem leases wgpu targets at this exact pixel size; + /// moving the window between displays with different scale factors does not + /// change the producer's allocation size. + /// + /// Paint code gets the lightweight [`SurfaceView`] with [`Self::view`] and + /// then creates one or more [`SurfaceView::image`] handles. + /// + /// This separation is intentional: producer size is physical + /// allocation/rendering state, while image size is logical + /// brush/source-coordinate state. + #[must_use] + pub fn new(window_id: WindowId, size: Size, config: CompositorSurfaceProducerConfig) -> Self { + let view = SurfaceView::new(window_id, SurfaceSlotId::next(), size); + view.handle().set_frame_rate_preference(config.frame_rate); + let producer = Self::from_view(view, size, config); + producer + .handle + .set_provider(Arc::new(Mutex::new(producer.provider()))); + producer + } + + fn from_view( + view: SurfaceView, + target_size: Size, + config: CompositorSurfaceProducerConfig, + ) -> Self { + let (completion_tx, completion_rx) = mpsc::channel(); + Self { + view, + handle: view.handle(), + config, + target_size: Arc::new(Mutex::new(target_size)), + callback: Arc::new(Mutex::new(None)), + completions: Arc::new(Mutex::new(completion_rx)), + completion_tx, + in_flight: Arc::new(Mutex::new(0)), + } + } + + #[must_use] + pub fn view(&self) -> SurfaceView { + self.view + } + + /// Creates a Floem image handle for this producer's submitted surface. + /// + /// The image's intrinsic source size is the producer's current physical + /// target size. Use paint transforms or brush transforms to place/sample it + /// differently. + #[must_use] + pub fn image(&self) -> crate::effects::SurfaceImage { + crate::effects::SurfaceImage::new(self.view.slot_id, self.target_size()) + } + + /// Returns the producer's current physical-pixel target size. + #[must_use] + pub fn target_size(&self) -> Size { + *self.target_size.lock().unwrap() + } + + /// Sets the physical-pixel target size used for future frame opportunities. + /// + /// This changes the wgpu target size Floem leases for the producer. It does + /// not change existing [`SurfaceView::image`] handles or their logical + /// image-view source sizes. + pub fn set_target_size(&self, size: Size) { + *self.target_size.lock().unwrap() = size; + self.handle.request_frame(); + } + + #[must_use] + pub fn slot_id(&self) -> SurfaceSlotId { + self.handle.id() + } + + #[must_use] + pub fn surface_id(&self) -> SurfaceSlotId { + self.slot_id() + } + + /// Installs the frame callback for this producer and requests a frame. + /// + /// Floem invokes the callback when the associated image participates in a + /// compositor plan that needs producer work. The callback may skip the + /// opportunity, or it may acquire one target and complete it + /// asynchronously. Returning from the callback does not mean rendering is + /// complete; completion is reported through + /// [`CompositorSurfaceFrameCx::completion_sender`]. + pub fn set_frame_callback( + &self, + callback: impl FnMut( + &mut CompositorSurfaceFrameCx, + ) -> Result<(), subduction::wgpu::SurfaceFrameError> + + Send + + 'static, + ) { + *self.callback.lock().unwrap() = Some(Box::new(callback)); + self.handle.request_frame(); + } + + /// Sets the frame-rate preference for producer callbacks and diagnostics. + pub fn set_frame_rate_preference(&self, frame_rate: FrameRatePreference) { + self.handle.set_frame_rate_preference(frame_rate); + } + + /// Sets whether completed frames publish with the window compositor transaction. + /// + /// The default is `true`. When enabled, newly completed frames publish + /// atomically with the rest of the window layer tree. This is the correct + /// mode when other Floem layers sample the produced image, when resize + /// synchronization matters, or when independent publication would let layers + /// visibly update out of phase. + /// + /// Passing `false` allows completed frames to publish outside the window + /// transaction. Use that only for independent compositor content whose + /// updates are allowed to race ahead of normal scene commits. + /// + /// The setting is persistent surface state. It applies to future + /// completions until changed again. + pub fn presents_with_transaction(&self, presents_with_transaction: bool) { + self.handle + .presents_with_transaction(presents_with_transaction); + } + + fn provider(&self) -> CompositorSurfaceProducerProvider { + CompositorSurfaceProducerProvider { + handle: self.handle.clone(), + config: self.config.clone(), + callback: self.callback.clone(), + completions: self.completions.clone(), + completion_tx: self.completion_tx.clone(), + in_flight: self.in_flight.clone(), + target_size: self.target_size.clone(), + current_content: None, + pending_request_started_at: FxHashMap::default(), + last_requested_frame_index: None, + } + } +} + +/// Per-frame context passed to a [`CompositorSurfaceProducer`] callback. +/// +/// Each context represents one frame opportunity for one image placement group. +/// The callback can acquire at most one wgpu target. If it acquires a target, +/// it is responsible for sending exactly one +/// [`subduction::wgpu::SurfaceFrameCompletion`] through +/// [`Self::completion_sender`] when rendering finishes. +/// +/// Presentation policy is persistent surface state. Producers present with the +/// window compositor transaction by default. Call +/// [`CompositorSurfaceProducer::presents_with_transaction`] to change that +/// policy until it is changed again. +pub struct CompositorSurfaceFrameCx { + frame_time: crate::frame::FrameTime, + is_window_resize: bool, + opportunity: subduction::wgpu::SurfaceFrameOpportunity, + config: CompositorSurfaceProducerConfig, + completion_tx: mpsc::Sender, + in_flight: Arc>, + target: Option, + acquired_target: bool, + decision: SurfaceFrameCallbackDecision, +} + +impl CompositorSurfaceFrameCx { + #[must_use] + pub fn frame_time(&self) -> crate::frame::FrameTime { + self.frame_time + } + + /// Returns true when this frame opportunity was produced in response to a + /// window resize. + /// + /// Producers can use this to prioritize resize-correct content over normal + /// animation cadence, or to choose lower-latency work during live resize. + #[must_use] + pub fn is_window_resize(&self) -> bool { + self.is_window_resize + } + + #[must_use] + pub fn opportunity(&self) -> subduction::wgpu::SurfaceFrameOpportunity { + self.opportunity + } + + #[must_use] + pub fn config(&self) -> &CompositorSurfaceProducerConfig { + &self.config + } + + /// Returns the channel used to deliver asynchronous frame completions. + /// + /// The callback normally clones this sender, moves the acquired target to + /// a render task, then sends the task's completion when rendering finishes. + #[must_use] + pub fn completion_sender(&self) -> mpsc::Sender { + self.completion_tx.clone() + } + + /// Acquires the Subduction-managed wgpu target for this frame opportunity. + /// + /// This is non-blocking. It returns [`SurfaceFrameError::NoTargetAvailable`] + /// when the producer already has the configured number of frames in flight, + /// and [`SurfaceFrameError::Unsupported`] when no target can be provided. + pub fn acquire_target( + &mut self, + ) -> Result { + if self.target.is_none() { + return Err(subduction::wgpu::SurfaceFrameError::Unsupported); + } + let max_latency = self.config.surface.max_frame_latency.max(1); + { + let mut in_flight = self.in_flight.lock().unwrap(); + if *in_flight >= max_latency { + return Err(subduction::wgpu::SurfaceFrameError::NoTargetAvailable); + } + *in_flight += 1; + } + if let Some(target) = self.target.take() { + self.acquired_target = true; + return Ok(target); + } + unreachable!("target existence was checked before in-flight reservation") + } + + /// Marks this opportunity as skipped. + pub fn skip(&mut self, reason: subduction::wgpu::SurfaceSkipReason) { + self.decision = SurfaceFrameCallbackDecision::Skip(reason); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SurfaceFrameCallbackDecision { + None, + Skip(subduction::wgpu::SurfaceSkipReason), +} + +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug)] +pub struct SubductionFrameTick { + pub received_at: Instant, + pub frame_index: u64, + pub refresh_interval: Option, + pub predicted_present: Option, + pub display_timing: Option, +} + +/// Preferred storage/presentation class for compositor surface content. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompositorSurfaceKind { + /// Native platform texture or layer-like content, when available. + NativeTexture, + /// Subduction-managed wgpu texture content. + WgpuTexture, + /// CPU image fallback content. + CpuImageFallback, +} + +/// Alpha mode for compositor surface content. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompositorSurfaceAlphaMode { + Opaque, + Premultiplied, + Unpremultiplied, +} + +/// Configuration for a compositor-owned surface slot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompositorSurfaceConfig { + pub kind: CompositorSurfaceKind, + pub alpha_mode: CompositorSurfaceAlphaMode, + /// Frame-rate preference for this compositor surface. + /// + /// This is interpreted the same way as + /// [`CompositorSurfaceProducer::set_frame_rate_preference`]. + pub frame_rate: FrameRatePreference, +} + +impl CompositorSurfaceConfig { + #[must_use] + pub fn texture() -> Self { + Self { + kind: CompositorSurfaceKind::WgpuTexture, + alpha_mode: CompositorSurfaceAlphaMode::Premultiplied, + frame_rate: FrameRatePreference::full(), + } + } + + #[must_use] + pub fn video() -> Self { + Self { + kind: CompositorSurfaceKind::NativeTexture, + alpha_mode: CompositorSurfaceAlphaMode::Opaque, + frame_rate: FrameRatePreference::full(), + } + } +} + +impl Default for CompositorSurfaceConfig { + fn default() -> Self { + Self::texture() + } +} + +/// Opaque texture-like payload submitted to a compositor surface. +/// +/// The payload is intentionally type-erased so producer/backends can pass +/// backend-specific handles without making Floem platform-specific. Direct +/// external surfaces validate this payload synchronously before accepting it. +#[derive(Clone, Debug)] +pub struct ExternalTexture { + pub size: Size, + pub payload: Arc, +} + +impl ExternalTexture { + #[must_use] + pub fn new(size: Size, payload: impl Any + Send + Sync) -> Self { + Self { + size, + payload: Arc::new(payload), + } + } + + #[must_use] + pub fn from_submitted_frame(frame: subduction::wgpu::SubmittedSurfaceFrame) -> Self { + let size = Size::new(f64::from(frame.size.width), f64::from(frame.size.height)); + Self::new(size, frame) + } +} + +/// Current content for a compositor-owned surface slot. +#[derive(Clone, Debug)] +pub enum CompositorSurfaceContent { + Empty, + Texture(ExternalTexture), + /// Opaque platform layer. Floem only stores and orders it; Subduction owns + /// platform-specific attachment. + NativeLayer(subduction::NativeLayer), + Image(ImageData), + Subduction(Arc), +} + +pub type CompositorSurfaceProviderHandle = Arc>; + +/// Producer interface used by Floem to request and poll compositor content. +/// +/// Providers should avoid blocking the UI thread. If a frame cannot be +/// produced immediately, return a deferred update and complete through the +/// configured completion channel. +pub trait CompositorSurfaceProvider { + fn can_accept_frame_target(&self) -> bool { + true + } + + /// Returns the physical-pixel wgpu target size to lease for a requested + /// logical source size. + /// + /// The default scales the requested logical source size by the window's + /// effective scale. Producer-backed compositor surfaces override this with + /// the fixed physical texture size passed to + /// [`CompositorSurfaceProducer::new`]. + fn target_size(&self, requested_source_size: Size, effective_scale: f64) -> Size { + requested_source_size * effective_scale + } + + fn poll_current_content(&mut self) -> CompositorSurfaceFrameUpdate { + CompositorSurfaceFrameUpdate::idle() + } + + fn update_current_content( + &mut self, + args: CompositorSurfaceFrameArgs, + ) -> CompositorSurfaceFrameUpdate; + + fn current_content(&self) -> Option; + + fn release_current_content(&mut self, outcome: CompositorSurfaceOutcome); +} + +struct CompositorSurfaceProducerProvider { + handle: CompositorSurfaceHandle, + config: CompositorSurfaceProducerConfig, + callback: Arc>>, + completions: Arc>>, + completion_tx: mpsc::Sender, + in_flight: Arc>, + target_size: Arc>, + current_content: Option, + pending_request_started_at: FxHashMap, + last_requested_frame_index: Option, +} + +impl CompositorSurfaceProvider for CompositorSurfaceProducerProvider { + fn target_size(&self, _requested_source_size: Size, _effective_scale: f64) -> Size { + *self.target_size.lock().unwrap() + } + + fn can_accept_frame_target(&self) -> bool { + if self.callback.lock().unwrap().is_none() { + return false; + } + let max_latency = self.config.surface.max_frame_latency.max(1); + let in_flight = self.in_flight.lock().unwrap(); + *in_flight < max_latency + } + + fn poll_current_content(&mut self) -> CompositorSurfaceFrameUpdate { + self.drain_completions() + } + + fn update_current_content( + &mut self, + args: CompositorSurfaceFrameArgs, + ) -> CompositorSurfaceFrameUpdate { + let mut frame_update = self.drain_completions(); + let callback_started_at = Instant::now(); + let diag = crate::frame_source::frame_pacing_diag_enabled() + || std::env::var_os("FLOEM_CUBE_DIAG").is_some(); + + if !args.visible { + if diag { + crate::floem_debug_log!( + "floem compositor surface provider skip surface={:?} frame={} reason=not_visible", + args.surface_id, + args.frame_index, + ); + } + return CompositorSurfaceFrameUpdate { + content_changed: frame_update.content_changed, + request_next_frame: false, + producer_observed_latency: frame_update.producer_observed_latency, + }; + } + + if args.gpu_resources.is_none() { + if diag { + crate::floem_debug_log!( + "floem compositor surface provider skip surface={:?} frame={} reason=no_gpu_resources", + args.surface_id, + args.frame_index, + ); + } + return CompositorSurfaceFrameUpdate { + content_changed: frame_update.content_changed, + request_next_frame: true, + producer_observed_latency: frame_update.producer_observed_latency, + }; + } + + if self.last_requested_frame_index == Some(args.frame_index) { + if diag { + crate::floem_debug_log!( + "floem compositor surface provider skip surface={:?} frame={} reason=already_requested content_changed={}", + args.surface_id, + args.frame_index, + frame_update.content_changed, + ); + } + frame_update.request_next_frame = true; + return frame_update; + } + + if diag { + crate::floem_debug_log!( + "floem compositor surface provider opportunity surface={:?} frame={} rect={:?} size_px={:.1}x{:.1} has_target={}", + args.surface_id, + args.frame_index, + args.rect, + args.size_px.width, + args.size_px.height, + args.target.is_some(), + ); + } + + let opportunity = subduction::wgpu::SurfaceFrameOpportunity { + surface_id: subduction_core::layer::SurfaceId(args.surface_id.get() as u32), + frame_index: args.frame_index, + now: subduction_core::time::HostTime(0), + target_timestamp: None, + target_present: None, + previous_present: None, + refresh_interval: None, + confidence: subduction_core::timing::TimingConfidence::PacingOnly, + }; + let cx = CompositorSurfaceFrameCx { + frame_time: args.frame_time, + is_window_resize: args.is_window_resize, + opportunity, + config: self.config.clone(), + completion_tx: self.completion_tx.clone(), + in_flight: self.in_flight.clone(), + target: args.target, + acquired_target: false, + decision: SurfaceFrameCallbackDecision::None, + }; + + let mut cx = cx; + let callback_result = self + .callback + .lock() + .unwrap() + .as_mut() + .map(|callback| callback(&mut cx)); + let decision = match callback_result { + Some(Ok(())) => Some(Ok((cx.decision, cx.acquired_target))), + Some(Err(err)) => Some(Err(err)), + None => None, + }; + let request_next_frame = match decision { + Some(Ok((SurfaceFrameCallbackDecision::None, true))) => { + self.last_requested_frame_index = Some(args.frame_index); + self.pending_request_started_at + .insert(args.frame_index, callback_started_at); + if diag { + crate::floem_debug_log!( + "floem compositor surface provider decision surface={:?} frame={} decision=present", + args.surface_id, + args.frame_index, + ); + } + true + } + Some(Ok((SurfaceFrameCallbackDecision::Skip(reason), acquired_target))) => { + if acquired_target { + self.release_acquired_target(); + } + if diag { + crate::floem_debug_log!( + "floem compositor surface provider decision surface={:?} frame={} decision=skip reason={reason:?}", + args.surface_id, + args.frame_index, + ); + } + false + } + Some(Ok((SurfaceFrameCallbackDecision::None, false))) => { + if diag { + crate::floem_debug_log!( + "floem compositor surface provider decision surface={:?} frame={} decision=none", + args.surface_id, + args.frame_index, + ); + } + false + } + Some(Err(err)) => { + if cx.acquired_target { + self.release_acquired_target(); + } + if diag { + crate::floem_debug_log!( + "floem compositor surface provider decision surface={:?} frame={} decision=error error={err:?}", + args.surface_id, + args.frame_index, + ); + } + true + } + None => { + if diag { + crate::floem_debug_log!( + "floem compositor surface provider decision surface={:?} frame={} decision=no_callback", + args.surface_id, + args.frame_index, + ); + } + false + } + }; + + if request_next_frame { + self.handle.request_frame(); + } + + frame_update.request_next_frame |= request_next_frame; + frame_update + } + + fn current_content(&self) -> Option { + self.current_content.clone() + } + + fn release_current_content(&mut self, _outcome: CompositorSurfaceOutcome) {} +} + +impl CompositorSurfaceProducerProvider { + fn release_acquired_target(&self) { + let mut in_flight = self.in_flight.lock().unwrap(); + *in_flight = in_flight.saturating_sub(1); + } + + fn drain_completions(&mut self) -> CompositorSurfaceFrameUpdate { + let diag = crate::frame_source::frame_pacing_diag_enabled() + || std::env::var_os("FLOEM_CUBE_DIAG").is_some(); + let mut content_changed = false; + let mut max_observed_latency = None; + while let Ok(completion) = self.completions.lock().unwrap().try_recv() { + let mut in_flight = self.in_flight.lock().unwrap(); + *in_flight = in_flight.saturating_sub(1); + drop(in_flight); + match completion { + subduction::wgpu::SurfaceFrameCompletion::Submitted(frame) => { + let observed_latency = self + .pending_request_started_at + .remove(&frame.opportunity.frame_index) + .map(|started_at| Instant::now().saturating_duration_since(started_at)); + if diag { + crate::floem_debug_log!( + "floem compositor surface provider completion surface={:?} frame={} submitted size={}x{} resource_key={:?} observed_latency={:?}", + self.handle.id(), + frame.opportunity.frame_index, + frame.size.width, + frame.size.height, + frame.resource_key, + observed_latency, + ); + } + self.current_content = Some(CompositorSurfaceContent::Texture( + ExternalTexture::from_submitted_frame(frame), + )); + content_changed = true; + if let Some(observed_latency) = observed_latency { + max_observed_latency = Some( + max_observed_latency + .map(|latency: Duration| latency.max(observed_latency)) + .unwrap_or(observed_latency), + ); + } + } + subduction::wgpu::SurfaceFrameCompletion::Skipped(frame) => { + self.pending_request_started_at + .remove(&frame.opportunity.frame_index); + if diag { + crate::floem_debug_log!( + "floem compositor surface provider completion surface={:?} frame={} skipped reason={:?}", + self.handle.id(), + frame.opportunity.frame_index, + frame.reason, + ); + } + } + } + } + CompositorSurfaceFrameUpdate { + content_changed, + request_next_frame: false, + producer_observed_latency: max_observed_latency, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CompositorSurfaceFrameUpdate { + pub content_changed: bool, + pub request_next_frame: bool, + pub producer_observed_latency: Option, +} + +impl CompositorSurfaceFrameUpdate { + #[must_use] + pub fn idle() -> Self { + Self::default() + } + + #[must_use] + pub fn content_changed() -> Self { + Self { + content_changed: true, + request_next_frame: false, + producer_observed_latency: None, + } + } + + #[must_use] + pub fn request_next_frame() -> Self { + Self { + content_changed: false, + request_next_frame: true, + producer_observed_latency: None, + } + } + + #[must_use] + pub fn content_changed_and_request_next_frame() -> Self { + Self { + content_changed: true, + request_next_frame: true, + producer_observed_latency: None, + } + } +} + +#[derive(Debug)] +pub struct CompositorSurfaceFrameArgs { + pub surface_id: CompositorSurfaceId, + pub frame_index: u64, + pub frame_time: crate::frame::FrameTime, + pub interval: PresentationInterval, + pub is_window_resize: bool, + pub visible: bool, + pub rect: Rect, + pub size_px: Size, + pub gpu_resources: Option, + pub target: Option, + pub previous_outcome: Option, +} + +#[derive(Clone, Copy, Debug)] +pub struct CompositorSurfaceOutcome { + pub surface_id: CompositorSurfaceId, + pub frame_index: u64, + pub visible: bool, + pub outcome: FrameOutcome, +} + +#[derive(Clone, Debug)] +pub struct CompositorSurfaceHandle { + id: CompositorSurfaceId, + window_id: WindowId, +} + +impl CompositorSurfaceHandle { + #[must_use] + pub fn id(&self) -> CompositorSurfaceId { + self.id + } + + pub fn submit_texture(&self, texture: ExternalTexture) { + self.submit(CompositorSurfaceContent::Texture(texture)); + } + + pub fn submit_image(&self, image: ImageData) { + self.submit(CompositorSurfaceContent::Image(image)); + } + + pub fn submit_subduction_surface(&self, surface: impl Any + Send + Sync) { + self.submit(CompositorSurfaceContent::Subduction(Arc::new(surface))); + } + + pub fn submit_subduction_surface_arc(&self, surface: Arc) { + self.submit(CompositorSurfaceContent::Subduction(surface)); + } + + pub fn clear(&self) { + self.submit(CompositorSurfaceContent::Empty); + } + + pub fn request_frame(&self) { + Application::send_proxy_event(UserEvent::CompositorSurfaceRequestFrame { + window_id: self.window_id, + surface_id: self.id, + }); + } + + pub fn set_provider(&self, provider: CompositorSurfaceProviderHandle) { + Application::send_proxy_event(UserEvent::CompositorSurfaceProvider { + window_id: self.window_id, + surface_id: self.id, + provider, + }); + } + + pub fn set_frame_rate_preference(&self, frame_rate: FrameRatePreference) { + Application::send_proxy_event(UserEvent::CompositorSurfaceFrameRatePreference { + window_id: self.window_id, + surface_id: self.id, + frame_rate, + }); + } + + /// Sets whether submitted content publishes with the window compositor transaction. + /// + /// `true` is the default and keeps surface updates atomic with the window + /// layer tree. `false` allows future surface updates to publish + /// independently until changed again. + pub fn presents_with_transaction(&self, presents_with_transaction: bool) { + Application::send_proxy_event(UserEvent::CompositorSurfacePresentsWithTransaction { + window_id: self.window_id, + surface_id: self.id, + presents_with_transaction, + }); + } + + fn submit(&self, content: CompositorSurfaceContent) { + Application::send_proxy_event(UserEvent::CompositorSurfaceContent { + window_id: self.window_id, + surface_id: self.id, + content, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::effects::SurfaceImage; + + #[test] + fn surface_image_registry_tracks_image_identity_separately_from_surface() { + let surface_id = CompositorSurfaceId::test_new(7); + let first = SurfaceImage::new(surface_id, Size::new(100.0, 50.0)); + let first_clone = first.clone(); + let second = SurfaceImage::new(surface_id, Size::new(100.0, 50.0)); + let mut registry = SurfaceImageRegistry::default(); + + let first_external = registry.register(&first, Size::new(100.0, 50.0)); + let first_clone_external = registry.register(&first_clone, Size::new(100.0, 50.0)); + let second_external = registry.register(&second, Size::new(100.0, 50.0)); + + assert_eq!(first_external.id, first_clone_external.id); + assert_ne!(first_external.id, second_external.id); + assert_eq!((first_external.width, first_external.height), (200, 100)); + assert_eq!( + registry + .resolve_registered(first_external.id) + .unwrap() + .slot_id, + surface_id + ); + assert_eq!( + registry + .resolve_registered(second_external.id) + .unwrap() + .slot_id, + surface_id + ); + } +} diff --git a/src/context.rs b/src/context.rs index 95e231023..a919033e9 100644 --- a/src/context.rs +++ b/src/context.rs @@ -3,11 +3,9 @@ use smallvec::SmallVec; use std::{cell::RefCell, rc::Rc}; use crate::{ - ElementId, custom_event, + custom_event, event::{EventPropagation, Phase}, platform::menu::Menu, - style::recalc::StyleReason, - view::ViewId, }; pub type EventCallback = dyn FnMut(&mut EventCx) -> EventPropagation; @@ -145,7 +143,7 @@ pub type EventListenerVec = SmallVec<[(Rc>, EventCallback /// interacting with platform APIs that expect window coordinates. /// /// Use `box_local()` and `content_box_local()` to get coordinates in the view's local space. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct LayoutChanged { /// The new layout box relative to the parent layout pub new_box: Rect, @@ -268,19 +266,12 @@ impl VisualChanged { pub(crate) type CleanupListeners = Vec>; -pub(crate) enum FrameUpdate { - Style(ElementId, StyleReason), - Layout, - BoxTreeCommit, - Paint(ViewId), -} - // Re-export EventCx from event module for backward compatibility pub use crate::event::EventCx; // Re-export style context types from style module for backward compatibility pub use crate::style::{InteractionState, StyleCx}; // Re-export paint context types from paint module for backward compatibility -pub use crate::paint::{PaintCx, PaintState}; +pub use crate::paint::PaintCx; // Re-export update context types from message module for backward compatibility pub use crate::message::UpdateCx; diff --git a/src/effects.rs b/src/effects.rs new file mode 100644 index 000000000..e547b8538 --- /dev/null +++ b/src/effects.rs @@ -0,0 +1,1502 @@ +//! Compositor-aware filters, shaders, and composites. +//! +//! Floem uses Imaging's generic group API with [`Filter`] and +//! [`Composite`] payloads. Standard Imaging filters/composites keep flowing to +//! renderers. Floem-only shader filters and sources are consumed while lowering +//! the display list and executed as compositor render passes when needed. + +use std::{ + fmt::Debug, + ops::{Deref, DerefMut}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; + +use floem_reactive::UpdaterEffect; +use imaging::{ + Composite as ImagingComposite, Filter as ImagingFilter, GroupRef as ImagingGroupRef, + record::Clip, +}; +use peniko::{ + ImageData, + kurbo::{Affine, Size}, +}; +use subduction::wgpu::SurfaceColorSpace; + +use crate::{ + Application, + animate::easing::{Bezier, Easing, Linear, Spring}, + app::UserEvent, + compositor_surface::SurfaceSlotId, + gradient::Gradient, + platform::{Duration, Instant}, +}; + +/// A Floem group filter. +/// +/// Standard Imaging filters are forwarded to renderers unchanged. Floem-only filters are consumed +/// during Floem's compositor lowering step and are not exposed to renderer backends that do not +/// understand them. +#[derive(Clone, Debug, PartialEq)] +pub enum Filter { + /// A renderer-supported Imaging filter. + Imaging(ImagingFilter), + /// A color-only shader that transforms the current pixel color. + Color(ColorFilter), + /// A shader that can sample an isolated input layer. + Layer(LayerFilter), +} + +/// An executable compositor shader pass. +#[derive(Clone, Debug, PartialEq)] +pub enum CompositorShader { + /// A shader pass that transforms the current pixel color. + Color(ColorFilter), + /// A shader pass that samples the previous pass output. + Layer(LayerFilter), + /// A shader pass that fills a target without sampling previous output. + Source(ShaderSource), +} + +/// A compositor shader pass plus the isolated group clip that constrains its output. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct CompositorShaderPass { + pub shader: CompositorShader, + pub clip: Option, + pub position_transform: Affine, +} + +impl From for Filter { + fn from(filter: ImagingFilter) -> Self { + Self::Imaging(filter) + } +} + +impl From for Filter { + fn from(filter: ColorFilter) -> Self { + Self::Color(filter) + } +} + +impl From for Filter { + fn from(filter: LayerFilter) -> Self { + Self::Layer(filter) + } +} + +/// Image payload accepted by Floem image brushes. +#[derive(Clone, Debug, PartialEq)] +pub enum Image { + /// Raster image content. + Raster(RasterImage), + /// Retained scene image content. + Scene(SceneImage), + /// Compositor/external surface content lowered to an Imaging external image + /// during display-list processing. + Surface(SurfaceImage), + /// Compositor-generated image content. + Source(ShaderSourceImage), +} + +impl Image { + /// Borrow this image payload. + #[must_use] + pub fn as_ref(&self) -> ImageRef<'_> { + match self { + Self::Raster(image) => ImageRef::Raster(image), + Self::Scene(image) => ImageRef::Scene(image), + Self::Surface(image) => ImageRef::Surface(image), + Self::Source(source) => ImageRef::Source(source), + } + } + + #[must_use] + pub fn intrinsic_size(&self) -> Size { + match self { + Self::Raster(image) => image.intrinsic_size, + Self::Scene(image) => image.intrinsic_size, + Self::Surface(surface) => surface.intrinsic_size, + Self::Source(source) => source.intrinsic_size, + } + } +} + +impl TryFrom for Image { + type Error = imaging::ExternalImage; + + fn try_from(image: imaging::Image) -> Result { + match image { + imaging::Image::Raster(image) => Ok(Self::Raster(image.into())), + imaging::Image::Scene(image) => Ok(Self::Scene(image.into())), + imaging::Image::External(image) => Err(image), + } + } +} + +impl From for Image { + fn from(image: ImageData) -> Self { + Self::Raster(image.into()) + } +} + +impl From for Image { + fn from(image: imaging::SceneImage) -> Self { + Self::Scene(image.into()) + } +} + +impl From for Image { + fn from(image: SurfaceImage) -> Self { + Self::Surface(image) + } +} + +impl From for Image { + fn from(image: ShaderSourceImage) -> Self { + Self::Source(image) + } +} + +/// Floem brush payload. +/// +/// This is Imaging's generic brush container fully specialized for Floem image +/// payloads and Peniko gradients. Renderer-backed images and +/// compositor-generated shader sources share the same brush path until the +/// display-list recorder lowers source images into compositor passes. +pub type Brush = imaging::Brush; + +/// Alpha helpers for Floem's concrete brush specialization. +pub trait BrushAlphaExt { + /// Return the brush with the alpha component set to `alpha`. + #[must_use] + fn with_alpha(self, alpha: f32) -> Self; + + /// Return the brush with its alpha component multiplied by `alpha`. + #[must_use] + #[track_caller] + fn multiply_alpha(self, alpha: f32) -> Self; +} + +impl BrushAlphaExt for Brush { + fn with_alpha(self, alpha: f32) -> Self { + match self { + Self::Solid(color) => Self::Solid(color.with_alpha(alpha)), + Self::Gradient(gradient) => Self::Gradient(gradient.with_alpha(alpha)), + Self::Image(image) => Self::Image(image.with_alpha(alpha)), + } + } + + fn multiply_alpha(self, alpha: f32) -> Self { + debug_assert!( + alpha.is_finite() && alpha >= 0.0, + "A non-finite or negative alpha ({alpha}) is meaningless." + ); + if alpha == 1.0 { + self + } else { + match self { + Self::Solid(color) => Self::Solid(color.multiply_alpha(alpha)), + Self::Gradient(gradient) => Self::Gradient(gradient.multiply_alpha(alpha)), + Self::Image(image) => Self::Image(image.multiply_alpha(alpha)), + } + } + } +} + +/// Borrowed Floem image payload. +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum ImageRef<'a> { + /// Borrowed raster image content. + Raster(&'a RasterImage), + /// Borrowed retained scene image content. + Scene(&'a SceneImage), + /// Borrowed compositor/external surface image content. + Surface(&'a SurfaceImage), + /// Borrowed compositor-generated image content. + Source(&'a ShaderSourceImage), +} + +impl ImageRef<'_> { + #[must_use] + pub fn to_owned(&self) -> Image { + match self { + Self::Raster(image) => Image::Raster((*image).clone()), + Self::Scene(image) => Image::Scene((*image).clone()), + Self::Surface(image) => Image::Surface((*image).clone()), + Self::Source(source) => Image::Source((*source).clone()), + } + } +} + +/// Floem-owned image brush. +/// +/// This mirrors [`peniko::ImageBrush`] and Imaging's image brush wrapper: the +/// generic `D` is the image payload, while Peniko owns the sampler, extend +/// modes, quality hint, and alpha multiplier. Use [`Image`] for owned Floem +/// image content and [`ImageRef`] for borrowed content. +#[derive(Clone, Debug, PartialEq)] +pub struct ImageBrush { + /// Peniko image brush payload and sampler. + pub brush: peniko::ImageBrush, +} + +impl Deref for ImageBrush { + type Target = peniko::ImageBrush; + + fn deref(&self) -> &Self::Target { + &self.brush + } +} + +impl DerefMut for ImageBrush { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.brush + } +} + +impl ImageBrush { + /// Builder method for setting the image extend mode in both directions. + #[must_use] + pub fn with_extend(mut self, mode: peniko::Extend) -> Self { + self.brush = self.brush.with_extend(mode); + self + } + + /// Builder method for setting the image extend mode in the horizontal direction. + #[must_use] + pub fn with_x_extend(mut self, mode: peniko::Extend) -> Self { + self.brush = self.brush.with_x_extend(mode); + self + } + + /// Builder method for setting the image extend mode in the vertical direction. + #[must_use] + pub fn with_y_extend(mut self, mode: peniko::Extend) -> Self { + self.brush = self.brush.with_y_extend(mode); + self + } + + /// Builder method for setting the desired image quality hint. + #[must_use] + pub fn with_quality(mut self, quality: peniko::ImageQuality) -> Self { + self.brush = self.brush.with_quality(quality); + self + } + + /// Return the image with the alpha multiplier set to `alpha`. + #[must_use] + #[track_caller] + pub fn with_alpha(mut self, alpha: f32) -> Self { + self.brush = self.brush.with_alpha(alpha); + self + } + + /// Return the image with its alpha multiplier multiplied by `alpha`. + #[must_use] + #[track_caller] + pub fn multiply_alpha(mut self, alpha: f32) -> Self { + self.brush = self.brush.multiply_alpha(alpha); + self + } +} + +impl ImageBrush { + /// Create a new image brush with default sampling. + #[must_use] + pub fn new(image: impl Into) -> Self { + Self { + brush: peniko::ImageBrush { + image: image.into(), + sampler: peniko::ImageSampler::default(), + }, + } + } + + /// Borrow this image brush. + #[must_use] + pub fn as_ref(&self) -> ImageBrushRef<'_> { + ImageBrush { + brush: peniko::ImageBrush { + image: self.image.as_ref(), + sampler: self.sampler, + }, + } + } +} + +impl From for ImageBrush { + fn from(image: Image) -> Self { + Self::new(image) + } +} + +impl From for ImageBrush { + fn from(image: imaging::ImageBrush) -> Self { + let image_payload = match image.image.clone() { + imaging::Image::Raster(image) => Image::Raster(image.into()), + imaging::Image::Scene(image) => Image::Scene(image.into()), + imaging::Image::External(_) => { + panic!("imaging::ExternalImage cannot be stored in a Floem image brush") + } + }; + Self { + brush: peniko::ImageBrush { + image: image_payload, + sampler: image.sampler, + }, + } + } +} + +impl From> for ImageBrush { + fn from(image: imaging::ImageBrushRef<'_>) -> Self { + let image_payload = match image.image { + imaging::ImageRef::Raster(image) => Image::Raster(image.clone().into()), + imaging::ImageRef::Scene(image) => Image::Scene(image.clone().into()), + imaging::ImageRef::External(_) => { + panic!("imaging::ExternalImage cannot be stored in a Floem image brush") + } + }; + Self { + brush: peniko::ImageBrush { + image: image_payload, + sampler: image.sampler, + }, + } + } +} + +impl From for ImageBrush { + fn from(image: ShaderSourceImage) -> Self { + Self::new(image) + } +} + +impl From for ImageBrush { + fn from(image: SurfaceImage) -> Self { + Self::new(image) + } +} + +/// Borrowed Floem image brush. +pub type ImageBrushRef<'a> = ImageBrush>; + +impl From> for ImageBrush { + fn from(value: ImageBrushRef<'_>) -> Self { + Self { + brush: peniko::ImageBrush { + image: value.image.to_owned(), + sampler: value.sampler, + }, + } + } +} + +impl From for Brush { + fn from(value: ImageBrush) -> Self { + Self::Image(value) + } +} + +impl From for Brush { + fn from(value: Image) -> Self { + Self::Image(ImageBrush::from(value)) + } +} + +impl From for Brush { + fn from(value: SurfaceImage) -> Self { + Self::Image(ImageBrush::from(value)) + } +} + +impl From for Brush { + fn from(value: ShaderSourceImage) -> Self { + Self::Image(ImageBrush::from(value)) + } +} + +impl From<&ImageBrush> for Brush { + fn from(value: &ImageBrush) -> Self { + Self::Image(value.as_ref().into()) + } +} + +impl From for Brush { + fn from(value: Gradient) -> Self { + Self::Gradient(value) + } +} + +/// Raster image content with its intrinsic source size. +#[derive(Clone, Debug, PartialEq)] +pub struct RasterImage { + pub image: ImageData, + pub intrinsic_size: Size, +} + +impl RasterImage { + #[must_use] + pub fn new(image: ImageData) -> Self { + let intrinsic_size = Size::new(f64::from(image.width), f64::from(image.height)); + Self { + image, + intrinsic_size, + } + } +} + +impl From for RasterImage { + fn from(image: ImageData) -> Self { + Self::new(image) + } +} + +/// Retained scene image content with its intrinsic source size. +#[derive(Clone, Debug, PartialEq)] +pub struct SceneImage { + pub image: imaging::SceneImage, + pub intrinsic_size: Size, +} + +impl SceneImage { + #[must_use] + pub fn new(image: imaging::SceneImage) -> Self { + let intrinsic_size = Size::new(f64::from(image.width()), f64::from(image.height())); + Self { + image, + intrinsic_size, + } + } +} + +impl From for SceneImage { + fn from(image: imaging::SceneImage) -> Self { + Self::new(image) + } +} + +/// Shader source image content with its intrinsic source size. +/// +/// Shader sources are shader programs, not image brushes. Create one of these +/// explicit image payloads with [`ShaderSource::image`] before using a source +/// as an [`ImageBrush`] payload. +#[derive(Clone, Debug, PartialEq)] +pub struct ShaderSourceImage { + pub source: ShaderSource, + pub intrinsic_size: Size, +} + +/// Floem image identity for compositor/external surface content. +/// +/// Surface images stay as Floem image payloads in paint commands. They are +/// converted to [`imaging::ExternalImage`] only when Floem lowers the display +/// list for renderer/compositor consumption. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SurfaceImage { + /// Stable image-placement identity. Multiple image handles can reference + /// the same producer surface with independent source sizing. + pub(crate) image_id: SurfaceImageId, + /// Stable slot identity used to resolve submitted surface content. + pub slot_id: SurfaceSlotId, + /// Intrinsic source size for brush sampling. + pub intrinsic_size: Size, +} + +impl SurfaceImage { + /// Create a new surface image identity with a concrete intrinsic source size. + #[must_use] + pub fn new(slot_id: SurfaceSlotId, intrinsic_size: Size) -> Self { + Self { + image_id: SurfaceImageId::next(), + slot_id, + intrinsic_size, + } + } +} + +static NEXT_SURFACE_IMAGE_ID: AtomicU64 = AtomicU64::new(1); + +/// Internal stable identity for one Floem image handle backed by a compositor surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct SurfaceImageId(u64); + +impl SurfaceImageId { + fn next() -> Self { + Self(NEXT_SURFACE_IMAGE_ID.fetch_add(1, Ordering::Relaxed)) + } +} + +/// A Floem group composite operation. +/// +/// This currently wraps Imaging's composite type and leaves room for compositor-only shader +/// compositing without changing renderer-facing Imaging APIs. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Composite { + /// A renderer-supported Imaging composite operation. + Imaging(ImagingComposite), + /// A compositor-only shader composite. + Shader(ShaderComposite), +} + +impl Default for Composite { + fn default() -> Self { + Self::Imaging(ImagingComposite::default()) + } +} + +impl From for Composite { + fn from(composite: ImagingComposite) -> Self { + Self::Imaging(composite) + } +} + +impl From for Composite { + fn from(effect: ShaderComposite) -> Self { + Self::Shader(effect) + } +} + +/// Group reference used by Floem's painter. +/// +/// Filters are applied in slice order. Floem may insert intermediate render +/// passes so each shader observes the output of the previous filter/effect. +pub type GroupRef<'a> = ImagingGroupRef<'a, Filter, Composite>; + +/// Returns an empty Floem shader group. +/// +/// Start from this when using `Painter::with_group` with Floem-specific +/// shader filters or sources: +/// +/// ```ignore +/// let filters = [filter.into(), imaging::Filter::blur(5.0).into()]; +/// painter.with_group(group_ref().with_filters(&filters), |painter| { +/// // isolated content +/// }); +/// ``` +#[must_use] +pub fn group_ref<'a>() -> GroupRef<'a> { + GroupRef { + clip: None, + mask: None, + filters: &[], + composite: Composite::default(), + } +} + +static NEXT_COLOR_EFFECT_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_SHADER_EFFECT_ID: AtomicU64 = AtomicU64::new(1); + +fn next_color_filter_id() -> ColorFilterId { + ColorFilterId(NEXT_COLOR_EFFECT_ID.fetch_add(1, Ordering::Relaxed)) +} + +fn next_shader_effect_id() -> ShaderEffectId { + ShaderEffectId(NEXT_SHADER_EFFECT_ID.fetch_add(1, Ordering::Relaxed)) +} + +/// Internal stable identifier for a reusable compositor shader program. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ColorFilterId(pub u64); + +/// Internal stable identifier for reusable compositor source/shader programs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ShaderEffectId(pub u64); + +/// A SwiftUI-style color filter applied to an isolated compositor subtree. +/// +/// The effect receives the already-sampled source color for the destination +/// pixel. It cannot sample neighboring pixels or sample the input layer at a +/// different coordinate. Use [`LayerFilter`] when the shader needs layer +/// sampling. +/// +/// Shader bodies are written in logical window coordinates by default. Use +/// [`ColorFilter::wgsl`] for the generated shader wrapper and available +/// parameters. Animated or app-driven values should be passed explicitly +/// through [`ShaderUniform`]. +#[derive(Clone, Debug, PartialEq)] +pub struct ColorFilter { + pub(crate) id: ColorFilterId, + pub shader: ColorFilterShader, + pub args: ShaderArgs, + pub color_space: SurfaceColorSpace, +} + +impl ColorFilter { + /// Creates a WGSL color filter from a function body. + /// + /// The body must be valid WGSL statements for a function that returns + /// `vec4`. Floem wraps it in a complete fullscreen shader. The + /// generated source has this shape: + /// + /// ```wgsl + /// struct ShaderArgs { + /// data: vec4, + /// }; + /// + /// struct ShaderFrame { + /// effective_scale: f32, + /// target_width: f32, + /// target_height: f32, + /// clip_mask_enabled: f32, + /// position_transform0: vec4, + /// position_transform1: vec4, + /// }; + /// + /// // Floem samples the source texture before calling `color_filter`. + /// // The texture and sampler bindings are internal; use `LayerFilter` + /// // when shader code needs to sample the input layer. + /// @group(0) @binding(2) var args: ShaderArgs; + /// @group(0) @binding(3) var frame: ShaderFrame; + /// + /// struct VsOut { + /// @builtin(position) position: vec4, + /// @location(0) uv: vec2, + /// }; + /// + /// @vertex + /// fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + /// var out: VsOut; + /// let x = f32(i32(vi & 1u)) * 4.0 - 1.0; + /// let y = f32(i32(vi >> 1u)) * 4.0 - 1.0; + /// out.position = vec4(x, y, 0.0, 1.0); + /// out.uv = vec2(x, -y) * 0.5 + vec2(0.5, 0.5); + /// return out; + /// } + /// + /// fn color_filter( + /// position: vec2, // logical pixels, top-left origin + /// uv: vec2, // normalized texture coordinates + /// color: vec4, // sampled source color at uv + /// args: ShaderArgs, + /// frame: ShaderFrame, + /// ) -> vec4 { + /// // fragment_body + /// } + /// + /// @fragment + /// fn fs_main(in: VsOut) -> @location(0) vec4 { + /// let target_position = in.position.xy / vec2(frame.effective_scale); + /// let logical_position = vec2( + /// frame.position_transform0.x * target_position.x + frame.position_transform0.z * target_position.y + frame.position_transform1.x, + /// frame.position_transform0.y * target_position.x + frame.position_transform0.w * target_position.y + frame.position_transform1.y, + /// ); + /// let color = /* sampled source color */; + /// return color_filter(logical_position, in.uv, color, args, frame); + /// } + /// ``` + #[must_use] + pub fn wgsl(fragment_body: impl Into>) -> Self { + Self::wgsl_with_id(next_color_filter_id(), fragment_body) + } + + pub(crate) fn wgsl_with_id(id: ColorFilterId, fragment_body: impl Into>) -> Self { + Self { + id, + shader: ColorFilterShader::Wgsl { + label: None, + fragment_body: fragment_body.into(), + }, + args: ShaderArgs::default(), + color_space: SurfaceColorSpace::ExtendedLinearSrgb, + } + } + + /// Adds a human-readable label for GPU debugging and pipeline caching. + #[must_use] + pub fn with_label(mut self, label: impl Into>) -> Self { + match &mut self.shader { + ColorFilterShader::Wgsl { label: slot, .. } => *slot = Some(label.into()), + } + self + } + + /// Sets raw uniform argument bytes for the effect. + #[must_use] + pub fn with_args(mut self, args: impl Into>) -> Self { + self.args = ShaderArgs::new(args); + self + } + + #[must_use] + pub fn with_uniforms( + mut self, + uniforms: ShaderUniform, + ) -> Self { + uniforms.attach_current_view_if_unset(); + self.args = ShaderArgs::from_uniforms(uniforms); + self + } + + #[must_use] + pub fn with_derived_uniforms(mut self, uniforms: F) -> Self + where + T: ShaderUniforms + Send + 'static, + F: Fn() -> T + 'static, + { + self.args = ShaderArgs::from_uniforms(ShaderUniform::derived(uniforms)); + self + } + + /// Sets the working/output color space for this effect. + #[must_use] + pub fn with_color_space(mut self, color_space: SurfaceColorSpace) -> Self { + self.color_space = color_space; + self + } +} + +/// A SwiftUI-style layer filter applied to an isolated compositor subtree. +/// +/// The effect is evaluated over an input texture containing the already-rendered +/// subtree. Backends expose the input texture and sampler to the shader, so the +/// shader may either use the pre-sampled `color` value or sample the input +/// texture at another `uv`. +/// +/// Like [`ColorFilter`], `position` is in logical pixels and `uv` is normalized +/// texture space. Use `frame.effective_scale` to convert to physical pixels. +/// Use [`LayerFilter::wgsl`] for the generated shader wrapper and available +/// parameters. Animated or app-driven values should be passed explicitly +/// through [`ShaderUniform`]. +#[derive(Clone, Debug, PartialEq)] +pub struct LayerFilter { + pub(crate) id: ColorFilterId, + pub shader: LayerFilterShader, + pub args: ShaderArgs, + pub color_space: SurfaceColorSpace, +} + +impl LayerFilter { + /// Creates a WGSL layer filter from a function body. + /// + /// The body must be valid WGSL statements for a function that returns + /// `vec4`. Floem wraps it in a complete fullscreen shader. The + /// generated source has this shape: + /// + /// ```wgsl + /// struct ShaderArgs { + /// data: vec4, + /// }; + /// + /// struct ShaderFrame { + /// effective_scale: f32, + /// target_width: f32, + /// target_height: f32, + /// clip_mask_enabled: f32, + /// position_transform0: vec4, + /// position_transform1: vec4, + /// }; + /// + /// @group(0) @binding(0) var input_texture: texture_2d; + /// @group(0) @binding(1) var input_sampler: sampler; + /// @group(0) @binding(2) var args: ShaderArgs; + /// @group(0) @binding(3) var frame: ShaderFrame; + /// + /// struct VsOut { + /// @builtin(position) position: vec4, + /// @location(0) uv: vec2, + /// }; + /// + /// @vertex + /// fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + /// var out: VsOut; + /// let x = f32(i32(vi & 1u)) * 4.0 - 1.0; + /// let y = f32(i32(vi >> 1u)) * 4.0 - 1.0; + /// out.position = vec4(x, y, 0.0, 1.0); + /// out.uv = vec2(x, -y) * 0.5 + vec2(0.5, 0.5); + /// return out; + /// } + /// + /// fn layer_filter( + /// position: vec2, // logical pixels, top-left origin + /// uv: vec2, // normalized texture coordinates + /// color: vec4, // sampled input-layer color at uv + /// args: ShaderArgs, + /// frame: ShaderFrame, + /// ) -> vec4 { + /// // fragment_body + /// } + /// + /// @fragment + /// fn fs_main(in: VsOut) -> @location(0) vec4 { + /// let target_position = in.position.xy / vec2(frame.effective_scale); + /// let logical_position = vec2( + /// frame.position_transform0.x * target_position.x + frame.position_transform0.z * target_position.y + frame.position_transform1.x, + /// frame.position_transform0.y * target_position.x + frame.position_transform0.w * target_position.y + frame.position_transform1.y, + /// ); + /// let color = textureSample(input_texture, input_sampler, in.uv); + /// return layer_filter(logical_position, in.uv, color, args, frame); + /// } + /// ``` + #[must_use] + pub fn wgsl(fragment_body: impl Into>) -> Self { + Self::wgsl_with_id(next_color_filter_id(), fragment_body) + } + + pub(crate) fn wgsl_with_id(id: ColorFilterId, fragment_body: impl Into>) -> Self { + Self { + id, + shader: LayerFilterShader::Wgsl { + label: None, + fragment_body: fragment_body.into(), + }, + args: ShaderArgs::default(), + color_space: SurfaceColorSpace::ExtendedLinearSrgb, + } + } + + /// Adds a human-readable label for GPU debugging and pipeline caching. + #[must_use] + pub fn with_label(mut self, label: impl Into>) -> Self { + match &mut self.shader { + LayerFilterShader::Wgsl { label: slot, .. } => *slot = Some(label.into()), + } + self + } + + /// Sets raw uniform argument bytes for the effect. + #[must_use] + pub fn with_args(mut self, args: impl Into>) -> Self { + self.args = ShaderArgs::new(args); + self + } + + #[must_use] + pub fn with_uniforms( + mut self, + uniforms: ShaderUniform, + ) -> Self { + uniforms.attach_current_view_if_unset(); + self.args = ShaderArgs::from_uniforms(uniforms); + self + } + + #[must_use] + pub fn with_derived_uniforms(mut self, uniforms: F) -> Self + where + T: ShaderUniforms + Send + 'static, + F: Fn() -> T + 'static, + { + self.args = ShaderArgs::from_uniforms(ShaderUniform::derived(uniforms)); + self + } + + /// Sets the working/output color space for this effect. + #[must_use] + pub fn with_color_space(mut self, color_space: SurfaceColorSpace) -> Self { + self.color_space = color_space; + self + } +} + +/// A no-input shader that generates a compositor texture from position, uv, args, and frame data. +/// +/// This is useful for SwiftUI-style generated visual content: procedural gradients, noise, +/// checkerboards, animated backgrounds, or any shader-backed image that does not sample an input +/// layer. Like [`ColorFilter`], `position` is in logical pixels and `uv` is normalized texture +/// space. Use `frame.effective_scale` to convert to physical pixels. Use [`ShaderSource::wgsl`] +/// for the generated shader wrapper and available parameters. Animated or app-driven values should +/// be passed explicitly through [`ShaderUniform`]. +#[derive(Clone, Debug, PartialEq)] +pub struct ShaderSource { + pub(crate) id: ShaderEffectId, + pub shader: ShaderSourceShader, + pub args: ShaderArgs, + pub color_space: SurfaceColorSpace, +} + +impl ShaderSource { + /// Creates an image payload for this shader source. + /// + /// Shader sources must be given a concrete intrinsic source size before + /// they can be painted with an [`ImageBrush`]. Use + /// [`ShaderSourceImage::view`] for percentage- or length-based brush views. + #[must_use] + pub fn image(self, intrinsic_size: impl Into) -> ShaderSourceImage { + ShaderSourceImage { + source: self, + intrinsic_size: intrinsic_size.into(), + } + } + + /// Creates a WGSL shader source from a function body. + /// + /// The body must be valid WGSL statements for a function that returns + /// `vec4`. Floem wraps it in a complete fullscreen shader. The + /// generated source has this shape: + /// + /// ```wgsl + /// struct ShaderArgs { + /// data: vec4, + /// }; + /// + /// struct ShaderFrame { + /// effective_scale: f32, + /// target_width: f32, + /// target_height: f32, + /// clip_mask_enabled: f32, + /// position_transform0: vec4, + /// position_transform1: vec4, + /// }; + /// + /// // Floem uses an internal texture only to apply the generated source + /// // to the requested image shape. Shader sources do not receive an + /// // input texture; use `LayerFilter` when shader code needs sampling. + /// @group(0) @binding(2) var args: ShaderArgs; + /// @group(0) @binding(3) var frame: ShaderFrame; + /// + /// struct VsOut { + /// @builtin(position) position: vec4, + /// @location(0) uv: vec2, + /// }; + /// + /// @vertex + /// fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + /// var out: VsOut; + /// let x = f32(i32(vi & 1u)) * 4.0 - 1.0; + /// let y = f32(i32(vi >> 1u)) * 4.0 - 1.0; + /// out.position = vec4(x, y, 0.0, 1.0); + /// out.uv = vec2(x, -y) * 0.5 + vec2(0.5, 0.5); + /// return out; + /// } + /// + /// fn shader_source( + /// position: vec2, // logical pixels, top-left origin + /// uv: vec2, // normalized texture coordinates + /// args: ShaderArgs, + /// frame: ShaderFrame, + /// ) -> vec4 { + /// // fragment_body + /// } + /// + /// @fragment + /// fn fs_main(in: VsOut) -> @location(0) vec4 { + /// let target_position = in.position.xy / vec2(frame.effective_scale); + /// let logical_position = vec2( + /// frame.position_transform0.x * target_position.x + frame.position_transform0.z * target_position.y + frame.position_transform1.x, + /// frame.position_transform0.y * target_position.x + frame.position_transform0.w * target_position.y + frame.position_transform1.y, + /// ); + /// return shader_source(logical_position, in.uv, args, frame); + /// } + /// ``` + #[must_use] + pub fn wgsl(fragment_body: impl Into>) -> Self { + Self::wgsl_with_id(next_shader_effect_id(), fragment_body) + } + + pub(crate) fn wgsl_with_id(id: ShaderEffectId, fragment_body: impl Into>) -> Self { + Self { + id, + shader: ShaderSourceShader::Wgsl { + label: None, + fragment_body: fragment_body.into(), + }, + args: ShaderArgs::default(), + color_space: SurfaceColorSpace::ExtendedLinearSrgb, + } + } + + /// Adds a human-readable label for GPU debugging and pipeline caching. + #[must_use] + pub fn with_label(mut self, label: impl Into>) -> Self { + match &mut self.shader { + ShaderSourceShader::Wgsl { label: slot, .. } => *slot = Some(label.into()), + } + self + } + + /// Sets raw uniform argument bytes for the source shader. + #[must_use] + pub fn with_args(mut self, args: impl Into>) -> Self { + self.args = ShaderArgs::new(args); + self + } + + #[must_use] + pub fn with_uniforms( + mut self, + uniforms: ShaderUniform, + ) -> Self { + uniforms.attach_current_view_if_unset(); + self.args = ShaderArgs::from_uniforms(uniforms); + self + } + + #[must_use] + pub fn with_derived_uniforms(mut self, uniforms: F) -> Self + where + T: ShaderUniforms + Send + 'static, + F: Fn() -> T + 'static, + { + self.args = ShaderArgs::from_uniforms(ShaderUniform::derived(uniforms)); + self + } + + /// Sets the working/output color space for this shader source. + #[must_use] + pub fn with_color_space(mut self, color_space: SurfaceColorSpace) -> Self { + self.color_space = color_space; + self + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ShaderSourceShader { + /// WGSL function body for the generated `shader_source` function. + /// + /// Prefer [`ShaderSource::wgsl`] when constructing this variant; that + /// method documents the generated wrapper and available shader inputs. + Wgsl { + label: Option>, + fragment_body: Arc, + }, +} + +/// A future compositor shader blend between source and backdrop. +/// +/// This is intentionally separate from filters: a composite shader needs both the isolated source +/// layer and the already-rendered backdrop. The current compositor can carry this in the generic +/// group API, but execution requires a backdrop render pass and currently fails loudly if used. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ShaderComposite { + /// Stable program identifier for the composite shader. + pub(crate) id: ShaderEffectId, +} + +impl ShaderComposite { + /// Creates a compositor composite effect with an automatically assigned + /// internal identity. + #[must_use] + pub fn new() -> Self { + Self { + id: next_shader_effect_id(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ColorFilterShader { + /// WGSL function body for the generated `color_filter` function. + /// + /// Prefer [`ColorFilter::wgsl`] when constructing this variant; that + /// method documents the generated wrapper and available shader inputs. + Wgsl { + label: Option>, + fragment_body: Arc, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LayerFilterShader { + /// WGSL function body for the generated `layer_filter` function. + /// + /// Prefer [`LayerFilter::wgsl`] when constructing this variant; that + /// method documents the generated wrapper and available shader inputs. + Wgsl { + label: Option>, + fragment_body: Arc, + }, +} + +#[derive(Clone)] +pub struct ShaderArgs { + source: ShaderArgsSource, +} + +#[derive(Clone)] +enum ShaderArgsSource { + Static(Arc<[u8]>), + Dynamic(Arc), +} + +impl Default for ShaderArgs { + fn default() -> Self { + Self::new(Vec::new()) + } +} + +impl Debug for ShaderArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShaderArgs") + .field("bytes_len", &self.bytes().len()) + .field("revision", &self.revision()) + .finish() + } +} + +impl PartialEq for ShaderArgs { + fn eq(&self, other: &Self) -> bool { + self.revision() == other.revision() && self.bytes() == other.bytes() + } +} + +impl Eq for ShaderArgs {} + +impl ShaderArgs { + #[must_use] + pub fn new(args: impl Into>) -> Self { + Self { + source: ShaderArgsSource::Static(args.into().into()), + } + } + + #[must_use] + pub fn from_uniforms(uniforms: ShaderUniform) -> Self { + Self { + source: ShaderArgsSource::Dynamic(uniforms.inner), + } + } + + #[must_use] + pub fn bytes(&self) -> Vec { + match &self.source { + ShaderArgsSource::Static(bytes) => bytes.to_vec(), + ShaderArgsSource::Dynamic(provider) => provider.bytes(), + } + } + + #[must_use] + pub fn revision(&self) -> u64 { + match &self.source { + ShaderArgsSource::Static(_) => 0, + ShaderArgsSource::Dynamic(provider) => provider.revision(), + } + } +} + +trait ShaderUniformProvider: Send + Sync { + fn bytes(&self) -> Vec; + fn revision(&self) -> u64; +} + +/// Typed uniform payload accepted by Floem compositor effects. +pub trait ShaderUniforms: Clone + PartialEq + Debug + 'static { + fn uniform_bytes(&self) -> Vec; +} + +/// Uniform payload that can be interpolated by [`ShaderUniform::transition`]. +pub trait AnimatableShaderUniforms: ShaderUniforms { + fn interpolate(from: &Self, to: &Self, t: f64) -> Self; +} + +impl ShaderUniforms for Vec { + fn uniform_bytes(&self) -> Vec { + self.clone() + } +} + +impl ShaderUniforms for [u8; N] { + fn uniform_bytes(&self) -> Vec { + self.to_vec() + } +} + +impl ShaderUniforms for f32 { + fn uniform_bytes(&self) -> Vec { + self.to_ne_bytes().to_vec() + } +} + +impl AnimatableShaderUniforms for f32 { + fn interpolate(from: &Self, to: &Self, t: f64) -> Self { + *from + (*to - *from) * t as f32 + } +} + +impl ShaderUniforms for [f32; N] { + fn uniform_bytes(&self) -> Vec { + self.iter().flat_map(|value| value.to_ne_bytes()).collect() + } +} + +impl AnimatableShaderUniforms for [f32; N] { + fn interpolate(from: &Self, to: &Self, t: f64) -> Self { + std::array::from_fn(|index| f32::interpolate(&from[index], &to[index], t)) + } +} + +/// Sendable transition descriptor for effect uniforms. +#[derive(Clone)] +pub struct ShaderTransition { + pub duration: Duration, + pub easing: Arc, +} + +impl Debug for ShaderTransition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShaderTransition") + .field("duration", &self.duration) + .field("easing", &self.easing) + .finish() + } +} + +impl ShaderTransition { + #[must_use] + pub fn new(duration: Duration, easing: impl Easing + Send + Sync + 'static) -> Self { + Self { + duration, + easing: Arc::new(easing), + } + } + + #[must_use] + pub fn linear(duration: Duration) -> Self { + Self::new(duration, Linear) + } + + #[must_use] + pub fn ease_in_out(duration: Duration) -> Self { + Self::new(duration, Bezier::ease_in_out()) + } + + #[must_use] + pub fn spring(duration: Duration) -> Self { + Self::new(duration, Spring::default()) + } +} + +struct RunningShaderTransition { + transition: ShaderTransition, + started_at: Instant, + from: T, + to: T, +} + +struct ShaderUniformState { + target: T, + presentation: T, + running: Option>, + revision: u64, + window_id: Option, +} + +/// App-owned handle for updating and animating effect uniforms. +pub struct ShaderUniform { + inner: Arc>, +} + +impl Clone for ShaderUniform { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +struct ShaderUniformInner { + state: Mutex>, + frame_requested: AtomicBool, +} + +impl ShaderUniform { + #[must_use] + pub fn new(initial: T) -> Self { + Self { + inner: Arc::new(ShaderUniformInner { + state: Mutex::new(ShaderUniformState { + target: initial.clone(), + presentation: initial, + running: None, + revision: 0, + window_id: None, + }), + frame_requested: AtomicBool::new(false), + }), + } + } + + #[must_use] + pub fn derived(uniforms: impl Fn() -> T + 'static) -> Self { + let initial = uniforms(); + let handle = Self::new(initial); + handle.attach_current_view_if_unset(); + let update_handle = handle.clone(); + UpdaterEffect::new(uniforms, move |uniforms| { + update_handle.set(uniforms); + }); + handle + } + + pub fn set(&self, value: T) { + let changed = { + let mut state = self.inner.state.lock().unwrap(); + state.running = None; + state.target = value.clone(); + if state.presentation == value { + false + } else { + state.presentation = value; + state.revision = state.revision.wrapping_add(1); + true + } + }; + if changed { + self.request_repaint(); + } + } + + pub fn update(&self, update: impl FnOnce(&mut T)) { + let mut value = self.target(); + update(&mut value); + self.set(value); + } + + #[must_use] + pub fn get(&self) -> T { + self.inner.state.lock().unwrap().presentation.clone() + } + + #[must_use] + pub fn target(&self) -> T { + self.inner.state.lock().unwrap().target.clone() + } + + pub fn cancel_animation(&self) { + let changed = { + let mut state = self.inner.state.lock().unwrap(); + let was_running = state.running.take().is_some(); + if was_running { + state.target = state.presentation.clone(); + state.revision = state.revision.wrapping_add(1); + } + was_running + }; + if changed { + self.request_repaint(); + } + } + + pub fn finish_animation(&self) { + let changed = { + let mut state = self.inner.state.lock().unwrap(); + let Some(running) = state.running.take() else { + return; + }; + state.presentation = running.to.clone(); + state.target = running.to; + state.revision = state.revision.wrapping_add(1); + true + }; + if changed { + self.request_repaint(); + } + } + + fn attach_current_view_if_unset(&self) { + let mut state = self.inner.state.lock().unwrap(); + if state.window_id.is_none() { + state.window_id = crate::window::handle::get_current_view().window_id(); + } + } + + fn request_repaint(&self) { + let window_id = self.inner.state.lock().unwrap().window_id; + if let Some(window_id) = window_id { + Application::send_proxy_event(UserEvent::WindowPaint { window_id }); + } + } +} + +impl ShaderUniform { + pub fn transition(&self, transition: ShaderTransition, update: impl FnOnce(&mut T)) { + let changed = { + let mut state = self.inner.state.lock().unwrap(); + let mut target = state.target.clone(); + update(&mut target); + if state.target == target && state.running.is_none() { + false + } else { + let from = state.presentation.clone(); + state.target = target.clone(); + state.running = Some(RunningShaderTransition { + transition, + started_at: Instant::now(), + from, + to: target, + }); + true + } + }; + if changed { + self.request_repaint(); + self.request_animation_frame(); + } + } + + fn step_at(&self, now: Instant) -> bool { + let mut state = self.inner.state.lock().unwrap(); + let Some(running) = state.running.as_ref() else { + return false; + }; + let transition = running.transition.clone(); + let started_at = running.started_at; + let from = running.from.clone(); + let to = running.to.clone(); + let elapsed = now.saturating_duration_since(started_at); + let time_percent = elapsed.as_secs_f64() / transition.duration.as_secs_f64(); + let finished = elapsed >= transition.duration && transition.easing.finished(time_percent); + let next = if finished { + to.clone() + } else { + T::interpolate(&from, &to, transition.easing.eval(time_percent)) + }; + let changed = state.presentation != next; + if changed { + state.presentation = next; + state.revision = state.revision.wrapping_add(1); + } + if finished { + state.running = None; + state.presentation = to; + } + changed || finished + } + + fn is_animating(&self) -> bool { + self.inner.state.lock().unwrap().running.is_some() + } + + fn request_animation_frame(&self) { + if self.inner.frame_requested.swap(true, Ordering::Relaxed) { + return; + } + let handle = self.clone(); + crate::action::request_animation_frame(move |frame_time| { + let now = frame_time + .interval + .predicted_present + .unwrap_or(frame_time.now); + if handle.step_at(now) { + handle.request_repaint(); + } + if !handle.is_animating() { + handle.inner.frame_requested.store(false, Ordering::Relaxed); + } + }); + } +} + +impl ShaderUniformProvider for ShaderUniformInner { + fn bytes(&self) -> Vec { + self.state.lock().unwrap().presentation.uniform_bytes() + } + + fn revision(&self) -> u64 { + self.state.lock().unwrap().revision + } +} + +/// Uniform values made available to shader programs from frame timing. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ShaderFrameUniform { + pub effective_scale: f32, + pub target_width: f32, + pub target_height: f32, + pub clip_mask_enabled: f32, + pub position_transform0: [f32; 4], + pub position_transform1: [f32; 4], +} diff --git a/src/event/dispatch.rs b/src/event/dispatch.rs index 243c999bb..f5d69c938 100644 --- a/src/event/dispatch.rs +++ b/src/event/dispatch.rs @@ -1,6 +1,6 @@ //! Event dispatch logic for handling events through the view tree. -use std::{rc::Rc, sync::LazyLock, time::Instant}; +use std::{rc::Rc, sync::LazyLock, time::Instant as StdInstant}; use peniko::kurbo::{Affine, Point, Rect}; use smallvec::SmallVec; @@ -19,18 +19,39 @@ use winit::keyboard::KeyCode; use crate::{ BoxTree, ElementId, ElementMeta, ViewId, - action::show_context_menu, + action::{capture_metal, inspect, set_window_scale, show_context_menu, toggle_hud}, context::Phases, event::{ DragEvent, DragToken, Event, FocusEvent, InteractionEvent, Phase, PointerCaptureEvent, WindowEvent, drag_state::DragEventDispatch, dropped_file::FileDragEvent, path::hit_test, }, + platform::Instant, style::{StyleSelector, StyleSelectors, recalc::StyleReason}, view::{VIEW_STORAGE, View}, window::WindowState, }; -static START_TIME: LazyLock = LazyLock::new(Instant::now); +static START_TIME: LazyLock = LazyLock::new(StdInstant::now); + +fn platform_primary_modifier() -> Modifiers { + if cfg!(target_os = "macos") { + Modifiers::META + } else { + Modifiers::CONTROL + } +} + +fn primary_modifier_with_optional_shift(modifiers: Modifiers) -> bool { + modifiers == platform_primary_modifier() + || modifiers == (platform_primary_modifier() | Modifiers::SHIFT) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ZoomShortcut { + In, + Out, + Reset, +} /// A single step in a capture/target/bubble dispatch sequence. #[derive(Clone, Copy, Debug)] @@ -127,7 +148,7 @@ fn build_ancestor_chain(target: ElementId, box_tree: &BoxTree) -> SmallVec<[Elem // Prevent trivial cycles via path scan instead of HashSet allocation. // Path length is expected to be small (UI tree depth is usually small). if path.contains(¤t) { - eprintln!("Warning: Detected cycle in box tree parent chain"); + crate::floem_debug_log!("Warning: Detected cycle in box tree parent chain"); break; } @@ -517,6 +538,7 @@ pub(crate) struct RouteCx<'r, 'w> { /// Guard against double-finish (e.g. explicit call + drop). finished: bool, prevent_default: bool, + profile_event: Option<(Instant, String)>, } // ============================================================================ @@ -565,6 +587,7 @@ impl<'r, 'w> RouteCx<'r, 'w> { }; Self { + profile_event: gcx.window_state.begin_profile_event(format!("{event:?}")), gcx, event, hit_path, @@ -587,6 +610,7 @@ impl<'r, 'w> RouteCx<'r, 'w> { .and_then(|point| hit_test(gcx.window_state.root_view_id, point)); Self { + profile_event: gcx.window_state.begin_profile_event(format!("{event:?}")), gcx, event, hit_path, @@ -607,6 +631,7 @@ impl<'r, 'w> RouteCx<'r, 'w> { let event = gcx.event.clone(); let source = gcx.source; Self { + profile_event: gcx.window_state.begin_profile_event(format!("{event:?}")), gcx, event, hit_path: None, @@ -1054,6 +1079,9 @@ impl RouteCx<'_, '_> { } self.flush_pending_events(); + self.gcx + .window_state + .finish_profile_event(self.profile_event.take()); } /// Route a synthetic event, carrying the current event as `triggered_by`. @@ -1106,11 +1134,11 @@ impl RouteCx<'_, '_> { .drag_tracker .check_threshold(&pu, &mut box_tree.borrow_mut()); if let Some(drag_dispatch) = res { - self.gcx.window_state.needs_box_tree_commit = true; + self.gcx.window_state.request_box_tree_commit(); self.dispatch_drag_event(drag_dispatch); } if let Some(_active) = &self.gcx.window_state.drag_tracker.active_drag { - self.gcx.window_state.needs_box_tree_from_layout = true; + self.gcx.window_state.request_box_tree_commit(); let hover_path = self .hit_path .as_ref() @@ -1194,6 +1222,53 @@ impl RouteCx<'_, '_> { self.element_arrow_navigation(&arrow_key); } + // Platform window/app shortcuts. + if let Event::Key(key_event) = &self.event + && key_event.state == KeyState::Up + { + if self.is_quit_shortcut(key_event) { + crate::quit_app(); + } else if self.is_close_window_shortcut(key_event) + && let Some(window_id) = + crate::window::tracking::window_id_for_root(self.gcx.window_state.root_view_id) + { + crate::window::request_close_window(window_id); + } + } + + // Platform zoom shortcuts. + if let Event::Key(key_event) = &self.event + && key_event.state == KeyState::Down + { + match self.zoom_shortcut(key_event) { + Some(ZoomShortcut::In) => { + set_window_scale((self.gcx.window_state.user_scale * 1.1).clamp(0.1, 10.0)); + } + Some(ZoomShortcut::Out) => { + set_window_scale((self.gcx.window_state.user_scale / 1.1).clamp(0.1, 10.0)); + } + Some(ZoomShortcut::Reset) => set_window_scale(1.0), + None => {} + } + } + + // Debug shortcuts. + if let Event::Key(KeyboardEvent { + key: Key::Named(name @ (NamedKey::F10 | NamedKey::F11 | NamedKey::F12)), + modifiers, + state: KeyState::Up, + .. + }) = &self.event + && modifiers.is_empty() + { + match name { + NamedKey::F10 => toggle_hud(), + NamedKey::F11 => inspect(), + NamedKey::F12 => capture_metal(), + _ => {} + } + } + // Context / popout menus (platform-specific timing). let pbe = match &self.event { Event::Pointer(PointerEvent::Down(pbe)) if cfg!(target_os = "macos") => Some(pbe), @@ -1213,6 +1288,42 @@ impl RouteCx<'_, '_> { } } + fn is_quit_shortcut(&self, key_event: &KeyboardEvent) -> bool { + matches!(&key_event.key, Key::Character(ch) if ch.eq_ignore_ascii_case("q")) + && key_event.modifiers == platform_primary_modifier() + } + + fn is_close_window_shortcut(&self, key_event: &KeyboardEvent) -> bool { + let primary_close = matches!(&key_event.key, Key::Character(ch) if ch.eq_ignore_ascii_case("w")) + && key_event.modifiers == platform_primary_modifier(); + let windows_alt_f4 = cfg!(target_os = "windows") + && key_event.key == Key::Named(NamedKey::F4) + && key_event.modifiers == Modifiers::ALT; + primary_close || windows_alt_f4 + } + + fn zoom_shortcut(&self, key_event: &KeyboardEvent) -> Option { + match &key_event.key { + Key::Character(ch) + if (ch == "=" || ch == "+") + && primary_modifier_with_optional_shift(key_event.modifiers) => + { + Some(ZoomShortcut::In) + } + Key::Character(ch) + if ch == "-" && key_event.modifiers == platform_primary_modifier() => + { + Some(ZoomShortcut::Out) + } + Key::Character(ch) + if ch == "0" && key_event.modifiers == platform_primary_modifier() => + { + Some(ZoomShortcut::Reset) + } + _ => None, + } + } + /// Helper: dispatch a `DragEventDispatch` variant as a synthetic route. fn dispatch_drag_event(&mut self, drag_dispatch: DragEventDispatch) { use crate::context::Phases; @@ -1597,7 +1708,19 @@ impl RouteCx<'_, '_> { /// The ordering rules here match the linear policy model: /// explicit `order` first, then reading-order geometry. #[inline] - fn initial_tab_target(entries: &[FocusEntry], backwards: bool) -> Option { + fn initial_tab_target( + entries: &[FocusEntry], + autofocus: Option, + backwards: bool, + ) -> Option { + if let Some(autofocus) = autofocus + && entries + .iter() + .any(|entry| entry.id == autofocus && entry.enabled) + { + return Some(autofocus); + } + let mut indices: SmallVec<[usize; 128]> = entries .iter() .enumerate() @@ -1749,11 +1872,12 @@ impl RouteCx<'_, '_> { }; let space = self.gcx.window_state.keyboard_focus_space(); let entries = space.nodes; + let autofocus = space.autofocus; let next = if entries.is_empty() { None } else if active_focus.is_none() { - Self::initial_tab_target(entries, backwards) + Self::initial_tab_target(entries, autofocus, backwards) } else { let current_focus = active_focus.unwrap(); if !entries.iter().any(|e| e.id == current_focus) { @@ -1817,7 +1941,10 @@ impl RouteCx<'_, '_> { .cloned() .collect(); if grouped.iter().any(|e| e.id == current_focus) && grouped.len() > 1 { - let grouped_space = FocusSpace { nodes: &grouped }; + let grouped_space = FocusSpace { + nodes: &grouped, + autofocus: None, + }; if let Some(next) = policy.next(current_focus, navigation, &grouped_space) { Some(next) } else { @@ -1901,19 +2028,31 @@ impl RouteCx<'_, '_> { .cloned() .collect(); if grouped.iter().any(|e| e.id == current_focus) { - let grouped_space = FocusSpace { nodes: &grouped }; + let grouped_space = FocusSpace { + nodes: &grouped, + autofocus: None, + }; policy .next(current_focus, direction, &grouped_space) .or_else(|| { - let space = FocusSpace { nodes: &entries }; + let space = FocusSpace { + nodes: &entries, + autofocus: None, + }; policy.next(current_focus, direction, &space) }) } else { - let space = FocusSpace { nodes: &entries }; + let space = FocusSpace { + nodes: &entries, + autofocus: None, + }; policy.next(current_focus, direction, &space) } } else { - let space = FocusSpace { nodes: &entries }; + let space = FocusSpace { + nodes: &entries, + autofocus: None, + }; policy.next(current_focus, direction, &space) }; diff --git a/src/event/listener.rs b/src/event/listener.rs index 2acd9cd02..c5ae42733 100644 --- a/src/event/listener.rs +++ b/src/event/listener.rs @@ -87,6 +87,7 @@ pub use inner::{ WindowCloseRequested, WindowClosed, WindowGainedFocus, + WindowGpuResourcesReady, WindowLostFocus, WindowMaximizeChanged, WindowMoved, @@ -114,6 +115,7 @@ mod inner { DragSourceEvent, DragStartEvent, DragTargetEvent, DragToken, Event, FileDragEvent, FocusEvent, ImeEvent, InteractionEvent, PointerCaptureEvent, UpdatePhaseEvent, WindowEvent, }; + use crate::gpu_resources::GpuResources; // EventListener using the same pattern as StyleClass #[derive(Copy, Clone)] @@ -234,7 +236,15 @@ mod inner { /// /// # Default Actions (preventable with `cx.prevent_default()`) /// - `Space`, `Enter`, `NumpadEnter` (key-up): Generates [`InteractionEvent::Click`] on - /// the focused element. (All other keys on key-up have no preventable default action.) + /// the focused element. + /// - Platform primary modifier + `Q` (key-up): Quits the app. The primary modifier is + /// Command on macOS and Control on Windows/Linux and other non-macOS targets. + /// - Platform primary modifier + `W` (key-up): Requests the current window to close. On + /// Windows, `Alt+F4` does the same. + /// - Platform primary modifier + `+`/`=`/`-`/`0` (key-down): Zooms the current window in, + /// out, or back to 100%. + /// - `F10`, `F11`, `F12` (key-up, no modifiers): Toggles the HUD, opens the Inspector, + /// and captures the next Metal frame, respectively. pub KeyUp: ui_events::keyboard::KeyboardEvent, |event| { if let Event::Key(kb_event) = event @@ -974,6 +984,21 @@ mod inner { } ); + event_listener!( + /// Receives [`Event::Window`] `GpuResourcesReady` variant — fired once Floem has acquired + /// the wgpu instance/adapter/device/queue for this window. + /// + /// Compositor-surface producers should use these resources instead of creating an unrelated + /// wgpu device. + pub WindowGpuResourcesReady: GpuResources, + |event| { + if let Event::Window(WindowEvent::GpuResourcesReady(resources)) = event { + return Some(resources as &dyn Any); + } + None + } + ); + event_listener!( /// Receives [`Event::Window`] `Moved` variant — fired when the window is moved. /// @@ -1189,8 +1214,9 @@ mod inner { ); event_listener!( - /// Receives `Event::Window(UpdatePhase(PaintPresent))` — fired when the entire update cycle - /// is complete and the window is ready for painting if needed. + /// Receives `Event::Window(UpdatePhase(PaintPresent(info)))` — fired after a frame is + /// presented. The payload includes Floem's best presentation timestamp and changed + /// compositor layers for the frame. /// /// # Routing /// Dispatched to **Target phase only** on all views that have registered this listener. @@ -1198,10 +1224,10 @@ mod inner { /// /// # Default Actions /// No preventable default action. - pub UpdatePhasePaintPresent: (), + pub UpdatePhasePaintPresent: crate::event::PaintPresentInfo, |event| { - if let Event::Window(WindowEvent::UpdatePhase(UpdatePhaseEvent::PaintPresent)) = event { - return Some(&() as &dyn Any); + if let Event::Window(WindowEvent::UpdatePhase(UpdatePhaseEvent::PaintPresent(info))) = event { + return Some(info as &dyn Any); } None } diff --git a/src/event/mod.rs b/src/event/mod.rs index 80b8b5f95..8a3fbe146 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -28,6 +28,7 @@ pub use path::clear_hit_test_cache; pub use dispatch::*; use crate::ElementId; +use crate::platform::{Duration, Instant}; use std::any::Any; @@ -654,7 +655,7 @@ pub enum InteractionEvent { /// and react to different stages of the update pipeline. This is primarily useful /// for debugging, profiling, or coordinating effects that need to run at specific /// points in the update cycle. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +#[derive(Clone, Debug)] pub enum UpdatePhaseEvent { /// Processing update messages from the queue. /// This phase handles state changes and view updates queued by the application. @@ -685,10 +686,78 @@ pub enum UpdatePhaseEvent { /// is ready for painting if needed. Complete, - /// Update cycle complete. - /// This phase signals that all updates have been processed and the window - /// is ready for painting if needed. - PaintPresent, + /// Painting was presented for this update cycle. + /// + /// The timestamp is Floem's best presentation timestamp for the frame. When + /// available, this is the display-link/subduction predicted present time; + /// otherwise it falls back to the submit-side timestamp. + PaintPresent(PaintPresentInfo), +} + +/// Presentation feedback for a committed paint frame. +#[derive(Clone, Debug)] +pub struct PaintPresentInfo { + /// Floem's best presentation timestamp for the frame. + pub presented_at: Instant, + /// Compositor layers that changed or published content in this commit. + pub layers: Vec, + /// Compositor layers that are still active in the window after this commit. + /// + /// This is topology feedback, not presentation feedback. A layer can be active without + /// having presented new content in this commit. + pub active_layers: Vec, +} + +/// Presentation feedback for one compositor layer. +#[derive(Clone, Debug)] +pub struct PaintPresentLayer { + /// Backend layer slot/index for diagnostics. + pub layer_id: u32, + /// Stable diagnostic token for the first Floem element that contributed + /// content to this layer, if known. + pub source_element_id: Option, + /// Debug name for `source_element_id`, if known. + pub debug_name: Option, + /// Frame-rate preference requested for this layer, if any. + /// + /// This is the requested policy before display/backend constraints are + /// applied. It should be read together with [`Self::target_frame_interval`], + /// which is the effective interval Floem used for this presentation. + pub frame_rate: Option, + /// Effective target interval used when the layer was submitted. + /// + /// Examples: a 60 fps request on fixed 75 Hz reports about 26.7 ms + /// (37.5 fps); the same request on fixed 120 Hz reports about 16.7 ms; on a + /// 48-75 Hz VRR display it may also report about 16.7 ms. + pub target_frame_interval: Option, + /// Effective frame-source interval used for this presentation. + /// + /// On fixed displays this is the display interval. On no-granularity VRR, + /// Floem may keep the source at full rate and manually skip opportunities + /// to satisfy [`Self::target_frame_interval`]. + pub source_frame_interval: Option, + /// Whether the layer missed the submit deadline for that target interval. + pub missed_deadline: bool, + /// Timing classification for this presentation, if Floem had frame timing + /// context for it. + pub timing: Option, +} + +/// Deterministic timing attribution for a presented paint frame. +#[derive(Clone, Debug)] +pub struct PaintPresentTiming { + /// When Floem started handling the frame opportunity. + pub opportunity_started_at: Instant, + /// Latest acceptable timestamp for the frame to be ready for presentation. + pub deadline_at: Instant, + /// When Floem submitted the frame to the compositor/layer host. + pub submitted_at: Option, + /// Whether the frame opportunity was already late when Floem started it. + pub opportunity_late: bool, + /// Whether Floem submitted late after receiving a valid opportunity. + pub app_late: bool, + /// Whether Floem submitted on time but the presentation feedback was late. + pub present_late: bool, } /// Events related to the application window state. @@ -697,7 +766,7 @@ pub enum UpdatePhaseEvent { /// window events are sent only to views that have registered a listener /// for them (e.g., `.on_event(listener::WindowResized, ...)`). Views that /// do not register a listener will not receive window events. -#[derive(Clone, Copy, PartialEq, Debug)] +#[derive(Clone, Debug)] pub enum WindowEvent { /// The window gained input focus. FocusGained, @@ -734,6 +803,13 @@ pub enum WindowEvent { /// The element under the cursor changed. ChangeUnderCursor, + /// Floem has acquired the window GPU resources. + /// + /// Compositor-surface producers should use these resources when they need to + /// render into compositor-owned textures/surfaces so all wgpu work is on + /// the same instance/adapter/device/queue as the app renderer. + GpuResourcesReady(crate::gpu_resources::GpuResources), + /// The window update cycle entered a new phase. /// /// These events are fired during the update processing loop to signal @@ -1293,6 +1369,15 @@ pub enum Event { /// - `Space`, `Enter`, `NumpadEnter` (on key-up or repeat): Generates an /// [`InteractionEvent::Click`] on the currently focused element. /// See [`Event::is_keyboard_trigger`]. + /// - Platform primary modifier + `Q` (KeyUp): Quits the app. The primary + /// modifier is Command on macOS and Control on Windows/Linux and other + /// non-macOS targets. + /// - Platform primary modifier + `W` (KeyUp): Requests the current window + /// to close. On Windows, `Alt+F4` does the same. + /// - Platform primary modifier + `+`/`=`/`-`/`0` (KeyDown): Zooms the + /// current window in, out, or back to 100%. + /// - `F10`, `F11`, `F12` (KeyUp, no modifiers): Toggles the HUD, opens + /// the Inspector, and captures the next Metal frame, respectively. /// /// # Example /// ```rust @@ -1728,7 +1813,7 @@ impl Clone for Event { Self::PointerCapture(arg0) => Self::PointerCapture(*arg0), Self::Ime(arg0) => Self::Ime(arg0.clone()), Self::Focus(arg0) => Self::Focus(*arg0), - Self::Window(arg0) => Self::Window(*arg0), + Self::Window(arg0) => Self::Window(arg0.clone()), Self::Interaction(arg0) => Self::Interaction(*arg0), Self::Drag(arg0) => Self::Drag(arg0.clone()), Self::Custom(arg0) => Self::Custom(arg0.clone_box()), @@ -2065,6 +2150,9 @@ impl Event { Self::Window(WindowEvent::FocusLost) => WindowLostFocus::listener_key(), Self::Window(WindowEvent::ThemeChanged(_)) => ThemeChanged::listener_key(), Self::Window(WindowEvent::ChangeUnderCursor) => WindowChangeUnderCursor::listener_key(), + Self::Window(WindowEvent::GpuResourcesReady(_)) => { + WindowGpuResourcesReady::listener_key() + } Self::Window(WindowEvent::UpdatePhase(UpdatePhaseEvent::ProcessingMessages)) => { UpdatePhaseProcessingMessages::listener_key() } @@ -2086,7 +2174,7 @@ impl Event { Self::Window(WindowEvent::UpdatePhase(UpdatePhaseEvent::Complete)) => { UpdatePhaseComplete::listener_key() } - Self::Window(WindowEvent::UpdatePhase(UpdatePhaseEvent::PaintPresent)) => { + Self::Window(WindowEvent::UpdatePhase(UpdatePhaseEvent::PaintPresent(_))) => { UpdatePhasePaintPresent::listener_key() } Self::Interaction(InteractionEvent::Click) => Click::listener_key(), diff --git a/src/external_surface.rs b/src/external_surface.rs new file mode 100644 index 000000000..368963e01 --- /dev/null +++ b/src/external_surface.rs @@ -0,0 +1,294 @@ +//! Direct compositor surfaces owned by an external producer. +//! +//! [`ExternalSurface`] is the direct-composition counterpart to +//! [`crate::compositor_surface::SurfaceView`]. It is for content that +//! the producer already owns as a compositor-compatible texture or native +//! layer. Floem validates each submitted texture synchronously and returns an +//! error when the content cannot be directly composited. If the surface is also +//! used as an image brush, normal display-list lowering may sample that +//! submitted texture when direct layer promotion is not legal. +//! +//! Use [`crate::compositor_surface::SurfaceView`] with +//! [`crate::compositor_surface::CompositorSurfaceProducer`] when Floem should +//! place the content as an image and remain free to flatten it for clips, +//! masks, filters, effects, or grouped rendering. + +use std::fmt; + +use peniko::kurbo::Size; +use winit::window::WindowId; + +use crate::{ + Application, + app::UserEvent, + compositor_surface::{CompositorSurfaceContent, ExternalTexture, SurfaceSlotId, SurfaceView}, + frame::FrameRatePreference, +}; + +/// Stable identity for a direct external compositor slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ExternalSurfaceId(SurfaceSlotId); + +impl ExternalSurfaceId { + #[must_use] + pub fn surface_slot_id(self) -> SurfaceSlotId { + self.0 + } +} + +/// Direct compositor slot supplied by an external producer. +/// +/// Use this for producer-owned textures or native layers that are submitted +/// directly to Floem rather than requested through a Floem frame callback. +/// Texture submissions are validated immediately. If a texture cannot be +/// attached directly, the submit call returns [`ExternalSurfaceError`]. +/// +/// Call [`Self::view`] to use the latest submitted content as an image brush in +/// the Floem scene. The external surface still owns submission and +/// presentation policy; the view is only the paint-facing handle. +#[derive(Clone, Debug)] +pub struct ExternalSurface { + id: ExternalSurfaceId, + window_id: WindowId, +} + +impl ExternalSurface { + /// Creates a direct external surface associated with `window_id`. + #[must_use] + pub fn new(window_id: WindowId) -> Self { + Self { + id: ExternalSurfaceId(SurfaceSlotId::next()), + window_id, + } + } + + #[must_use] + pub fn id(&self) -> ExternalSurfaceId { + self.id + } + + /// Returns the paint-facing view for this external surface. + /// + /// The returned view creates intrinsic surface images. Use paint transforms + /// or brush transforms to place/sample them differently. + #[must_use] + pub fn view(&self) -> SurfaceView { + SurfaceView::new( + self.window_id, + self.id.surface_slot_id(), + peniko::kurbo::Size::ZERO, + ) + } + + #[must_use] + pub fn handle(&self) -> ExternalSurfaceHandle { + ExternalSurfaceHandle { + id: self.id, + window_id: self.window_id, + } + } + + /// Submits a texture for direct compositor publication. + /// + /// Validation happens before the update is sent to the window. The texture + /// must wrap a Subduction-submitted surface frame with a compositor resource + /// key and a size matching `texture.size`. + pub fn submit_texture(&self, texture: ExternalTexture) -> Result<(), ExternalSurfaceError> { + self.handle().submit_texture(texture) + } + + /// Submits an opaque platform layer for direct compositor publication. + /// + /// Platform-specific layer types live in Subduction. Floem only stores and + /// orders the opaque `NativeLayer`; backend attachment happens during + /// compositor commit. + pub fn submit_native_layer( + &self, + native_layer: subduction::NativeLayer, + ) -> Result<(), ExternalSurfaceError> { + self.handle().submit_native_layer(native_layer) + } + + pub fn clear(&self) { + self.handle().clear(); + } + + /// Sets the frame-rate preference for this direct compositor layer. + pub fn set_frame_rate_preference(&self, frame_rate: FrameRatePreference) { + self.handle().set_frame_rate_preference(frame_rate); + } + + /// Sets whether submitted content publishes with the window compositor transaction. + /// + /// The default is `true`, which keeps external content synchronized with + /// the window's normal layer-tree commit. Passing `false` allows future + /// submitted content to publish independently until changed again. Use + /// independent publication only when the external layer does not need to + /// stay visually synchronized with Floem scene layers. + pub fn presents_with_transaction(&self, presents_with_transaction: bool) { + self.handle() + .presents_with_transaction(presents_with_transaction); + } +} + +/// Sendable handle for updating a direct external surface. +#[derive(Clone, Debug)] +pub struct ExternalSurfaceHandle { + id: ExternalSurfaceId, + window_id: WindowId, +} + +impl ExternalSurfaceHandle { + #[must_use] + pub fn id(&self) -> ExternalSurfaceId { + self.id + } + + /// Submits a texture for direct compositor publication. + /// + /// See [`ExternalSurface::submit_texture`] for validation rules. + pub fn submit_texture(&self, texture: ExternalTexture) -> Result<(), ExternalSurfaceError> { + validate_direct_texture(&texture)?; + Application::send_proxy_event(UserEvent::CompositorSurfaceContent { + window_id: self.window_id, + surface_id: self.id.surface_slot_id(), + content: CompositorSurfaceContent::Texture(texture), + }); + Ok(()) + } + + /// Submits an opaque platform layer for direct compositor publication. + pub fn submit_native_layer( + &self, + native_layer: subduction::NativeLayer, + ) -> Result<(), ExternalSurfaceError> { + Application::send_proxy_event(UserEvent::CompositorSurfaceContent { + window_id: self.window_id, + surface_id: self.id.surface_slot_id(), + content: CompositorSurfaceContent::NativeLayer(native_layer), + }); + Ok(()) + } + + pub fn clear(&self) { + Application::send_proxy_event(UserEvent::CompositorSurfaceContent { + window_id: self.window_id, + surface_id: self.id.surface_slot_id(), + content: CompositorSurfaceContent::Empty, + }); + } + + pub fn set_frame_rate_preference(&self, frame_rate: FrameRatePreference) { + Application::send_proxy_event(UserEvent::CompositorSurfaceFrameRatePreference { + window_id: self.window_id, + surface_id: self.id.surface_slot_id(), + frame_rate, + }); + } + + /// Sets whether submitted content publishes with the window compositor transaction. + /// + /// This is the sendable-handle equivalent of + /// [`ExternalSurface::presents_with_transaction`]. The setting persists for + /// future submissions until changed again. + pub fn presents_with_transaction(&self, presents_with_transaction: bool) { + Application::send_proxy_event(UserEvent::CompositorSurfacePresentsWithTransaction { + window_id: self.window_id, + surface_id: self.id.surface_slot_id(), + presents_with_transaction, + }); + } +} + +/// Error returned when direct external content cannot be accepted. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExternalSurfaceError { + /// The submitted texture has a zero width or height. + EmptyTextureSize, + /// The payload is not a direct-compositable Subduction submitted frame. + UnsupportedTexturePayload, + /// The submitted frame has no compositor resource key. + MissingCompositorResource, + /// The declared texture size does not match the submitted frame size. + SizeMismatch { + texture: (u32, u32), + submitted: (u32, u32), + }, +} + +impl fmt::Display for ExternalSurfaceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyTextureSize => write!(f, "external surface texture size must be non-zero"), + Self::UnsupportedTexturePayload => write!( + f, + "external surface texture is not directly publishable by the compositor" + ), + Self::MissingCompositorResource => write!( + f, + "external surface texture has no compositor resource key for direct publication" + ), + Self::SizeMismatch { texture, submitted } => write!( + f, + "external surface texture size {:?} does not match submitted frame size {:?}", + texture, submitted + ), + } + } +} + +impl std::error::Error for ExternalSurfaceError {} + +fn validate_direct_texture(texture: &ExternalTexture) -> Result<(), ExternalSurfaceError> { + if texture.size.width <= 0.0 || texture.size.height <= 0.0 { + return Err(ExternalSurfaceError::EmptyTextureSize); + } + let Some(frame) = texture + .payload + .downcast_ref::() + else { + return Err(ExternalSurfaceError::UnsupportedTexturePayload); + }; + if frame.resource_key.is_none() { + return Err(ExternalSurfaceError::MissingCompositorResource); + } + let texture_size = size_to_u32(texture.size); + let submitted_size = (frame.size.width, frame.size.height); + if texture_size != submitted_size { + return Err(ExternalSurfaceError::SizeMismatch { + texture: texture_size, + submitted: submitted_size, + }); + } + Ok(()) +} + +fn size_to_u32(size: Size) -> (u32, u32) { + ( + size.width.round().max(0.0) as u32, + size.height.round().max(0.0) as u32, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_external_surface_rejects_empty_texture_size() { + let texture = ExternalTexture::new(Size::ZERO, 7_u32); + assert_eq!( + validate_direct_texture(&texture), + Err(ExternalSurfaceError::EmptyTextureSize) + ); + } + + #[test] + fn direct_external_surface_rejects_non_compositor_payload() { + let texture = ExternalTexture::new(Size::new(16.0, 16.0), 7_u32); + assert_eq!( + validate_direct_texture(&texture), + Err(ExternalSurfaceError::UnsupportedTexturePayload) + ); + } +} diff --git a/src/frame.rs b/src/frame.rs new file mode 100644 index 000000000..976769b9f --- /dev/null +++ b/src/frame.rs @@ -0,0 +1,375 @@ +use crate::platform::{Duration, Instant}; +use understory_frame_pacing::{ + DisplayTiming as PacingDisplayTiming, Duration as PacingDuration, + FrameRatePlan as PacingFrameRatePlan, FrameRatePreference as PacingFrameRatePreference, + choose_frame_rate_source_interval, +}; + +/// Runtime display timing capabilities for the frame target. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisplayTiming { + /// The display refreshes at a fixed cadence. + Fixed { interval: Duration }, + /// The display can vary how long a frame remains visible. + Variable { + min_frame_interval: Duration, + max_frame_interval: Duration, + update_granularity: Option, + }, +} + +impl DisplayTiming { + #[must_use] + pub fn fixed(interval: Duration) -> Self { + Self::Fixed { interval } + } + + #[must_use] + pub fn is_variable(self) -> bool { + matches!(self, Self::Variable { .. }) + } +} + +/// Backend present pacing selected for a prepared frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PresentPacing { + /// Submit/present as soon as the backend accepts the frame. + AsSoonAsPossible, + /// Target an absolute presentation time. + AtTime(Instant), + /// On variable-rate displays, keep frames visible for at least this long. + AfterMinimumDuration(Duration), +} + +/// Frame-rate hint and acceptable fallback range. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameRatePreference(PacingFrameRatePreference); + +impl FrameRatePreference { + /// No throttling by this policy. + #[must_use] + pub const fn full() -> Self { + Self(PacingFrameRatePreference::full()) + } + + /// Runs no faster than `fps`, choosing a clean lower cadence on fixed-rate + /// displays when `fps` is not directly supported. + #[must_use] + pub fn at_most(fps: f64) -> Option { + PacingFrameRatePreference::at_most(fps).map(Self) + } + + /// Starts a preference builder with a preferred FPS. + #[must_use] + pub fn preferred(fps: f64) -> Option { + PacingFrameRatePreference::preferred(fps).map(FrameRatePreferenceBuilder) + } + + /// Starts a preference builder with an acceptable FPS range. + #[must_use] + pub fn range(min_fps: f64, max_fps: f64) -> Option { + PacingFrameRatePreference::range(min_fps, max_fps).map(FrameRatePreferenceBuilder) + } + + pub(crate) const fn pacing(self) -> PacingFrameRatePreference { + self.0 + } + + pub(crate) const fn is_full(self) -> bool { + matches!(self.0, PacingFrameRatePreference::Full) + } + + pub(crate) fn effective_interval(self, frame_time: Option) -> Option { + self.plan(frame_time) + .map(FrameRatePlan::delivery_interval) + .or_else(|| match frame_time { + Some(frame_time) => Some(frame_time.frame_interval), + None => self.0.preferred_interval().map(std_duration_from_pacing), + }) + } + + pub(crate) fn plan(self, frame_time: Option) -> Option { + let frame_time = frame_time?; + self.0 + .plan(pacing_display_timing(frame_time.interval.display_timing)) + .map(FrameRatePlan) + } +} + +/// Source and delivery cadence selected for a frame-rate preference. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct FrameRatePlan(PacingFrameRatePlan); + +impl FrameRatePlan { + #[must_use] + pub(crate) fn delivery_interval(self) -> Duration { + std_duration_from_pacing(self.0.delivery_interval()) + } +} + +impl Default for FrameRatePreference { + fn default() -> Self { + Self::full() + } +} + +impl From for FrameRatePreference { + fn from(fps: f64) -> Self { + Self::at_most(fps).unwrap_or_else(Self::full) + } +} + +/// Builder for [`FrameRatePreference`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameRatePreferenceBuilder(understory_frame_pacing::FrameRatePreferenceBuilder); + +impl FrameRatePreferenceBuilder { + /// Sets the preferred FPS. + #[must_use] + pub fn preferred(self, fps: f64) -> Option { + self.0.preferred(fps).map(Self) + } + + /// Sets the minimum acceptable FPS. + #[must_use] + pub fn minimum(self, fps: f64) -> Option { + self.0.minimum(fps).map(Self) + } + + /// Sets the maximum acceptable FPS. + #[must_use] + pub fn maximum(self, fps: f64) -> Option { + self.0.maximum(fps).map(Self) + } + + /// Sets an acceptable FPS range. + #[must_use] + pub fn range(self, min_fps: f64, max_fps: f64) -> Option { + self.0.range(min_fps, max_fps).map(Self) + } + + /// Builds the preference. + #[must_use] + pub const fn build(self) -> FrameRatePreference { + FrameRatePreference(self.0.build()) + } +} + +bitflags::bitflags! { + /// Reasons the frame is being produced. + /// + /// Multiple causes can be pending at once. The frame clock derives an + /// effective scheduling policy from the full set instead of forcing event + /// handling to collapse demand to a single priority too early. + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] + pub struct FrameDemand: u8 { + /// Discrete input such as key presses, clicks, taps, or IME. + const DISCRETE_INPUT = 1 << 0; + /// Continuous/coalesced input such as pointer move, scroll, or gesture. + const CONTINUOUS_INPUT = 1 << 1; + /// Animation timeline callbacks. + const ANIMATION = 1 << 2; + /// Compositor-surface producers requesting frame cadence. + const COMPOSITOR_SURFACE = 1 << 3; + } +} + +impl FrameDemand { + /// Take the current demand and leave the source empty. + #[must_use] + pub fn take(&mut self) -> Self { + let demand = *self; + *self = Self::empty(); + demand + } +} + +/// Renderer timing observations for scheduler feedback. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FrameTimingFeedback { + pub render_cpu: Option, + pub present_cpu: Option, + pub gpu: Option, +} + +/// The presentation interval targeted by the current frame opportunity. +#[derive(Clone, Copy, Debug)] +pub struct PresentationInterval { + pub deadline_min: Instant, + pub deadline_max: Instant, + pub predicted_present: Option, + pub display_timing: DisplayTiming, + pub present_pacing: PresentPacing, + pub background_rendering: bool, +} + +/// Timing information delivered to begin-frame callbacks. +#[derive(Clone, Copy, Debug)] +pub struct FrameTime { + pub now: Instant, + pub interval: PresentationInterval, + pub source_interval: Duration, + pub frame_interval: Duration, + pub frame_index: u64, +} + +/// Repeat policy for an animation-frame callback. +/// +/// Floem removes due callbacks before dispatch, so callbacks may schedule or +/// cancel other callbacks while they run. A repeating callback is reinserted +/// after dispatch unless it has been cancelled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameCallbackRepeat { + /// Run once at the next eligible begin-frame opportunity. + None, + /// Run at each eligible begin-frame opportunity. + EveryFrame, +} + +impl FrameCallbackRepeat { + /// Creates a one-shot policy. + #[must_use] + pub const fn none() -> Self { + Self::None + } + + /// Creates a repeating policy. + #[must_use] + pub const fn every_frame() -> Self { + Self::EveryFrame + } + + /// Returns whether this policy repeats after dispatch. + #[must_use] + pub const fn is_repeating(self) -> bool { + matches!(self, Self::EveryFrame) + } +} + +#[must_use] +pub(crate) fn frame_rate_due(frame_time: FrameTime, preference: FrameRatePreference) -> bool { + preference.pacing().should_deliver( + frame_time.frame_index, + pacing_display_timing(frame_time.interval.display_timing), + pacing_duration_from_std(frame_time.source_interval), + ) +} + +#[must_use] +pub(crate) fn frame_rate_interval( + preference: FrameRatePreference, + frame_time: Option, +) -> Option { + preference.effective_interval(frame_time) +} + +#[must_use] +pub(crate) fn frame_rate_source_interval( + preferences: &[FrameRatePreference], + display_timing: DisplayTiming, +) -> Duration { + let preferences = preferences + .iter() + .map(|preference| preference.pacing()) + .collect::>(); + std_duration_from_pacing(choose_frame_rate_source_interval( + &preferences, + pacing_display_timing(display_timing), + )) +} + +fn pacing_display_timing(display_timing: DisplayTiming) -> PacingDisplayTiming { + match display_timing { + DisplayTiming::Fixed { interval } => { + PacingDisplayTiming::fixed(pacing_duration_from_std(interval)) + } + DisplayTiming::Variable { + min_frame_interval, + max_frame_interval, + update_granularity, + } => PacingDisplayTiming::variable( + pacing_duration_from_std(min_frame_interval), + pacing_duration_from_std(max_frame_interval), + update_granularity.map(pacing_duration_from_std), + ), + } +} + +fn pacing_duration_from_std(duration: Duration) -> PacingDuration { + PacingDuration::from_nanos(duration.as_nanos().min(u64::MAX as u128) as u64) +} + +fn std_duration_from_pacing(duration: PacingDuration) -> Duration { + Duration::from_nanos(duration.as_nanos()) +} + +/// Outcome information for content prepared during a frame. +#[derive(Clone, Copy, Debug, Default)] +pub struct FrameOutcome { + pub draw_attempted: bool, + pub draw_completed: bool, + pub missed_deadline: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn variable_47_to_75hz() -> DisplayTiming { + DisplayTiming::Variable { + min_frame_interval: Duration::from_nanos(13_333_333), + max_frame_interval: Duration::from_nanos(21_276_596), + update_granularity: None, + } + } + + fn frame_time(frame_index: u64, source_interval: Duration) -> FrameTime { + let now = Instant::now(); + FrameTime { + now, + interval: PresentationInterval { + deadline_min: now, + deadline_max: now, + predicted_present: Some(now), + display_timing: variable_47_to_75hz(), + present_pacing: PresentPacing::AtTime(now), + background_rendering: false, + }, + source_interval, + frame_interval: Duration::from_nanos(13_333_333), + frame_index, + } + } + + #[test] + fn throttling_uses_selected_source_interval_not_reported_display_interval() { + let preference = FrameRatePreference::at_most(60.0).unwrap(); + let source_interval = Duration::from_nanos(16_666_667); + + assert!((0..75).all(|frame_index| frame_rate_due( + frame_time(frame_index, source_interval), + preference + ))); + } + + #[test] + fn throttling_divides_full_rate_source_when_full_consumer_keeps_source_fast() { + let source_interval = Duration::from_nanos(13_333_333); + let at_most_60 = FrameRatePreference::at_most(60.0).unwrap(); + let at_most_30 = FrameRatePreference::at_most(30.0).unwrap(); + + let delivered_60 = (0..75) + .filter(|frame_index| { + frame_rate_due(frame_time(*frame_index, source_interval), at_most_60) + }) + .count(); + let delivered_30 = (0..75) + .filter(|frame_index| { + frame_rate_due(frame_time(*frame_index, source_interval), at_most_30) + }) + .count(); + + assert_eq!(delivered_60, 60); + assert_eq!(delivered_30, 30); + } +} diff --git a/src/frame_source.rs b/src/frame_source.rs new file mode 100644 index 000000000..df936dcc3 --- /dev/null +++ b/src/frame_source.rs @@ -0,0 +1,316 @@ +use crate::{ + Application, + app::UserEvent, + frame::{DisplayTiming, FrameTime, PresentPacing, PresentationInterval}, + platform::{Duration, Instant}, +}; +use raw_window_handle::HasWindowHandle; +use subduction::{ + FrameSourceDisplayTiming, FrameSourceTarget, frame_source::FrameSource as SubductionFrameSource, +}; +use subduction_core::output::OutputId; +use winit::window::{Window as WinitWindow, WindowId}; + +pub(crate) fn frame_pacing_diag_enabled() -> bool { + std::env::var_os("FLOEM_FRAME_PACING_DIAG").is_some() +} + +pub(crate) fn diagnostic_logs_enabled() -> bool { + std::env::var_os("FLOEM_DIAG").is_some() + || frame_pacing_diag_enabled() + || std::env::var_os("FLOEM_SUBDUCTION_TIMING_DIAG").is_some() + || std::env::var_os("FLOEM_CUBE_DIAG").is_some() + || std::env::var_os("FLOEM_RESIZE_DIAG").is_some() +} + +pub(crate) fn duration_ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1000.0 +} + +pub(crate) fn duration_hz(duration: Duration) -> f64 { + let seconds = duration.as_secs_f64(); + if seconds > 0.0 { 1.0 / seconds } else { 0.0 } +} + +pub(crate) fn window_frame_interval(window: &dyn WinitWindow) -> Duration { + window + .current_monitor() + .and_then(|monitor| monitor.current_video_mode()) + .and_then(|mode| mode.refresh_rate_millihertz()) + .map(|mhz| Duration::from_nanos(1_000_000_000_000 / mhz.get() as u64)) + .unwrap_or(Duration::from_millis(16)) +} + +#[derive(Debug)] +pub(crate) struct FrameSource { + window_id: WindowId, + inner: SubductionFrameSource, + target: Option, + preferred_source_interval: Option, + observed_source_interval: Option, + last_logged_observed_source_interval: Option, + latest_tick_received_at: Option, + preferred_source_millihertz: Option, + active: bool, +} + +pub(crate) fn new_window_frame_source(window_id: WindowId, output_id: u32) -> FrameSource { + let inner = SubductionFrameSource::new(OutputId(output_id), move |tick| { + if frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem frame pacing display link callback window={:?} tick={} predicted={:?} refresh={:?}", + window_id, + tick.frame_index, + tick.predicted_present, + tick.refresh_interval, + ); + } + Application::send_proxy_event(UserEvent::FrameTick { window_id, tick }); + }); + FrameSource { + window_id, + inner, + target: None, + preferred_source_interval: None, + observed_source_interval: None, + last_logged_observed_source_interval: None, + latest_tick_received_at: None, + preferred_source_millihertz: None, + active: false, + } +} + +impl FrameSource { + pub(crate) fn frame_interval(&mut self, window: &dyn WinitWindow) -> Duration { + self.inner.frame_interval(window_frame_interval(window)) + } + + pub(crate) fn display_timing(&self, fallback: Duration) -> DisplayTiming { + match self.inner.display_timing(fallback) { + FrameSourceDisplayTiming::Fixed { interval_ns } => { + DisplayTiming::fixed(Duration::from_nanos(interval_ns)) + } + FrameSourceDisplayTiming::Variable { + min_interval_ns, + max_interval_ns, + update_granularity_ns, + } => DisplayTiming::Variable { + min_frame_interval: Duration::from_nanos(min_interval_ns), + max_frame_interval: Duration::from_nanos(max_interval_ns), + update_granularity: update_granularity_ns.map(Duration::from_nanos), + }, + } + } + + pub(crate) fn refresh_window_target(&mut self, window: &dyn WinitWindow) -> bool { + let monitor = window.current_monitor(); + let raw_window_handle = window.window_handle().ok().map(|handle| handle.as_raw()); + let target = FrameSourceTarget { + monitor_name: monitor + .as_ref() + .and_then(|monitor| monitor.name().map(|name| name.to_string())), + refresh_millihertz: monitor + .as_ref() + .and_then(|monitor| monitor.current_video_mode()) + .and_then(|mode| mode.refresh_rate_millihertz()) + .map(|rate| rate.get()), + raw_window_handle, + }; + if frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem frame source target window={:?} monitor={:?} refresh_millihz={:?}", + self.window_id, + target.monitor_name, + target.refresh_millihertz, + ); + } + let changed = self.target.as_ref() != Some(&target); + if !changed { + return false; + } + self.target = Some(target.clone()); + self.inner.refresh_target(target); + if self.active && self.preferred_source_millihertz.is_some() { + self.inner + .set_preferred_frame_rate_millihertz(self.preferred_source_millihertz); + } + true + } + + pub(crate) fn set_preferred_source_interval(&mut self, interval: Duration) { + let nanos = interval.as_nanos(); + let millihertz = (nanos > 0) + .then(|| ((1_000_000_000_000u128 + (nanos / 2)) / nanos).min(u32::MAX as u128) as u32); + if self.preferred_source_interval == Some(interval) + && self.preferred_source_millihertz == millihertz + { + return; + } + self.preferred_source_interval = Some(interval); + self.preferred_source_millihertz = millihertz; + if frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem frame source preferred window={:?} interval={:.3}ms hz={:.3} millihertz={:?} observed={:?}", + self.window_id, + duration_ms(interval), + duration_hz(interval), + self.preferred_source_millihertz, + self.observed_source_interval + .map(|interval| (duration_ms(interval), duration_hz(interval))), + ); + } + if self.active { + self.inner + .set_preferred_frame_rate_millihertz(self.preferred_source_millihertz); + } else if frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem frame source preferred deferred window={:?} millihertz={:?}", + self.window_id, + self.preferred_source_millihertz, + ); + } + } + + pub(crate) fn current_frame_time( + &mut self, + window: &dyn WinitWindow, + now: Instant, + background_rendering: bool, + ) -> FrameTime { + let frame_interval = self.frame_interval(window); + let source_interval = self + .preferred_source_interval + .or(self.observed_source_interval) + .unwrap_or(frame_interval); + let display_timing = self.display_timing(frame_interval); + if let Some(tick) = self.inner.latest_tick() + && tick.predicted_present.is_some() + { + let predicted_present = self.tick_predicted_present_instant(tick, now); + return FrameTime { + now: predicted_present, + interval: PresentationInterval { + deadline_min: now, + deadline_max: predicted_present, + predicted_present: Some(predicted_present), + display_timing, + present_pacing: PresentPacing::AtTime(predicted_present), + background_rendering, + }, + source_interval, + frame_interval, + frame_index: tick.frame_index, + }; + } + + let predicted_present = now.checked_add(frame_interval).unwrap_or(now); + FrameTime { + now: predicted_present, + interval: PresentationInterval { + deadline_min: now, + deadline_max: predicted_present, + predicted_present: Some(predicted_present), + display_timing, + present_pacing: PresentPacing::AtTime(predicted_present), + background_rendering, + }, + source_interval, + frame_interval, + frame_index: self.inner.frame_counter(), + } + } + + pub(crate) fn receive_frame_tick(&mut self, tick: subduction_core::timing::FrameTick) { + self.latest_tick_received_at = Some(Instant::now()); + self.observed_source_interval = self + .inner + .latest_tick() + .and_then(|previous| { + previous.predicted_present.zip(tick.predicted_present).map( + |(previous_present, predicted_present)| { + let previous = self.inner.host_to_instant(previous_present); + let predicted = self.inner.host_to_instant(predicted_present); + if predicted >= previous { + predicted.saturating_duration_since(previous) + } else { + previous.saturating_duration_since(predicted) + } + }, + ) + }) + .filter(|interval| !interval.is_zero()); + if frame_pacing_diag_enabled() + && self.observed_source_interval.is_some() + && self.last_logged_observed_source_interval != self.observed_source_interval + { + let interval = self.observed_source_interval.unwrap(); + crate::floem_debug_log!( + "floem frame source observed window={:?} interval={:.3}ms hz={:.3} preferred={:?}", + self.window_id, + duration_ms(interval), + duration_hz(interval), + self.preferred_source_interval + .map(|interval| (duration_ms(interval), duration_hz(interval))), + ); + self.last_logged_observed_source_interval = self.observed_source_interval; + } + self.inner.receive_frame_tick(tick); + } + + fn tick_predicted_present_instant( + &self, + tick: subduction_core::timing::FrameTick, + now: Instant, + ) -> Instant { + let Some(predicted_present) = tick.predicted_present else { + return now; + }; + let absolute = self.inner.host_to_instant(predicted_present); + if absolute > now { + return absolute; + } + + let relative = if predicted_present >= tick.now { + let tick_now = self.inner.host_to_instant(tick.now); + let tick_predicted = self.inner.host_to_instant(predicted_present); + let delta = tick_predicted.saturating_duration_since(tick_now); + self.latest_tick_received_at + .unwrap_or(now) + .checked_add(delta) + .unwrap_or(now) + } else { + now + }; + if frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem frame source stale predicted window={:?} tick={} absolute_late_by={:.3}ms relative_from_tick={:.3}ms", + self.window_id, + tick.frame_index, + now.saturating_duration_since(absolute).as_secs_f64() * 1000.0, + relative.saturating_duration_since(now).as_secs_f64() * 1000.0, + ); + } + relative.max(now) + } + + pub(crate) fn latest_tick(&self) -> Option { + self.inner.latest_tick() + } + + pub(crate) fn set_active(&mut self, active: bool) { + let changed = self.active != active; + self.active = active; + if changed && active { + if frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem frame source preferred apply-on-active window={:?} millihertz={:?}", + self.window_id, + self.preferred_source_millihertz, + ); + } + self.inner + .set_preferred_frame_rate_millihertz(self.preferred_source_millihertz); + } + self.inner.set_active(active); + } +} diff --git a/src/gpu_completion.rs b/src/gpu_completion.rs new file mode 100644 index 000000000..ca0e44162 --- /dev/null +++ b/src/gpu_completion.rs @@ -0,0 +1,29 @@ +use crate::platform::Instant; + +#[cfg(target_os = "macos")] +use std::sync::{Arc, Mutex}; + +#[cfg(target_os = "macos")] +pub(crate) fn notify_after_metal_queue_completion( + queue: &wgpu::Queue, + callback: Box, +) -> Result<(), Box> { + let Some(hal_queue) = (unsafe { queue.as_hal::() }) else { + return Err(callback); + }; + let raw_queue = hal_queue.as_raw().lock(); + let command_buffer = raw_queue.new_command_buffer(); + command_buffer.set_label("floem gpu completion sentinel"); + + let callback = Arc::new(Mutex::new(Some(callback))); + let block_callback = callback.clone(); + let block = block::ConcreteBlock::new(move |_| { + if let Some(callback) = block_callback.lock().unwrap().take() { + callback(Instant::now()); + } + }) + .copy(); + command_buffer.add_completed_handler(&block); + command_buffer.commit(); + Ok(()) +} diff --git a/renderer/src/gpu_resources.rs b/src/gpu_resources.rs similarity index 59% rename from renderer/src/gpu_resources.rs rename to src/gpu_resources.rs index d14dca7a9..de8d1fd60 100644 --- a/renderer/src/gpu_resources.rs +++ b/src/gpu_resources.rs @@ -24,58 +24,38 @@ pub struct GpuResources { /// The wgpu instance pub instance: wgpu::Instance, - /// The adapter that represents the GPU or a rendering backend. It provides information about - /// the capabilities of the hardware and is used to request a logical device (`wgpu::Device`). + /// The adapter that represents the GPU or a rendering backend. pub adapter: wgpu::Adapter, - /// The logical device that serves as an interface to the GPU. It is responsible for creating - /// resources such as buffers, textures, and pipelines, and manages the execution of commands. - /// The `device` provides a connection to the physical hardware represented by the `adapter`. + /// The logical device that serves as an interface to the GPU. pub device: wgpu::Device, - /// The command queue that manages the submission of command buffers to the GPU for execution. - /// It is used to send rendering and computation commands to the device. The `queue` ensures - /// that commands are executed in the correct order and manages synchronization. + /// The command queue that manages the submission of command buffers to the GPU. pub queue: wgpu::Queue, } impl GpuResources { - /// Request GPU resources - /// - /// # Parameters - /// - `on_result`: Function to notify upon completion or error. - /// - `window`: The window to associate with the created surface. + /// Request GPU resources. pub fn request( on_result: F, required_features: wgpu::Features, backends: Option, window: Arc, - ) -> Receiver), GpuResourceError>> { + ) -> Receiver> + { let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { backends: Backends::from_env().or(backends).unwrap_or(Backends::all()), flags: InstanceFlags::from_env_or_default(), ..Default::default() }); - // Channel passing to do async out-of-band within the winit event_loop since wasm can't - // execute futures with a return value let (tx, rx) = sync_channel(1); spawn({ async move { - let surface = match instance.create_surface(Arc::clone(&window)) { - Ok(surface) => surface, - Err(err) => { - tx.send(Err(GpuResourceError::SurfaceCreationError(err))) - .unwrap(); - on_result(window.id()); - return; - } - }; - let Ok(adapter) = instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::default(), - compatible_surface: Some(&surface), + compatible_surface: None, force_fallback_adapter: false, }) .await @@ -86,22 +66,45 @@ impl GpuResources { return; }; + let surface_caps = subduction::wgpu::ExternalSurfaceCapabilities { + formats: vec![ + wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Bgra8Unorm, + ], + color_spaces: vec![subduction::wgpu::SurfaceColorSpace::Srgb], + dynamic_ranges: vec![subduction::wgpu::SurfaceDynamicRange::Standard], + alpha_modes: vec![ + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Opaque, + ], + usages: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + max_size: None, + supports_frame_opportunities: true, + supports_render_targets: true, + supports_submitted_textures: true, + }; + let request_result = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("floem shared wgpu device"), + required_features, + ..Default::default() + }) + .await; tx.send( - adapter - .request_device(&wgpu::DeviceDescriptor { - label: None, - required_features, - ..Default::default() - }) - .await + request_result .map_err(GpuResourceError::DeviceRequestError) - .map(|(device, queue)| Self { - adapter, - device, - queue, - instance, - }) - .map(|res| (res, surface)), + .map(|(device, queue)| { + ( + Self { + adapter, + device, + queue, + instance, + }, + surface_caps, + ) + }), ) .unwrap(); on_result(window.id()); @@ -114,7 +117,6 @@ impl GpuResources { /// Possible errors during GPU resource setup. #[derive(Debug)] pub enum GpuResourceError { - SurfaceCreationError(wgpu::CreateSurfaceError), AdapterNotFoundError, DeviceRequestError(wgpu::RequestDeviceError), } @@ -122,9 +124,6 @@ pub enum GpuResourceError { impl std::fmt::Display for GpuResourceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - GpuResourceError::SurfaceCreationError(err) => { - write!(f, "Surface creation error: {err}") - } GpuResourceError::AdapterNotFoundError => { write!(f, "Failed to find a suitable GPU adapter") } @@ -134,10 +133,6 @@ impl std::fmt::Display for GpuResourceError { } /// Spawns a future for execution, adapting to the target environment. -/// -/// On WASM (`wasm32`), it uses `wasm_bindgen_futures::spawn_local` to avoid blocking -/// the main thread. On other targets, it uses `pollster::block_on` to synchronously -/// wait for the future to complete. pub fn spawn(future: F) where F: Future + 'static, diff --git a/src/gradient.rs b/src/gradient.rs new file mode 100644 index 000000000..4165888a8 --- /dev/null +++ b/src/gradient.rs @@ -0,0 +1,508 @@ +//! Floem gradient values. +//! +//! Floem keeps a parallel gradient type so color stops can use Floem units. +//! The value lowers to Peniko only once paint bounds are known. + +use std::ops::{Deref, DerefMut}; + +use peniko::{ + Extend, InterpolationAlphaSpace, + color::{AlphaColor, ColorSpace, ColorSpaceTag, DynamicColor, HueDirection, OpaqueColor}, + kurbo::{Point, Rect}, +}; + +use crate::unit::{FontSizeCx, Length, Pct}; + +const DEFAULT_GRADIENT_COLOR_SPACE: ColorSpaceTag = ColorSpaceTag::Srgb; + +/// Bounds-resolved point used by Floem gradients. +/// +/// Numeric tuple coordinates are absolute Floem points. Use `pct()` or +/// [`Length`] for coordinates relative to the bounds of the painted geometry. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GradientPoint { + pub x: Length, + pub y: Length, +} + +impl GradientPoint { + #[must_use] + pub fn resolve(self, bounds: Rect, font_size: &FontSizeCx) -> Point { + Point::new( + bounds.x0 + self.x.resolve(bounds.width(), font_size), + bounds.y0 + self.y.resolve(bounds.height(), font_size), + ) + } +} + +impl From for GradientPoint { + fn from(value: Point) -> Self { + Self { + x: Length::Pt(value.x), + y: Length::Pt(value.y), + } + } +} + +impl From<(X, Y)> for GradientPoint +where + X: Into, + Y: Into, +{ + fn from(value: (X, Y)) -> Self { + Self { + x: value.0.into(), + y: value.1.into(), + } + } +} + +/// Offset and color of a transition point in a Floem gradient. +#[derive(Clone, Debug, PartialEq)] +pub struct ColorStop { + pub offset: Length, + pub color: DynamicColor, +} + +impl ColorStop { + #[must_use] + pub fn with_alpha(mut self, alpha: f32) -> Self { + self.color = self.color.with_alpha(alpha); + self + } + + #[must_use] + pub fn multiply_alpha(mut self, alpha: f32) -> Self { + self.color = self.color.multiply_alpha(alpha); + self + } +} + +macro_rules! impl_color_stop_tuple { + ($offset:ty, $to_length:expr) => { + impl From<($offset, AlphaColor)> for ColorStop { + fn from(pair: ($offset, AlphaColor)) -> Self { + Self { + offset: $to_length(pair.0), + color: DynamicColor::from_alpha_color(pair.1), + } + } + } + + impl From<($offset, DynamicColor)> for ColorStop { + fn from(pair: ($offset, DynamicColor)) -> Self { + Self { + offset: $to_length(pair.0), + color: pair.1, + } + } + } + + impl From<($offset, OpaqueColor)> for ColorStop { + fn from(pair: ($offset, OpaqueColor)) -> Self { + Self { + offset: $to_length(pair.0), + color: DynamicColor::from_alpha_color(pair.1.with_alpha(1.0)), + } + } + } + }; +} + +impl_color_stop_tuple!(Length, |offset| offset); +impl_color_stop_tuple!(Pct, |offset: Pct| Length::Pct(offset.0)); +impl_color_stop_tuple!(f32, |offset: f32| Length::Pct(f64::from(offset) * 100.0)); +impl_color_stop_tuple!(f64, |offset: f64| Length::Pct(offset * 100.0)); +impl_color_stop_tuple!(i32, |offset: i32| Length::Pct(f64::from(offset) * 100.0)); + +impl From for ColorStop { + fn from(stop: peniko::ColorStop) -> Self { + Self { + offset: Length::Pct(f64::from(stop.offset) * 100.0), + color: stop.color, + } + } +} + +/// Collection of Floem gradient stops. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ColorStops(pub smallvec::SmallVec<[ColorStop; 4]>); + +impl Deref for ColorStops { + type Target = smallvec::SmallVec<[ColorStop; 4]>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ColorStops { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From<&[ColorStop]> for ColorStops { + fn from(value: &[ColorStop]) -> Self { + Self(value.into()) + } +} + +impl From for ColorStops { + fn from(value: peniko::ColorStops) -> Self { + Self(value.iter().cloned().map(Into::into).collect()) + } +} + +impl From<&peniko::ColorStops> for ColorStops { + fn from(value: &peniko::ColorStops) -> Self { + Self(value.iter().cloned().map(Into::into).collect()) + } +} + +/// Source of Floem gradient stops. +pub trait ColorStopsSource { + fn collect_stops(self, stops: &mut ColorStops); +} + +impl ColorStopsSource for &'_ [T] +where + T: Into + Clone, +{ + fn collect_stops(self, stops: &mut ColorStops) { + stops.extend(self.iter().cloned().map(Into::into)); + } +} + +impl ColorStopsSource for [T; N] +where + T: Into, +{ + fn collect_stops(self, stops: &mut ColorStops) { + stops.extend(self.into_iter().map(Into::into)); + } +} + +impl ColorStopsSource for &'_ [AlphaColor] { + fn collect_stops(self, stops: &mut ColorStops) { + if !self.is_empty() { + let denom = (self.len() - 1).max(1) as f32; + stops.extend(self.iter().enumerate().map(|(i, c)| ColorStop { + offset: Length::Pct(f64::from((i as f32) / denom) * 100.0), + color: DynamicColor::from_alpha_color(*c), + })); + } + } +} + +impl ColorStopsSource for [AlphaColor; N] { + fn collect_stops(self, stops: &mut ColorStops) { + self.as_slice().collect_stops(stops); + } +} + +impl ColorStopsSource for &'_ [DynamicColor] { + fn collect_stops(self, stops: &mut ColorStops) { + if !self.is_empty() { + let denom = (self.len() - 1).max(1) as f32; + stops.extend(self.iter().enumerate().map(|(i, c)| ColorStop { + offset: Length::Pct(f64::from((i as f32) / denom) * 100.0), + color: *c, + })); + } + } +} + +impl ColorStopsSource for [DynamicColor; N] { + fn collect_stops(self, stops: &mut ColorStops) { + self.as_slice().collect_stops(stops); + } +} + +impl ColorStopsSource for &'_ [OpaqueColor] { + fn collect_stops(self, stops: &mut ColorStops) { + if !self.is_empty() { + let denom = (self.len() - 1).max(1) as f32; + stops.extend(self.iter().enumerate().map(|(i, c)| ColorStop { + offset: Length::Pct(f64::from((i as f32) / denom) * 100.0), + color: DynamicColor::from_alpha_color(c.with_alpha(1.0)), + })); + } + } +} + +impl ColorStopsSource for [OpaqueColor; N] { + fn collect_stops(self, stops: &mut ColorStops) { + self.as_slice().collect_stops(stops); + } +} + +/// Floem gradient definition. +/// +/// This is intentionally separate from [`peniko::Gradient`]. Peniko gradients +/// are renderer-ready values with concrete geometry and normalized color-stop +/// offsets. Floem gradients can keep geometry and stops as [`Length`] values, +/// then resolve them against the bounds of the fill or stroke geometry while +/// lowering the display list. For a background, that geometry is the full view +/// bounds; for explicit painter fills/strokes, it is the painted shape's bounds. +/// +/// Numeric stop offsets keep Peniko semantics: `0.0` is the start and `1.0` is +/// the end. Use `pct()`/`Length::Pct` when the stop should be expressed as a +/// Floem length: +/// +/// ``` +/// # use floem::{Gradient, kurbo::Point, peniko::Color, prelude::*}; +/// # let red = Color::from_rgb8(255, 0, 0); +/// # let blue = Color::from_rgb8(0, 0, 255); +/// let gradient = Gradient::new_linear((0.0.pct(), 0.0.pct()), (100.0.pct(), 0.0.pct())) +/// .with_stops([ +/// (0.0.pct(), red), +/// (100.0.pct(), blue), +/// ]); +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct Gradient { + pub kind: peniko::GradientKind, + pub extend: Extend, + pub interpolation_cs: ColorSpaceTag, + pub hue_direction: HueDirection, + pub interpolation_alpha_space: InterpolationAlphaSpace, + pub stops: ColorStops, + pub(crate) geometry: GradientGeometry, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) enum GradientGeometry { + #[default] + Absolute, + Linear { + start: GradientPoint, + end: GradientPoint, + }, + Radial { + center: GradientPoint, + }, + TwoPointRadial { + start_center: GradientPoint, + end_center: GradientPoint, + }, + Sweep { + center: GradientPoint, + }, +} + +impl Default for Gradient { + fn default() -> Self { + Self { + kind: peniko::LinearGradientPosition { + start: Point::default(), + end: Point::default(), + } + .into(), + extend: Extend::default(), + interpolation_cs: DEFAULT_GRADIENT_COLOR_SPACE, + hue_direction: HueDirection::default(), + interpolation_alpha_space: InterpolationAlphaSpace::default(), + stops: ColorStops::default(), + geometry: GradientGeometry::Absolute, + } + } +} + +impl Gradient { + pub fn new_linear(start: impl Into, end: impl Into) -> Self { + let start = start.into(); + let end = end.into(); + let kind = peniko::LinearGradientPosition::new( + start.resolve(Rect::ZERO, &FontSizeCx::new(14.0, 16.0)), + end.resolve(Rect::ZERO, &FontSizeCx::new(14.0, 16.0)), + ) + .into(); + Self { + kind, + geometry: GradientGeometry::Linear { start, end }, + ..Self::default() + } + } + + pub fn new_radial(center: impl Into, radius: f32) -> Self { + let center = center.into(); + Self { + kind: peniko::RadialGradientPosition::new( + center.resolve(Rect::ZERO, &FontSizeCx::new(14.0, 16.0)), + radius, + ) + .into(), + geometry: GradientGeometry::Radial { center }, + ..Self::default() + } + } + + pub fn new_two_point_radial( + start_center: impl Into, + start_radius: f32, + end_center: impl Into, + end_radius: f32, + ) -> Self { + let start_center = start_center.into(); + let end_center = end_center.into(); + Self { + kind: peniko::RadialGradientPosition::new_two_point( + start_center.resolve(Rect::ZERO, &FontSizeCx::new(14.0, 16.0)), + start_radius, + end_center.resolve(Rect::ZERO, &FontSizeCx::new(14.0, 16.0)), + end_radius, + ) + .into(), + geometry: GradientGeometry::TwoPointRadial { + start_center, + end_center, + }, + ..Self::default() + } + } + + pub fn new_sweep(center: impl Into, start_angle: f32, end_angle: f32) -> Self { + let center = center.into(); + Self { + kind: peniko::SweepGradientPosition::new( + center.resolve(Rect::ZERO, &FontSizeCx::new(14.0, 16.0)), + start_angle, + end_angle, + ) + .into(), + geometry: GradientGeometry::Sweep { center }, + ..Self::default() + } + } + + #[must_use] + pub fn with_extend(mut self, mode: Extend) -> Self { + self.extend = mode; + self + } + + #[must_use] + pub fn with_interpolation_cs(mut self, interpolation_cs: ColorSpaceTag) -> Self { + self.interpolation_cs = interpolation_cs; + self + } + + #[must_use] + pub fn with_interpolation_alpha_space( + mut self, + interpolation_alpha_space: InterpolationAlphaSpace, + ) -> Self { + self.interpolation_alpha_space = interpolation_alpha_space; + self + } + + #[must_use] + pub fn with_hue_direction(mut self, hue_direction: HueDirection) -> Self { + self.hue_direction = hue_direction; + self + } + + #[must_use] + pub fn with_stops(mut self, stops: impl ColorStopsSource) -> Self { + self.stops.clear(); + stops.collect_stops(&mut self.stops); + self + } + + #[must_use] + pub fn with_alpha(mut self, alpha: f32) -> Self { + self.stops + .iter_mut() + .for_each(|stop| *stop = stop.clone().with_alpha(alpha)); + self + } + + #[must_use] + pub fn multiply_alpha(mut self, alpha: f32) -> Self { + self.stops + .iter_mut() + .for_each(|stop| *stop = stop.clone().multiply_alpha(alpha)); + self + } + + pub(crate) fn to_peniko(&self, bounds: Rect, font_size: &FontSizeCx) -> peniko::Gradient { + let kind = match (self.kind, self.geometry) { + (peniko::GradientKind::Linear(_), GradientGeometry::Linear { start, end }) => { + peniko::LinearGradientPosition::new( + start.resolve(bounds, font_size), + end.resolve(bounds, font_size), + ) + .into() + } + (peniko::GradientKind::Radial(position), GradientGeometry::Radial { center }) => { + peniko::RadialGradientPosition::new( + center.resolve(bounds, font_size), + position.end_radius, + ) + .into() + } + ( + peniko::GradientKind::Radial(position), + GradientGeometry::TwoPointRadial { + start_center, + end_center, + }, + ) => peniko::RadialGradientPosition::new_two_point( + start_center.resolve(bounds, font_size), + position.start_radius, + end_center.resolve(bounds, font_size), + position.end_radius, + ) + .into(), + (peniko::GradientKind::Sweep(position), GradientGeometry::Sweep { center }) => { + peniko::SweepGradientPosition::new( + center.resolve(bounds, font_size), + position.start_angle, + position.end_angle, + ) + .into() + } + (kind, _) => kind, + }; + let reference = match kind { + peniko::GradientKind::Linear(position) => position.start.distance(position.end), + peniko::GradientKind::Radial(position) => f64::from(position.end_radius.max(1.0)), + peniko::GradientKind::Sweep(_) => bounds.size().min_side().max(1.0), + }; + let stops = self + .stops + .iter() + .map(|stop| peniko::ColorStop { + offset: if reference <= f64::EPSILON { + 0.0 + } else { + (stop.offset.resolve(reference, font_size) / reference) as f32 + }, + color: stop.color, + }) + .collect::>(); + peniko::Gradient { + kind, + extend: self.extend, + interpolation_cs: self.interpolation_cs, + hue_direction: self.hue_direction, + interpolation_alpha_space: self.interpolation_alpha_space, + stops: peniko::ColorStops(stops), + } + } +} + +impl From for Gradient { + fn from(value: peniko::Gradient) -> Self { + Self { + kind: value.kind, + extend: value.extend, + interpolation_cs: value.interpolation_cs, + hue_direction: value.hue_direction, + interpolation_alpha_space: value.interpolation_alpha_space, + stops: ColorStops::from(value.stops), + geometry: GradientGeometry::Absolute, + } + } +} diff --git a/src/headless.rs b/src/headless.rs index a757d067f..9e6ff5b04 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -30,7 +30,8 @@ use crate::context::InteractionState; use crate::event::Event; use crate::event::path::hit_test; use crate::paint::{ - clear_paint_order, disable_paint_order_tracking, enable_paint_order_tracking, get_paint_order, + PaintStats, clear_paint_order, disable_paint_order_tracking, enable_paint_order_tracking, + get_paint_order, }; use crate::style::{Style, StyleSelector}; use crate::view::IntoView; @@ -187,8 +188,10 @@ impl HeadlessHarness { /// assert!(needs_repaint, "Style change should trigger repaint"); /// ``` pub fn process_update_no_paint(&mut self) { - // Process any scheduled updates (style/layout requests from previous frame) - self.window_handle.process_scheduled_updates(); + // Promote any next-frame work when the harness explicitly advances. + self.window_handle + .ui + .with_direct(|ui| ui.state.promote_next_frame_work()); // Run style and layout passes, returning whether paint is needed self.window_handle.process_update_no_paint() @@ -196,7 +199,7 @@ impl HeadlessHarness { /// Get the root view ID. pub fn root_id(&self) -> ViewId { - self.window_handle.id + self.window_handle.ui.with_direct(|ui| ui.root_id) } /// Returns the `winit::window::WindowId` of the underlying headless window. @@ -344,22 +347,30 @@ impl HeadlessHarness { /// 2: returning into the element /// or keyboard trigger is down). pub fn is_active(&self, id: impl Into) -> bool { - self.window_handle.window_state.is_active(id) + self.window_handle + .ui + .with_direct(|ui| ui.state.is_active(id)) } /// Check if a view is currently hovered. pub fn is_hovered(&self, id: impl Into) -> bool { - self.window_handle.window_state.is_hovered(id) + self.window_handle + .ui + .with_direct(|ui| ui.state.is_hovered(id)) } /// Check if a view is currently focused. pub fn is_focused(&self, id: impl Into) -> bool { - self.window_handle.window_state.is_focused(id) + self.window_handle + .ui + .with_direct(|ui| ui.state.is_focused(id)) } /// Check if a view has pointer capture. pub fn has_capture(&self, id: impl Into) -> bool { - self.window_handle.window_state.has_capture(id) + self.window_handle + .ui + .with_direct(|ui| ui.state.has_capture(id)) } /// Get the current interaction state for a view. @@ -369,7 +380,9 @@ impl HeadlessHarness { pub fn get_interaction_state(&mut self, id: impl Into) -> InteractionState { let id: ElementId = id.into(); let view_id = id.owning_id(); - crate::style::StyleCx::get_interact_state(&self.window_handle.window_state, view_id) + self.window_handle + .ui + .with_direct(|ui| crate::style::StyleCx::get_interact_state(&ui.state, view_id)) } /// Check if a view has styles defined for the given selector. @@ -378,8 +391,8 @@ impl HeadlessHarness { /// if the view has an `:active` style defined. pub fn has_style_for_selector(&mut self, id: ViewId, selector: StyleSelector) -> bool { self.window_handle - .window_state - .has_style_for_sel(id, selector) + .ui + .with_direct(|ui| ui.state.has_style_for_sel(id, selector)) } /// Get the computed style for a view. @@ -425,22 +438,25 @@ impl HeadlessHarness { /// /// This is useful for verifying that style changes trigger repaints. pub fn paint_requested(&self) -> bool { - self.window_handle.window_state.request_paint + self.window_handle + .ui + .with_direct(|ui| ui.state.has_pending_paint()) } /// Clear the paint request flag. /// /// Call this before an operation to then check if it triggered a repaint. pub fn clear_paint_request(&mut self) { - self.window_handle.window_state.request_paint = false; + self.window_handle + .ui + .with_direct(|ui| ui.state.clear_pending_paint()); } /// Check if a view has pending style changes. pub fn has_pending_style_change(&self, id: ViewId) -> bool { self.window_handle - .window_state - .style_dirty - .contains_key(&id) + .ui + .with_direct(|ui| ui.state.style_dirty.contains_key(&id)) } /// Check if there are scheduled updates for the next frame. @@ -448,7 +464,9 @@ impl HeadlessHarness { /// This is useful for testing transitions, which schedule style updates /// to animate across frames. pub fn has_scheduled_updates(&self) -> bool { - !self.window_handle.window_state.scheduled_updates.is_empty() + self.window_handle + .ui + .with_direct(|ui| ui.state.has_next_frame_work()) } /// Check if a view is in the style_dirty set. @@ -456,9 +474,8 @@ impl HeadlessHarness { /// Views in this set will be processed during the next style pass. pub fn is_style_dirty(&self, id: ViewId) -> bool { self.window_handle - .window_state - .style_dirty - .contains_key(&id) + .ui + .with_direct(|ui| ui.state.style_dirty.contains_key(&id)) } /// Get the layout rectangle for a view. @@ -500,7 +517,7 @@ impl HeadlessHarness { /// ``` pub fn paint_and_get_order(&mut self) -> Vec { enable_paint_order_tracking(); - self.window_handle.paint(); + self.window_handle.paint(0); let order = get_paint_order(); disable_paint_order_tracking(); order @@ -538,7 +555,13 @@ impl HeadlessHarness { /// Use with `enable_paint_tracking()` and `get_paint_order()` to /// verify paint order. pub fn paint(&mut self) { - self.window_handle.paint(); + self.window_handle.paint(0); + } + + pub fn last_paint_stats(&self) -> PaintStats { + self.window_handle + .ui + .with_direct(|ui| ui.state.last_paint_stats) } } diff --git a/src/hud.rs b/src/hud.rs new file mode 100644 index 000000000..128c219bc --- /dev/null +++ b/src/hud.rs @@ -0,0 +1,935 @@ +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet, VecDeque}, + rc::Rc, +}; + +use floem_reactive::{RwSignal, SignalGet, SignalUpdate}; +use peniko::{ + Color, + kurbo::{Point, Rect, Size, Stroke}, +}; + +use crate::{ + context::PaintCx, + event::{PaintPresentInfo, PaintPresentLayer}, + paint::composition::LayerSourceId, + platform::{Duration, Instant}, + prelude::*, + style::Position, + text::{Alignment, Attrs, AttrsList, FamilyOwned, FontWeight, TextLayout}, + view::View, + views::Overlay, +}; + +fn duration_ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1000.0 +} + +const DEFAULT_SAMPLE_COUNT: usize = 90; +const MAX_SAMPLE_COUNT: usize = 1000; +const MISS_WINDOW_SECONDS: f64 = 1.5; +const GRAPH_COUNT: usize = 44; +const HUD_WIDTH: f64 = 330.0; +const HUD_HEADER_HEIGHT: f64 = 64.0; +const HUD_LAYER_HEIGHT: f64 = 64.0; +const HUD_MAX_HEIGHT: f64 = 360.0; +const HUD_TARGET_FPS: f64 = 30.0; + +#[derive(Clone)] +pub(crate) struct Hud { + inner: Rc, +} + +struct HudInner { + visible: RwSignal, + layer_source_ids: Rc>>, + text_cache: RefCell, + reports: RefCell>, + metrics: RwSignal, + last_metrics_update: RefCell>, +} + +#[derive(Clone, Debug)] +struct HudMetrics { + layers: Vec, +} + +impl Default for HudMetrics { + fn default() -> Self { + Self { layers: Vec::new() } + } +} + +#[derive(Clone, Debug)] +struct LayerReport { + layer_id: u32, + name: String, + last_presented_at: Option, + samples: VecDeque, + smoothed_ms: Option, + present_count: u64, + target_interval: Option, + source_interval: Option, +} + +impl LayerReport { + fn new(layer_id: u32) -> Self { + Self { + layer_id, + name: format!("Layer {layer_id}"), + last_presented_at: None, + samples: VecDeque::with_capacity(DEFAULT_SAMPLE_COUNT), + smoothed_ms: None, + present_count: 0, + target_interval: None, + source_interval: None, + } + } + + fn update_intervals(&mut self, target: Option, source: Option) { + self.target_interval = target; + self.source_interval = source; + } +} + +#[derive(Clone, Copy, Debug)] +struct HudFrameSample { + duration: Duration, + missed_presents: u64, + hitch_ms: f64, + opportunity_misses: u64, + app_misses: u64, + present_misses: u64, +} + +#[derive(Clone, Debug)] +struct LayerMetrics { + layer_id: u32, + name: String, + fps: f64, + avg_ms: f64, + min_ms: f64, + max_ms: f64, + frame_ms: [f32; GRAPH_COUNT], + frame_misses: [u64; GRAPH_COUNT], + frame_count: u64, + missed_presents: u64, + hitch_ms_per_s: f64, + opportunity_misses: u64, + app_misses: u64, + present_misses: u64, + target_ms: Option, + source_ms: Option, +} + +impl Default for LayerMetrics { + fn default() -> Self { + Self { + layer_id: 0, + name: String::new(), + fps: 0.0, + avg_ms: 0.0, + min_ms: 0.0, + max_ms: 0.0, + frame_ms: [0.0; GRAPH_COUNT], + frame_misses: [0; GRAPH_COUNT], + frame_count: 0, + missed_presents: 0, + hitch_ms_per_s: 0.0, + opportunity_misses: 0, + app_misses: 0, + present_misses: 0, + target_ms: None, + source_ms: None, + } + } +} + +#[derive(Default)] +struct HudTextCache { + layouts: BTreeMap, +} + +impl HudTextCache { + fn draw_text( + &mut self, + cx: &mut PaintCx<'_>, + origin: Point, + text: &str, + font_size: f32, + weight: FontWeight, + color: Color, + width: f64, + monospace: bool, + ) { + const MAX_TEXT_LAYOUTS: usize = 1024; + if self.layouts.len() > MAX_TEXT_LAYOUTS { + self.layouts.clear(); + } + + let key = HudTextKey::new(text, font_size, weight, color, width, monospace); + let layout = self.layouts.entry(key).or_insert_with(|| { + let attrs = Attrs::new() + .font_size(font_size) + .weight(weight) + .color(color); + let mut layout = if monospace { + let family = [FamilyOwned::Monospace]; + TextLayout::new_with_text( + text, + AttrsList::new(attrs.family(&family)), + Some(Alignment::Start), + ) + } else { + TextLayout::new_with_text(text, AttrsList::new(attrs), Some(Alignment::Start)) + }; + layout.set_size(width.max(1.0) as f32, 24.0); + layout + }); + layout.draw(cx, origin); + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct HudTextKey { + text: String, + font_size_milli: u32, + weight_milli: u32, + color_rgba: u32, + width_milli: u32, + monospace: bool, +} + +impl HudTextKey { + fn new( + text: &str, + font_size: f32, + weight: FontWeight, + color: Color, + width: f64, + monospace: bool, + ) -> Self { + let rgba = color.to_rgba8(); + Self { + text: text.to_owned(), + font_size_milli: (font_size * 1000.0).round() as u32, + weight_milli: (weight.value() * 1000.0).round() as u32, + color_rgba: u32::from_be_bytes([rgba.r, rgba.g, rgba.b, rgba.a]), + width_milli: (width * 1000.0).round() as u32, + monospace, + } + } +} + +impl Hud { + pub(crate) fn new() -> Self { + Self { + inner: Rc::new(HudInner { + visible: RwSignal::new(false), + layer_source_ids: Rc::new(RefCell::new(Vec::new())), + text_cache: RefCell::new(HudTextCache::default()), + reports: RefCell::new(BTreeMap::new()), + metrics: RwSignal::new(HudMetrics::default()), + last_metrics_update: RefCell::new(None), + }), + } + } + + pub(crate) fn toggle(&self) { + let next_visible = !self.inner.visible.get_untracked(); + self.inner.visible.set(next_visible); + if next_visible { + self.inner + .metrics + .set(metrics_from_reports(&self.inner.reports.borrow())); + *self.inner.last_metrics_update.borrow_mut() = Some(Instant::now()); + } + } + + pub(crate) fn record_present(&self, info: &PaintPresentInfo) { + let active_layer_ids = info + .active_layers + .iter() + .map(|layer| layer.layer_id) + .collect::>(); + let presented_at = info.presented_at; + { + let mut reports = self.inner.reports.borrow_mut(); + if !active_layer_ids.is_empty() { + reports.retain(|layer_id, _| active_layer_ids.contains(layer_id)); + } + for layer in &info.active_layers { + let report = reports + .entry(layer.layer_id) + .or_insert_with(|| LayerReport::new(layer.layer_id)); + if let Some(debug_name) = &layer.debug_name { + if !debug_name.is_empty() { + report.name = debug_name.clone(); + } + } + report.update_intervals(layer.target_frame_interval, layer.source_frame_interval); + } + for layer in &info.layers { + let report = reports + .entry(layer.layer_id) + .or_insert_with(|| LayerReport::new(layer.layer_id)); + if let Some(debug_name) = &layer.debug_name { + if !debug_name.is_empty() { + report.name = debug_name.clone(); + } + } + report.present_count += 1; + report.update_intervals(layer.target_frame_interval, layer.source_frame_interval); + if let Some(previous) = report.last_presented_at.replace(presented_at) { + let dt = presented_at.saturating_duration_since(previous); + if !dt.is_zero() { + let target_ms = report.target_interval.map(duration_ms); + let dt_ms = duration_ms(dt); + let sample = frame_sample_for_present(dt, report.target_interval, layer); + if sample.missed_presents > 0 { + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem hud missed-present layer={} name={:?} dt={:.3}ms target={:?} missed={} opp={} app={} pres={} frame_rate={:?} presented_at={:?} sample_count={}", + layer.layer_id, + report.name, + dt_ms, + target_ms, + sample.missed_presents, + sample.opportunity_misses, + sample.app_misses, + sample.present_misses, + layer.frame_rate, + presented_at, + report.samples.len().saturating_add(1), + ); + } + } + report.samples.push_back(sample); + trim_samples(&mut report.samples, report.target_interval); + report.smoothed_ms = Some(match report.smoothed_ms { + Some(previous) => previous * 0.9 + dt_ms * 0.1, + None => dt_ms, + }); + } + } else if layer.missed_deadline { + report.samples.push_back(first_present_deadline_miss_sample( + report.target_interval, + layer, + )); + trim_samples(&mut report.samples, report.target_interval); + } + } + } + + if self.inner.visible.get_untracked() && self.should_update_metrics(presented_at) { + self.inner + .metrics + .set(metrics_from_reports(&self.inner.reports.borrow())); + } + } + + fn should_update_metrics(&self, now: Instant) -> bool { + let mut last_update = self.inner.last_metrics_update.borrow_mut(); + let should_update = last_update.is_none_or(|last_update| { + now.saturating_duration_since(last_update) + >= Duration::from_secs_f64(1.0 / HUD_TARGET_FPS) + }); + if should_update { + *last_update = Some(now); + } + should_update + } + + pub(crate) fn view(&self) -> Overlay { + let inner = self.inner.clone(); + let metrics = self.inner.metrics; + let hud_canvas = canvas(move |cx, size| { + if inner.visible.get() { + draw_hud(cx, size, &inner.metrics.get(), &inner.text_cache); + } + }) + .style(move |s| { + let metrics = metrics.get(); + s.width(HUD_WIDTH) + .height(hud_height_for_layer_count(metrics.layers.len())) + }); + register_hud_source( + &self.inner.layer_source_ids, + hud_canvas.id().get_element_id(), + ); + let visible = self.inner.visible; + let stack = hud_canvas.scroll().debug_name("floem-hud").style(move |s| { + let style = s + .position(Position::Absolute) + .inset_top(12.0) + .inset_right(12.0) + .padding(2) + .width(HUD_WIDTH) + .max_height(HUD_MAX_HEIGHT) + .pointer_events_none() + .wants_layer(true) + .layer_frame_rate( + crate::frame::FrameRatePreference::at_most(HUD_TARGET_FPS).unwrap(), + ); + if !visible.get() { style.hide() } else { style } + }); + register_hud_source(&self.inner.layer_source_ids, stack.id().get_element_id()); + Overlay::new(stack) + } +} + +fn register_hud_source(sources: &Rc>>, id: crate::ElementId) { + let source = LayerSourceId::from_element_id(id); + let mut sources = sources.borrow_mut(); + if !sources.contains(&source) { + sources.push(source); + } +} + +fn hud_height_for_layer_count(layer_count: usize) -> f64 { + if layer_count == 0 { + HUD_HEADER_HEIGHT + } else { + HUD_HEADER_HEIGHT + 4.0 + layer_count as f64 * (HUD_LAYER_HEIGHT + 4.0) - 4.0 + } +} + +fn metrics_from_reports(reports: &BTreeMap) -> HudMetrics { + let mut layers = reports + .values() + .map(metrics_from_report) + .collect::>(); + layers.sort_by_key(|layer| layer.layer_id); + HudMetrics { layers } +} + +fn target_rate_miss_count(elapsed: Duration, target: Option) -> u64 { + let Some(target) = target.filter(|target| !target.is_zero()) else { + return 0; + }; + let target_ns = target.as_nanos(); + let elapsed_intervals = ((elapsed.as_nanos() + target_ns / 2) / target_ns).max(1); + elapsed_intervals.saturating_sub(1).min(u64::MAX as u128) as u64 +} + +fn frame_sample_for_present( + duration: Duration, + target: Option, + layer: &PaintPresentLayer, +) -> HudFrameSample { + let cadence_misses = target_rate_miss_count(duration, target); + let has_late_attribution = layer + .timing + .as_ref() + .is_some_and(|timing| timing.opportunity_late || timing.app_late || timing.present_late); + let missed_presents = if has_late_attribution { + cadence_misses.max(1) + } else { + cadence_misses + }; + let mut sample = HudFrameSample { + duration, + missed_presents, + hitch_ms: hitch_ms(duration, target, missed_presents), + opportunity_misses: 0, + app_misses: 0, + present_misses: 0, + }; + if missed_presents == 0 { + return sample; + } + + match layer.timing.as_ref() { + Some(timing) if timing.app_late => sample.app_misses = missed_presents, + Some(timing) if timing.present_late => sample.present_misses = missed_presents, + Some(timing) if timing.opportunity_late => sample.opportunity_misses = missed_presents, + _ => sample.opportunity_misses = missed_presents, + } + sample +} + +fn first_present_deadline_miss_sample( + target: Option, + layer: &PaintPresentLayer, +) -> HudFrameSample { + let mut sample = HudFrameSample { + duration: Duration::ZERO, + missed_presents: 1, + hitch_ms: target.map(duration_ms).unwrap_or(0.0), + opportunity_misses: 0, + app_misses: 0, + present_misses: 0, + }; + match layer.timing.as_ref() { + Some(timing) if timing.app_late => sample.app_misses = 1, + Some(timing) if timing.present_late => sample.present_misses = 1, + Some(timing) if timing.opportunity_late => sample.opportunity_misses = 1, + _ => sample.opportunity_misses = 1, + } + sample +} + +fn hitch_ms(duration: Duration, target: Option, missed_presents: u64) -> f64 { + let Some(target) = target.filter(|target| !target.is_zero()) else { + return 0.0; + }; + if missed_presents == 0 { + 0.0 + } else { + duration_ms(duration.saturating_sub(target)) + } +} + +fn target_rate_window_sample_count(target: Option) -> usize { + target + .filter(|target| !target.is_zero()) + .map(|target| (MISS_WINDOW_SECONDS / target.as_secs_f64()).ceil() as usize) + .unwrap_or(DEFAULT_SAMPLE_COUNT) + .clamp(1, MAX_SAMPLE_COUNT) +} + +fn trim_samples(samples: &mut VecDeque, target: Option) { + let keep = target_rate_window_sample_count(target).max(GRAPH_COUNT); + while samples.len() > keep { + samples.pop_front(); + } +} + +fn metrics_from_report(report: &LayerReport) -> LayerMetrics { + let target_ms = report + .target_interval + .map(|interval| interval.as_secs_f64() * 1000.0); + let source_ms = report + .source_interval + .map(|interval| interval.as_secs_f64() * 1000.0); + let mut min_ms = f64::INFINITY; + let mut max_ms: f64 = 0.0; + let mut total_ms = 0.0; + let mut duration_count = 0; + for sample in &report.samples { + if sample.duration.is_zero() { + continue; + } + let ms = sample.duration.as_secs_f64() * 1000.0; + min_ms = min_ms.min(ms); + max_ms = max_ms.max(ms); + total_ms += ms; + duration_count += 1; + } + let miss_window_offset = report + .samples + .len() + .saturating_sub(target_rate_window_sample_count(report.target_interval)); + let missed_presents = report + .samples + .iter() + .skip(miss_window_offset) + .map(|sample| sample.missed_presents) + .sum(); + let hitch_ms: f64 = report + .samples + .iter() + .skip(miss_window_offset) + .map(|sample| sample.hitch_ms) + .sum(); + let opportunity_misses = report + .samples + .iter() + .skip(miss_window_offset) + .map(|sample| sample.opportunity_misses) + .sum(); + let app_misses = report + .samples + .iter() + .skip(miss_window_offset) + .map(|sample| sample.app_misses) + .sum(); + let present_misses = report + .samples + .iter() + .skip(miss_window_offset) + .map(|sample| sample.present_misses) + .sum(); + let mut metrics = LayerMetrics { + layer_id: report.layer_id, + name: report.name.clone(), + frame_count: report.present_count, + missed_presents, + hitch_ms_per_s: hitch_ms / MISS_WINDOW_SECONDS, + opportunity_misses, + app_misses, + present_misses, + target_ms, + source_ms, + ..LayerMetrics::default() + }; + if duration_count > 0 { + metrics.avg_ms = total_ms / duration_count as f64; + metrics.fps = 1000.0 / report.smoothed_ms.unwrap_or(metrics.avg_ms).max(0.001); + metrics.min_ms = min_ms; + metrics.max_ms = max_ms; + } + + let graph_offset = report.samples.len().saturating_sub(GRAPH_COUNT); + for (index, sample) in report.samples.iter().skip(graph_offset).enumerate() { + metrics.frame_ms[index] = (sample.duration.as_secs_f64() * 1000.0) as f32; + metrics.frame_misses[index] = sample.missed_presents; + } + metrics +} + +fn draw_hud( + cx: &mut PaintCx<'_>, + size: Size, + metrics: &HudMetrics, + text_cache: &RefCell, +) { + draw_hud_header( + cx, + Size::new(size.width, HUD_HEADER_HEIGHT.min(size.height)), + metrics, + text_cache, + ); + let mut y = HUD_HEADER_HEIGHT + 4.0; + for layer in &metrics.layers { + draw_layer_canvas( + cx, + Rect::new(0.0, y, size.width, y + HUD_LAYER_HEIGHT), + layer, + text_cache, + ); + y += HUD_LAYER_HEIGHT + 4.0; + } +} + +fn draw_hud_header( + cx: &mut PaintCx<'_>, + size: Size, + metrics: &HudMetrics, + text_cache: &RefCell, +) { + let bounds = Rect::ZERO.with_size(size); + let panel = bounds.to_rounded_rect(11.0); + cx.painter + .fill(panel, Color::from_rgba8(6, 10, 8, 218)) + .draw(); + cx.painter + .stroke( + panel, + &Stroke::new(1.0), + Color::from_rgba8(113, 255, 167, 82), + ) + .draw(); + + draw_text( + text_cache, + cx, + Point::new(10.0, 7.0), + "FLOEM HUD", + 9.0, + FontWeight::BOLD, + Color::from_rgba8(122, 255, 176, 235), + ); + draw_monospace_text( + text_cache, + cx, + Point::new(10.0, 24.0), + "miss = missed target opportunities", + 9.0, + FontWeight::NORMAL, + Color::from_rgba8(184, 255, 205, 210), + ); + draw_monospace_text( + text_cache, + cx, + Point::new(10.0, 38.0), + "opp/app/pres = attribution buckets", + 9.0, + FontWeight::NORMAL, + Color::from_rgba8(184, 255, 205, 210), + ); + if metrics.layers.is_empty() { + draw_text( + text_cache, + cx, + Point::new(10.0, 50.0), + "Waiting for layer presents", + 9.0, + FontWeight::NORMAL, + Color::from_rgba8(184, 255, 205, 220), + ); + } +} + +fn draw_layer_canvas( + cx: &mut PaintCx<'_>, + bounds: Rect, + metrics: &LayerMetrics, + text_cache: &RefCell, +) { + cx.painter + .fill( + bounds.to_rounded_rect(9.0), + Color::from_rgba8(6, 10, 8, 218), + ) + .draw(); + cx.painter + .stroke( + bounds.to_rounded_rect(9.0), + &Stroke::new(1.0), + Color::from_rgba8(113, 255, 167, 62), + ) + .draw(); + draw_layer_report( + cx, + Rect::new( + bounds.x0 + 4.0, + bounds.y0 + 3.0, + bounds.x1 - 4.0, + bounds.y1 - 3.0, + ), + metrics, + text_cache, + ); +} + +fn draw_layer_report( + cx: &mut PaintCx<'_>, + rect: Rect, + metrics: &LayerMetrics, + text_cache: &RefCell, +) { + let graph_rect = Rect::new(rect.x1 - 86.0, rect.y0 + 8.0, rect.x1 - 7.0, rect.y1 - 8.0); + let text_width = (graph_rect.x0 - rect.x0 - 13.0).max(1.0); + cx.painter + .fill( + rect.to_rounded_rect(7.0), + Color::from_rgba8(11, 24, 18, 168), + ) + .draw(); + let mut name = metrics.name.chars().take(20).collect::(); + if name.len() < metrics.name.len() { + name.push_str("..."); + } + draw_text_with_width( + text_cache, + cx, + Point::new(rect.x0 + 7.0, rect.y0 + 4.0), + &name, + 9.0, + FontWeight::BOLD, + Color::from_rgba8(122, 255, 176, 235), + text_width, + ); + draw_text_with_width( + text_cache, + cx, + Point::new(rect.x0 + 7.0, rect.y0 + 17.0), + &format!("{:>5.1} FPS", metrics.fps), + 15.0, + FontWeight::BOLD, + Color::from_rgb8(226, 255, 235), + 76.0, + ); + let target = metrics + .target_ms + .map(|ms| format!("{ms:.1}ms")) + .unwrap_or_else(|| "--".to_owned()); + let source = metrics + .source_ms + .map(|ms| format!(" src {ms:.1}")) + .unwrap_or_default(); + draw_text_with_width( + text_cache, + cx, + Point::new(rect.x0 + 86.0, rect.y0 + 18.0), + &format!("{:>4.1}ms", metrics.avg_ms), + 9.0, + FontWeight::BOLD, + Color::from_rgba8(184, 255, 205, 230), + text_width - 79.0, + ); + draw_text_with_width( + text_cache, + cx, + Point::new(rect.x0 + 7.0, rect.y0 + 32.0), + &format!( + "target {target}{source} miss {} hitch {:.0}ms/s", + metrics.missed_presents, metrics.hitch_ms_per_s + ), + 8.0, + FontWeight::NORMAL, + Color::from_rgba8(135, 176, 153, 230), + text_width, + ); + draw_text_with_width( + text_cache, + cx, + Point::new(rect.x0 + 7.0, rect.y0 + 45.0), + &format!( + "opp {} app {} pres {} #{}", + metrics.opportunity_misses, + metrics.app_misses, + metrics.present_misses, + metrics.frame_count + ), + 8.0, + FontWeight::NORMAL, + Color::from_rgba8(135, 176, 153, 230), + text_width, + ); + draw_graph(cx, graph_rect, metrics); +} + +fn draw_graph(cx: &mut PaintCx<'_>, rect: Rect, metrics: &LayerMetrics) { + cx.painter + .fill( + rect.to_rounded_rect(4.0), + Color::from_rgba8(13, 32, 22, 190), + ) + .draw(); + + if let Some(target_ms) = metrics.target_ms { + let target_y = rect.y1 - (target_ms / 35.0 * rect.height()).min(rect.height()); + cx.painter + .stroke( + Rect::new(rect.x0, target_y, rect.x1, target_y), + &Stroke::new(1.0), + Color::from_rgba8(210, 240, 126, 88), + ) + .draw(); + } + + let bar_width = rect.width() / GRAPH_COUNT as f64; + for (index, ms) in metrics.frame_ms.iter().enumerate() { + if *ms <= 0.0 { + continue; + } + let ms = f64::from(*ms); + let normalized = (ms / 35.0).min(1.0); + let height = (rect.height() * normalized).max(1.0); + let x0 = rect.x0 + index as f64 * bar_width + 0.5; + let x1 = (x0 + bar_width - 1.0).min(rect.x1); + let color = if metrics.frame_misses[index] > 0 { + Color::from_rgba8(255, 103, 92, 235) + } else { + Color::from_rgba8(92, 255, 151, 218) + }; + cx.painter + .fill(Rect::new(x0, rect.y1 - height, x1, rect.y1), color) + .draw(); + } +} + +fn draw_text( + cache: &RefCell, + cx: &mut PaintCx<'_>, + origin: Point, + text: &str, + font_size: f32, + weight: FontWeight, + color: Color, +) { + draw_text_with_width(cache, cx, origin, text, font_size, weight, color, 220.0); +} + +fn draw_monospace_text( + cache: &RefCell, + cx: &mut PaintCx<'_>, + origin: Point, + text: &str, + font_size: f32, + weight: FontWeight, + color: Color, +) { + cache + .borrow_mut() + .draw_text(cx, origin, text, font_size, weight, color, 220.0, true); +} + +fn draw_text_with_width( + cache: &RefCell, + cx: &mut PaintCx<'_>, + origin: Point, + text: &str, + font_size: f32, + weight: FontWeight, + color: Color, + width: f64, +) { + cache + .borrow_mut() + .draw_text(cx, origin, text, font_size, weight, color, width, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_rate_miss_count_counts_extra_target_intervals() { + let target = Duration::from_secs_f64(1.0 / 60.0); + + assert_eq!(target_rate_miss_count(target, Some(target)), 0); + assert_eq!( + target_rate_miss_count(target.saturating_mul(2), Some(target)), + 1 + ); + assert_eq!( + target_rate_miss_count(target.saturating_mul(3), Some(target)), + 2 + ); + } + + #[test] + fn frame_sample_deadline_miss_counts_at_least_one() { + let target = Duration::from_secs_f64(1.0 / 60.0); + let layer = test_layer_with_timing(false, true, false); + let sample = frame_sample_for_present(target, Some(target), &layer); + assert_eq!(sample.missed_presents, 1); + assert_eq!(sample.app_misses, 1); + assert_eq!( + frame_sample_for_present(target.saturating_mul(3), Some(target), &layer) + .missed_presents, + 2 + ); + } + + #[test] + fn target_rate_window_sample_count_uses_one_and_a_half_seconds() { + assert_eq!( + target_rate_window_sample_count(Some(Duration::from_secs_f64(1.0 / 60.0))), + 90 + ); + assert_eq!( + target_rate_window_sample_count(Some(Duration::from_secs_f64(1.0 / 30.0))), + 45 + ); + } + + fn test_layer_with_timing( + opportunity_late: bool, + app_late: bool, + present_late: bool, + ) -> PaintPresentLayer { + let now = Instant::now(); + PaintPresentLayer { + layer_id: 1, + source_element_id: None, + debug_name: None, + frame_rate: None, + target_frame_interval: None, + source_frame_interval: None, + missed_deadline: opportunity_late || app_late || present_late, + timing: Some(crate::event::PaintPresentTiming { + opportunity_started_at: now, + deadline_at: now, + submitted_at: Some(now), + opportunity_late, + app_late, + present_late, + }), + } + } +} diff --git a/src/inspector/data.rs b/src/inspector/data.rs index 4b757f84b..212143947 100644 --- a/src/inspector/data.rs +++ b/src/inspector/data.rs @@ -2,27 +2,21 @@ use crate::ViewId; use crate::inspector::CapturedView; use crate::views::VirtualVector; use floem_reactive::{RwSignal, SignalGet, SignalUpdate}; -use std::collections::HashMap; use std::ops::AddAssign; use std::rc::Rc; -use taffy::Display; #[derive(Clone)] pub struct CapturedDatas { pub root: CapturedData, pub focus_line: RwSignal, - by_data_id: HashMap, } impl CapturedDatas { pub fn init_from_view(view: Rc) -> Self { let root = CapturedData::init_from_view(view); - let mut by_data_id = HashMap::new(); - root.fill_data_id_index(&mut by_data_id); Self { root, focus_line: RwSignal::new(0), - by_data_id, } } @@ -51,19 +45,6 @@ impl CapturedDatas { ) -> Vec<(usize, usize, CapturedData)> { self.root.get_children(next, min, max, level) } - - pub fn visible_data_id_map(&self) -> HashMap { - self.by_data_id - .iter() - .filter_map(|(data_id, (view_id, is_visible))| { - if *is_visible { - Some((data_id.clone(), *view_id)) - } else { - None - } - }) - .collect() - } } #[derive(Clone, Debug)] pub enum DataType { @@ -196,17 +177,6 @@ impl CapturedData { } children_data } - - fn fill_data_id_index(&self, index: &mut HashMap) { - let visible = self.view_conf.direct_style.builtin().display() != Display::None - && self.view_conf.world_bounds.area() > 0.0; - index.insert(self.view_conf.id_data_str.clone(), (self.id, visible)); - if let DataType::Internal { children, .. } = &self.ty { - for child in children { - child.fill_data_id_index(index); - } - } - } } impl VirtualVector<(usize, usize, CapturedData)> for CapturedDatas { diff --git a/src/inspector/mod.rs b/src/inspector/mod.rs index 0e652a000..9802c9872 100644 --- a/src/inspector/mod.rs +++ b/src/inspector/mod.rs @@ -1,8 +1,9 @@ mod data; pub(crate) mod profiler; mod view; +use crate::text::FontWeight; use floem_reactive::{Effect, Scope}; -use floem_renderer::text::FontWeight; +use imaging::record::{Scene, replay}; use peniko::kurbo::{Rect, Size}; use peniko::{ Color, @@ -13,18 +14,30 @@ pub use view::capture; use crate::{ AnyView, Clipboard, ElementId, ViewId, WindowState, + effects::Brush, event::EventPropagation, inspector::data::CapturedDatas, platform::{Duration, Instant}, prelude::*, style::{ - BorderRadius, FontSizeCx, Length, LengthAuto, OverflowX, OverflowY, StrokeWrap, Style, - StyleCx, StyleThemeExt, TextColor, + BorderRadius, DesignSystem, FontSizeCx, Length, LengthAuto, OverflowX, OverflowY, + StrokeWrap, Style, StyleThemeExt, TextColor, scene_debug_view_with_size, }, }; -use std::{cell::Cell, collections::HashMap, fmt::Display, rc::Rc}; +use std::{ + collections::HashMap, + fmt::Display, + rc::Rc, + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; +use understory_box_tree::NodeFlags; +use winit::window::WindowId; +use crate::views::TabSelectorClass; use taffy::{ prelude::{Layout, auto, fr}, style::FlexDirection, @@ -379,7 +392,7 @@ fn box_model_view(data: BoxModelViewData) -> impl View { blend_box_model_color(content_fill, t.bg_base(), false) }; let l = fill.convert::().components[0]; - Some(if l < 0.5 { css::WHITE } else { css::BLACK }) + Some(Brush::Solid(if l < 0.5 { css::WHITE } else { css::BLACK })) }), ) }) @@ -562,32 +575,419 @@ impl CapturedView { } } +#[derive(Clone, Debug)] +pub struct CapturedElement { + id: ElementId, + world_bounds: Rect, + local_bounds: Rect, + z_index: i32, + flags: NodeFlags, + focused: bool, + scene: Scene, + children: Vec>, +} + +impl CapturedElement { + fn find(&self, id: ElementId) -> Option<&CapturedElement> { + if self.id == id { + return Some(self); + } + self.children + .iter() + .filter_map(|child| child.find(id)) + .next() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum InspectorSelection { + View(ViewId), + BoxNode(ElementId), +} + +impl InspectorSelection { + fn owner_view_id(self) -> ViewId { + match self { + Self::View(id) => id, + Self::BoxNode(id) => id.owning_id(), + } + } + + fn box_node_id(self) -> Option { + match self { + Self::View(_) => None, + Self::BoxNode(id) => Some(id), + } + } +} + pub struct Capture { pub root: Rc, - pub start: Instant, - pub post_style: Instant, - pub post_layout: Instant, - pub end: Instant, - pub taffy_duration: Duration, + pub timings: TimingReport, pub taffy_node_count: usize, pub taffy_depth: usize, /// Captured window image in render-target pixels, if the backend supports capture. - pub window: Option, + pub window: Option, + /// Capture failure from the active renderer, if image capture failed. + pub window_capture_error: Option, /// Logical capture size used by layout/box-tree coordinates and inspector overlays. pub window_size: Size, pub state: CaptureState, pub renderer: String, } -#[derive(Default)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TimingKind { + Total, + Style, + Layout, + BoxTree, + Paint, + Present, + Renderer, + #[allow(dead_code, reason = "Reserved for backend GPU timestamp spans.")] + Gpu, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TimingThread { + Main, + Renderer(usize), + Gpu, +} + +#[derive(Clone, Debug)] +pub struct TimingSpan { + pub label: &'static str, + pub start: Duration, + pub duration: Duration, + pub kind: TimingKind, + pub thread: TimingThread, + pub children: Vec, +} + +impl TimingSpan { + pub fn new_on_thread( + label: &'static str, + start: Duration, + duration: Duration, + kind: TimingKind, + thread: TimingThread, + ) -> Self { + Self { + label, + start, + duration, + kind, + thread, + children: Vec::new(), + } + } + + fn end(&self) -> Duration { + self.start.saturating_add(self.duration) + } + + fn contains_range(&self, start: Duration, duration: Duration) -> bool { + let end = start.saturating_add(duration); + start >= self.start && end <= self.end() + } + + pub(crate) fn shift_by(&mut self, delta: Duration) { + self.start = self.start.saturating_add(delta); + for child in &mut self.children { + child.shift_by(delta); + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct TimingReport { + pub anchor: Option, + pub total: Duration, + pub spans: Vec, +} + +impl TimingReport { + pub fn new(anchor: Option, total: Duration) -> Self { + Self { + anchor, + total, + spans: Vec::new(), + } + } + + pub fn push_span_on_thread( + &mut self, + label: &'static str, + start: Duration, + duration: Duration, + kind: TimingKind, + thread: TimingThread, + ) { + self.push_timing_span(TimingSpan::new_on_thread( + label, start, duration, kind, thread, + )); + } + + pub fn push_timing_span(&mut self, span: TimingSpan) { + insert_timing_span(&mut self.spans, span); + } + + pub fn flattened_spans(&self) -> Vec { + let mut spans = Vec::new(); + flatten_timing_spans(&self.spans, 0, &mut spans); + spans + } + + pub fn flattened_spans_for_thread(&self, thread: TimingThread) -> Vec { + let mut spans = Vec::new(); + flatten_timing_spans_for_thread(&self.spans, thread, 0, &mut spans); + spans + } + + pub fn thread_total(&self, thread: TimingThread) -> Duration { + let mut intervals = Vec::new(); + collect_thread_intervals(&self.spans, thread, &mut intervals); + union_duration(intervals) + } + + pub fn renderer_threads(&self) -> Vec { + let mut threads = Vec::new(); + collect_renderer_threads(&self.spans, &mut threads); + threads.sort_unstable(); + threads.dedup(); + threads + } +} + +#[derive(Clone, Debug)] +pub struct FlattenedTimingSpan { + pub label: &'static str, + pub start: Duration, + pub duration: Duration, + pub depth: usize, + pub kind: TimingKind, + pub thread: TimingThread, +} + +fn insert_timing_span(spans: &mut Vec, mut new_span: TimingSpan) { + if let Some(child) = spans + .iter_mut() + .find(|span| span.contains_range(new_span.start, new_span.duration)) + { + insert_timing_span(&mut child.children, new_span); + return; + } + + let mut idx = 0; + while idx < spans.len() { + if new_span.contains_range(spans[idx].start, spans[idx].duration) { + let child = spans.remove(idx); + insert_timing_span(&mut new_span.children, child); + } else { + idx += 1; + } + } + + let insert_at = spans + .iter() + .position(|span| { + (span.start, std::cmp::Reverse(span.duration)) + > (new_span.start, std::cmp::Reverse(new_span.duration)) + }) + .unwrap_or(spans.len()); + spans.insert(insert_at, new_span); +} + +fn span_duration_for_thread(span: &TimingSpan, thread: TimingThread) -> Duration { + if span.thread != thread { + return Duration::ZERO; + } + + let mut covered: Vec<(Duration, Duration)> = span + .children + .iter() + .filter(|child| child.thread != thread) + .map(|child| (child.start.max(span.start), child.end().min(span.end()))) + .filter(|(start, end)| end > start) + .collect(); + + if covered.is_empty() { + return span.duration; + } + + covered.sort_by_key(|(start, _)| *start); + let mut covered_duration = Duration::ZERO; + let (mut current_start, mut current_end) = covered[0]; + for (start, end) in covered.into_iter().skip(1) { + if start <= current_end { + current_end = current_end.max(end); + } else { + covered_duration += current_end.saturating_sub(current_start); + current_start = start; + current_end = end; + } + } + covered_duration += current_end.saturating_sub(current_start); + span.duration.saturating_sub(covered_duration) +} + +fn collect_thread_intervals( + spans: &[TimingSpan], + thread: TimingThread, + out: &mut Vec<(Duration, Duration)>, +) { + for span in spans { + if span.thread == thread { + let mut cursor = span.start; + let mut blockers: Vec<(Duration, Duration)> = span + .children + .iter() + .filter(|child| child.thread != thread) + .map(|child| (child.start.max(span.start), child.end().min(span.end()))) + .filter(|(start, end)| end > start) + .collect(); + blockers.sort_by_key(|(start, _)| *start); + for (start, end) in blockers { + if start > cursor { + out.push((cursor, start)); + } + cursor = cursor.max(end); + } + if cursor < span.end() { + out.push((cursor, span.end())); + } + } + collect_thread_intervals(&span.children, thread, out); + } +} + +fn collect_renderer_threads(spans: &[TimingSpan], out: &mut Vec) { + for span in spans { + if let TimingThread::Renderer(index) = span.thread { + out.push(index); + } + collect_renderer_threads(&span.children, out); + } +} + +fn union_duration(mut intervals: Vec<(Duration, Duration)>) -> Duration { + if intervals.is_empty() { + return Duration::ZERO; + } + + intervals.sort_by_key(|(start, _)| *start); + let mut total = Duration::ZERO; + let (mut current_start, mut current_end) = intervals[0]; + for (start, end) in intervals.into_iter().skip(1) { + if start <= current_end { + current_end = current_end.max(end); + } else { + total += current_end.saturating_sub(current_start); + current_start = start; + current_end = end; + } + } + total + current_end.saturating_sub(current_start) +} + +fn flatten_timing_spans(spans: &[TimingSpan], depth: usize, out: &mut Vec) { + for span in spans { + out.push(FlattenedTimingSpan { + label: span.label, + start: span.start, + duration: span.duration, + depth, + kind: span.kind, + thread: span.thread, + }); + flatten_timing_spans(&span.children, depth + 1, out); + } +} + +fn flatten_timing_spans_for_thread( + spans: &[TimingSpan], + thread: TimingThread, + depth: usize, + out: &mut Vec, +) { + for span in spans { + if span.thread == thread { + out.push(FlattenedTimingSpan { + label: span.label, + start: span.start, + duration: span_duration_for_thread(span, thread), + depth, + kind: span.kind, + thread: span.thread, + }); + flatten_timing_spans_for_thread(&span.children, thread, depth + 1, out); + } else { + flatten_timing_spans_for_thread(&span.children, thread, depth, out); + } + } +} + pub struct CaptureState { computed_styles: HashMap, + elements_root: Rc, + elements_by_view: HashMap>, } impl CaptureState { - pub(crate) fn capture_style(id: ViewId, cx: &mut StyleCx, computed_style: Style) { - if let Some(capture) = cx.window_state.capture.as_mut() { - capture.computed_styles.insert(id, computed_style); + pub(crate) fn collect_from(root: ViewId, window_state: &WindowState) -> Self { + fn collect_views(id: ViewId, computed_styles: &mut HashMap) { + computed_styles.insert(id, id.state().borrow().computed_style.clone()); + for child in id.children() { + collect_views(child, computed_styles); + } + } + + fn collect_elements( + id: ElementId, + window_state: &WindowState, + box_tree: &crate::BoxTree, + elements_by_view: &mut HashMap>, + ) -> Rc { + let mut scene = Scene::new(); + if let Some(element) = window_state.display_list.element(id) { + replay(&element.paint.scene, &mut scene); + replay(&element.post.scene, &mut scene); + } + elements_by_view.entry(id.owning_id()).or_default().push(id); + let children = box_tree + .children_of(id.0) + .iter() + .filter_map(|child| box_tree.element_id_of(*child)) + .map(|child| collect_elements(child, window_state, box_tree, elements_by_view)) + .collect(); + Rc::new(CapturedElement { + id, + world_bounds: box_tree.world_bounds(id.0).unwrap_or_default(), + local_bounds: box_tree.local_bounds(id.0).unwrap_or_default(), + z_index: box_tree.z_index(id.0).unwrap_or(0), + flags: box_tree.flags(id.0).unwrap_or_default(), + focused: window_state.focus_state.current_path().last() == Some(&id), + scene, + children, + }) + } + + let mut computed_styles = HashMap::new(); + collect_views(root, &mut computed_styles); + let mut elements_by_view = HashMap::new(); + let box_tree = window_state.box_tree.borrow(); + let elements_root = collect_elements( + root.get_element_id(), + window_state, + &box_tree, + &mut elements_by_view, + ); + Self { + computed_styles, + elements_root, + elements_by_view, } } } @@ -646,14 +1046,16 @@ fn add_event( } pub(crate) fn header(label: impl Display) -> Label { - Label::new(label).style(|s| { - s.padding(5.0) - .width_full() - .height(27.0) - .border_bottom(1.) - .font_bold() - .with_theme(|s, t| s.border_color(t.border()).color(t.primary())) - }) + Label::new(label) + .style(|s| { + s.padding(5.0) + .width_full() + .height(27.0) + .border_bottom(1.) + .font_bold() + .with_theme(|s, t| s.border_color(t.border()).color(t.primary())) + }) + .debug_name("Inspector Header") } fn info(name: impl Display, value: String) -> AnyView { @@ -667,69 +1069,519 @@ fn info_row(name: String, view: impl IntoView + 'static) -> impl View { .with_theme(|s, t| s.color(t.text_muted())) }) .container() - .style(|s| s.min_width(150.0).flex_direction(FlexDirection::RowReverse)); - (name, view).h_stack() + .style(|s| s.min_width(150.0).flex_direction(FlexDirection::RowReverse)) + .debug_name("Inspector Info Label"); + (name, view).h_stack().debug_name("Inspector Info Row") } -fn stats(capture: &Capture) -> impl IntoView + use<> { - let style_time = capture.post_style.saturating_duration_since(capture.start); - let layout_time = capture - .post_layout - .saturating_duration_since(capture.post_style); - let paint_time = capture.end.saturating_duration_since(capture.post_layout); - let style_time = info( - "Full Style Time", - format!("{:.4} ms", style_time.as_secs_f64() * 1000.0), - ); - let layout_time = info( - "Full Layout Time", - format!("{:.4} ms", layout_time.as_secs_f64() * 1000.0), - ); - let taffy_time = info( - "Taffy Time", - format!("{:.4} ms", capture.taffy_duration.as_secs_f64() * 1000.0), - ); - let taffy_node_count = info("Taffy Node Count", capture.taffy_node_count.to_string()); - let taffy_depth = info("Taffy Depth", capture.taffy_depth.to_string()); - let paint_time = info( - "Paint Time", - format!("{:.4} ms", paint_time.as_secs_f64() * 1000.0), - ); - let w = info("Window Width", format!("{}", capture.window_size.width)); - let h = info("Window Height", format!("{}", capture.window_size.height)); - Stack::vertical_from_iter( - [ - style_time, - layout_time, - taffy_time, - taffy_node_count, - taffy_depth, - paint_time, - w, - h, - ] - .into_iter() - .enumerate() - .map(|(idx, v)| { - v.style(move |s| { - s.padding(3).with_theme(move |s, t| { - s.apply_if(idx.is_multiple_of(2), |s| s.background(t.bg_base())) - .apply_if(!idx.is_multiple_of(2), |s| s.background(t.bg_elevated())) +fn format_duration_ms(duration: Duration) -> String { + format!("{:.4} ms", duration.as_secs_f64() * 1000.0) +} + +fn timing_color(kind: TimingKind, theme: &DesignSystem) -> Color { + match kind { + TimingKind::Total => theme.primary_muted(), + TimingKind::Style => theme.success(), + TimingKind::Layout => theme.warning(), + TimingKind::BoxTree => theme + .warning() + .map_lightness(|l| l + if theme.is_dark { -0.06 } else { -0.08 }), + TimingKind::Paint => theme.danger(), + TimingKind::Present => theme + .primary() + .map_lightness(|l| l + if theme.is_dark { 0.10 } else { 0.04 }), + TimingKind::Renderer => theme + .primary() + .map_lightness(|l| l + if theme.is_dark { -0.06 } else { -0.10 }), + TimingKind::Gpu => Color::from_rgb8(121, 104, 218), + } +} + +const TIMING_SECTION_GAP: f64 = 8.0; +const TIMING_ROW_GAP: f64 = 4.0; +const TIMING_ROW_PAD_H: f64 = 10.0; +const TIMING_ROW_PAD_V: f64 = 6.0; +const TIMING_COLUMN_GAP: f64 = 10.0; + +fn timing_section_button( + title: &'static str, + subtitle: &'static str, + expanded: RwSignal, +) -> impl View { + let chevron = move || { + if expanded.get() { + svg( + r#""#, + ) + } else { + svg( + r#""#, + ) + } + .style(|s| s.size_full().with_theme(|s, t| s.color(t.text_muted()))) + }; + + Button::new( + Stack::horizontal(( + dyn_view(chevron).style(|s| s.size(16.0, 16.0)), + Stack::vertical(( + Label::new(title).style(|s| s.font_bold().font_size(14.0)), + Label::new(subtitle) + .style(|s| s.font_size(11.0).with_theme(|s, t| s.color(t.text_muted()))), + )) + .style(|s| s.gap(1.0).items_start().min_width(0.0).flex_grow(1.0)), + )) + .style(|s| s.items_center().gap(TIMING_COLUMN_GAP)), + ) + .style(|s| { + s.width_full() + .justify_start() + .padding_horiz(2.0) + .padding_vert(1.0) + }) + .action(move || expanded.update(|value| *value = !*value)) +} + +fn timing_preview( + report: &TimingReport, + thread: TimingThread, + total: Duration, +) -> impl View + use<> { + let spans = report.flattened_spans_for_thread(thread); + let total_secs = total.as_secs_f64().max(f64::EPSILON); + Stack::vertical_from_iter(spans.into_iter().map(|span| { + let left = span.start.as_secs_f64() / total_secs * 100.0; + let width = (span.duration.as_secs_f64() / total_secs * 100.0).max(0.125); + let kind = span.kind; + let indent = TIMING_ROW_PAD_H + span.depth as f64 * 14.0; + let row_height = 14.0; + Stack::horizontal(( + Stack::vertical(( + Stack::horizontal(( + ().style(move |s| { + s.size(8.0, 8.0) + .border_radius(999.0) + .outline(2.0) + .with_theme(move |s, t| { + s.background(t.def(move |t| timing_color(kind, &t))) + .outline_color( + t.def(move |t| timing_color(kind, &t).with_alpha(0.15)), + ) + }) + }), + Label::new(span.label) + .style(|s| s.text_ellipsis().min_width(0.0).font_bold().font_size(12.0)), + )) + .style(|s| s.items_center().gap(7.0)), + Label::new(format_duration_ms(span.start)) + .style(|s| s.font_size(10.5).with_theme(|s, t| s.color(t.text_muted()))), + )) + .style(move |s| { + s.items_start() + .gap(1.0) + .padding_left(indent) + .min_width(176.0) + .max_width(176.0) + }), + Stack::new(( + ().style(|s| { + s.absolute() + .size_full() + .border_radius(999.0) + .with_theme(|s, t| { + s.background(t.def(|t| t.border_muted().with_alpha(0.18))) + }) + }), + ().style(move |s| { + s.absolute() + .inset_left_pct(left) + .width_pct(width) + .height(row_height) + .border_radius(999.0) + .with_theme(move |s, t| { + s.background(t.def(move |t| timing_color(kind, &t).with_alpha(0.78))) + }) + }), + ().style(move |s| { + s.absolute() + .inset_left_pct(left) + .width(2.0) + .height(row_height) + .border_radius(999.0) + .with_theme(|s, t| s.background(t.def(|t| t.text().with_alpha(0.72)))) + }), + )) + .style(move |s| s.height(row_height).flex_grow(1.0).min_width(240.0)), + Label::new(format_duration_ms(span.duration)).style(move |s| { + s.min_width(96.0) + .justify_end() + .font_bold() + .font_size(12.0) + .with_theme(move |s, t| s.color(t.def(move |t| timing_color(kind, &t)))) + }), + )) + .style(|s| { + s.items_center() + .gap(TIMING_COLUMN_GAP) + .padding_horiz(TIMING_ROW_PAD_H) + .padding_vert(TIMING_ROW_PAD_V) + .border_radius(14.0) + .with_theme(|s, t| s.background(t.def(|t| t.bg_base().with_alpha(0.72)))) + }) + .debug_name(format!("Timing Timeline Row: {}", span.label)) + })) + .style(|s| s.gap(TIMING_ROW_GAP)) + .debug_name("Timing Timeline") +} + +fn timing_details(report: &TimingReport, thread: TimingThread) -> impl View + use<> { + let spans = report.flattened_spans_for_thread(thread); + Stack::vertical(( + Stack::horizontal(( + (Stack::horizontal(( + Label::new("Span").style(|s| s.min_width(0.0).flex_grow(1.0).font_bold()), + Label::new("Start").style(|s| s.min_width(88.0).font_bold().justify_end()), + Label::new("Duration").style(|s| s.min_width(88.0).font_bold().justify_end()), + )) + .style(|s| { + s.padding_horiz(TIMING_ROW_PAD_H) + .padding_vert(TIMING_ROW_PAD_V) + .items_center() + .gap(TIMING_COLUMN_GAP) + .width_full() + })), + ().style(|s| s.flex_grow(1.0)), + )) + .style(|s| s.width_full().with_theme(|s, t| s.color(t.text_muted()))) + .debug_name("Timing Details Header"), + Stack::vertical_from_iter(spans.into_iter().enumerate().map(|(idx, span)| { + let kind = span.kind; + let indent = TIMING_ROW_PAD_H + span.depth as f64 * 14.0; + Stack::horizontal(( + (Stack::horizontal(( + Stack::horizontal(( + ().style(move |s| { + s.size(8.0, 8.0) + .border_radius(999.0) + .outline(2.0) + .with_theme(move |s, t| { + s.background(t.def(move |t| timing_color(kind, &t))) + .outline_color( + t.def(move |t| timing_color(kind, &t).with_alpha(0.15)), + ) + }) + }), + Label::new(span.label).style(|s| s.text_ellipsis().min_width(0.0)), + )) + .style(move |s| { + s.items_center() + .gap(7.0) + .padding_left(indent) + .min_width(0.0) + .flex_grow(1.0) + }), + Label::new(format_duration_ms(span.start)).style(|s| { + s.min_width(88.0) + .justify_end() + .with_theme(|s, t| s.color(t.text_muted())) + }), + Label::new(format_duration_ms(span.duration)) + .style(|s| s.min_width(88.0).justify_end().font_bold()), + )) + .style(move |s| { + s.padding_horiz(TIMING_ROW_PAD_H) + .padding_vert(TIMING_ROW_PAD_V) + .items_center() + .gap(TIMING_COLUMN_GAP) + .width_full() + })), + ().style(|s| s.flex_grow(1.0)), + )) + .style(move |s| { + s.width_full().border_radius(12.0).with_theme(move |s, t| { + s.background(if idx.is_multiple_of(2) { + t.def(|t| t.bg_base().with_alpha(0.68)) + } else { + t.def(|t| t.bg_overlay().with_alpha(0.82)) + }) }) }) - }), - ) - .style(|s| s.gap(5)) + .debug_name(format!("Timing Details Row: {}", span.label)) + })) + .style(|s| s.width_full().gap(TIMING_ROW_GAP)) + .debug_name("Timing Details Rows"), + )) + .style(|s| s.width_full().gap(TIMING_ROW_GAP)) + .debug_name("Timing Details") +} + +fn timing_thread_breakdown( + title: String, + subtitle: &'static str, + report: TimingReport, + thread: TimingThread, + total: Duration, + details_mode: RwSignal, +) -> impl View + use<> { + Stack::vertical(( + Stack::horizontal(( + Stack::vertical(( + Label::new(title).style(|s| { + s.font_size(13.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + Label::new(subtitle) + .style(|s| s.font_size(11.0).with_theme(|s, t| s.color(t.text_muted()))), + )) + .style(|s| s.gap(2.0).min_width(0.0).flex_grow(1.0)), + Label::new(format_duration_ms(total)).style(|s| { + s.font_bold() + .font_size(12.0) + .padding_horiz(10.0) + .padding_vert(5.0) + .border_radius(999.0) + .with_theme(|s, t| { + s.background( + t.def(|t| timing_color(TimingKind::Total, &t).with_alpha(0.10)), + ) + .color(t.def(|t| timing_color(TimingKind::Total, &t))) + }) + }), + )) + .style(|s| s.items_center().gap(12.0)), + tab( + move || Some(details_mode.get()), + move || [0, 1], + |it| *it, + { + let report = report.clone(); + move |it| match it { + 0 => timing_preview(&report, thread, total).into_any(), + 1 => timing_details(&report, thread).into_any(), + _ => panic!(), + } + }, + ) + .style(|s| s.width_full()), + )) + .style(|s| { + s.width_full() + .gap(TIMING_ROW_GAP) + .padding(10.0) + .border_radius(16.0) + .with_theme(|s, t| s.background(t.bg_overlay())) + }) +} + +fn timing_report_view(report: TimingReport) -> AnyView { + let details_open = RwSignal::new(false); + let details_mode = RwSignal::new(0usize); + let details_report = report.clone(); + let renderer_threads = report.renderer_threads(); + let gpu_total = report.thread_total(TimingThread::Gpu); + let has_gpu_spans = gpu_total > Duration::ZERO; + + Stack::vertical(( + Stack::horizontal(( + Stack::vertical(( + Label::new("Frame timing").style(|s| { + s.font_size(18.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + Label::new("Thread-aware intervals. The headline total is main-thread frame time.") + .style(|s| s.font_size(12.0).with_theme(|s, t| s.color(t.text_muted()))), + )) + .style(|s| s.gap(2.0).min_width(0.0).flex_grow(1.0)), + Label::new(format_duration_ms(report.total)).style(|s| { + s.font_bold() + .font_size(16.0) + .padding_horiz(12.0) + .padding_vert(6.0) + .border_radius(999.0) + .with_theme(|s, t| { + s.background( + t.def(|t| timing_color(TimingKind::Total, &t).with_alpha(0.12)), + ) + .color(t.def(|t| timing_color(TimingKind::Total, &t))) + }) + }), + )) + .style(|s| s.items_center().gap(16.0)), + Stack::vertical(( + timing_section_button( + "Breakdown", + "Timeline or table view of every nested span", + details_open, + ), + dyn_container( + move || details_open.get(), + move |is_open| { + if is_open { + Stack::vertical(( + Stack::horizontal(( + Label::new("Timeline") + .class(TabSelectorClass) + .style(move |s| s.set_selected(details_mode.get() == 0)) + .action(move || details_mode.set(0)) + .debug_name("Timing Details Timeline Tab"), + Label::new("Table") + .class(TabSelectorClass) + .style(move |s| s.set_selected(details_mode.get() == 1)) + .action(move || details_mode.set(1)) + .debug_name("Timing Details Table Tab"), + )) + .style(|s| s.gap(TIMING_ROW_GAP)) + .debug_name("Timing Details Mode Switch"), + Stack::vertical(( + timing_thread_breakdown( + "Main thread".to_string(), + "Frame update, paint recording, and present work", + details_report.clone(), + TimingThread::Main, + details_report.total, + details_mode, + ), + Stack::vertical_from_iter(renderer_threads.iter().map({ + let details_report = details_report.clone(); + move |index| { + let thread = TimingThread::Renderer(*index); + let total = details_report.thread_total(thread); + timing_thread_breakdown( + format!("Render({index})"), + "Renderer conversion and backend CPU work", + details_report.clone(), + thread, + total, + details_mode, + ) + .into_any() + } + })) + .style(|s| s.width_full().gap(TIMING_SECTION_GAP)), + dyn_container(move || has_gpu_spans, { + let details_report = details_report.clone(); + move |has_gpu_spans| { + if has_gpu_spans { + timing_thread_breakdown( + "GPU".to_string(), + "Resolved GPU command execution intervals", + details_report.clone(), + TimingThread::Gpu, + gpu_total, + details_mode, + ) + .into_any() + } else { + ().into_any() + } + } + }), + )) + .style(|s| s.width_full().gap(TIMING_SECTION_GAP)) + .debug_name("Timing Details Mode Content"), + )) + .style(|s| s.width_full().gap(TIMING_SECTION_GAP)) + .debug_name("Timing Details Content") + .into_any() + } else { + ().into_any() + } + }, + ), + )) + .style(|s| s.width_full().gap(TIMING_SECTION_GAP)), + )) + .style(|s| { + s.width_full() + .gap(TIMING_SECTION_GAP) + .padding(14.0) + .with_theme(|s, t| s.background(t.bg_base())) + }) + .debug_name("Timing Report") + .into_any() +} + +impl IntoView for TimingReport { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + timing_report_view(self) + } +} + +fn stats(capture: &Capture) -> impl IntoView + use<> { + fn box_tree_count(view: &CapturedView) -> usize { + 1 + view + .children + .iter() + .map(|child| box_tree_count(child)) + .sum::() + } + + fn box_tree_depth(view: &CapturedView) -> usize { + 1 + view + .children + .iter() + .map(|child| box_tree_depth(child)) + .max() + .unwrap_or(0) + } + + let box_tree_node_count = box_tree_count(&capture.root); + let box_tree_depth = box_tree_depth(&capture.root); + let width_px = capture + .window + .as_ref() + .map(|image| image.width.to_string()) + .unwrap_or_else(|| format!("{:.0}", capture.window_size.width.round())); + let height_px = capture + .window + .as_ref() + .map(|image| image.height.to_string()) + .unwrap_or_else(|| format!("{:.0}", capture.window_size.height.round())); + + Stack::vertical(( + header("Capture"), + Stack::vertical(( + info("Renderer", capture.renderer.clone()), + info("Taffy Node Count", capture.taffy_node_count.to_string()), + info("Taffy Depth", capture.taffy_depth.to_string()), + info("BoxTree Node Count", box_tree_node_count.to_string()), + info("BoxTree Depth", box_tree_depth.to_string()), + info( + "Window Width", + format!("{:.1} pt / {} px", capture.window_size.width, width_px), + ), + info( + "Window Height", + format!("{:.1} pt / {} px", capture.window_size.height, height_px), + ), + )) + .style(|s| s.gap(4.0)), + header("Stats"), + capture.timings.clone(), + )) + .style(|s| s.width_full().gap(8.0)) } fn selected_view( capture: &Rc, - selected: RwSignal>, + capture_view: CaptureView, + datas: RwSignal, ) -> impl IntoView + use<> { let capture = capture.clone(); - let dyn_view_builder = move |selected_value: Option| { - if let Some(view) = selected_value.and_then(|id| capture.root.find(id)) { + let dyn_view_builder = move |selected_value: Option| { + let Some(selection) = selected_value else { + return Label::new("No selection") + .style(|s| s.padding(5.0)) + .into_any(); + }; + let owner_view_id = selection.owner_view_id(); + if let Some(view) = capture.root.find(owner_view_id) { let name = info("View Debug", view.name.clone()); let id = info("Id", format!("{:?}", view.id)); let count = info("Child Count", format!("{}", view.children.len())); @@ -825,18 +1677,6 @@ fn selected_view( .get(&view.id) .cloned() .unwrap_or_default(); - - let style_list = style - .debug_view(Some(&view.direct_style)) - .style(|s| s.height_full().flex_grow(1.)) - .scroll() - .style(|s| { - s.set(OverflowX, taffy::Overflow::Scroll) - .set(OverflowY, taffy::Overflow::Visible) - .height_full() - .flex_grow(1.) - }); - let selected_view_info = Stack::vertical_from_iter( [name, id, count, x, y, w, h, tx, ty, tw, th] .into_iter() @@ -875,12 +1715,133 @@ fn selected_view( let selected_view_panel = Stack::vertical((header("Selected View"), selected_view_summary)) .style(|s| s.width_full().min_size(0., 0.).flex_grow(1.)); - let style_panel = Stack::vertical((header("View Style"), style_list)) - .style(|s| s.width_full().min_size(0., 0.).flex_grow(1.)); + let owned_elements = + selected_box_nodes_by_view(&capture, view.id, capture_view, datas, "Box Nodes"); + let style_panel = Stack::vertical(( + header("Style"), + style + .debug_view(Some(&view.direct_style)) + .style(|s| s.height_full().flex_grow(1.)) + .scroll() + .style(|s| { + s.set(OverflowX, taffy::Overflow::Scroll) + .set(OverflowY, taffy::Overflow::Visible) + .height_full() + .flex_grow(1.) + }), + )) + .style(|s| s.width_full().min_size(0., 0.).flex_grow(1.)); - Stack::vertical((selected_view_panel, style_panel)) - .style(|s| s.width_full().flex_shrink(0.).gap(10).min_size(0., 0.)) - .into_any() + let selected_element_panel = match selection { + InspectorSelection::BoxNode(element_id) => capture + .state + .elements_root + .find(element_id) + .and_then(|element| { + let info_rows = [ + info( + "Node", + if element.id.is_view() { + format!("{:?} (primary view element)", element.id.0) + } else { + format!("{:?}", element.id.0) + }, + ), + info( + "Kind", + if element.id.is_view() { + "view".into() + } else { + "box node".into() + }, + ), + info("Child Count", element.children.len().to_string()), + info("Flags", format!("{:?}", element.flags)), + info("Focused", element.focused.to_string()), + info("World X", format_float(element.world_bounds.x0)), + info("World Y", format_float(element.world_bounds.y0)), + info("World Width", format_float(element.world_bounds.width())), + info("World Height", format_float(element.world_bounds.height())), + info("Local X", format_float(element.local_bounds.x0)), + info("Local Y", format_float(element.local_bounds.y0)), + info("Local Width", format_float(element.local_bounds.width())), + info("Local Height", format_float(element.local_bounds.height())), + ]; + let summary = Stack::vertical_from_iter( + info_rows.into_iter().enumerate().map(|(idx, row)| { + row.style(move |s| { + s.padding(3).with_theme(move |s, t| { + s.apply_if(idx.is_multiple_of(2), |s| { + s.background(t.bg_base()) + }) + .apply_if(!idx.is_multiple_of(2), |s| { + s.background(t.bg_elevated()) + }) + }) + }) + }), + ) + .style(|s| s.flex_grow(1.).height_full().gap(2.0)) + .scroll() + .style(|s| { + s.set(OverflowX, taffy::Overflow::Scroll) + .set(OverflowY, taffy::Overflow::Visible) + .width_full() + }); + let scene = scene_debug_view_with_size( + element.scene.clone(), + element.local_bounds.size(), + ) + .style(|s| s.height_full().flex_grow(1.)) + .scroll() + .style(|s| { + s.set(OverflowX, taffy::Overflow::Scroll) + .set(OverflowY, taffy::Overflow::Visible) + .height_full() + .flex_grow(1.) + }); + Stack::vertical(( + header("Selected Box Node"), + summary, + header("Selected Node Scene"), + scene, + )) + .style(|s| s.width_full().min_size(0., 0.).flex_grow(1.)) + .into_any() + .into() + }), + InspectorSelection::View(_) => None, + }; + + match selected_element_panel { + Some(selected_element_panel) => { + let child_elements = capture + .state + .elements_root + .find( + selection + .box_node_id() + .expect("selected element panel implies box-node selection"), + ) + .map(|element| { + selected_box_nodes( + element.children.iter().map(|child| child.id).collect(), + capture_view, + datas, + "Child Box Nodes", + ) + }) + .unwrap_or_else(|| { + selected_box_nodes(Vec::new(), capture_view, datas, "Child Box Nodes") + }); + Stack::vertical((selected_element_panel, child_elements)) + .style(|s| s.width_full().flex_shrink(0.).gap(10).min_size(0., 0.)) + .into_any() + } + None => Stack::vertical((selected_view_panel, style_panel, owned_elements)) + .style(|s| s.width_full().flex_shrink(0.).gap(10).min_size(0., 0.)) + .into_any(), + } } else { Label::new("No selection") .style(|s| s.padding(5.0)) @@ -888,24 +1849,72 @@ fn selected_view( } }; - dyn_container(move || selected.get(), dyn_view_builder) + dyn_container(move || capture_view.selected.get(), dyn_view_builder) } #[derive(Clone, Copy)] struct CaptureView { expanding_selection: RwSignal>, scroll_to: RwSignal>, - selected: RwSignal>, - highlighted: RwSignal>, + selected: RwSignal>, + highlighted: RwSignal>, } thread_local! { - pub(crate) static RUNNING: Cell = const { Cell::new(false) }; pub(crate) static CAPTURE: RwSignal>> = { Scope::new().create_rw_signal(None) }; } +pub(crate) static RUNNING: InspectorRunning = InspectorRunning::new(); +pub(crate) static INSPECTOR_WINDOW: InspectorWindow = InspectorWindow::new(); + +pub(crate) struct InspectorRunning { + running: AtomicBool, +} + +impl InspectorRunning { + const fn new() -> Self { + Self { + running: AtomicBool::new(false), + } + } + + pub(crate) fn get(&self) -> bool { + self.running.load(Ordering::Relaxed) + } + + pub(crate) fn set(&self, running: bool) { + self.running.store(running, Ordering::Relaxed); + } +} + +pub(crate) struct InspectorWindow { + window_id: Mutex>, +} + +impl InspectorWindow { + const fn new() -> Self { + Self { + window_id: Mutex::new(None), + } + } + + pub(crate) fn get(&self) -> Option { + *self + .window_id + .lock() + .expect("inspector window mutex poisoned") + } + + pub(crate) fn set(&self, window_id: Option) { + *self + .window_id + .lock() + .expect("inspector window mutex poisoned") = window_id; + } +} + fn find_view(name: &str, views: &Rc) -> Vec { let mut ids = Vec::new(); if name.is_empty() { @@ -932,6 +1941,65 @@ fn find_view(name: &str, views: &Rc) -> Vec { }) } +fn selected_box_nodes_by_view( + capture: &Rc, + view_id: ViewId, + capture_view: CaptureView, + datas: RwSignal, + title: &'static str, +) -> impl IntoView { + let elements = capture + .state + .elements_by_view + .get(&view_id) + .cloned() + .unwrap_or_default(); + selected_box_nodes(elements, capture_view, datas, title) +} + +fn selected_box_nodes( + elements: Vec, + capture_view: CaptureView, + datas: RwSignal, + title: &'static str, +) -> impl IntoView { + Stack::vertical(( + header(title), + Stack::vertical_from_iter(elements.into_iter().map(move |element_id| { + let is_primary = element_id.is_view(); + let label = if is_primary { + format!("{:?} primary view element", element_id.0) + } else { + format!("{:?} box node", element_id.0) + }; + Label::new(label) + .container() + .style(move |s| { + s.padding(4.0) + .width_full() + .keyboard_navigable() + .with_theme(move |s, t| { + s.apply_if( + capture_view.selected.get() + == Some(InspectorSelection::BoxNode(element_id)), + |s| s.background(t.bg_elevated()).color(t.primary()), + ) + }) + }) + .on_event_cont(crate::event::listener::PointerEnter, move |_, _| { + capture_view + .highlighted + .set(Some(InspectorSelection::BoxNode(element_id))); + }) + .action(move || { + update_select_box_node_id(element_id, &capture_view, true, Some(datas)); + }) + .into_any() + })), + )) + .style(|s| s.width_full().gap(4.0)) +} + fn find_relative_view_by_id_without_self( id: ViewId, views: &Rc, @@ -991,8 +2059,8 @@ fn update_select_view_id( request_focus: bool, datas: RwSignal, ) { - capture.selected.set(Some(id)); - capture.highlighted.set(Some(id)); + capture.selected.set(Some(InspectorSelection::View(id))); + capture.highlighted.set(Some(InspectorSelection::View(id))); capture.expanding_selection.set(Some((id, request_focus))); Effect::batch(|| { datas.update(|x| { @@ -1001,6 +2069,59 @@ fn update_select_view_id( }); } +fn update_select_element_id( + id: ElementId, + capture: &CaptureView, + request_focus: bool, + datas: Option>, +) { + let owner_id = id.owning_id(); + let selection = if id.is_view() { + InspectorSelection::View(owner_id) + } else { + InspectorSelection::BoxNode(id) + }; + capture.selected.set(Some(selection)); + capture.highlighted.set(Some(selection)); + capture + .expanding_selection + .set(Some((owner_id, request_focus))); + if let Some(datas) = datas { + Effect::batch(|| { + datas.update(|x| { + x.focus(owner_id); + }); + }); + } +} + +fn update_select_box_node_id( + id: ElementId, + capture: &CaptureView, + request_focus: bool, + datas: Option>, +) { + let owner_id = id.owning_id(); + capture.selected.set(Some(InspectorSelection::BoxNode(id))); + capture + .highlighted + .set(Some(InspectorSelection::BoxNode(id))); + capture + .expanding_selection + .set(Some((owner_id, request_focus))); + if let Some(datas) = datas { + Effect::batch(|| { + datas.update(|x| { + x.focus(owner_id); + }); + }); + } +} + +fn selection_matches_view(selection: Option, id: ViewId) -> bool { + selection.is_some_and(|selection| selection.owner_view_id() == id) +} + #[derive(Debug, Default, Clone)] struct RelativeViewId { pub parent_id: Option, @@ -1027,8 +2148,9 @@ impl RelativeViewId { #[cfg(test)] mod tests { - use super::{box_model_data, resolve_length}; + use super::{TimingKind, TimingReport, TimingThread, box_model_data, resolve_length}; use crate::{ + platform::Duration, style::{FontSizeCx, Style}, unit::UnitExt, }; @@ -1061,4 +2183,36 @@ mod tests { assert_eq!(data.content_width, 84.0); assert_eq!(data.content_height, 68.0); } + + #[test] + fn timing_report_totals_are_thread_exclusive() { + let mut report = TimingReport::new(None, Duration::ZERO); + report.push_span_on_thread( + "Paint", + Duration::ZERO, + Duration::from_millis(10), + TimingKind::Paint, + TimingThread::Main, + ); + report.push_span_on_thread( + "Render", + Duration::from_millis(2), + Duration::from_millis(5), + TimingKind::Renderer, + TimingThread::Renderer(0), + ); + + assert_eq!( + report.thread_total(TimingThread::Main), + Duration::from_millis(5) + ); + assert_eq!( + report.thread_total(TimingThread::Renderer(0)), + Duration::from_millis(5) + ); + + let main_spans = report.flattened_spans_for_thread(TimingThread::Main); + assert_eq!(main_spans[0].label, "Paint"); + assert_eq!(main_spans[0].duration, Duration::from_millis(5)); + } } diff --git a/src/inspector/profiler.rs b/src/inspector/profiler.rs index 1899e38fb..d844b5da3 100644 --- a/src/inspector/profiler.rs +++ b/src/inspector/profiler.rs @@ -1,57 +1,1615 @@ -use super::header; -use crate::app::{AppUpdateEvent, add_app_update_event}; -use crate::event::{EventPropagation, PointerScrollEventExt, listener}; -use crate::style::CustomStylable; +use super::{TimingKind, TimingReport, TimingThread}; +use crate::app::UserEvent; +use crate::context::{LayoutChanged, LayoutChangedListener, PaintCx, StyleCx}; +use crate::effects::Brush; +use crate::event::{Event, EventPropagation, PointerScrollEventExt, listener}; +use crate::prelude::palette::css; +use crate::prelude::{EventListenerTrait, TooltipExt}; +use crate::style::theme::Theme; +use crate::text::{Attrs, AttrsList, TextLayout}; use crate::theme::StyleThemeExt; -use crate::unit::UnitExt; -use crate::view::IntoView; +use crate::ui_events::pointer::{PointerButton, PointerEvent, PointerGesture}; +use crate::view::{IntoView, View, ViewId}; use crate::views::resizable::Resizable; use crate::views::{ - Button, Clip, Container, ContainerExt, Decorators, Label, Scroll, Stack, dyn_container, list, + Button, ContainerExt, Decorators, Label, ListItemClass, Scroll, ScrollExt, Stack, + dyn_container, list, }; -use floem_reactive::{RwSignal, Scope, SignalGet, SignalUpdate}; +use crate::{ElementId, box_tree::ElementMeta}; +use floem_reactive::{Effect, Memo, RwSignal, Scope, SignalGet, SignalUpdate}; +use peniko::Color; +use peniko::kurbo::{Affine, Line, Point, Rect, Size, Stroke}; +use std::collections::HashMap; use std::fmt::Display; use std::mem; use std::rc::Rc; -use taffy::AlignItems; use taffy::style::FlexDirection; +use understory_box_tree::NodeFlags; +use understory_view2d::Viewport1D; use winit::window::WindowId; use crate::platform::{Duration, Instant}; +use peniko::color::HueDirection; #[derive(Clone)] pub struct ProfileEvent { pub start: Instant, pub end: Instant, - pub name: &'static str, + pub name: String, + pub depth: usize, } #[derive(Default)] pub struct ProfileFrame { pub events: Vec, + pub timing: Option, } #[derive(Default)] pub struct Profile { pub current: ProfileFrame, - frames: Vec, + timeline_origin: Option, + events: Vec, + frames: Vec, } impl Profile { pub fn next_frame(&mut self) { - self.frames.push(mem::take(&mut self.current)); + let frame = mem::take(&mut self.current); + if frame.events.is_empty() && frame.timing.is_none() { + return; + } + + let frame_start = frame + .events + .iter() + .filter(|event| event.name == "VSync") + .map(|event| event.start) + .min() + .or_else(|| { + frame + .events + .iter() + .map(|event| event.start) + .chain(frame.timing.iter().filter_map(|timing| timing.anchor)) + .min() + }); + let Some(frame_start) = frame_start else { + return; + }; + let frame_end = frame + .events + .iter() + .map(|event| event.end) + .chain( + frame + .timing + .iter() + .filter_map(|timing| timing.anchor.map(|anchor| anchor + timing.total)), + ) + .max() + .unwrap_or(frame_start); + match self.timeline_origin { + Some(origin) if frame_start < origin => self.timeline_origin = Some(frame_start), + None => self.timeline_origin = Some(frame_start), + _ => {} + } + let event_sum: Duration = frame + .events + .iter() + .map(|event| event.end.saturating_duration_since(event.start)) + .sum(); + let timing_sum = frame + .timing + .as_ref() + .map(|timing| timing.total) + .unwrap_or_default(); + let timing_count = frame + .timing + .as_ref() + .map(|timing| timing.flattened_spans().len()) + .unwrap_or_default(); + let event_count = frame.events.len(); + self.events.extend(frame.events); + self.frames.push(ProfileFrameSummary { + start: Some(frame_start), + duration: frame_end.saturating_duration_since(frame_start), + sum: event_sum + timing_sum, + event_count, + timing_count, + timing: frame.timing, + }); } } struct ProfileFrameData { + index: usize, + start: Duration, + duration: Duration, + sum: Duration, + event_count: usize, + timing_count: usize, +} + +#[derive(Default)] +struct ProfileFrameSummary { start: Option, duration: Duration, sum: Duration, - events: Vec, + event_count: usize, + timing_count: usize, + timing: Option, +} + +#[derive(Clone)] +struct TimelineFrameMarker { + index: usize, + start: Duration, +} + +#[derive(Clone)] +struct TimelineInstantMarker { + start: Duration, + color: Color, +} + +#[derive(Clone)] +enum TimelineItemKind { + Event, + Timing(TimingKind), +} + +#[derive(Clone)] +struct TimelineItem { + label: String, + source: String, + start: Duration, + duration: Duration, + depth: usize, + kind: TimelineItemKind, +} + +fn timeline_item_color(item: &TimelineItem) -> Color { + match &item.kind { + TimelineItemKind::Event if item.label == "VSync" => Color::from_rgb8(153, 97, 191), + TimelineItemKind::Event if item.label == "BeginFrameWork" => Color::from_rgb8(221, 154, 52), + TimelineItemKind::Event if item.label == "FramePresented" => Color::from_rgb8(48, 166, 127), + TimelineItemKind::Event if item.label == "CompositorCarryCommit" => { + Color::from_rgb8(224, 112, 74) + } + TimelineItemKind::Event => Color::from_rgb8(54, 111, 196), + TimelineItemKind::Timing(TimingKind::Total) => Color::from_rgb8(41, 78, 163), + TimelineItemKind::Timing(TimingKind::Style) => Color::from_rgb8(29, 142, 120), + TimelineItemKind::Timing(TimingKind::Layout) => Color::from_rgb8(201, 138, 33), + TimelineItemKind::Timing(TimingKind::BoxTree) => Color::from_rgb8(188, 130, 36), + TimelineItemKind::Timing(TimingKind::Paint) => Color::from_rgb8(203, 92, 73), + TimelineItemKind::Timing(TimingKind::Present) => Color::from_rgb8(64, 157, 163), + TimelineItemKind::Timing(TimingKind::Renderer) => Color::from_rgb8(62, 126, 214), + TimelineItemKind::Timing(TimingKind::Gpu) => Color::from_rgb8(121, 104, 218), + } +} + +fn instant_marker_color(label: &str) -> Color { + match label { + "VSync" => Color::from_rgb8(153, 97, 191), + "BeginFrameWork" => Color::from_rgb8(221, 154, 52), + "FramePresented" => Color::from_rgb8(48, 166, 127), + "CompositorCarryCommit" => Color::from_rgb8(224, 112, 74), + _ => Color::from_rgb8(54, 111, 196), + } +} + +fn profiler_panel(view: impl IntoView + 'static) -> impl IntoView { + view.into_view().style(|s| { + s.border(1.0).border_radius(22.0).with_theme(|s, t| { + s.background(t.bg_elevated()) + .border_color(t.border_muted()) + .color(t.text()) + }) + }) +} + +fn profiler_chip( + label: impl Into, + value: impl Into, + accent: Color, +) -> impl IntoView { + let label = label.into(); + let value = value.into(); + Stack::vertical(( + Label::new(label).style(|s| s.font_size(10.5).with_theme(|s, t| s.color(t.text_muted()))), + Label::new(value).style(move |s| { + s.font_size(13.0) + .font_bold() + .color(accent) + .text_ellipsis() + .min_width(0.0) + }), + )) + .style(move |s| { + s.gap(4.0) + .padding_horiz(12.0) + .padding_vert(10.0) + .border_radius(16.0) + .border(1.0) + .background(accent.with_alpha(0.10)) + .border_color(accent.with_alpha(0.18)) + .min_width(0.0) + }) +} + +fn profiler_marker_legend_item( + label: impl Into, + color: Color, + description: impl Into, +) -> impl IntoView { + let description = description.into(); + Stack::horizontal(( + color, + Label::new(label.into()).style(|s| { + s.font_size(10.5) + .text_ellipsis() + .min_width(0.0) + .with_theme(|s, t| s.color(t.text_muted())) + }), + )) + .tooltip(move || { + Label::new(description.clone()).style(|s| { + s.max_width(260.0) + .padding(10.0) + .border_radius(10.0) + .font_size(11.0) + .text_wrap() + .with_theme(|s, t| { + s.background(t.bg_overlay()) + .border_color(t.border_muted()) + .color(t.text()) + }) + .border(1.0) + }) + }) + .style(|s| s.items_center().gap(6.0).min_width(0.0)) +} + +fn profiler_marker_legend() -> impl IntoView { + Stack::horizontal(( + Label::new("Markers").style(|s| { + s.font_size(10.5) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + profiler_marker_legend_item( + "VSync", + instant_marker_color("VSync"), + "Display-link frame opportunity received by Floem.", + ), + profiler_marker_legend_item( + "Begin", + instant_marker_color("BeginFrameWork"), + "Deferred frame work begins: style, layout, paint, and render scheduling.", + ), + profiler_marker_legend_item( + "Commit", + instant_marker_color("CompositorCommit"), + "Core Animation compositor transaction committed with ready scene/layer content.", + ), + profiler_marker_legend_item( + "Carry", + instant_marker_color("CompositorCarryCommit"), + "Deadline commit while scene render work is still pending; commits ready or old state.", + ), + profiler_marker_legend_item( + "Presented", + instant_marker_color("FramePresented"), + "Layer-host feedback observed the committed frame as presented.", + ), + )) + .style(|s| { + s.items_center() + .gap(10.0) + .padding_horiz(12.0) + .padding_vert(10.0) + .border_radius(16.0) + .border(1.0) + .with_theme(|s, t| s.background(t.bg_overlay()).border_color(t.border_muted())) + .min_width(0.0) + .flex_wrap(taffy::style::FlexWrap::Wrap) + }) +} + +fn build_timeline_lanes(profile: &Profile) -> Vec> { + let mut items = Vec::new(); + + let Some(origin) = profile.timeline_origin else { + return Vec::new(); + }; + + items.extend(profile.events.iter().filter_map(|event| { + let duration = event.end.saturating_duration_since(event.start); + (!duration.is_zero()).then(|| TimelineItem { + label: event.name.to_string(), + source: "Profiler Event".to_string(), + start: event.start.saturating_duration_since(origin), + duration, + depth: event.depth, + kind: TimelineItemKind::Event, + }) + })); + + for frame in &profile.frames { + let Some(report) = &frame.timing else { + continue; + }; + let Some(anchor) = report.anchor else { + continue; + }; + items.extend( + report + .flattened_spans() + .into_iter() + .map(|span| TimelineItem { + label: span.label.to_string(), + source: match span.thread { + TimingThread::Main => "Main Thread".to_string(), + TimingThread::Renderer(index) => format!("Render({index})"), + TimingThread::Gpu => "GPU".to_string(), + }, + start: anchor.saturating_duration_since(origin) + span.start, + duration: span.duration, + depth: span.depth, + kind: TimelineItemKind::Timing(span.kind), + }), + ); + } + + items.sort_by(|a, b| { + a.start + .cmp(&b.start) + .then_with(|| b.duration.cmp(&a.duration)) + .then_with(|| a.depth.cmp(&b.depth)) + .then_with(|| a.label.cmp(&b.label)) + }); + + let mut lane_threads: Vec = Vec::new(); + let mut lane_ends: Vec = Vec::new(); + let mut lanes: Vec> = Vec::new(); + + for item in items { + let item_end = item.start.saturating_add(item.duration); + if let Some((lane_idx, lane_end)) = lane_ends + .iter_mut() + .enumerate() + .find(|(idx, lane_end)| lane_threads[*idx] == item.source && **lane_end <= item.start) + { + *lane_end = item_end; + lanes[lane_idx].push(item); + } else { + lane_threads.push(item.source.clone()); + lane_ends.push(item_end); + lanes.push(vec![item]); + } + } + + lanes +} + +fn profile_total_duration(profile: &Profile) -> Duration { + let Some(origin) = profile.timeline_origin else { + return Duration::ZERO; + }; + + let event_end = profile + .events + .iter() + .map(|event| event.end.saturating_duration_since(origin)) + .max() + .unwrap_or_default(); + + let frame_end = profile + .frames + .iter() + .filter_map(|frame| { + frame + .start + .map(|start| start.saturating_duration_since(origin) + frame.duration) + }) + .max() + .unwrap_or_default(); + + let timing_end = profile + .frames + .iter() + .filter_map(|frame| { + frame.timing.as_ref().and_then(|timing| { + timing + .anchor + .map(|anchor| anchor.saturating_duration_since(origin) + timing.total) + }) + }) + .max() + .unwrap_or_default(); + + event_end.max(frame_end).max(timing_end) +} + +const TIMELINE_PADDING: f64 = 8.0; +const TIMELINE_LANE_HEIGHT: f64 = 18.0; +const TIMELINE_LANE_GAP: f64 = 4.0; +const TIMELINE_LABEL_FONT_SIZE: f32 = 11.0; +const OVERVIEW_HEIGHT: f64 = 84.0; +const OVERVIEW_INSET: f64 = 8.0; +const OVERVIEW_BIN_COUNT: usize = 360; + +#[derive(Clone, Copy)] +struct TimelinePalette { + lane_bg: Color, + border: Color, + text: Color, + selected: Color, +} + +fn format_duration_short(duration: Duration) -> String { + let micros = duration.as_secs_f64() * 1_000_000.0; + if micros >= 1_000.0 { + format!("{:.2} ms", micros / 1_000.0) + } else { + format!("{micros:.0} us") + } +} + +fn frame_for_visible_range( + frames: &[Rc], + visible_range: (f64, f64), +) -> Option> { + let center = (visible_range.0 + visible_range.1) * 0.5; + let containing = frames + .iter() + .find(|frame| { + let start = frame.start.as_secs_f64(); + let end = start + frame.duration.as_secs_f64(); + center >= start && center <= end + }) + .cloned(); + containing.or_else(|| { + frames + .iter() + .min_by(|a, b| { + let a_center = a.start.as_secs_f64() + a.duration.as_secs_f64() * 0.5; + let b_center = b.start.as_secs_f64() + b.duration.as_secs_f64() * 0.5; + (a_center - center) + .abs() + .total_cmp(&(b_center - center).abs()) + }) + .cloned() + }) +} + +fn frame_for_cursor_time( + frames: &[Rc], + cursor_time: f64, +) -> Option> { + frames + .iter() + .min_by(|a, b| { + (a.start.as_secs_f64() - cursor_time) + .abs() + .total_cmp(&(b.start.as_secs_f64() - cursor_time).abs()) + }) + .cloned() +} + +struct TimelineElement { + element_id: ElementId, + lane_idx: usize, + item: TimelineItem, + label_layout: TextLayout, +} + +enum ProfilerTimelineUpdate { + SelectedFrame(Option>), + ViewportRequest(ProfilerViewportRequest), +} + +#[derive(Clone, Copy)] +enum ProfilerViewportRequest { + Center(f64), + PanByWorld(f64), + ZoomAround { anchor_time: f64, factor: f64 }, +} + +struct ProfilerTimelineView { + id: ViewId, + hovered_event: RwSignal>, + cursor_time: RwSignal>, + selected_frame: RwSignal>>, + active_frame_index: RwSignal>, + visible_range: RwSignal<(f64, f64)>, + viewport_request: RwSignal>, + viewport: Viewport1D, + viewport_initialized: bool, + content_origin_x: f64, + size: Size, + content_element: ElementId, + elements: Vec, + element_indices: HashMap, + hovered_element: Option, + lane_count: usize, + frame_markers: Rc<[TimelineFrameMarker]>, + instant_markers: Rc<[TimelineInstantMarker]>, + last_selected_frame: Option, + palette: TimelinePalette, +} + +impl ProfilerTimelineView { + fn new( + lanes: Rc<[Vec]>, + frame_markers: Rc<[TimelineFrameMarker]>, + instant_markers: Rc<[TimelineInstantMarker]>, + total_duration: Duration, + hovered_event: RwSignal>, + cursor_time: RwSignal>, + selected_frame: RwSignal>>, + active_frame_index: RwSignal>, + visible_range: RwSignal<(f64, f64)>, + viewport_request: RwSignal>, + ) -> Self { + let id = ViewId::new(); + id.register_listener(LayoutChangedListener::listener_key()); + + let mut viewport = Viewport1D::new(0.0..1.0); + viewport.set_world_bounds(Some(0.0..total_duration.as_secs_f64())); + viewport.set_zoom_limits(1e-6, 1e12); + + let content_element = id.create_child_element_id(0); + let pending_elements: Vec<_> = lanes + .iter() + .enumerate() + .flat_map(|(lane_idx, lane)| { + lane.iter().cloned().map(move |item| { + let element_id = id.create_child_element_id(1); + (lane_idx, element_id, item) + }) + }) + .collect(); + let mut elements = Vec::new(); + let mut element_indices = HashMap::new(); + + { + let box_tree = id.box_tree(); + let mut bt = box_tree.borrow_mut(); + bt.set_flags(content_element.0, NodeFlags::VISIBLE); + bt.set_element_meta(content_element.0, Some(ElementMeta::new(content_element))); + bt.set_local_bounds( + content_element.0, + Rect::new( + 0.0, + 0.0, + total_duration.as_secs_f64().max(1e-6), + Self::total_height_for_lane_count(lanes.len()), + ), + ); + + for (lane_idx, element_id, item) in pending_elements { + bt.reparent(element_id.0, Some(content_element.0)); + bt.set_flags(element_id.0, NodeFlags::VISIBLE | NodeFlags::PICKABLE); + bt.set_element_meta(element_id.0, Some(ElementMeta::new(element_id))); + let x0 = item.start.as_secs_f64(); + let width = item.duration.as_secs_f64().max(1e-6); + let lane_y = + TIMELINE_PADDING + lane_idx as f64 * (TIMELINE_LANE_HEIGHT + TIMELINE_LANE_GAP); + bt.set_local_bounds( + element_id.0, + Rect::new(x0, lane_y, x0 + width, lane_y + TIMELINE_LANE_HEIGHT), + ); + element_indices.insert(element_id, elements.len()); + elements.push(TimelineElement { + element_id, + lane_idx, + item, + label_layout: TextLayout::new(), + }); + } + } + + let mut this = Self { + id, + hovered_event, + cursor_time, + selected_frame, + active_frame_index, + visible_range, + viewport_request, + viewport, + viewport_initialized: false, + content_origin_x: 0.0, + size: Size::ZERO, + content_element, + elements, + element_indices, + hovered_element: None, + lane_count: lanes.len(), + frame_markers, + instant_markers, + last_selected_frame: None, + palette: TimelinePalette { + lane_bg: css::LIGHT_GRAY.with_alpha(0.3), + border: css::GRAY, + text: css::BLACK, + selected: css::TOMATO, + }, + }; + let effect_id = this.id; + let effect_selected_frame = this.selected_frame; + Effect::new(move |_| { + effect_id.update_state(ProfilerTimelineUpdate::SelectedFrame( + effect_selected_frame.get(), + )); + }); + let effect_id = this.id; + let effect_active_frame_index = this.active_frame_index; + Effect::new(move |_| { + let _ = effect_active_frame_index.get(); + effect_id.request_paint(); + }); + let effect_id = this.id; + let effect_viewport_request = this.viewport_request; + Effect::new(move |_| { + if let Some(request) = effect_viewport_request.get() { + effect_id.update_state(ProfilerTimelineUpdate::ViewportRequest(request)); + } + }); + this.rebuild_label_layouts(); + this + } + + fn total_height_for_lane_count(lane_count: usize) -> f64 { + let lane_count = lane_count as f64; + if lane_count == 0.0 { + TIMELINE_PADDING * 2.0 + } else { + TIMELINE_PADDING * 2.0 + + lane_count * TIMELINE_LANE_HEIGHT + + (lane_count - 1.0) * TIMELINE_LANE_GAP + } + } + + fn sync_viewport_size(&mut self) { + self.viewport.set_view_span(0.0..self.size.width.max(1.0)); + if !self.viewport_initialized { + self.viewport.fit_world(); + self.viewport_initialized = true; + } + } + + fn total_duration_secs(&self) -> f64 { + self.viewport + .world_bounds() + .map(|bounds| (bounds.end - bounds.start).abs()) + .unwrap_or(1.0) + } + + fn root_local_x(&self, cx: &crate::context::EventCx<'_>, point: Point) -> f64 { + if cx.target == self.id.get_element_id() { + return point.x; + } + + let window_point = cx.world_transform.inverse() * point; + let box_tree = self.id.box_tree(); + let box_tree = box_tree.borrow(); + let root_world = box_tree + .world_transform(self.id.get_element_id().0) + .unwrap_or_default(); + (root_world.inverse() * window_point).x + } + + fn rebuild_label_layouts(&mut self) { + let attrs = Attrs::new() + .font_size(TIMELINE_LABEL_FONT_SIZE) + .color(self.palette.text); + let attrs_list = AttrsList::new(attrs); + for element in &mut self.elements { + element + .label_layout + .set_text(&element.item.label, attrs_list.clone(), None); + } + } + + fn time_at_root_local_x(&self, x: f64) -> Option { + if self.size.width <= f64::EPSILON { + return None; + } + Some( + self.viewport + .view_to_world_x(x.clamp(0.0, self.size.width)) + .clamp(0.0, self.total_duration_secs()), + ) + } + + fn fit_selected_frame(&mut self, frame: &ProfileFrameData) { + if !self.viewport_initialized { + return; + } + let visible = self.viewport.visible_world_range(); + let visible_width = (visible.end - visible.start).abs().max(f64::MIN_POSITIVE); + let center = frame.start.as_secs_f64() + frame.duration.as_secs_f64() * 0.5; + self.viewport + .fit_range((center - visible_width * 0.5)..(center + visible_width * 0.5)); + } + + fn center_viewport_on(&mut self, center: f64) { + if !self.viewport_initialized { + return; + } + let visible = self.viewport.visible_world_range(); + let total = self.total_duration_secs().max(f64::MIN_POSITIVE); + let visible_width = (visible.end - visible.start) + .abs() + .clamp(f64::MIN_POSITIVE, total); + let mut start = center - visible_width * 0.5; + let mut end = center + visible_width * 0.5; + + if start < 0.0 { + end -= start; + start = 0.0; + } + if end > total { + start -= end - total; + end = total; + } + if start < 0.0 { + start = 0.0; + } + self.viewport + .fit_range(start..end.max(start + f64::MIN_POSITIVE)); + } + + fn pan_viewport_by_world(&mut self, delta: f64) { + if !self.viewport_initialized { + return; + } + let visible = self.viewport.visible_world_range(); + let visible_width = (visible.end - visible.start).abs().max(f64::MIN_POSITIVE); + self.center_viewport_on((visible.start + visible_width * 0.5) + delta); + } + + fn zoom_viewport_around_time(&mut self, anchor_time: f64, factor: f64) { + if !self.viewport_initialized { + return; + } + let total = self.total_duration_secs().max(f64::MIN_POSITIVE); + let visible = self.viewport.visible_world_range(); + let width = (visible.end - visible.start) + .abs() + .clamp(f64::MIN_POSITIVE, total); + let factor = factor.max(0.01); + let new_width = (width / factor).clamp(f64::MIN_POSITIVE, total); + let anchor_time = anchor_time.clamp(0.0, total); + let anchor_ratio = if width <= f64::MIN_POSITIVE { + 0.5 + } else { + ((anchor_time - visible.start) / width).clamp(0.0, 1.0) + }; + let mut start = anchor_time - new_width * anchor_ratio; + let mut end = start + new_width; + if start < 0.0 { + end -= start; + start = 0.0; + } + if end > total { + start -= end - total; + end = total; + } + if start < 0.0 { + start = 0.0; + } + self.viewport + .fit_range(start..end.max(start + f64::MIN_POSITIVE)); + } + + fn sync_selected_frame(&mut self, selected_frame: Option>) -> bool { + let selected_index = selected_frame.as_ref().map(|frame| frame.index); + if selected_index == self.last_selected_frame { + return false; + } + self.last_selected_frame = selected_index; + if let Some(frame) = selected_frame.as_ref() { + self.fit_selected_frame(frame); + } + true + } + + fn refresh_viewport(&mut self, window_state: &mut crate::WindowState) { + let visible = self.viewport.visible_world_range(); + self.visible_range.set((visible.start, visible.end)); + self.sync_content_transform(window_state); + window_state.request_paint(self.id.get_element_id()); + } + + fn invalidate_timeline_paint(&self, window_state: &mut crate::WindowState) { + window_state.request_paint(self.id.get_element_id()); + window_state.request_paint(self.content_element); + for element in &self.elements { + window_state.request_paint(element.element_id); + } + } + + fn maybe_rebase_content_geometry(&mut self, window_state: &mut crate::WindowState) -> bool { + if !self.viewport_initialized { + return false; + } + + let visible = self.viewport.visible_world_range(); + let desired_origin = visible.start.max(0.0); + let rebase_threshold = + (256.0 / self.viewport.zoom().abs().max(f64::MIN_POSITIVE)).max(1e-9); + if (desired_origin - self.content_origin_x).abs() < rebase_threshold { + return false; + } + + self.content_origin_x = desired_origin; + let total_duration = self.total_duration_secs(); + let total_height = Self::total_height_for_lane_count(self.lane_count); + + let box_tree = self.id.box_tree(); + let mut bt = box_tree.borrow_mut(); + bt.set_local_bounds( + self.content_element.0, + Rect::new( + -self.content_origin_x, + 0.0, + total_duration - self.content_origin_x, + total_height, + ), + ); + + for element in &self.elements { + let x0 = element.item.start.as_secs_f64() - self.content_origin_x; + let width = element.item.duration.as_secs_f64().max(1e-6); + let lane_y = TIMELINE_PADDING + + element.lane_idx as f64 * (TIMELINE_LANE_HEIGHT + TIMELINE_LANE_GAP); + bt.set_local_bounds( + element.element_id.0, + Rect::new(x0, lane_y, x0 + width, lane_y + TIMELINE_LANE_HEIGHT), + ); + } + drop(bt); + + self.id.request_box_tree_commit(); + self.invalidate_timeline_paint(window_state); + true + } + + fn sync_content_transform(&mut self, window_state: &mut crate::WindowState) { + let rebased = self.maybe_rebase_content_geometry(window_state); + let pan = self.viewport.world_to_view_x(self.content_origin_x); + let transform = Affine::new([self.viewport.zoom(), 0.0, 0.0, 1.0, pan, 0.0]); + let box_tree = self.id.box_tree(); + let transform_changed = box_tree + .borrow_mut() + .set_local_transform(self.content_element.0, transform); + if transform_changed || rebased { + self.id.request_box_tree_commit(); + window_state.request_paint(self.id.get_element_id()); + } + } + + fn set_hovered_element( + &mut self, + hovered: Option, + window_state: &mut crate::WindowState, + ) { + if hovered == self.hovered_element { + return; + } + let previous = self.hovered_element; + self.hovered_element = hovered; + if let Some(previous) = previous + && previous != self.id.get_element_id() + { + window_state.request_paint(previous); + } + if let Some(current) = hovered { + window_state.request_paint(current); + } + let event = hovered + .and_then(|element_id| self.element_indices.get(&element_id).copied()) + .map(|idx| self.elements[idx].item.clone()); + self.hovered_event.set(event); + } + + fn update_from_layout( + &mut self, + layout: &LayoutChanged, + window_state: &mut crate::WindowState, + ) { + let new_size = layout.new_box.size(); + if self.viewport_initialized && self.size == new_size { + return; + } + + self.size = new_size; + self.sync_viewport_size(); + let selected_frame = self.selected_frame.get_untracked(); + self.sync_selected_frame(selected_frame); + self.refresh_viewport(window_state); + } +} + +impl View for ProfilerTimelineView { + fn id(&self) -> ViewId { + self.id + } + + fn debug_name(&self) -> std::borrow::Cow<'static, str> { + "Profiler Timeline View".into() + } + + fn update(&mut self, cx: &mut crate::context::UpdateCx, state: Box) { + if let Ok(update) = state.downcast::() { + match *update { + ProfilerTimelineUpdate::SelectedFrame(selected_frame) => { + if self.sync_selected_frame(selected_frame) { + self.refresh_viewport(cx.window_state); + } + } + ProfilerTimelineUpdate::ViewportRequest(request) => { + self.viewport_request.set(None); + match request { + ProfilerViewportRequest::Center(center) => { + self.center_viewport_on(center); + } + ProfilerViewportRequest::PanByWorld(delta) => { + self.pan_viewport_by_world(delta); + } + ProfilerViewportRequest::ZoomAround { + anchor_time, + factor, + } => { + self.zoom_viewport_around_time(anchor_time, factor); + } + } + self.refresh_viewport(cx.window_state); + } + } + } + } + + fn style_pass(&mut self, cx: &mut StyleCx<'_>) { + let Some(theme) = cx.get_prop(Theme) else { + return; + }; + let palette = TimelinePalette { + lane_bg: theme.bg_elevated(), + border: theme.border(), + text: theme.text(), + selected: css::TOMATO, + }; + let palette_changed = palette.lane_bg != self.palette.lane_bg + || palette.border != self.palette.border + || palette.text != self.palette.text + || palette.selected != self.palette.selected; + if palette_changed { + self.palette = palette; + self.rebuild_label_layouts(); + cx.window_state.request_paint(self.id); + } + } + + fn event(&mut self, cx: &mut crate::context::EventCx) -> EventPropagation { + if let Some(layout) = LayoutChangedListener::extract(&cx.event) { + self.update_from_layout(layout, cx.window_state); + return EventPropagation::Continue; + } + + match &cx.event { + Event::Pointer(PointerEvent::Enter(_)) => { + let hovered = self + .element_indices + .contains_key(&cx.target) + .then_some(cx.target); + if hovered.is_some() { + self.set_hovered_element(hovered, cx.window_state); + EventPropagation::Stop + } else { + EventPropagation::Continue + } + } + Event::Pointer(PointerEvent::Move(event)) => { + let root_local_x = self.root_local_x(cx, event.current.logical_point()); + self.cursor_time + .set(self.time_at_root_local_x(root_local_x)); + EventPropagation::Continue + } + Event::Pointer(PointerEvent::Scroll(event)) => { + let delta = event.resolve_to_points(None, None); + let anchor_x = self.root_local_x(cx, event.state.logical_point()); + let mut changed = false; + let mut zoomed = false; + + if delta.x.abs() > delta.y.abs() && delta.x.abs() > f64::EPSILON { + self.viewport.pan_by_view(delta.x); + changed = true; + } else if delta.y.abs() > f64::EPSILON { + let factor = (1.0 - delta.y / 400.0).max(0.01); + self.viewport.zoom_about_view_point(anchor_x, factor); + changed = true; + zoomed = true; + } + + if changed { + if zoomed { + self.invalidate_timeline_paint(cx.window_state); + } + self.refresh_viewport(cx.window_state); + EventPropagation::Stop + } else { + EventPropagation::Continue + } + } + Event::Pointer(PointerEvent::Gesture(gesture)) => { + let PointerGesture::Pinch(delta) = gesture.gesture else { + return EventPropagation::Continue; + }; + let factor = (1.0 + f64::from(delta)).max(0.01); + let anchor_x = self.root_local_x(cx, gesture.state.logical_point()); + self.viewport.zoom_about_view_point(anchor_x, factor); + self.invalidate_timeline_paint(cx.window_state); + self.refresh_viewport(cx.window_state); + EventPropagation::Stop + } + Event::Pointer(PointerEvent::Leave(_)) if cx.target == self.id.get_element_id() => { + self.cursor_time.set(None); + EventPropagation::Continue + } + _ => EventPropagation::Continue, + } + } + + fn paint(&mut self, cx: &mut PaintCx) { + if cx.target_id == self.id.get_element_id() { + for lane_idx in 0..self.lane_count { + let y = + TIMELINE_PADDING + lane_idx as f64 * (TIMELINE_LANE_HEIGHT + TIMELINE_LANE_GAP); + let lane_rect = Rect::new(0.0, y, self.size.width, y + TIMELINE_LANE_HEIGHT); + cx.painter + .fill(lane_rect, &Brush::Solid(self.palette.lane_bg)) + .draw(); + } + let total_height = Self::total_height_for_lane_count(self.lane_count); + let selected_index = self.active_frame_index.get_untracked(); + for marker in self.frame_markers.iter() { + let x = self.viewport.world_to_view_x(marker.start.as_secs_f64()); + if x < 0.0 || x > self.size.width { + continue; + } + let is_selected = selected_index == Some(marker.index); + let color = if is_selected { + self.palette.selected + } else { + self.palette.border.with_alpha(0.35) + }; + cx.painter + .stroke( + Line::new( + (x, TIMELINE_PADDING - 2.0), + (x, total_height - TIMELINE_PADDING + 2.0), + ), + &Stroke::new(if is_selected { 1.5 } else { 1.0 }), + &Brush::Solid(color), + ) + .draw(); + cx.painter + .fill( + Rect::new(x - 2.5, 2.0, x + 2.5, 8.0).to_rounded_rect(2.0), + &Brush::Solid(color), + ) + .draw(); + } + for marker in self.instant_markers.iter() { + let x = self.viewport.world_to_view_x(marker.start.as_secs_f64()); + if x < 0.0 || x > self.size.width { + continue; + } + let color = marker.color.with_alpha(0.7); + cx.painter + .stroke( + Line::new( + (x, TIMELINE_PADDING - 2.0), + (x, total_height - TIMELINE_PADDING + 2.0), + ), + &Stroke::new(1.0), + &Brush::Solid(color), + ) + .draw(); + cx.painter + .fill( + Rect::new(x - 2.5, 2.0, x + 2.5, 8.0).to_rounded_rect(2.0), + &Brush::Solid(color), + ) + .draw(); + } + return; + } + + if let Some(&element_idx) = self.element_indices.get(&cx.target_id) { + let element = &self.elements[element_idx]; + let rect = cx.layout_rect_local; + let rounded_rect = rect.to_rounded_rect(2.0); + let hovered = self.hovered_element == Some(element.element_id); + let color = timeline_item_color(&element.item); + cx.painter + .fill( + rounded_rect, + &Brush::Solid(color.with_alpha(if hovered { 0.9 } else { 0.7 })), + ) + .draw(); + } + } + + fn post_paint(&mut self, cx: &mut PaintCx) { + if cx.target_id != self.id.get_element_id() { + return; + } + + let visible = self.viewport.visible_world_range(); + + for element in &self.elements { + let start = element.item.start.as_secs_f64(); + let end = start + element.item.duration.as_secs_f64(); + if end <= visible.start || start >= visible.end { + continue; + } + + let x0 = self.viewport.world_to_view_x(start); + let x1 = self.viewport.world_to_view_x(end); + let lane_y = TIMELINE_PADDING + + element.lane_idx as f64 * (TIMELINE_LANE_HEIGHT + TIMELINE_LANE_GAP); + let rect = Rect::new(x0, lane_y, x1, lane_y + TIMELINE_LANE_HEIGHT); + if rect.width() < 10.0 || rect.x1 <= 0.0 || rect.x0 >= self.size.width { + continue; + } + + let text_origin = Point::new( + rect.x0 + 6.0, + rect.y0 + + ((TIMELINE_LANE_HEIGHT.min(rect.height())) + - element.label_layout.size().height) + * 0.5, + ); + cx.painter.with_fill_clip(rect, |p| { + element.label_layout.draw_with_painter( + p.as_dyn(), + text_origin, + cx.font_embolden, + cx.window_state.effective_scale(), + ); + }); + } + } +} + +#[derive(Clone, Copy)] +struct OverviewPalette { + background: Color, + border: Color, + textured_base: Color, + viewport_fill: Color, + viewport_stroke: Color, +} + +#[derive(Clone, Copy)] +struct OverviewBin { + density: f64, + color: Color, +} + +#[derive(Default)] +struct OverviewAccum { + density: f64, + weight: f64, + color_r: f64, + color_g: f64, + color_b: f64, +} + +fn mix_overview_color(base: Color, accent: Color, amount: f64) -> Color { + base.lerp( + accent, + amount.clamp(0.0, 1.0) as f32, + HueDirection::default(), + ) +} + +fn smooth_overview_bins(bins: &mut [OverviewBin]) { + if bins.len() < 3 { + return; + } + let source = bins.to_vec(); + for idx in 0..bins.len() { + let prev = source[idx.saturating_sub(1)]; + let curr = source[idx]; + let next = source[(idx + 1).min(source.len() - 1)]; + bins[idx].density = prev.density * 0.2 + curr.density * 0.6 + next.density * 0.2; + bins[idx].color = mix_overview_color( + mix_overview_color(curr.color, prev.color, 0.25), + next.color, + 0.25, + ); + } +} + +fn build_overview_bins( + lanes: &[Vec], + total_duration_secs: f64, + lane_count: usize, +) -> Rc<[OverviewBin]> { + if lanes.is_empty() || total_duration_secs <= f64::EPSILON { + return Rc::from(Vec::::new()); + } + + let mut accum = (0..OVERVIEW_BIN_COUNT) + .map(|_| OverviewAccum::default()) + .collect::>(); + let bin_span = total_duration_secs / OVERVIEW_BIN_COUNT as f64; + let lane_norm = lane_count.max(1) as f64; + + for lane in lanes { + for item in lane { + let color = timeline_item_color(item).to_rgba8(); + let start = item.start.as_secs_f64().clamp(0.0, total_duration_secs); + let end = + (start + item.duration.as_secs_f64().max(1e-6)).clamp(0.0, total_duration_secs); + if end <= start { + continue; + } + + let start_idx = + ((start / total_duration_secs) * OVERVIEW_BIN_COUNT as f64).floor() as usize; + let end_idx = (((end / total_duration_secs) * OVERVIEW_BIN_COUNT as f64).ceil() + as usize) + .min(OVERVIEW_BIN_COUNT.saturating_sub(1)); + + for (idx, acc) in accum + .iter_mut() + .enumerate() + .take(end_idx + 1) + .skip(start_idx.min(OVERVIEW_BIN_COUNT - 1)) + { + let bin_start = idx as f64 * bin_span; + let bin_end = bin_start + bin_span; + let overlap = (end.min(bin_end) - start.max(bin_start)).max(0.0); + if overlap <= 0.0 { + continue; + } + let weight = overlap / bin_span.max(f64::MIN_POSITIVE); + acc.density += weight / lane_norm; + acc.weight += weight; + acc.color_r += f64::from(color.r) * weight; + acc.color_g += f64::from(color.g) * weight; + acc.color_b += f64::from(color.b) * weight; + } + } + } + + let max_density = accum + .iter() + .map(|acc| acc.density) + .fold(0.0_f64, f64::max) + .max(f64::MIN_POSITIVE); + let mut bins = accum + .into_iter() + .map(|acc| { + let avg_color = if acc.weight > 0.0 { + Color::from_rgb8( + (acc.color_r / acc.weight).clamp(0.0, 255.0) as u8, + (acc.color_g / acc.weight).clamp(0.0, 255.0) as u8, + (acc.color_b / acc.weight).clamp(0.0, 255.0) as u8, + ) + } else { + Color::from_rgb8(145, 155, 168) + }; + OverviewBin { + density: (acc.density / max_density).sqrt().clamp(0.0, 1.0), + color: avg_color, + } + }) + .collect::>(); + smooth_overview_bins(&mut bins); + Rc::from(bins) +} + +struct ProfilerOverviewView { + id: ViewId, + bins: Rc<[OverviewBin]>, + total_duration_secs: f64, + cursor_time: RwSignal>, + visible_range: RwSignal<(f64, f64)>, + viewport_request: RwSignal>, + dragging: bool, + size: Size, + palette: OverviewPalette, +} + +impl ProfilerOverviewView { + fn new( + bins: Rc<[OverviewBin]>, + total_duration: Duration, + cursor_time: RwSignal>, + visible_range: RwSignal<(f64, f64)>, + viewport_request: RwSignal>, + ) -> Self { + let id = ViewId::new(); + id.register_listener(LayoutChangedListener::listener_key()); + + let effect_id = id; + let effect_visible_range = visible_range; + Effect::new(move |_| { + let _ = effect_visible_range.get(); + effect_id.request_paint(); + }); + + Self { + id, + bins, + total_duration_secs: total_duration.as_secs_f64().max(f64::MIN_POSITIVE), + cursor_time, + visible_range, + viewport_request, + dragging: false, + size: Size::ZERO, + palette: OverviewPalette { + background: Color::from_rgb8(245, 247, 250), + border: Color::from_rgb8(202, 208, 216), + textured_base: Color::from_rgb8(178, 186, 197), + viewport_fill: Color::from_rgba8(54, 111, 196, 28), + viewport_stroke: Color::from_rgba8(54, 111, 196, 180), + }, + } + } + + fn update_from_layout( + &mut self, + layout: &LayoutChanged, + window_state: &mut crate::WindowState, + ) { + let new_size = layout.new_box.size(); + if self.size == new_size { + return; + } + self.size = new_size; + window_state.request_paint(self.id); + } + + fn inner_rect(&self) -> Rect { + Rect::new( + OVERVIEW_INSET, + OVERVIEW_INSET, + (self.size.width - OVERVIEW_INSET).max(OVERVIEW_INSET), + (self.size.height - OVERVIEW_INSET).max(OVERVIEW_INSET), + ) + } + + fn time_at_local_point(&self, point: Point) -> Option { + let inner = self.inner_rect(); + if inner.width() <= f64::EPSILON { + return None; + } + let t = ((point.x - inner.x0) / inner.width()).clamp(0.0, 1.0); + Some(self.total_duration_secs * t) + } + + fn visible_width_secs(&self) -> f64 { + let (start, end) = self.visible_range.get_untracked(); + (end - start).abs().clamp( + f64::MIN_POSITIVE, + self.total_duration_secs.max(f64::MIN_POSITIVE), + ) + } + + fn request_center_at(&mut self, point: Point, window_state: &mut crate::WindowState) { + if let Some(time) = self.time_at_local_point(point) { + self.viewport_request + .set(Some(ProfilerViewportRequest::Center(time))); + } + window_state.request_paint(self.id); + } + + fn request_pan_by(&mut self, delta_x: f64, window_state: &mut crate::WindowState) { + let inner = self.inner_rect(); + if inner.width() <= f64::EPSILON { + return; + } + let world_delta = delta_x * (self.visible_width_secs() / inner.width()); + self.viewport_request + .set(Some(ProfilerViewportRequest::PanByWorld(world_delta))); + window_state.request_paint(self.id); + } + + fn request_zoom_at( + &mut self, + point: Point, + factor: f64, + window_state: &mut crate::WindowState, + ) { + if let Some(anchor_time) = self.time_at_local_point(point) { + self.viewport_request + .set(Some(ProfilerViewportRequest::ZoomAround { + anchor_time, + factor, + })); + } + window_state.request_paint(self.id); + } +} + +impl View for ProfilerOverviewView { + fn id(&self) -> ViewId { + self.id + } + + fn debug_name(&self) -> std::borrow::Cow<'static, str> { + "Profiler Overview View".into() + } + + fn style_pass(&mut self, cx: &mut StyleCx<'_>) { + let Some(theme) = cx.get_prop(Theme) else { + return; + }; + self.palette = OverviewPalette { + background: theme.bg_overlay(), + border: theme.border_muted(), + textured_base: theme.text_muted().with_alpha(0.55), + viewport_fill: theme.primary().with_alpha(0.12), + viewport_stroke: theme.primary().with_alpha(0.72), + }; + cx.window_state.request_paint(self.id); + } + + fn event(&mut self, cx: &mut crate::context::EventCx) -> EventPropagation { + if let Some(layout) = LayoutChangedListener::extract(&cx.event) { + self.update_from_layout(layout, cx.window_state); + return EventPropagation::Continue; + } + + match &cx.event { + Event::Pointer(PointerEvent::Down(event)) => { + if event.button == Some(PointerButton::Primary) { + let pointer_id = event.pointer.pointer_id; + let point = event.state.logical_point(); + if let Some(pointer_id) = pointer_id { + cx.request_pointer_capture(pointer_id); + } + self.dragging = true; + self.request_center_at(point, cx.window_state); + EventPropagation::Stop + } else { + EventPropagation::Continue + } + } + Event::Pointer(PointerEvent::Move(event)) => { + self.cursor_time + .set(self.time_at_local_point(event.current.logical_point())); + if self.dragging { + self.request_center_at(event.current.logical_point(), cx.window_state); + EventPropagation::Stop + } else { + EventPropagation::Continue + } + } + Event::Pointer(PointerEvent::Scroll(event)) => { + let delta = event.resolve_to_points(None, None); + if delta.x.abs() > delta.y.abs() && delta.x.abs() > f64::EPSILON { + self.request_pan_by(delta.x, cx.window_state); + EventPropagation::Stop + } else if delta.y.abs() > f64::EPSILON { + let factor = (1.0 - delta.y / 400.0).max(0.01); + self.request_zoom_at(event.state.logical_point(), factor, cx.window_state); + EventPropagation::Stop + } else { + EventPropagation::Continue + } + } + Event::Pointer(PointerEvent::Gesture(gesture)) => { + let PointerGesture::Pinch(delta) = gesture.gesture else { + return EventPropagation::Continue; + }; + let factor = (1.0 + f64::from(delta)).max(0.01); + self.request_zoom_at(gesture.state.logical_point(), factor, cx.window_state); + EventPropagation::Stop + } + Event::Pointer(PointerEvent::Up(_)) => { + self.dragging = false; + EventPropagation::Continue + } + Event::Pointer(PointerEvent::Leave(_)) => { + self.dragging = false; + self.cursor_time.set(None); + EventPropagation::Continue + } + _ => EventPropagation::Continue, + } + } + + fn paint(&mut self, cx: &mut PaintCx) { + if cx.target_id != self.id.get_element_id() { + return; + } + + let outer = Rect::from_origin_size(Point::ZERO, self.size).to_rounded_rect(16.0); + cx.painter + .fill(outer, &Brush::Solid(self.palette.background)) + .draw(); + cx.painter + .stroke(outer, &Stroke::new(1.0), &Brush::Solid(self.palette.border)) + .draw(); + + let inner = self.inner_rect(); + if inner.width() <= f64::EPSILON || inner.height() <= f64::EPSILON { + return; + } + + cx.painter + .fill( + inner.to_rounded_rect(12.0), + &Brush::Solid(self.palette.background.with_alpha(0.82)), + ) + .draw(); + + if !self.bins.is_empty() { + let bin_width = inner.width() / self.bins.len() as f64; + let min_bar_height = 6.0_f64.min(inner.height()); + for (idx, bin) in self.bins.iter().enumerate() { + if bin.density <= 0.01 { + continue; + } + let x0 = inner.x0 + idx as f64 * bin_width; + let x1 = (x0 + bin_width + 0.35).min(inner.x1); + let height = min_bar_height + (inner.height() - min_bar_height) * bin.density; + let y0 = inner.y1 - height; + let fill = mix_overview_color( + self.palette.textured_base, + bin.color, + 0.25 + bin.density * 0.75, + ) + .with_alpha(0.92); + cx.painter + .fill( + Rect::new(x0, y0, x1, inner.y1).to_rounded_rect(1.5), + &Brush::Solid(fill), + ) + .draw(); + } + } + + let (visible_start, visible_end) = self.visible_range.get(); + let x0 = inner.x0 + inner.width() * (visible_start / self.total_duration_secs); + let x1 = inner.x0 + inner.width() * (visible_end / self.total_duration_secs); + let viewport_rect = Rect::new(x0, inner.y0, x1.max(x0 + 1.0), inner.y1); + cx.painter + .fill( + viewport_rect.to_rounded_rect(10.0), + &Brush::Solid(self.palette.viewport_fill), + ) + .draw(); + cx.painter + .stroke( + viewport_rect.to_rounded_rect(10.0), + &Stroke::new(1.0), + &Brush::Solid(self.palette.viewport_stroke), + ) + .draw(); + } } fn info(name: impl Display, value: String) -> impl IntoView { - info_row(name.to_string(), Label::new(value)) + info_row( + name.to_string(), + Label::new(value).style(|s| { + s.font_size(12.5) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + ) } fn info_row(name: String, view: impl IntoView + 'static) -> impl IntoView { @@ -59,6 +1617,7 @@ fn info_row(name: String, view: impl IntoView + 'static) -> impl IntoView { Label::new(name) .style(|s| { s.margin_right(5.0) + .font_size(10.5) .with_theme(|s, t| s.color(t.text_muted())) }) .container() @@ -66,179 +1625,389 @@ fn info_row(name: String, view: impl IntoView + 'static) -> impl IntoView { view, )) .style(|s| { - s.padding(5.0) - .with_theme(|s, t| s.hover(|s| s.background(t.bg_elevated()))) + s.padding_horiz(10.0) + .padding_vert(8.0) + .border_radius(12.0) + .with_theme(|s, t| s.background(t.bg_overlay())) }) } fn profile_view(profile: &Rc) -> impl IntoView { - let mut frames: Vec<_> = profile + let origin = profile.timeline_origin; + let frames: Rc<[_]> = profile .frames .iter() - .map(|frame| { - let start = frame.events.first().map(|event| event.start); - let end = frame.events.last().map(|event| event.end); - let sum = frame - .events - .iter() - .map(|event| event.end.saturating_duration_since(event.start)) - .sum(); - let duration = start - .and_then(|start| end.map(|end| end.saturating_duration_since(start))) - .unwrap_or_default(); - Rc::new(ProfileFrameData { - start, - duration, - sum, - events: frame.events.clone(), + .enumerate() + .filter_map(|(index, frame)| { + frame.start.zip(origin).map(|(start, origin)| { + Rc::new(ProfileFrameData { + index, + start: start.saturating_duration_since(origin), + duration: frame.duration, + sum: frame.sum, + event_count: frame.event_count, + timing_count: frame.timing_count, + }) }) }) - .collect(); - frames.sort_by(|a, b| b.sum.cmp(&a.sum)); - let frames: Rc<[_]> = frames.into(); + .collect::>() + .into(); - let selected_frame = RwSignal::new(None); - - let zoom = RwSignal::new(1.0); - - let frame_views: Vec<_> = frames + let total_duration = profile_total_duration(profile).max(Duration::from_micros(1)); + let lanes: Rc<[Vec]> = Rc::from(build_timeline_lanes(profile)); + let overview_bins = build_overview_bins(&lanes, total_duration.as_secs_f64(), lanes.len()); + let interval_count = lanes.iter().map(|lane| lane.len()).sum::(); + let frame_markers: Rc<[TimelineFrameMarker]> = profile + .frames .iter() - .cloned() .enumerate() - .map(|(i, frame)| { - let frame = frame.clone(); - Stack::horizontal(( - Label::new(format!("Frame #{i}")).style(|s| s.flex_grow(1.0)), - Label::new(format!("{:.4} ms", frame.sum.as_secs_f64() * 1000.0)) - .style(|s| s.margin_right(16)), - )) - .style(move |s| s.selectable(false).padding(5.0)) + .filter_map(|(index, frame)| { + frame + .start + .zip(origin) + .map(|(start, origin)| TimelineFrameMarker { + index, + start: start.saturating_duration_since(origin), + }) }) - .collect(); + .collect::>() + .into(); + let instant_markers: Rc<[TimelineInstantMarker]> = profile + .events + .iter() + .filter(|event| event.start == event.end) + .filter_map(|event| { + origin.map(|origin| TimelineInstantMarker { + start: event.start.saturating_duration_since(origin), + color: instant_marker_color(&event.name), + }) + }) + .collect::>() + .into(); + let selected_frame = RwSignal::new(None::>); + let active_frame_index = RwSignal::new(None::); + let cursor_time = RwSignal::new(None::); + let visible_range = RwSignal::new((0.0, total_duration.as_secs_f64())); + let viewport_request = RwSignal::new(None::); + let suppress_frame_list_select = RwSignal::new(false); - let hovered_event: RwSignal> = RwSignal::new(None); + let hovered_event: RwSignal> = RwSignal::new(None); let event_tooltip = dyn_container( move || hovered_event.get(), move |hovered_event| { if let Some(event) = hovered_event { - let len = event - .end - .saturating_duration_since(event.start) - .as_secs_f64(); + let accent = timeline_item_color(&event); Stack::vertical(( - info("Name", event.name.to_string()).style(|s| s.width_full()), - info("Time", format!("{:.4} ms", len * 1000.0)).style(|s| s.width_full()), + Stack::vertical(( + Label::new(event.label) + .style(|s| { + s.font_size(15.0) + .font_bold() + .text_wrap() + .min_width(0.0) + .max_width_full() + .with_theme(|s, t| s.color(t.text())) + }) + .scroll() + .style(|s| s.width_full().max_height(200)), + Label::new(event.source.to_string()) + .style(move |s| s.font_size(11.0).color(accent)), + )) + .style(|s| s.gap(3.0).width_full()), + Stack::vertical(( + info("Depth", event.depth.to_string()), + info("Start", format_duration_short(event.start)), + info("Duration", format_duration_short(event.duration)), + )) + .style(|s| s.gap(6.0).width_full()), )) - .style(|s| s.width_full()) + .style(|s| s.width_full().gap(10.0)) .into_any() } else { - Label::new("No hovered event") - .style(|s| s.padding(5.0).width_full()) - .into_any() + Stack::vertical(( + Label::new("Hover an interval").style(|s| { + s.font_size(14.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + Label::new("Bars expose exact start and duration here.") + .style(|s| s.font_size(11.0).with_theme(|s, t| s.color(t.text_muted()))), + )) + .style(|s| s.padding(2.0).width_full().gap(4.0)) + .into_any() } }, ) - .style(|s| s.width_full()); + .style(|s| { + s.width_full() + .padding(14.0) + .border_radius(18.0) + .border(1.0) + .with_theme(|s, t| s.background(t.bg_overlay()).border_color(t.border_muted())) + }); + + let frame_views: Vec<_> = frames + .iter() + .map(|frame| { + let frame = frame.clone(); + let frame_for_style = frame.clone(); + Button::new( + Stack::vertical(( + Stack::horizontal(( + Label::new(format!("Frame #{}", frame.index)).style(|s| { + s.font_size(13.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + .flex_grow(1.0) + }), + Label::new(format_duration_short(frame.sum)).style(|s| { + s.font_size(11.0) + .font_bold() + .padding_horiz(8.0) + .padding_vert(4.0) + .border_radius(999.0) + .background(Color::from_rgba8(54, 111, 196, 22)) + .color(Color::from_rgb8(54, 111, 196)) + }), + )) + .style(|s| s.items_center().gap(10.0)), + Stack::horizontal(( + Label::new(format!("start {}", format_duration_short(frame.start))), + Label::new(format!("{} events", frame.event_count)), + Label::new(format!("{} spans", frame.timing_count)), + )) + .style(|s| { + s.gap(10.0) + .font_size(10.5) + .with_theme(|s, t| s.color(t.text_muted())) + }), + )) + .style(move |s| { + let selected = active_frame_index.get() == Some(frame_for_style.index); + s.width_full() + .justify_start() + .padding(12.0) + .border_radius(16.0) + .border(1.0) + .with_theme(move |s, t| { + s.border_color(if selected { + t.def(|t| t.primary().with_alpha(0.38)) + } else { + t.border_muted() + }) + .background(if selected { + t.def(|t| t.primary().with_alpha(0.12)) + } else { + t.bg_base() + }) + }) + }), + ) + .style(|s| s.width_full().justify_start()) + }) + .collect(); let frames_clone = frames.clone(); + let frames_list = list(frame_views); + let frame_list_selection = frames_list.selection(); + let frames_list = frames_list.on_select(move |idx| { + if suppress_frame_list_select.get_untracked() { + return; + } + let frame = idx.and_then(|idx| frames_clone.get(idx)).cloned(); + let new_index = frame.as_ref().map(|frame| frame.index); + let current_index = selected_frame + .get_untracked() + .as_ref() + .map(|frame| frame.index); + if current_index == new_index { + return; + } + active_frame_index.set(new_index); + selected_frame.set(frame); + }); - let frames = Stack::vertical(( - header("Frames"), - Scroll::new( - list(frame_views) - .on_select(move |idx| { - selected_frame.set(idx.and_then(|idx| frames_clone.get(idx)).cloned()) - }) - .style(|s| s.width_full()), - ) + let frames_for_current = frames.clone(); + let current_frame_index = Memo::new(move |_| { + if let Some(cursor_time) = cursor_time.get() { + frame_for_cursor_time(&frames_for_current, cursor_time).map(|frame| frame.index) + } else { + let visible = visible_range.get(); + frame_for_visible_range(&frames_for_current, visible).map(|frame| frame.index) + } + }); + + { + Effect::new(move |_| { + let frame_index = current_frame_index.get(); + if active_frame_index.get_untracked() != frame_index { + active_frame_index.set(frame_index); + } + if frame_list_selection.get_untracked() != frame_index { + suppress_frame_list_select.set(true); + frame_list_selection.set(frame_index); + suppress_frame_list_select.set(false); + } + }); + } + + let frames_list = frames_list.style(|s| { + s.width_full().gap(8.0).class(ListItemClass, |s| { + s.selected(|s| s.unset_background().hover(|s| s.unset_background())) + .hover(|s| s.unset_background()) + }) + }); + + let frames_panel = profiler_panel( + Stack::vertical(( + Stack::vertical(( + Label::new("Frames").style(|s| { + s.font_size(16.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + Label::new("Select a frame to center the timeline.") + .style(|s| s.font_size(11.0).with_theme(|s, t| s.color(t.text_muted()))), + )) + .style(|s| s.gap(4.0)), + Scroll::new(frames_list).style(|s| { + s.flex_basis(0) + .min_height(0) + .flex_grow(1.0) + .padding_right(2.0) + }), + Stack::vertical(( + Label::new("Inspector").style(|s| { + s.font_size(12.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + event_tooltip, + )) + .style(|s| s.width_full().gap(10.0)), + )) + .style(|s| s.padding(16.0).width_full().height_full().gap(14.0)), + ) + .style(|s| s.min_width(280.0).flex_grow(1.0)); + + let timeline = if lanes.is_empty() { + Stack::vertical(( + Label::new("No timeline events").style(|s| { + s.font_size(18.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + Label::new("Start profiling and capture a frame to populate the timeline.") + .style(|s| s.font_size(12.0).with_theme(|s, t| s.color(t.text_muted()))), + )) .style(|s| { - s.flex_basis(0) + s.width_full() .min_height(0) + .flex_basis(0) .flex_grow(1.0) - .with_theme(|s, t| s.background(t.bg_base())) - }), - header("Event").style(|s| { - s.border_top(1) - .with_theme(|s, t| s.border_top_color(t.border()).color(t.primary())) - }), - event_tooltip, - )) - .style(|s| s.min_width(230.0).flex_grow(1.)); - - let timeline = dyn_container( - move || selected_frame.get(), - move |selected_frame| { - if let Some(frame) = selected_frame { - let list = frame.events.iter().map(|event| { - let len = event - .end - .saturating_duration_since(event.start) - .as_secs_f64(); - let left = event - .start - .saturating_duration_since(frame.start.unwrap()) - .as_secs_f64() - / frame.duration.as_secs_f64(); - let width = len / frame.duration.as_secs_f64(); - let event_ = event.clone(); - Clip::new( - Label::new(format!("{} ({:.4} ms)", event.name, len * 1000.0)).style(|s| { - s.selectable(false) - .padding(5.0) - .text_clip() - .align_self(AlignItems::Center) - }), - ) - .style(move |s| { - s.min_width(0) - .min_height(50.pt()) - .width_pct(width * 100.0) - .absolute() - .inset_left_pct(left * 100.0) - .border(0.5) - .border_radius(2.) - .text_clip() - .with_theme(|s, t| { - s.border_color(t.border()) - .background(t.primary_muted()) - .hover(|s| s.background(t.def(|t| t.primary().with_alpha(0.5)))) - }) - }) - .on_event_cont(listener::PointerEnter, move |_, _| { - hovered_event.set(Some(event_.clone())) - }) - }); - Scroll::new( - Stack::vertical_from_iter(list) - .style(move |s| s.min_width_pct(zoom.get() * 100.0).height_full()), + .padding(24.0) + .items_center() + .justify_center() + .gap(6.0) + }) + .into_any() + } else { + Stack::vertical(( + ProfilerTimelineView::new( + lanes, + frame_markers.clone(), + instant_markers.clone(), + total_duration, + hovered_event, + cursor_time, + selected_frame, + active_frame_index, + visible_range, + viewport_request, + ) + .style(|s| s.width_full().min_height(0.0).flex_grow(1.0)), + Stack::vertical(( + Stack::horizontal(( + Label::new("Overview").style(|s| { + s.font_size(11.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + Label::new("Drag, scroll, or pinch to navigate the timeline.") + .style(|s| s.font_size(10.5).with_theme(|s, t| s.color(t.text_muted()))), + )) + .style(|s| s.items_center().justify_between().gap(10.0)), + ProfilerOverviewView::new( + overview_bins, + total_duration, + cursor_time, + visible_range, + viewport_request, ) - .custom_style(|s| s.vertical_track_inset(5.).show_bars_when_idle(false)) - .style(|s| s.height_full().min_width(0).flex_basis(0).flex_grow(1.0)) - .on_event(listener::PointerWheel, move |_cx, se| { - let delta = se.resolve_to_points(None, None); - zoom.set(zoom.get() * (1.0 - delta.y / 400.0)); - EventPropagation::Stop - }) - .into_any() - } else { - Label::new("No selected frame") - .style(|s| s.padding(5.0)) - .into_any() - } - }, - ) - .style(|s| { - s.width_full() - .min_height(0) - .flex_basis(0) - .flex_grow(1.0) - .with_theme(|s, t| s.background(t.bg_base())) - }); + .style(|s| s.width_full().height(OVERVIEW_HEIGHT)), + )) + .style(|s| s.width_full().gap(8.0)), + )) + .style(|s| s.size_full().min_height(0.0).gap(12.0)) + .into_any() + }; - let timeline = Stack::vertical((header("Timeline"), timeline)) - .style(|s| s.min_width(0).flex_basis(0).flex_grow(1.0)); + let timeline_panel = profiler_panel( + Stack::vertical(( + Stack::horizontal(( + Stack::vertical(( + Label::new("Timeline").style(|s| { + s.font_size(18.0) + .font_bold() + .with_theme(|s, t| s.color(t.text())) + }), + Label::new("Events are packed into independent tracks by overlap.") + .style(|s| s.font_size(11.0).with_theme(|s, t| s.color(t.text_muted()))), + )) + .style(|s| s.gap(4.0).min_width(0.0).flex_grow(1.0)), + Stack::horizontal(( + profiler_chip("Start", "0 ms", Color::from_rgb8(41, 78, 163)), + profiler_chip( + "End", + format_duration_short(total_duration), + Color::from_rgb8(203, 92, 73), + ), + )) + .style(|s| s.gap(8.0)), + )) + .style(|s| s.items_start().gap(12.0)), + Stack::horizontal(( + profiler_chip( + "Frames", + frames.len().to_string(), + Color::from_rgb8(29, 142, 120), + ), + profiler_chip( + "Intervals", + interval_count.to_string(), + Color::from_rgb8(54, 111, 196), + ), + profiler_chip( + "Captured Span", + format_duration_short(total_duration), + Color::from_rgb8(64, 157, 163), + ), + profiler_marker_legend(), + )) + .style(|s| s.gap(8.0).flex_wrap(taffy::style::FlexWrap::Wrap)), + timeline, + )) + .style(|s| s.padding(16.0).min_width(0.0).height_full().gap(14.0)), + ) + .style(|s| s.min_width(0).flex_basis(0).flex_grow(1.0)); - Resizable::new((frames, timeline)).style(|s| s.height_full().width_full().max_width_full()) + Resizable::new((frames_panel, timeline_panel)).style(|s| { + s.height_full() + .width_full() + .gap(14.0) + .padding(14.0) + .with_theme(|s, t| s.background(t.bg_base())) + }) } thread_local! { @@ -247,7 +2016,7 @@ thread_local! { }; } -pub fn profiler(window_id: WindowId) -> impl IntoView { +pub fn profiler(window_id: WindowId, inspector_window_id: WindowId) -> impl IntoView { let profiling = RwSignal::new(false); let profile = PROFILE.with(|c| *c); @@ -260,13 +2029,13 @@ pub fn profiler(window_id: WindowId) -> impl IntoView { } })) .action(move || { - add_app_update_event(AppUpdateEvent::ProfileWindow { + if !profiling.get() { + PROFILE.with(|profile| profile.set(None)); + } + crate::Application::send_proxy_event(UserEvent::InspectorProfileWindow { window_id, - end_profile: if profiling.get() { - Some(profile.write_only()) - } else { - None - }, + inspector_window_id, + stop: profiling.get(), }); profiling.set(!profiling.get()); }) @@ -295,14 +2064,14 @@ pub fn profiler(window_id: WindowId) -> impl IntoView { ) .style(|s| s.width_full().min_height(0).flex_basis(0).flex_grow(1.0)); - // FIXME: This needs an extra `container` or the `Stack::vertical` ends up horizontal. - Container::new(Stack::vertical((button, separator, lower)).style(|s| s.size_full())) + Stack::vertical((button, separator, lower)) .style(|s| s.size_full()) .on_event_cont(listener::WindowClosed, move |_, _| { if profiling.get() { - add_app_update_event(AppUpdateEvent::ProfileWindow { + crate::Application::send_proxy_event(UserEvent::InspectorProfileWindow { window_id, - end_profile: Some(profile.write_only()), + inspector_window_id, + stop: true, }); } }) diff --git a/src/inspector/view.rs b/src/inspector/view.rs index ff67bc6e2..c690cca45 100644 --- a/src/inspector/view.rs +++ b/src/inspector/view.rs @@ -1,13 +1,15 @@ -use super::profiler::profiler; +use super::profiler::{PROFILE, profiler}; use crate::{ - AnyView, ElementId, IntoView, View, ViewId, - action::inspect, - app::{AppUpdateEvent, add_app_update_event}, + AnyView, Brush, ElementId, IntoView, View, ViewId, + app::{AppUpdateEvent, UserEvent, add_app_update_event}, + effects::BrushAlphaExt as _, event::{EventPropagation, listener}, inspector::{ - CAPTURE, Capture, CaptureView, CapturedView, RUNNING, add_event, + CAPTURE, Capture, CaptureView, CapturedElement, INSPECTOR_WINDOW, InspectorSelection, + RUNNING, add_event, data::{CapturedData, CapturedDatas}, - find_view, header, selected_view, stats, update_select_view_id, + find_view, header, selected_view, selection_matches_view, stats, update_select_element_id, + update_select_view_id, }, new_window, prelude::*, @@ -15,7 +17,7 @@ use crate::{ theme::StyleThemeExt as _, unit::LengthAuto, views::{ - Button, CheckboxClass, ContainerExt, Decorators, Label, ListClass, ListItemClass, + Button, CheckboxClass, ContainerExt, Decorators, Image, Label, ListClass, ListItemClass, ScrollExt, Stack, TabSelectorClass, TooltipExt, resizable::Resizable, }, window::WindowConfig, @@ -39,12 +41,17 @@ const OS_MOD: Modifiers = if cfg!(target_os = "macos") { }; pub fn capture(window_id: WindowId) { - let capture = CAPTURE.with(|c| *c); - if !RUNNING.get() { + RUNNING.set(true); new_window( move |inspector_window_id| { + INSPECTOR_WINDOW.set(Some(inspector_window_id)); + let capture = CAPTURE.with(|c| *c); let selected = RwSignal::new(0); + crate::Application::send_proxy_event(UserEvent::InspectorCaptureWindow { + window_id, + inspector_window_id, + }); let tab_item = |name, index| { Label::new(name) @@ -65,12 +72,18 @@ pub fn capture(window_id: WindowId) { 0 => dyn_container( move || capture.get(), move |capture_value| { - inspector_view(window_id, capture, &capture_value).into_any() + inspector_view( + window_id, + inspector_window_id, + capture, + &capture_value, + ) + .into_any() }, ) .style(|s| s.width_full().height_full()) .into_any(), - 1 => profiler(window_id).into_any(), + 1 => profiler(window_id, inspector_window_id).into_any(), _ => panic!(), }, ) @@ -82,78 +95,37 @@ pub fn capture(window_id: WindowId) { .with_theme(|s, t| s.background(t.border())) }); - let mut window_scale = RwSignal::new(1.); - Stack::vertical((tabs, separator, tab)) .style(|s| s.width_full().height_full()) - .on_event( - el::KeyUp, - move |_cx, KeyboardEvent { key, modifiers, .. }| { - if *key == ui_events::keyboard::Key::Named(NamedKey::F11) - && modifiers.shift() - { - inspect(); - return EventPropagation::Stop; - } - EventPropagation::Continue - }, - ) .on_event(el::WindowClosed, |_, _| { RUNNING.set(false); + INSPECTOR_WINDOW.set(None); + CAPTURE.with(|capture| capture.set(None)); + PROFILE.with(|profile| profile.set(None)); EventPropagation::Continue }) - .on_event_stop( - listener::KeyUp, - move |_cx, KeyboardEvent { modifiers, key, .. }| { - if *key == Key::Character("q".into()) && modifiers.contains(OS_MOD) { - crate::quit_app(); - } else if *key == Key::Character("w".into()) - && modifiers.contains(OS_MOD) - { - crate::close_window(inspector_window_id); - } - }, - ) - .on_event_stop( - el::KeyDown, - move |_, KeyboardEvent { key, modifiers, .. }| match key { - Key::Character(ch) - if (ch == "=" || ch == "+") && modifiers.contains(OS_MOD) => - { - window_scale *= 1.1; - crate::action::set_window_scale(window_scale.get()); - } - - Key::Character(ch) if ch == "-" && *modifiers == OS_MOD => { - window_scale /= 1.1; - crate::action::set_window_scale(window_scale.get()); - } - - Key::Character(ch) if ch == "0" && *modifiers == OS_MOD => { - window_scale.set(1.); - crate::action::set_window_scale(window_scale.get()); - } - _ => {} - }, - ) }, Some(WindowConfig::default().size((1200.0, 800.0))), ); } - add_app_update_event(AppUpdateEvent::CaptureWindow { - window_id, - capture: capture.write_only(), - }) + if let Some(inspector_window_id) = INSPECTOR_WINDOW.get() { + crate::Application::send_proxy_event(UserEvent::InspectorCaptureWindow { + window_id, + inspector_window_id, + }); + } } fn inspector_view( window_id: WindowId, + inspector_window_id: WindowId, capture_s: RwSignal>>, capture: &Option>, ) -> impl IntoView { let view = if let Some(capture) = capture { - capture_view(window_id, capture_s, capture.clone()).into_any() + let _ = capture_s; + capture_view(window_id, inspector_window_id, capture.clone()).into_any() } else { Label::new("No capture").into_any() }; @@ -179,7 +151,7 @@ fn inspector_view( fn capture_view( window_id: WindowId, - capture_s: RwSignal>>, + inspector_window_id: WindowId, capture: Rc, ) -> impl IntoView { let capture_view = CaptureView { @@ -190,14 +162,13 @@ fn capture_view( }; let datas = RwSignal::new(CapturedDatas::init_from_view(capture.root.clone())); let window = capture.window.clone(); + let window_capture_error = capture.window_capture_error.clone(); let capture_ = capture.clone(); let size = capture_.window_size; let image_width = size.width; let image_height = size.height; - let renderer = capture_.renderer.clone(); - let image = if let Some(window) = window { - img_dynamic(move || window.clone()).into_any() + Image::new(window.clone()).into_any() } else { ().style(move |s| s.min_width(size.width).min_height(size.height)) .into_any() @@ -209,14 +180,31 @@ fn capture_view( .width(image_width + 2.0) .height(image_height + 2.0) .keyboard_navigable() - }); + }) + .into_any(); let image_view = InspectorImageView::new(image, capture.clone(), capture_view, datas); + let error_overlay = if let Some(error) = window_capture_error { + Label::new(format!("Capture failed\n{error}")) + .style(|s| { + s.absolute() + .inset_left(6.0) + .inset_top(6.0) + .padding(8.0) + .color(palette::css::CRIMSON) + .background(palette::css::WHITE.with_alpha(0.92)) + }) + .into_any() + } else { + ().into_any() + }; let recapture = Button::new("Recapture").action(move || { - add_app_update_event(AppUpdateEvent::CaptureWindow { + crate::Application::send_proxy_event(UserEvent::InspectorCaptureWindow { window_id, - capture: capture_s.write_only(), + inspector_window_id, }) }); + let metal_capture = Button::new("Capture Metal") + .action(move || add_app_update_event(AppUpdateEvent::CaptureMetalFrame { window_id })); let make_toggle_button = |signal: RwSignal, label: &'static str| { Stack::new(( @@ -258,14 +246,8 @@ fn capture_view( |it| *it, move |it| { match it { - 0 => selected_view(&capture_sig.get(), capture_view.selected).into_any(), - 1 => Stack::vertical(( - header("Stats"), - stats(&capture_sig.get()), - header("Renderer"), - Label::new(renderer.clone()).style(|s| s.padding(5.0)), - )) - .into_any(), + 0 => selected_view(&capture_sig.get(), capture_view, datas).into_any(), + 1 => stats(&capture_sig.get()).into_any(), _ => panic!(), } .style(|s| s.min_size(0, 0.).flex_grow(1.)) @@ -286,6 +268,7 @@ fn capture_view( let tabs = Stack::vertical(( Stack::horizontal(( recapture, + metal_capture, tree_button, selected_button, clear, @@ -312,13 +295,19 @@ fn capture_view( let left = Stack::vertical(( header("Captured Window"), Resizable::new(( - image_view.scroll().style(|s| { - s.min_size(0, 0) - .flex_grow(1.) - .grid() - .items_center() - .justify_items(AlignItems::Center) - }), + Stack::new(( + image_view + .scroll() + .style(|s| { + s.min_size(0, 0) + .flex_grow(1.) + .grid() + .items_center() + .justify_items(AlignItems::Center) + }) + .into_any(), + error_overlay, + )), tabs, )) .custom_sizes(move || vec![(0, size.height.min(500.))]) @@ -412,9 +401,9 @@ fn capture_view( if *key == Key::Named(NamedKey::F5) || (*key == Key::Character("r".to_string()) && modifiers.contains(OS_MOD)) { - add_app_update_event(AppUpdateEvent::CaptureWindow { + crate::Application::send_proxy_event(UserEvent::InspectorCaptureWindow { window_id, - capture: capture_s.write_only(), + inspector_window_id, }); } }, @@ -485,10 +474,14 @@ fn tree_node( .width_full() .keyboard_navigable() .text_clip() - .apply_if(selected.get() == Some(id), |s| s.set_selected(true)) + .apply_if(selection_matches_view(selected.get(), id), |s| { + s.set_selected(true) + }) }) - .action(move || selected.set(Some(id))) - .on_event_cont(el::PointerEnter, move |_, _| highlighted.set(Some(id))); + .action(move || selected.set(Some(InspectorSelection::View(id)))) + .on_event_cont(el::PointerEnter, move |_, _| { + highlighted.set(Some(InspectorSelection::View(id))) + }); let row = add_event( row, view.view_conf.name.clone(), @@ -592,9 +585,8 @@ pub struct InspectorImageView { capture: Rc, capture_view: CaptureView, datas: RwSignal, - data_id_to_view: HashMap, - element_to_data_id: HashMap, - contain_ids: Vec, + element_to_capture_element: HashMap, + contain_ids: Vec, contain_index: usize, selected_overlay_color: Color, selected_overlay_border_color: Color, @@ -618,17 +610,19 @@ impl InspectorImageView { id.request_paint(); }); id.add_child(child); - let data_id_to_view = datas.get_untracked().visible_data_id_map(); - let mut element_to_data_id = HashMap::new(); - register_capture_elements(id, &capture.root, &mut element_to_data_id); + let mut element_to_capture_element = HashMap::new(); + register_capture_elements( + id, + &capture.state.elements_root, + &mut element_to_capture_element, + ); id.request_box_tree_commit(); Self { id: id.get_element_id(), capture, capture_view, datas, - data_id_to_view, - element_to_data_id, + element_to_capture_element, contain_ids: Vec::new(), contain_index: 0, selected_overlay_color: css::DODGER_BLUE.with_alpha(0.5), @@ -638,34 +632,39 @@ impl InspectorImageView { } } - fn overlay_rect(&self, id: Option) -> Option { - let view = id.and_then(|id| self.capture.root.find(id))?; + fn overlay_rect(&self, selection: Option) -> Option { + let bounds = match selection? { + InspectorSelection::View(id) => self.capture.root.find(id)?.world_bounds, + InspectorSelection::BoxNode(id) => { + self.capture.state.elements_root.find(id)?.world_bounds + } + }; Some(Rect::new( - 5.0 + view.world_bounds.x0 + 1.0, - 5.0 + view.world_bounds.y0 + 1.0, - 5.0 + view.world_bounds.x1 + 1.0, - 5.0 + view.world_bounds.y1 + 1.0, + 5.0 + bounds.x0 + 1.0, + 5.0 + bounds.y0 + 1.0, + 5.0 + bounds.x1 + 1.0, + 5.0 + bounds.y1 + 1.0, )) } } fn register_capture_elements( owner_id: ViewId, - root: &Rc, - element_to_data_id: &mut HashMap, + root: &Rc, + element_to_capture_element: &mut HashMap, ) { fn register_one( owner_id: ViewId, - captured: &Rc, + captured: &Rc, parent_element: ElementId, - element_to_data_id: &mut HashMap, + element_to_capture_element: &mut HashMap, ) { - let is_visible = captured.direct_style.builtin().display() != taffy::Display::None - && captured.world_bounds.area() > 0.0; + let is_visible = + captured.flags.contains(NodeFlags::VISIBLE) && captured.world_bounds.area() > 0.0; let mut next_parent = parent_element; if is_visible { - let element = owner_id.create_child_element_id(0); + let element = owner_id.create_child_element_id(captured.z_index); let rect = Rect::new( 6.0 + captured.world_bounds.x0, 6.0 + captured.world_bounds.y0, @@ -675,7 +674,6 @@ fn register_capture_elements( let box_tree = owner_id.box_tree(); let mut bt = box_tree.borrow_mut(); bt.reparent(element.0, Some(parent_element.0)); - bt.set_local_bounds(element.0, rect); bt.set_flags(element.0, NodeFlags::VISIBLE | NodeFlags::PICKABLE); bt.set_element_meta( element.0, @@ -684,16 +682,17 @@ fn register_capture_elements( ))), ); drop(bt); - element_to_data_id.insert(element, captured.id_data_str.clone()); + element.set_local_bounds(rect); + element_to_capture_element.insert(element, captured.id); next_parent = element; } for child in &captured.children { - register_one(owner_id, child, next_parent, element_to_data_id); + register_one(owner_id, child, next_parent, element_to_capture_element); } } let root_element = owner_id.get_element_id(); - register_one(owner_id, root, root_element, element_to_data_id); + register_one(owner_id, root, root_element, element_to_capture_element); } impl View for InspectorImageView { @@ -737,9 +736,14 @@ impl View for InspectorImageView { self.contain_index - 1 }; if let Some(id) = self.contain_ids.get(self.contain_index).copied() { - cx.window_state.request_paint = true; + cx.window_state.request_paint(self.id); self.id.owning_id().request_paint(); - update_select_view_id(id, &self.capture_view, false, self.datas); + update_select_element_id( + id, + &self.capture_view, + false, + Some(self.datas), + ); return EventPropagation::Stop; } } @@ -748,9 +752,14 @@ impl View for InspectorImageView { if !self.contain_ids.is_empty() { self.contain_index = (self.contain_index + 1) % self.contain_ids.len(); if let Some(id) = self.contain_ids.get(self.contain_index).copied() { - cx.window_state.request_paint = true; + cx.window_state.request_paint(self.id); self.id.owning_id().request_paint(); - update_select_view_id(id, &self.capture_view, false, self.datas); + update_select_element_id( + id, + &self.capture_view, + false, + Some(self.datas), + ); return EventPropagation::Stop; } } @@ -764,15 +773,20 @@ impl View for InspectorImageView { self.contain_ids.extend( hit_path .iter() - .filter_map(|id| self.element_to_data_id.get(id)) - .filter_map(|data_id| self.data_id_to_view.get(data_id).copied()), + .filter_map(|id| self.element_to_capture_element.get(id).copied()), ); } - cx.window_state.request_paint = true; + cx.window_state.request_paint(self.id); self.contain_index = 0; self.capture_view .highlighted - .set(self.contain_ids.last().copied()); + .set(self.contain_ids.last().copied().map(|id| { + if id.is_view() { + InspectorSelection::View(id.owning_id()) + } else { + InspectorSelection::BoxNode(id) + } + })); } Event::Pointer(PointerEvent::Up(_)) => { self.contain_ids.clear(); @@ -780,15 +794,14 @@ impl View for InspectorImageView { self.contain_ids.extend( hit_path .iter() - .filter_map(|id| self.element_to_data_id.get(id)) - .filter_map(|data_id| self.data_id_to_view.get(data_id).copied()), + .filter_map(|id| self.element_to_capture_element.get(id).copied()), ); } self.contain_index = 0; if let Some(id) = self.contain_ids.last().copied() { - cx.window_state.request_paint = true; + cx.window_state.request_paint(self.id); self.id.owning_id().request_paint(); - update_select_view_id(id, &self.capture_view, false, self.datas); + update_select_element_id(id, &self.capture_view, false, Some(self.datas)); return EventPropagation::Stop; } } @@ -804,22 +817,22 @@ impl View for InspectorImageView { fn post_paint(&mut self, cx: &mut crate::paint::PaintCx) { if cx.target_id == self.id { if let Some(selected_overlay) = self.overlay_rect(self.capture_view.selected.get()) { - cx.fill(&selected_overlay, self.selected_overlay_color, 0.); - cx.stroke( - &selected_overlay, - self.selected_overlay_border_color, - &Stroke::new(1.0), - ); + let fill = Brush::Solid(self.selected_overlay_color); + let stroke = Brush::Solid(self.selected_overlay_border_color); + cx.painter.fill(selected_overlay, &fill).draw(); + cx.painter + .stroke(selected_overlay, &Stroke::new(1.0), &stroke) + .draw(); } if let Some(highlighted_overlay) = self.overlay_rect(self.capture_view.highlighted.get()) { - cx.fill(&highlighted_overlay, self.highlighted_overlay_color, 0.); - cx.stroke( - &highlighted_overlay, - self.highlighted_overlay_border_color, - &Stroke::new(1.0), - ); + let fill = Brush::Solid(self.highlighted_overlay_color); + let stroke = Brush::Solid(self.highlighted_overlay_border_color); + cx.painter.fill(highlighted_overlay, &fill).draw(); + cx.painter + .stroke(highlighted_overlay, &Stroke::new(1.0), &stroke) + .draw(); } } } diff --git a/src/lib.rs b/src/lib.rs index 87cd5c435..45522baf6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,20 @@ //! //! Floem's objectives prioritize simplicity and performance, enabling the development of complex graphical user interfaces with minimal boilerplate. //! +//! ## Renderer Selection +//! +//! Floem normally uses the active renderer selected by crate features and falls back to the +//! configured CPU renderer only if needed. +//! +//! To force Floem to use the CPU renderer path at runtime, set: +//! +//! ```text +//! FLOEM_FORCE_CPU=1 +//! ``` +//! +//! `FLOEM_FORCE_TINY_SKIA=1` is still accepted as a deprecated alias for compatibility, but new +//! code should use `FLOEM_FORCE_CPU`. +//! //! ## Views //! Floem models the UI using a tree of [Views](view::View). Views, such as the `h_stack`, `label`, and //! `button` elements are the building blocks of UI in Floem. @@ -176,13 +190,31 @@ //! //! For additional information about animation, [see here](crate::animate::Animation). +macro_rules! floem_debug_log { + ($($arg:tt)*) => { + if $crate::frame_source::diagnostic_logs_enabled() { + eprintln!($($arg)*); + } + }; +} + +pub(crate) use floem_debug_log; + pub mod action; pub mod animate; mod app; mod box_tree; +pub mod compositor_surface; pub mod context; +pub mod effects; pub mod event; pub mod ext_event; +pub mod external_surface; +pub mod frame; +pub(crate) mod frame_source; +pub(crate) mod gpu_completion; +pub mod gradient; +pub(crate) mod hud; mod inspector; pub mod layout; mod message; @@ -220,14 +252,35 @@ pub mod receiver_signal { pub use stream_signal::*; } +pub mod gpu_resources; +pub use app::{AppConfig, AppEvent, Application, launch, quit_app, reopen}; pub use box_tree::{BoxTree, ElementId, ElementMeta, FocusNavMeta}; pub(crate) use box_tree::{bump_focus_nav_meta_revision, focus_nav_meta_revision}; - -pub use app::{AppConfig, AppEvent, Application, launch, quit_app, reopen}; +pub use compositor_surface::{ + CompositorSurfaceAlphaMode, CompositorSurfaceConfig, CompositorSurfaceContent, + CompositorSurfaceFrameArgs, CompositorSurfaceFrameCx, CompositorSurfaceFrameUpdate, + CompositorSurfaceHandle, CompositorSurfaceId, CompositorSurfaceKind, CompositorSurfaceOutcome, + CompositorSurfaceProducer, CompositorSurfaceProducerConfig, CompositorSurfaceProvider, + CompositorSurfaceProviderHandle, ExternalTexture, SurfaceSlotId, SurfaceView, +}; +pub use effects::{ + AnimatableShaderUniforms, Brush, ColorFilter, ColorFilterShader, Composite, CompositorShader, + Filter, GroupRef, Image, ImageBrush, ImageBrushRef, ImageRef, LayerFilter, LayerFilterShader, + RasterImage, SceneImage, ShaderArgs, ShaderComposite, ShaderFrameUniform, ShaderSource, + ShaderSourceImage, ShaderSourceShader, ShaderTransition, ShaderUniform, ShaderUniforms, + SurfaceImage, group_ref, +}; +pub use external_surface::{ + ExternalSurface, ExternalSurfaceError, ExternalSurfaceHandle, ExternalSurfaceId, +}; pub use floem_reactive as reactive; -pub use floem_renderer::Renderer; -pub use floem_renderer::Svg as RendererSvg; -pub use floem_renderer::gpu_resources::GpuResources; +pub use frame::{ + DisplayTiming, FrameCallbackRepeat, FrameDemand, FrameOutcome, FrameRatePreference, + FrameRatePreferenceBuilder, FrameTime, FrameTimingFeedback, PresentPacing, + PresentationInterval, +}; +pub use gpu_resources::GpuResources; +pub use gradient::{ColorStop, ColorStops, ColorStopsSource, Gradient, GradientPoint}; pub use imbl; pub use layout::ScreenLayout; #[cfg(not(target_arch = "wasm32"))] @@ -257,10 +310,20 @@ pub use app::take_close_window_event_count; pub use style::{theme, unit}; pub mod prelude { - pub use crate::Renderer; + pub use crate::compositor_surface::{ + CompositorSurfaceConfig, CompositorSurfaceProducer, CompositorSurfaceProducerConfig, + ExternalTexture, SurfaceSlotId, SurfaceView, + }; pub use crate::event::listener as el; pub use crate::event::listener; pub use crate::event::listener::EventListenerTrait; + pub use crate::external_surface::ExternalSurface; + pub use crate::frame::{ + DisplayTiming, FrameCallbackRepeat, FrameDemand, FrameOutcome, FrameRatePreference, + FrameRatePreferenceBuilder, FrameTime, FrameTimingFeedback, PresentPacing, + PresentationInterval, + }; + pub use crate::gradient::{ColorStop, ColorStops, ColorStopsSource, Gradient, GradientPoint}; pub use crate::unit::{DurationUnitExt, UnitExt}; pub use crate::view::IntoViewIter; pub use crate::view::ViewTuple; @@ -278,3 +341,4 @@ pub mod prelude { pointer::{PointerButtonEvent, PointerEvent}, }; } +pub use imaging; diff --git a/src/message.rs b/src/message.rs index 494ed3edf..3e0dcc915 100644 --- a/src/message.rs +++ b/src/message.rs @@ -8,6 +8,7 @@ use winit::window::{ResizeDirection, Theme}; use crate::{ ElementId, event::{Event, RouteKind, listener}, + frame::{FrameCallbackRepeat, FrameRatePreference}, platform::menu::Menu, style::recalc::StyleReason, view::{AnyView, View, ViewId}, @@ -65,7 +66,7 @@ pub enum UpdateMessage { pointer_id: PointerId, }, WindowScale(f64), - RequestPaint, + RequestPaint(ElementId), RequestLayout, /// Request that the box tree be updated from the layout tree (full walk) and committed RequestBoxTreeUpdate, @@ -101,7 +102,9 @@ pub enum UpdateMessage { RemoveOverlay { id: ViewId, }, + ToggleHud, Inspect, + CaptureMetalFrame, ScrollTo { id: ElementId, rect: Option, @@ -115,6 +118,15 @@ pub enum UpdateMessage { size: Size, }, WindowVisible(bool), + SetAnimationFrameCallback { + token: u64, + frame_rate: FrameRatePreference, + repeat: FrameCallbackRepeat, + callback: Box, + }, + CancelAnimationFrameCallback { + token: u64, + }, ViewTransitionAnimComplete(ViewId), SetTheme(Option), /// Remove views from the tree (used by keyed children). diff --git a/src/paint/border_path_iter.rs b/src/paint/border_path_iter.rs index 26d039ab1..2b6a37291 100644 --- a/src/paint/border_path_iter.rs +++ b/src/paint/border_path_iter.rs @@ -1,7 +1,8 @@ use core::ops::Range; use std::f64::consts::FRAC_PI_2; -use peniko::{Brush, kurbo::*}; +use crate::effects::Brush; +use peniko::kurbo::*; pub struct BorderPath { path_iter: RoundedRectPathIter, diff --git a/src/paint/composition.rs b/src/paint/composition.rs new file mode 100644 index 000000000..9b24be2e7 --- /dev/null +++ b/src/paint/composition.rs @@ -0,0 +1,173 @@ +use std::hash::{Hash, Hasher}; + +use peniko::{ + BlendMode, + kurbo::{Affine, Point, Rect, RoundedRect, Size}, +}; + +use crate::{ + ElementId, compositor_surface::CompositorSurfaceId, effects::CompositorShaderPass, + frame::FrameRatePreference, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LayerSourceId(u64); + +impl LayerSourceId { + pub(crate) fn from_element_id(id: ElementId) -> Self { + let mut hasher = rustc_hash::FxHasher::default(); + id.hash(&mut hasher); + Self(hasher.finish()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum PaintStage { + Paint, + Post, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum CompositionKey { + SceneRun { + run_index: u32, + }, + CompositorSurface { + surface_id: CompositorSurfaceId, + occurrence: u32, + }, +} + +#[derive(Clone, Debug)] +pub(crate) struct CompositionPlan { + pub items: Vec, +} + +impl CompositionPlan { + pub(crate) fn new() -> Self { + Self { items: Vec::new() } + } + + pub(crate) fn has_compositor_surfaces(&self) -> bool { + self.items.iter().any(|item| match item { + CompositionItem::CompositorSurface(_) => true, + CompositionItem::Scene(layer) => !layer.external_images.is_empty(), + }) + } +} + +#[derive(Clone, Debug)] +pub(crate) enum CompositionItem { + Scene(SceneLayer), + CompositorSurface(CompositorSurfaceLayer), +} + +#[derive(Clone, Debug)] +pub(crate) struct SceneLayer { + pub key: CompositionKey, + pub source_element_id: Option, + pub debug_name: Option, + pub scene: imaging::record::Scene, + pub external_images: Vec, + pub color_filters: Vec, + pub content_revision: u64, + pub transform: Affine, + pub clip: Option, + pub bounds: Rect, + pub content_bounds: Option, + pub opacity: f32, + pub blend_mode: BlendMode, + pub promoted: bool, + pub frame_rate: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SceneExternalImage { + pub image_id: imaging::ExternalImageId, + pub surface_id: CompositorSurfaceId, + pub rect: Rect, + pub source_size: peniko::kurbo::Size, +} + +#[derive(Clone, Debug)] +pub(crate) struct CompositorSurfaceLayer { + pub key: CompositionKey, + pub surface_id: CompositorSurfaceId, + pub rect: Rect, + pub source_size: peniko::kurbo::Size, + pub transform: Affine, + pub clip: Option, + pub opacity: f32, + pub blend_mode: BlendMode, +} + +pub(crate) fn clip_scene_layers_to_viewport(plan: &mut CompositionPlan, viewport_size: Size) { + let viewport = Rect::from_origin_size(Point::ZERO, viewport_size); + if !is_non_empty(viewport) { + return; + } + + for item in &mut plan.items { + let CompositionItem::Scene(layer) = item else { + continue; + }; + + let world_bounds = transform_rect_bbox(layer.transform, layer.bounds); + let visible_world = intersect_rects(world_bounds, viewport); + if !is_non_empty(visible_world) { + layer.bounds = Rect::ZERO; + layer.content_bounds = None; + continue; + } + + let visible_local = intersect_rects( + layer.bounds, + transform_rect_bbox(layer.transform.inverse(), visible_world), + ); + if !is_non_empty(visible_local) { + layer.bounds = Rect::ZERO; + layer.content_bounds = None; + continue; + } + + let bounds_delta = visible_local.origin() - layer.bounds.origin(); + if bounds_delta != peniko::kurbo::Vec2::ZERO { + let bounds_transform = Affine::translate(bounds_delta); + for filter in &mut layer.color_filters { + filter.position_transform = filter.position_transform * bounds_transform; + } + } + + layer.bounds = visible_local; + layer.content_bounds = layer + .content_bounds + .map(|content_bounds| intersect_rects(content_bounds, visible_local)) + .filter(|bounds| is_non_empty(*bounds)); + } +} + +fn transform_rect_bbox(transform: Affine, rect: Rect) -> Rect { + let p0 = transform * rect.origin(); + let p1 = transform * Point::new(rect.x1, rect.y0); + let p2 = transform * Point::new(rect.x0, rect.y1); + let p3 = transform * Point::new(rect.x1, rect.y1); + Rect::new( + p0.x.min(p1.x).min(p2.x).min(p3.x), + p0.y.min(p1.y).min(p2.y).min(p3.y), + p0.x.max(p1.x).max(p2.x).max(p3.x), + p0.y.max(p1.y).max(p2.y).max(p3.y), + ) +} + +fn intersect_rects(a: Rect, b: Rect) -> Rect { + Rect::new( + a.x0.max(b.x0), + a.y0.max(b.y0), + a.x1.min(b.x1), + a.y1.min(b.y1), + ) +} + +fn is_non_empty(rect: Rect) -> bool { + rect.x0 < rect.x1 && rect.y0 < rect.y1 +} diff --git a/src/paint/display_list.rs b/src/paint/display_list.rs new file mode 100644 index 000000000..78159e7b9 --- /dev/null +++ b/src/paint/display_list.rs @@ -0,0 +1,4425 @@ +//! Retained paint artifact storage and replay. +//! +//! The retained display list stores per-element paint and post-paint recordings as +//! [`Scene`] values in local space. Retention happens at the element/stage +//! level: we rerecord only dirty elements and reuse unchanged retained scenes across +//! transform changes when the recorded content allows it. + +use crate::text::GlyphRunRef; +use imaging::{ + BlurredRoundedRect, Brush as ImagingBrush, ClipRef, FillRef, Filter as ImagingFilter, + GeometryRef, GroupRef as ImagingGroupRef, PaintSink, StrokeRef, + record::{ + Clip, Command, Draw, DrawId, Geometry, Glyph as ImagingGlyph, Scene, replay_transformed, + }, +}; +use peniko::{ + BlendMode, Fill, + kurbo::{Affine, Point, Rect, RoundedRect, Shape as _, Size}, +}; +use rustc_hash::{FxHashMap, FxHashSet, FxHasher}; +use std::{ + cell::RefCell, + hash::{Hash, Hasher}, + mem, + rc::Rc, +}; +use understory_box_tree::NodeFlags; + +use crate::{ + BoxTree, ElementId, + compositor_surface::SurfaceImageRegistry, + effects::{ + Composite, CompositorShader, CompositorShaderPass, Filter, GroupRef, Image, LayerFilter, + }, + frame::FrameRatePreference, + gradient::Gradient, + paint::composition::{ + CompositionItem, CompositionKey, CompositionPlan, CompositorSurfaceLayer, PaintStage, + SceneExternalImage, SceneLayer, + }, + unit::FontSizeCx, + view::stacking::{StackingContextItem, collect_stacking_context_items_into}, +}; + +const COMPOSED_SCENE_MIN_SUBTREE_SIZE: usize = 8; + +/// Transform class describing when recorded content remains valid. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub(crate) enum TransformClass { + Exact, + TranslateOnly, + Orthonormal, + Affine, +} + +impl TransformClass { + pub(crate) fn supports(self, diff: Self) -> bool { + match self { + Self::Exact => matches!(diff, Self::Exact), + Self::TranslateOnly => matches!(diff, Self::Exact | Self::TranslateOnly), + Self::Orthonormal => !matches!(diff, Self::Affine), + Self::Affine => true, + } + } + + pub(crate) fn combine(self, other: Self) -> Self { + use TransformClass::{Affine, Exact, Orthonormal, TranslateOnly}; + + match (self, other) { + (Exact, x) | (x, Exact) => x, + (TranslateOnly, _) | (_, TranslateOnly) => TranslateOnly, + (Orthonormal, _) | (_, Orthonormal) => Orthonormal, + (Affine, Affine) => Affine, + } + } +} + +pub(crate) fn transform_diff_class(original: Affine, current: Affine) -> TransformClass { + if current == original { + return TransformClass::Exact; + } + + let o = original.as_coeffs(); + let c = current.as_coeffs(); + let same_linear = o[0] == c[0] && o[1] == c[1] && o[2] == c[2] && o[3] == c[3]; + if same_linear { + TransformClass::TranslateOnly + } else { + TransformClass::Affine + } +} + +#[derive(Clone)] +pub(crate) struct ElementStage { + pub scene: Scene, + pub color_filters: Vec, + pub transform_class: TransformClass, + pub content_revision: u64, + stack_index: StageStackIndex, +} + +impl Default for ElementStage { + fn default() -> Self { + Self { + scene: Scene::new(), + color_filters: Vec::new(), + transform_class: TransformClass::Affine, + content_revision: 0, + stack_index: StageStackIndex::default(), + } + } +} + +impl ElementStage { + pub(crate) fn set_scene(&mut self, scene: Scene, mut color_filters: Vec) { + color_filters.sort_by_key(|effect| effect.command_index); + let content_changed = self.scene != scene || self.color_filters != color_filters; + self.transform_class = scene_transform_class(&scene); + self.stack_index = StageStackIndex::build(&scene); + self.stack_index.apply_effect_commands(&color_filters); + self.scene = scene; + self.color_filters = color_filters; + if content_changed { + self.content_revision = self.content_revision.wrapping_add(1); + } + } +} + +#[derive(Clone, Default)] +struct StageStackIndex { + group_depth_before_command: Vec, + clip_depth_before_command: Vec, + effect_depth_before_command: Vec, +} + +impl StageStackIndex { + fn build(scene: &Scene) -> Self { + let mut group_depth = 0u16; + let mut clip_depth = 0u16; + let mut group_depth_before_command = Vec::with_capacity(scene.commands().len() + 1); + let mut clip_depth_before_command = Vec::with_capacity(scene.commands().len() + 1); + let effect_depth_before_command = vec![0; scene.commands().len() + 1]; + + for command in scene.commands() { + group_depth_before_command.push(group_depth); + clip_depth_before_command.push(clip_depth); + match command { + Command::PushGroup(_) => group_depth = group_depth.saturating_add(1), + Command::PopGroup => group_depth = group_depth.saturating_sub(1), + Command::PushClip(_) => clip_depth = clip_depth.saturating_add(1), + Command::PopClip => clip_depth = clip_depth.saturating_sub(1), + Command::PushContext(_) | Command::PopContext | Command::Draw(_) => {} + } + } + + group_depth_before_command.push(group_depth); + clip_depth_before_command.push(clip_depth); + + Self { + group_depth_before_command, + clip_depth_before_command, + effect_depth_before_command, + } + } + + fn apply_effect_commands(&mut self, effects: &[ShaderCommand]) { + self.effect_depth_before_command.fill(0); + let mut events = effects.iter().collect::>(); + events.sort_by_key(|event| event.command_index); + let mut event_index = 0usize; + let mut depth = 0u16; + for command_index in 0..self.effect_depth_before_command.len() { + while event_index < events.len() && events[event_index].command_index == command_index { + match &events[event_index].kind { + ShaderCommandKind::Push(_) => depth = depth.saturating_add(1), + ShaderCommandKind::Pop => depth = depth.saturating_sub(1), + } + event_index += 1; + } + self.effect_depth_before_command[command_index] = depth; + } + } + + fn has_active_group_or_clip(&self, command_index: usize) -> bool { + let group_depth = self + .group_depth_before_command + .get(command_index) + .copied() + .unwrap_or(0); + let clip_depth = self + .clip_depth_before_command + .get(command_index) + .copied() + .unwrap_or(0); + let effect_depth = self + .effect_depth_before_command + .get(command_index) + .copied() + .unwrap_or(0); + group_depth > 0 || clip_depth > 0 || effect_depth > 0 + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ShaderCommand { + pub command_index: usize, + pub kind: ShaderCommandKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ShaderCommandKind { + Push(CompositorShaderPass), + Pop, +} + +enum ShaderGroupClose { + Group, + ColorFilter, +} + +#[doc(hidden)] +pub struct StageRecorder { + scene: Scene, + color_filters: Vec, + effect_group_stack: Vec>, + surface_images: Rc>, + font_size_cx: FontSizeCx, +} + +impl StageRecorder { + pub(crate) fn from_stage( + stage: &mut ElementStage, + surface_images: Rc>, + font_size_cx: FontSizeCx, + _effective_scale: f64, + ) -> Self { + let scene = mem::take(&mut stage.scene); + Self { + scene, + color_filters: mem::take(&mut stage.color_filters), + effect_group_stack: Vec::new(), + surface_images, + font_size_cx, + } + } + + #[cfg(test)] + fn from_stage_for_test(stage: &mut ElementStage) -> Self { + Self::from_stage( + stage, + Rc::new(RefCell::new(SurfaceImageRegistry::default())), + FontSizeCx::new(14.0, 16.0), + 1.0, + ) + } + + pub(crate) fn finish(self, stage: &mut ElementStage) { + debug_assert!( + self.effect_group_stack.is_empty(), + "unbalanced Floem shader groups" + ); + stage.set_scene(self.scene, self.color_filters); + } + + pub(crate) fn clear(&mut self) { + self.scene.clear(); + self.color_filters.clear(); + self.effect_group_stack.clear(); + } + + fn register_surface_image( + &self, + surface: &crate::effects::SurfaceImage, + source_size: Size, + ) -> imaging::Image { + imaging::Image::External( + self.surface_images + .borrow_mut() + .register(surface, source_size), + ) + } + + fn register_transformed_surface_image( + &self, + surface: &crate::effects::SurfaceImage, + source_size: Size, + transform: Affine, + bounds: Rect, + ) -> imaging::Image { + let _ = transform_rect_bbox(transform, bounds); + self.register_surface_image(surface, source_size) + } + + fn lower_gradient(&self, gradient: &Gradient, shape: GeometryRef<'_>) -> peniko::Gradient { + gradient.to_peniko(geometry_ref_bounds(shape), &self.font_size_cx) + } + + pub fn push_effect_group(&mut self, group: GroupRef<'_>) { + let has_compositor_effect = group + .filters + .iter() + .any(|filter| matches!(filter, Filter::Color(_) | Filter::Layer(_))); + let composite = match group.composite { + Composite::Imaging(composite) => composite, + Composite::Shader(effect) => { + panic!( + "shader composite effects require a backdrop render pass and are not implemented yet: {effect:?}" + ) + } + }; + let imaging_filters = if has_compositor_effect { + Vec::new() + } else { + group + .filters + .iter() + .filter_map(|filter| match filter { + Filter::Imaging(filter) => Some(*filter), + Filter::Color(_) | Filter::Layer(_) => None, + }) + .collect::>() + }; + let erased_group = ImagingGroupRef::new() + .with_filters(&imaging_filters) + .with_composite(composite); + let effect_clip = group.clip.clone().map(ClipRef::to_owned); + let erased_group = if let Some(clip) = group.clip.clone() { + erased_group.with_clip(clip) + } else { + erased_group + }; + let erased_group = if let Some(mask) = group.mask { + ImagingGroupRef { + clip: erased_group.clip, + mask: Some(mask), + filters: erased_group.filters, + composite: erased_group.composite, + } + } else { + erased_group + }; + PaintSink::push_group(&mut self.scene, erased_group); + let mut close_ops = vec![ShaderGroupClose::Group]; + + if has_compositor_effect { + for filter in group.filters { + let shader = match filter { + Filter::Imaging(filter) => CompositorShader::Layer( + compositor_effect_for_imaging_filter(*filter).unwrap_or_else(|| { + panic!( + "cannot preserve ordered mixed filter chain with unsupported Imaging filter: {filter:?}" + ) + }), + ), + Filter::Color(effect) => CompositorShader::Color(effect.clone()), + Filter::Layer(effect) => CompositorShader::Layer(effect.clone()), + }; + self.color_filters.push(ShaderCommand { + command_index: self.current_command_index(), + kind: ShaderCommandKind::Push(CompositorShaderPass { + shader, + clip: effect_clip.clone(), + position_transform: Affine::IDENTITY, + }), + }); + close_ops.push(ShaderGroupClose::ColorFilter); + } + } + + self.effect_group_stack.push(close_ops); + } + + pub fn pop_effect_group(&mut self) { + let close_ops = self + .effect_group_stack + .pop() + .expect("unbalanced Floem shader group pop"); + for op in close_ops.into_iter().rev() { + match op { + ShaderGroupClose::Group => self.scene.pop_group(), + ShaderGroupClose::ColorFilter => self.pop_compositor_color_filter(), + } + } + } + + fn pop_compositor_color_filter(&mut self) { + self.color_filters.push(ShaderCommand { + command_index: self.current_command_index(), + kind: ShaderCommandKind::Pop, + }); + } + + fn current_command_index(&self) -> usize { + self.scene.commands().len() + } +} + +impl StageRecorder { + fn imaging_solid(color: peniko::Color) -> ImagingBrush { + ImagingBrush::Solid(color) + } + + fn source_composite( + mut composite: imaging::Composite, + sampler: peniko::ImageSampler, + ) -> imaging::Composite { + composite.alpha *= sampler.alpha; + composite + } + + fn push_shader_source(&mut self, source: crate::effects::ShaderSource) { + PaintSink::push_group(&mut self.scene, ImagingGroupRef::new()); + self.color_filters.push(ShaderCommand { + command_index: self.current_command_index(), + kind: ShaderCommandKind::Push(CompositorShaderPass { + shader: CompositorShader::Source(source), + clip: None, + position_transform: Affine::IDENTITY, + }), + }); + } + + fn pop_shader_source(&mut self) { + self.pop_compositor_color_filter(); + self.scene.pop_group(); + } + + fn shader_source_fill( + &mut self, + transform: Affine, + fill_rule: Fill, + brush_transform: Option, + shape: GeometryRef<'_>, + composite: imaging::Composite, + source: crate::effects::ShaderSource, + ) { + self.push_shader_source(source); + let shape = shape.to_owned(); + let _ = self.scene.draw(Draw::Fill { + transform, + fill_rule, + brush: Self::imaging_solid(peniko::Color::WHITE), + brush_transform, + shape, + composite, + }); + self.pop_shader_source(); + } +} + +impl imaging::ImagingSceneSink for StageRecorder { + fn imaging_scene_mut(&mut self) -> &mut Scene { + &mut self.scene + } +} + +fn compositor_effect_for_imaging_filter(filter: ImagingFilter) -> Option { + match filter { + ImagingFilter::Blur { + std_deviation_x, + std_deviation_y, + } => { + let id = u64::from(std_deviation_x.to_bits()) + | (u64::from(std_deviation_y.to_bits()) << 32) ^ 0xB10E_0000_0000_0000; + Some( + LayerFilter::wgsl_with_id( + crate::effects::ColorFilterId(id), + format!( + r#" +let radius = vec2({std_deviation_x:?}, {std_deviation_y:?}); +let texel = vec2(1.0 / frame.target_width, 1.0 / frame.target_height); +var acc = textureSample(input_texture, input_sampler, uv) * 0.227027; +acc += textureSample(input_texture, input_sampler, uv + texel * vec2( radius.x, 0.0)) * 0.1945946; +acc += textureSample(input_texture, input_sampler, uv + texel * vec2(-radius.x, 0.0)) * 0.1945946; +acc += textureSample(input_texture, input_sampler, uv + texel * vec2(0.0, radius.y)) * 0.1216216; +acc += textureSample(input_texture, input_sampler, uv + texel * vec2(0.0, -radius.y)) * 0.1216216; +acc += textureSample(input_texture, input_sampler, uv + texel * vec2( radius.x, radius.y)) * 0.035135; +acc += textureSample(input_texture, input_sampler, uv + texel * vec2(-radius.x, radius.y)) * 0.035135; +acc += textureSample(input_texture, input_sampler, uv + texel * vec2( radius.x, -radius.y)) * 0.035135; +acc += textureSample(input_texture, input_sampler, uv + texel * vec2(-radius.x, -radius.y)) * 0.035135; +return acc; +"# + ), + ) + .with_label("imaging blur filter"), + ) + } + _ => None, + } +} + +impl PaintSink for StageRecorder { + fn push_context(&mut self, context: imaging::ContextRef<'_>) { + self.scene.push_context(context.label, context.source); + } + + fn pop_context(&mut self) { + self.scene.pop_context(); + } + + fn push_clip(&mut self, clip: ClipRef<'_>) { + let clip = clip.to_owned(); + self.scene.push_clip(clip); + } + + fn pop_clip(&mut self) { + self.scene.pop_clip(); + } + + fn push_group(&mut self, group: GroupRef<'_>) { + self.push_effect_group(group); + } + + fn pop_group(&mut self) { + self.pop_effect_group(); + } + + fn fill(&mut self, draw: FillRef<'_, crate::effects::Brush>) { + let FillRef { + transform, + fill_rule, + brush, + brush_transform, + shape, + composite, + } = draw; + match brush { + crate::effects::Brush::Solid(color) => { + let _ = self.scene.draw( + FillRef { + transform, + fill_rule, + brush: Self::imaging_solid(color), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + crate::effects::Brush::Gradient(gradient) => { + let gradient = self.lower_gradient(&gradient, shape.clone()); + let _ = self.scene.draw( + FillRef { + transform, + fill_rule, + brush: ImagingBrush::Gradient(gradient), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + crate::effects::Brush::Image(image) => { + let peniko::ImageBrush { image, sampler } = image.brush; + match image { + Image::Raster(image) => { + let image = imaging::Image::Raster(image.image); + let image = imaging::ImageBrush(peniko::ImageBrush { image, sampler }); + let _ = self.scene.draw( + FillRef { + transform, + fill_rule, + brush: ImagingBrush::Image(image), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + Image::Scene(image) => { + let image = imaging::Image::Scene(image.image); + let image = imaging::ImageBrush(peniko::ImageBrush { image, sampler }); + let _ = self.scene.draw( + FillRef { + transform, + fill_rule, + brush: ImagingBrush::Image(image), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + Image::Surface(surface) => { + let bounds = geometry_ref_bounds(shape.clone()); + let image = self.register_transformed_surface_image( + &surface, + surface.intrinsic_size, + transform, + bounds, + ); + let image = imaging::ImageBrush(peniko::ImageBrush { image, sampler }); + let _ = self.scene.draw( + FillRef { + transform, + fill_rule, + brush: ImagingBrush::Image(image), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + Image::Source(source) => { + self.shader_source_fill( + transform, + fill_rule, + brush_transform, + shape, + Self::source_composite(composite, sampler), + source.source, + ); + } + } + } + } + } + + fn stroke(&mut self, draw: StrokeRef<'_, crate::effects::Brush>) { + let StrokeRef { + transform, + stroke, + brush, + brush_transform, + shape, + composite, + } = draw; + match brush { + crate::effects::Brush::Solid(color) => { + let _ = self.scene.draw( + StrokeRef { + transform, + stroke, + brush: Self::imaging_solid(color), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + crate::effects::Brush::Gradient(gradient) => { + let gradient = self.lower_gradient(&gradient, shape.clone()); + let _ = self.scene.draw( + StrokeRef { + transform, + stroke, + brush: ImagingBrush::Gradient(gradient), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + crate::effects::Brush::Image(image) => { + let peniko::ImageBrush { image, sampler } = image.brush; + match image { + Image::Raster(image) => { + let image = imaging::Image::Raster(image.image); + let _ = self.scene.draw( + StrokeRef { + transform, + stroke, + brush: ImagingBrush::Image(imaging::ImageBrush( + peniko::ImageBrush { image, sampler }, + )), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + Image::Scene(image) => { + let image = imaging::Image::Scene(image.image); + let _ = self.scene.draw( + StrokeRef { + transform, + stroke, + brush: ImagingBrush::Image(imaging::ImageBrush( + peniko::ImageBrush { image, sampler }, + )), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + Image::Surface(surface) => { + let bounds = geometry_ref_bounds(shape.clone()); + let image = self.register_transformed_surface_image( + &surface, + surface.intrinsic_size, + transform, + bounds, + ); + let _ = self.scene.draw( + StrokeRef { + transform, + stroke, + brush: ImagingBrush::Image(imaging::ImageBrush( + peniko::ImageBrush { image, sampler }, + )), + brush_transform, + shape, + composite, + } + .to_owned(), + ); + } + Image::Source(source) => { + self.push_shader_source(source.source); + let _ = self.scene.draw( + StrokeRef { + transform, + stroke, + brush: Self::imaging_solid(peniko::Color::WHITE), + brush_transform, + shape, + composite: Self::source_composite(composite, sampler), + } + .to_owned(), + ); + self.pop_shader_source(); + } + } + } + } + } + + fn glyph_run( + &mut self, + draw: GlyphRunRef<'_, crate::effects::Brush>, + glyphs: &mut dyn Iterator, + ) { + let GlyphRunRef { + font, + transform, + glyph_transform, + font_size, + font_embolden, + hint, + normalized_coords, + style, + brush, + brush_transform, + composite, + } = draw; + match brush { + crate::effects::Brush::Solid(color) => { + let _ = self.scene.draw(imaging::record::Draw::GlyphRun( + GlyphRunRef { + font, + transform, + glyph_transform, + font_size, + font_embolden, + hint, + normalized_coords, + style, + brush: Self::imaging_solid(color), + brush_transform, + composite, + } + .to_owned(glyphs), + )); + } + crate::effects::Brush::Gradient(gradient) => { + let gradient = gradient.to_peniko(Rect::ZERO, &self.font_size_cx); + let _ = self.scene.draw(imaging::record::Draw::GlyphRun( + GlyphRunRef { + font, + transform, + glyph_transform, + font_size, + font_embolden, + hint, + normalized_coords, + style, + brush: ImagingBrush::Gradient(gradient), + brush_transform, + composite, + } + .to_owned(glyphs), + )); + } + crate::effects::Brush::Image(image) => { + let peniko::ImageBrush { image, sampler } = image.brush; + match image { + Image::Raster(image) => { + let image = imaging::Image::Raster(image.image); + let _ = self.scene.draw(imaging::record::Draw::GlyphRun( + GlyphRunRef { + font, + transform, + glyph_transform, + font_size, + font_embolden, + hint, + normalized_coords, + style, + brush: ImagingBrush::Image(imaging::ImageBrush( + peniko::ImageBrush { image, sampler }, + )), + brush_transform, + composite, + } + .to_owned(glyphs), + )); + } + Image::Scene(image) => { + let image = imaging::Image::Scene(image.image); + let _ = self.scene.draw(imaging::record::Draw::GlyphRun( + GlyphRunRef { + font, + transform, + glyph_transform, + font_size, + font_embolden, + hint, + normalized_coords, + style, + brush: ImagingBrush::Image(imaging::ImageBrush( + peniko::ImageBrush { image, sampler }, + )), + brush_transform, + composite, + } + .to_owned(glyphs), + )); + } + Image::Surface(surface) => { + let glyphs = glyphs.collect::>(); + let image = self.register_surface_image(&surface, surface.intrinsic_size); + let _ = self.scene.draw(imaging::record::Draw::GlyphRun( + GlyphRunRef { + font, + transform, + glyph_transform, + font_size, + font_embolden, + hint, + normalized_coords, + style, + brush: ImagingBrush::Image(imaging::ImageBrush( + peniko::ImageBrush { image, sampler }, + )), + brush_transform, + composite, + } + .to_owned(&mut glyphs.into_iter()), + )); + } + Image::Source(source) => { + self.push_shader_source(source.source); + let _ = self.scene.draw(imaging::record::Draw::GlyphRun( + GlyphRunRef { + font, + transform, + glyph_transform, + font_size, + font_embolden, + hint, + normalized_coords, + style, + brush: Self::imaging_solid(peniko::Color::WHITE), + brush_transform, + composite: Self::source_composite(composite, sampler), + } + .to_owned(glyphs), + )); + self.pop_shader_source(); + } + } + } + } + } + + fn blurred_rounded_rect(&mut self, draw: BlurredRoundedRect) { + PaintSink::blurred_rounded_rect(&mut self.scene, draw); + } + + fn scene_picture(&mut self, picture: &imaging::ScenePicture, transform: Affine) { + PaintSink::scene_picture(&mut self.scene, picture, transform); + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ElementSnapshot { + pub local_bounds: Rect, + pub clip: Option, + pub world_transform: Affine, + pub wants_layer: bool, + pub layer_frame_rate: Option, +} + +impl ElementSnapshot { + pub(crate) fn from_box_tree(box_tree: &crate::BoxTree, element_id: ElementId) -> Self { + Self { + local_bounds: box_tree.local_bounds(element_id.0).unwrap_or_default(), + clip: box_tree.clipped_local_clip(element_id.0), + world_transform: box_tree.world_transform(element_id.0).unwrap_or_default(), + wants_layer: box_tree.wants_layer(element_id.0), + layer_frame_rate: box_tree.layer_frame_rate(element_id.0), + } + } + + pub(crate) fn supports_reuse(self, current: Self) -> bool { + self.local_bounds == current.local_bounds && self.clip == current.clip + } +} + +#[derive(Clone, Default)] +pub(crate) struct ElementDisplayList { + pub paint: ElementStage, + pub post: ElementStage, + pub snapshot: Option, +} + +pub(crate) struct DisplayListSync { + pub active_ids: FxHashSet, + pub newly_active_ids: FxHashSet, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct DisplayNodeSlot(usize); + +#[derive(Clone, Default)] +struct ChildList { + ordered: Vec, +} + +impl ChildList { + fn new(children: Vec, _nodes: &[Option]) -> Self { + Self { ordered: children } + } +} + +#[derive(Clone, Default)] +struct DisplayNode { + element_id: Option, + parent: Option, + children: ChildList, + display: ElementDisplayList, + composed_scene: Scene, + composed_dirty: bool, + subtree_size: usize, +} + +#[derive(Default)] +pub struct RetainedDisplayList { + roots: Vec, + nodes: Vec>, + free_list: Vec, + slot_by_id: FxHashMap, + inactive_elements: FxHashMap, + active_count: usize, +} + +impl RetainedDisplayList { + pub(crate) fn sync_structure( + &mut self, + root: ElementId, + box_tree: &BoxTree, + dragging_preview: Option, + ) -> DisplayListSync { + let mut existing = mem::take(&mut self.slot_by_id); + + let mut roots = Vec::new(); + let mut active_ids = FxHashSet::default(); + let mut newly_active_ids = FxHashSet::default(); + + self.sync_branch( + root, + None, + false, + dragging_preview, + box_tree, + &mut existing, + &mut active_ids, + &mut newly_active_ids, + &mut roots, + ); + + if let Some(preview) = dragging_preview { + self.sync_branch( + preview, + None, + true, + None, + box_tree, + &mut existing, + &mut active_ids, + &mut newly_active_ids, + &mut roots, + ); + } + + for (_, slot) in existing { + self.free_slot(slot); + } + + self.active_count = active_ids.len(); + self.roots = roots; + DisplayListSync { + active_ids, + newly_active_ids, + } + } + + pub(crate) fn replay_step_count(&self) -> usize { + self.active_count * 2 + } + + pub(crate) fn element_mut(&mut self, id: ElementId) -> &mut ElementDisplayList { + if let Some(slot) = self.find_slot(id) { + return &mut self + .node_mut(slot) + .expect("display list node missing") + .display; + } + self.inactive_elements.entry(id).or_default() + } + + pub(crate) fn element(&self, id: ElementId) -> Option<&ElementDisplayList> { + if let Some(slot) = self.find_slot(id) { + return Some(&self.node(slot)?.display); + } + self.inactive_elements.get(&id) + } + + pub(crate) fn needs_stage_rerecord(&self, id: ElementId, snapshot: ElementSnapshot) -> bool { + let Some(element) = self.element(id) else { + return true; + }; + let Some(previous) = element.snapshot else { + return true; + }; + + if !previous.supports_reuse(snapshot) { + return true; + } + + let diff = transform_diff_class(previous.world_transform, snapshot.world_transform); + !element.paint.transform_class.supports(diff) + || !element.post.transform_class.supports(diff) + } + + pub(crate) fn child_slots(&self, slot: DisplayNodeSlot) -> Option<&[DisplayNodeSlot]> { + Some(&self.node(slot)?.children.ordered) + } + + pub(crate) fn mark_composed_dirty(&mut self, id: ElementId) { + self.mark_composed_dirty_from_slot(self.find_slot(id)); + } + + pub(crate) fn ensure_composed_scene(&mut self, slot: DisplayNodeSlot) { + if !self.slot_has_composed_scene(slot) { + return; + } + + let (dirty, child_slots) = match self.node(slot) { + Some(node) => (node.composed_dirty, node.children.ordered.clone()), + None => return, + }; + if !dirty { + return; + } + + for child in child_slots.iter().copied() { + if self.slot_has_composed_scene(child) { + self.ensure_composed_scene(child); + } + } + + let mut composed = { + let node = self.node_mut(slot).expect("display list node missing"); + let mut scene = mem::take(&mut node.composed_scene); + scene.clear(); + scene + }; + + let snapshot = { + let node = self.node(slot).expect("display list node missing"); + node.display.snapshot + }; + if let Some(snapshot) = snapshot { + self.append_node_contents_to_scene(slot, &mut composed, snapshot.world_transform); + } + + let node = self.node_mut(slot).expect("display list node missing"); + node.composed_scene = composed; + node.composed_dirty = false; + } + + pub(crate) fn composed_scene(&self, slot: DisplayNodeSlot) -> Option<&Scene> { + Some(&self.node(slot)?.composed_scene) + } + + pub(crate) fn slot_has_composed_scene(&self, slot: DisplayNodeSlot) -> bool { + let Some(node) = self.node(slot) else { + return false; + }; + if !node.caches_composed_scene() { + return false; + } + let Some(parent) = node.parent else { + return true; + }; + !self + .node(parent) + .is_some_and(DisplayNode::caches_composed_scene) + } + + pub(crate) fn lower_composition_plan( + &self, + effective_scale: f64, + surface_images: &SurfaceImageRegistry, + ) -> CompositionPlan { + let mut chunk_index = 0; + let mut external_occurrence = 0; + let mut chunks = Vec::new(); + let property_state = PropertyState::default(); + let mut lowering_stack = FxHashSet::default(); + for slot in &self.roots { + self.lower_slot_into_chunks( + *slot, + &mut lowering_stack, + &mut chunks, + &mut chunk_index, + &mut external_occurrence, + effective_scale, + surface_images, + &property_state, + ); + } + let mut plan = chunks_into_composition_plan(chunks); + mark_promoted_scene_layers(&mut plan); + plan + } + + #[cfg(test)] + fn lower_composition_plan_for_test(&self, effective_scale: f64) -> CompositionPlan { + let surface_images = test_surface_image_registry(); + self.lower_composition_plan(effective_scale, &surface_images.borrow()) + } + + fn lower_slot_into_chunks<'a>( + &'a self, + slot: DisplayNodeSlot, + lowering_stack: &mut FxHashSet, + chunks: &mut Vec>, + chunk_index: &mut u32, + external_occurrence: &mut u32, + effective_scale: f64, + surface_images: &SurfaceImageRegistry, + property_state: &PropertyState, + ) { + let Some(node) = self.node(slot) else { + return; + }; + let Some(element_id) = node.element_id else { + return; + }; + let Some(snapshot) = node.display.snapshot else { + return; + }; + if !lowering_stack.insert(slot) { + debug_assert!(false, "cycle detected while lowering retained display list"); + return; + } + + let scoped_property_state; + let property_state = if let Some(clip) = snapshot.clip { + scoped_property_state = property_state.with_clip(PropertyClip { + clip, + transform: snapshot.world_transform, + bounds: snapshot.local_bounds, + }); + &scoped_property_state + } else { + property_state + }; + let scoped_compositing_state; + let property_state = if let Some(style) = element_compositing_style(element_id) { + scoped_compositing_state = + property_state.with_compositing(&style, snapshot.world_transform); + &scoped_compositing_state + } else { + property_state + }; + + if snapshot.wants_layer { + chunks.push(LoweredChunk::LayerStart(LayerRunSource { + element_id, + frame_rate: snapshot.layer_frame_rate, + })); + } + + self.lower_stage_into_plan( + element_id, + PaintStage::Paint, + &node.display.paint, + &snapshot, + chunks, + chunk_index, + external_occurrence, + effective_scale, + surface_images, + property_state, + ); + + for child in &node.children.ordered { + self.lower_slot_into_chunks( + *child, + lowering_stack, + chunks, + chunk_index, + external_occurrence, + effective_scale, + surface_images, + property_state, + ); + } + + self.lower_stage_into_plan( + element_id, + PaintStage::Post, + &node.display.post, + &snapshot, + chunks, + chunk_index, + external_occurrence, + effective_scale, + surface_images, + property_state, + ); + + if snapshot.wants_layer { + chunks.push(LoweredChunk::LayerEnd); + } + + lowering_stack.remove(&slot); + } + + fn lower_stage_into_plan<'a>( + &self, + element_id: ElementId, + stage_kind: PaintStage, + stage: &'a ElementStage, + snapshot: &ElementSnapshot, + chunks: &mut Vec>, + chunk_index: &mut u32, + external_occurrence: &mut u32, + effective_scale: f64, + surface_images: &SurfaceImageRegistry, + property_state: &PropertyState, + ) { + if stage.scene.is_empty() { + return; + } + + let command_count = stage.scene.commands().len(); + let mut range_start = 0; + let mut effect_index = 0usize; + let mut scoped_effects = Vec::new(); + + for command_index in 0..=command_count { + while effect_index < stage.color_filters.len() + && stage.color_filters[effect_index] + .command_index + .min(command_count) + == command_index + { + push_scene_range( + chunks, + chunk_index, + element_id, + stage_kind, + stage, + range_start, + command_index, + effective_scale, + surface_images, + snapshot, + &property_state.with_effects(&scoped_effects, snapshot.world_transform), + ); + range_start = command_index; + match &stage.color_filters[effect_index].kind { + ShaderCommandKind::Push(effect) => { + scoped_effects.push(effect.clone()); + } + ShaderCommandKind::Pop => { + let _ = scoped_effects.pop(); + } + } + effect_index += 1; + } + + let Some(command) = stage.scene.commands().get(command_index) else { + continue; + }; + if property_state.can_direct_promote() + && scoped_effects.is_empty() + && !stage.stack_index.has_active_group_or_clip(command_index) + && let Some(external) = promotable_external_image_fill( + &stage.scene, + command, + *external_occurrence, + surface_images, + snapshot.world_transform, + property_state.opacity, + ) + { + push_scene_range( + chunks, + chunk_index, + element_id, + stage_kind, + stage, + range_start, + command_index, + effective_scale, + surface_images, + snapshot, + property_state, + ); + chunks.push(LoweredChunk::External(external)); + *external_occurrence += 1; + range_start = command_index + 1; + } + } + + push_scene_range( + chunks, + chunk_index, + element_id, + stage_kind, + stage, + range_start, + command_count, + effective_scale, + surface_images, + snapshot, + &property_state.with_effects(&scoped_effects, snapshot.world_transform), + ); + } + + fn sync_branch( + &mut self, + element_id: ElementId, + parent: Option, + is_drag_preview: bool, + skip_element_id: Option, + box_tree: &BoxTree, + existing: &mut FxHashMap, + active_ids: &mut FxHashSet, + newly_active_ids: &mut FxHashSet, + out: &mut Vec, + ) { + if !is_drag_preview && Some(element_id) == skip_element_id { + return; + } + + if box_tree + .flags(element_id.0) + .is_none_or(|f| !f.contains(NodeFlags::VISIBLE)) + { + return; + } + + let mut child_items: Vec = Vec::new(); + collect_stacking_context_items_into(element_id, box_tree, &mut child_items); + + let paints_this_node = if is_drag_preview { + true + } else { + box_tree + .world_bounds(element_id.0) + .is_none_or(|bounds| bounds.area() != 0.0) + }; + + if paints_this_node { + let (slot, is_new) = match existing.remove(&element_id) { + Some(slot) => (slot, false), + None => (self.alloc_slot(element_id), true), + }; + let mut child_slots = Vec::with_capacity(child_items.len()); + for child in child_items { + self.sync_branch( + child.element_id, + Some(slot), + is_drag_preview, + skip_element_id, + box_tree, + existing, + active_ids, + newly_active_ids, + &mut child_slots, + ); + } + + let children = ChildList::new(child_slots, &self.nodes); + let inactive_display = if is_new { + self.inactive_elements.remove(&element_id) + } else { + None + }; + let subtree_size = 1 + children + .ordered + .iter() + .map(|child| { + self.node(*child) + .map(|child_node| child_node.subtree_size) + .unwrap_or(0) + }) + .sum::(); + let node = self.node_mut(slot).expect("display list node missing"); + let structure_changed = node.parent != parent + || node.children.ordered != children.ordered + || node.subtree_size != subtree_size; + node.element_id = Some(element_id); + node.parent = parent; + node.children = children; + node.subtree_size = subtree_size; + if let Some(display) = inactive_display { + node.display = display; + } + if is_new || structure_changed { + node.composed_dirty = true; + } + let parent_to_mark = if is_new || structure_changed { + node.parent + } else { + None + }; + self.slot_by_id.insert(element_id, slot); + active_ids.insert(element_id); + if is_new { + newly_active_ids.insert(element_id); + } + out.push(slot); + self.mark_composed_dirty_from_slot(parent_to_mark); + } else { + for child in child_items { + self.sync_branch( + child.element_id, + parent, + is_drag_preview, + skip_element_id, + box_tree, + existing, + active_ids, + newly_active_ids, + out, + ); + } + } + } + + fn find_slot(&self, id: ElementId) -> Option { + self.slot_by_id.get(&id).copied() + } + + fn alloc_slot(&mut self, element_id: ElementId) -> DisplayNodeSlot { + if let Some(slot) = self.free_list.pop() { + self.nodes[slot.0] = Some(DisplayNode { + element_id: Some(element_id), + ..DisplayNode::default() + }); + return slot; + } + + let slot = DisplayNodeSlot(self.nodes.len()); + self.nodes.push(Some(DisplayNode { + element_id: Some(element_id), + ..DisplayNode::default() + })); + slot + } + + fn free_slot(&mut self, slot: DisplayNodeSlot) { + if let Some(node) = self.nodes.get_mut(slot.0).and_then(Option::take) { + if let Some(element_id) = node.element_id { + self.slot_by_id.remove(&element_id); + } + if node.element_id.is_some() && self.active_count > 0 { + self.active_count -= 1; + } + self.free_list.push(slot); + } + } + + fn node(&self, slot: DisplayNodeSlot) -> Option<&DisplayNode> { + self.nodes.get(slot.0)?.as_ref() + } + + fn node_mut(&mut self, slot: DisplayNodeSlot) -> Option<&mut DisplayNode> { + self.nodes.get_mut(slot.0)?.as_mut() + } + + fn mark_composed_dirty_from_slot(&mut self, mut current: Option) { + while let Some(slot) = current { + let Some(node) = self.node_mut(slot) else { + break; + }; + node.composed_dirty = true; + current = node.parent; + } + } + + fn append_node_contents_to_scene( + &self, + slot: DisplayNodeSlot, + scene: &mut Scene, + scene_world_transform: Affine, + ) { + let Some(node) = self.node(slot) else { + return; + }; + let Some(snapshot) = node.display.snapshot else { + return; + }; + // The composed scene is flattened into a single coordinate space rooted at + // `scene_world_transform`, so every descendant transform must be expressed + // relative to that same anchor. + let local_transform = scene_world_transform.inverse() * snapshot.world_transform; + + scene.reserve_like(&node.display.paint.scene); + scene.reserve_like(&node.display.post.scene); + if let Some(clip) = snapshot.clip { + let _ = scene.push_clip(Clip::Fill { + transform: local_transform, + shape: Geometry::RoundedRect(clip), + fill_rule: peniko::Fill::NonZero, + }); + } + scene.append_transformed(&node.display.paint.scene, local_transform); + for child in &node.children.ordered { + let Some(child_node) = self.node(*child) else { + continue; + }; + if self.slot_has_composed_scene(*child) && !child_node.composed_dirty { + let Some(child_snapshot) = child_node.display.snapshot else { + continue; + }; + let child_transform = + scene_world_transform.inverse() * child_snapshot.world_transform; + scene.reserve_like(&child_node.composed_scene); + scene.append_transformed(&child_node.composed_scene, child_transform); + } else { + self.append_node_contents_to_scene(*child, scene, scene_world_transform); + } + } + scene.append_transformed(&node.display.post.scene, local_transform); + if snapshot.clip.is_some() { + scene.pop_clip(); + } + } +} + +impl DisplayNode { + fn caches_composed_scene(&self) -> bool { + self.subtree_size >= COMPOSED_SCENE_MIN_SUBTREE_SIZE + } +} + +// Floem's small equivalent of Chromium's paint property tree state. Lowered +// chunks carry the nearest active clip/effect state separately from their draw +// commands; layerization then coalesces adjacent chunks with identical property +// state and materializes that state only once in the final scene run. +#[derive(Clone, Debug, PartialEq)] +struct PropertyState { + clips: Vec, + effects: Vec, + opacity: f32, + blend_mode: BlendMode, +} + +impl PropertyState { + fn with_clip(&self, clip: PropertyClip) -> Self { + let mut next = self.clone(); + next.clips.push(clip); + next + } + + fn with_effects(&self, effects: &[CompositorShaderPass], transform: Affine) -> Self { + let mut next = self.clone(); + next.effects + .extend(transform_shader_passes(effects, transform)); + next + } + + fn with_compositing(&self, style: &PropertyCompositingStyle, transform: Affine) -> Self { + let mut next = self.clone(); + next.opacity = (next.opacity * style.opacity).clamp(0.0, 1.0); + if style.blend_mode != BlendMode::default() { + next.blend_mode = style.blend_mode; + } + let effects = style + .filters + .iter() + .map(|filter| match filter { + Filter::Imaging(filter) => CompositorShader::Layer( + compositor_effect_for_imaging_filter(*filter).unwrap_or_else(|| { + panic!( + "cannot preserve style filter with unsupported Imaging filter: {filter:?}" + ) + }), + ), + Filter::Color(effect) => CompositorShader::Color(effect.clone()), + Filter::Layer(effect) => CompositorShader::Layer(effect.clone()), + }) + .map(|shader| CompositorShaderPass { + shader, + clip: None, + position_transform: Affine::IDENTITY, + }) + .collect::>(); + next.effects + .extend(transform_shader_passes(&effects, transform)); + next + } + + fn can_direct_promote(&self) -> bool { + self.clips.is_empty() && self.effects.is_empty() && self.blend_mode == BlendMode::default() + } +} + +impl Default for PropertyState { + fn default() -> Self { + Self { + clips: Vec::new(), + effects: Vec::new(), + opacity: 1.0, + blend_mode: BlendMode::default(), + } + } +} + +struct PropertyCompositingStyle { + opacity: f32, + filters: smallvec::SmallVec<[Filter; 3]>, + blend_mode: BlendMode, +} + +fn element_compositing_style(element_id: ElementId) -> Option { + if !element_id.is_view() { + return None; + } + let state = element_id.owning_id().state(); + let state = state.borrow(); + let opacity = state.view_style_props.opacity().clamp(0.0, 1.0); + let filters = state.view_style_props.filters(); + let blend_mode = state.view_style_props.blend_mode(); + (opacity != 1.0 || !filters.is_empty() || blend_mode != BlendMode::default()).then_some( + PropertyCompositingStyle { + opacity, + filters, + blend_mode, + }, + ) +} + +fn transform_shader_passes( + effects: &[CompositorShaderPass], + transform: Affine, +) -> Vec { + effects + .iter() + .cloned() + .map(|mut pass| { + if let Some(clip) = &mut pass.clip { + prepend_clip_transform(clip, transform); + } + pass.position_transform = pass.position_transform * transform.inverse(); + pass + }) + .collect() +} + +#[derive(Clone, Debug, PartialEq)] +struct PropertyClip { + clip: RoundedRect, + transform: Affine, + bounds: Rect, +} + +#[derive(Clone)] +enum LoweredChunk<'a> { + LayerStart(LayerRunSource), + LayerEnd, + Scene(LoweredSceneChunk<'a>), + External(CompositorSurfaceLayer), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct LayerRunSource { + element_id: ElementId, + frame_rate: Option, +} + +#[derive(Clone)] +struct LoweredSceneChunk<'a> { + scene: &'a Scene, + start: usize, + end: usize, + external_images: Vec, + layer_frame_rate: Option, + property_state: PropertyState, + element_id: ElementId, + stage_kind: PaintStage, + content_revision: u64, + transform: Affine, + bounds: Rect, +} + +fn chunks_into_composition_plan(chunks: Vec>) -> CompositionPlan { + let mut plan = CompositionPlan::new(); + let mut pending = SceneRunBuilder::default(); + let mut layer_stack: Vec = Vec::new(); + let mut run_index = 0u32; + + for chunk in chunks { + match chunk { + LoweredChunk::LayerStart(source) => { + pending.flush(&mut plan, &mut run_index); + layer_stack.push(source); + pending.layer_source = layer_stack.last().copied(); + } + LoweredChunk::LayerEnd => { + pending.flush(&mut plan, &mut run_index); + layer_stack.pop(); + pending.layer_source = layer_stack.last().copied(); + } + LoweredChunk::Scene(scene) => { + let layer_source = layer_stack.last().copied(); + if pending.layer_source != layer_source { + pending.flush(&mut plan, &mut run_index); + pending.layer_source = layer_source; + } + if !pending.accepts(&scene.property_state) { + pending.flush(&mut plan, &mut run_index); + pending.layer_source = layer_source; + } + pending.push(scene); + } + LoweredChunk::External(external) => { + pending.flush(&mut plan, &mut run_index); + plan.items + .push(CompositionItem::CompositorSurface(external)); + } + } + } + + pending.flush(&mut plan, &mut run_index); + plan +} + +#[derive(Default)] +struct SceneRunBuilder<'a> { + ranges: Vec>, + external_images: Vec, + layer_source: Option, + property_state: Option, + content_revision: u64, + bounds: Option, + content_bounds: Option, + clip_bounds: Option, + content_chunks: usize, +} + +struct PendingSceneRange<'a> { + scene: &'a Scene, + start: usize, + end: usize, + layer_frame_rate: Option, + element_id: ElementId, + stage_kind: PaintStage, + transform: Affine, +} + +impl<'a> SceneRunBuilder<'a> { + fn accepts(&self, property_state: &PropertyState) -> bool { + self.content_chunks == 0 || self.property_state.as_ref() == Some(property_state) + } + + fn push(&mut self, chunk: LoweredSceneChunk<'a>) { + if self.property_state.as_ref() != Some(&chunk.property_state) { + debug_assert_eq!(self.content_chunks, 0); + for clip in &chunk.property_state.clips { + let clip_bounds = property_clip_bounds(clip); + self.clip_bounds = Some(match self.clip_bounds { + Some(bounds) => intersect_rects(bounds, clip_bounds), + None => clip_bounds, + }); + } + self.property_state = Some(chunk.property_state.clone()); + } + self.ranges.push(PendingSceneRange { + scene: chunk.scene, + start: chunk.start, + end: chunk.end, + layer_frame_rate: chunk.layer_frame_rate, + element_id: chunk.element_id, + stage_kind: chunk.stage_kind, + transform: chunk.transform, + }); + self.external_images.extend(chunk.external_images); + self.content_revision = self.content_revision.wrapping_add(chunk.content_revision); + self.content_chunks += 1; + let chunk_bounds = transform_rect_bbox(chunk.transform, chunk.bounds); + self.bounds = Some(match self.bounds { + Some(bounds) => union_rects(bounds, chunk_bounds), + None => chunk_bounds, + }); + if let Some(content_bounds) = + scene_command_range_bounds(chunk.scene, chunk.start, chunk.end) + { + let content_bounds = transform_rect_bbox(chunk.transform, content_bounds); + self.content_bounds = Some(match self.content_bounds { + Some(bounds) => union_rects(bounds, content_bounds), + None => content_bounds, + }); + } + } + + fn flush(&mut self, plan: &mut CompositionPlan, run_index: &mut u32) { + if self.content_chunks == 0 { + self.ranges.clear(); + self.bounds = None; + self.content_bounds = None; + self.clip_bounds = None; + self.property_state = None; + return; + } + + let property_state = self.property_state.take().unwrap_or_default(); + let mut bounds = match (self.bounds, self.content_bounds) { + (Some(bounds), Some(content_bounds)) => union_rects(bounds, content_bounds), + (Some(bounds), None) => bounds, + (None, Some(content_bounds)) => content_bounds, + (None, None) => Rect::ZERO, + }; + if let Some(clip_bounds) = self.clip_bounds { + bounds = intersect_rects(bounds, clip_bounds); + } + let clipped_content_bounds = self + .content_bounds + .map(|content_bounds| intersect_rects(content_bounds, bounds)) + .filter(|content_bounds| is_non_empty_rect(*content_bounds)); + let mut scene = Scene::new(); + let local_transform = Affine::translate((-bounds.x0, -bounds.y0)); + let mut scene_state_revision = self.content_revision; + for clip in &property_state.clips { + push_snapshot_clip( + &mut scene, + clip.clip, + local_transform * clip.transform, + clip.bounds, + ); + hash_property_clip(&mut scene_state_revision, clip); + } + for range in &self.ranges { + hash_value(&mut scene_state_revision, &range.element_id); + hash_value(&mut scene_state_revision, &range.stage_kind); + hash_u64(&mut scene_state_revision, range.start as u64); + hash_u64(&mut scene_state_revision, range.end as u64); + hash_affine(&mut scene_state_revision, local_transform * range.transform); + append_scene_range_transformed( + range.scene, + &mut scene, + range.start, + range.end, + local_transform * range.transform, + ); + } + for _ in &property_state.clips { + scene.pop_clip(); + } + let local_content_bounds = + clipped_content_bounds.map(|rect| rect - bounds.origin().to_vec2()); + let local_effects = transform_shader_passes(&property_state.effects, local_transform); + let source_element_id = self + .layer_source + .map(|source| { + crate::paint::composition::LayerSourceId::from_element_id(source.element_id) + }) + .or_else(|| { + self.ranges.first().map(|range| { + crate::paint::composition::LayerSourceId::from_element_id(range.element_id) + }) + }); + let debug_name = self + .layer_source + .map(|source| source.element_id.owning_id().debug_name()) + .or_else(|| { + self.ranges + .first() + .map(|range| range.element_id.owning_id().debug_name()) + }) + .filter(|name| !name.is_empty()); + let frame_rate = self + .layer_source + .and_then(|source| source.frame_rate) + .or_else(|| self.ranges.first().and_then(|range| range.layer_frame_rate)); + plan.items.push(CompositionItem::Scene(SceneLayer { + key: CompositionKey::SceneRun { + run_index: *run_index, + }, + source_element_id, + debug_name, + scene, + external_images: mem::take(&mut self.external_images), + color_filters: local_effects, + content_revision: scene_state_revision, + transform: Affine::translate(bounds.origin().to_vec2()), + clip: None, + bounds: Rect::from_origin_size(Point::ZERO, bounds.size()), + content_bounds: local_content_bounds, + opacity: property_state.opacity, + blend_mode: property_state.blend_mode, + promoted: false, + frame_rate, + })); + self.content_revision = 0; + self.bounds = None; + self.content_bounds = None; + self.clip_bounds = None; + self.content_chunks = 0; + self.ranges.clear(); + *run_index += 1; + } +} + +fn push_scene_range<'a>( + chunks: &mut Vec>, + chunk_index: &mut u32, + element_id: ElementId, + stage_kind: PaintStage, + stage: &'a ElementStage, + start: usize, + end: usize, + effective_scale: f64, + surface_images: &SurfaceImageRegistry, + snapshot: &ElementSnapshot, + property_state: &PropertyState, +) { + if start >= end { + return; + } + let external_images = + external_images_in_command_range(&stage.scene, start, end, effective_scale, surface_images); + chunks.push(LoweredChunk::Scene(LoweredSceneChunk { + scene: &stage.scene, + start, + end, + external_images, + layer_frame_rate: snapshot.layer_frame_rate, + property_state: property_state.clone(), + element_id, + stage_kind, + content_revision: stage.content_revision, + transform: snapshot.world_transform, + bounds: snapshot.local_bounds, + })); + *chunk_index += 1; +} + +fn push_snapshot_clip(scene: &mut Scene, clip: RoundedRect, transform: Affine, bounds: Rect) { + let clip = constrain_infinite_rounded_rect(clip, Affine::IDENTITY, bounds.size()); + let _ = scene.push_clip(Clip::Fill { + transform, + shape: Geometry::RoundedRect(clip), + fill_rule: Fill::NonZero, + }); +} + +fn property_clip_bounds(property_clip: &PropertyClip) -> Rect { + let clip = constrain_infinite_rounded_rect( + property_clip.clip, + Affine::IDENTITY, + property_clip.bounds.size(), + ); + transform_rect_bbox(property_clip.transform, clip.rect()) +} + +fn hash_value(hash: &mut u64, value: &T) { + let mut hasher = FxHasher::default(); + value.hash(&mut hasher); + hash_u64(hash, hasher.finish()); +} + +fn hash_property_clip(hash: &mut u64, clip: &PropertyClip) { + hash_rounded_rect(hash, clip.clip); + hash_affine(hash, clip.transform); + hash_rect(hash, clip.bounds); +} + +fn hash_rounded_rect(hash: &mut u64, rounded: RoundedRect) { + hash_rect(hash, rounded.rect()); + let radii = rounded.radii(); + hash_f64(hash, radii.top_left); + hash_f64(hash, radii.top_right); + hash_f64(hash, radii.bottom_right); + hash_f64(hash, radii.bottom_left); +} + +fn hash_rect(hash: &mut u64, rect: Rect) { + hash_f64(hash, rect.x0); + hash_f64(hash, rect.y0); + hash_f64(hash, rect.x1); + hash_f64(hash, rect.y1); +} + +fn hash_affine(hash: &mut u64, transform: Affine) { + for coeff in transform.as_coeffs() { + hash_f64(hash, coeff); + } +} + +fn hash_f64(hash: &mut u64, value: f64) { + hash_u64(hash, value.to_bits()); +} + +fn hash_u64(hash: &mut u64, value: u64) { + let mixed = value + .wrapping_add(0x9e37_79b9_7f4a_7c15) + .wrapping_add(hash.wrapping_shl(6)) + .wrapping_add(hash.wrapping_shr(2)); + *hash ^= mixed; +} + +fn geometry_ref_bounds(shape: GeometryRef<'_>) -> Rect { + match shape { + GeometryRef::Rect(rect) => rect, + GeometryRef::RoundedRect(rounded) => rounded.rect(), + GeometryRef::Path(path) => path.bounding_box(), + GeometryRef::OwnedPath(path) => path.bounding_box(), + } +} + +fn is_surface_image_sampling_transform(brush_transform: Option) -> bool { + brush_transform.is_none() || brush_transform == Some(Affine::IDENTITY) +} + +fn is_translation_transform(transform: Affine) -> bool { + let [a, b, c, d, _, _] = transform.as_coeffs(); + const EPSILON: f64 = 1e-9; + (a - 1.0).abs() <= EPSILON + && b.abs() <= EPSILON + && c.abs() <= EPSILON + && (d - 1.0).abs() <= EPSILON +} + +#[cfg(test)] +fn test_surface_image_registry() -> Rc> { + Rc::new(RefCell::new(SurfaceImageRegistry::default())) +} + +fn external_images_in_command_range( + scene: &Scene, + start: usize, + end: usize, + effective_scale: f64, + surface_images: &SurfaceImageRegistry, +) -> Vec { + scene.commands()[start..end] + .iter() + .flat_map(|command| { + external_images_in_command(scene, command, effective_scale, surface_images) + }) + .collect() +} + +fn promotable_external_image_fill( + scene: &Scene, + command: &Command, + occurrence: u32, + surface_images: &SurfaceImageRegistry, + transform: Affine, + opacity: f32, +) -> Option { + let Command::Draw(draw_id) = command else { + return None; + }; + let Draw::Fill { + transform: draw_transform, + fill_rule, + brush, + brush_transform, + shape, + composite, + } = scene.draw_op(*draw_id) + else { + return None; + }; + if *fill_rule != Fill::NonZero || composite.blend != BlendMode::default() { + return None; + } + let Geometry::Rect(rect) = shape else { + return None; + }; + let ImagingBrush::Image(image) = brush else { + return None; + }; + if image.sampler.x_extend != peniko::Extend::Pad + || image.sampler.y_extend != peniko::Extend::Pad + { + return None; + } + let imaging::Image::External(external) = image.image else { + return None; + }; + let registered = surface_images.resolve_registered(external.id)?; + if !is_surface_image_sampling_transform(*brush_transform) { + return None; + } + if !is_translation_transform(*draw_transform) { + return None; + } + let promoted_rect = transform_rect_bbox(*draw_transform, *rect); + let opacity = (opacity * composite.alpha * image.sampler.alpha).clamp(0.0, 1.0); + Some(CompositorSurfaceLayer { + key: CompositionKey::CompositorSurface { + surface_id: registered.slot_id, + occurrence, + }, + surface_id: registered.slot_id, + rect: promoted_rect, + source_size: registered.source_size, + transform, + clip: None, + opacity, + blend_mode: BlendMode::default(), + }) +} + +fn external_images_in_command( + scene: &Scene, + command: &Command, + _effective_scale: f64, + surface_images: &SurfaceImageRegistry, +) -> Vec { + let Command::Draw(draw_id) = command else { + return Vec::new(); + }; + match scene.draw_op(*draw_id) { + Draw::Fill { brush, .. } | Draw::Stroke { brush, .. } => { + let Some(bounds) = draw_op_bounds(scene, *draw_id) else { + return Vec::new(); + }; + external_images_in_brush(brush, bounds, surface_images) + .into_iter() + .collect() + } + Draw::GlyphRun(run) => glyph_run_bounds(run) + .and_then(|bounds| external_images_in_brush(&run.brush, bounds, surface_images)) + .into_iter() + .collect(), + Draw::BlurredRoundedRect(_) | Draw::ScenePicture { .. } => Vec::new(), + } +} + +fn external_images_in_brush( + brush: &ImagingBrush, + rect: Rect, + surface_images: &SurfaceImageRegistry, +) -> Option { + let ImagingBrush::Image(image) = brush else { + return None; + }; + let imaging::Image::External(external) = image.image else { + return None; + }; + let registered = surface_images.resolve_registered(external.id)?; + Some(SceneExternalImage { + image_id: external.id, + surface_id: registered.slot_id, + rect, + source_size: registered.source_size, + }) +} + +fn mark_promoted_scene_layers(plan: &mut CompositionPlan) { + let mut earlier_external_bounds = Vec::new(); + for item in &mut plan.items { + match item { + CompositionItem::CompositorSurface(layer) => { + earlier_external_bounds.push(layer_visible_bounds( + layer.rect, + layer.transform, + layer.clip, + )); + } + CompositionItem::Scene(layer) => { + let scene_bounds = layer_visible_bounds( + layer.content_bounds.unwrap_or(layer.bounds), + layer.transform, + layer.clip, + ); + layer.promoted = earlier_external_bounds + .iter() + .any(|external_bounds| rects_overlap(*external_bounds, scene_bounds)); + } + } + } +} + +fn layer_visible_bounds(rect: Rect, transform: Affine, clip: Option) -> Rect { + let transformed_rect = transform_rect_bbox(transform, rect); + if let Some(clip) = clip { + return intersect_rects( + transformed_rect, + transform_rect_bbox(transform, clip.rect()), + ); + } + transformed_rect +} + +fn transform_rect_bbox(transform: Affine, rect: Rect) -> Rect { + let p0 = transform * rect.origin(); + let p1 = transform * Point::new(rect.x1, rect.y0); + let p2 = transform * Point::new(rect.x0, rect.y1); + let p3 = transform * Point::new(rect.x1, rect.y1); + Rect::new( + p0.x.min(p1.x).min(p2.x).min(p3.x), + p0.y.min(p1.y).min(p2.y).min(p3.y), + p0.x.max(p1.x).max(p2.x).max(p3.x), + p0.y.max(p1.y).max(p2.y).max(p3.y), + ) +} + +fn intersect_rects(a: Rect, b: Rect) -> Rect { + Rect::new( + a.x0.max(b.x0), + a.y0.max(b.y0), + a.x1.min(b.x1), + a.y1.min(b.y1), + ) +} + +fn is_non_empty_rect(rect: Rect) -> bool { + rect.x0 < rect.x1 && rect.y0 < rect.y1 +} + +fn rects_overlap(a: Rect, b: Rect) -> bool { + a.x0 < a.x1 + && a.y0 < a.y1 + && b.x0 < b.x1 + && b.y0 < b.y1 + && a.x0 < b.x1 + && b.x0 < a.x1 + && a.y0 < b.y1 + && b.y0 < a.y1 +} + +fn scene_command_range_bounds(scene: &Scene, start: usize, end: usize) -> Option { + let mut bounds = None; + for command in &scene.commands()[start..end] { + let Command::Draw(draw_id) = command else { + continue; + }; + let draw_bounds = draw_op_bounds(scene, *draw_id)?; + bounds = Some(match bounds { + Some(bounds) => union_rects(bounds, draw_bounds), + None => draw_bounds, + }); + } + bounds +} + +fn draw_op_bounds(scene: &Scene, draw_id: DrawId) -> Option { + match scene.draw_op(draw_id) { + Draw::Fill { + transform, shape, .. + } => Some(transform_rect_bbox(*transform, geometry_bounds(shape))), + Draw::Stroke { + transform, + stroke, + shape, + .. + } => Some(expand_rect( + transform_rect_bbox(*transform, geometry_bounds(shape)), + stroke.width * 0.5, + )), + Draw::BlurredRoundedRect(draw) => Some(expand_rect( + transform_rect_bbox(draw.transform, draw.rect), + draw.std_dev * 3.0, + )), + Draw::ScenePicture { transform, picture } => { + Some(transform_rect_bbox(*transform, picture.bounds())) + } + Draw::GlyphRun(run) => glyph_run_bounds(run), + } +} + +fn glyph_run_bounds(run: &imaging::record::GlyphRun) -> Option { + glyph_run_glyph_bounds(run.font_size, &run.glyphs) + .map(|bounds| transform_rect_bbox(run.transform, bounds)) +} + +fn glyph_run_glyph_bounds(font_size: f32, glyphs: &[ImagingGlyph]) -> Option { + if glyphs.is_empty() { + return None; + } + + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + let advance_pad = f64::from(font_size) * 0.75; + let ascent = f64::from(font_size); + let descent = f64::from(font_size) * 0.25; + + for glyph in glyphs { + let x = f64::from(glyph.x); + let y = f64::from(glyph.y); + min_x = min_x.min(x); + min_y = min_y.min(y - ascent); + max_x = max_x.max(x + advance_pad); + max_y = max_y.max(y + descent); + } + + Some(Rect::new(min_x, min_y, max_x, max_y)) +} + +fn geometry_bounds(geometry: &Geometry) -> Rect { + match geometry { + Geometry::Rect(rect) => *rect, + Geometry::RoundedRect(rect) => rect.rect(), + Geometry::Path(path) => path.bounding_box(), + } +} + +fn expand_rect(rect: Rect, amount: f64) -> Rect { + Rect::new( + rect.x0 - amount, + rect.y0 - amount, + rect.x1 + amount, + rect.y1 + amount, + ) +} + +fn union_rects(a: Rect, b: Rect) -> Rect { + Rect::new( + a.x0.min(b.x0), + a.y0.min(b.y0), + a.x1.max(b.x1), + a.y1.max(b.y1), + ) +} + +fn append_scene_range_transformed( + source: &Scene, + dest: &mut Scene, + start: usize, + end: usize, + transform: Affine, +) { + if start >= end { + return; + } + + let mut active_commands = Vec::new(); + for command in &source.commands()[..start] { + match command { + Command::PushContext(_) | Command::PushClip(_) | Command::PushGroup(_) => { + active_commands.push(command); + } + Command::PopContext | Command::PopClip | Command::PopGroup => { + let _ = active_commands.pop(); + } + Command::Draw(_) => {} + } + } + dest.reserve_additional( + active_commands.len() + end.saturating_sub(start) + active_commands.len(), + 0, + 0, + 0, + active_commands.len(), + active_commands.len(), + end.saturating_sub(start), + active_commands.len(), + ); + for command in &active_commands { + append_scene_command_transformed(source, dest, command, transform); + } + let mut open_commands = active_commands; + for command in &source.commands()[start..end] { + append_scene_command_transformed(source, dest, command, transform); + match command { + Command::PushContext(_) | Command::PushClip(_) | Command::PushGroup(_) => { + open_commands.push(command); + } + Command::PopContext | Command::PopClip | Command::PopGroup => { + let _ = open_commands.pop(); + } + Command::Draw(_) => {} + } + } + for command in open_commands.iter().rev() { + match command { + Command::PushContext(_) => dest.pop_context(), + Command::PushClip(_) => dest.pop_clip(), + Command::PushGroup(_) => dest.pop_group(), + Command::PopContext | Command::PopClip | Command::PopGroup | Command::Draw(_) => {} + } + } +} + +fn append_scene_command_transformed( + source: &Scene, + dest: &mut Scene, + command: &Command, + transform: Affine, +) { + match *command { + Command::PushContext(id) => { + let context = source.context(id); + dest.push_context(context.as_ref(source).label, context.as_ref(source).source); + } + Command::PopContext => dest.pop_context(), + Command::PushClip(id) => { + let mut clip = source.clip(id).clone(); + prepend_clip_transform(&mut clip, transform); + dest.push_clip(clip); + } + Command::PopClip => dest.pop_clip(), + Command::PushGroup(id) => { + let mut group = source.group(id).clone(); + prepend_group_transform(source, dest, &mut group, transform); + dest.push_group(group); + } + Command::PopGroup => dest.pop_group(), + Command::Draw(id) => { + let mut draw = source.draw_op(id).clone(); + prepend_draw_transform(&mut draw, transform); + dest.draw(draw); + } + } +} + +fn prepend_clip_transform(clip: &mut Clip, prefix: Affine) { + match clip { + Clip::Fill { transform, .. } | Clip::Stroke { transform, .. } => { + *transform = prefix * *transform; + } + } +} + +fn prepend_group_transform( + source: &Scene, + dest: &mut Scene, + group: &mut imaging::record::Group, + prefix: Affine, +) { + if let Some(clip) = &mut group.clip { + prepend_clip_transform(clip, prefix); + } + if let Some(mask) = &mut group.mask { + let source_mask = source.mask(mask.mask); + mask.mask = dest.define_mask(source_mask.clone()); + mask.transform = prefix * mask.transform; + } +} + +fn prepend_draw_transform(draw: &mut Draw, prefix: Affine) { + match draw { + Draw::Fill { transform, .. } | Draw::Stroke { transform, .. } => { + *transform = prefix * *transform; + } + Draw::GlyphRun(run) => { + run.transform = prefix * run.transform; + } + Draw::BlurredRoundedRect(draw) => { + draw.transform = prefix * draw.transform; + } + Draw::ScenePicture { transform, .. } => { + *transform = prefix * *transform; + } + } +} + +#[doc(hidden)] +pub mod bench_support { + use super::*; + use crate::ViewId; + use imaging::{PaintSink, record::replay}; + use peniko::Color; + use std::collections::VecDeque; + + #[derive(Clone, Copy, Debug)] + pub enum TreeShape { + Deep, + Broad, + } + + #[derive(Clone, Copy, Debug)] + pub enum InvalidationDepth { + Shallow, + Deep, + } + + pub struct SyntheticDisplayList { + list: RetainedDisplayList, + root_slot: DisplayNodeSlot, + shallow_id: ElementId, + deep_id: ElementId, + scene_commands: usize, + mutation_epoch: u32, + } + + impl SyntheticDisplayList { + pub fn new(node_count: usize, scene_commands: usize, shape: TreeShape) -> Self { + assert!(node_count >= 1); + let mut list = RetainedDisplayList::default(); + let (root_slot, root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY), + make_scene(scene_commands, 0), + ); + + let mut shallow_id = root_id; + let mut deep_id = root_id; + + match shape { + TreeShape::Deep => { + let mut parent = root_slot; + for idx in 1..node_count { + let offset = idx as f64; + let (slot, element_id) = attach_node( + &mut list, + Some(parent), + snapshot(Affine::translate((offset, offset * 0.25))), + make_scene(scene_commands, idx as u32), + ); + if idx == 1 { + shallow_id = element_id; + } + deep_id = element_id; + parent = slot; + } + } + TreeShape::Broad => { + let mut frontier = VecDeque::from([root_slot]); + let mut parent = root_slot; + let mut remaining_at_parent = 8usize; + for idx in 1..node_count { + if remaining_at_parent == 0 { + parent = frontier.pop_front().expect("broad tree parent"); + remaining_at_parent = 8; + } + let offset = idx as f64; + let (slot, element_id) = attach_node( + &mut list, + Some(parent), + snapshot(Affine::translate((offset * 0.5, offset * 0.125))), + make_scene(scene_commands, idx as u32), + ); + if idx == 1 { + shallow_id = element_id; + } + deep_id = element_id; + frontier.push_back(slot); + remaining_at_parent -= 1; + } + } + } + + finalize_subtree_sizes(&mut list, root_slot); + + Self { + list, + root_slot, + shallow_id, + deep_id, + scene_commands, + mutation_epoch: node_count as u32, + } + } + + pub fn compose_root(&mut self) { + self.list.ensure_composed_scene(self.root_slot); + } + + pub fn invalidate(&mut self, depth: InvalidationDepth) { + let element_id = match depth { + InvalidationDepth::Shallow => self.shallow_id, + InvalidationDepth::Deep => self.deep_id, + }; + let slot = self.list.find_slot(element_id).expect("bench node"); + self.mutation_epoch = self.mutation_epoch.wrapping_add(1); + let node = self.list.node_mut(slot).expect("bench node"); + node.display.paint.set_scene( + make_scene(self.scene_commands, self.mutation_epoch), + Vec::new(), + ); + self.list.mark_composed_dirty(element_id); + } + + pub fn mark_dirty(&mut self, depth: InvalidationDepth) { + let element_id = match depth { + InvalidationDepth::Shallow => self.shallow_id, + InvalidationDepth::Deep => self.deep_id, + }; + self.list.mark_composed_dirty(element_id); + } + + pub fn replay_composed(&mut self, sink: &mut S) { + self.list.ensure_composed_scene(self.root_slot); + if let Some(scene) = self.list.composed_scene(self.root_slot) { + replay(scene, sink); + } + } + } + + fn attach_node( + list: &mut RetainedDisplayList, + parent: Option, + snapshot: ElementSnapshot, + paint_scene: Scene, + ) -> (DisplayNodeSlot, ElementId) { + let element_id = ViewId::new().get_element_id(); + let slot = list.alloc_slot(element_id); + { + let node = list.node_mut(slot).expect("bench node"); + node.parent = parent; + node.display.snapshot = Some(snapshot); + node.display.paint.set_scene(paint_scene, Vec::new()); + node.display.post.set_scene(Scene::new(), Vec::new()); + node.composed_dirty = true; + } + list.slot_by_id.insert(element_id, slot); + if let Some(parent) = parent { + list.node_mut(parent) + .expect("bench parent") + .children + .ordered + .push(slot); + } else { + list.roots.push(slot); + } + (slot, element_id) + } + + fn finalize_subtree_sizes(list: &mut RetainedDisplayList, slot: DisplayNodeSlot) -> usize { + let child_slots = list.child_slots(slot).expect("bench children").to_vec(); + let subtree_size = 1 + child_slots + .into_iter() + .map(|child| finalize_subtree_sizes(list, child)) + .sum::(); + list.node_mut(slot).expect("bench node").subtree_size = subtree_size; + subtree_size + } + + fn snapshot(world_transform: Affine) -> ElementSnapshot { + ElementSnapshot { + local_bounds: Rect::new(0.0, 0.0, 100.0, 40.0), + clip: None, + world_transform, + wants_layer: false, + layer_frame_rate: None, + } + } + + fn make_scene(command_count: usize, seed: u32) -> Scene { + let mut scene = Scene::new(); + for idx in 0..command_count { + let offset = f64::from(seed) + idx as f64; + let rect = Rect::new(offset, offset * 0.5, offset + 10.0, offset * 0.5 + 8.0); + let color = if idx % 2 == 0 { + Color::from_rgb8(0x33, 0x66, 0x99) + } else { + Color::from_rgb8(0xaa, 0x55, 0x22) + }; + let draw = if idx % 3 == 0 { + Draw::Stroke { + transform: Affine::translate((offset * 0.25, offset * 0.1)), + stroke: peniko::kurbo::Stroke::new(1.0), + brush: ImagingBrush::Solid(color), + brush_transform: None, + shape: Geometry::Rect(rect), + composite: imaging::Composite::default(), + } + } else { + Draw::Fill { + transform: Affine::translate((offset * 0.25, offset * 0.1)), + fill_rule: peniko::Fill::NonZero, + brush: ImagingBrush::Solid(color), + brush_transform: None, + shape: Geometry::Rect(rect), + composite: imaging::Composite::default(), + } + }; + let _ = scene.draw(draw); + } + scene + } +} + +pub(crate) fn replay_scene( + scene: &Scene, + sink: &mut dyn PaintSink, + base_transform: Affine, + render_size: Size, +) { + let mut sink = SanitizingSink { + inner: sink, + render_size, + }; + replay_transformed(scene, &mut sink, base_transform); +} + +pub(crate) fn replay_view_clip( + sink: &mut dyn PaintSink, + clip: RoundedRect, + base_transform: Affine, + render_size: Size, +) { + let clip = constrain_infinite_rounded_rect(clip, base_transform, render_size); + PaintSink::push_clip(sink, ClipRef::fill(clip).with_transform(base_transform)); +} + +struct SanitizingSink<'a> { + inner: &'a mut dyn PaintSink, + render_size: Size, +} + +impl PaintSink for SanitizingSink<'_> { + fn push_clip(&mut self, clip: ClipRef<'_>) { + let clip = sanitize_clip_ref(clip, self.render_size); + self.inner.push_clip(clip.as_ref()); + } + + fn pop_clip(&mut self) { + self.inner.pop_clip(); + } + + fn push_group(&mut self, group: ImagingGroupRef<'_>) { + let clip = group + .clip + .map(|clip| sanitize_clip_ref(clip, self.render_size)); + let group = ImagingGroupRef { + clip: clip.as_ref().map(Clip::as_ref), + mask: group.mask.clone(), + filters: group.filters, + composite: group.composite, + }; + self.inner.push_group(group); + } + + fn pop_group(&mut self) { + self.inner.pop_group(); + } + + fn fill(&mut self, draw: FillRef<'_>) { + self.inner.fill(draw); + } + + fn stroke(&mut self, draw: StrokeRef<'_>) { + self.inner.stroke(draw); + } + + fn glyph_run(&mut self, draw: GlyphRunRef<'_>, glyphs: &mut dyn Iterator) { + self.inner.glyph_run(draw, glyphs); + } + + fn blurred_rounded_rect(&mut self, draw: BlurredRoundedRect) { + self.inner.blurred_rounded_rect(draw); + } +} + +fn scene_transform_class(scene: &Scene) -> TransformClass { + scene + .commands() + .iter() + .map(|command| command_transform_class(scene, command)) + .fold(TransformClass::Exact, TransformClass::combine) +} + +fn command_transform_class(scene: &Scene, command: &Command) -> TransformClass { + match command { + Command::PushContext(_) | Command::PopContext => TransformClass::Exact, + Command::PushClip(_) | Command::PopClip | Command::PushGroup(_) | Command::PopGroup => { + TransformClass::Affine + } + Command::Draw(id) => match scene.draw_op(*id) { + Draw::Fill { .. } | Draw::Stroke { .. } => TransformClass::Affine, + Draw::GlyphRun(_) | Draw::BlurredRoundedRect(_) => TransformClass::TranslateOnly, + Draw::ScenePicture { .. } => TransformClass::Affine, + }, + } +} + +fn sanitize_clip_ref(clip: ClipRef<'_>, render_size: Size) -> Clip { + match clip { + ClipRef::Fill { + transform, + shape, + fill_rule, + } => Clip::Fill { + transform, + shape: sanitize_clip_geometry(shape, transform, render_size), + fill_rule, + }, + ClipRef::Stroke { + transform, + shape, + stroke, + } => Clip::Stroke { + transform, + shape: sanitize_clip_geometry(shape, transform, render_size), + stroke: stroke.clone(), + }, + } +} + +fn sanitize_clip_geometry( + shape: GeometryRef<'_>, + transform: Affine, + render_size: Size, +) -> Geometry { + match shape { + GeometryRef::Rect(rect) => { + Geometry::Rect(constrain_infinite_rect(rect, transform, render_size)) + } + GeometryRef::RoundedRect(rect) => Geometry::RoundedRect(constrain_infinite_rounded_rect( + rect, + transform, + render_size, + )), + GeometryRef::Path(path) => Geometry::Path(path.clone()), + GeometryRef::OwnedPath(path) => Geometry::Path(path), + } +} + +fn constrain_infinite_rounded_rect( + rect: RoundedRect, + transform: Affine, + render_size: Size, +) -> RoundedRect { + let constrained = constrain_infinite_rect(rect.rect(), transform, render_size); + RoundedRect::from_rect(constrained, rect.radii()) +} + +pub(crate) fn constrain_rect_to_render_bounds( + rect: Rect, + transform: Affine, + render_size: Size, +) -> Rect { + if rect.x0.is_finite() && rect.x1.is_finite() && rect.y0.is_finite() && rect.y1.is_finite() { + return rect; + } + + let viewport = Rect::from_origin_size(Point::ZERO, render_size); + let inverse = transform.inverse(); + let local_viewport = inverse.transform_rect_bbox(viewport); + + Rect::new( + if rect.x0.is_finite() { + rect.x0 + } else { + local_viewport.x0 + }, + if rect.y0.is_finite() { + rect.y0 + } else { + local_viewport.y0 + }, + if rect.x1.is_finite() { + rect.x1 + } else { + local_viewport.x1 + }, + if rect.y1.is_finite() { + rect.y1 + } else { + local_viewport.y1 + }, + ) +} + +fn constrain_infinite_rect(rect: Rect, transform: Affine, render_size: Size) -> Rect { + constrain_rect_to_render_bounds(rect, transform, render_size) +} + +#[cfg(test)] +#[allow(clippy::items_after_test_module)] +mod tests { + use super::*; + use crate::ViewId; + use imaging::{Composite, Filter as ImagingFilter}; + use peniko::{Color, Fill}; + + fn fill_draw(rect: Rect, transform: Affine) -> Draw { + Draw::Fill { + transform, + fill_rule: Fill::NonZero, + brush: Color::BLACK.into(), + brush_transform: None, + shape: Geometry::Rect(rect), + composite: Composite::default(), + } + } + + fn fill_draw_with_color(rect: Rect, transform: Affine, color: Color) -> Draw { + Draw::Fill { + transform, + fill_rule: Fill::NonZero, + brush: ImagingBrush::Solid(color), + brush_transform: None, + shape: Geometry::Rect(rect), + composite: Composite::default(), + } + } + + fn external_image_fill_draw( + surface_id: crate::compositor_surface::CompositorSurfaceId, + rect: Rect, + effective_scale: f64, + ) -> Draw { + let width = (rect.width() * effective_scale).ceil().max(1.0) as u32; + let height = (rect.height() * effective_scale).ceil().max(1.0) as u32; + let image = imaging::ExternalImage::new( + imaging::ExternalImageId(surface_id.get()), + width, + height, + peniko::ImageAlphaType::AlphaPremultiplied, + ); + Draw::Fill { + transform: Affine::IDENTITY, + fill_rule: Fill::NonZero, + brush: ImagingBrush::Image(imaging::ImageBrush::from(image)), + brush_transform: None, + shape: Geometry::Rect(rect), + composite: Composite::default(), + } + } + + fn external_image_fill_draw_with_transform( + surface_id: crate::compositor_surface::CompositorSurfaceId, + rect: Rect, + effective_scale: f64, + transform: Affine, + ) -> Draw { + let mut draw = external_image_fill_draw(surface_id, rect, effective_scale); + if let Draw::Fill { + transform: draw_transform, + .. + } = &mut draw + { + *draw_transform = transform; + } + draw + } + + fn scene_with_draw(draw: Draw) -> Scene { + let mut scene = Scene::new(); + let _ = scene.draw(draw); + scene + } + + #[test] + fn element_stage_revision_tracks_semantic_content_changes() { + let mut stage = ElementStage::default(); + let scene = scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)); + + stage.set_scene(scene.clone(), Vec::new()); + let first_revision = stage.content_revision; + assert_ne!(first_revision, 0); + + stage.set_scene(scene, Vec::new()); + assert_eq!(stage.content_revision, first_revision); + + stage.set_scene( + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 12.0, 10.0), Affine::IDENTITY)), + Vec::new(), + ); + assert_ne!(stage.content_revision, first_revision); + } + + #[test] + fn stage_recorder_preserves_order_for_mixed_imaging_and_color_filter_filters() { + let mut stage = ElementStage::default(); + let mut recorder = StageRecorder::from_stage_for_test(&mut stage); + let effect = crate::effects::ColorFilter::wgsl("return color;"); + let filters = [ + Filter::Imaging(ImagingFilter::Blur { + std_deviation_x: 2.0, + std_deviation_y: 2.0, + }), + Filter::Color(effect.clone()), + Filter::Imaging(ImagingFilter::Blur { + std_deviation_x: 5.0, + std_deviation_y: 5.0, + }), + ]; + + recorder.push_effect_group(crate::effects::group_ref().with_filters(&filters)); + recorder.fill(FillRef::new( + GeometryRef::Rect(Rect::new(0.0, 0.0, 10.0, 10.0)), + Color::BLACK, + )); + recorder.pop_effect_group(); + recorder.finish(&mut stage); + + assert!(matches!( + stage.scene.commands(), + [Command::PushGroup(_), Command::Draw(_), Command::PopGroup] + )); + + let Command::PushGroup(final_group) = stage.scene.commands()[0] else { + panic!("expected final property group"); + }; + assert!(stage.scene.group(final_group).filters.is_empty()); + + assert_eq!(stage.color_filters.len(), 6); + assert_eq!( + stage + .color_filters + .iter() + .map(|command| command.command_index) + .collect::>(), + vec![1, 1, 1, 2, 2, 2] + ); + assert!(matches!( + &stage.color_filters[0].kind, + ShaderCommandKind::Push(CompositorShaderPass { + shader: CompositorShader::Layer(_), + .. + }) + )); + assert_eq!( + stage.color_filters[1], + ShaderCommand { + command_index: 1, + kind: ShaderCommandKind::Push(CompositorShaderPass { + shader: CompositorShader::Color(effect.clone()), + clip: None, + position_transform: Affine::IDENTITY, + }), + } + ); + assert!(matches!( + &stage.color_filters[2].kind, + ShaderCommandKind::Push(CompositorShaderPass { + shader: CompositorShader::Layer(_), + .. + }) + )); + assert!( + stage.color_filters[3..] + .iter() + .all(|command| matches!(command.kind, ShaderCommandKind::Pop)) + ); + } + + #[test] + fn shader_source_fill_records_placeholder_draw_inside_shader_source() { + let mut stage = ElementStage::default(); + let mut recorder = StageRecorder::from_stage_for_test(&mut stage); + let effect = crate::effects::ShaderSource::wgsl("return vec4(uv, 0.0, 1.0);"); + + recorder.shader_source_fill( + Affine::IDENTITY, + Fill::NonZero, + None, + GeometryRef::RoundedRect(Rect::new(1.0, 2.0, 11.0, 22.0).to_rounded_rect(4.0)), + imaging::Composite::default(), + effect.clone(), + ); + recorder.finish(&mut stage); + + assert!(matches!( + stage.scene.commands(), + [Command::PushGroup(_), Command::Draw(_), Command::PopGroup] + )); + assert_eq!(stage.color_filters.len(), 2); + assert!(matches!( + &stage.color_filters[0].kind, + ShaderCommandKind::Push(CompositorShaderPass { + shader: CompositorShader::Source(recorded), + .. + }) if recorded == &effect + )); + assert!(matches!( + stage.color_filters[1].kind, + ShaderCommandKind::Pop + )); + } + + fn scene_with_clip_and_draw(clip: RoundedRect, clip_transform: Affine, draw: Draw) -> Scene { + let mut scene = Scene::new(); + let _ = scene.push_clip(Clip::Fill { + transform: clip_transform, + shape: Geometry::RoundedRect(clip), + fill_rule: Fill::NonZero, + }); + let _ = scene.draw(draw); + scene.pop_clip(); + scene + } + + fn snapshot(world_transform: Affine, clip: Option) -> ElementSnapshot { + ElementSnapshot { + local_bounds: Rect::new(0.0, 0.0, 100.0, 100.0), + clip, + world_transform, + wants_layer: false, + layer_frame_rate: None, + } + } + + fn attach_node( + list: &mut RetainedDisplayList, + parent: Option, + snapshot: ElementSnapshot, + paint_scene: Scene, + post_scene: Scene, + ) -> (DisplayNodeSlot, ElementId) { + let element_id = ViewId::new().get_element_id(); + let slot = list.alloc_slot(element_id); + { + let node = list.node_mut(slot).expect("node"); + node.parent = parent; + node.display.snapshot = Some(snapshot); + node.display.paint.set_scene(paint_scene, Vec::new()); + node.display.post.set_scene(post_scene, Vec::new()); + node.composed_dirty = true; + } + list.slot_by_id.insert(element_id, slot); + if let Some(parent) = parent { + list.node_mut(parent) + .expect("parent") + .children + .ordered + .push(slot); + } else { + list.roots.push(slot); + } + (slot, element_id) + } + + fn finalize_subtree_sizes(list: &mut RetainedDisplayList, slot: DisplayNodeSlot) -> usize { + let children = list.child_slots(slot).expect("children").to_vec(); + let subtree_size = 1 + children + .into_iter() + .map(|child| finalize_subtree_sizes(list, child)) + .sum::(); + let node = list.node_mut(slot).expect("node"); + node.subtree_size = subtree_size; + subtree_size + } + + fn make_cached_root_with_fillers( + root_snapshot: ElementSnapshot, + root_paint: Scene, + root_post: Scene, + filler_count: usize, + ) -> (RetainedDisplayList, DisplayNodeSlot, ElementId) { + let mut list = RetainedDisplayList::default(); + let (root_slot, root_id) = + attach_node(&mut list, None, root_snapshot, root_paint, root_post); + for i in 0..filler_count { + let filler_transform = Affine::translate((100.0 + i as f64, 200.0 + i as f64)); + let filler_snapshot = snapshot(filler_transform, None); + let _ = attach_node( + &mut list, + Some(root_slot), + filler_snapshot, + Scene::new(), + Scene::new(), + ); + } + finalize_subtree_sizes(&mut list, root_slot); + (list, root_slot, root_id) + } + + #[test] + fn stage_stores_scene_directly() { + let rect = Rect::new(0.0, 0.0, 10.0, 10.0); + let mut stage = ElementStage::default(); + let mut scene = Scene::new(); + let _ = scene.draw(fill_draw(rect, Affine::IDENTITY)); + let _ = scene.draw(Draw::Stroke { + transform: Affine::IDENTITY, + stroke: peniko::kurbo::Stroke::new(1.0), + brush: Color::BLACK.into(), + brush_transform: None, + shape: Geometry::Rect(rect), + composite: Composite::default(), + }); + stage.set_scene(scene, Vec::new()); + + assert_eq!(stage.scene.commands().len(), 2); + assert_eq!(stage.transform_class, TransformClass::Affine); + } + + #[test] + fn clip_commands_are_preserved_in_scene() { + let rect = Rect::new(0.0, 0.0, 10.0, 10.0); + let mut stage = ElementStage::default(); + let mut scene = Scene::new(); + let _ = scene.push_clip(Clip::Fill { + transform: Affine::IDENTITY, + shape: Geometry::Rect(rect), + fill_rule: Fill::NonZero, + }); + let _ = scene.draw(fill_draw(rect, Affine::IDENTITY)); + scene.pop_clip(); + stage.set_scene(scene, Vec::new()); + + assert_eq!(stage.scene.commands().len(), 3); + } + + #[test] + fn transformed_glyph_or_blur_downgrades_retention() { + let mut stage = ElementStage::default(); + let mut scene = Scene::new(); + let _ = scene.draw(Draw::BlurredRoundedRect(imaging::BlurredRoundedRect { + transform: Affine::IDENTITY, + rect: Rect::new(0.0, 0.0, 10.0, 10.0), + color: Color::BLACK, + radius: 4.0, + std_dev: 6.0, + composite: Composite::default(), + })); + stage.set_scene(scene, Vec::new()); + + assert_eq!(stage.transform_class, TransformClass::TranslateOnly); + } + + #[test] + fn lower_composition_plan_promotes_simple_external_image_fill() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(7); + let mut list = RetainedDisplayList::default(); + let rect = Rect::new(4.0, 5.0, 24.0, 25.0); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::translate((10.0, 20.0)), None), + scene_with_draw(external_image_fill_draw(surface_id, rect, 2.0)), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(2.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::CompositorSurface(layer) = &plan.items[0] else { + panic!("expected promoted external image fill"); + }; + assert_eq!(layer.surface_id, surface_id); + assert_eq!(layer.rect, rect); + assert_eq!(layer.source_size, rect.size()); + assert_eq!(layer.transform, Affine::translate((10.0, 20.0))); + assert_eq!( + layer.key, + CompositionKey::CompositorSurface { + surface_id, + occurrence: 0, + } + ); + } + + #[test] + fn lower_composition_plan_promotes_translated_external_image_fill() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(71); + let mut list = RetainedDisplayList::default(); + let rect = Rect::new(0.0, 0.0, 40.0, 30.0); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + scene_with_draw(external_image_fill_draw_with_transform( + surface_id, + rect, + 1.0, + Affine::translate((12.0, 18.0)), + )), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::CompositorSurface(layer) = &plan.items[0] else { + panic!("expected translated external image fill to promote"); + }; + assert_eq!(layer.surface_id, surface_id); + assert_eq!(layer.rect, Rect::new(12.0, 18.0, 52.0, 48.0)); + } + + #[test] + fn lower_composition_plan_does_not_promote_scaled_external_image_fill() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(72); + let mut list = RetainedDisplayList::default(); + let rect = Rect::new(0.0, 0.0, 40.0, 30.0); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + scene_with_draw(external_image_fill_draw_with_transform( + surface_id, + rect, + 1.0, + Affine::scale_non_uniform(0.5, 2.0), + )), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected scaled external image fill to remain in scene"); + }; + assert_eq!(layer.external_images.len(), 1); + } + + #[test] + fn lower_composition_plan_keeps_repeated_external_image_fills_as_distinct_layers() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(9); + let mut scene = Scene::new(); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(0.0, 0.0, 10.0, 10.0), + 1.0, + )); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(20.0, 20.0, 30.0, 30.0), + 1.0, + )); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + scene, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + let placements = plan + .items + .iter() + .filter_map(|item| match item { + CompositionItem::CompositorSurface(layer) => Some(layer), + CompositionItem::Scene(_) => None, + }) + .collect::>(); + assert_eq!(placements.len(), 2); + assert_eq!( + placements[0].key, + CompositionKey::CompositorSurface { + surface_id, + occurrence: 0 + } + ); + assert_eq!( + placements[1].key, + CompositionKey::CompositorSurface { + surface_id, + occurrence: 1 + } + ); + } + + #[test] + fn lower_composition_plan_coalesces_adjacent_scene_layers_across_elements() { + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)), + Scene::new(), + ); + let (_label_slot, _label_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((20.0, 0.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 8.0, 8.0), Affine::IDENTITY)), + Scene::new(), + ); + let (_canvas_slot, _canvas_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((0.0, 20.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 12.0, 12.0), Affine::IDENTITY)), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected coalesced scene layer"); + }; + assert_eq!(layer.key, CompositionKey::SceneRun { run_index: 0 }); + assert_eq!(layer.scene.commands().len(), 3); + assert_eq!(layer.transform, Affine::IDENTITY); + assert_eq!(layer.bounds, Rect::new(0.0, 0.0, 120.0, 120.0)); + assert_eq!(layer.content_bounds, Some(Rect::new(0.0, 0.0, 28.0, 32.0))); + assert!(layer.external_images.is_empty()); + assert!(!layer.promoted); + } + + #[test] + fn lower_composition_plan_revision_changes_for_replaced_element_with_same_stage_revision() { + fn revision_for_new_child() -> u64 { + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + Scene::new(), + Scene::new(), + ); + let (_child_slot, _child_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((8.0, 9.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 20.0, 10.0), Affine::IDENTITY)), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected scene run"); + }; + layer.content_revision + } + + let first = revision_for_new_child(); + let second = revision_for_new_child(); + + assert_ne!(first, second); + } + + #[test] + fn lower_composition_plan_revision_changes_when_retained_clip_changes() { + let first_clip = RoundedRect::from_rect(Rect::new(0.0, 0.0, 50.0, 50.0), 0.0); + let second_clip = RoundedRect::from_rect(Rect::new(0.0, 0.0, 60.0, 50.0), 0.0); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, Some(first_clip)), + Scene::new(), + Scene::new(), + ); + let (_child_slot, _child_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::IDENTITY, None), + scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 200.0, 100.0), + Affine::IDENTITY, + )), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let first = list.lower_composition_plan_for_test(1.0); + let first_revision = match &first.items[0] { + CompositionItem::Scene(layer) => { + assert_eq!(layer.bounds, Rect::new(0.0, 0.0, 50.0, 50.0)); + layer.content_revision + } + CompositionItem::CompositorSurface(_) => panic!("expected scene layer"), + }; + + let mut updated = snapshot(Affine::IDENTITY, Some(second_clip)); + updated.local_bounds = Rect::new(0.0, 0.0, 100.0, 100.0); + list.node_mut(root_slot) + .expect("root node") + .display + .snapshot = Some(updated); + + let second = list.lower_composition_plan_for_test(1.0); + let second_revision = match &second.items[0] { + CompositionItem::Scene(layer) => { + assert_eq!(layer.bounds, Rect::new(0.0, 0.0, 60.0, 50.0)); + layer.content_revision + } + CompositionItem::CompositorSurface(_) => panic!("expected scene layer"), + }; + + assert_ne!(first_revision, second_revision); + } + + #[test] + fn lower_composition_plan_tightens_layer_bounds_to_active_clip() { + let clip = RoundedRect::from_rect(Rect::new(20.0, 10.0, 80.0, 50.0), 0.0); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, Some(clip)), + Scene::new(), + Scene::new(), + ); + let (_child_slot, _child_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::IDENTITY, None), + scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 200.0, 100.0), + Affine::IDENTITY, + )), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected scene layer"); + }; + assert_eq!(layer.bounds, Rect::new(0.0, 0.0, 60.0, 40.0)); + assert_eq!(layer.transform, Affine::translate((20.0, 10.0))); + assert_eq!(layer.content_bounds, Some(Rect::new(0.0, 0.0, 60.0, 40.0))); + } + + #[test] + fn lower_composition_plan_coalesces_paint_children_and_post_into_one_scene_run() { + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)), + scene_with_draw(fill_draw( + Rect::new(40.0, 0.0, 50.0, 10.0), + Affine::IDENTITY, + )), + ); + let (_child_slot, _child_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((20.0, 0.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected one scene run"); + }; + assert_eq!(layer.key, CompositionKey::SceneRun { run_index: 0 }); + assert_eq!(layer.scene.commands().len(), 3); + assert_eq!(layer.transform, Affine::IDENTITY); + assert_eq!(layer.bounds, Rect::new(0.0, 0.0, 120.0, 100.0)); + assert_eq!(layer.content_bounds, Some(Rect::new(0.0, 0.0, 50.0, 10.0))); + assert!(layer.external_images.is_empty()); + } + + #[test] + fn lower_composition_plan_scene_run_target_is_local_to_content_bounds() { + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::translate((40.0, 30.0)), None), + scene_with_draw(fill_draw( + Rect::new(10.0, 20.0, 30.0, 50.0), + Affine::IDENTITY, + )), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected one scene run"); + }; + assert_eq!(layer.transform, Affine::translate((40.0, 30.0))); + assert_eq!(layer.bounds, Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_eq!( + layer.content_bounds, + Some(Rect::new(10.0, 20.0, 30.0, 50.0)) + ); + assert_eq!( + scene_command_range_bounds(&layer.scene, 0, layer.scene.commands().len()), + Some(Rect::new(10.0, 20.0, 30.0, 50.0)) + ); + } + + #[test] + fn lower_composition_plan_replays_snapshot_clip_inside_scene_run() { + let clip = RoundedRect::from_rect(Rect::new(5.0, 6.0, 25.0, 26.0), 3.0); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::translate((10.0, 20.0)), Some(clip)), + scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 100.0, 100.0), + Affine::IDENTITY, + )), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected one scene run"); + }; + assert_eq!(layer.clip, None); + assert!(matches!( + layer.scene.commands(), + [Command::PushClip(_), Command::Draw(_), Command::PopClip] + )); + let Command::PushClip(clip_id) = layer.scene.commands()[0] else { + panic!("expected clip"); + }; + let Clip::Fill { + transform, shape, .. + } = layer.scene.clip(clip_id) + else { + panic!("expected fill clip"); + }; + assert_eq!(*transform, Affine::translate((-5.0, -6.0))); + assert_eq!(*shape, Geometry::RoundedRect(clip)); + } + + #[test] + fn lower_composition_plan_sanitizes_infinite_snapshot_clip_inside_scene_run() { + let clip = + RoundedRect::from_rect(Rect::new(f64::NEG_INFINITY, 6.0, f64::INFINITY, 26.0), 3.0); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::translate((10.0, 20.0)), Some(clip)), + scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 100.0, 100.0), + Affine::IDENTITY, + )), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected one scene run"); + }; + assert_eq!(layer.clip, None); + let Command::PushClip(clip_id) = layer.scene.commands()[0] else { + panic!("expected clip"); + }; + let Clip::Fill { + transform, shape, .. + } = layer.scene.clip(clip_id) + else { + panic!("expected fill clip"); + }; + assert_eq!(*transform, Affine::translate((0.0, -6.0))); + assert_eq!( + *shape, + Geometry::RoundedRect(RoundedRect::from_rect( + Rect::new(0.0, 6.0, 100.0, 26.0), + 3.0 + )) + ); + } + + #[test] + fn lower_composition_plan_applies_sanitized_ancestor_scroll_clip_to_child_scene_run() { + let clip = + RoundedRect::from_rect(Rect::new(f64::NEG_INFINITY, 6.0, f64::INFINITY, 26.0), 3.0); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::translate((10.0, 20.0)), Some(clip)), + Scene::new(), + Scene::new(), + ); + let (_child_slot, _child_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((15.0, 30.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 20.0, 20.0), Affine::IDENTITY)), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected one scene run"); + }; + assert_eq!(layer.clip, None); + assert!(matches!( + layer.scene.commands(), + [Command::PushClip(_), Command::Draw(_), Command::PopClip] + )); + let Command::PushClip(clip_id) = layer.scene.commands()[0] else { + panic!("expected ancestor clip"); + }; + let Clip::Fill { + transform, shape, .. + } = layer.scene.clip(clip_id) + else { + panic!("expected fill clip"); + }; + assert_eq!(*transform, Affine::translate((-5.0, -10.0))); + assert_eq!( + *shape, + Geometry::RoundedRect(RoundedRect::from_rect( + Rect::new(0.0, 6.0, 100.0, 26.0), + 3.0 + )) + ); + } + + #[test] + fn lower_composition_plan_coalesces_sibling_chunks_with_same_property_state() { + let clip = RoundedRect::from_rect(Rect::new(0.0, 0.0, 100.0, 100.0), 4.0); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, Some(clip)), + Scene::new(), + Scene::new(), + ); + let (_first_slot, _first_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((10.0, 10.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)), + Scene::new(), + ); + let (_second_slot, _second_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((30.0, 10.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected one scene run"); + }; + assert!(matches!( + layer.scene.commands(), + [ + Command::PushClip(_), + Command::Draw(_), + Command::Draw(_), + Command::PopClip + ] + )); + } + + #[test] + #[should_panic(expected = "cycle detected while lowering retained display list")] + fn lower_composition_plan_detects_display_tree_cycles() { + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)), + Scene::new(), + ); + let (child_slot, _child_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((5.0, 5.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)), + Scene::new(), + ); + list.node_mut(child_slot) + .expect("child") + .children + .ordered + .push(root_slot); + + let _ = list.lower_composition_plan_for_test(1.0); + } + + #[test] + fn lower_composition_plan_flattens_compositor_surface_under_ancestor_clip() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(19); + let clip = RoundedRect::from_rect(Rect::new(0.0, 0.0, 50.0, 50.0), 0.0); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, Some(clip)), + Scene::new(), + Scene::new(), + ); + let (_child_slot, _child_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((10.0, 10.0)), None), + { + let mut scene = Scene::new(); + let _ = scene.draw(fill_draw(Rect::new(0.0, 0.0, 20.0, 20.0), Affine::IDENTITY)); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(20.0, 0.0, 40.0, 20.0), + 1.0, + )); + scene + }, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected flattened scene run"); + }; + assert_eq!(layer.external_images.len(), 1); + assert_eq!(layer.external_images[0].surface_id, surface_id); + assert!(matches!( + layer.scene.commands(), + [ + Command::PushClip(_), + Command::Draw(_), + Command::Draw(_), + Command::PopClip + ] + )); + } + + #[test] + fn lower_composition_plan_coalesces_cube_like_scene_runs_around_compositor_surface() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(10); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 100.0, 40.0), + Affine::IDENTITY, + )), + Scene::new(), + ); + let (_label_slot, _label_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((0.0, 50.0)), None), + scene_with_draw(fill_draw(Rect::new(0.0, 0.0, 80.0, 20.0), Affine::IDENTITY)), + Scene::new(), + ); + let (_canvas_slot, _canvas_id) = attach_node( + &mut list, + Some(root_slot), + snapshot(Affine::translate((0.0, 100.0)), None), + { + let mut scene = Scene::new(); + let _ = scene.draw(fill_draw( + Rect::new(0.0, 0.0, 100.0, 100.0), + Affine::IDENTITY, + )); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(20.0, 20.0, 80.0, 80.0), + 1.0, + )); + let _ = scene.draw(fill_draw( + Rect::new(10.0, 10.0, 90.0, 90.0), + Affine::IDENTITY, + )); + scene + }, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 3); + let CompositionItem::Scene(before) = &plan.items[0] else { + panic!("expected scene before compositor surface"); + }; + let CompositionItem::CompositorSurface(external) = &plan.items[1] else { + panic!("expected compositor surface"); + }; + let CompositionItem::Scene(after) = &plan.items[2] else { + panic!("expected scene after compositor surface"); + }; + assert_eq!(before.key, CompositionKey::SceneRun { run_index: 0 }); + assert_eq!(after.key, CompositionKey::SceneRun { run_index: 1 }); + assert_eq!(external.clip, None); + + let scene_command_counts = plan + .items + .iter() + .filter_map(|item| match item { + CompositionItem::Scene(layer) => Some(layer.scene.commands().len()), + CompositionItem::CompositorSurface(_) => None, + }) + .collect::>(); + assert_eq!(scene_command_counts, vec![3, 1]); + } + + #[test] + fn lower_composition_plan_does_not_put_snapshot_clip_on_direct_external_layer() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(17); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::translate((10.0, 20.0)), None), + scene_with_draw(external_image_fill_draw( + surface_id, + Rect::new(4.0, 5.0, 24.0, 25.0), + 1.0, + )), + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::CompositorSurface(layer) = &plan.items[0] else { + panic!("expected direct compositor surface"); + }; + assert_eq!(layer.surface_id, surface_id); + assert_eq!(layer.transform, Affine::translate((10.0, 20.0))); + assert_eq!(layer.clip, None); + } + + #[test] + fn lower_composition_plan_flattens_group_spanning_compositor_surface() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(18); + let mut scene = Scene::new(); + let _ = scene.push_group(imaging::record::Group::default()); + let _ = scene.draw(fill_draw(Rect::new(0.0, 0.0, 20.0, 20.0), Affine::IDENTITY)); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(20.0, 0.0, 40.0, 20.0), + 1.0, + )); + let _ = scene.draw(fill_draw( + Rect::new(40.0, 0.0, 60.0, 20.0), + Affine::IDENTITY, + )); + scene.pop_group(); + + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + scene, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected flattened scene layer"); + }; + assert_eq!(layer.external_images.len(), 1); + assert_eq!(layer.external_images[0].surface_id, surface_id); + assert!(matches!( + layer.scene.commands(), + [ + Command::PushGroup(_), + Command::Draw(_), + Command::Draw(_), + Command::Draw(_), + Command::PopGroup, + ] + )); + let Command::Draw(external_draw_id) = layer.scene.commands()[2] else { + panic!("expected inserted external image draw"); + }; + let Draw::Fill { brush, .. } = layer.scene.draw_op(external_draw_id) else { + panic!("expected fill draw"); + }; + assert!( + matches!(brush, ImagingBrush::Image(image) if matches!(image.image, imaging::Image::External(_))) + ); + } + + #[test] + fn lower_composition_plan_keeps_each_compositor_surface_as_split_boundary() { + let first_surface = crate::compositor_surface::CompositorSurfaceId::test_new(14); + let second_surface = crate::compositor_surface::CompositorSurfaceId::test_new(15); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + { + let mut scene = Scene::new(); + let _ = scene.draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)); + let _ = scene.draw(external_image_fill_draw( + first_surface, + Rect::new(12.0, 0.0, 18.0, 10.0), + 1.0, + )); + let _ = scene.draw(fill_draw( + Rect::new(20.0, 0.0, 30.0, 10.0), + Affine::IDENTITY, + )); + let _ = scene.draw(external_image_fill_draw( + second_surface, + Rect::new(32.0, 0.0, 38.0, 10.0), + 1.0, + )); + let _ = scene.draw(fill_draw( + Rect::new(40.0, 0.0, 50.0, 10.0), + Affine::IDENTITY, + )); + scene + }, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 5); + assert!(matches!(plan.items[0], CompositionItem::Scene(_))); + assert!(matches!( + plan.items[1], + CompositionItem::CompositorSurface(_) + )); + assert!(matches!(plan.items[2], CompositionItem::Scene(_))); + assert!(matches!( + plan.items[3], + CompositionItem::CompositorSurface(_) + )); + assert!(matches!(plan.items[4], CompositionItem::Scene(_))); + } + + #[test] + fn lower_composition_plan_flattens_active_clip_spanning_compositor_surface() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(16); + let clip = RoundedRect::from_rect(Rect::new(0.0, 0.0, 50.0, 50.0), 0.0); + let mut scene = Scene::new(); + let _ = scene.push_clip(Clip::Fill { + transform: Affine::IDENTITY, + shape: Geometry::RoundedRect(clip), + fill_rule: Fill::NonZero, + }); + let _ = scene.draw(fill_draw(Rect::new(4.0, 4.0, 12.0, 12.0), Affine::IDENTITY)); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(14.0, 14.0, 30.0, 30.0), + 1.0, + )); + let _ = scene.draw(fill_draw( + Rect::new(20.0, 20.0, 28.0, 28.0), + Affine::IDENTITY, + )); + scene.pop_clip(); + + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::translate((10.0, 20.0)), None), + scene, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected flattened scene"); + }; + assert_eq!(layer.transform, Affine::translate((10.0, 20.0))); + assert_eq!(layer.clip, None); + assert_eq!(layer.external_images.len(), 1); + assert_eq!(layer.external_images[0].surface_id, surface_id); + let [ + Command::PushClip(clip_id), + Command::Draw(_), + Command::Draw(_), + Command::Draw(_), + Command::PopClip, + ] = layer.scene.commands() + else { + panic!("expected clipped flattened scene with inserted external image"); + }; + assert!(matches!(layer.scene.clip(*clip_id), Clip::Fill { .. })); + let Command::Draw(external_draw_id) = layer.scene.commands()[2] else { + panic!("expected inserted external image draw"); + }; + let Draw::Fill { brush, .. } = layer.scene.draw_op(external_draw_id) else { + panic!("expected fill draw"); + }; + assert!( + matches!(brush, ImagingBrush::Image(image) if matches!(image.image, imaging::Image::External(_))) + ); + } + + #[test] + fn lower_composition_plan_promotes_only_scene_chunks_overlapping_earlier_compositor_surface() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(11); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + { + let mut scene = Scene::new(); + let _ = scene.draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(20.0, 20.0, 40.0, 40.0), + 1.0, + )); + let _ = scene.draw(fill_draw( + Rect::new(100.0, 100.0, 110.0, 110.0), + Affine::IDENTITY, + )); + scene + }, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + let scene_promotions = plan + .items + .iter() + .filter_map(|item| match item { + CompositionItem::Scene(layer) => Some(layer.promoted), + CompositionItem::CompositorSurface(_) => None, + }) + .collect::>(); + assert_eq!(scene_promotions, vec![false, false]); + } + + #[test] + fn lower_composition_plan_promotes_scene_chunk_overlapping_earlier_compositor_surface() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(12); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, None), + { + let mut scene = Scene::new(); + let _ = scene.draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(20.0, 20.0, 40.0, 40.0), + 1.0, + )); + let _ = scene.draw(fill_draw( + Rect::new(25.0, 25.0, 35.0, 35.0), + Affine::IDENTITY, + )); + scene + }, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + let scene_promotions = plan + .items + .iter() + .filter_map(|item| match item { + CompositionItem::Scene(layer) => Some(layer.promoted), + CompositionItem::CompositorSurface(_) => None, + }) + .collect::>(); + assert_eq!(scene_promotions, vec![false, true]); + } + + #[test] + fn lower_composition_plan_flattens_snapshot_clip_spanning_compositor_surface() { + let surface_id = crate::compositor_surface::CompositorSurfaceId::test_new(13); + let clip = RoundedRect::from_rect(Rect::new(100.0, 100.0, 120.0, 120.0), 0.0); + let mut list = RetainedDisplayList::default(); + let (root_slot, _root_id) = attach_node( + &mut list, + None, + snapshot(Affine::IDENTITY, Some(clip)), + { + let mut scene = Scene::new(); + let _ = scene.draw(fill_draw(Rect::new(0.0, 0.0, 10.0, 10.0), Affine::IDENTITY)); + let _ = scene.draw(external_image_fill_draw( + surface_id, + Rect::new(20.0, 20.0, 40.0, 40.0), + 1.0, + )); + let _ = scene.draw(fill_draw( + Rect::new(25.0, 25.0, 35.0, 35.0), + Affine::IDENTITY, + )); + scene + }, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + let plan = list.lower_composition_plan_for_test(1.0); + assert_eq!(plan.items.len(), 1); + let CompositionItem::Scene(layer) = &plan.items[0] else { + panic!("expected flattened scene"); + }; + assert_eq!(layer.external_images.len(), 1); + assert_eq!(layer.external_images[0].surface_id, surface_id); + assert_eq!(layer.clip, None); + assert!(!layer.promoted); + } + + #[test] + fn infinite_clip_is_constrained_to_render_bounds() { + let constrained = constrain_infinite_rect( + Rect::new(f64::NEG_INFINITY, 10.0, f64::INFINITY, 20.0), + Affine::IDENTITY, + Size::new(200.0, 100.0), + ); + + assert_eq!(constrained.x0, 0.0); + assert_eq!(constrained.x1, 200.0); + assert_eq!(constrained.y0, 10.0); + assert_eq!(constrained.y1, 20.0); + } + + #[test] + fn composed_scene_flattens_transforms_and_clips() { + let root_clip = RoundedRect::from_rect(Rect::new(0.0, 0.0, 40.0, 30.0), 4.0); + let child_clip = RoundedRect::from_rect(Rect::new(1.0, 2.0, 9.0, 12.0), 2.0); + let root_snapshot = snapshot(Affine::translate((10.0, 20.0)), Some(root_clip)); + let root_paint = scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 5.0, 5.0), + Affine::translate((1.0, 2.0)), + )); + let (mut list, root_slot, _) = + make_cached_root_with_fillers(root_snapshot, root_paint, Scene::new(), 6); + + let child_snapshot = snapshot(Affine::translate((15.0, 27.0)), Some(child_clip)); + let child_scene = scene_with_clip_and_draw( + child_clip, + Affine::translate((0.5, 1.5)), + fill_draw(Rect::new(0.0, 0.0, 3.0, 4.0), Affine::translate((3.0, 4.0))), + ); + let _ = attach_node( + &mut list, + Some(root_slot), + child_snapshot, + child_scene, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + assert!(list.slot_has_composed_scene(root_slot)); + list.ensure_composed_scene(root_slot); + + let mut expected = Scene::new(); + let _ = expected.push_clip(Clip::Fill { + transform: Affine::IDENTITY, + shape: Geometry::RoundedRect(root_clip), + fill_rule: Fill::NonZero, + }); + let _ = expected.draw(fill_draw( + Rect::new(0.0, 0.0, 5.0, 5.0), + Affine::translate((1.0, 2.0)), + )); + let _ = expected.push_clip(Clip::Fill { + transform: Affine::translate((5.0, 7.0)), + shape: Geometry::RoundedRect(child_clip), + fill_rule: Fill::NonZero, + }); + let _ = expected.push_clip(Clip::Fill { + transform: Affine::translate((5.5, 8.5)), + shape: Geometry::RoundedRect(child_clip), + fill_rule: Fill::NonZero, + }); + let _ = expected.draw(fill_draw( + Rect::new(0.0, 0.0, 3.0, 4.0), + Affine::translate((8.0, 11.0)), + )); + expected.pop_clip(); + expected.pop_clip(); + expected.pop_clip(); + + assert_eq!(list.composed_scene(root_slot), Some(&expected)); + } + + #[test] + fn composed_scene_avoids_nested_cached_subtrees_and_keeps_correct_transform() { + let root_snapshot = snapshot(Affine::translate((50.0, 60.0)), None); + let root_paint = scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 2.0, 2.0), + Affine::translate((1.0, 1.0)), + )); + let (mut list, root_slot, _) = + make_cached_root_with_fillers(root_snapshot, root_paint, Scene::new(), 6); + + let child_snapshot = snapshot(Affine::translate((70.0, 90.0)), None); + let child_paint = scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 4.0, 4.0), + Affine::translate((2.0, 3.0)), + )); + let (child_slot, _) = attach_node( + &mut list, + Some(root_slot), + child_snapshot, + child_paint, + Scene::new(), + ); + for i in 0..7 { + let grandchild_snapshot = + snapshot(Affine::translate((75.0 + i as f64, 95.0 + i as f64)), None); + let grandchild_scene = if i == 0 { + scene_with_draw(fill_draw( + Rect::new(0.0, 0.0, 1.0, 1.0), + Affine::translate((4.0, 5.0)), + )) + } else { + Scene::new() + }; + let _ = attach_node( + &mut list, + Some(child_slot), + grandchild_snapshot, + grandchild_scene, + Scene::new(), + ); + } + finalize_subtree_sizes(&mut list, root_slot); + + assert!(list.slot_has_composed_scene(root_slot)); + assert!(!list.slot_has_composed_scene(child_slot)); + list.ensure_composed_scene(root_slot); + + let mut expected = Scene::new(); + let _ = expected.draw(fill_draw( + Rect::new(0.0, 0.0, 2.0, 2.0), + Affine::translate((1.0, 1.0)), + )); + let _ = expected.draw(fill_draw( + Rect::new(0.0, 0.0, 4.0, 4.0), + Affine::translate((22.0, 33.0)), + )); + let _ = expected.draw(fill_draw( + Rect::new(0.0, 0.0, 1.0, 1.0), + Affine::translate((29.0, 40.0)), + )); + + assert_eq!(list.composed_scene(root_slot), Some(&expected)); + } + + #[test] + fn composed_scene_rebuilds_when_child_stage_changes() { + let root_snapshot = snapshot(Affine::translate((10.0, 10.0)), None); + let (mut list, root_slot, _) = + make_cached_root_with_fillers(root_snapshot, Scene::new(), Scene::new(), 6); + + let child_snapshot = snapshot(Affine::translate((15.0, 17.0)), None); + let child_scene = scene_with_draw(fill_draw_with_color( + Rect::new(0.0, 0.0, 2.0, 2.0), + Affine::translate((1.0, 1.0)), + Color::BLACK, + )); + let (child_slot, child_id) = attach_node( + &mut list, + Some(root_slot), + child_snapshot, + child_scene, + Scene::new(), + ); + finalize_subtree_sizes(&mut list, root_slot); + + list.ensure_composed_scene(root_slot); + let first = list + .composed_scene(root_slot) + .cloned() + .expect("composed scene"); + + let updated_child_scene = scene_with_draw(fill_draw_with_color( + Rect::new(0.0, 0.0, 3.0, 3.0), + Affine::translate((2.0, 2.0)), + Color::WHITE, + )); + list.node_mut(child_slot) + .expect("child node") + .display + .paint + .set_scene(updated_child_scene, Vec::new()); + list.mark_composed_dirty(child_id); + list.ensure_composed_scene(root_slot); + + let mut expected = Scene::new(); + let _ = expected.draw(fill_draw_with_color( + Rect::new(0.0, 0.0, 3.0, 3.0), + Affine::translate((7.0, 9.0)), + Color::WHITE, + )); + + assert_ne!(list.composed_scene(root_slot), Some(&first)); + assert_eq!(list.composed_scene(root_slot), Some(&expected)); + } +} diff --git a/src/paint/mod.rs b/src/paint/mod.rs index d70ae9037..06b7cead1 100644 --- a/src/paint/mod.rs +++ b/src/paint/mod.rs @@ -3,21 +3,17 @@ //! This module contains the types used during the paint phase: //! - [`PaintCx`] - Context for painting views //! - [`PaintState`] - State for the renderer (pending or initialized) -//! - [`Renderer`] - Backend renderer abstraction pub mod border_path_iter; +pub(crate) mod composition; +pub mod display_list; pub mod renderer; +use crate::effects::{Composite, Filter}; +use crate::gpu_resources::{GpuResourceError, GpuResources}; pub use border_path_iter::{BorderPath, BorderPathEvent}; -pub use renderer::Renderer; - -use floem_renderer::Renderer as FloemRenderer; -use floem_renderer::gpu_resources::{GpuResourceError, GpuResources}; -use peniko::kurbo::{Affine, RoundedRect, Shape, Size}; -use std::ops::{Deref, DerefMut}; -use std::sync::Arc; -use understory_box_tree::NodeFlags; -use winit::window::Window; +use imaging::Painter; +use peniko::kurbo::{Affine, RoundedRect}; #[cfg(feature = "crossbeam")] use crossbeam::channel::Receiver; @@ -27,9 +23,10 @@ use std::sync::mpsc::Receiver; use crate::ElementId; use crate::style::FontSizeCx; use crate::view::ViewId; -use crate::view::stacking::{StackingContextItem, collect_stacking_context_items_into}; use crate::view::{paint_bg, paint_border, paint_outline}; use crate::window::state::WindowState; +use composition::clip_scene_layers_to_viewport; +use display_list::{ElementSnapshot, StageRecorder}; std::thread_local! { /// Holds the ID of a View being painted very briefly if it is being rendered as @@ -60,12 +57,15 @@ impl PaintOrderTracker { order: Vec::new(), } } +} - fn record(&mut self, id: ViewId) { - if self.enabled { - self.order.push(id); - } - } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct PaintStats { + pub active_ids: usize, + pub explicit_dirty_ids: usize, + pub reusable_descendants: usize, + pub rerecord_ids: usize, + pub replay_steps: usize, } /// Enable paint order tracking. When enabled, all painted ViewIds are recorded in order. @@ -101,23 +101,11 @@ pub fn is_paint_order_tracking_enabled() -> bool { PAINT_ORDER_TRACKER.with(|tracker| tracker.borrow().enabled) } -/// Record a view being painted (internal use). -#[inline] -fn record_paint(id: ViewId) { - PAINT_ORDER_TRACKER.with(|tracker| { - tracker.borrow_mut().record(id); - }); -} - /// Global paint context - holds shared state for entire paint pass /// Similar to GlobalEventCx in event dispatch pub struct GlobalPaintCx<'a> { pub window_state: &'a mut WindowState, - pub(crate) paint_state: &'a mut PaintState, pub gpu_resources: Option, - pub window: Arc, - /// Whether to record paint order for testing. Cached from thread-local at creation. - pub(crate) record_paint_order: bool, } /// Per-target paint context - created for each visual node @@ -125,8 +113,10 @@ pub struct GlobalPaintCx<'a> { pub struct PaintCx<'a> { /// Reference to global paint state pub window_state: &'a mut WindowState, - paint_state: &'a mut PaintState, - /// The target visual node being painted (CRITICAL for views with multiple visuals) + gpu_resources: Option<&'a GpuResources>, + pub painter: Painter<'a, StageRecorder, Filter, Composite, crate::effects::Brush>, + is_vger: bool, + /// The target visual node being painted pub target_id: ElementId, /// World transform for this visual node (from box tree) pub world_transform: Affine, @@ -135,201 +125,200 @@ pub struct PaintCx<'a> { /// Optional clip for this visual node (from box tree) pub clip: Option, pub font_size_cx: FontSizeCx, + pub font_embolden: peniko::kurbo::Vec2, + pub effective_scale: f64, } -pub(crate) enum PaintOrPost { - Paint(ElementId), - Post(ElementId), -} - -/// Collect VisualIds in paint order (depth-first, z-index sorted) without recursion. -pub(crate) fn collect_visual_order( - root_element_id: ElementId, - box_tree: &mut crate::BoxTree, - paint_order: &mut Vec, - is_drag_preview: bool, - skip_element_id: Option, -) { - enum TraversalStep { - Visit(ElementId), - Post(ElementId), - } - - // Local closure instead of nested fn - let should_paint = |element_id: ElementId, box_tree: &mut crate::BoxTree| { - if is_drag_preview { - return true; - } - - box_tree - .world_bounds(element_id.0) - .is_none_or(|bounds| bounds.area() != 0.0) - }; - - let mut stack = Vec::new(); - let mut stacking_scratch: Vec = Vec::new(); - stack.push(TraversalStep::Visit(root_element_id)); - - while let Some(step) = stack.pop() { - match step { - TraversalStep::Visit(element_id) => { - // Skip specific element and subtree when not drag preview - if !is_drag_preview && Some(element_id) == skip_element_id { - continue; - } - - // If hidden skip this element and the subtree - if box_tree - .flags(element_id.0) - .is_none_or(|f| !f.contains(NodeFlags::VISIBLE)) - { - continue; - } - - let paints_this_node = should_paint(element_id, box_tree); - if paints_this_node { - paint_order.push(PaintOrPost::Paint(element_id)); - // Keep Paint/Post paired for the same node. If a node is culled - // (e.g. zero world bounds), emitting Post without Paint can pop clip - // state that belongs to an ancestor and corrupt sibling paint order. - stack.push(TraversalStep::Post(element_id)); - } - - // Push children in reverse so they are visited in forward order. - collect_stacking_context_items_into(element_id, box_tree, &mut stacking_scratch); - for item in stacking_scratch.iter().rev() { - stack.push(TraversalStep::Visit(item.element_id)); - } - } - TraversalStep::Post(element_id) => paint_order.push(PaintOrPost::Post(element_id)), - } +impl<'a> PaintCx<'a> { + /// Returns the WGPU resources used by Floem's renderer for this window, if + /// the active renderer has a GPU context. + pub fn gpu_resources(&self) -> Option<&'a GpuResources> { + self.gpu_resources } } impl GlobalPaintCx<'_> { - /// Build explicit paint order for entire view tree. - /// - /// Returns a flat list of VisualIds in paint order (back-to-front, respecting z-index). - /// Filters out hidden views and views with zero-area bounds. - /// - /// # Arguments - /// * `root` - The root VisualId to start traversal from - /// * `box_tree` - The box tree for querying spatial information - /// - /// # Returns - /// Vector of VisualIds in paint order (back-to-front) - fn build_paint_order( - &self, - root: ElementId, - box_tree: &mut crate::BoxTree, - ) -> Vec { - let mut paint_order = Vec::new(); - + pub(crate) fn prepare_display_list(&mut self, root_id: ViewId) { + let root_element_id = root_id.get_element_id(); let dragging_element_id = self .window_state .drag_tracker .active_drag .as_ref() .and_then(|ad| ad.dragging_preview.as_ref().map(|p| p.element_id)); - // Collect main tree - collect_visual_order(root, box_tree, &mut paint_order, false, dragging_element_id); - - // Paint drag overlay separately (always on top) - if let Some(preview) = self - .window_state - .drag_tracker - .active_drag - .as_ref() - .and_then(|ad| ad.dragging_preview.as_ref().map(|p| p.element_id)) - { - crate::paint::collect_visual_order(preview, box_tree, &mut paint_order, true, None); + let sync = { + let box_tree = self.window_state.box_tree.borrow(); + self.window_state.display_list.sync_structure( + root_element_id, + &box_tree, + dragging_element_id, + ) + }; + let active_ids = sync.active_ids; + + let mut dirty_ids = self.window_state.take_dirty_paint_elements(); + let explicit_dirty_ids = dirty_ids.len(); + dirty_ids.extend(sync.newly_active_ids); + let snapshots = { + let box_tree = self.window_state.box_tree.borrow(); + active_ids + .iter() + .copied() + .map(|element_id| { + ( + element_id, + ElementSnapshot::from_box_tree(&box_tree, element_id), + ) + }) + .collect::>() + }; + let mut reused_snapshots = Vec::new(); + + for &(element_id, snapshot) in &snapshots { + if self + .window_state + .display_list + .needs_stage_rerecord(element_id, snapshot) + { + dirty_ids.insert(element_id); + } else { + reused_snapshots.push((element_id, snapshot)); + } + } + let rerecord_ids = dirty_ids.len(); + for (element_id, snapshot) in reused_snapshots { + if let Some(element) = self.window_state.display_list.element(element_id) + && element.snapshot != Some(snapshot) + { + // Retained commands can be reused across pure transform/clip changes, but the + // current snapshot still needs to track the element's latest geometry/transform. + self.window_state + .display_list + .element_mut(element_id) + .snapshot = Some(snapshot); + self.window_state + .display_list + .mark_composed_dirty(element_id); + } + } + for element_id in dirty_ids { + self.record_visual_node(element_id, false); + self.record_visual_node(element_id, true); } - paint_order + self.window_state.last_paint_stats = PaintStats { + active_ids: active_ids.len(), + explicit_dirty_ids, + reusable_descendants: 0, + rerecord_ids, + replay_steps: self.window_state.display_list.replay_step_count(), + }; + self.update_composition_plan(); } - /// Paint entire tree using explicit traversal - pub(crate) fn paint_with_traversal(&mut self, root_id: ViewId) { - let root_element_id = root_id.get_element_id(); - let mut box_tree = self.window_state.box_tree.borrow_mut(); - let paint_order = self.build_paint_order(root_element_id, &mut box_tree); - drop(box_tree); - - for id_or_pop in paint_order { - match id_or_pop { - PaintOrPost::Paint(element_id) => { - // Record for testing - if self.record_paint_order { - record_paint(element_id.owning_id()); - } - // Create per-target PaintCx and paint this visual node - self.paint_visual_node(element_id, false); - } - PaintOrPost::Post(element_id) => { - self.paint_visual_node(element_id, true); - } - } - } + fn update_composition_plan(&mut self) { + let effective_scale = self.window_state.effective_scale(); + let mut plan = self.window_state.display_list.lower_composition_plan( + effective_scale, + &self.window_state.surface_image_registry.borrow(), + ); + clip_scene_layers_to_viewport(&mut plan, self.window_state.root_size); + self.window_state.composition_plan = plan; } - /// Paint a single visual node with its absolute transform - pub(crate) fn paint_visual_node(&mut self, element_id: ElementId, is_post: bool) { + fn element_base_transform(&mut self, element_id: ElementId) -> Affine { // Get state from box tree for this visual node + let box_tree = self.window_state.box_tree.borrow(); + box_tree.world_transform(element_id.0).unwrap_or_default() + } + + /// Record a single visual node in local coordinates. + pub(crate) fn record_visual_node(&mut self, element_id: ElementId, is_post: bool) { let box_tree = self.window_state.box_tree.borrow_mut(); - let world_transform = box_tree.world_transform(element_id.0).unwrap_or_default(); let layout_rect_local = box_tree.local_bounds(element_id.0).unwrap_or_default(); - let clip = box_tree.clipped_local_clip(element_id.0); + let snapshot = ElementSnapshot::from_box_tree(&box_tree, element_id); drop(box_tree); - // Set absolute transform on renderer - let device_transform = world_transform.then_scale(self.window_state.effective_scale()); - self.paint_state - .renderer_mut() - .set_transform(device_transform); - - let layout_rect = layout_rect_local; let view_id = element_id.owning_id(); let view = view_id.view(); let view_state = view_id.state(); - - // Create per-target PaintCx - let mut cx = PaintCx { - window_state: self.window_state, - paint_state: self.paint_state, - target_id: element_id, - world_transform, - layout_rect_local, - clip, - font_size_cx: view_state.borrow().layout_props.font_size_cx(), + let font_size_cx = view_state.borrow().layout_props.font_size_cx(); + let font_embolden = view_state.borrow().view_style_props.font_embolden(); + let effective_scale = self.window_state.effective_scale(); + let mut recorder = { + let element = self.window_state.display_list.element_mut(element_id); + let stage = if is_post { + &mut element.post + } else { + &mut element.paint + }; + let mut recorder = StageRecorder::from_stage( + stage, + self.window_state.surface_image_registry.clone(), + font_size_cx, + effective_scale, + ); + recorder.clear(); + recorder }; - if !is_post { - if element_id.is_view() { - let state = view_state.borrow(); - paint_bg(&mut cx, &state.view_style_props, layout_rect); - paint_border( - &mut cx, - &state.layout_props, - &state.view_style_props, - layout_rect, - ); - drop(state); - // Apply overflow clip (stays active through children) - if let Some(clip_shape) = clip { - cx.clip(&clip_shape); + let layout_rect = layout_rect_local; + let is_vger = false; + let world_transform = self.element_base_transform(element_id); + { + // Create per-target PaintCx + let mut cx = PaintCx { + window_state: self.window_state, + gpu_resources: self.gpu_resources.as_ref(), + painter: Painter::new(&mut recorder), + is_vger, + target_id: element_id, + world_transform, + layout_rect_local, + clip: snapshot.clip, + font_size_cx, + font_embolden, + effective_scale, + }; + + if !is_post { + if element_id.is_view() { + let state = view_state.borrow(); + paint_bg(&mut cx, &state.view_style_props, layout_rect); + paint_border( + &mut cx, + &state.layout_props, + &state.view_style_props, + layout_rect, + ); + drop(state); + } + view.borrow_mut().paint(&mut cx); + } else { + view.borrow_mut().post_paint(&mut cx); + if element_id.is_view() { + let state = view_state.borrow(); + paint_outline(&mut cx, &state.view_style_props, layout_rect); } } - view.borrow_mut().paint(&mut cx); - } else { - if element_id.is_view() && clip.is_some() { - cx.clear_clip(); - } - view.borrow_mut().post_paint(&mut cx); - if element_id.is_view() { - let state = view_state.borrow(); - paint_outline(&mut cx, &state.view_style_props, layout_rect); - } } + + { + let element = self.window_state.display_list.element_mut(element_id); + let stage = if is_post { + &mut element.post + } else { + &mut element.paint + }; + recorder.finish(stage); + } + self.window_state + .display_list + .element_mut(element_id) + .snapshot = Some(snapshot); + self.window_state + .display_list + .mark_composed_dirty(element_id); } } @@ -350,121 +339,33 @@ impl PaintCx<'_> { false } - /// Clip the drawing area (delegates to helper methods) - pub fn clip(&mut self, shape: &impl Shape) { - if self.paint_state.renderer().uses_layer_clip() { - use peniko::Mix; - self.push_layer(Mix::Normal, 1.0, Affine::IDENTITY, shape); - } else { - self.paint_state.renderer_mut().clip(shape); - } + pub fn is_vger(&self) -> bool { + self.is_vger } - - /// Clear clip - pub fn clear_clip(&mut self) { - if self.paint_state.renderer().uses_layer_clip() { - self.pop_layer(); - } else { - self.paint_state.renderer_mut().clear_clip(); - } - } - - // Note: get_transform/set_transform removed as Renderer doesn't expose transform() - // Views that previously used save/restore should use clip/clear_clip instead } -// TODO: should this be private? -pub enum PaintState { +pub(crate) enum PaintState { /// The renderer is not yet initialized. This state is used to wait for the GPU resources to be acquired. PendingGpuResources { - window: Arc, - rx: Receiver), GpuResourceError>>, - font_embolden: f32, - /// This field holds an instance of `Renderer::Uninitialized` until the GPU resources are acquired, - /// which will be returned in `PaintState::renderer` and `PaintState::renderer_mut`. - /// All calls to renderer methods will be no-ops until the renderer is initialized. - /// - /// Previously, `PaintState::renderer` and `PaintState::renderer_mut` would panic if called when the renderer was uninitialized. - /// However, this turned out to be hard to handle properly and led to panics, especially since the rest of the application code can't control when the renderer is initialized. - renderer: Renderer, + rx: Receiver< + Result<(GpuResources, subduction::wgpu::ExternalSurfaceCapabilities), GpuResourceError>, + >, }, /// The renderer is initialized and ready to paint. - Initialized { renderer: Renderer }, + Initialized, + Headless, } impl PaintState { pub fn new_pending( - window: Arc, - rx: Receiver), GpuResourceError>>, - size: Size, - font_embolden: f32, + rx: Receiver< + Result<(GpuResources, subduction::wgpu::ExternalSurfaceCapabilities), GpuResourceError>, + >, ) -> Self { - Self::PendingGpuResources { - window, - rx, - font_embolden, - renderer: Renderer::Uninitialized { size }, - } + Self::PendingGpuResources { rx } } - pub fn new( - window: Arc, - surface: wgpu::Surface<'static>, - gpu_resources: GpuResources, - scale: f64, - size: Size, - font_embolden: f32, - ) -> Self { - let renderer = Renderer::new( - window.clone(), - gpu_resources, - surface, - scale, - size, - font_embolden, - ); - Self::Initialized { renderer } - } - - #[cfg(feature = "skia")] - pub fn new_skia(window: Arc, scale: f64, size: Size, font_embolden: f32) -> Self { - let renderer = Renderer::new_skia(window.clone(), scale, size, font_embolden); - Self::Initialized { renderer } - } - - pub(crate) fn renderer(&self) -> &Renderer { - match self { - PaintState::PendingGpuResources { renderer, .. } => renderer, - PaintState::Initialized { renderer } => renderer, - } - } - - pub(crate) fn renderer_mut(&mut self) -> &mut Renderer { - match self { - PaintState::PendingGpuResources { renderer, .. } => renderer, - PaintState::Initialized { renderer } => renderer, - } - } - - pub(crate) fn resize(&mut self, scale: f64, size: Size) { - self.renderer_mut().resize(scale, size); - } - - pub(crate) fn set_scale(&mut self, scale: f64) { - self.renderer_mut().set_scale(scale); - } -} - -impl Deref for PaintCx<'_> { - type Target = Renderer; - - fn deref(&self) -> &Self::Target { - self.paint_state.renderer() - } -} - -impl DerefMut for PaintCx<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.paint_state.renderer_mut() + pub(crate) fn is_initialized(&self) -> bool { + matches!(self, Self::Initialized | Self::Headless) } } diff --git a/src/paint/renderer.rs b/src/paint/renderer.rs index ac55e5617..bd261096d 100644 --- a/src/paint/renderer.rs +++ b/src/paint/renderer.rs @@ -47,385 +47,1423 @@ //! - Only one view can be active at a time. //! - Only one view can be focused at a time. //! -use std::sync::Arc; - -use floem_renderer::Img; -use floem_renderer::gpu_resources::GpuResources; -#[cfg(feature = "skia")] -use floem_skia_renderer::SkiaRenderer as ActiveRenderer; -use floem_tiny_skia_renderer::TinySkiaRenderer; -#[cfg(all(feature = "vello", not(feature = "skia")))] -use floem_vello_renderer::VelloRenderer as ActiveRenderer; -#[cfg(all(not(feature = "vello"), not(feature = "skia")))] -use floem_vger_renderer::VgerRenderer as ActiveRenderer; -use peniko::BrushRef; -use peniko::kurbo::{Affine, Point, Rect, Shape, Size, Stroke}; -use winit::window::Window; - -#[allow(clippy::large_enum_variant)] -pub enum Renderer { - Active(ActiveRenderer), - TinySkia(TinySkiaRenderer>), - /// Uninitialized renderer, used to allow the renderer to be created lazily - /// All operations on this renderer are no-ops - Uninitialized { - size: Size, +use std::{ + collections::VecDeque, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, }, + thread, +}; + +use crate::gpu_resources::GpuResources; +use crate::paint::composition::CompositionKey; +use crate::window::compositor::SceneRenderSignature; +use imaging::{ImageRenderer, PaintSink, RenderSource, RgbaImage, record::Scene}; +use imaging_wgpu::{ + ExternalImageResolver, ResolvedExternalImage, TextureRenderSubmission, TextureRenderer, + TextureViewTarget, +}; +use peniko::ImageData; +use peniko::kurbo::Size; +use winit::window::WindowId; + +use crate::platform::{Duration, Instant}; +#[cfg(not(target_arch = "wasm32"))] +use crate::{Application, app::UserEvent}; + +#[derive(Clone, Debug, Default)] +pub(crate) struct ExternalImageResources { + images: Vec, } -impl Renderer { - #[cfg(feature = "skia")] - const ACTIVE_RENDERER_NAME: &str = "SkiaRenderer"; - #[cfg(all(feature = "vello", not(feature = "skia")))] - const ACTIVE_RENDERER_NAME: &str = "VelloRenderer"; - #[cfg(all(not(feature = "vello"), not(feature = "skia")))] - const ACTIVE_RENDERER_NAME: &str = "VgerRenderer"; +impl ExternalImageResources { + pub(crate) fn insert(&mut self, id: imaging::ExternalImageId, image: ResolvedExternalImage) { + self.images.push(ExternalImageResource { id, image }); + } +} - #[cfg(all(not(feature = "vello"), not(feature = "skia")))] - const ACTIVE_IS_VGER: bool = true; - #[cfg(any(feature = "vello", feature = "skia"))] - const ACTIVE_IS_VGER: bool = false; +#[derive(Clone, Debug)] +struct ExternalImageResource { + id: imaging::ExternalImageId, + image: ResolvedExternalImage, +} - #[cfg(all(feature = "vello", not(feature = "skia")))] - const ACTIVE_USES_LAYER_CLIP: bool = true; - #[cfg(any(not(feature = "vello"), feature = "skia"))] - const ACTIVE_USES_LAYER_CLIP: bool = false; +struct ExternalImageResourceResolver { + images: ExternalImageResources, +} - #[cfg(feature = "skia")] - pub fn new_skia(window: Arc, scale: f64, size: Size, font_embolden: f32) -> Self { - let size = Size::new(size.width.max(1.0), size.height.max(1.0)); +impl ExternalImageResourceResolver { + fn new(images: ExternalImageResources) -> Self { + Self { images } + } +} - let force_tiny_skia = std::env::var("FLOEM_FORCE_TINY_SKIA") - .ok() - .map(|val| val.as_str() == "1") - .unwrap_or(false); - - if !force_tiny_skia { - match ActiveRenderer::new( - window.clone(), - size.width as u32, - size.height as u32, - scale, - font_embolden, - ) { - Ok(renderer) => return Self::Active(renderer), - Err(err) => panic!("Failed to create {}: {err}", Self::ACTIVE_RENDERER_NAME), - } - } +impl ExternalImageResolver for ExternalImageResourceResolver { + fn resolve_external_image( + &mut self, + image: imaging::ExternalImage, + ) -> Option { + self.images + .images + .iter() + .find(|resource| resource.id == image.id) + .map(|resource| resource.image.clone()) + } +} - match TinySkiaRenderer::new( - window, - size.width as u32, - size.height as u32, - scale, - font_embolden, - ) { - Ok(tiny_skia) => Self::TinySkia(tiny_skia), - Err(err) => panic!("Failed to create TinySkiaRenderer: {err}"), +pub(crate) type RendererChooser = Arc RendererSpec + Send + Sync>; + +#[derive(Clone, Default)] +pub(crate) struct SharedSceneFragmentRendererPool { + inner: Arc>>>, +} + +impl SharedSceneFragmentRendererPool { + pub(crate) fn get(&self) -> Option> { + self.inner.lock().ok()?.clone() + } + + pub(crate) fn init_if_needed( + &self, + chooser: &RendererChooser, + cx: NewRendererCx, + ) -> Arc { + let mut inner = self.inner.lock().expect("renderer pool mutex poisoned"); + if let Some(pool) = inner.as_ref() { + return Arc::clone(pool); } + let pool = Arc::new( + SceneFragmentRendererPool::new(Arc::clone(chooser), cx) + .expect("create scene fragment renderer pool"), + ); + *inner = Some(Arc::clone(&pool)); + pool } +} + +pub(crate) struct SceneFragmentRenderJob { + pub(crate) scene: Scene, + pub(crate) base_transform: peniko::kurbo::Affine, + pub(crate) clip: Option, + pub(crate) render_size: Size, + pub(crate) texture: wgpu::Texture, + pub(crate) external_images: ExternalImageResources, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SceneFragmentRenderKind { + Content, + ClipMask, +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) struct SceneFragmentRenderCompletion { + pub(crate) window_id: WindowId, + pub(crate) key: CompositionKey, + pub(crate) signature: SceneRenderSignature, + pub(crate) kind: SceneFragmentRenderKind, +} + +pub(crate) struct SceneFragmentRendererPool { + name: &'static str, + compositor_texture_format: Option, + workers: Mutex, + chooser: RendererChooser, + cx: NewRendererCx, + worker_gpu_callback_pump: Option, + max_workers: usize, + #[cfg(not(target_arch = "wasm32"))] + _gpu_callback_pump: Option, +} - pub(crate) fn is_vger(&self) -> bool { - Self::ACTIVE_IS_VGER && matches!(self, Renderer::Active(_)) +#[cfg(not(target_arch = "wasm32"))] +struct SceneFragmentWorkerPoolState { + workers: Vec, + next_worker_index: usize, +} + +#[derive(Clone)] +pub struct NewRendererCx { + pub gpu_resources: Option, + pub surface_caps: Option, + pub transparent: bool, + pub size: Size, + pub scale: f64, + pub maximum_drawable_count: u32, +} + +impl NewRendererCx { + #[cfg(target_arch = "wasm32")] + fn normalized_size(&self) -> Size { + Size::new(self.size.width.max(1.0), self.size.height.max(1.0)) } - pub(crate) fn uses_layer_clip(&self) -> bool { - match self { - Renderer::Active(_) => Self::ACTIVE_USES_LAYER_CLIP, - Renderer::TinySkia(_) => false, - Renderer::Uninitialized { .. } => false, + pub fn gpu(&self) -> Option> { + if force_cpu_requested() { + return None; + } + + match (&self.surface_caps, &self.gpu_resources) { + (Some(surface_caps), Some(gpu_resources)) => Some(GpuRendererChooserCx { + gpu_resources, + surface_caps, + }), + _ => None, } } - #[cfg(all(feature = "vello", not(feature = "skia")))] - fn try_new_active( - gpu_resources: GpuResources, - surface: wgpu::Surface<'static>, - width: u32, - height: u32, - scale: f64, - font_embolden: f32, - ) -> Result { - ActiveRenderer::new(gpu_resources, surface, width, height, scale, font_embolden) - .map_err(|err| err.to_string()) - } - - #[cfg(all(not(feature = "vello"), not(feature = "skia")))] - fn try_new_active( - gpu_resources: GpuResources, - surface: wgpu::Surface<'static>, - width: u32, - height: u32, - scale: f64, - font_embolden: f32, - ) -> Result { - ActiveRenderer::new(gpu_resources, surface, width, height, scale, font_embolden) - .map_err(|err| err.to_string()) - } - - #[allow(unused_variables)] - pub fn new( - window: Arc, - gpu_resources: GpuResources, - surface: wgpu::Surface<'static>, - scale: f64, - size: Size, - font_embolden: f32, - ) -> Self { - let size = Size::new(size.width.max(1.0), size.height.max(1.0)); + pub fn image_renderer( + self, + backend: impl ImageRenderer + 'static, + name: &'static str, + ) -> RendererSpec { + RendererSpec(RendererSpecInner::Cpu(CpuRenderer::new(backend, name))) + } - let force_tiny_skia = std::env::var("FLOEM_FORCE_TINY_SKIA") - .ok() - .map(|val| val.as_str() == "1") - .unwrap_or(false); + pub fn provided_texture_renderer( + self, + backend: impl TextureRenderer + 'static, + surface_format: wgpu::TextureFormat, + name: &'static str, + ) -> RendererSpec { + let gpu_resources = self + .gpu_resources + .as_ref() + .expect("renderer requires GPU resources"); + RendererSpec(RendererSpecInner::Gpu { + backend: GpuRenderer::provided_texture( + backend, + gpu_resources.device.clone(), + gpu_resources.queue.clone(), + name, + ), + surface_format, + }) + } - #[cfg(not(feature = "skia"))] - let active_err = if !force_tiny_skia { - match Self::try_new_active( - gpu_resources, - surface, - size.width as u32, - size.height as u32, - scale, - font_embolden, - ) { - Ok(renderer) => return Self::Active(renderer), - Err(err) => Some(err), + pub fn owned_texture_renderer( + self, + backend: impl TextureRenderer + 'static, + surface_format: wgpu::TextureFormat, + name: &'static str, + ) -> RendererSpec { + let gpu_resources = self + .gpu_resources + .as_ref() + .expect("renderer requires GPU resources"); + RendererSpec(RendererSpecInner::Gpu { + backend: GpuRenderer::owned_texture( + backend, + gpu_resources.device.clone(), + gpu_resources.queue.clone(), + name, + ), + surface_format, + }) + } + + pub fn provided_texture_view_renderer( + self, + backend: impl TextureRenderer + + 'static, + surface_format: wgpu::TextureFormat, + name: &'static str, + ) -> RendererSpec { + let gpu_resources = self + .gpu_resources + .as_ref() + .expect("renderer requires GPU resources"); + RendererSpec(RendererSpecInner::Gpu { + backend: GpuRenderer::provided_texture_view( + backend, + gpu_resources.device.clone(), + gpu_resources.queue.clone(), + name, + ), + surface_format, + }) + } + + pub fn owned_texture_view_renderer( + self, + backend: impl TextureRenderer + + 'static, + surface_format: wgpu::TextureFormat, + name: &'static str, + ) -> RendererSpec { + let gpu_resources = self + .gpu_resources + .as_ref() + .expect("renderer requires GPU resources"); + RendererSpec(RendererSpecInner::Gpu { + backend: GpuRenderer::owned_texture_view( + backend, + gpu_resources.device.clone(), + gpu_resources.queue.clone(), + name, + ), + surface_format, + }) + } +} + +pub struct GpuRendererChooserCx<'a> { + pub gpu_resources: &'a GpuResources, + pub surface_caps: &'a subduction::wgpu::ExternalSurfaceCapabilities, +} + +impl GpuRendererChooserCx<'_> { + pub fn surface_formats(&self) -> &[wgpu::TextureFormat] { + &self.surface_caps.formats + } +} + +fn rgba_image_into_image_data(image: RgbaImage) -> ImageData { + ImageData { + data: peniko::Blob::new(Arc::new(image.data)), + format: peniko::ImageFormat::Rgba8, + width: image.width, + height: image.height, + alpha_type: peniko::ImageAlphaType::Alpha, + } +} + +fn read_texture_into_rgba_image( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + format: wgpu::TextureFormat, + image: &mut RgbaImage, +) -> Result<(), String> { + let width = image.width; + let height = image.height; + let width_bytes = width.saturating_mul(4); + let padded_row_bytes = width_bytes.div_ceil(256) * 256; + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("floem inspector compositor capture readback"), + size: u64::from(padded_row_bytes) * u64::from(height), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("floem inspector compositor capture readback"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_row_bytes), + rows_per_image: None, + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + + let slice = readback.slice(..); + let (tx, rx) = mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + device + .poll(wgpu::PollType::wait_indefinitely()) + .map_err(|_| "wgpu device poll failed during compositor capture".to_owned())?; + rx.recv() + .map_err(|_| "wgpu readback callback dropped during compositor capture".to_owned())? + .map_err(|_| "wgpu readback buffer map failed during compositor capture".to_owned())?; + + let mapped = slice.get_mapped_range(); + let row_bytes = width_bytes as usize; + for (source, dest) in mapped + .chunks_exact(padded_row_bytes as usize) + .zip(image.data.chunks_exact_mut(row_bytes)) + { + dest.copy_from_slice(&source[..row_bytes]); + if matches!( + format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ) { + for pixel in dest.chunks_exact_mut(4) { + pixel.swap(0, 2); } - } else { - None + } + } + drop(mapped); + readback.unmap(); + Ok(()) +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct CaptureTiming { + pub total: Duration, + pub resize: Duration, + pub pre_present_notify: Duration, + pub prepare: Duration, + pub scene: Duration, + pub finalize: Duration, + pub readback: Duration, + pub convert: Duration, +} + +#[derive(Clone, Debug, Default)] +pub struct CaptureOutput { + pub image: Option, + pub error: Option, + pub timing: CaptureTiming, +} + +pub(crate) fn capture_source_with_external_images( + renderer_pool: &SceneFragmentRendererPool, + gpu_resources: &GpuResources, + size: Size, + scene: Scene, + external_images: ExternalImageResources, +) -> CaptureOutput { + let total_start = Instant::now(); + let width = size.width.ceil().max(1.0) as u32; + let height = size.height.ceil().max(1.0) as u32; + let Some(format) = renderer_pool.compositor_texture_format() else { + return CaptureOutput { + error: Some("renderer has no compositor texture format".to_owned()), + timing: CaptureTiming { + total: total_start.elapsed(), + ..Default::default() + }, + ..Default::default() }; + }; - let tiny_skia_err = match TinySkiaRenderer::new( - window, - size.width as u32, - size.height as u32, - scale, - font_embolden, - ) { - Ok(tiny_skia) => return Self::TinySkia(tiny_skia), - Err(err) => err, + let texture = gpu_resources + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("floem inspector compositor capture target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let rendered = renderer_pool.render_for_capture(SceneFragmentRenderJob { + scene, + base_transform: peniko::kurbo::Affine::IDENTITY, + clip: None, + render_size: size, + texture: texture.clone(), + external_images, + }); + if !rendered { + return CaptureOutput { + error: Some("renderer could not render compositor capture texture".to_owned()), + timing: CaptureTiming { + total: total_start.elapsed(), + ..Default::default() + }, + ..Default::default() }; + } + + let readback_start = Instant::now(); + let mut image = RgbaImage::new(width, height); + let error = read_texture_into_rgba_image( + &gpu_resources.device, + &gpu_resources.queue, + &texture, + format, + &mut image, + ) + .err(); + let readback = readback_start.elapsed(); + CaptureOutput { + image: error.is_none().then(|| rgba_image_into_image_data(image)), + error, + timing: CaptureTiming { + total: total_start.elapsed(), + readback, + ..Default::default() + }, + } +} + +#[cfg(not(target_arch = "wasm32"))] +struct SceneFragmentRenderWorker { + sender: mpsc::Sender, + in_flight: Arc, + join_handle: Option>, +} - if !force_tiny_skia { - #[cfg(feature = "skia")] - panic!("Failed to create TinySkiaRenderer: {tiny_skia_err}"); +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone)] +struct GpuCallbackPump { + inner: Arc, +} + +#[cfg(not(target_arch = "wasm32"))] +struct GpuCallbackPumpThread { + pump: GpuCallbackPump, + join_handle: Option>, +} + +#[cfg(not(target_arch = "wasm32"))] +struct GpuCallbackPumpInner { + device: wgpu::Device, + state: Mutex, + cvar: Condvar, +} + +#[cfg(not(target_arch = "wasm32"))] +struct GpuCallbackPumpState { + pending: VecDeque>, + stop: bool, +} + +const GPU_CALLBACK_POLL_TIMEOUT: Duration = Duration::from_micros(100); - #[cfg(not(feature = "skia"))] - panic!( - "Failed to create {}: {}\nFailed to create TinySkiaRenderer: {tiny_skia_err}", - Self::ACTIVE_RENDERER_NAME, - active_err.unwrap() +#[cfg(not(target_arch = "wasm32"))] +struct SceneFragmentRenderResult { + submission_index: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +enum SceneFragmentRenderCommand { + Render { + job: SceneFragmentRenderJob, + completion: SceneFragmentRenderCompletion, + }, + RenderForCapture { + job: SceneFragmentRenderJob, + response: mpsc::Sender, + }, + Shutdown, +} + +#[cfg(not(target_arch = "wasm32"))] +impl GpuCallbackPumpThread { + fn spawn(device: wgpu::Device) -> Result { + let pump = GpuCallbackPump { + inner: Arc::new(GpuCallbackPumpInner { + device, + state: Mutex::new(GpuCallbackPumpState { + pending: VecDeque::new(), + stop: false, + }), + cvar: Condvar::new(), + }), + }; + let thread_pump = pump.clone(); + let join_handle = thread::Builder::new() + .name("floem-gpu-callback-pump".to_owned()) + .spawn(move || thread_pump.run()) + .map_err(|err| format!("failed to spawn gpu callback pump: {err}"))?; + Ok(Self { + pump, + join_handle: Some(join_handle), + }) + } + + fn pump(&self) -> GpuCallbackPump { + self.pump.clone() + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for GpuCallbackPumpThread { + fn drop(&mut self) { + { + let mut state = self.pump.inner.state.lock().unwrap(); + state.stop = true; + self.pump.inner.cvar.notify_one(); + } + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl GpuCallbackPump { + fn wait_for(&self, submission_index: Option) { + let mut state = self.inner.state.lock().unwrap(); + state.pending.push_back(submission_index); + self.inner.cvar.notify_one(); + } + + fn run(self) { + loop { + let mut state = self.inner.state.lock().unwrap(); + while state.pending.is_empty() && !state.stop { + state = self.inner.cvar.wait(state).unwrap(); + } + if state.stop { + break; + } + let submission_index = state.pending.pop_front().flatten(); + drop(state); + let result = self.inner.device.poll(wgpu::PollType::Wait { + submission_index: submission_index.clone(), + timeout: Some(GPU_CALLBACK_POLL_TIMEOUT), + }); + if matches!(result, Err(wgpu::PollError::Timeout)) { + let mut state = self.inner.state.lock().unwrap(); + state.pending.push_back(submission_index); + self.inner.cvar.notify_one(); + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +struct SceneFragmentSource { + scene: Scene, + base_transform: peniko::kurbo::Affine, + clip: Option, + render_size: Size, +} + +#[cfg(not(target_arch = "wasm32"))] +impl RenderSource for SceneFragmentSource { + fn paint_into(&mut self, sink: &mut dyn PaintSink) { + if let Some(clip) = self.clip { + crate::paint::display_list::replay_view_clip( + sink, + clip, + self.base_transform, + self.render_size, ); - } else { - panic!("Failed to create TinySkiaRenderer: {tiny_skia_err}"); + } + crate::paint::display_list::replay_scene( + &self.scene, + sink, + self.base_transform, + self.render_size, + ); + if self.clip.is_some() { + sink.pop_clip(); } } +} - pub fn resize(&mut self, scale: f64, size: Size) { - let size = Size::new(size.width.max(1.0), size.height.max(1.0)); - match self { - Renderer::Active(r) => r.resize(size.width as u32, size.height as u32, scale), - Renderer::TinySkia(r) => r.resize(size.width as u32, size.height as u32, scale), - Renderer::Uninitialized { .. } => {} +pub struct RendererSpec(RendererSpecInner); + +impl RendererSpec { + #[cfg(not(target_arch = "wasm32"))] + fn gpu_fence_resources(&self) -> Option<(wgpu::Device, wgpu::Queue)> { + match &self.0 { + RendererSpecInner::Gpu { backend, .. } => { + Some((backend.device.clone(), backend.queue.clone())) + } + RendererSpecInner::Cpu(_) => None, } } +} - pub fn set_scale(&mut self, scale: f64) { - match self { - Renderer::Active(r) => r.set_scale(scale), - Renderer::TinySkia(r) => r.set_scale(scale), - Renderer::Uninitialized { .. } => {} +enum RendererSpecInner { + Cpu(CpuRenderer), + Gpu { + backend: GpuRenderer, + surface_format: wgpu::TextureFormat, + }, +} + +#[cfg(not(target_arch = "wasm32"))] +enum RendererInit { + Cpu { + name: &'static str, + }, + Gpu { + name: &'static str, + surface_format: wgpu::TextureFormat, + }, +} + +#[cfg(not(target_arch = "wasm32"))] +impl RendererInit { + fn from_spec(spec: &RendererSpec) -> Self { + match &spec.0 { + RendererSpecInner::Cpu(backend) => Self::Cpu { name: backend.name }, + RendererSpecInner::Gpu { + backend, + surface_format, + } => Self::Gpu { + name: backend.name, + surface_format: *surface_format, + }, } } - pub fn size(&self) -> Size { + fn name(&self) -> &'static str { match self { - Renderer::Active(r) => r.size(), - Renderer::TinySkia(r) => r.size(), - Renderer::Uninitialized { size } => *size, + RendererInit::Cpu { name } | RendererInit::Gpu { name, .. } => name, } } +} + +#[cfg(not(target_arch = "wasm32"))] +impl SceneFragmentRendererPool { + fn new(chooser: RendererChooser, cx: NewRendererCx) -> Result { + let max_workers = std::env::var("FLOEM_RENDER_THREADS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| { + std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1) + .clamp(1, 4) + }) + .max(1); + #[cfg(not(target_arch = "wasm32"))] + let gpu_callback_pump = cx + .gpu_resources + .as_ref() + .map(|gpu_resources| GpuCallbackPumpThread::spawn(gpu_resources.device.clone())) + .transpose()?; + #[cfg(not(target_arch = "wasm32"))] + let worker_gpu_callback_pump = gpu_callback_pump.as_ref().map(GpuCallbackPumpThread::pump); + let (worker, init) = SceneFragmentRenderWorker::spawn( + 0, + Arc::clone(&chooser), + cx.clone(), + worker_gpu_callback_pump.clone(), + None, + )?; + let compositor_texture_format = match &init { + RendererInit::Gpu { surface_format, .. } => Some(*surface_format), + RendererInit::Cpu { .. } => None, + }; + Ok(Self { + name: init.name(), + compositor_texture_format, + workers: Mutex::new(SceneFragmentWorkerPoolState { + workers: vec![worker], + next_worker_index: 1, + }), + chooser, + cx, + worker_gpu_callback_pump, + max_workers, + #[cfg(not(target_arch = "wasm32"))] + _gpu_callback_pump: gpu_callback_pump, + }) + } + + pub(crate) fn compositor_texture_format(&self) -> Option { + self.compositor_texture_format + } pub(crate) fn debug_info(&self) -> String { - use crate::Renderer; + format!( + "Renderer: {} (scene fragment pool, workers={}/{})", + self.name, + self.workers + .lock() + .map(|state| state.workers.len()) + .unwrap_or(0), + self.max_workers, + ) + } - match self { - Self::Active(r) => r.debug_info(), - Self::TinySkia(r) => r.debug_info(), - Self::Uninitialized { .. } => "Uninitialized".to_string(), + pub(crate) fn submit( + &self, + job: SceneFragmentRenderJob, + completion: SceneFragmentRenderCompletion, + ) -> bool { + let mut state = self.workers.lock().expect("renderer worker pool poisoned"); + state.reap_finished(); + if state.should_spawn_extra(self.max_workers) + && let Err(err) = state.spawn_extra( + Arc::clone(&self.chooser), + self.cx.clone(), + self.worker_gpu_callback_pump.clone(), + ) + { + crate::floem_debug_log!("floem render pool failed to spawn extra worker: {err}"); } + state.submit(job, completion) + } + + fn render_for_capture(&self, job: SceneFragmentRenderJob) -> bool { + let mut state = self.workers.lock().expect("renderer worker pool poisoned"); + state.reap_finished(); + state.render_for_capture(job) } } -impl floem_renderer::Renderer for Renderer { - fn begin(&mut self, capture: bool) { - match self { - Renderer::Active(r) => { - r.begin(capture); - } - Renderer::TinySkia(r) => { - r.begin(capture); - } - Renderer::Uninitialized { .. } => {} +#[cfg(not(target_arch = "wasm32"))] +impl Drop for SceneFragmentRendererPool { + fn drop(&mut self) { + let Ok(mut state) = self.workers.lock() else { + return; + }; + for worker in &mut state.workers { + worker.shutdown(); } } +} - fn clip(&mut self, shape: &impl Shape) { - match self { - Renderer::Active(v) => { - v.clip(shape); +#[cfg(not(target_arch = "wasm32"))] +impl SceneFragmentWorkerPoolState { + fn reap_finished(&mut self) { + let mut index = 0; + while index < self.workers.len() { + if self.workers[index].is_finished() { + let mut worker = self.workers.remove(index); + worker.join(); + } else { + index += 1; } - Renderer::TinySkia(v) => { - v.clip(shape); - } - Renderer::Uninitialized { .. } => {} } } - fn clear_clip(&mut self) { - match self { - Renderer::Active(v) => { - v.clear_clip(); - } - Renderer::TinySkia(v) => { - v.clear_clip(); - } - Renderer::Uninitialized { .. } => {} - } + fn should_spawn_extra(&self, max_workers: usize) -> bool { + self.workers.len() < max_workers + && self + .workers + .iter() + .all(|worker| worker.in_flight.load(Ordering::Relaxed) > 0) } - fn stroke<'b, 's>( + fn spawn_extra( &mut self, - shape: &impl Shape, - brush: impl Into>, - stroke: &'s Stroke, - ) { - match self { - Renderer::Active(v) => { - v.stroke(shape, brush, stroke); - } - Renderer::TinySkia(v) => { - v.stroke(shape, brush, stroke); - } - Renderer::Uninitialized { .. } => {} - } + chooser: RendererChooser, + cx: NewRendererCx, + gpu_callback_pump: Option, + ) -> Result<(), String> { + const EXTRA_WORKER_IDLE_TIMEOUT: Duration = Duration::from_secs(2); + let index = self.next_worker_index; + self.next_worker_index = self.next_worker_index.wrapping_add(1).max(1); + let (worker, _) = SceneFragmentRenderWorker::spawn( + index, + chooser, + cx, + gpu_callback_pump, + Some(EXTRA_WORKER_IDLE_TIMEOUT), + )?; + self.workers.push(worker); + Ok(()) } - fn fill<'b>( + fn submit( &mut self, - path: &impl peniko::kurbo::Shape, - brush: impl Into>, - blur_radius: f64, - ) { - match self { - Renderer::Active(v) => { - v.fill(path, brush, blur_radius); - } - Renderer::TinySkia(v) => { - v.fill(path, brush, blur_radius); - } - Renderer::Uninitialized { .. } => {} + job: SceneFragmentRenderJob, + completion: SceneFragmentRenderCompletion, + ) -> bool { + let Some(worker) = self.least_loaded_worker() else { + return false; + }; + worker.submit(job, completion) + } + + fn render_for_capture(&mut self, job: SceneFragmentRenderJob) -> bool { + let Some(worker) = self.least_loaded_worker() else { + return false; + }; + worker.render_for_capture(job) + } + + fn least_loaded_worker(&self) -> Option<&SceneFragmentRenderWorker> { + self.workers + .iter() + .min_by_key(|worker| worker.in_flight.load(Ordering::Relaxed)) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl SceneFragmentRenderWorker { + fn spawn( + index: usize, + chooser: RendererChooser, + cx: NewRendererCx, + gpu_callback_pump: Option, + idle_timeout: Option, + ) -> Result<(Self, RendererInit), String> { + let (command_tx, command_rx) = mpsc::channel(); + let (init_tx, init_rx) = mpsc::channel(); + let in_flight = Arc::new(AtomicUsize::new(0)); + let worker_in_flight = Arc::clone(&in_flight); + let join_handle = thread::Builder::new() + .name(format!("floem-render-pool-{index}")) + .spawn(move || { + let mut backend = chooser(cx); + let init = RendererInit::from_spec(&backend); + if init_tx.send(init).is_err() { + return; + } + loop { + let command = match idle_timeout { + Some(timeout) => match command_rx.recv_timeout(timeout) { + Ok(command) => command, + Err(mpsc::RecvTimeoutError::Timeout) => { + if worker_in_flight.load(Ordering::Relaxed) == 0 { + break; + } + continue; + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }, + None => match command_rx.recv() { + Ok(command) => command, + Err(_) => break, + }, + }; + match command { + SceneFragmentRenderCommand::Render { job, completion } => { + let render_start = Instant::now(); + let mut source = SceneFragmentSource { + scene: job.scene, + base_transform: job.base_transform, + clip: job.clip, + render_size: job.render_size, + }; + let render_result = render_into_existing_texture_with_external_images( + &mut backend, + &mut source, + job.render_size, + &job.texture, + job.external_images, + ); + let render_end = Instant::now(); + worker_in_flight.fetch_sub(1, Ordering::Relaxed); + if let Some(render_result) = render_result { + send_scene_fragment_ready_after_gpu_work_done( + &backend, + gpu_callback_pump.clone(), + render_result.submission_index, + completion, + index, + render_start, + render_end, + ); + } else { + send_scene_fragment_ready( + completion, + false, + index, + render_start, + render_end, + render_end, + ); + } + } + SceneFragmentRenderCommand::RenderForCapture { job, response } => { + let mut source = SceneFragmentSource { + scene: job.scene, + base_transform: job.base_transform, + clip: job.clip, + render_size: job.render_size, + }; + let rendered = render_into_existing_texture_with_external_images( + &mut backend, + &mut source, + job.render_size, + &job.texture, + job.external_images, + ) + .is_some(); + let _ = response.send(rendered); + worker_in_flight.fetch_sub(1, Ordering::Relaxed); + } + SceneFragmentRenderCommand::Shutdown => break, + } + } + }) + .expect("failed to spawn render worker"); + let init = init_rx + .recv() + .map_err(|_| "render worker thread stopped during initialization".to_string())?; + Ok(( + Self { + sender: command_tx, + in_flight, + join_handle: Some(join_handle), + }, + init, + )) + } + + fn submit( + &self, + job: SceneFragmentRenderJob, + completion: SceneFragmentRenderCompletion, + ) -> bool { + self.in_flight.fetch_add(1, Ordering::Relaxed); + let sent = self + .sender + .send(SceneFragmentRenderCommand::Render { job, completion }) + .is_ok(); + if !sent { + self.in_flight.fetch_sub(1, Ordering::Relaxed); + return false; } + true } - fn push_layer( - &mut self, - blend: impl Into, - alpha: f32, - transform: Affine, - clip: &impl Shape, - ) { - match self { - Renderer::Active(v) => { - v.push_layer(blend, alpha, transform, clip); - } - Renderer::TinySkia(v) => v.push_layer(blend, alpha, transform, clip), - Renderer::Uninitialized { .. } => {} + fn render_for_capture(&self, job: SceneFragmentRenderJob) -> bool { + let (response_tx, response_rx) = mpsc::channel(); + self.in_flight.fetch_add(1, Ordering::Relaxed); + let sent = self + .sender + .send(SceneFragmentRenderCommand::RenderForCapture { + job, + response: response_tx, + }) + .is_ok(); + if !sent { + self.in_flight.fetch_sub(1, Ordering::Relaxed); + return false; } + response_rx.recv().unwrap_or(false) } - fn pop_layer(&mut self) { - match self { - Renderer::Active(v) => { - v.pop_layer(); - } - Renderer::TinySkia(v) => v.pop_layer(), - Renderer::Uninitialized { .. } => {} + fn shutdown(&mut self) { + let _ = self.sender.send(SceneFragmentRenderCommand::Shutdown); + self.join(); + } + + fn is_finished(&self) -> bool { + self.join_handle + .as_ref() + .is_some_and(thread::JoinHandle::is_finished) + } + + fn join(&mut self) { + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); } } +} - fn draw_img(&mut self, img: Img<'_>, rect: Rect) { - match self { - Renderer::Active(v) => { - v.draw_img(img, rect); - } - Renderer::TinySkia(v) => { - v.draw_img(img, rect); - } - Renderer::Uninitialized { .. } => {} +struct CpuRenderer { + name: &'static str, +} + +impl CpuRenderer { + fn new(_backend: impl ImageRenderer + 'static, name: &'static str) -> Self { + Self { name } + } +} + +struct GpuRenderer { + backend: GpuRendererBackend, + device: wgpu::Device, + queue: wgpu::Queue, + name: &'static str, +} + +#[allow( + dead_code, + reason = "Some GPU backend variants are only constructed when optional renderers are enabled." +)] +enum GpuRendererBackend { + Texture(Box>), + TextureView( + Box>, + ), +} + +impl GpuRenderer { + #[allow( + dead_code, + reason = "Texture-target GPU constructors are only used when optional renderers are enabled." + )] + fn provided_texture( + backend: impl TextureRenderer + 'static, + device: wgpu::Device, + queue: wgpu::Queue, + name: &'static str, + ) -> Self { + Self { + backend: GpuRendererBackend::Texture(Box::new(backend)), + device, + queue, + name, } } - fn draw_glyphs<'a>( - &mut self, - origin: Point, - props: &floem_renderer::text::GlyphRunProps<'a>, - glyphs: impl Iterator + 'a, - ) { - match self { - Renderer::Active(v) => v.draw_glyphs(origin, props, glyphs), - Renderer::TinySkia(v) => v.draw_glyphs(origin, props, glyphs), - Renderer::Uninitialized { .. } => {} + #[allow( + dead_code, + reason = "Texture-target GPU constructors are only used when optional renderers are enabled." + )] + fn owned_texture( + backend: impl TextureRenderer + 'static, + device: wgpu::Device, + queue: wgpu::Queue, + name: &'static str, + ) -> Self { + Self { + backend: GpuRendererBackend::Texture(Box::new(backend)), + device, + queue, + name, } } - fn draw_svg<'b>( - &mut self, - svg: floem_renderer::Svg<'b>, - rect: Rect, - brush: Option>>, - ) { - match self { - Renderer::Active(v) => { - v.draw_svg(svg, rect, brush); + fn provided_texture_view( + backend: impl TextureRenderer + + 'static, + device: wgpu::Device, + queue: wgpu::Queue, + name: &'static str, + ) -> Self { + Self { + backend: GpuRendererBackend::TextureView(Box::new(backend)), + device, + queue, + name, + } + } + + fn owned_texture_view( + backend: impl TextureRenderer + + 'static, + device: wgpu::Device, + queue: wgpu::Queue, + name: &'static str, + ) -> Self { + Self { + backend: GpuRendererBackend::TextureView(Box::new(backend)), + device, + queue, + name, + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn send_scene_fragment_ready( + completion: SceneFragmentRenderCompletion, + rendered: bool, + worker_index: usize, + render_start: Instant, + render_end: Instant, + gpu_end: Instant, +) { + if crate::frame_source::frame_pacing_diag_enabled() { + let now = Instant::now(); + eprintln!( + "floem scene fragment ready key={:?} kind={:?} rendered={} worker={} cpu={:.3}ms render_end_to_gpu={:.3}ms gpu_to_callback={:.3}ms total_ready={:.3}ms", + completion.key, + completion.kind, + rendered, + worker_index, + render_end + .saturating_duration_since(render_start) + .as_secs_f64() + * 1000.0, + gpu_end.saturating_duration_since(render_end).as_secs_f64() * 1000.0, + now.saturating_duration_since(gpu_end).as_secs_f64() * 1000.0, + now.saturating_duration_since(render_start).as_secs_f64() * 1000.0, + ); + } + Application::send_proxy_event(UserEvent::SceneFragmentReady { + window_id: completion.window_id, + key: completion.key, + signature: completion.signature, + kind: completion.kind, + rendered, + worker_index, + render_start, + render_end, + gpu_end, + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn send_scene_fragment_ready_after_gpu_work_done( + backend: &RendererSpec, + gpu_callback_pump: Option, + submission_index: Option, + completion: SceneFragmentRenderCompletion, + worker_index: usize, + render_start: Instant, + render_end: Instant, +) { + let Some((device, queue)) = backend.gpu_fence_resources() else { + send_scene_fragment_ready( + completion, + true, + worker_index, + render_start, + render_end, + render_end, + ); + return; + }; + let callback: Box = Box::new(move |gpu_end| { + send_scene_fragment_ready( + completion, + true, + worker_index, + render_start, + render_end, + gpu_end, + ); + }); + #[cfg(target_os = "macos")] + let callback = + match crate::gpu_completion::notify_after_metal_queue_completion(&queue, callback) { + Ok(()) => return, + Err(callback) => callback, + }; + #[cfg(not(target_os = "macos"))] + let callback = callback; + if let Some(pump) = gpu_callback_pump { + queue.on_submitted_work_done(move || { + callback(Instant::now()); + }); + pump.wait_for(submission_index); + return; + } + queue.on_submitted_work_done(move || { + callback(Instant::now()); + }); + let _ = device.poll(wgpu::PollType::Poll); +} + +#[cfg(not(target_arch = "wasm32"))] +fn render_into_existing_texture_with_external_images( + backend: &mut RendererSpec, + source: &mut dyn RenderSource, + size: Size, + texture: &wgpu::Texture, + external_images: ExternalImageResources, +) -> Option { + let width = size.width.max(1.0) as u32; + let height = size.height.max(1.0) as u32; + let mut resolver = ExternalImageResourceResolver::new(external_images); + match &mut backend.0 { + RendererSpecInner::Cpu(_) => None, + RendererSpecInner::Gpu { backend, .. } => match &mut backend.backend { + GpuRendererBackend::Texture(renderer) => renderer + .render_source_into_texture_with_external_images( + source, + texture.clone(), + &mut resolver, + ) + .ok() + .map(scene_fragment_render_result), + GpuRendererBackend::TextureView(renderer) => { + let view = texture.create_view(&wgpu::TextureViewDescriptor { + label: Some("floem render existing texture external-image target view"), + ..Default::default() + }); + renderer + .render_source_into_texture_with_external_images( + source, + TextureViewTarget::new(&view, width, height), + &mut resolver, + ) + .ok() + .map(scene_fragment_render_result) + } + }, + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn scene_fragment_render_result(submission: TextureRenderSubmission) -> SceneFragmentRenderResult { + SceneFragmentRenderResult { + submission_index: submission.submission_index, + } +} + +fn env_flag_requested(name: &str) -> bool { + std::env::var(name) + .ok() + .is_some_and(|value| value.as_str() == "1") +} + +pub(crate) fn force_cpu_requested() -> bool { + env_flag_requested("FLOEM_FORCE_CPU") || env_flag_requested("FLOEM_FORCE_TINY_SKIA") +} + +#[allow( + dead_code, + reason = "Used only when a GPU renderer feature with provided texture targets is enabled." +)] +fn pick_supported_texture_format( + surface_formats: &[wgpu::TextureFormat], + renderer_formats: &[wgpu::TextureFormat], +) -> Option { + surface_formats + .iter() + .copied() + .find(|format| renderer_formats.contains(format)) +} + +fn is_srgb_texture_format(format: wgpu::TextureFormat) -> bool { + matches!( + format, + wgpu::TextureFormat::Rgba8UnormSrgb | wgpu::TextureFormat::Bgra8UnormSrgb + ) +} + +fn choose_default_renderer(cx: NewRendererCx) -> Result { + #[allow( + unreachable_code, + reason = "Some feature combinations end the chooser earlier with a concrete fallback renderer." + )] + { + #[cfg(feature = "vello")] + if let Some(gpu) = cx.gpu() { + let fallback_surface_format = gpu + .surface_formats() + .iter() + .copied() + .find(|format| !is_srgb_texture_format(*format)); + let device = gpu.gpu_resources.device.clone(); + let queue = gpu.gpu_resources.queue.clone(); + let backend = + imaging_vello::VelloRenderer::new(device, queue).map_err(|err| err.to_string())?; + if let Some(surface_format) = pick_supported_texture_format( + gpu.surface_formats(), + &backend.supported_texture_formats(), + ) { + return Ok(cx.provided_texture_view_renderer(backend, surface_format, "Vello GPU")); } - Renderer::TinySkia(v) => { - v.draw_svg(svg, rect, brush); + if let Some(surface_format) = fallback_surface_format { + return Ok(cx.owned_texture_view_renderer(backend, surface_format, "Vello GPU")); } - Renderer::Uninitialized { .. } => {} } - } - fn set_transform(&mut self, transform: Affine) { - match self { - Renderer::Active(v) => { - v.set_transform(transform); + #[cfg(feature = "vger")] + if let Some(gpu) = cx.gpu() { + let fallback_surface_format = gpu + .surface_formats() + .iter() + .copied() + .find(|format| !is_srgb_texture_format(*format)); + let adapter = gpu.gpu_resources.adapter.clone(); + let device = gpu.gpu_resources.device.clone(); + let queue = gpu.gpu_resources.queue.clone(); + let width = cx.size.width.max(1.0) as u32; + let height = cx.size.height.max(1.0) as u32; + let backend = + floem_vger_renderer::VgerRenderer::new(adapter, device, queue, width, height) + .map_err(|err| err.to_string())?; + if let Some(surface_format) = pick_supported_texture_format( + gpu.surface_formats(), + &backend.supported_texture_formats(), + ) { + return Ok(cx.provided_texture_view_renderer(backend, surface_format, "Vger GPU")); } - Renderer::TinySkia(v) => { - v.set_transform(transform); + if let Some(surface_format) = fallback_surface_format { + return Ok(cx.owned_texture_view_renderer(backend, surface_format, "Vger GPU")); } - Renderer::Uninitialized { .. } => {} } - } - fn set_z_index(&mut self, z_index: i32) { - match self { - Renderer::Active(v) => { - v.set_z_index(z_index); + #[cfg(feature = "skia")] + if let Some(gpu) = cx.gpu() { + let fallback_surface_format = gpu + .surface_formats() + .iter() + .copied() + .find(|format| !(is_srgb_texture_format(*format))); + let adapter = gpu.gpu_resources.adapter.clone(); + let device = gpu.gpu_resources.device.clone(); + let queue = gpu.gpu_resources.queue.clone(); + let backend = imaging_skia::SkiaRenderer::new(adapter, device, queue) + .map_err(|err| err.to_string())?; + if let Some(surface_format) = pick_supported_texture_format( + gpu.surface_formats(), + &backend.supported_texture_formats(), + ) { + return Ok(cx.provided_texture_renderer(backend, surface_format, "Skia GPU")); } - Renderer::TinySkia(v) => { - v.set_z_index(z_index); + if let Some(surface_format) = fallback_surface_format { + return Ok(cx.owned_texture_renderer(backend, surface_format, "Skia GPU")); } - Renderer::Uninitialized { .. } => {} } - } - fn finish(&mut self) -> Option { - match self { - Renderer::Active(r) => r.finish(), - Renderer::TinySkia(r) => r.finish(), - Renderer::Uninitialized { .. } => None, + #[cfg(feature = "vello-hybrid")] + if let Some(gpu) = cx.gpu() { + let fallback_surface_format = gpu + .surface_formats() + .iter() + .copied() + .find(|format| !is_srgb_texture_format(*format)); + let device = gpu.gpu_resources.device.clone(); + let queue = gpu.gpu_resources.queue.clone(); + let backend = imaging_vello_hybrid::VelloHybridRenderer::new(device, queue); + if let Some(surface_format) = pick_supported_texture_format( + gpu.surface_formats(), + &backend.supported_texture_formats(), + ) { + return Ok(cx.provided_texture_view_renderer( + backend, + surface_format, + "Vello Hybrid GPU", + )); + } + if let Some(surface_format) = fallback_surface_format { + return Ok(cx.owned_texture_view_renderer( + backend, + surface_format, + "Vello Hybrid GPU", + )); + } + } + + #[cfg(feature = "vello-cpu")] + { + let width = u16::try_from(cx.size.width.max(1.0) as u32) + .map_err(|_| "width exceeds vello cpu limit".to_string())?; + let height = u16::try_from(cx.size.height.max(1.0) as u32) + .map_err(|_| "height exceeds vello cpu limit".to_string())?; + let backend = imaging_vello_cpu::VelloCpuRenderer::new(width, height); + return Ok(cx.image_renderer(backend, "Vello CPU")); + } + + #[cfg(feature = "skia-cpu")] + { + let backend = imaging_skia::SkiaCpuRenderer::new(); + return Ok(cx.image_renderer(backend, "Skia CPU")); } + + #[cfg(feature = "tiny-skia")] + { + let width = cx.size.width.max(1.0) as u32; + let height = cx.size.height.max(1.0) as u32; + let backend = imaging_tiny_skia::TinySkiaRenderer::new_with_size(width, height) + .map_err(|err| err.to_string())?; + return Ok(cx.image_renderer(backend, "Tiny Skia CPU")); + } + + #[cfg(feature = "vello-cpu")] + { + let width = u16::try_from(cx.size.width.max(1.0) as u32) + .map_err(|_| "width exceeds vello_cpu limit".to_string())?; + let height = u16::try_from(cx.size.height.max(1.0) as u32) + .map_err(|_| "height exceeds vello_cpu limit".to_string())?; + let backend = imaging_vello_cpu::VelloCpuRenderer::new(width, height); + return Ok(cx.image_renderer(backend, "Vello CPU")); + } + + Err("no renderer available for this window target".to_string()) } } + +pub(crate) fn default_renderer() -> RendererChooser { + Arc::new(|cx| choose_default_renderer(cx).expect("create renderer")) +} diff --git a/src/platform/context_menu.rs b/src/platform/context_menu.rs index a30b935f9..fe015aec3 100644 --- a/src/platform/context_menu.rs +++ b/src/platform/context_menu.rs @@ -11,10 +11,9 @@ use ui_events::keyboard::{Key, NamedKey}; use crate::context::VisualChangedListener; use crate::event::listener; -use crate::platform::menu::MudaMenu; +use crate::platform::menu::{MenuEntry, MenuSpec}; use crate::style::CursorStyle; -use crate::platform::menu_types; use crate::unit::UnitExt; use crate::view::{IntoView, View}; use crate::views::{Container, Decorators, Label, Stack, svg}; @@ -30,78 +29,78 @@ enum MenuDisplay { }, } -fn format_menu(menu: &MudaMenu) -> Vec { +fn format_menu(menu: &MenuSpec) -> Vec { menu.items() .iter() .enumerate() .map(|(s, item)| match item { - menu_types::MenuItemKind::MenuItem(menu_item) => MenuDisplay::Item { + MenuEntry::Item(menu_item) => MenuDisplay::Item { id: Some(menu_item.id().as_ref().to_string()), enabled: menu_item.is_enabled(), - title: menu_item.text().to_string(), + title: format_item_text(menu_item.text(), menu_item.is_checked()), children: None, }, - menu_types::MenuItemKind::Submenu(submenu) => MenuDisplay::Item { + MenuEntry::Submenu(submenu) => MenuDisplay::Item { id: None, enabled: submenu.is_enabled(), title: submenu.text().to_string(), children: Some(format_submenu(submenu)), }, - menu_types::MenuItemKind::Predefined(_) => MenuDisplay::Separator(s), - menu_types::MenuItemKind::Check(check_item) => MenuDisplay::Item { - id: Some(check_item.id().as_ref().to_string()), - enabled: check_item.is_enabled(), - title: check_item.text().to_string(), - children: None, - }, - menu_types::MenuItemKind::Icon(icon_item) => MenuDisplay::Item { - id: Some(icon_item.id().as_ref().to_string()), - enabled: icon_item.is_enabled(), - title: icon_item.text().to_string(), - children: None, - }, + MenuEntry::Predefined(predefined) => predefined_display(s, predefined), }) .collect() } -fn format_submenu(submenu: &menu_types::Submenu) -> Vec { +fn format_submenu(submenu: &crate::platform::menu::SubmenuSpec) -> Vec { submenu .items() .iter() .enumerate() .map(|(s, item)| match item { - menu_types::MenuItemKind::MenuItem(menu_item) => MenuDisplay::Item { + MenuEntry::Item(menu_item) => MenuDisplay::Item { id: Some(menu_item.id().as_ref().to_string()), enabled: menu_item.is_enabled(), - title: menu_item.text().to_string(), + title: format_item_text(menu_item.text(), menu_item.is_checked()), children: None, }, - menu_types::MenuItemKind::Submenu(nested_submenu) => MenuDisplay::Item { + MenuEntry::Submenu(nested_submenu) => MenuDisplay::Item { id: None, enabled: nested_submenu.is_enabled(), title: nested_submenu.text().to_string(), children: Some(format_submenu(nested_submenu)), }, - menu_types::MenuItemKind::Predefined(_) => MenuDisplay::Separator(s), - menu_types::MenuItemKind::Check(check_item) => MenuDisplay::Item { - id: Some(check_item.id().as_ref().to_string()), - enabled: check_item.is_enabled(), - title: check_item.text().to_string(), - children: None, - }, - menu_types::MenuItemKind::Icon(icon_item) => MenuDisplay::Item { - id: Some(icon_item.id().as_ref().to_string()), - enabled: icon_item.is_enabled(), - title: icon_item.text().to_string(), - children: None, - }, + MenuEntry::Predefined(predefined) => predefined_display(s, predefined), }) .collect() } +fn format_item_text(text: &str, checked: Option) -> String { + match checked { + Some(true) => format!("✓ {text}"), + Some(false) => format!(" {text}"), + None => text.to_string(), + } +} + +fn predefined_display( + index: usize, + item: &crate::platform::menu::PredefinedMenuItem, +) -> MenuDisplay { + if item.is_separator() { + MenuDisplay::Separator(index) + } else { + MenuDisplay::Item { + id: None, + enabled: true, + title: "System Item".to_string(), + children: None, + } + } +} + pub(crate) fn context_menu_view( cx: Scope, - context_menu: RwSignal>, + context_menu: RwSignal>, window_size: RwSignal, ) -> impl IntoView { use crate::{ @@ -112,14 +111,14 @@ pub(crate) fn context_menu_view( let context_menu_items = cx.create_memo(move |_| { context_menu.with(|menu| { menu.as_ref() - .map(|(menu, _, _): &(MudaMenu, Point, bool)| format_menu(menu)) + .map(|(menu, _, _): &(MenuSpec, Point, bool)| format_menu(menu)) }) }); let context_menu_size = cx.create_rw_signal(Size::ZERO); fn view_fn( menu: MenuDisplay, - context_menu: RwSignal>, + context_menu: RwSignal>, on_child_submenu_for_parent: RwSignal, ) -> impl IntoView { match menu { diff --git a/src/platform/menu.rs b/src/platform/menu.rs index 3aebaa0a0..6d267576c 100644 --- a/src/platform/menu.rs +++ b/src/platform/menu.rs @@ -1,12 +1,50 @@ -use super::menu_types::{ - Accelerator, CheckMenuItem, Icon, IconMenuItem, IsMenuItem, MenuId, MenuItem as MudaMenuItem, - NativeIcon, PredefinedMenuItem, -}; +pub use super::menu_types::{Accelerator, Icon, MenuId, NativeIcon}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(not(target_arch = "wasm32"))] pub use super::menu_types::{Menu as MudaMenu, Submenu as MudaSubmenu}; +#[cfg(target_arch = "wasm32")] +pub use super::menu_types::{Menu as MudaMenu, Submenu as MudaSubmenu}; + +#[cfg(not(target_arch = "wasm32"))] +pub use muda::{AboutMetadata, AboutMetadataBuilder}; + +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Debug, Default)] +pub struct AboutMetadata; + +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Debug, Default)] +pub struct AboutMetadataBuilder(AboutMetadata); + +#[cfg(target_arch = "wasm32")] +impl AboutMetadataBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn name(self, _name: Option<&str>) -> Self { + self + } + + pub fn license(self, _license: Option<&str>) -> Self { + self + } + + pub fn version(self, _version: Option<&str>) -> Self { + self + } + + pub fn copyright(self, _copyright: Option<&str>) -> Self { + self + } + + pub fn build(self) -> AboutMetadata { + self.0 + } +} static COUNTER: AtomicU64 = AtomicU64::new(0); @@ -26,6 +64,215 @@ pub struct HasNativeIcon; // Type state markers for icon configuration pub struct NoIcon; +#[derive(Clone)] +pub(crate) struct MenuSpec { + items: Vec, +} + +impl MenuSpec { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn items(&self) -> &[MenuEntry] { + &self.items + } + + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn into_muda_menu(self) -> MudaMenu { + let menu = MudaMenu::new(); + for item in self.items { + append_muda_entry(&menu, item); + } + menu + } +} + +#[derive(Clone)] +pub(crate) enum MenuEntry { + Item(MenuItemSpec), + Submenu(SubmenuSpec), + Predefined(PredefinedMenuItem), +} + +#[derive(Clone)] +pub(crate) struct MenuItemSpec { + id: MenuId, + text: String, + enabled: bool, + accelerator: Option, + kind: MenuItemKind, +} + +impl MenuItemSpec { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn id(&self) -> &MenuId { + &self.id + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn text(&self) -> &str { + &self.text + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn is_enabled(&self) -> bool { + self.enabled + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn is_checked(&self) -> Option { + match self.kind { + MenuItemKind::Plain | MenuItemKind::Icon { .. } | MenuItemKind::NativeIcon { .. } => { + None + } + MenuItemKind::Check { checked } => Some(checked), + } + } +} + +#[derive(Clone)] +enum MenuItemKind { + Plain, + Check { checked: bool }, + Icon { icon: Icon }, + NativeIcon { native_icon: NativeIcon }, +} + +#[derive(Clone)] +pub(crate) struct SubmenuSpec { + text: String, + enabled: bool, + icon: Option, + native_icon: Option, + items: Vec, +} + +impl SubmenuSpec { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn text(&self) -> &str { + &self.text + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn is_enabled(&self) -> bool { + self.enabled + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn items(&self) -> &[MenuEntry] { + &self.items + } +} + +/// A data-only predefined menu item. +/// +/// Floem stores predefined items as descriptors so menus can be created on the +/// UI thread and converted to native `muda` menu items later on the main thread. +#[derive(Clone)] +pub enum PredefinedMenuItem { + Separator, + Copy(Option), + Cut(Option), + Paste(Option), + SelectAll(Option), + Undo(Option), + Redo(Option), + Minimize(Option), + Maximize(Option), + Fullscreen(Option), + Hide(Option), + HideOthers(Option), + ShowAll(Option), + CloseWindow(Option), + Quit(Option), + About { + text: Option, + metadata: Option, + }, + Services(Option), + BringAllToFront(Option), +} + +impl PredefinedMenuItem { + pub fn separator() -> Self { + Self::Separator + } + + pub fn copy(text: Option<&str>) -> Self { + Self::Copy(text.map(ToOwned::to_owned)) + } + + pub fn cut(text: Option<&str>) -> Self { + Self::Cut(text.map(ToOwned::to_owned)) + } + + pub fn paste(text: Option<&str>) -> Self { + Self::Paste(text.map(ToOwned::to_owned)) + } + + pub fn select_all(text: Option<&str>) -> Self { + Self::SelectAll(text.map(ToOwned::to_owned)) + } + + pub fn undo(text: Option<&str>) -> Self { + Self::Undo(text.map(ToOwned::to_owned)) + } + + pub fn redo(text: Option<&str>) -> Self { + Self::Redo(text.map(ToOwned::to_owned)) + } + + pub fn minimize(text: Option<&str>) -> Self { + Self::Minimize(text.map(ToOwned::to_owned)) + } + + pub fn maximize(text: Option<&str>) -> Self { + Self::Maximize(text.map(ToOwned::to_owned)) + } + + pub fn fullscreen(text: Option<&str>) -> Self { + Self::Fullscreen(text.map(ToOwned::to_owned)) + } + + pub fn hide(text: Option<&str>) -> Self { + Self::Hide(text.map(ToOwned::to_owned)) + } + + pub fn hide_others(text: Option<&str>) -> Self { + Self::HideOthers(text.map(ToOwned::to_owned)) + } + + pub fn show_all(text: Option<&str>) -> Self { + Self::ShowAll(text.map(ToOwned::to_owned)) + } + + pub fn close_window(text: Option<&str>) -> Self { + Self::CloseWindow(text.map(ToOwned::to_owned)) + } + + pub fn quit(text: Option<&str>) -> Self { + Self::Quit(text.map(ToOwned::to_owned)) + } + + pub fn about(text: Option<&str>, metadata: Option) -> Self { + Self::About { + text: text.map(ToOwned::to_owned), + metadata, + } + } + + pub fn services(text: Option<&str>) -> Self { + Self::Services(text.map(ToOwned::to_owned)) + } + + pub fn bring_all_to_front(text: Option<&str>) -> Self { + Self::BringAllToFront(text.map(ToOwned::to_owned)) + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] + pub(crate) fn is_separator(&self) -> bool { + matches!(self, Self::Separator) + } +} + pub struct ItemBuilder { id: MenuId, text: String, @@ -33,7 +280,6 @@ pub struct ItemBuilder { accelerator: Option, action: Option, state: std::marker::PhantomData, - // State-specific data checked: Option, icon: Option, native_icon: Option, @@ -97,7 +343,6 @@ impl ItemBuilder { } } -// Common methods available on all item states impl ItemBuilder { pub fn enabled(mut self, enabled: bool) -> Self { self.enabled = enabled; @@ -116,19 +361,20 @@ impl ItemBuilder { } trait BuildMenuItem { - fn build_menu_item(self) -> Box; + fn build_menu_item(&self) -> MenuItemSpec; fn get_id(&self) -> &MenuId; fn take_action(&mut self) -> Option; } impl BuildMenuItem for ItemBuilder { - fn build_menu_item(self) -> Box { - Box::new(MudaMenuItem::with_id( - self.id.clone(), - &self.text, - self.enabled, - self.accelerator, - )) + fn build_menu_item(&self) -> MenuItemSpec { + MenuItemSpec { + id: self.id.clone(), + text: self.text.clone(), + enabled: self.enabled, + accelerator: self.accelerator.clone(), + kind: MenuItemKind::Plain, + } } fn get_id(&self) -> &MenuId { @@ -141,14 +387,16 @@ impl BuildMenuItem for ItemBuilder { } impl BuildMenuItem for ItemBuilder { - fn build_menu_item(self) -> Box { - Box::new(CheckMenuItem::with_id( - self.id.clone(), - &self.text, - self.enabled, - self.checked.unwrap_or(false), - self.accelerator, - )) + fn build_menu_item(&self) -> MenuItemSpec { + MenuItemSpec { + id: self.id.clone(), + text: self.text.clone(), + enabled: self.enabled, + accelerator: self.accelerator.clone(), + kind: MenuItemKind::Check { + checked: self.checked.unwrap_or(false), + }, + } } fn get_id(&self) -> &MenuId { @@ -161,14 +409,16 @@ impl BuildMenuItem for ItemBuilder { } impl BuildMenuItem for ItemBuilder { - fn build_menu_item(self) -> Box { - Box::new(IconMenuItem::with_id( - self.id.clone(), - &self.text, - self.enabled, - self.icon, - self.accelerator, - )) + fn build_menu_item(&self) -> MenuItemSpec { + MenuItemSpec { + id: self.id.clone(), + text: self.text.clone(), + enabled: self.enabled, + accelerator: self.accelerator.clone(), + kind: MenuItemKind::Icon { + icon: self.icon.clone().expect("icon item must contain an icon"), + }, + } } fn get_id(&self) -> &MenuId { @@ -181,16 +431,19 @@ impl BuildMenuItem for ItemBuilder { } impl BuildMenuItem for ItemBuilder { - fn build_menu_item(self) -> Box { - let item = IconMenuItem::with_id( - self.id.clone(), - &self.text, - self.enabled, - self.icon, - self.accelerator, - ); - item.set_native_icon(self.native_icon); - Box::new(item) + fn build_menu_item(&self) -> MenuItemSpec { + MenuItemSpec { + id: self.id.clone(), + text: self.text.clone(), + enabled: self.enabled, + accelerator: self.accelerator.clone(), + kind: MenuItemKind::NativeIcon { + native_icon: self + .native_icon + .clone() + .expect("native icon item must contain an icon"), + }, + } } fn get_id(&self) -> &MenuId { @@ -202,95 +455,97 @@ impl BuildMenuItem for ItemBuilder { } } -pub type Menu = MenuBuilder; -pub type SubMenu = MenuBuilder; +#[derive(Clone, Default)] +pub struct MenuRoot; + +#[derive(Clone)] +pub struct MenuSubmenu { + text: String, + enabled: bool, + icon: Option, + native_icon: Option, +} + +pub type Menu = MenuBuilder; +pub type SubMenu = MenuBuilder; -pub struct MenuBuilder { +pub struct MenuBuilder { menu: MenuType, + items: Vec, actions: HashMap, - icon: std::marker::PhantomData, + icon: std::marker::PhantomData, } -impl MenuBuilder { +impl MenuBuilder { pub fn new() -> Self { Self { - menu: MudaMenu::new(), + menu: MenuRoot, + items: Vec::new(), actions: HashMap::new(), icon: std::marker::PhantomData, } } - pub(crate) fn build(self) -> (MudaMenu, HashMap) { - (self.menu, self.actions) + pub(crate) fn build(self) -> (MenuSpec, HashMap) { + (MenuSpec { items: self.items }, self.actions) } } -impl Default for MenuBuilder { +impl Default for MenuBuilder { fn default() -> Self { Self::new() } } -impl MenuBuilder { +impl MenuBuilder { fn new_submenu(text: impl AsRef, enabled: bool) -> Self { Self { - menu: MudaSubmenu::new(text.as_ref(), enabled), + menu: MenuSubmenu { + text: text.as_ref().to_string(), + enabled, + icon: None, + native_icon: None, + }, + items: Vec::new(), actions: HashMap::new(), icon: std::marker::PhantomData, } } - pub fn icon(self, icon: Icon) -> MenuBuilder { - self.menu.set_icon(Some(icon)); + pub fn icon(mut self, icon: Icon) -> MenuBuilder { + self.menu.icon = Some(icon); MenuBuilder { menu: self.menu, + items: self.items, actions: self.actions, icon: std::marker::PhantomData, } } pub fn native_icon( - self, + mut self, native_icon: NativeIcon, - ) -> MenuBuilder { - self.menu.set_native_icon(Some(native_icon)); + ) -> MenuBuilder { + self.menu.native_icon = Some(native_icon); MenuBuilder { menu: self.menu, + items: self.items, actions: self.actions, icon: std::marker::PhantomData, } } } -impl MenuBuilder { - pub fn enabled(self, enabled: bool) -> Self { - self.menu.set_enabled(enabled); +impl MenuBuilder { + pub fn enabled(mut self, enabled: bool) -> Self { + self.menu.enabled = enabled; self } } -trait AppendItem { - fn append_item(&self, item: &dyn IsMenuItem); -} - -impl AppendItem for MudaMenu { - fn append_item(&self, item: &dyn IsMenuItem) { - let _ = self.append(item); - } -} - -impl AppendItem for MudaSubmenu { - fn append_item(&self, item: &dyn IsMenuItem) { - let _ = self.append(item); - } -} - -// Common methods available on all states +// Common methods available on all states. #[allow(private_bounds)] -impl MenuBuilder -where - MenuType: AppendItem, -{ +impl MenuBuilder { pub fn item(mut self, text: impl AsRef, props: P) -> Self where P: FnOnce(ItemBuilder) -> T, @@ -301,7 +556,7 @@ where let action = builder.take_action(); let menu_item = builder.build_menu_item(); - self.menu.append_item(menu_item.as_ref()); + self.items.push(MenuEntry::Item(menu_item)); if let Some(action) = action { self.actions.insert(id, action); @@ -310,33 +565,143 @@ where self } - pub fn separator(self) -> Self { - let separator = PredefinedMenuItem::separator(); - self.menu.append_item(&separator); + pub fn separator(mut self) -> Self { + self.items + .push(MenuEntry::Predefined(PredefinedMenuItem::separator())); self } - pub fn predefined(self, predefined: &PredefinedMenuItem) -> Self { - self.menu.append_item(predefined); - self - } - - pub fn muda(self, item: &dyn IsMenuItem) -> Self { - self.menu.append_item(item); + pub fn predefined(mut self, predefined: &PredefinedMenuItem) -> Self { + self.items.push(MenuEntry::Predefined(predefined.clone())); self } pub fn submenu( mut self, text: impl AsRef, - menu_builder: impl FnOnce(SubMenu) -> MenuBuilder, + menu_builder: impl FnOnce(SubMenu) -> MenuBuilder, ) -> Self { let submenu_builder = MenuBuilder::new_submenu(text, true); let built_submenu = menu_builder(submenu_builder); - - self.menu.append_item(&built_submenu.menu); + let spec = SubmenuSpec { + text: built_submenu.menu.text, + enabled: built_submenu.menu.enabled, + icon: built_submenu.menu.icon, + native_icon: built_submenu.menu.native_icon, + items: built_submenu.items, + }; + + self.items.push(MenuEntry::Submenu(spec)); self.actions.extend(built_submenu.actions); self } } + +#[cfg(not(target_arch = "wasm32"))] +trait MudaAppend { + fn append_menu_item(&self, item: &dyn muda::IsMenuItem); +} + +#[cfg(not(target_arch = "wasm32"))] +impl MudaAppend for muda::Menu { + fn append_menu_item(&self, item: &dyn muda::IsMenuItem) { + let _ = self.append(item); + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl MudaAppend for muda::Submenu { + fn append_menu_item(&self, item: &dyn muda::IsMenuItem) { + let _ = self.append(item); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn append_muda_entry(parent: &impl MudaAppend, entry: MenuEntry) { + match entry { + MenuEntry::Item(item) => { + let platform_item: Box = match item.kind { + MenuItemKind::Plain => Box::new(muda::MenuItem::with_id( + item.id, + &item.text, + item.enabled, + item.accelerator, + )), + MenuItemKind::Check { checked } => Box::new(muda::CheckMenuItem::with_id( + item.id, + &item.text, + item.enabled, + checked, + item.accelerator, + )), + MenuItemKind::Icon { icon } => Box::new(muda::IconMenuItem::with_id( + item.id, + &item.text, + item.enabled, + Some(icon), + item.accelerator, + )), + MenuItemKind::NativeIcon { native_icon } => { + let item = muda::IconMenuItem::with_id( + item.id, + &item.text, + item.enabled, + None, + item.accelerator, + ); + item.set_native_icon(Some(native_icon)); + Box::new(item) + } + }; + parent.append_menu_item(platform_item.as_ref()); + } + MenuEntry::Submenu(submenu) => { + let platform_submenu = muda::Submenu::new(&submenu.text, submenu.enabled); + if let Some(icon) = submenu.icon { + platform_submenu.set_icon(Some(icon)); + } + if let Some(native_icon) = submenu.native_icon { + platform_submenu.set_native_icon(Some(native_icon)); + } + for item in submenu.items { + append_muda_entry(&platform_submenu, item); + } + parent.append_menu_item(&platform_submenu); + } + MenuEntry::Predefined(predefined) => { + let predefined = predefined.into_muda(); + parent.append_menu_item(&predefined); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl PredefinedMenuItem { + fn into_muda(self) -> muda::PredefinedMenuItem { + match self { + Self::Separator => muda::PredefinedMenuItem::separator(), + Self::Copy(text) => muda::PredefinedMenuItem::copy(text.as_deref()), + Self::Cut(text) => muda::PredefinedMenuItem::cut(text.as_deref()), + Self::Paste(text) => muda::PredefinedMenuItem::paste(text.as_deref()), + Self::SelectAll(text) => muda::PredefinedMenuItem::select_all(text.as_deref()), + Self::Undo(text) => muda::PredefinedMenuItem::undo(text.as_deref()), + Self::Redo(text) => muda::PredefinedMenuItem::redo(text.as_deref()), + Self::Minimize(text) => muda::PredefinedMenuItem::minimize(text.as_deref()), + Self::Maximize(text) => muda::PredefinedMenuItem::maximize(text.as_deref()), + Self::Fullscreen(text) => muda::PredefinedMenuItem::fullscreen(text.as_deref()), + Self::Hide(text) => muda::PredefinedMenuItem::hide(text.as_deref()), + Self::HideOthers(text) => muda::PredefinedMenuItem::hide_others(text.as_deref()), + Self::ShowAll(text) => muda::PredefinedMenuItem::show_all(text.as_deref()), + Self::CloseWindow(text) => muda::PredefinedMenuItem::close_window(text.as_deref()), + Self::Quit(text) => muda::PredefinedMenuItem::quit(text.as_deref()), + Self::About { text, metadata } => { + muda::PredefinedMenuItem::about(text.as_deref(), metadata) + } + Self::Services(text) => muda::PredefinedMenuItem::services(text.as_deref()), + Self::BringAllToFront(text) => { + muda::PredefinedMenuItem::bring_all_to_front(text.as_deref()) + } + } + } +} diff --git a/src/platform/menu_types.rs b/src/platform/menu_types.rs index 9b6fc2c63..692d44dc5 100644 --- a/src/platform/menu_types.rs +++ b/src/platform/menu_types.rs @@ -4,6 +4,7 @@ //! using muda on native platforms and wasm_stubs on wasm32. #[cfg(not(target_arch = "wasm32"))] +#[allow(unused_imports)] pub use muda::{ CheckMenuItem, Icon, IconMenuItem, IsMenuItem, Menu, MenuId, MenuItem, NativeIcon, PredefinedMenuItem, Submenu, accelerator::Accelerator, diff --git a/src/style/components.rs b/src/style/components.rs index 24cc2291a..318532378 100644 --- a/src/style/components.rs +++ b/src/style/components.rs @@ -3,11 +3,12 @@ //! This module provides composite style types that represent multi-value //! CSS properties like borders and padding. -use floem_renderer::text::FontWeight; +use crate::effects::Brush; +use crate::text::FontWeight; use parley::style::{OverflowWrap, WordBreakStrength}; +use peniko::Color; use peniko::color::palette; use peniko::kurbo::Stroke; -use peniko::{Brush, Color}; use crate::theme::StyleThemeExt; use crate::unit::{FontSizeCx, Length, LengthAuto}; diff --git a/src/style/cx.rs b/src/style/cx.rs index d26dcfd6b..dcc7646ee 100644 --- a/src/style/cx.rs +++ b/src/style/cx.rs @@ -17,7 +17,6 @@ use web_time::Instant; use crate::{ ElementId, animate::{AnimStateKind, RepeatMode}, - inspector::CaptureState, style::{ StyleClassRef, recalc::{StyleReason, StyleReasonFlags}, @@ -131,6 +130,15 @@ pub struct StyleCx<'a> { impl<'a> StyleCx<'a> { pub fn new(window_state: &'a mut WindowState, view_id: ViewId, reason: StyleReason) -> Self { + Self::new_at(window_state, view_id, reason, Instant::now()) + } + + pub(crate) fn new_at( + window_state: &'a mut WindowState, + view_id: ViewId, + reason: StyleReason, + now: Instant, + ) -> Self { // Get the style parent: either custom style_cx_parent or DOM parent let style_parent = view_id .state() @@ -221,7 +229,7 @@ impl<'a> StyleCx<'a> { inherited, class_context, direct: Default::default(), - now: Instant::now(), + now, view_interact_state, reason, targeted_elements, @@ -303,7 +311,7 @@ impl<'a> StyleCx<'a> { if self.reason.needs_animation() { let has_active_animation = view_state .borrow_mut() - .apply_animations(&mut self.view_interact_state); + .apply_animations_at(&mut self.view_interact_state, self.now); if has_active_animation { self.window_state .schedule_style(view_id, StyleReason::animation()); @@ -426,7 +434,7 @@ impl<'a> StyleCx<'a> { vs.view_transform_props .read_explicit(&computed, &self.now, &mut transitioning); if box_tree_changed { - view_id.request_box_tree_update_for_view(); + self.window_state.request_box_tree_update_for_view(view_id); } let old_cursor = vs.style_cursor; @@ -468,8 +476,6 @@ impl<'a> StyleCx<'a> { .mark_descendants_with_selector_dirty(view_id, super::StyleSelector::Disabled); } - CaptureState::capture_style(view_id, self, view_state.borrow().computed_style.clone()); - if did_refresh_style || self.reason.has_transition() || self.reason.has_visiblity() { // ───────────────────────────────────────────────────────────────────── // Phase 8: visibility transitions and set taffy style @@ -490,12 +496,12 @@ impl<'a> StyleCx<'a> { phase.transition( computed_display, || { - let count = animations_on_remove(view_id, Scope::current()); + let count = animations_on_remove(view_id, Scope::current(), self.now); view_state.borrow_mut().num_waiting_animations = count; count > 0 }, - || animations_on_create(view_id), - || stop_reset_remove_animations(view_id), + || animations_on_create(view_id, self.now), + || stop_reset_remove_animations(view_id, self.now), || view_state.borrow().num_waiting_animations, ); @@ -523,6 +529,7 @@ impl<'a> StyleCx<'a> { } if taffy_style != vs.taffy_style { + let display_changed = taffy_style.display != vs.taffy_style.display; let taffy_node = vs.layout_id; vs.taffy_style = taffy_style.clone(); view_id @@ -530,17 +537,20 @@ impl<'a> StyleCx<'a> { .borrow_mut() .set_style(taffy_node, taffy_style.clone()) .unwrap(); - if !is_hidden_final { + if !is_hidden_final || display_changed { self.window_state.needs_layout = true; } } // ───────────────────────────────────────────────────────────────────── // Phase 8.2: Update box tree visiblity dependent props (must happen after visibility phase override) // ───────────────────────────────────────────────────────────────────── + let element_id = vs.element_id; let focus_nav_changed; + let visibility_changed; + let wants_layer_changed; + let layer_frame_rate_changed; { let box_tree = view_id.box_tree(); - let element_id = vs.element_id; let box_tree = &mut box_tree.borrow_mut(); let old_flags = box_tree.flags(element_id.0).unwrap_or(NodeFlags::empty()); let old_focus = box_tree.focus_nav_meta(element_id.0).unwrap_or_default(); @@ -555,6 +565,8 @@ impl<'a> StyleCx<'a> { if !is_hidden_final { flags |= NodeFlags::VISIBLE; } + visibility_changed = old_flags.contains(NodeFlags::VISIBLE) + != flags.contains(NodeFlags::VISIBLE); if old_flags != flags { box_tree.set_flags(element_id.0, flags); } @@ -580,15 +592,26 @@ impl<'a> StyleCx<'a> { if old_z_index != new_z_index { box_tree.set_z_index(element_id.0, new_z_index); } + + let new_wants_layer = vs.combined_style.builtin().wants_layer(); + wants_layer_changed = box_tree.set_wants_layer(element_id.0, new_wants_layer); + let new_layer_frame_rate = vs.combined_style.builtin().layer_frame_rate(); + layer_frame_rate_changed = + box_tree.set_layer_frame_rate(element_id.0, new_layer_frame_rate); } if focus_nav_changed { self.window_state.invalidate_focus_nav_cache(); } // ───────────────────────────────────────────────────────────────────── - // Phase 8.3: request paint for view style changes if not hidden + // Phase 8.3: request paint for visible style changes and visibility flips. + // Hidden nodes are omitted from the retained display list, so hiding a node must + // schedule a rebuild that lets compositor layers/surfaces for that node be removed. // ───────────────────────────────────────────────────────────────────── - if !is_hidden_final && need_paint { - self.window_state.request_paint(view_id); + if visibility_changed + || (!is_hidden_final + && (need_paint || wants_layer_changed || layer_frame_rate_changed)) + { + self.window_state.request_paint(element_id); } } } @@ -757,7 +780,7 @@ impl<'a> StyleCx<'a> { // Animation helper functions used by StyleCx::style_view -fn animations_on_remove(id: ViewId, scope: Scope) -> u16 { +fn animations_on_remove(id: ViewId, scope: Scope, now: Instant) -> u16 { let mut wait_for = 0; let state = id.state(); let mut state = state.borrow_mut(); @@ -766,7 +789,7 @@ fn animations_on_remove(id: ViewId, scope: Scope) -> u16 { let mut request_style = false; for anim in animations { if anim.run_on_remove && !matches!(anim.repeat_mode, RepeatMode::LoopForever) { - anim.reverse_mut(); + anim.reverse_mut_at(now); request_style = true; wait_for += 1; let trigger = anim.on_visual_complete; @@ -783,12 +806,12 @@ fn animations_on_remove(id: ViewId, scope: Scope) -> u16 { id.request_style(StyleReason::animation()); } - id.children() - .into_iter() - .fold(wait_for, |acc, id| acc + animations_on_remove(id, scope)) + id.children().into_iter().fold(wait_for, |acc, id| { + acc + animations_on_remove(id, scope, now) + }) } -fn stop_reset_remove_animations(id: ViewId) { +fn stop_reset_remove_animations(id: ViewId, now: Instant) { let state = id.state(); let mut state = state.borrow_mut(); let animations = &mut state.animations.stack; @@ -798,7 +821,7 @@ fn stop_reset_remove_animations(id: ViewId) { && anim.state_kind() == AnimStateKind::PassInProgress && !matches!(anim.repeat_mode, RepeatMode::LoopForever) { - anim.start_mut(); + anim.start_mut_at(now); request_style = true; } } @@ -809,10 +832,10 @@ fn stop_reset_remove_animations(id: ViewId) { id.children() .into_iter() - .for_each(stop_reset_remove_animations) + .for_each(|id| stop_reset_remove_animations(id, now)) } -fn animations_on_create(id: ViewId) { +fn animations_on_create(id: ViewId, now: Instant) { let state = id.state(); let mut state = state.borrow_mut(); state.num_waiting_animations = 0; @@ -820,7 +843,7 @@ fn animations_on_create(id: ViewId) { let mut request_style = false; for anim in animations { if anim.run_on_create && !matches!(anim.repeat_mode, RepeatMode::LoopForever) { - anim.start_mut(); + anim.start_mut_at(now); request_style = true; } } @@ -829,5 +852,7 @@ fn animations_on_create(id: ViewId) { id.request_style(StyleReason::animation()); } - id.children().into_iter().for_each(animations_on_create); + id.children() + .into_iter() + .for_each(|id| animations_on_create(id, now)); } diff --git a/src/style/mod.rs b/src/style/mod.rs index cf190e587..45570f1e0 100644 --- a/src/style/mod.rs +++ b/src/style/mod.rs @@ -127,10 +127,12 @@ //! //! You can create custom extractors and embed them in your custom views so that you can get out any built in prop, or any of your custom props from the final combined style that is applied to your `View`. -use floem_renderer::text::FontWeight as FontWeightProp; +use crate::effects::{Brush, Filter}; +use crate::frame::FrameRatePreference; +use crate::text::FontWeight as FontWeightProp; use peniko::color::palette; use peniko::kurbo::{self, Affine, RoundedRect, Stroke, Vec2}; -use peniko::{Brush, Color}; +use peniko::{BlendMode, Color}; use rustc_hash::FxHashMap; use smallvec::SmallVec; use std::any::Any; @@ -201,6 +203,7 @@ pub use values::{ pub use cache::{StyleCache, StyleCacheKey}; pub(crate) use props::{RESPONSIVE_SELECTORS_INFO, STRUCTURAL_SELECTORS_INFO, style_key_selector}; +pub(crate) use values::scene_debug_view_with_size; static NEXT_STYLE_MERGE_ID: AtomicU64 = AtomicU64::new(1); const MERGE_MIX_CONST: u64 = 0x9E3779B97F4A7C15; @@ -1886,21 +1889,86 @@ define_builtin_props!( /// Higher values appear in front of lower values. ZIndex z_index { tr }: Option [i32] {} = None, + /// Hints that this view should be isolated into its own compositor layer. + /// + /// This is a performance and diagnostics hint. Floem may still flatten the + /// view if it has no paint output or if compositor layer promotion is not + /// available. + WantsLayer wants_layer {}: bool {} = false, + + /// Frame-rate preference for a compositor layer created for this view. + /// + /// This only applies when the view is isolated into its own compositor layer, + /// usually through `wants_layer`. It is a pacing + /// preference for layer invalidation and diagnostics, not a guarantee that + /// the layer will visibly present at exactly this rate. + /// + /// [`FrameRatePreference::full`] means full eligible frame-source cadence. + /// [`FrameRatePreference::at_most`] caps the layer to a display-friendly + /// cadence no faster than that value. + /// + /// Fixed-rate displays round capped values down to stable divisors of the + /// hardware refresh. For example, `Some(60.0)` on fixed 75 Hz becomes + /// 37.5 fps; `Some(60.0)` on fixed 120 Hz becomes 60 fps. Floem does not use + /// uneven fixed-display patterns such as 4-out-of-5 frames. + /// + /// Variable-refresh displays may use an in-range request directly. For + /// example, `Some(60.0)` on a 48-75 Hz VRR display can remain 60 fps. + /// Requests outside the reported VRR range fall back to the nearest + /// supported display-friendly cadence. + /// + /// Actual presentation can still be lower if the layer depends on another + /// surface, is part of a transaction group, or if compositor/GPU work misses + /// the relevant deadline. A dependent layer generally updates when its own + /// content or a sampled surface changes; it does not independently force + /// the producer to run at this rate. + LayerFrameRate layer_frame_rate {}: FrameRatePreference {} = FrameRatePreference::full(), + /// Sets the cursor style when hovering over the view. /// /// Changes the appearance of the mouse cursor. Cursor cursor { }: Option [CursorStyle] {} = None, - /// Sets the text color. + /// Sets the text brush. /// - /// This property is inherited by child views. - TextColor color { tr }: Option [Color] { inherited } = None, + /// This property is inherited by child views. Solid colors remain the common + /// case, but text can also use Floem gradients, images, surfaces, and shader + /// sources through the same brush path as other paint commands. + TextColor color { tr }: Option [Brush] { inherited } = None, + + /// Applies an additional raw brush-space transform to text brushes. + /// + /// This is inherited with text color. It only changes how gradient/image + /// brushes are sampled for glyph fills; it does not move the glyphs. The + /// affine is applied directly in brush/source coordinates. + TextBrushTransform text_brush_transform { tr }: Affine { inherited } = Affine::IDENTITY, /// Sets the background color or image. /// /// Can be a solid color, gradient, or image. Background background { tr }: Option [Brush] {} = None, + /// Applies an additional raw brush-space transform to background brushes. + /// + /// This only changes gradient/image sampling. It does not move the view + /// background geometry. The affine is applied directly in brush/source + /// coordinates. + BackgroundBrushTransform background_brush_transform { tr }: Affine {} = Affine::IDENTITY, + + /// Applies one or more filters to the rendered view subtree. + /// + /// Filters isolate the view subtree into a compositor scene layer before + /// applying the filter chain. Compositor surface children under a filtered + /// view are sampled into that layer instead of being directly promoted. + Filters filters {}: SmallVec<[Filter; 3]> {} = SmallVec::new(), + + /// Sets the blend mode used when compositing this view subtree with content behind it. + /// + /// Native compositor layers currently support normal source-over blending. + /// Non-default blend modes are carried through Floem's scene/capture paths + /// and prevent direct compositor-surface promotion. + BlendModeProp blend_mode {}: BlendMode {} = BlendMode::default(), + /// Sets the foreground color or pattern. /// /// Used for drawing content like icons or shapes. @@ -1916,6 +1984,11 @@ define_builtin_props!( /// This property is inherited by child views. FontSize font_size { nocb, tr }: f64 { inherited } = 14., + /// Adjusts glyph emboldening for text content. + /// + /// This property is inherited by child views. + FontEmbolden font_embolden { nocb, tr }: Vec2 { inherited } = Vec2::ZERO, + /// Sets the font family for text content. /// /// This property is inherited by child views. @@ -1980,6 +2053,11 @@ define_builtin_props!( /// Corresponds to common CSS `object-position` keyword combinations. ObjectPositionProp object_position {}: ObjectPosition {} = ObjectPosition::Center, + /// Controls how image brushes are sampled when painted. + /// + /// This includes extend mode, sampling quality, and an alpha multiplier. + ImageSamplerProp image_sampler {}: peniko::ImageSampler {} = peniko::ImageSampler::new(), + /// Sets the gap between columns in grid or flex layouts. /// /// Creates space between items in the horizontal direction. diff --git a/src/style/props.rs b/src/style/props.rs index 492373620..d5751dbe3 100644 --- a/src/style/props.rs +++ b/src/style/props.rs @@ -398,7 +398,7 @@ impl StylePropReader for P { // there is a previously stored value in state.0. if the values are different, a transition should be started if there is one let changed = new != state.0; if changed && !prop_animated { - state.1.transition(&Self::get(state), &new); + state.1.transition_at(&Self::get(state), &new, *now); state.0 = new; } else if prop_animated { state.0 = new; diff --git a/src/style/recalc.rs b/src/style/recalc.rs index a27b1b4f6..bbfa2c240 100644 --- a/src/style/recalc.rs +++ b/src/style/recalc.rs @@ -251,6 +251,26 @@ impl StyleReason { self.flags.contains(StyleReasonFlags::TRANSITION) } + pub fn is_timeline_only(&self) -> bool { + let timeline = StyleReasonFlags::ANIMATION | StyleReasonFlags::TRANSITION; + let target = StyleReasonFlags::TARGET; + if self.flags.is_empty() { + return false; + } + let non_timeline = self.flags - timeline - target; + if !non_timeline.is_empty() { + return false; + } + if self.flags.contains(target) { + return !self.targets.is_empty() + && self + .targets + .iter() + .all(|(_, reason)| reason.is_timeline_only()); + } + self.flags.intersects(timeline) + } + pub fn has_selector(&self) -> bool { self.flags.contains(StyleReasonFlags::SELECTOR) } diff --git a/src/style/tests.rs b/src/style/tests.rs index 0e7e4ce87..5b8ee32ad 100644 --- a/src/style/tests.rs +++ b/src/style/tests.rs @@ -1,8 +1,9 @@ //! Unit tests for the style system. use super::{Background, Padding, PaddingBottom, PaddingLeft, PaddingRight, Style, TextColor}; +use crate::effects::Brush; use crate::unit::Length; -use peniko::{Brush, color::palette::css}; +use peniko::color::palette::css; #[test] fn style_override() { diff --git a/src/style/theme.rs b/src/style/theme.rs index a94bbfcae..d9141e571 100644 --- a/src/style/theme.rs +++ b/src/style/theme.rs @@ -19,8 +19,7 @@ use crate::{ slider::{SliderClass, SliderCustomExprStyle, SliderCustomStyle}, }, }; -use floem_renderer::text::FontWeight; -use peniko::{Brush, Color, color::palette::css}; +use peniko::{Color, color::palette::css}; use smallvec::smallvec; style_class!(pub HoverTargetClass); @@ -287,9 +286,10 @@ impl StylePropValue for DesignSystem { dyn_view(chevron) .class(ButtonClass) .style(|s| s.size(16.0, 16.0).padding(0.)), - "Design System" - .to_string() - .style(|s| s.font_size(14.0).font_weight(FontWeight::SEMI_BOLD)), + "Design System".to_string().style(|s| { + s.font_size(14.0) + .font_weight(crate::text::FontWeight::SEMI_BOLD) + }), )) .action(move || { is_expanded.update(|v| *v = !*v); @@ -547,7 +547,13 @@ pub fn overlay_style() -> Style { .dark_mode(|s| s.border(1).border_top(2.)) } -pub(crate) fn default_theme(os_theme: winit::window::Theme) -> Style { +fn macos_font_embolden(font_size: f64, effective_scale: f64) -> kurbo::Vec2 { + let size_in_device_px = font_size * effective_scale; + let embolden = kurbo::Vec2::new(0.015125, 0.0121) * size_in_device_px; + kurbo::Vec2::new(embolden.x.min(0.3), embolden.y.min(0.3)) +} + +pub(crate) fn default_theme(os_theme: winit::window::Theme, effective_scale: f64) -> Style { let button_style = Style::new() .selectable(false) .with_theme(|s, t| { @@ -770,6 +776,14 @@ pub(crate) fn default_theme(os_theme: winit::window::Theme) -> Style { .color(dark.text()) .theme(dark) }) + .apply_if(cfg!(target_os = "macos"), |s| { + s.with::(|s, fs| { + s.set_context( + FontEmbolden, + fs.def(move |font_size| macos_font_embolden(font_size, effective_scale)), + ) + }) + }) .line_height(1.2) .class(LabelClass, |s| { s.with_theme(|s, t| { diff --git a/src/style/transition.rs b/src/style/transition.rs index 707b59659..903dbb094 100644 --- a/src/style/transition.rs +++ b/src/style/transition.rs @@ -6,15 +6,15 @@ //! - [`DirectTransition`] - Standalone transition controller use floem_reactive::{RwSignal, SignalGet}; -use floem_renderer::Renderer; use peniko::color::palette; -use peniko::kurbo::{self, Point, Stroke}; +use peniko::kurbo::{self, Point, Shape, Stroke}; use std::rc::Rc; use taffy::prelude::{auto, fr}; use crate::platform::{Duration, Instant}; use crate::animate::{Bezier, Easing, Linear, Spring}; +use crate::effects::Brush; use crate::theme::StyleThemeExt; use crate::view::{IntoView, View}; use crate::views::{ContainerExt, Decorators, Label, Stack, TooltipExt, canvas}; @@ -43,12 +43,16 @@ impl TransitionState { } pub(crate) fn transition(&mut self, before: &T, after: &T) { + self.transition_at(before, after, Instant::now()); + } + + pub(crate) fn transition_at(&mut self, before: &T, after: &T, now: Instant) { if !self.initial { return; } if self.transition.is_some() { self.active = Some(ActiveTransition { - start: Instant::now(), + start: now, before: before.clone(), current: before.clone(), after: after.clone(), @@ -146,14 +150,17 @@ impl Transition { } // Draw the curve - cx.stroke( - &path, - curve_color.get(), - &Stroke { - width: 2.0, - ..Default::default() - }, - ); + let curve_brush = Brush::Solid(curve_color.get()); + cx.painter + .stroke( + path, + &Stroke { + width: 2.0, + ..Default::default() + }, + &curve_brush, + ) + .draw(); // Draw axes let axis_stroke = Stroke { @@ -162,24 +169,31 @@ impl Transition { }; // X axis - cx.stroke( - &kurbo::Line::new( - Point::new(padding, height - padding), - Point::new(width - padding, height - padding), - ), - axis_color.get(), - &axis_stroke, - ); + let axis_brush = Brush::Solid(axis_color.get()); + cx.painter + .stroke( + kurbo::Line::new( + Point::new(padding, height - padding), + Point::new(width - padding, height - padding), + ) + .to_path(0.1), + &axis_stroke, + &axis_brush, + ) + .draw(); // Y axis - cx.stroke( - &kurbo::Line::new( - Point::new(padding, padding), - Point::new(padding, height - padding), - ), - axis_color.get(), - &axis_stroke, - ); + cx.painter + .stroke( + kurbo::Line::new( + Point::new(padding, padding), + Point::new(padding, height - padding), + ) + .to_path(0.1), + &axis_stroke, + &axis_brush, + ) + .draw(); }) .style(|s| s.width(80.0).height(60.0)) .container() diff --git a/src/style/unit.rs b/src/style/unit.rs index 27c180e8d..2bff720f8 100644 --- a/src/style/unit.rs +++ b/src/style/unit.rs @@ -17,7 +17,7 @@ #![allow(deprecated)] use std::{ops::Neg, time::Duration}; -pub use floem_renderer::text::LineHeightValue; +pub use crate::text::LineHeightValue; use taffy::style::{Dimension, LengthPercentage, LengthPercentageAuto}; /// An absolute length value in Floem layout points. diff --git a/src/style/values.rs b/src/style/values.rs index b3a6aafd5..dc3102e87 100644 --- a/src/style/values.rs +++ b/src/style/values.rs @@ -1,12 +1,19 @@ //! Core style property value trait and implementations. +#[path = "values_extended_scene.rs"] +mod values_extended_scene; +pub(crate) use values_extended_scene::scene_debug_view_with_size; + +use crate::text::{FontWeight, LineHeightValue}; +use crate::{ + effects::{Brush, Filter}, + gradient::{ColorStop, ColorStops, Gradient}, +}; use floem_reactive::{RwSignal, SignalGet, SignalUpdate as _}; -use floem_renderer::Renderer; -use floem_renderer::text::{FontWeight, LineHeightValue}; use peniko::color::{HueDirection, palette}; -use peniko::kurbo::{self, Affine, Point, Stroke, Vec2}; +use peniko::kurbo::{self, Affine, Point, Shape, Stroke, Vec2}; use peniko::{ - Brush, Color, ColorStop, ColorStops, Gradient, GradientKind, InterpolationAlphaSpace, + BlendMode, Color, GradientKind, ImageQuality, ImageSampler, InterpolationAlphaSpace, LinearGradientPosition, }; use smallvec::SmallVec; @@ -148,6 +155,17 @@ impl StylePropValue for f64 { Some(*self * (1.0 - value) + *other * value) } } +impl StylePropValue for crate::frame::FrameRatePreference {} +impl StylePropValue for BlendMode {} +impl StylePropValue for Filter {} +impl StylePropValue for Vec2 { + fn interpolate(&self, other: &Self, value: f64) -> Option { + Some(Vec2::new( + self.x * (1.0 - value) + other.x * value, + self.y * (1.0 - value) + other.y * value, + )) + } +} impl StylePropValue for Overflow {} impl StylePropValue for Display {} impl StylePropValue for Position {} @@ -156,6 +174,17 @@ impl StylePropValue for FlexWrap {} impl StylePropValue for AlignItems {} impl StylePropValue for BoxSizing {} impl StylePropValue for AlignContent {} +impl StylePropValue for ImageQuality {} +impl StylePropValue for ImageSampler { + fn interpolate(&self, other: &Self, value: f64) -> Option { + Some(Self { + x_extend: self.x_extend, + y_extend: self.y_extend, + quality: self.quality, + alpha: self.alpha + (other.alpha - self.alpha) * value as f32, + }) + } +} impl StylePropValue for GridTemplateComponent {} impl StylePropValue for MinTrackSizingFunction {} impl StylePropValue for MaxTrackSizingFunction {} @@ -248,14 +277,17 @@ impl StylePropValue for ObjectFit { ), 2.0, ); - cx.stroke( - &container_rect, - container_color.get(), - &Stroke { - width: 1.5, - ..Default::default() - }, - ); + let container_brush = Brush::Solid(container_color.get()); + cx.painter + .stroke( + container_rect, + &Stroke { + width: 1.5, + ..Default::default() + }, + &container_brush, + ) + .draw(); // Simulate an image with 2:1 aspect ratio (wider than tall) let image_aspect = 2.0; @@ -305,15 +337,16 @@ impl StylePropValue for ObjectFit { 2.0, ); // Show it as semi-transparent to indicate it's clipped - let clipped_color = image_color.get().with_alpha(0.7); - cx.fill(&img_rect, clipped_color, 0.0); + let clipped_color = Brush::Solid(image_color.get().with_alpha(0.7)); + cx.painter.fill(img_rect, &clipped_color).draw(); } else { // Draw the image rect normally let img_rect = RoundedRect::from_rect( kurbo::Rect::new(img_x, img_y, img_x + img_width, img_y + img_height), 2.0, ); - cx.fill(&img_rect, image_color.get(), 0.0); + let image_brush = Brush::Solid(image_color.get()); + cx.painter.fill(img_rect, &image_brush).draw(); } }) .style(|s| s.width(70.0).height(70.0)) @@ -391,14 +424,17 @@ impl StylePropValue for ObjectPosition { ), 2.0, ); - cx.stroke( - &container_rect, - container_color.get(), - &Stroke { - width: 1.5, - ..Default::default() - }, - ); + let container_brush = Brush::Solid(container_color.get()); + cx.painter + .stroke( + container_rect, + &Stroke { + width: 1.5, + ..Default::default() + }, + &container_brush, + ) + .draw(); let image_w = container_size * 0.55; let image_h = container_size * 0.35; @@ -428,13 +464,15 @@ impl StylePropValue for ObjectPosition { kurbo::Rect::new(img_x, img_y, img_x + image_w, img_y + image_h), 2.0, ); - cx.fill(&img_rect, image_color.get(), 0.0); + let image_brush = Brush::Solid(image_color.get()); + cx.painter.fill(img_rect, &image_brush).draw(); let marker = Circle::new( kurbo::Point::new(img_x + image_w / 2.0, img_y + image_h / 2.0), 2.5, ); - cx.fill(&marker, marker_color.get(), 0.0); + let marker_brush = Brush::Solid(marker_color.get()); + cx.painter.fill(marker.to_path(0.1), &marker_brush).draw(); }) .style(|s| s.width(70.0).height(70.0)) .container() @@ -804,9 +842,12 @@ pub(crate) fn views(views: impl ViewTuple) -> Vec { views.into_views() } -impl StylePropValue for Color { - fn debug_view(&self) -> Option> { - let color = *self; +impl IntoView for Color { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let color = self; let swatch = () .style(move |s| { s.background(color) @@ -888,12 +929,16 @@ impl StylePropValue for Color { }) }; - Some( - swatch - .tooltip(tooltip_view) - .style(|s| s.items_center()) - .into_any(), - ) + swatch + .tooltip(tooltip_view) + .style(|s| s.items_center()) + .into_any() + } +} + +impl StylePropValue for Color { + fn debug_view(&self) -> Option> { + Some((*self).into_any()) } fn interpolate(&self, other: &Self, value: f64) -> Option { @@ -901,13 +946,17 @@ impl StylePropValue for Color { } } -impl StylePropValue for Gradient { - fn debug_view(&self) -> Option> { - let box_width = 22.; - let box_height = 14.; +impl IntoView for Gradient { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let box_width = 34.; + let box_height = 18.; let mut grad = self.clone(); - grad.kind = match grad.kind { - GradientKind::Linear(LinearGradientPosition { start, end }) => { + grad.kind = match (grad.kind, grad.geometry) { + (GradientKind::Linear(position), crate::gradient::GradientGeometry::Absolute) => { + let LinearGradientPosition { start, end } = position; let dx = end.x - start.x; let dy = end.y - start.y; @@ -933,7 +982,7 @@ impl StylePropValue for Gradient { end: new_end, }) } - _ => grad.kind, + (kind, _) => kind, }; let color = ().style(move |s| { s.background(grad.clone()) @@ -949,11 +998,66 @@ impl StylePropValue for Gradient { .border_radius(5.0) .margin_left(6.0) }); - Some( - Stack::new((Label::new(format!("{self:?}")), color)) - .style(|s| s.items_center()) - .into_any(), - ) + + let tooltip_gradient = self.clone(); + let tooltip_view = move || { + let kind = format!("{:?}", tooltip_gradient.kind); + let extend = format!("{:?}", tooltip_gradient.extend); + let interpolation_cs = format!("{:?}", tooltip_gradient.interpolation_cs); + let alpha_space = format!("{:?}", tooltip_gradient.interpolation_alpha_space); + let hue_direction = format!("{:?}", tooltip_gradient.hue_direction); + let metadata = Stack::vertical(( + Stack::horizontal(( + "Kind:".style(|s| s.font_bold().min_width(110.0).justify_end()), + Label::new(kind.clone()), + )), + Stack::horizontal(( + "Extend:".style(|s| s.font_bold().min_width(110.0).justify_end()), + Label::new(extend.clone()), + )), + Stack::horizontal(( + "Interpolation:".style(|s| s.font_bold().min_width(110.0).justify_end()), + Label::new(interpolation_cs.clone()), + )), + Stack::horizontal(( + "Alpha Space:".style(|s| s.font_bold().min_width(110.0).justify_end()), + Label::new(alpha_space.clone()), + )), + Stack::horizontal(( + "Hue Direction:".style(|s| s.font_bold().min_width(110.0).justify_end()), + Label::new(hue_direction.clone()), + )), + )) + .style(|s| s.gap(10.0)); + + let stops = Stack::vertical_from_iter(tooltip_gradient.stops.iter().enumerate().map( + |(idx, stop)| { + Stack::horizontal(( + Label::new(format!("[{idx}]")) + .style(|s| s.font_bold().min_width(36.0).justify_end()), + Label::new(format!("{:?}", stop.offset)).style(|s| s.min_width(52.0)), + stop.color.to_alpha_color().into_any(), + )) + .style(|s| s.items_center().gap(10.0)) + }, + )) + .style(|s| s.gap(8.0).width_full()); + + Stack::vertical(( + metadata, + Label::new("Stops").style(|s| s.font_bold().margin_top(12.0)), + stops, + )) + .style(|s| s.padding(20.0).gap(8.0).min_width(320.0)) + }; + + color.tooltip(tooltip_view).into_any() + } +} + +impl StylePropValue for Gradient { + fn debug_view(&self) -> Option> { + Some(self.clone().into_any()) } fn interpolate(&self, _other: &Self, _value: f64) -> Option { @@ -991,23 +1095,30 @@ impl From for StrokeWrap { } } -impl StylePropValue for Stroke { - fn debug_view(&self) -> Option> { - let stroke = self.clone(); +impl IntoView for Stroke { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let stroke = self; let clone = stroke.clone(); let color = RwSignal::new(palette::css::RED); // Visual preview of the stroke let preview = canvas(move |cx, size| { - cx.stroke( - &kurbo::Line::new( - Point::new(0., size.height / 2.), - Point::new(size.width, size.height / 2.), - ), - color.get(), - &clone, - ); + let brush = Brush::Solid(color.get()); + cx.painter + .stroke( + kurbo::Line::new( + Point::new(0., size.height / 2.), + Point::new(size.width, size.height / 2.), + ) + .to_path(0.1), + &clone, + &brush, + ) + .draw(); }) .style(move |s| s.width(80.0).height(20.0)) .container() @@ -1091,22 +1202,59 @@ impl StylePropValue for Stroke { }) }; - Some( - preview - .tooltip(tooltip_view) - .style(|s| s.items_center()) - .into_any(), - ) + preview + .tooltip(tooltip_view) + .style(|s| s.items_center()) + .into_any() } } -impl StylePropValue for Brush { + +impl StylePropValue for Stroke { fn debug_view(&self) -> Option> { + Some(self.clone().into_any()) + } +} + +impl IntoView for Brush { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { match self { - Brush::Solid(color) => color.debug_view(), - Brush::Gradient(grad) => grad.debug_view(), - Brush::Image(_) => None, + Brush::Solid(color) => color.into_any(), + Brush::Gradient(grad) => grad.into_any(), + Brush::Image(image) => Stack::vertical(( + Label::new("image brush").style(|s| { + s.font_size(10.0) + .font_bold() + .with_theme(|s, t| s.color(t.text_muted())) + }), + Label::new(format!("{:?}", image.image)) + .style(|s| s.font_size(11.0).min_width(0.0).text_wrap()), + Label::new(format!( + "quality: {:?}, extend: {:?}/{:?}, alpha: {:.3}", + image.sampler.quality, + image.sampler.x_extend, + image.sampler.y_extend, + image.sampler.alpha + )) + .style(|s| { + s.font_size(11.0) + .min_width(0.0) + .text_wrap() + .with_theme(|s, t| s.color(t.text_muted())) + }), + )) + .style(|s| s.gap(3.0).min_width(0.0)) + .into_any(), } } +} + +impl StylePropValue for Brush { + fn debug_view(&self) -> Option> { + Some(self.clone().into_any()) + } fn interpolate(&self, other: &Self, value: f64) -> Option { match (self, other) { @@ -1135,6 +1283,7 @@ impl StylePropValue for Brush { hue_direction: gradient.hue_direction, stops: ColorStops::from(&*interpolated_stops), interpolation_alpha_space: InterpolationAlphaSpace::Premultiplied, + geometry: gradient.geometry, })) } (Brush::Solid(solid), Brush::Gradient(gradient)) => { @@ -1157,6 +1306,7 @@ impl StylePropValue for Brush { hue_direction: gradient.hue_direction, stops: ColorStops::from(&*interpolated_stops), interpolation_alpha_space: InterpolationAlphaSpace::Premultiplied, + geometry: gradient.geometry, })) } @@ -1194,9 +1344,12 @@ impl StylePropValue for super::AnchorAbout { } } -impl StylePropValue for kurbo::Rect { - fn debug_view(&self) -> Option> { - let r = *self; +impl IntoView for kurbo::Rect { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let r = self; let w = r.x1 - r.x0; let h = r.y1 - r.y0; @@ -1213,10 +1366,12 @@ impl StylePropValue for kurbo::Rect { let preview = Empty::new().style(move |s| { let max = w.abs().max(h.abs()).max(1.0); - let scale = 60.0 / max; + let scale = 64.0 / max; s.width(w.abs() * scale) .height(h.abs() * scale) + .min_width(8.0) + .min_height(8.0) .border(1.0) .with_theme(|s, t| { s.border_color(t.border()) @@ -1225,16 +1380,20 @@ impl StylePropValue for kurbo::Rect { }) }); - Some( - ( - "Rect", - preview, - coords.style(|s| s.gap(2)), - wh.style(|s| s.gap(8)), - ) - .v_stack() - .into_any(), - ) + Stack::vertical(( + Label::new("Rect").style(|s| s.font_bold()), + preview.container().style(|s| s.padding_vert(4.0)), + coords.style(|s| s.gap(2).font_size(11.0)), + wh.style(|s| s.gap(8).font_size(11.0)), + )) + .style(|s| s.gap(4.0).items_start().min_width(0.0)) + .into_any() + } +} + +impl StylePropValue for kurbo::Rect { + fn debug_view(&self) -> Option> { + Some((*self).into_any()) } fn interpolate(&self, other: &Self, value: f64) -> Option { @@ -1249,9 +1408,12 @@ impl StylePropValue for kurbo::Rect { } } -impl StylePropValue for Affine { - fn debug_view(&self) -> Option> { - let affine = *self; +impl IntoView for Affine { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let affine = self; let coeffs = affine.as_coeffs(); // Decompose to show meaningful transform components @@ -1266,11 +1428,14 @@ impl StylePropValue for Affine { // Draw original position (dashed outline) let original_rect = kurbo::Rect::from_center_size(center, kurbo::Size::new(box_size, box_size)); - cx.stroke( - &original_rect, - palette::css::GRAY.with_alpha(0.5), - &kurbo::Stroke::new(1.0).with_dashes(0., [3., 3.]), - ); + let original_brush = Brush::Solid(palette::css::GRAY.with_alpha(0.5)); + cx.painter + .stroke( + original_rect, + &kurbo::Stroke::new(1.0).with_dashes(0., [3., 3.]), + &original_brush, + ) + .draw(); // Draw transformed position let transform_offset = @@ -1278,20 +1443,20 @@ impl StylePropValue for Affine { let display_transform = transform_offset * affine * transform_offset.inverse(); let transformed_rect = kurbo::Rect::new(0., 0., box_size, box_size); - cx.fill( - &display_transform.transform_rect_bbox(transformed_rect), - palette::css::BLUE.with_alpha(0.7), - 0., - ); - cx.stroke( - &(display_transform.transform_rect_bbox(transformed_rect)), - palette::css::BLUE, - &kurbo::Stroke::new(2.0), - ); + let transformed = display_transform.transform_rect_bbox(transformed_rect); + let fill_brush = Brush::Solid(palette::css::BLUE.with_alpha(0.7)); + let stroke_brush = Brush::Solid(palette::css::BLUE); + cx.painter.fill(transformed, &fill_brush).draw(); + cx.painter + .stroke(transformed, &kurbo::Stroke::new(2.0), &stroke_brush) + .draw(); // Draw origin point let origin_marker = kurbo::Circle::new(display_transform * Point::ZERO, 3.0); - cx.fill(&origin_marker, palette::css::RED, 0.); + let origin_brush = Brush::Solid(palette::css::RED); + cx.painter + .fill(origin_marker.to_path(0.1), &origin_brush) + .draw(); }) .style(|s| s.width(80.0).height(60.0)) .container() @@ -1392,12 +1557,16 @@ impl StylePropValue for Affine { Stack::vertical_from_iter(content).style(|s| s.padding(20)) }; - Some( - preview - .tooltip(tooltip_view) - .style(|s| s.items_center()) - .into_any(), - ) + preview + .tooltip(tooltip_view) + .style(|s| s.items_center()) + .into_any() + } +} + +impl StylePropValue for Affine { + fn debug_view(&self) -> Option> { + Some((*self).into_any()) } fn interpolate(&self, other: &Self, t: f64) -> Option { diff --git a/src/style/values_extended_scene.rs b/src/style/values_extended_scene.rs new file mode 100644 index 000000000..22290b085 --- /dev/null +++ b/src/style/values_extended_scene.rs @@ -0,0 +1,872 @@ +use floem_reactive::{RwSignal, SignalGet, SignalUpdate}; +use imaging::{ + Brush, Composite, Filter, + record::{AppliedMask, Clip, Command, Context, Draw, Geometry, Group, Mask, Scene, replay}, +}; +use peniko::{ + Color, + color::palette::css, + kurbo::{Affine, Rect, Shape, Size}, +}; + +use crate::{ + AnyView, IntoView, View, + style::{BoxShadow, StylePropValue, StyleThemeExt}, + views::{ContainerExt, Decorators, Label, ScrollExt, Stack, canvas, dyn_view, svg}, +}; + +#[derive(Clone)] +enum ResolvedCommand { + PushContext(Context), + PopContext, + PushClip(Clip), + PopClip, + PushGroup(Group), + PopGroup, + Draw(Draw), +} + +fn format_float(value: f64) -> String { + if value.fract().abs() < 0.01 { + format!("{}", value.round() as i64) + } else { + format!("{value:.2}") + } +} + +fn format_point(x: f64, y: f64) -> String { + format!("({}, {})", format_float(x), format_float(y)) +} + +fn format_affine(transform: Affine) -> String { + let [a, b, c, d, e, f] = transform.as_coeffs(); + format!( + "[{:.3}, {:.3}, {:.3}, {:.3}, {:.3}, {:.3}]", + a, b, c, d, e, f + ) +} + +fn detail_label(text: impl Into) -> AnyView { + Label::new(text.into()) + .style(|s| s.font_size(11.0).min_width(0.0).text_wrap()) + .into_any() +} + +fn debug_block(text: impl Into) -> AnyView { + Label::new(text.into()) + .style(|s| { + s.font_size(11.0) + .min_width(0.0) + .text_wrap() + .padding_vert(2.0) + }) + .into_any() +} + +fn value_view(value: &T) -> AnyView +where + T::V: View + 'static, +{ + value.clone().into_any() +} + +fn field_row(name: impl Into, value: impl Into) -> AnyView { + Stack::horizontal(( + Label::new(format!("{}:", name.into())).style(|s| { + s.font_size(11.0) + .font_bold() + .min_width(92.0) + .padding_right(10.0) + .with_theme(|s, t| s.color(t.text_muted())) + }), + detail_label(value), + )) + .style(|s| s.items_start().width_full().min_width(0.0).gap(2.0)) + .into_any() +} + +fn field_view(name: impl Into, value: AnyView) -> AnyView { + Stack::horizontal(( + Label::new(format!("{}:", name.into())).style(|s| { + s.font_size(11.0) + .font_bold() + .min_width(92.0) + .padding_right(10.0) + .with_theme(|s, t| s.color(t.text_muted())) + }), + value.style(|s| s.min_width(0.0).flex_grow(1.0)), + )) + .style(|s| s.items_start().width_full().min_width(0.0).gap(2.0)) + .into_any() +} + +fn field_value(name: impl Into, value: &T) -> AnyView +where + T::V: View + 'static, +{ + field_view(name, value_view(value)) +} + +fn section(title: impl Into, body: AnyView) -> AnyView { + Stack::vertical(( + Label::new(title.into()).style(|s| { + s.font_size(10.5) + .font_bold() + .with_theme(|s, t| s.color(t.text_muted())) + }), + body.style(|s| s.padding_left(12.0)), + )) + .style(|s| { + s.width_full() + .gap(6.0) + .padding(8.0) + .border_radius(8.0) + .border(1.0) + .with_theme(|s, t| s.background(t.bg_base()).border_color(t.border())) + }) + .into_any() +} + +fn stack_rows(rows: Vec) -> AnyView { + Stack::vertical_from_iter(rows) + .style(|s| s.width_full().gap(4.0).min_width(0.0)) + .into_any() +} + +fn metric_chip(label: impl Into, value: impl Into) -> AnyView { + Stack::horizontal(( + Label::new(label.into()).style(|s| { + s.font_size(10.0) + .font_bold() + .with_theme(|s, t| s.color(t.text_muted())) + }), + Label::new(value.into()).style(|s| s.font_size(11.0)), + )) + .style(|s| s.items_center().gap(4.0)) + .into_any() +} + +fn bounds_view(rect: Rect) -> AnyView { + Stack::vertical(( + Stack::horizontal(( + metric_chip("x", format_float(rect.x0)), + metric_chip("y", format_float(rect.y0)), + )), + Stack::horizontal(( + metric_chip("w", format_float(rect.width())), + metric_chip("h", format_float(rect.height())), + )), + )) + .style(|s| s.gap(2.0).min_width(0.0)) + .into_any() +} + +fn disclosure_icon(expanded: bool) -> AnyView { + let icon = if expanded { + r#""# + } else { + r#""# + }; + svg(icon) + .style(|s| { + s.size(14.0, 14.0) + .with_theme(|s, t| s.color(t.text_muted())) + }) + .into_any() +} + +fn collapsible_section( + title: impl Into, + subtitle: impl Into>, + default_open: bool, + body: impl Fn() -> AnyView + 'static, +) -> AnyView { + let title = title.into(); + let subtitle = subtitle.into(); + let expanded = RwSignal::new(default_open); + let subtitle_view = subtitle + .map(|subtitle| { + Label::new(subtitle) + .style(|s| s.font_size(10.0).with_theme(|s, t| s.color(t.text_muted()))) + .into_any() + }) + .unwrap_or_else(|| ().into_any()); + Stack::vertical(( + Stack::horizontal(( + dyn_view(move || disclosure_icon(expanded.get())), + Stack::vertical(( + Label::new(title).style(|s| s.font_size(11.0).font_bold()), + subtitle_view, + )) + .style(|s| s.gap(1.0).min_width(0.0).flex_grow(1.0)), + )) + .style(|s| { + s.items_start() + .gap(6.0) + .width_full() + .padding_horiz(8.0) + .padding_vert(7.0) + .border_radius(8.0) + .border(1.0) + .with_theme(|s, t| s.background(t.bg_base()).border_color(t.border())) + }) + .action(move || expanded.update(|value| *value = !*value)), + dyn_view(move || { + if expanded.get() { + body() + .style(|s| s.padding_left(10.0).padding_top(4.0)) + .into_any() + } else { + ().into_any() + } + }), + )) + .style(|s| s.width_full().gap(2.0)) + .into_any() +} + +fn raw_debug_section(title: impl Into, text: impl Into) -> AnyView { + let text = text.into(); + let subtitle = format!("{} characters", text.len()); + collapsible_section(title, Some(subtitle), false, move || { + debug_block(text.clone()) + .style(|s| { + s.padding(8.0) + .border_radius(8.0) + .border(1.0) + .with_theme(|s, t| s.background(t.bg_base()).border_color(t.border())) + }) + .into_any() + }) +} + +fn scene_size_hint(size: Size) -> Size { + let width = size.width.clamp(120.0, 220.0); + let height = size.height.clamp(72.0, 160.0); + Size::new(width, height) +} + +fn nested_scene_section( + title: impl Into, + subtitle: impl Into, + scene: Scene, + size: Size, +) -> AnyView { + collapsible_section(title, Some(subtitle.into()), false, move || { + scene_debug_view_with_size(scene.clone(), scene_size_hint(size)) + .style(|s| { + s.padding(8.0) + .border_radius(10.0) + .border(1.0) + .with_theme(|s, t| s.background(t.bg_base()).border_color(t.border())) + }) + .into_any() + }) +} + +fn brush_summary_view(brush: &Brush) -> AnyView { + match brush { + Brush::Solid(color) => Stack::horizontal(( + Label::new("solid").style(|s| { + s.font_size(10.0) + .font_bold() + .with_theme(|s, t| s.color(t.text_muted())) + }), + color.clone().into_any(), + )) + .style(|s| s.items_center().gap(8.0).min_width(0.0)) + .into_any(), + Brush::Gradient(gradient) => Stack::vertical(( + Label::new("gradient").style(|s| { + s.font_size(10.0) + .font_bold() + .with_theme(|s, t| s.color(t.text_muted())) + }), + crate::gradient::Gradient::from(gradient.clone()).into_any(), + )) + .style(|s| s.gap(4.0).min_width(0.0)) + .into_any(), + Brush::Image(image_brush) => { + let source_label = match &image_brush.image { + imaging::Image::Raster(image) => format!("raster {}×{}", image.width, image.height), + imaging::Image::Scene(scene) => { + format!( + "scene image #{} {}×{}", + scene.id(), + scene.width(), + scene.height() + ) + } + imaging::Image::External(image) => { + format!( + "external image #{} {}×{}", + image.id.0, image.width, image.height + ) + } + }; + let mut rows = vec![ + Stack::horizontal(( + Label::new("image brush").style(|s| { + s.font_size(10.0) + .font_bold() + .with_theme(|s, t| s.color(t.text_muted())) + }), + detail_label(source_label), + )) + .style(|s| s.items_center().gap(8.0)) + .into_any(), + Stack::horizontal(( + metric_chip("quality", format!("{:?}", image_brush.sampler.quality)), + metric_chip( + "extend", + format!( + "{:?}/{:?}", + image_brush.sampler.x_extend, image_brush.sampler.y_extend + ), + ), + metric_chip("alpha", format!("{:.3}", image_brush.sampler.alpha)), + )) + .style(|s| s.items_center().gap(10.0).min_width(0.0).text_wrap()) + .into_any(), + ]; + match &image_brush.image { + imaging::Image::Scene(scene_image) => { + rows.push(nested_scene_section( + "Scene Image", + format!("picture #{}", scene_image.picture().id()), + scene_image.scene().clone(), + Size::new(scene_image.width() as f64, scene_image.height() as f64), + )); + rows.push(raw_debug_section( + "Image Metadata", + format!("{:?}", scene_image), + )); + } + imaging::Image::Raster(image) => { + rows.push(raw_debug_section("Image Metadata", format!("{:?}", image))); + } + imaging::Image::External(image) => { + rows.push(raw_debug_section("Image Metadata", format!("{:?}", image))); + } + } + stack_rows(rows) + } + } +} + +fn default_composite(composite: Composite) -> bool { + composite == Composite::default() +} + +impl IntoView for Composite { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + stack_rows(vec![ + field_row("blend", format!("{:?}", self.blend)), + field_row("alpha", format!("{:.3}", self.alpha)), + ]) + } +} + +impl IntoView for Filter { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let rows = match self { + Filter::Flood { color } => { + vec![field_row("kind", "flood"), field_value("color", &color)] + } + Filter::Blur { + std_deviation_x, + std_deviation_y, + } => vec![ + field_row("kind", "blur"), + field_row("sigma x", format!("{std_deviation_x:.2}")), + field_row("sigma y", format!("{std_deviation_y:.2}")), + ], + Filter::DropShadow { + dx, + dy, + std_deviation_x, + std_deviation_y, + color, + } => vec![ + field_row("kind", "drop shadow"), + field_row("offset", format_point(dx as f64, dy as f64)), + field_row("sigma x", format!("{std_deviation_x:.2}")), + field_row("sigma y", format!("{std_deviation_y:.2}")), + field_value("color", &color), + ], + Filter::Offset { dx, dy } => vec![ + field_row("kind", "offset"), + field_row("delta", format_point(dx as f64, dy as f64)), + ], + }; + stack_rows(rows) + } +} + +impl IntoView for Geometry { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let mut rows = match &self { + Geometry::Rect(rect) => vec![ + field_row("kind", "rect"), + field_view("bounds", bounds_view(*rect)), + field_row("elements", "1"), + ], + Geometry::RoundedRect(rect) => vec![ + field_row("kind", "rounded rect"), + field_view("bounds", bounds_view(rect.bounding_box())), + field_row("radii", format!("{:?}", rect.radii())), + field_row("elements", "1"), + ], + Geometry::Path(path) => vec![ + field_row("kind", "path"), + field_view("bounds", bounds_view(path.bounding_box())), + field_row("elements", path.elements().len().to_string()), + ], + }; + rows.push(raw_debug_section("Shape Detail", format!("{self:?}"))); + stack_rows(rows) + } +} + +impl IntoView for AppliedMask { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let mut rows = vec![field_row("mask", format!("{:?}", self.mask))]; + if self.transform != Affine::IDENTITY { + rows.push(field_row("transform", format_affine(self.transform))); + } + stack_rows(rows) + } +} + +impl IntoView for Mask { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let mut rows = vec![ + field_row("mode", format!("{:?}", self.mode)), + field_row("scene commands", self.scene.commands().len().to_string()), + ]; + rows.push(nested_scene_section( + "Mask Scene", + format!("{} commands", self.scene.commands().len()), + self.scene.clone(), + Size::new(180.0, 100.0), + )); + stack_rows(rows) + } +} + +impl IntoView for Context { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let mut rows = vec![field_row("label", format!("{:?}", self.label))]; + if let Some(source) = self.source { + rows.push(field_row( + "source", + format!("{:?}:{}:{}", source.file, source.line, source.column), + )); + } + stack_rows(rows) + } +} + +impl IntoView for Clip { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + match self { + Clip::Fill { + transform, + shape, + fill_rule, + } => { + let mut rows = vec![ + field_row("kind", "fill clip"), + field_row("fill rule", format!("{fill_rule:?}")), + field_view("shape", shape.into_any()), + ]; + if transform != Affine::IDENTITY { + rows.insert(1, field_value("transform", &transform)); + } + stack_rows(rows) + } + Clip::Stroke { + transform, + shape, + stroke, + } => { + let mut rows = vec![ + field_row("kind", "stroke clip"), + field_value("stroke", &stroke), + field_view("shape", shape.into_any()), + ]; + if transform != Affine::IDENTITY { + rows.insert(1, field_value("transform", &transform)); + } + stack_rows(rows) + } + } + } +} + +impl IntoView for Group { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let mut rows = Vec::new(); + rows.push(field_row("clip", self.clip.is_some().to_string())); + rows.push(field_row("mask", self.mask.is_some().to_string())); + rows.push(field_row("filters", self.filters.len().to_string())); + if !default_composite(self.composite) { + rows.push(section("Composite", self.composite.into_any())); + } else { + rows.push(field_row("composite", "default")); + } + if let Some(clip) = self.clip { + rows.push(section("Group Clip", clip.into_any())); + } + if let Some(mask) = self.mask { + rows.push(section("Applied Mask", mask.into_any())); + } + if !self.filters.is_empty() { + rows.push(section( + "Filters", + Stack::vertical_from_iter(self.filters.into_iter().map(|filter| { + filter.into_any().style(|s| { + s.padding(6.0) + .width_full() + .with_theme(|s, t| s.background(t.bg_base())) + }) + })) + .style(|s| s.width_full().gap(4.0)) + .into_any(), + )); + } + stack_rows(rows) + } +} + +impl IntoView for Draw { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + match self { + Draw::Fill { + transform, + fill_rule, + brush, + brush_transform, + shape, + composite, + } => { + let mut rows = vec![ + field_row("kind", "fill"), + field_row("fill rule", format!("{fill_rule:?}")), + field_view("brush", brush_summary_view(&brush)), + field_view("shape", shape.into_any()), + ]; + if transform != Affine::IDENTITY { + rows.insert(1, field_value("transform", &transform)); + } + if let Some(brush_transform) = brush_transform { + rows.push(field_value("brush transform", &brush_transform)); + } + if !default_composite(composite) { + rows.push(section("Composite", composite.into_any())); + } + stack_rows(rows) + } + Draw::Stroke { + transform, + stroke, + brush, + brush_transform, + shape, + composite, + } => { + let mut rows = vec![ + field_row("kind", "stroke"), + field_value("stroke", &stroke), + field_view("brush", brush_summary_view(&brush)), + field_view("shape", shape.into_any()), + ]; + if transform != Affine::IDENTITY { + rows.insert(1, field_value("transform", &transform)); + } + if let Some(brush_transform) = brush_transform { + rows.push(field_value("brush transform", &brush_transform)); + } + if !default_composite(composite) { + rows.push(section("Composite", composite.into_any())); + } + stack_rows(rows) + } + Draw::GlyphRun(run) => { + let mut rows = vec![ + field_row("kind", "glyph run"), + field_row("font size", format!("{:.2}", run.font_size)), + field_row("glyphs", run.glyphs.len().to_string()), + field_row("hint", run.hint.to_string()), + field_row("style", format!("{:?}", run.style)), + field_view("brush", brush_summary_view(&run.brush)), + ]; + if run.transform != Affine::IDENTITY { + rows.insert(1, field_value("transform", &run.transform)); + } + if let Some(glyph_transform) = run.glyph_transform { + rows.push(field_value("glyph transform", &glyph_transform)); + } + if !run.normalized_coords.is_empty() { + rows.push(field_row( + "variations", + run.normalized_coords.len().to_string(), + )); + } + if !default_composite(run.composite) { + rows.push(section("Composite", run.composite.into_any())); + } + stack_rows(rows) + } + Draw::BlurredRoundedRect(draw) => { + let mut rows = vec![ + field_row("kind", "blurred rounded rect"), + field_value("rect", &draw.rect), + field_row("radius", format_float(draw.radius)), + field_row("std dev", format_float(draw.std_dev)), + field_value("color", &draw.color), + ]; + if draw.transform != Affine::IDENTITY { + rows.insert(1, field_value("transform", &draw.transform)); + } + if !default_composite(draw.composite) { + rows.push(section("Composite", draw.composite.into_any())); + } + stack_rows(rows) + } + Draw::ScenePicture { transform, picture } => { + let mut rows = vec![ + field_row("kind", "scene picture"), + field_row("picture id", picture.id().to_string()), + field_view("bounds", bounds_view(picture.bounds())), + field_row( + "scene commands", + picture.scene().commands().len().to_string(), + ), + ]; + if transform != Affine::IDENTITY { + rows.insert(1, field_value("transform", &transform)); + } + rows.push(nested_scene_section( + "Picture Scene", + format!("{} commands", picture.scene().commands().len()), + picture.scene().clone(), + picture.bounds().size(), + )); + stack_rows(rows) + } + } + } +} + +impl IntoView for Command { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + let rows = match self { + Command::PushContext(id) => vec![ + field_row("command", "push context"), + field_row("context", format!("{id:?}")), + ], + Command::PopContext => vec![field_row("command", "pop context")], + Command::PushClip(id) => vec![ + field_row("command", "push clip"), + field_row("clip", format!("{id:?}")), + ], + Command::PopClip => vec![field_row("command", "pop clip")], + Command::PushGroup(id) => vec![ + field_row("command", "push group"), + field_row("group", format!("{id:?}")), + ], + Command::PopGroup => vec![field_row("command", "pop group")], + Command::Draw(id) => vec![ + field_row("command", "draw"), + field_row("draw", format!("{id:?}")), + ], + }; + stack_rows(rows) + } +} + +impl IntoView for ResolvedCommand { + type V = AnyView; + type Intermediate = AnyView; + + fn into_intermediate(self) -> Self::Intermediate { + match self { + Self::PushContext(context) => command_card("Push Context", context.into_any()), + Self::PopContext => command_card("Pop Context", field_row("command", "pop context")), + Self::PushClip(clip) => command_card("Push Clip", clip.into_any()), + Self::PopClip => command_card("Pop Clip", field_row("command", "pop clip")), + Self::PushGroup(group) => command_card("Push Group", group.into_any()), + Self::PopGroup => command_card("Pop Group", field_row("command", "pop group")), + Self::Draw(draw) => command_card("Draw", draw.into_any()), + } + } +} + +fn command_card(title: impl Into, body: AnyView) -> AnyView { + Stack::vertical(( + Label::new(title.into()).style(|s| s.font_bold().font_size(12.5)), + body.style(|s| s.padding_left(2.0)), + )) + .style(|s| { + s.width_full() + .gap(8.0) + .padding(10.0) + .min_width(0.0) + .border_radius(10.0) + .border(1.0) + .with_theme(|s, t| s.background(t.bg_elevated()).border_color(t.border())) + }) + .into_any() +} + +fn resolve_command(scene: &Scene, command: &Command) -> ResolvedCommand { + match command { + Command::PushContext(id) => ResolvedCommand::PushContext(scene.context(*id).clone()), + Command::PopContext => ResolvedCommand::PopContext, + Command::PushClip(id) => ResolvedCommand::PushClip(scene.clip(*id).clone()), + Command::PopClip => ResolvedCommand::PopClip, + Command::PushGroup(id) => ResolvedCommand::PushGroup(scene.group(*id).clone()), + Command::PopGroup => ResolvedCommand::PopGroup, + Command::Draw(id) => ResolvedCommand::Draw(scene.draw_op(*id).clone()), + } +} + +fn command_rows(scene: &Scene) -> Vec<(usize, ResolvedCommand)> { + let mut rows = Vec::new(); + let mut depth = 0usize; + for command in scene.commands() { + match command { + Command::PopContext | Command::PopClip | Command::PopGroup => { + depth = depth.saturating_sub(1); + rows.push((depth, resolve_command(scene, command))); + } + Command::PushContext(_) | Command::PushClip(_) | Command::PushGroup(_) => { + rows.push((depth, resolve_command(scene, command))); + depth += 1; + } + Command::Draw(_) => { + rows.push((depth, resolve_command(scene, command))); + } + } + } + rows +} + +pub(crate) fn scene_debug_view_with_size( + scene: Scene, + content_size: peniko::kurbo::Size, +) -> AnyView { + let preview_scene = scene.clone(); + let rows = command_rows(&scene); + let preview_width = content_size.width.max(1.0); + let preview_height = content_size.height.max(1.0); + + let preview = canvas(move |cx, _size| { + replay(&preview_scene, cx.painter.as_imaging_painter().sink_mut()); + }) + .style(move |s| s.width(preview_width).height(preview_height)); + + let preview = preview.container().style(|s| { + s.padding(16.0) + .background(css::WHITE) + .border(1.0) + .border_color(Color::from_rgba8(0, 0, 0, 24)) + .border_radius(10.0) + .apply_box_shadows(vec![ + BoxShadow::new() + .color(css::BLACK.multiply_alpha(0.3)) + .top_offset(-13.) + .bottom_offset(0.4) + .right_offset(-4.) + .left_offset(-4.) + .blur_radius(2.) + .spread(1.), + BoxShadow::new() + .color(css::BLACK.multiply_alpha(0.3)) + .top_offset(-15.) + .bottom_offset(4.) + .right_offset(-6.) + .left_offset(-6.) + .blur_radius(5.) + .spread(6.), + ]) + }); + + let tree = if rows.is_empty() { + Label::new("No retained commands") + .style(|s| s.padding(8.0)) + .into_any() + } else { + Stack::vertical_from_iter(rows.into_iter().enumerate().map(|(idx, (depth, command))| { + command.into_any().style(move |s| { + s.width_full() + .padding_left(8.0 + depth as f64 * 16.0) + .with_theme(move |s, t| { + s.apply_if(idx.is_multiple_of(2), |s| s.background(t.bg_base())) + .apply_if(!idx.is_multiple_of(2), |s| s.background(t.bg_elevated())) + }) + }) + })) + .style(|s| s.width_full().gap(4.0)) + .into_any() + }; + + let command_tree = tree + .scroll() + .style(|s| s.width_full().height_full().min_height(0.0)) + .into_any(); + + let commands = Stack::vertical(( + Label::new("Commands").style(|s| s.font_bold().padding_horiz(4.0)), + command_tree, + )) + .style(|s| s.width_full().height_full().min_height(0.0).gap(4.0)); + + Stack::vertical(( + Label::new("Preview").style(|s| s.font_bold().padding_horiz(4.0)), + preview, + commands, + )) + .style(|s| s.width_full().min_size(0.0, 0.0).gap(8.0)) + .into_any() +} + +impl StylePropValue for Scene { + fn debug_view(&self) -> Option> { + Some(scene_debug_view_with_size( + self.clone(), + peniko::kurbo::Size::new(220.0, 120.0), + )) + } +} diff --git a/renderer/src/text/attrs.rs b/src/text/attrs.rs similarity index 93% rename from renderer/src/text/attrs.rs rename to src/text/attrs.rs index 3096964ca..9cac2304c 100644 --- a/renderer/src/text/attrs.rs +++ b/src/text/attrs.rs @@ -1,11 +1,22 @@ use std::ops::Range; +use crate::effects::Brush; use crate::text::TextBrush; use crate::text::{FontStyle, FontWeight, FontWidth}; use fontique::GenericFamily; use parley::style::{FontFamily, FontStack, StyleProperty, WordBreakStrength}; use peniko::Color; +fn text_brush_index(brushes: &mut Vec, brush: &Brush) -> TextBrush { + if let Some(index) = brushes.iter().position(|existing| existing == brush) { + TextBrush(index) + } else { + let index = brushes.len(); + brushes.push(brush.clone()); + TextBrush(index) + } +} + /// An owned font family identifier. /// /// This is an owned equivalent of Parley's [`FontFamily`] that can be stored @@ -15,7 +26,7 @@ use peniko::Color; /// # Example /// /// ``` -/// use floem_renderer::text::FamilyOwned; +/// use floem::text::FamilyOwned; /// /// let families: Vec = FamilyOwned::parse_list("'Fira Code', monospace").collect(); /// assert_eq!(families, vec![ @@ -52,7 +63,7 @@ impl FamilyOwned { /// # Example /// /// ``` - /// use floem_renderer::text::FamilyOwned; + /// use floem::text::FamilyOwned; /// /// let families: Vec<_> = FamilyOwned::parse_list("Arial, sans-serif").collect(); /// assert_eq!(families, vec![ @@ -111,7 +122,7 @@ impl FamilyOwned { /// # Example /// /// ``` -/// use floem_renderer::text::{Attrs, LineHeightValue}; +/// use floem::text::{Attrs, LineHeightValue}; /// /// // 1.5x the font size (e.g. 24px for a 16px font). /// let attrs = Attrs::new().line_height(LineHeightValue::Normal(1.5)); @@ -153,7 +164,7 @@ impl From for LineHeightValue { } } -/// Text styling attributes used to configure font properties, color, and layout. +/// Text styling attributes used to configure font properties, brush, and layout. /// /// `Attrs` uses a builder pattern where each setter consumes and returns `self`, /// making it easy to chain calls. Unset fields (`None`) inherit from the layout @@ -165,7 +176,7 @@ impl From for LineHeightValue { /// |-------------|-----------------------------------| /// | font_size | `16.0` | /// | line_height | `LineHeightValue::Normal(1.0)` | -/// | color | `None` (inherits from context) | +/// | brush | `None` (inherits from context) | /// | family | `None` (system default) | /// | weight | `None` (normal) | /// | style | `None` (normal) | @@ -175,7 +186,7 @@ impl From for LineHeightValue { /// # Example /// /// ``` -/// use floem_renderer::text::{Attrs, FamilyOwned, FontWeight, LineHeightValue}; +/// use floem::text::{Attrs, FamilyOwned, FontWeight, LineHeightValue}; /// use peniko::Color; /// /// let families = [FamilyOwned::Name("Inter".to_string()), FamilyOwned::SansSerif]; @@ -192,8 +203,8 @@ pub struct Attrs<'a> { pub font_size: f32, /// Line height mode — either a multiplier of `font_size` or an absolute point value. line_height: LineHeightValue, - /// Text color, or `None` to inherit from the rendering context. - color: Option, + /// Text brush, or `None` to inherit from the rendering context. + brush: Option, /// Ordered list of font families to try, or `None` to use the system default. family: Option<&'a [FamilyOwned]>, /// Font weight (e.g. normal, bold), or `None` for the default weight. @@ -220,7 +231,7 @@ impl<'a> Attrs<'a> { Self { font_size: 16.0, line_height: LineHeightValue::Normal(1.0), - color: None, + brush: None, family: None, weight: None, style: None, @@ -232,7 +243,16 @@ impl<'a> Attrs<'a> { /// Sets the text color. pub fn color(mut self, color: Color) -> Self { - self.color = Some(color); + self.brush = Some(Brush::Solid(color)); + self + } + + /// Sets the text brush. + /// + /// Use this for gradient, image, surface, and shader-source text fills. + /// [`Self::color`] is shorthand for a solid-color brush. + pub fn brush(mut self, brush: impl Into) -> Self { + self.brush = Some(brush.into()); self } @@ -295,7 +315,15 @@ impl<'a> Attrs<'a> { /// Returns the text color, or `None` if unset. pub fn get_color(&self) -> Option { - self.color + match self.brush { + Some(Brush::Solid(color)) => Some(color), + _ => None, + } + } + + /// Returns the text brush, or `None` if unset. + pub fn get_brush(&self) -> Option<&Brush> { + self.brush.as_ref() } /// Returns the line height setting. @@ -346,18 +374,22 @@ impl<'a> Attrs<'a> { /// Pushes all set properties as defaults onto a Parley [`RangedBuilder`]. /// - /// Font size and line height are always pushed. Optional properties (color, + /// Font size and line height are always pushed. Optional properties (brush, /// family, weight, style, width) are only pushed when set. /// /// [`RangedBuilder`]: parley::RangedBuilder - pub fn apply_defaults(&self, builder: &mut parley::RangedBuilder<'_, TextBrush>) { + pub fn apply_defaults( + &self, + builder: &mut parley::RangedBuilder<'_, TextBrush>, + brushes: &mut Vec, + ) { builder.push_default(StyleProperty::FontSize(self.font_size)); let lh = self.effective_line_height(); builder.push_default(StyleProperty::LineHeight( parley::style::LineHeight::Absolute(lh), )); - if let Some(color) = self.color { - builder.push_default(StyleProperty::Brush(TextBrush(color))); + if let Some(brush) = &self.brush { + builder.push_default(StyleProperty::Brush(text_brush_index(brushes, brush))); } if let Some(family) = self.family { let stack = FamilyOwned::to_font_stack(family); @@ -388,6 +420,7 @@ impl<'a> Attrs<'a> { builder: &mut parley::RangedBuilder<'_, TextBrush>, range: Range, defaults: &Attrs<'_>, + brushes: &mut Vec, ) { if self.font_size != defaults.font_size { builder.push(StyleProperty::FontSize(self.font_size), range.clone()); @@ -399,8 +432,11 @@ impl<'a> Attrs<'a> { range.clone(), ); } - if let Some(color) = self.color { - builder.push(StyleProperty::Brush(TextBrush(color)), range.clone()); + if let Some(brush) = &self.brush { + builder.push( + StyleProperty::Brush(text_brush_index(brushes, brush)), + range.clone(), + ); } if let Some(family) = self.family { let stack = FamilyOwned::to_font_stack(family); @@ -429,7 +465,7 @@ impl<'a> Attrs<'a> { /// # Example /// /// ``` -/// use floem_renderer::text::{Attrs, AttrsOwned, FamilyOwned, FontWeight}; +/// use floem::text::{Attrs, AttrsOwned, FamilyOwned, FontWeight}; /// /// let families = [FamilyOwned::Monospace]; /// let attrs = Attrs::new().family(&families).weight(FontWeight::BOLD); @@ -444,8 +480,8 @@ pub struct AttrsOwned { pub font_size: f32, /// Line height mode — either a multiplier of `font_size` or an absolute pixel value. line_height: LineHeightValue, - /// Text color, or `None` to inherit from the rendering context. - color: Option, + /// Text brush, or `None` to inherit from the rendering context. + brush: Option, /// Owned list of font families to try, or `None` to use the system default. family: Option>, /// Font weight (e.g. normal, bold), or `None` for the default weight. @@ -466,7 +502,7 @@ impl AttrsOwned { Self { font_size: attrs.font_size, line_height: attrs.line_height, - color: attrs.color, + brush: attrs.brush, family: attrs.family.map(|f| f.to_vec()), weight: attrs.weight, style: attrs.style, @@ -481,7 +517,7 @@ impl AttrsOwned { Attrs { font_size: self.font_size, line_height: self.line_height, - color: self.color, + brush: self.brush.clone(), family: self.family.as_deref(), weight: self.weight, style: self.style, @@ -502,7 +538,7 @@ impl AttrsOwned { /// # Example /// /// ``` -/// use floem_renderer::text::{Attrs, AttrsList, FontWeight}; +/// use floem::text::{Attrs, AttrsList, FontWeight}; /// use peniko::Color; /// /// let mut attrs_list = AttrsList::new(Attrs::new().font_size(14.0)); @@ -608,13 +644,17 @@ impl AttrsList { /// to avoid redundant work. /// /// [`RangedBuilder`]: parley::RangedBuilder - pub fn apply_to_builder(&self, builder: &mut parley::RangedBuilder<'_, TextBrush>) { + pub fn apply_to_builder( + &self, + builder: &mut parley::RangedBuilder<'_, TextBrush>, + brushes: &mut Vec, + ) { let defaults = self.defaults.as_attrs(); - defaults.apply_defaults(builder); + defaults.apply_defaults(builder, brushes); for (range, attrs) in &self.spans { attrs .as_attrs() - .apply_range(builder, range.clone(), &defaults); + .apply_range(builder, range.clone(), &defaults, brushes); } } diff --git a/src/text/layout.rs b/src/text/layout.rs index c488006ba..b823395c7 100644 --- a/src/text/layout.rs +++ b/src/text/layout.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, cell::RefCell, collections::HashMap, num::NonZeroU8, @@ -6,10 +7,11 @@ use std::{ sync::LazyLock, }; -use floem_renderer::Renderer as _; -use floem_renderer::text::{AttrsList, GlyphRunProps, TextBrush}; +use crate::effects::Brush; +use crate::text::{AttrsList, TextBrush}; +use imaging::{PaintSink, Painter, record::Glyph as ImagingGlyph}; use parking_lot::Mutex; -use parley::swash::{FontRef, scale::ScaleContext, zeno}; +use parley::swash::{FontRef, StringId, Tag, scale::ScaleContext, tag_from_bytes, zeno}; use parley::{ Affinity, Alignment, Cursor, FontContext, LayoutContext, Selection, layout::{AlignmentOptions, Layout}, @@ -17,10 +19,42 @@ use parley::{ }; use peniko::{ Fill, FontData, - kurbo::{Affine, Point, Size}, + kurbo::{Affine, Point, Rect, Size}, }; -use crate::paint::Renderer; +fn default_text_brush() -> Brush { + Brush::Solid(peniko::Color::from_rgba8(0, 0, 0, 255)) +} + +/// Resolves text-layout-relative brush inputs before recording glyph runs. +fn text_layout_brush(brush: Brush, bounds: Rect, font_size_cx: &crate::unit::FontSizeCx) -> Brush { + let _ = (bounds, font_size_cx); + brush +} + +fn text_layout_brush_transform(transform: Affine) -> Option { + (transform != Affine::IDENTITY).then_some(transform) +} + +#[derive(Clone, Debug, PartialEq)] +struct BrushStyle { + brush: B, + transform: Affine, +} + +impl BrushStyle { + fn new(brush: B) -> Self { + Self { + brush, + transform: Affine::IDENTITY, + } + } + + fn with_transform(mut self, transform: Option) -> Self { + self.transform = transform.unwrap_or(Affine::IDENTITY); + self + } +} /// Shared Parley font context used by Floem text layout construction. /// @@ -29,6 +63,8 @@ use crate::paint::Renderer; pub static FONT_CONTEXT: LazyLock> = LazyLock::new(|| Mutex::new(FontContext::new())); +const OPSZ_TAG: Tag = tag_from_bytes(b"opsz"); + thread_local! { static LAYOUT_CONTEXT: RefCell> = RefCell::new(LayoutContext::new()); @@ -138,6 +174,64 @@ fn expand_tabs(text: &str, tab_width: usize) -> Option { }) } +#[cfg(target_os = "macos")] +fn effective_normalized_coords<'a>( + font: &'a FontData, + font_size: f32, + effective_scale: f64, + normalized_coords: &'a [i16], +) -> Cow<'a, [i16]> { + if !normalized_coords.is_empty() { + return Cow::Borrowed(normalized_coords); + } + + let Some(font_ref) = FontRef::from_index(font.data.data(), font.index as usize) else { + return Cow::Borrowed(normalized_coords); + }; + + synthesize_opsz_coords(font_ref, font_size * effective_scale as f32) + .map(Cow::Owned) + .unwrap_or_else(|| Cow::Borrowed(normalized_coords)) +} + +#[cfg(not(target_os = "macos"))] +fn effective_normalized_coords<'a>( + _font: &'a FontData, + _font_size: f32, + _effective_scale: f64, + normalized_coords: &'a [i16], +) -> Cow<'a, [i16]> { + Cow::Borrowed(normalized_coords) +} + +#[cfg(target_os = "macos")] +fn synthesize_opsz_coords(font_ref: FontRef<'_>, font_size: f32) -> Option> { + let variations = font_ref.variations(); + let opsz = variations.find_by_tag(OPSZ_TAG)?; + let axis_value = + macos_opsz_value(font_ref, font_size).clamp(opsz.min_value(), opsz.max_value()); + let mut coords = vec![0; variations.len()]; + coords[opsz.index()] = opsz.normalize(axis_value); + Some(coords) +} + +#[cfg(target_os = "macos")] +fn macos_opsz_value(font_ref: FontRef<'_>, font_size: f32) -> f32 { + if is_macos_system_ui_font(font_ref) { + font_size.max(17.0) + } else { + font_size + } +} + +#[cfg(target_os = "macos")] +fn is_macos_system_ui_font(font_ref: FontRef<'_>) -> bool { + let strings = font_ref.localized_strings(); + strings + .find_by_id(StringId::PostScript, None) + .is_some_and(|name| name.to_string().starts_with(".SF")) +} + #[derive(Clone)] /// A Floem wrapper around a Parley text layout. /// @@ -161,6 +255,7 @@ pub struct TextLayout { height_opt: Option, tab_width: Option, tab_info: Option, + brushes: Vec, } impl std::fmt::Debug for TextLayout { @@ -237,6 +332,10 @@ impl TextLayout { skew: Option, glyph_id: u16, ) -> Option { + // Selection geometry currently has no access to window effective scale, so this uses + // logical font size until that context is threaded through. + let normalized_coords = + effective_normalized_coords(font, font_size, 1.0, normalized_coords); let key = GlyphOutlineBoundsKey { font_blob_id: font.data.id(), font_index: font.index, @@ -258,7 +357,7 @@ impl TextLayout { .builder(font_ref) .size(font_size) .hint(true) - .normalized_coords(normalized_coords) + .normalized_coords(normalized_coords.iter().copied()) .build(); let mut outline = scaler.scale_outline(glyph_id)?; if let Some(angle) = skew { @@ -373,6 +472,7 @@ impl TextLayout { height_opt: None, tab_width: None, tab_info: None, + brushes: vec![default_text_brush()], } } @@ -398,6 +498,8 @@ impl TextLayout { .tab_info .as_ref() .map_or(text, |ti| ti.display_text.as_str()); + self.brushes.clear(); + self.brushes.push(default_text_brush()); { let mut font_cx = FONT_CONTEXT.lock(); @@ -407,16 +509,19 @@ impl TextLayout { if let Some(ref ti) = self.tab_info { let defaults = attrs_list.defaults(); - defaults.apply_defaults(&mut builder); + defaults.apply_defaults(&mut builder, &mut self.brushes); for (range, attrs_owned) in attrs_list.spans() { let display_range = ti.orig_to_display(range.start)..ti.orig_to_display(range.end); - attrs_owned - .as_attrs() - .apply_range(&mut builder, display_range, &defaults); + attrs_owned.as_attrs().apply_range( + &mut builder, + display_range, + &defaults, + &mut self.brushes, + ); } } else { - attrs_list.apply_to_builder(&mut builder); + attrs_list.apply_to_builder(&mut builder, &mut self.brushes); } builder.push_default(StyleProperty::TextWrapMode(self.text_wrap_mode)); @@ -497,6 +602,17 @@ impl TextLayout { Size::new(self.layout.full_width() as f64, self.layout.height() as f64) } + fn paint_bounds(&self) -> Rect { + let size = self.size(); + let width = self.width_opt.map(f64::from).unwrap_or(size.width); + let height = self + .height_opt + .filter(|height| height.is_finite() && *height < f32::MAX) + .map(f64::from) + .unwrap_or(size.height); + Rect::ZERO.with_size(Size::new(width, height)) + } + /// Performs hit testing and returns the nearest Parley cursor. /// /// The returned cursor remains in display/layout coordinates. Use @@ -663,6 +779,7 @@ impl TextLayout { pub fn selection_geometry_with_line_metrics( &self, selection: &TextSelection, + effective_scale: f64, mut f: impl FnMut(f64, f64, f64, f64), ) { let selection_range = selection.selection.text_range(); @@ -699,7 +816,13 @@ impl TextLayout { if let Some(bounds) = Self::glyph_outline_bounds( run.font(), run.font_size(), - run.normalized_coords(), + effective_normalized_coords( + run.font(), + run.font_size(), + effective_scale, + run.normalized_coords(), + ) + .as_ref(), run.synthesis().skew(), glyph.id as u16, ) { @@ -779,10 +902,109 @@ impl TextLayout { (min_y.is_finite() && max_y.is_finite()).then_some((min_y, max_y)) } - /// Draws the layout at the given origin using Floem's renderer wrapper. - pub fn draw(&self, renderer: &mut Renderer, origin: impl Into) { + pub fn draw_with_painter( + &self, + painter: Painter<'_, S, F, C, Brush>, + origin: impl Into, + font_embolden: peniko::kurbo::Vec2, + effective_scale: f64, + ) where + S: PaintSink + ?Sized, + { + self.draw_with_painter_with_brush_transform( + painter, + origin, + font_embolden, + effective_scale, + None, + ); + } + + pub fn draw_with_painter_with_brush_transform( + &self, + painter: Painter<'_, S, F, C, Brush>, + origin: impl Into, + font_embolden: peniko::kurbo::Vec2, + effective_scale: f64, + brush_transform: Option, + ) where + S: PaintSink + ?Sized, + { + let origin = origin.into(); + let layout_bounds = self.paint_bounds(); + self.draw_with_painter_resolved_brushes( + painter, + origin, + font_embolden, + effective_scale, + |brush_index, font_size_cx| { + let brush = self + .brushes + .get(brush_index) + .cloned() + .unwrap_or_else(default_text_brush); + BrushStyle::new(text_layout_brush(brush, layout_bounds, font_size_cx)) + .with_transform(brush_transform) + }, + ); + } + + pub fn draw_with_painter_brush( + &self, + painter: Painter<'_, S, F, C, B>, + origin: impl Into, + font_embolden: peniko::kurbo::Vec2, + effective_scale: f64, + brush: B, + brush_transform: Option, + ) where + S: PaintSink + ?Sized, + B: Clone, + { + let origin = origin.into(); + self.draw_with_painter_resolved_brushes( + painter, + origin, + font_embolden, + effective_scale, + |_, _| BrushStyle::new(brush.clone()).with_transform(brush_transform), + ); + } + + pub fn draw_with_painter_brush_style( + &self, + painter: Painter<'_, S, F, C, Brush>, + origin: impl Into, + font_embolden: peniko::kurbo::Vec2, + effective_scale: f64, + brush: Brush, + brush_transform: Option, + ) where + S: PaintSink + ?Sized, + { let origin = origin.into(); + self.draw_with_painter_resolved_brushes( + painter, + origin, + font_embolden, + effective_scale, + |_, _| BrushStyle::new(brush.clone()).with_transform(brush_transform), + ); + } + + fn draw_with_painter_resolved_brushes( + &self, + mut painter: Painter<'_, S, F, C, B>, + origin: Point, + font_embolden: peniko::kurbo::Vec2, + effective_scale: f64, + mut resolve_brush: impl FnMut(usize, &crate::unit::FontSizeCx) -> BrushStyle, + ) where + S: PaintSink + ?Sized, + B: Clone, + { for line in self.layout.lines() { + let line_metrics = *line.metrics(); for item in line.items() { let parley::layout::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue; @@ -794,16 +1016,66 @@ impl TextLayout { .skew() .map(|angle| Affine::skew((angle as f64).to_radians().tan(), 0.0)); - let props = GlyphRunProps::new(run.font()) + let style = peniko::Style::Fill(Fill::NonZero); + let normalized_coords = effective_normalized_coords( + run.font(), + run.font_size(), + effective_scale, + run.normalized_coords(), + ); + let font_size_cx = crate::unit::FontSizeCx::new( + f64::from(run.font_size()), + f64::from(line_metrics.line_height), + ); + let brush_style = resolve_brush(glyph_run.style().brush.0, &font_size_cx); + let glyphs = glyph_run.positioned_glyphs().map(|glyph| ImagingGlyph { + id: glyph.id, + x: glyph.x, + y: glyph.y, + }); + + painter + .glyphs(run.font(), brush_style.brush.clone()) + .transform(Affine::translate(origin.to_vec2())) + .glyph_transform(glyph_transform) .font_size(run.font_size()) + .font_embolden(font_embolden) .hint(false) - .normalized_coords(run.normalized_coords()) - .style(Fill::NonZero) - .brush(glyph_run.style().brush.0) - .glyph_transform(glyph_transform); - - renderer.draw_glyphs(origin, &props, glyph_run.positioned_glyphs()); + .normalized_coords(normalized_coords.as_ref()) + .brush_transform(text_layout_brush_transform(brush_style.transform)) + .draw(&style, glyphs); } } } + + /// Draws the layout into Floem's retained paint recorder. + pub fn draw(&self, cx: &mut crate::paint::PaintCx<'_>, origin: impl Into) { + let font_embolden = cx.font_embolden; + let effective_scale = cx.effective_scale; + self.draw_with_painter_with_brush_transform( + cx.painter.as_dyn(), + origin, + font_embolden, + effective_scale, + None, + ); + } + + /// Draws the layout with an additional text brush transform. + pub fn draw_with_text_brush_transform( + &self, + cx: &mut crate::paint::PaintCx<'_>, + origin: impl Into, + brush_transform: Option, + ) { + let font_embolden = cx.font_embolden; + let effective_scale = cx.effective_scale; + self.draw_with_painter_with_brush_transform( + cx.painter.as_dyn(), + origin, + font_embolden, + effective_scale, + brush_transform, + ); + } } diff --git a/src/text/layout_state.rs b/src/text/layout_state.rs index ea419b73a..c44ab7d1b 100644 --- a/src/text/layout_state.rs +++ b/src/text/layout_state.rs @@ -4,7 +4,7 @@ use peniko::kurbo::{Point, Rect}; use crate::{ ViewId, - context::Phases, + context::{PaintCx, Phases}, custom_event, event::Event, style::{NoWrapOverflow, TextOverflow}, @@ -148,47 +148,80 @@ impl TextLayoutState { &self, selection: &TextSelection, text_origin: Point, + effective_scale: f64, mut f: impl FnMut(Rect), ) { self.with_effective_text_layout(|layout| { - layout.selection_geometry_with_line_metrics(selection, |x0, y0, x1, y1| { - let rect = Rect::new( - x0 + text_origin.x, - y0 + text_origin.y, - x1 + text_origin.x, - y1 + text_origin.y, - ); - if rect.width() > 0.0 && rect.height() > 0.0 { - f(rect); - } - }); + layout.selection_geometry_with_line_metrics( + selection, + effective_scale, + |x0, y0, x1, y1| { + let rect = Rect::new( + x0 + text_origin.x, + y0 + text_origin.y, + x1 + text_origin.x, + y1 + text_origin.y, + ); + if rect.width() > 0.0 && rect.height() > 0.0 { + f(rect); + } + }, + ); }); } + /// Paint-time convenience wrapper that sources optical-size scale from [`PaintCx`]. + pub fn selection_rects_for_selection_with_paint_cx( + &self, + selection: &TextSelection, + text_origin: Point, + paint_cx: &PaintCx, + f: impl FnMut(Rect), + ) { + self.selection_rects_for_selection(selection, text_origin, paint_cx.effective_scale, f); + } + /// Iterates selection rectangles for a byte range in view coordinates. pub fn selection_rects_for_byte_range( &self, start: usize, end: usize, text_origin: Point, + effective_scale: f64, mut f: impl FnMut(Rect), ) { self.with_effective_text_layout(|layout| { let selection = layout.selection_from_byte_range(start, end); - layout.selection_geometry_with_line_metrics(&selection, |x0, y0, x1, y1| { - let rect = Rect::new( - x0 + text_origin.x, - y0 + text_origin.y, - x1 + text_origin.x, - y1 + text_origin.y, - ); - if rect.width() > 0.0 && rect.height() > 0.0 { - f(rect); - } - }); + layout.selection_geometry_with_line_metrics( + &selection, + effective_scale, + |x0, y0, x1, y1| { + let rect = Rect::new( + x0 + text_origin.x, + y0 + text_origin.y, + x1 + text_origin.x, + y1 + text_origin.y, + ); + if rect.width() > 0.0 && rect.height() > 0.0 { + f(rect); + } + }, + ); }); } + /// Paint-time convenience wrapper that sources optical-size scale from [`PaintCx`]. + pub fn selection_rects_for_byte_range_with_paint_cx( + &self, + start: usize, + end: usize, + text_origin: Point, + paint_cx: &PaintCx, + f: impl FnMut(Rect), + ) { + self.selection_rects_for_byte_range(start, end, text_origin, paint_cx.effective_scale, f); + } + /// Returns the origin that vertically centers the effective layout in `content_rect`. pub fn centered_text_origin(&self, content_rect: Rect) -> Point { let mut origin = content_rect.origin(); diff --git a/src/text/mod.rs b/src/text/mod.rs index 9965a5900..eaaab9725 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -1,7 +1,7 @@ //! Floem's high-level text API. //! //! This module exposes the text types used by Floem views and editor code: -//! - styling attributes and font vocabulary re-exported from `floem_renderer::text` +//! - styling attributes and font vocabulary defined here //! - Parley alignment, cursor, selection, and wrapping vocabulary used directly by Floem //! - [`TextLayout`], Floem's layout wrapper around Parley //! - [`TextLayoutState`], shared view state for overflow-aware text layout @@ -11,19 +11,32 @@ use std::ops::Range; +mod attrs; mod layout; mod layout_state; -pub use floem_renderer::text::{ - Attrs, AttrsList, AttrsOwned, FamilyOwned, FontStyle, FontWeight, FontWidth, Glyph, - GlyphRunProps, LineHeightValue, NormalizedCoord, -}; +pub use attrs::{Attrs, AttrsList, AttrsOwned, FamilyOwned, LineHeightValue}; +pub use fontique::{FontStyle, FontWeight, FontWidth}; +pub use imaging::{GlyphRunRef, NormalizedCoord}; pub use layout::{FONT_CONTEXT, TextLayout, TextSelection}; pub use layout_state::{TextLayoutState, TextOverflowChanged}; pub use parley::Alignment; +pub use parley::layout::Glyph; pub use parley::layout::{Affinity, Cursor, Selection}; pub use parley::style::{OverflowWrap, TextWrapMode, WordBreakStrength}; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TextBrush(pub usize); + +pub trait GlyphDrawer { + fn draw_glyphs<'a>( + &mut self, + origin: peniko::kurbo::Point, + run: &GlyphRunRef<'a>, + glyphs: impl Iterator + 'a, + ); +} + /// Returns the byte ranges of the source text's logical paragraphs. /// /// This splits the original string on line-ending boundaries (`\n`, `\r\n`, or `\r`) diff --git a/src/view/id.rs b/src/view/id.rs index 05206842a..cba422fba 100644 --- a/src/view/id.rs +++ b/src/view/id.rs @@ -24,6 +24,7 @@ thread_local! { use crate::context::EventCallbackConfig; use crate::event::listener::EventListenerKey; use crate::event::{RouteKind, listener}; +use crate::paint::display_list::TransformClass; use crate::style::recalc::StyleReason; use crate::view::LayoutTree; use crate::window::handle::get_current_view; @@ -83,12 +84,6 @@ unsafe impl slotmap::Key for ViewId { } } -fn request_structural_selector_restyle(children: &[ViewId]) { - for child in children { - child.request_style(StyleReason::full_recalc()); - } -} - impl ViewId { /// Create a new unique `Viewid`. pub fn new() -> ViewId { @@ -314,7 +309,6 @@ impl ViewId { // Re-parent child's scope under nearest ancestor's scope to match view hierarchy. // This ensures scope hierarchy matches view hierarchy for proper cleanup. reparent_scope_if_needed(child_id, *self); - request_structural_selector_restyle(&self.children()); } /// Append multiple children to this Id's list of children. @@ -362,7 +356,6 @@ impl ViewId { for child_id in child_ids { reparent_scope_if_needed(child_id, *self); } - request_structural_selector_restyle(&self.children()); } /// Set the children views of this Id @@ -401,7 +394,6 @@ impl ViewId { for child_id in children_ids { reparent_scope_if_needed(child_id, *self); } - request_structural_selector_restyle(&self.children()); } /// Set the children views of this Id using a Vector @@ -448,7 +440,6 @@ impl ViewId { for child_id in children_ids { reparent_scope_if_needed(child_id, *self); } - request_structural_selector_restyle(&self.children()); } /// Set the view that should be associated with this Id @@ -508,7 +499,6 @@ impl ViewId { .set_children(this_taffy_node, &taffy_children); s.children.insert(*self, children); }); - request_structural_selector_restyle(&self.children()); } /// Get the list of `ViewId`s that are associated with the children views of this `ViewId` @@ -769,6 +759,7 @@ impl ViewId { if needs_box_tree_update { for child in self.children() { child.request_box_tree_update_for_view(); + child.request_paint(); } } needs_box_tree_update @@ -781,11 +772,7 @@ impl ViewId { /// set the clip rectange in local coordinates in the box tree pub fn set_box_tree_clip(&self, clip: Option) { - let element_id = self.get_element_id(); - VIEW_STORAGE.with_borrow_mut(|s| { - let box_tree = s.box_tree(*self); - box_tree.borrow_mut().set_local_clip(element_id.0, clip); - }) + self.get_element_id().set_local_clip(clip); } /// Returns true if this view is hidden. @@ -834,7 +821,6 @@ impl ViewId { } /// Request that this the `id` view be styled, laid out and painted again. - /// This will recursively request this for all parents. pub fn request_all(&self) { add_update_message(UpdateMessage::RequestStyle( self.get_element_id(), @@ -842,7 +828,7 @@ impl ViewId { )); self.request_layout(); self.request_box_tree_commit(); - self.add_update_message(UpdateMessage::RequestPaint); + self.add_update_message(UpdateMessage::RequestPaint(self.get_element_id())); } /// Request that this view have it's layout pass run @@ -881,7 +867,7 @@ impl ViewId { /// Request that this view have it's paint pass run pub fn request_paint(&self) { - self.add_update_message(UpdateMessage::RequestPaint); + self.add_update_message(UpdateMessage::RequestPaint(self.get_element_id())); } /// request that this node be styled again @@ -1326,6 +1312,18 @@ impl ViewId { true } + /// Mark an owned element as a retained transform boundary. + pub(crate) fn set_retained_transform_boundary_for_element( + &self, + element_id: ElementId, + boundary: Option, + ) -> bool { + let box_tree = self.box_tree(); + box_tree + .borrow_mut() + .set_retained_transform_boundary(element_id.0, boundary) + } + /// Set whether an owned element can receive focus at all. /// /// Clearing focusability also clears keyboard navigation. diff --git a/src/view/mod.rs b/src/view/mod.rs index d2bfcf8df..e2cd09107 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -56,6 +56,7 @@ pub use state::*; pub(crate) use storage::*; pub use tuple::*; +use crate::effects::Brush; use floem_reactive::{Effect, ReadSignal, RwSignal, Scope, SignalGet, UpdaterEffect}; use peniko::kurbo::*; use smallvec::SmallVec; @@ -65,7 +66,6 @@ use std::hash::Hash; use std::rc::Rc; use crate::{ - Renderer, context::{EventCx, PaintCx, StyleCx, UpdateCx}, event::EventPropagation, style::{LayoutProps, Style, StyleClassRef}, @@ -1129,7 +1129,7 @@ impl View for Box { } fn post_paint(&mut self, cx: &mut PaintCx) { - (**self).paint(cx) + (**self).post_paint(cx) } } @@ -1172,6 +1172,7 @@ fn border_to_radii_view( } pub(crate) fn paint_bg(cx: &mut PaintCx, style: &ViewStyleProps, rect: Rect) { + let brush_transform = background_brush_transform(style); let radii = border_to_radii_view(style, rect.size(), &cx.font_size_cx); if radii_max(radii) > 0.0 { paint_box_shadow(cx, style, rect, Some(radii)); @@ -1180,17 +1181,28 @@ pub(crate) fn paint_bg(cx: &mut PaintCx, style: &ViewStyleProps, rect: Rect) { None => return, }; let rounded_rect = rect.to_rounded_rect(radii); - cx.fill(&rounded_rect, &bg, 0.0); + cx.painter + .fill(rounded_rect, &bg) + .brush_transform(brush_transform) + .draw(); } else { paint_box_shadow(cx, style, rect, None); let bg = match style.background() { Some(color) => color, None => return, }; - cx.fill(&rect, &bg, 0.0); + cx.painter + .fill(rect, &bg) + .brush_transform(brush_transform) + .draw(); } } +fn background_brush_transform(style: &ViewStyleProps) -> Option { + let transform = style.background_brush_transform(); + (transform != Affine::IDENTITY).then_some(transform) +} + fn paint_box_shadow( cx: &mut PaintCx, style: &ViewStyleProps, @@ -1213,10 +1225,25 @@ fn paint_box_shadow( ); let rect = rect.inflate(spread, spread).inset(inset); if let Some(radii) = rect_radius { - let rounded_rect = RoundedRect::from_rect(rect, radii_add(radii, spread)); - cx.fill(&rounded_rect, shadow.color, blur_radius); + cx.painter + .blurred_rounded_rect(imaging::BlurredRoundedRect { + transform: Affine::IDENTITY, + rect, + color: shadow.color, + radius: radii_max(radii_add(radii, spread)), + std_dev: blur_radius, + composite: imaging::Composite::default(), + }); } else { - cx.fill(&rect, shadow.color, blur_radius); + cx.painter + .blurred_rounded_rect(imaging::BlurredRoundedRect { + transform: Affine::IDENTITY, + rect, + color: shadow.color, + radius: 0.0, + std_dev: blur_radius, + composite: imaging::Composite::default(), + }); } } } @@ -1229,11 +1256,13 @@ pub(crate) fn paint_outline(cx: &mut PaintCx, style: &ViewStyleProps, rect: Rect let half = outline.width / 2.0; let rect = rect.inflate(half, half); let border_radii = border_to_radii_view(style, rect.size(), &cx.font_size_cx); - cx.stroke( - &rect.to_rounded_rect(radii_add(border_radii, half)), - &style.outline_color(), - outline, - ); + cx.painter + .stroke( + rect.to_rounded_rect(radii_add(border_radii, half)), + outline, + &style.outline_color(), + ) + .draw(); return; } @@ -1279,7 +1308,13 @@ pub(crate) fn paint_outline(cx: &mut PaintCx, style: &ViewStyleProps, rect: Rect BorderPathEvent::NewStroke(stroke) => { // Render current path with previous stroke if any if !current_path.is_empty() { - cx.stroke(¤t_path.as_slice(), &outline_color, &stroke.0); + cx.painter + .stroke( + BezPath::from_vec(current_path.clone()), + &stroke.0, + &outline_color, + ) + .draw(); current_path.clear(); } } @@ -1294,7 +1329,23 @@ pub(crate) fn paint_border( style: &ViewStyleProps, rect: Rect, ) { + let all_border_colors_transparent = |style: &ViewStyleProps| { + let colors = style.border_color(); + [colors.top, colors.right, colors.bottom, colors.left] + .into_iter() + .all(|color| { + color.is_none_or(|color| match color { + Brush::Solid(color) => color.components[3] == 0.0, + Brush::Gradient(_) | Brush::Image(_) => false, + }) + }) + }; + if cx.is_vger() { + if all_border_colors_transparent(style) { + return; + } + let border = layout_style.border(); let left = border.left.unwrap_or(Stroke::new(0.)); @@ -1321,9 +1372,11 @@ pub(crate) fn paint_border( if let Some(color) = style.border_color().left { if radii_max(radii) > 0.0 { let radii = radii_map(radii, |r| (r - half).max(0.0)); - cx.stroke(&rect.to_rounded_rect(radii), &color, &left); + cx.painter + .stroke(rect.to_rounded_rect(radii), &left, &color) + .draw(); } else { - cx.stroke(&rect, &color, &left); + cx.painter.stroke(rect, &left, &color).draw(); } } } else { @@ -1331,47 +1384,59 @@ pub(crate) fn paint_border( && let Some(color) = style.border_color().left { let half = left.width / 2.0; - cx.stroke( - &Line::new(Point::new(half, 0.0), Point::new(half, rect.height())), - &color, - &left, - ); + cx.painter + .stroke( + Line::new(Point::new(half, 0.0), Point::new(half, rect.height())) + .to_path(0.1), + &left, + &color, + ) + .draw(); } if right.width > 0.0 && let Some(color) = style.border_color().right { let half = right.width / 2.0; - cx.stroke( - &Line::new( - Point::new(rect.width() - half, 0.0), - Point::new(rect.width() - half, rect.height()), - ), - &color, - &right, - ); + cx.painter + .stroke( + Line::new( + Point::new(rect.width() - half, 0.0), + Point::new(rect.width() - half, rect.height()), + ) + .to_path(0.1), + &right, + &color, + ) + .draw(); } if top.width > 0.0 && let Some(color) = style.border_color().top { let half = top.width / 2.0; - cx.stroke( - &Line::new(Point::new(0.0, half), Point::new(rect.width(), half)), - &color, - &top, - ); + cx.painter + .stroke( + Line::new(Point::new(0.0, half), Point::new(rect.width(), half)) + .to_path(0.1), + &top, + &color, + ) + .draw(); } if bottom.width > 0.0 && let Some(color) = style.border_color().bottom { let half = bottom.width / 2.0; - cx.stroke( - &Line::new( - Point::new(0.0, rect.height() - half), - Point::new(rect.width(), rect.height() - half), - ), - &color, - &bottom, - ); + cx.painter + .stroke( + Line::new( + Point::new(0.0, rect.height() - half), + Point::new(rect.width(), rect.height() - half), + ) + .to_path(0.1), + &bottom, + &color, + ) + .draw(); } } return; @@ -1403,7 +1468,7 @@ pub(crate) fn paint_border( ]; // Early return if no borders - if borders.iter().all(|b| b.0.width == 0.0) { + if borders.iter().all(|b| b.0.width == 0.0) || all_border_colors_transparent(style) { return; } @@ -1438,7 +1503,13 @@ pub(crate) fn paint_border( BorderPathEvent::NewStroke(stroke) => { // Render current path with previous stroke if any if !current_path.is_empty() && stroke.0.width > 0. { - cx.stroke(¤t_path.as_slice(), &stroke.1, &stroke.0); + cx.painter + .stroke( + BezPath::from_vec(current_path.to_vec()), + &stroke.0, + &stroke.1, + ) + .draw(); current_path.clear(); } else if stroke.0.width == 0. { current_path.clear(); diff --git a/src/view/state.rs b/src/view/state.rs index 03f94e3c1..03748bd86 100644 --- a/src/view/state.rs +++ b/src/view/state.rs @@ -1,3 +1,4 @@ +use crate::platform::Instant; use crate::{ ViewId, action::add_update_message, @@ -12,8 +13,8 @@ use crate::{ style::{ Background, BorderBottomColor, BorderBottomLeftRadius, BorderBottomRightRadius, BorderLeftColor, BorderRightColor, BorderTopColor, BorderTopLeftRadius, - BorderTopRightRadius, BoxShadowProp, CursorStyle, InheritedInteractionCx, LayoutProps, - Outline, OutlineColor, Style, StyleClassRef, StyleSelectors, TransformProps, + BorderTopRightRadius, BoxShadowProp, CursorStyle, FontEmbolden, InheritedInteractionCx, + LayoutProps, Outline, OutlineColor, Style, StyleClassRef, StyleSelectors, TransformProps, recalc::StyleReason, }, view::LayoutTree, @@ -93,7 +94,12 @@ prop_extractor! { pub border_right_color: BorderRightColor, pub border_bottom_color: BorderBottomColor, pub background: Background, + pub background_brush_transform: crate::style::BackgroundBrushTransform, + pub filters: crate::style::Filters, + pub blend_mode: crate::style::BlendModeProp, + pub opacity: crate::style::Opacity, pub shadow: BoxShadowProp, + pub font_embolden: FontEmbolden, } } @@ -514,6 +520,14 @@ impl ViewState { pub fn apply_animations( &mut self, interact_state: &mut crate::style::InteractionState, + ) -> bool { + self.apply_animations_at(interact_state, Instant::now()) + } + + pub fn apply_animations_at( + &mut self, + interact_state: &mut crate::style::InteractionState, + now: Instant, ) -> bool { let mut combined = self.combined_pre_animation_style.clone(); // ───────────────────────────────────────────────────────────────────── @@ -532,8 +546,8 @@ impl ViewState { { if animation.can_advance() { has_active_animation = true; - animation.animate_into(&mut combined); - animation.advance(); + animation.animate_into_at(&mut combined, now); + animation.advance_at(now); } else { animation.apply_folded(&mut combined); } diff --git a/src/view/storage.rs b/src/view/storage.rs index db029207f..9f6bf504b 100644 --- a/src/view/storage.rs +++ b/src/view/storage.rs @@ -142,6 +142,17 @@ impl ViewStorage { .clone() } + pub(crate) fn existing_box_tree(&self, view_id: ViewId) -> Rc> { + let root = self + .root + .get(view_id) + .expect("all view ids are created with a root"); + self.box_tree + .get(root) + .expect("box tree should exist for live view") + .clone() + } + pub(crate) fn state(&mut self, id: ViewId) -> Rc> { if !self.view_ids.contains_key(id) { // if view_ids doesn't have this view id, that means it's been cleaned up, diff --git a/src/views/dropdown.rs b/src/views/dropdown.rs index d77f0f50d..f1612df54 100644 --- a/src/views/dropdown.rs +++ b/src/views/dropdown.rs @@ -12,7 +12,7 @@ use peniko::kurbo::{Point, Size}; use crate::{ AnyView, - action::{add_overlay, exec_after_animation_frame, remove_overlay}, + action::{add_overlay, remove_overlay, request_animation_frame}, context::{Phases, VisualChangedListener}, custom_event, event::{Event, EventPropagation, Phase, RouteKind, listener}, @@ -610,7 +610,7 @@ impl Dropdown { let list = self.build_list_view(); let list_id = list.id(); - exec_after_animation_frame(move |_| { + request_animation_frame(move |_| { // we need to requet focus once the list has been styled and made visible or else it will not be considered focusable list_id.request_focus(); }); diff --git a/src/views/editor/gutter.rs b/src/views/editor/gutter.rs index 1f676e8fc..0a7c69d42 100644 --- a/src/views/editor/gutter.rs +++ b/src/views/editor/gutter.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, rc::Rc}; +use crate::effects::Brush; use crate::{ - Renderer, context::PaintCx, peniko::kurbo::Point, prop, prop_extractor, @@ -12,7 +12,7 @@ use crate::{ views::Decorators, }; use floem_editor_core::{cursor::CursorMode, mode::Mode}; -use floem_reactive::{RwSignal, SignalGet, SignalWith}; +use floem_reactive::{Effect, RwSignal, SignalGet, SignalTrack, SignalWith}; use peniko::Color; use peniko::color::palette; use peniko::kurbo::Rect; @@ -34,7 +34,10 @@ prop_extractor! { } impl GutterStyle { fn gs_accent_color(&self) -> Color { - self.accent_color().unwrap_or(palette::css::BLACK) + match self.accent_color() { + Some(Brush::Solid(color)) => color, + _ => palette::css::BLACK, + } } fn gs_dim_color(&self) -> Color { @@ -55,6 +58,11 @@ style_class!(pub GutterClass); pub fn editor_gutter_view(editor: RwSignal) -> EditorGutterView { let id = ViewId::new(); + let viewport = editor.get_untracked().viewport; + Effect::new(move |_| { + viewport.track(); + id.request_paint(); + }); let mut gutter = EditorGutterView { id, @@ -136,7 +144,8 @@ impl View for EditorGutterView { (*self.full_width.borrow() + 1.1, f64::from(line_height)), ); - cx.fill(&rect, current_line_color, 0.0); + let brush = Brush::Solid(current_line_color); + cx.painter.fill(rect, &brush).draw(); } } } diff --git a/src/views/editor/mod.rs b/src/views/editor/mod.rs index 9786750df..1429b899b 100644 --- a/src/views/editor/mod.rs +++ b/src/views/editor/mod.rs @@ -11,8 +11,9 @@ use std::{ use crate::{ action::{TimerToken, exec_after, set_ime_allowed}, + effects::Brush, kurbo::{Point, Rect, Size, Vec2}, - peniko::{Brush, Color, color::palette}, + peniko::{Color, color::palette}, prop, prop_extractor, reactive::{Effect, ReadSignal, RwSignal, Scope}, style::{CursorColor, StylePropValue, TextColor}, @@ -131,7 +132,10 @@ prop_extractor! { } impl EditorStyle { pub fn ed_text_color(&self) -> Color { - self.text_color().unwrap_or(palette::css::BLACK) + match self.text_color() { + Some(Brush::Solid(color)) => color, + _ => palette::css::BLACK, + } } } impl EditorStyle { @@ -279,7 +283,7 @@ impl Editor { let editor_style = cx.create_rw_signal(EditorStyle::default()); - let ed = Editor { + let mut ed = Editor { cx: Cell::new(cx), effects_cx: Cell::new(cx.create_child()), id, @@ -309,6 +313,9 @@ impl Editor { floem_style_id: cx.create_rw_signal(0), }; + let editor_view_focused = ed.editor_view_focused_value; + ed.cursor_info.should_blink = Rc::new(move || editor_view_focused.get_untracked()); + create_view_effects(ed.effects_cx.get(), &ed); ed @@ -358,9 +365,25 @@ impl Editor { }); } + fn text_document(&self) -> Option> { + (self.doc() as Rc).downcast().ok() + } + + pub(crate) fn sync_text_document_registration(&self) { + let Some(text_doc) = self.text_document() else { + return; + }; + + text_doc.register_editor(self.id(), self.editor_view_id, self.screen_lines); + } + /// Swap the underlying document out pub fn update_doc(&self, doc: Rc, styling: Option>) { Effect::batch(|| { + if let Some(text_doc) = self.text_document() { + text_doc.unregister_editor(self.id()); + } + // Get rid of all the effects self.effects_cx.get().dispose(); @@ -381,6 +404,8 @@ impl Editor { // Recreate the effects self.effects_cx.set(self.cx.get().create_child()); create_view_effects(self.effects_cx.get(), self); + + self.sync_text_document_registration(); }); } @@ -1534,6 +1559,19 @@ fn create_view_effects(cx: Scope, ed: &Editor) { }); } + { + let cursor_info = ed.cursor_info.clone(); + let focused = ed.editor_view_focused_value; + cx.create_effect(move |_| { + if focused.get() { + cursor_info.reset(); + } else { + cursor_info.hidden.set(false); + cursor_info.blink_timer.set(TimerToken::INVALID); + } + }); + } + let update_screen_lines = |ed: &Editor| { // This function should not depend on the viewport signal directly. @@ -1721,15 +1759,23 @@ impl CursorInfo { pub fn blink(&self) { let info = self.clone(); let blink_interval = (info.blink_interval)(); - if blink_interval > 0 && (info.should_blink)() { + if blink_interval > 0 + && (info.should_blink)() + && info.blink_timer.get_untracked() == TimerToken::INVALID + { let blink_timer = info.blink_timer; let timer_token = exec_after(Duration::from_millis(blink_interval), move |timer_token| { if info.blink_timer.try_get_untracked() == Some(timer_token) { - info.hidden.update(|hide| { - *hide = !*hide; - }); - info.blink(); + info.blink_timer.set(TimerToken::INVALID); + if (info.should_blink)() { + info.hidden.update(|hide| { + *hide = !*hide; + }); + info.blink(); + } else { + info.blink_timer.set(TimerToken::INVALID); + } } }); blink_timer.set(timer_token); @@ -1741,7 +1787,11 @@ impl CursorInfo { self.hidden.set(false); } - self.blink_timer.set(TimerToken::INVALID); + let blink_timer = self.blink_timer.get_untracked(); + if blink_timer != TimerToken::INVALID { + blink_timer.cancel(); + self.blink_timer.set(TimerToken::INVALID); + } self.blink(); } diff --git a/src/views/editor/text_document.rs b/src/views/editor/text_document.rs index 1a7c0e5f0..6f5361338 100644 --- a/src/views/editor/text_document.rs +++ b/src/views/editor/text_document.rs @@ -27,9 +27,18 @@ use super::{ id::EditorId, phantom_text::{PhantomText, PhantomTextKind, PhantomTextLine}, text::{Document, DocumentPhantom, PreeditData, SystemClipboard}, + view::ScreenLines, + visual_line::RVLine, }; +use crate::view::ViewId; type PreCommandFn = Box CommandExecuted>; +#[derive(Clone, Copy)] +struct AttachedEditor { + view_id: floem_reactive::RwSignal>, + screen_lines: floem_reactive::RwSignal, +} + #[derive(Debug, Clone)] pub struct PreCommand<'a> { pub editor: &'a Editor, @@ -73,6 +82,7 @@ pub struct TextDocument { pre_command: Rc>>>, on_updates: Rc>>, + attached_editors: Rc>>, } impl TextDocument { pub fn new(cx: Scope, text: impl Into) -> TextDocument { @@ -102,6 +112,7 @@ impl TextDocument { placeholders, pre_command: Rc::new(RefCell::new(HashMap::new())), on_updates: Rc::new(RefCell::new(SmallVec::new())), + attached_editors: Rc::new(RefCell::new(HashMap::new())), } } @@ -112,6 +123,8 @@ impl TextDocument { } fn on_update(&self, ed: Option<&Editor>, deltas: &[(Rope, RopeDelta, InvalLines)]) { + self.request_shared_editor_paint(ed, deltas); + let on_updates = self.on_updates.borrow(); let data = OnUpdate { editor: ed, deltas }; for on_update in on_updates.iter() { @@ -119,6 +132,74 @@ impl TextDocument { } } + pub fn register_editor( + &self, + editor_id: EditorId, + view_id: floem_reactive::RwSignal>, + screen_lines: floem_reactive::RwSignal, + ) { + self.attached_editors.borrow_mut().insert( + editor_id, + AttachedEditor { + view_id, + screen_lines, + }, + ); + } + + pub fn unregister_editor(&self, editor_id: EditorId) { + self.attached_editors.borrow_mut().remove(&editor_id); + } + + fn request_shared_editor_paint( + &self, + source: Option<&Editor>, + deltas: &[(Rope, RopeDelta, InvalLines)], + ) { + let attached_editors = self.attached_editors.borrow(); + + if let Some(source) = source { + let Some(source_range) = source + .screen_lines + .with_untracked(|screen_lines| screen_lines.rvline_range()) + else { + return; + }; + + for (editor_id, attached) in attached_editors.iter() { + if *editor_id == source.id() { + continue; + } + + let overlaps = attached + .screen_lines + .with_untracked(|screen_lines| screen_lines.rvline_range()) + .is_some_and(|peer_range| rvline_ranges_overlap(source_range, peer_range)); + + if overlaps && let Some(view_id) = attached.view_id.get_untracked() { + view_id.request_paint(); + } + } + + return; + } + + for attached in attached_editors.values() { + let overlaps_change = attached + .screen_lines + .with_untracked(|screen_lines| screen_lines.rvline_range()) + .is_some_and(|visible_range| { + deltas + .iter() + .any(|(_, _, inval)| inval_overlaps_visible_lines(inval, visible_range)) + }); + + if overlaps_change && let Some(view_id) = attached.view_id.get_untracked() { + view_id.request_paint(); + } + } + } + pub fn add_pre_command( &self, id: EditorId, @@ -153,6 +234,22 @@ impl TextDocument { .with_untracked(|placeholders| placeholders.get(&editor_id).cloned()) } } + +fn rvline_ranges_overlap( + (a_start, a_end): (RVLine, RVLine), + (b_start, b_end): (RVLine, RVLine), +) -> bool { + a_start <= b_end && b_start <= a_end +} + +fn inval_overlaps_visible_lines(inval: &InvalLines, visible_range: (RVLine, RVLine)) -> bool { + let start_line = inval.start_line; + let end_line = inval.start_line + usize::max(inval.inval_count, inval.new_count); + let visible_start = visible_range.0.line; + let visible_end = visible_range.1.line; + + start_line <= visible_end && visible_start <= end_line +} impl Document for TextDocument { fn text(&self) -> Rope { self.buffer.with_untracked(|buffer| buffer.text().clone()) diff --git a/src/views/editor/view.rs b/src/views/editor/view.rs index 5743317b6..661b7ae85 100644 --- a/src/views/editor/view.rs +++ b/src/views/editor/view.rs @@ -1,14 +1,14 @@ use std::{collections::HashMap, ops::RangeInclusive, rc::Rc}; +use crate::effects::Brush; use crate::{ - Renderer, action::{set_ime_allowed, set_ime_cursor_area}, context::{LayoutChanged, LayoutChangedListener, PaintCx, UpdateCx, VisualChanged}, event::{ CustomEvent, Event, EventPropagation, ImeEvent, PointerScrollEventExt, listener, listener::UpdatePhaseBoxTreeCommit, }, - kurbo::{BezPath, Line, Point, Rect, Size, Vec2}, + kurbo::{BezPath, Line, Point, Rect, Shape, Size, Vec2}, peniko::Color, prelude::EventListenerTrait, reactive::{Effect, Memo, RwSignal, Scope}, @@ -25,7 +25,6 @@ use floem_editor_core::{ mode::{Mode, VisualMode}, }; use floem_reactive::{SignalGet, SignalTrack, SignalUpdate, SignalWith}; -use peniko::Brush; use taffy::Overflow; use ui_events::{ keyboard::{Key, KeyboardEvent, Modifiers}, @@ -539,7 +538,7 @@ impl EditorView { }; let rect = Rect::from_origin_size((x0, vline_y), (width, line_height)); - cx.fill(&rect, color, 0.0); + cx.painter.fill(rect, color).draw(); } } @@ -588,7 +587,7 @@ impl EditorView { (viewport.x0, vline_y), (x1 - viewport.x0, f64::from(line_height)), ); - cx.fill(&rect, color, 0.0); + cx.painter.fill(rect, color).draw(); } } @@ -634,7 +633,7 @@ impl EditorView { let line_height = ed.line_height(line); let rect = Rect::from_origin_size((x0, line_info.vline_y), (x1 - x0, f64::from(line_height))); - cx.fill(&rect, color, 0.0); + cx.painter.fill(rect, color).draw(); } } @@ -667,7 +666,8 @@ impl EditorView { (viewport.width(), f64::from(line_height)), ); - cx.fill(&rect, current_line_color, 0.0); + let brush = Brush::Solid(current_line_color); + cx.painter.fill(rect, &brush).draw(); } } } @@ -792,7 +792,7 @@ impl EditorView { let line_height = ed.line_height(info.vline_info.rvline.line); let rect = Rect::from_origin_size((x, info.vline_y), (width, f64::from(line_height))); - cx.fill(&rect, &caret_color, 0.0); + cx.painter.fill(rect, &caret_color).draw(); } } }); @@ -815,7 +815,10 @@ impl EditorView { direction *= -1.0; } - cx.stroke(&path, color, &peniko::kurbo::Stroke::new(1.)); + let brush = Brush::Solid(color); + cx.painter + .stroke(path, &peniko::kurbo::Stroke::new(1.), &brush) + .draw(); } pub fn paint_extra_style( @@ -835,13 +838,15 @@ impl EditorView { }; let x = style.x + base; let y = y + style.y; - cx.fill( - &Rect::ZERO - .with_size(Size::new(width, height)) - .with_origin(Point::new(x, y)), - bg, - 0.0, - ); + let brush = Brush::Solid(bg); + cx.painter + .fill( + Rect::ZERO + .with_size(Size::new(width, height)) + .with_origin(Point::new(x, y)), + &brush, + ) + .draw(); } if let Some(color) = style.under_line { @@ -853,11 +858,14 @@ impl EditorView { }; let x = style.x + base; let y = y + style.y + height; - cx.stroke( - &Line::new(Point::new(x, y), Point::new(x + width, y)), - color, - &peniko::kurbo::Stroke::new(1.), - ); + let brush = Brush::Solid(color); + cx.painter + .stroke( + Line::new(Point::new(x, y), Point::new(x + width, y)).to_path(0.1), + &peniko::kurbo::Stroke::new(1.), + &brush, + ) + .draw(); } if let Some(color) = style.wave_line { @@ -897,16 +905,20 @@ impl EditorView { if ed.es.with(|s| s.show_indent_guide()) { // Cache the indent guide color outside the loop to avoid repeated signal access let indent_guide_color = ed.es.with_untracked(|es| es.indent_guide()); + let indent_guide_brush = Brush::Solid(indent_guide_color); for (line, y) in screen_lines.iter_lines_y() { let text_layout = ed.text_layout(line); let line_height = f64::from(ed.line_height(line)); let mut x = 0.0; while x + 1.0 < text_layout.indent { - cx.stroke( - &Line::new(Point::new(x, y), Point::new(x, y + line_height)), - indent_guide_color, - &peniko::kurbo::Stroke::new(1.), - ); + cx.painter + .stroke( + Line::new(Point::new(x, y), Point::new(x, y + line_height)) + .to_path(0.1), + &peniko::kurbo::Stroke::new(1.), + &indent_guide_brush, + ) + .draw(); x += indent_text_width; } } @@ -1033,6 +1045,7 @@ impl View for EditorView { ); if editor.viewport.with_untracked(|v| v != &viewport) { editor.viewport.set(viewport); + cx.window_state.request_paint(self.id()); } } if LayoutChangedListener::extract(&cx.event).is_some() { @@ -1101,9 +1114,12 @@ pub fn editor_view( }); let hide_cursor = ed.cursor_info.hidden; + let focused_for_blink = ed.editor_view_focused_value; Effect::new(move |_| { hide_cursor.track(); - id.request_paint(); + if focused_for_blink.get() { + id.request_paint(); + } }); let editor_window_origin = ed.window_origin; @@ -1332,6 +1348,11 @@ pub fn editor_container_view( .on_cleanup(move || { // TODO: should we have some way for doc to tell us if we're allowed to cleanup the editor? let editor = editor.get_untracked(); + if let Ok(doc) = + (editor.doc() as Rc).downcast::() + { + doc.unregister_editor(editor.id()); + } editor.cx.get().dispose(); }) } @@ -1379,6 +1400,7 @@ fn editor_content( let id = editor_content_view.id(); ed.editor_view_id.set(Some(id)); + ed.sync_text_document_registration(); editor_content_view .on_event_cont(listener::FocusGained, move |_, _| { diff --git a/src/views/editor/visual_line.rs b/src/views/editor/visual_line.rs index 5632f77b8..7436fa870 100644 --- a/src/views/editor/visual_line.rs +++ b/src/views/editor/visual_line.rs @@ -2960,7 +2960,6 @@ mod tests { let text = Rope::from("asdf\nposition: Some(EditorPosition::Offset(self.offset))\nasdf\nasdf"); let (text_prov, mut lines) = make_lines(&text, 1., true); - println!("Breaks: {:?}", render_breaks(&text, &mut lines, FONT_SIZE)); let rvline = lines.rvline_of_offset(&text_prov, 3, CursorAffinity::Backward); assert_eq!(rvline, RVLine::new(0, 0)); @@ -3303,7 +3302,6 @@ mod tests { // vs starting at zero and iterating to that index. for i in 0..x.len() { let nums = get_nums(i); - println!("i: {i}, #nums: {}, #&x[i..]: {}", nums.len(), x[i..].len()); assert_eq!(nums, &x[i..], "failed at #{i}"); } diff --git a/src/views/image.rs b/src/views/image.rs new file mode 100644 index 000000000..129c3610f --- /dev/null +++ b/src/views/image.rs @@ -0,0 +1,876 @@ +//! Module defining image view and its properties: style, position and fit. +#![deny(missing_docs)] +use std::{ + cell::RefCell, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; + +use floem_reactive::{ReadSignal, RwSignal, SignalWith, UpdaterEffect}; +use imaging::Image as ImagingImage; +use peniko::{Blob, ImageAlphaType, ImageData, kurbo::Rect}; + +use crate::{ + effects::{Brush, Image as EffectImage, ImageBrush, ShaderSourceImage}, + prop_extractor, + style::{FontSizeCx, ObjectFit, ObjectPosition}, + view::{LayoutNodeCx, MeasureFn, View, ViewId}, +}; + +/// Holds information about image dimensions for layout calculations. +#[derive(Clone)] +pub struct ImageLayoutData { + /// Natural width of the image in pixels + natural_width: u32, + /// Natural height of the image in pixels + natural_height: u32, +} + +impl ImageLayoutData { + /// Create new image layout data + pub fn new(width: u32, height: u32) -> Self { + Self { + natural_width: width, + natural_height: height, + } + } + + /// Get the natural aspect ratio of the image + pub fn aspect_ratio(&self) -> f32 { + if self.natural_height == 0 { + 1.0 + } else { + self.natural_width as f32 / self.natural_height as f32 + } + } + + /// Create a taffy layout function for image sizing following CSS rules + pub fn create_taffy_layout_fn( + layout_data: Rc>, + _object_fit: ObjectFit, + ) -> Box { + Box::new( + move |known_dimensions, _available_space, _node_id, style, _measure_ctx| { + use taffy::*; + + let data = layout_data.borrow(); + let natural_width = data.natural_width as f32; + let natural_height = data.natural_height as f32; + let preferred_aspect = style + .aspect_ratio + .filter(|aspect| aspect.is_finite() && *aspect > 0.0) + .unwrap_or_else(|| data.aspect_ratio()); + if natural_width == 0.0 || natural_height == 0.0 { + return Size { + width: 0.0, + height: 0.0, + }; + } + + // If only width is set, compute height from aspect ratio + if let Some(w) = known_dimensions.width { + let h = known_dimensions.height.unwrap_or_else(|| { + // Use aspect ratio to compute height + w / preferred_aspect + }); + return Size { + width: w, + height: h, + }; + } + + // If only height is set, compute width from aspect ratio + if let Some(h) = known_dimensions.height { + let w = h * preferred_aspect; + return Size { + width: w, + height: h, + }; + } + + // No explicit dimensions: keep the intrinsic width, but honor an explicit + // preferred aspect ratio when computing the auto height. + Size { + width: natural_width, + height: natural_width / preferred_aspect, + } + }, + ) + } +} + +prop_extractor! { + Extractor { + object_fit: crate::style::ObjectFitProp, + object_position: crate::style::ObjectPositionProp, + image_sampler: crate::style::ImageSamplerProp, + } +} + +/// Holds the data needed for [Image] view to display images. +pub struct Image { + id: ViewId, + img: EffectImage, + layout_data: Rc>, + style: Extractor, +} + +#[doc(hidden)] +pub enum ImgReader { + Static(EffectImage), + Reactive(Rc EffectImage>), +} + +/// A static input that can be converted into image content for [`Img`]. +pub trait ImgDataSource { + /// Convert this value into owned image data. + fn into_image_data(self) -> EffectImage; +} + +impl ImgDataSource for ImageData { + fn into_image_data(self) -> EffectImage { + self.into() + } +} + +impl ImgDataSource for ImagingImage { + fn into_image_data(self) -> EffectImage { + self.try_into() + .expect("imaging::ExternalImage cannot be stored in a Floem Img") + } +} + +impl ImgDataSource for EffectImage { + fn into_image_data(self) -> EffectImage { + self + } +} + +impl ImgDataSource for ShaderSourceImage { + fn into_image_data(self) -> EffectImage { + self.into() + } +} + +impl ImgDataSource for crate::effects::SurfaceImage { + fn into_image_data(self) -> EffectImage { + self.into() + } +} + +impl ImgDataSource for Vec { + fn into_image_data(self) -> EffectImage { + Image::image_data_from_bytes(&self).into() + } +} + +impl ImgDataSource for &'static [u8] { + fn into_image_data(self) -> EffectImage { + Image::image_data_from_bytes(self).into() + } +} + +impl ImgDataSource for &'static [u8; N] { + fn into_image_data(self) -> EffectImage { + Image::image_data_from_bytes(self.as_slice()).into() + } +} + +impl ImgDataSource for PathBuf { + fn into_image_data(self) -> EffectImage { + Image::image_data_from_path(&self).into() + } +} + +/// A source that can produce image content for [`Img`]. +/// +/// This supports: +/// - direct image bytes (`Vec`) +/// - direct file paths (`PathBuf`) +/// - direct [`ImageData`] +/// - closures returning any of the above +/// - [`ReadSignal`] and [`RwSignal`] values containing any of the above +pub trait ImgSource: 'static { + /// Convert this source into either a static image or a reactive reader. + fn into_img_reader(self) -> ImgReader; +} + +impl ImgSource for Vec { + fn into_img_reader(self) -> ImgReader { + ImgReader::Static(self.into_image_data()) + } +} + +impl ImgSource for &'static [u8] { + fn into_img_reader(self) -> ImgReader { + ImgReader::Static(self.into_image_data()) + } +} + +impl ImgSource for PathBuf { + fn into_img_reader(self) -> ImgReader { + ImgReader::Static(self.into_image_data()) + } +} + +impl ImgSource for ImageData { + fn into_img_reader(self) -> ImgReader { + ImgReader::Static(self.into_image_data()) + } +} + +impl ImgSource for ImagingImage { + fn into_img_reader(self) -> ImgReader { + ImgReader::Static(self.into_image_data()) + } +} + +impl ImgSource for EffectImage { + fn into_img_reader(self) -> ImgReader { + ImgReader::Static(self) + } +} + +impl ImgSource for ShaderSourceImage { + fn into_img_reader(self) -> ImgReader { + ImgReader::Static(self.into()) + } +} + +impl ImgSource for crate::effects::SurfaceImage { + fn into_img_reader(self) -> ImgReader { + ImgReader::Static(self.into()) + } +} + +impl ImgSource for F +where + F: Fn() -> T + 'static, + T: ImgDataSource, +{ + fn into_img_reader(self) -> ImgReader { + ImgReader::Reactive(Rc::new(move || self().into_image_data())) + } +} + +impl ImgSource for ReadSignal +where + ReadSignal: SignalWith

, + P: Clone + ImgDataSource + 'static, + S: 'static, +{ + fn into_img_reader(self) -> ImgReader { + ImgReader::Reactive(Rc::new(move || { + self.with(|value| value.clone().into_image_data()) + })) + } +} + +impl ImgSource for RwSignal +where + RwSignal: SignalWith

, + P: Clone + ImgDataSource + 'static, + S: 'static, +{ + fn into_img_reader(self) -> ImgReader { + ImgReader::Reactive(Rc::new(move || { + self.with(|value| value.clone().into_image_data()) + })) + } +} + +/// A view that can display an image and controls its position. +/// +/// It takes function that produce `Vec` and will convert it into [Image](peniko::Image). +/// +/// Image painting follows CSS-like object sizing from the image payload's +/// intrinsic size: +/// +/// 1. Layout computes this view's content box. +/// 2. [`ObjectFit`] and [`ObjectPosition`] choose the destination rect inside +/// that content box. `Contain`/`ScaleDown` may make the destination smaller +/// than the content box; `Cover` may make it larger and then clip it. +/// 3. The image brush is transformed from the intrinsic source rect into the +/// destination rect. +/// +/// Brush placement and sampling changes should be expressed with the draw +/// command's brush transform or style brush-transform properties, not by +/// changing the image payload. +/// +/// ### Example: +/// ```rust +/// # use crate::floem::views::Decorators; +/// # use floem::views::img; +/// let ferris_png = include_bytes!("../../examples/widget-gallery/assets/ferris.png"); +/// // Create an image from the function returning Vec: +/// img(move || ferris_png.to_vec()) +/// .style(|s| s.size(50.,50.)); +/// ``` +/// # Reactivity +/// The `img` function is not reactive, so to make it change on event, wrap it +/// with [`dyn_view`](crate::views::dyn_view::dyn_view). +/// +/// ### Example with reactive updates: +/// ```rust +/// # use floem::prelude::*; +/// # use crate::floem::views::Decorators; +/// # use floem::views::img; +/// # use floem::views::dyn_view; +/// # use floem::reactive::RwSignal; +/// +/// #[derive(Clone)] +/// enum Image { +/// ImageA, +/// ImageB +/// } +/// +/// let ferris = include_bytes!("../../examples/widget-gallery/assets/ferris.png"); +/// let sunflower = include_bytes!("../../examples/widget-gallery/assets/sunflower.jpg"); +/// let switch_image = RwSignal::new(Image::ImageA); +/// // Create an image from the function returning Vec: +/// dyn_view(move || { +/// let image = switch_image.get(); +/// img(move || { +/// match image { +/// Image::ImageA => ferris.to_vec(), +/// Image::ImageB => sunflower.to_vec() +/// } +/// }).style(|s| s.size(50.,50.)) +/// }); +/// ``` +#[deprecated(note = "Use Img::new(...) instead")] +pub fn img(image: impl Fn() -> Vec + 'static) -> Image { + Image::new(image) +} + +/// A view that can display an image and controls its position. +/// +/// It takes function that returns [`PathBuf`] and will convert it into [`Image`](peniko::Image). +/// +/// ### Example: +/// ```rust +/// # use std::path::PathBuf; +/// # use floem::views::Decorators; +/// # use floem::views::img_from_path; +/// +/// let path_to_ferris = PathBuf::from(r"../../examples/widget-gallery/assets/ferrig.png"); +/// // Create an image from the function returning PathBuf: +/// img_from_path(move || path_to_ferris.clone()) +/// .style(|s| s.size(50.,50.)); +/// ``` +/// # Reactivity +/// The `img` function is not reactive, so to make it change on event, wrap it +/// with [`dyn_view`](crate::views::dyn_view::dyn_view). +#[deprecated(note = "Use Img::new(...) instead")] +pub fn img_from_path(image: impl Fn() -> PathBuf + 'static) -> Image { + Image::new(image) +} + +impl Image { + /// Decode static image bytes, a static image path, or reuse existing image + /// content. + pub fn image_data(source: impl ImgDataSource) -> EffectImage { + source.into_image_data() + } + + fn image_data_from_dynamic(image: Option) -> ImageData { + let width = image.as_ref().map_or(0, |img| img.width()); + let height = image.as_ref().map_or(0, |img| img.height()); + let data = Arc::new(image.map_or_else(Vec::new, |img| img.into_rgba8().into_vec())); + let blob = Blob::new(data); + ImageData { + data: blob, + format: peniko::ImageFormat::Rgba8, + alpha_type: ImageAlphaType::Alpha, + width, + height, + } + } + + fn image_data_from_bytes(bytes: &[u8]) -> ImageData { + Self::image_data_from_dynamic(image::load_from_memory(bytes).ok()) + } + + fn image_data_from_path(path: &Path) -> ImageData { + Self::image_data_from_dynamic(image::open(path).ok()) + } + + /// Create an image view from bytes, a path, image data, + /// or a reactive closure/signal producing any of those. + pub fn new(source: impl ImgSource) -> Self { + let id = ViewId::new(); + let img = match source.into_img_reader() { + ImgReader::Static(image) => image, + ImgReader::Reactive(reader) => { + let initial = reader(); + UpdaterEffect::new( + move || reader(), + move |image| { + id.update_state(image); + }, + ); + initial + } + }; + + let layout_data = Rc::new(RefCell::new(ImageLayoutData::new( + intrinsic_image_width(&img), + intrinsic_image_height(&img), + ))); + + let mut img = Self { + id, + img, + layout_data, + style: Extractor::default(), + }; + + img.set_taffy_layout(); + img + } + + fn set_image(&mut self, image: EffectImage) { + self.layout_data.borrow_mut().natural_width = intrinsic_image_width(&image); + self.layout_data.borrow_mut().natural_height = intrinsic_image_height(&image); + self.img = image; + self.id.request_mark_view_layout_dirty(); + self.id.request_layout(); + } + + fn set_taffy_layout(&mut self) { + let taffy = self.id.taffy(); + let taffy_node = self.id.taffy_node(); + let mut taffy = taffy.borrow_mut(); + + let object_fit = self.style.object_fit(); + let layout_fn = + ImageLayoutData::create_taffy_layout_fn(self.layout_data.clone(), object_fit); + + let _ = taffy.set_node_context( + taffy_node, + Some(LayoutNodeCx::Custom { + measure: layout_fn, + finalize: None, + }), + ); + } + + /// Compute the destination rect for the image within the content box, + /// following CSS object-fit rules. The returned rect is in local coords + /// and may be larger than `content_rect` (for Cover) or smaller (for Contain). + pub fn object_fit_dest_rect(&self, content_rect: Rect) -> Rect { + self.object_fit_dest_rect_with_size( + content_rect, + object_fit_natural_size(&self.img, content_rect), + self.style.object_fit(), + self.style.object_position(), + ) + } + + /// This method is mostly here for tests + pub fn object_fit_dest_rect_with( + &self, + content_rect: Rect, + object_fit: ObjectFit, + object_position: ObjectPosition, + ) -> Rect { + self.object_fit_dest_rect_with_size( + content_rect, + object_fit_natural_size(&self.img, content_rect), + object_fit, + object_position, + ) + } + + fn object_fit_dest_rect_with_size( + &self, + content_rect: Rect, + natural_size: peniko::kurbo::Size, + object_fit: ObjectFit, + object_position: ObjectPosition, + ) -> Rect { + let natural_w = natural_size.width; + let natural_h = natural_size.height; + if natural_w == 0.0 || natural_h == 0.0 { + return content_rect; + } + let box_w = content_rect.width(); + let box_h = content_rect.height(); + if box_w == 0.0 || box_h == 0.0 { + return content_rect; + } + let (rendered_w, rendered_h) = match object_fit { + ObjectFit::Fill => (box_w, box_h), + ObjectFit::None => (natural_w, natural_h), + ObjectFit::Contain => { + let scale = (box_w / natural_w).min(box_h / natural_h); + (natural_w * scale, natural_h * scale) + } + ObjectFit::Cover => { + let scale = (box_w / natural_w).max(box_h / natural_h); + (natural_w * scale, natural_h * scale) + } + ObjectFit::ScaleDown => { + let contain_scale = (box_w / natural_w).min(box_h / natural_h); + let scale = contain_scale.min(1.0); + (natural_w * scale, natural_h * scale) + } + }; + let (x_align, y_align) = match object_position { + ObjectPosition::TopLeft => (0.0, 0.0), + ObjectPosition::Top => (0.5, 0.0), + ObjectPosition::TopRight => (1.0, 0.0), + ObjectPosition::Left => (0.0, 0.5), + ObjectPosition::Center => (0.5, 0.5), + ObjectPosition::Right => (1.0, 0.5), + ObjectPosition::BottomLeft => (0.0, 1.0), + ObjectPosition::Bottom => (0.5, 1.0), + ObjectPosition::BottomRight => (1.0, 1.0), + ObjectPosition::Custom(x, y) => { + let font_size_cx = FontSizeCx::new(16.0, 16.0); + let free_x = box_w - rendered_w; + let free_y = box_h - rendered_h; + let x0 = content_rect.x0 + x.resolve(free_x, &font_size_cx); + let y0 = content_rect.y0 + y.resolve(free_y, &font_size_cx); + return Rect::new(x0, y0, x0 + rendered_w, y0 + rendered_h); + } + }; + let x0 = content_rect.x0 + (box_w - rendered_w) * x_align; + let y0 = content_rect.y0 + (box_h - rendered_h) * y_align; + Rect::new(x0, y0, x0 + rendered_w, y0 + rendered_h) + } + + fn needs_clip(&self) -> bool { + matches!(self.style.object_fit(), ObjectFit::Cover | ObjectFit::None) + } +} + +impl View for Image { + fn id(&self) -> ViewId { + self.id + } + + fn debug_name(&self) -> std::borrow::Cow<'static, str> { + "Img".into() + } + + fn update(&mut self, _cx: &mut crate::context::UpdateCx, state: Box) { + match state.downcast::() { + Ok(img) => { + self.set_image(*img); + } + Err(state) => match state.downcast::() { + Ok(img) => self.set_image((*img).into_image_data()), + Err(state) => { + if let Ok(img) = state.downcast::() { + self.set_image((*img).into()); + } + } + }, + } + } + + fn style_pass(&mut self, cx: &mut crate::context::StyleCx<'_>) { + if self.style.read(cx) { + // object_fit or image quality changed + self.set_taffy_layout(); + self.id.request_layout(); + cx.window_state.request_paint(self.id); + } + } + + fn paint(&mut self, cx: &mut crate::context::PaintCx) { + let content_rect = self.id.get_content_rect_local(); + let dest_rect = self.object_fit_dest_rect(content_rect); + let mut image_brush = ImageBrush::new(self.img.clone()); + image_brush.sampler = self.style.image_sampler(); + let image_brush = Brush::Image(image_brush); + let source_size = resolved_image_size(&self.img, dest_rect); + let source_width = source_size.width; + let source_height = source_size.height; + + if source_width <= 0.0 || source_height <= 0.0 { + return; + } + + let source_rect = Rect::new(0.0, 0.0, source_width, source_height); + let image_transform = peniko::kurbo::Affine::scale_non_uniform( + dest_rect.width() / source_width, + dest_rect.height() / source_height, + ) + .then_translate(dest_rect.origin().to_vec2()); + + if self.needs_clip() { + cx.painter.with_fill_clip(content_rect, |p| { + p.fill(source_rect, &image_brush) + .transform(image_transform) + .draw(); + }); + } else { + cx.painter + .fill(source_rect, &image_brush) + .transform(image_transform) + .draw(); + } + } +} + +fn intrinsic_image_width(image: &EffectImage) -> u32 { + size_width(image.intrinsic_size()) +} + +fn intrinsic_image_height(image: &EffectImage) -> u32 { + size_height(image.intrinsic_size()) +} + +fn size_width(size: peniko::kurbo::Size) -> u32 { + size.width.max(0.0).round().min(u32::MAX as f64) as u32 +} + +fn size_height(size: peniko::kurbo::Size) -> u32 { + size.height.max(0.0).round().min(u32::MAX as f64) as u32 +} + +fn resolved_image_size(image: &EffectImage, bounds: Rect) -> peniko::kurbo::Size { + let _ = bounds; + image.intrinsic_size() +} + +fn object_fit_natural_size(image: &EffectImage, bounds: Rect) -> peniko::kurbo::Size { + let _ = bounds; + image.intrinsic_size() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::MeasureCx; + + fn run_measure( + width: u32, + height: u32, + object_fit: ObjectFit, + known: taffy::Size>, + available: taffy::Size, + ) -> taffy::Size { + run_measure_with_style( + width, + height, + object_fit, + known, + available, + taffy::Style::default(), + ) + } + + fn run_measure_with_style( + width: u32, + height: u32, + object_fit: ObjectFit, + known: taffy::Size>, + available: taffy::Size, + style: taffy::Style, + ) -> taffy::Size { + let layout_data = Rc::new(RefCell::new(ImageLayoutData::new(width, height))); + let mut measure = ImageLayoutData::create_taffy_layout_fn(layout_data, object_fit); + let mut tree = taffy::TaffyTree::<()>::new(); + let node_id = tree.new_leaf(style.clone()).unwrap(); + let mut measure_ctx = MeasureCx::default(); + + measure(known, available, node_id, &style, &mut measure_ctx) + } + + fn assert_close(actual: f32, expected: f32) { + let epsilon = 0.01f64; + assert!((actual as f64 - expected as f64).abs() < epsilon); + } + + fn assert_rect_close(actual: Rect, expected: Rect) { + let epsilon = 0.01; + assert!((actual.x0 - expected.x0).abs() < epsilon); + assert!((actual.y0 - expected.y0).abs() < epsilon); + assert!((actual.x1 - expected.x1).abs() < epsilon); + assert!((actual.y1 - expected.y1).abs() < epsilon); + } + + #[test] + fn img_object_fit_fill_uses_natural_size_without_explicit_dimensions() { + let result = run_measure( + 4, + 3, + ObjectFit::Fill, + taffy::Size { + width: None, + height: None, + }, + taffy::Size { + width: taffy::AvailableSpace::Definite(300.0), + height: taffy::AvailableSpace::Definite(200.0), + }, + ); + assert_close(result.width, 4.0); + assert_close(result.height, 3.0); + } + + #[test] + fn img_object_fit_contain_uses_natural_size_without_explicit_dimensions() { + let result = run_measure( + 4, + 3, + ObjectFit::Contain, + taffy::Size { + width: None, + height: None, + }, + taffy::Size { + width: taffy::AvailableSpace::Definite(100.0), + height: taffy::AvailableSpace::Definite(300.0), + }, + ); + assert_close(result.width, 4.0); + assert_close(result.height, 3.0); + } + + #[test] + fn img_object_fit_cover_uses_natural_size_without_explicit_dimensions() { + let result = run_measure( + 4, + 3, + ObjectFit::Cover, + taffy::Size { + width: None, + height: None, + }, + taffy::Size { + width: taffy::AvailableSpace::Definite(300.0), + height: taffy::AvailableSpace::Definite(300.0), + }, + ); + assert_close(result.width, 4.0); + assert_close(result.height, 3.0); + } + + #[test] + fn img_object_fit_scale_down_never_scales_above_natural() { + let result = run_measure( + 120, + 80, + ObjectFit::ScaleDown, + taffy::Size { + width: None, + height: None, + }, + taffy::Size { + width: taffy::AvailableSpace::Definite(2.0), + height: taffy::AvailableSpace::Definite(2.0), + }, + ); + assert_close(result.width, 120.0); + assert_close(result.height, 80.0); + } + + #[test] + fn img_explicit_width_keeps_aspect_ratio_without_height() { + let result = run_measure( + 4, + 3, + ObjectFit::Contain, + taffy::Size { + width: Some(100.0), + height: None, + }, + taffy::Size { + width: taffy::AvailableSpace::MaxContent, + height: taffy::AvailableSpace::MaxContent, + }, + ); + assert_close(result.width, 100.0); + assert_close(result.height, 75.0); + } + + #[test] + fn img_explicit_dimensions_override_object_fit() { + let result = run_measure( + 4, + 300, + ObjectFit::Fill, + taffy::Size { + width: Some(200.0), + height: Some(60.0), + }, + taffy::Size { + width: taffy::AvailableSpace::Definite(1000.0), + height: taffy::AvailableSpace::Definite(1000.0), + }, + ); + assert_close(result.width, 200.0); + assert_close(result.height, 60.0); + } + + #[test] + fn img_object_fit_contain_centers_square_image_vertically() { + let image = Image::new(ImageData { + data: Blob::new(Arc::new([])), + width: 640, + height: 640, + format: peniko::ImageFormat::Rgba8, + alpha_type: ImageAlphaType::AlphaPremultiplied, + }); + let dest_rect = image.object_fit_dest_rect_with( + Rect::new(0.0, 0.0, 150.0, 300.0), + ObjectFit::Contain, + ObjectPosition::Center, + ); + + assert_rect_close(dest_rect, Rect::new(0.0, 75.0, 150.0, 225.0)); + } + + #[test] + fn img_aspect_ratio_overrides_intrinsic_auto_height() { + let result = run_measure_with_style( + 600, + 600, + ObjectFit::Contain, + taffy::Size { + width: None, + height: None, + }, + taffy::Size { + width: taffy::AvailableSpace::MaxContent, + height: taffy::AvailableSpace::MaxContent, + }, + taffy::Style { + aspect_ratio: Some(1.5), + ..Default::default() + }, + ); + + assert_close(result.width, 600.0); + assert_close(result.height, 400.0); + } + + #[test] + fn img_aspect_ratio_overrides_intrinsic_from_explicit_width() { + let result = run_measure_with_style( + 600, + 600, + ObjectFit::Contain, + taffy::Size { + width: Some(300.0), + height: None, + }, + taffy::Size { + width: taffy::AvailableSpace::MaxContent, + height: taffy::AvailableSpace::MaxContent, + }, + taffy::Style { + aspect_ratio: Some(1.5), + ..Default::default() + }, + ); + + assert_close(result.width, 300.0); + assert_close(result.height, 200.0); + } +} diff --git a/src/views/img.rs b/src/views/img.rs deleted file mode 100644 index 8a5154f9e..000000000 --- a/src/views/img.rs +++ /dev/null @@ -1,530 +0,0 @@ -//! Module defining image view and its properties: style, position and fit. -#![deny(missing_docs)] -use std::{cell::RefCell, path::PathBuf, rc::Rc, sync::Arc}; - -use floem_reactive::UpdaterEffect; -use peniko::{Blob, ImageAlphaType, ImageData, kurbo::Rect}; -use sha2::{Digest, Sha256}; - -use crate::{ - Renderer, prop_extractor, - style::{FontSizeCx, ObjectFit, ObjectPosition}, - view::{LayoutNodeCx, MeasureFn, View, ViewId}, -}; - -/// Holds information about image dimensions for layout calculations. -#[derive(Clone)] -pub struct ImageLayoutData { - /// Natural width of the image in pixels - natural_width: u32, - /// Natural height of the image in pixels - natural_height: u32, -} - -impl ImageLayoutData { - /// Create new image layout data - pub fn new(width: u32, height: u32) -> Self { - Self { - natural_width: width, - natural_height: height, - } - } - - /// Get the natural aspect ratio of the image - pub fn aspect_ratio(&self) -> f32 { - if self.natural_height == 0 { - 1.0 - } else { - self.natural_width as f32 / self.natural_height as f32 - } - } - - /// Create a taffy layout function for image sizing following CSS rules - pub fn create_taffy_layout_fn( - layout_data: Rc>, - _object_fit: ObjectFit, - ) -> Box { - Box::new( - move |known_dimensions, _available_space, _node_id, _style, _measure_ctx| { - use taffy::*; - - let data = layout_data.borrow(); - let natural_width = data.natural_width as f32; - let natural_height = data.natural_height as f32; - let natural_aspect = data.aspect_ratio(); - if natural_width == 0.0 || natural_height == 0.0 { - return Size { - width: 0.0, - height: 0.0, - }; - } - - // If only width is set, compute height from aspect ratio - if let Some(w) = known_dimensions.width { - let h = known_dimensions.height.unwrap_or_else(|| { - // Use aspect ratio to compute height - w / natural_aspect - }); - return Size { - width: w, - height: h, - }; - } - - // If only height is set, compute width from aspect ratio - if let Some(h) = known_dimensions.height { - let w = h * natural_aspect; - return Size { - width: w, - height: h, - }; - } - - // No explicit dimensions: use intrinsic size to match CSS img default - // behavior when width/height are `auto`. - Size { - // Both dimensions provided by layout context still do not force a resized - // intrinsic image; object-fit is a paint-time behavior for sized boxes. - width: natural_width, - height: natural_height, - } - }, - ) - } -} - -prop_extractor! { - Extractor { - object_fit: crate::style::ObjectFitProp, - object_position: crate::style::ObjectPositionProp, - } -} - -/// Holds the data needed for [img] view fn to display images. -pub struct Img { - id: ViewId, - img: peniko::ImageBrush, - img_hash: Vec, - layout_data: Rc>, - style: Extractor, -} - -/// A view that can display an image and controls its position. -/// -/// It takes function that produce `Vec` and will convert it into [Image](peniko::Image). -/// -/// ### Example: -/// ```rust -/// # use crate::floem::views::Decorators; -/// # use floem::views::img; -/// let ferris_png = include_bytes!("../../examples/widget-gallery/assets/ferris.png"); -/// // Create an image from the function returning Vec: -/// img(move || ferris_png.to_vec()) -/// .style(|s| s.size(50.,50.)); -/// ``` -/// # Reactivity -/// The `img` function is not reactive, so to make it change on event, wrap it -/// with [`dyn_view`](crate::views::dyn_view::dyn_view). -/// -/// ### Example with reactive updates: -/// ```rust -/// # use floem::prelude::*; -/// # use crate::floem::views::Decorators; -/// # use floem::views::img; -/// # use floem::views::dyn_view; -/// # use floem::reactive::RwSignal; -/// -/// #[derive(Clone)] -/// enum Image { -/// ImageA, -/// ImageB -/// } -/// -/// let ferris = include_bytes!("../../examples/widget-gallery/assets/ferris.png"); -/// let sunflower = include_bytes!("../../examples/widget-gallery/assets/sunflower.jpg"); -/// let switch_image = RwSignal::new(Image::ImageA); -/// // Create an image from the function returning Vec: -/// dyn_view(move || { -/// let image = switch_image.get(); -/// img(move || { -/// match image { -/// Image::ImageA => ferris.to_vec(), -/// Image::ImageB => sunflower.to_vec() -/// } -/// }).style(|s| s.size(50.,50.)) -/// }); -/// ``` -pub fn img(image: impl Fn() -> Vec + 'static) -> Img { - let image = image::load_from_memory(&image()).ok(); - let width = image.as_ref().map_or(0, |img| img.width()); - let height = image.as_ref().map_or(0, |img| img.height()); - let data = Arc::new(image.map_or(Default::default(), |img| img.into_rgba8().into_vec())); - let blob = Blob::new(data); - let image = peniko::ImageBrush::new(ImageData { - data: blob, - format: peniko::ImageFormat::Rgba8, - alpha_type: ImageAlphaType::Alpha, - width, - height, - }) - .with_quality(peniko::ImageQuality::High); - img_dynamic(move || image.clone()) -} - -/// A view that can display an image and controls its position. -/// -/// It takes function that returns [`PathBuf`] and will convert it into [`Image`](peniko::Image). -/// -/// ### Example: -/// ```rust -/// # use std::path::PathBuf; -/// # use floem::views::Decorators; -/// # use floem::views::img_from_path; -/// -/// let path_to_ferris = PathBuf::from(r"../../examples/widget-gallery/assets/ferrig.png"); -/// // Create an image from the function returning PathBuf: -/// img_from_path(move || path_to_ferris.clone()) -/// .style(|s| s.size(50.,50.)); -/// ``` -/// # Reactivity -/// The `img` function is not reactive, so to make it change on event, wrap it -/// with [`dyn_view`](crate::views::dyn_view::dyn_view). -pub fn img_from_path(image: impl Fn() -> PathBuf + 'static) -> Img { - let image = image::open(image()).ok(); - let width = image.as_ref().map_or(0, |img| img.width()); - let height = image.as_ref().map_or(0, |img| img.height()); - let data = Arc::new(image.map_or(Default::default(), |img| img.into_rgba8().into_vec())); - let blob = Blob::new(data); - let image = peniko::ImageBrush::new(ImageData { - data: blob, - format: peniko::ImageFormat::Rgba8, - alpha_type: ImageAlphaType::Alpha, - width, - height, - }); - img_dynamic(move || image.clone()) -} - -pub(crate) fn img_dynamic(image: impl Fn() -> peniko::ImageBrush + 'static) -> Img { - let id = ViewId::new(); - - let img = UpdaterEffect::new(image, move |image| { - id.update_state(image); - }); - - let layout_data = Rc::new(RefCell::new(ImageLayoutData::new( - img.image.width, - img.image.height, - ))); - - let mut hasher = Sha256::new(); - hasher.update(img.image.data.data()); - let img_hash = hasher.finalize().to_vec(); - - let mut img = Img { - id, - img, - img_hash, - layout_data, - style: Extractor::default(), - }; - - img.set_taffy_layout(); - img -} - -impl Img { - fn set_taffy_layout(&mut self) { - let taffy = self.id.taffy(); - let taffy_node = self.id.taffy_node(); - let mut taffy = taffy.borrow_mut(); - - let object_fit = self.style.object_fit(); - let layout_fn = - ImageLayoutData::create_taffy_layout_fn(self.layout_data.clone(), object_fit); - - let _ = taffy.set_node_context( - taffy_node, - Some(LayoutNodeCx::Custom { - measure: layout_fn, - finalize: None, - }), - ); - } - - /// Compute the destination rect for the image within the content box, - /// following CSS object-fit rules. The returned rect is in local coords - /// and may be larger than `content_rect` (for Cover) or smaller (for Contain). - pub fn object_fit_dest_rect(&self, content_rect: Rect) -> Rect { - self.object_fit_dest_rect_with( - content_rect, - self.style.object_fit(), - self.style.object_position(), - ) - } - - /// This method is mostly here for tests - pub fn object_fit_dest_rect_with( - &self, - content_rect: Rect, - object_fit: ObjectFit, - object_position: ObjectPosition, - ) -> Rect { - let natural_w = self.img.image.width as f64; - let natural_h = self.img.image.height as f64; - if natural_w == 0.0 || natural_h == 0.0 { - return content_rect; - } - let box_w = content_rect.width(); - let box_h = content_rect.height(); - if box_w == 0.0 || box_h == 0.0 { - return content_rect; - } - let (rendered_w, rendered_h) = match object_fit { - ObjectFit::Fill => (box_w, box_h), - ObjectFit::None => (natural_w, natural_h), - ObjectFit::Contain => { - let scale = (box_w / natural_w).min(box_h / natural_h); - (natural_w * scale, natural_h * scale) - } - ObjectFit::Cover => { - let scale = (box_w / natural_w).max(box_h / natural_h); - (natural_w * scale, natural_h * scale) - } - ObjectFit::ScaleDown => { - let contain_scale = (box_w / natural_w).min(box_h / natural_h); - let scale = contain_scale.min(1.0); - (natural_w * scale, natural_h * scale) - } - }; - let (x_align, y_align) = match object_position { - ObjectPosition::TopLeft => (0.0, 0.0), - ObjectPosition::Top => (0.5, 0.0), - ObjectPosition::TopRight => (1.0, 0.0), - ObjectPosition::Left => (0.0, 0.5), - ObjectPosition::Center => (0.5, 0.5), - ObjectPosition::Right => (1.0, 0.5), - ObjectPosition::BottomLeft => (0.0, 1.0), - ObjectPosition::Bottom => (0.5, 1.0), - ObjectPosition::BottomRight => (1.0, 1.0), - ObjectPosition::Custom(x, y) => { - let font_size_cx = FontSizeCx::new(16.0, 16.0); - let free_x = box_w - rendered_w; - let free_y = box_h - rendered_h; - let x0 = content_rect.x0 + x.resolve(free_x, &font_size_cx); - let y0 = content_rect.y0 + y.resolve(free_y, &font_size_cx); - return Rect::new(x0, y0, x0 + rendered_w, y0 + rendered_h); - } - }; - let x0 = content_rect.x0 + (box_w - rendered_w) * x_align; - let y0 = content_rect.y0 + (box_h - rendered_h) * y_align; - Rect::new(x0, y0, x0 + rendered_w, y0 + rendered_h) - } - - fn needs_clip(&self) -> bool { - matches!(self.style.object_fit(), ObjectFit::Cover | ObjectFit::None) - } -} - -impl View for Img { - fn id(&self) -> ViewId { - self.id - } - - fn debug_name(&self) -> std::borrow::Cow<'static, str> { - "Img".into() - } - - fn update(&mut self, _cx: &mut crate::context::UpdateCx, state: Box) { - if let Ok(img) = state.downcast::() { - let mut hasher = Sha256::new(); - hasher.update(img.image.data.data()); - self.img_hash = hasher.finalize().to_vec(); - - // Update layout data with new image dimensions - let width = img.image.width; - let height = img.image.height; - self.layout_data.borrow_mut().natural_width = width; - self.layout_data.borrow_mut().natural_height = height; - - self.img = *img; - self.id.request_mark_view_layout_dirty(); - self.id.request_layout(); - } - } - - fn style_pass(&mut self, cx: &mut crate::context::StyleCx<'_>) { - if self.style.read(cx) { - // object_fit changed, update taffy layout - self.set_taffy_layout(); - self.id.request_layout(); - } - } - - fn paint(&mut self, cx: &mut crate::context::PaintCx) { - let content_rect = self.id.get_content_rect_local(); - let dest_rect = self.object_fit_dest_rect(content_rect); - - if self.needs_clip() { - cx.clip(&content_rect); - } - - cx.draw_img( - floem_renderer::Img { - img: self.img.clone(), - hash: &self.img_hash, - }, - dest_rect, - ); - if self.needs_clip() { - cx.clear_clip(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::view::MeasureCx; - - fn run_measure( - width: u32, - height: u32, - object_fit: ObjectFit, - known: taffy::Size>, - available: taffy::Size, - ) -> taffy::Size { - let layout_data = Rc::new(RefCell::new(ImageLayoutData::new(width, height))); - let mut measure = ImageLayoutData::create_taffy_layout_fn(layout_data, object_fit); - let mut tree = taffy::TaffyTree::<()>::new(); - let node_id = tree.new_leaf(taffy::Style::default()).unwrap(); - let mut measure_ctx = MeasureCx::default(); - - measure( - known, - available, - node_id, - &taffy::Style::default(), - &mut measure_ctx, - ) - } - - fn assert_close(actual: f32, expected: f32) { - let epsilon = 0.01f64; - assert!((actual as f64 - expected as f64).abs() < epsilon); - } - - #[test] - fn img_object_fit_fill_uses_natural_size_without_explicit_dimensions() { - let result = run_measure( - 4, - 3, - ObjectFit::Fill, - taffy::Size { - width: None, - height: None, - }, - taffy::Size { - width: taffy::AvailableSpace::Definite(300.0), - height: taffy::AvailableSpace::Definite(200.0), - }, - ); - assert_close(result.width, 4.0); - assert_close(result.height, 3.0); - } - - #[test] - fn img_object_fit_contain_uses_natural_size_without_explicit_dimensions() { - let result = run_measure( - 4, - 3, - ObjectFit::Contain, - taffy::Size { - width: None, - height: None, - }, - taffy::Size { - width: taffy::AvailableSpace::Definite(100.0), - height: taffy::AvailableSpace::Definite(300.0), - }, - ); - assert_close(result.width, 4.0); - assert_close(result.height, 3.0); - } - - #[test] - fn img_object_fit_cover_uses_natural_size_without_explicit_dimensions() { - let result = run_measure( - 4, - 3, - ObjectFit::Cover, - taffy::Size { - width: None, - height: None, - }, - taffy::Size { - width: taffy::AvailableSpace::Definite(300.0), - height: taffy::AvailableSpace::Definite(300.0), - }, - ); - assert_close(result.width, 4.0); - assert_close(result.height, 3.0); - } - - #[test] - fn img_object_fit_scale_down_never_scales_above_natural() { - let result = run_measure( - 120, - 80, - ObjectFit::ScaleDown, - taffy::Size { - width: None, - height: None, - }, - taffy::Size { - width: taffy::AvailableSpace::Definite(2.0), - height: taffy::AvailableSpace::Definite(2.0), - }, - ); - assert_close(result.width, 120.0); - assert_close(result.height, 80.0); - } - - #[test] - fn img_explicit_width_keeps_aspect_ratio_without_height() { - let result = run_measure( - 4, - 3, - ObjectFit::Contain, - taffy::Size { - width: Some(100.0), - height: None, - }, - taffy::Size { - width: taffy::AvailableSpace::MaxContent, - height: taffy::AvailableSpace::MaxContent, - }, - ); - assert_close(result.width, 100.0); - assert_close(result.height, 75.0); - } - - #[test] - fn img_explicit_dimensions_override_object_fit() { - let result = run_measure( - 4, - 300, - ObjectFit::Fill, - taffy::Size { - width: Some(200.0), - height: Some(60.0), - }, - taffy::Size { - width: taffy::AvailableSpace::Definite(1000.0), - height: taffy::AvailableSpace::Definite(1000.0), - }, - ); - assert_close(result.width, 200.0); - assert_close(result.height, 60.0); - } -} diff --git a/src/views/label.rs b/src/views/label.rs index daea0e671..e64d29f9e 100644 --- a/src/views/label.rs +++ b/src/views/label.rs @@ -1,5 +1,6 @@ use std::{any::Any, cell::RefCell, fmt::Display, rc::Rc}; +use crate::effects::Brush; use crate::{ Clipboard, ViewId, context::{EventCx, LayoutChangedListener, PaintCx, UpdateCx}, @@ -8,8 +9,8 @@ use crate::{ prop_extractor, style::{ ContextValue, CustomStylable, CustomStyle, ExprStyle, FontProps, LineHeight, Selectable, - SelectionCornerRadius, SelectionStyle, Style, TextAlignProp, TextColor, TextOverflow, - TextOverflowProp, + SelectionCornerRadius, SelectionStyle, Style, TextAlignProp, TextBrushTransform, TextColor, + TextOverflow, TextOverflowProp, }, style_class, text::{ @@ -20,9 +21,7 @@ use crate::{ views::editor::SelectionColor, }; use floem_reactive::UpdaterEffect; -use floem_renderer::Renderer; use peniko::{ - Brush, color::palette::{self}, kurbo::Point, }; @@ -38,6 +37,7 @@ prop_extractor! { color: TextColor, text_overflow: TextOverflowProp, line_height: LineHeight, + text_brush_transform: TextBrushTransform, text_selectable: Selectable, text_align: TextAlignProp, } @@ -219,7 +219,11 @@ impl Label { } fn get_attrs_list(&self) -> AttrsList { - let mut attrs = Attrs::new().color(self.label_props.color().unwrap_or(palette::css::BLACK)); + let mut attrs = Attrs::new().brush( + self.label_props + .color() + .unwrap_or(palette::css::BLACK.into()), + ); let font_size = self.font_props.size(); attrs = attrs.font_size(font_size as f32); @@ -344,11 +348,15 @@ impl Label { fn paint_selection(&self, text_loc: Point, paint_cx: &mut PaintCx) { if let Some(selection) = self.selection() { let selection_color = self.selection_style.selection_color(); - self.layout_data - .borrow() - .selection_rects_for_selection(&selection, text_loc, |rect| { - paint_cx.fill(&rect, &selection_color, 0.0); - }); + let effective_scale = paint_cx.effective_scale; + self.layout_data.borrow().selection_rects_for_selection( + &selection, + text_loc, + effective_scale, + |rect| { + paint_cx.painter.fill(rect, &selection_color).draw(); + }, + ); } } @@ -496,11 +504,13 @@ impl View for Label { fn update(&mut self, cx: &mut UpdateCx, state: Box) { if state.is::() && let Ok(state) = state.downcast::() + && *state != self.label { self.label = *state; self.layout_data.borrow_mut().clear_overflow_state(); self.set_text_layout(); - cx.window_state.schedule_layout(); + self.id.request_layout(); + cx.window_state.request_paint(self.id); } } @@ -514,9 +524,10 @@ impl View for Label { }; let text_loc = self.get_text_origin(this_node, text_node); + let text_brush_transform = Some(self.label_props.text_brush_transform()); self.with_effective_text_layout(|l| { - l.draw(cx, text_loc); + l.draw_with_text_brush_transform(cx, text_loc, text_brush_transform); if cx.window_state.is_focused(self.id) { self.paint_selection(text_loc, cx); } diff --git a/src/views/localization.rs b/src/views/localization.rs index dcbecee75..344e86889 100644 --- a/src/views/localization.rs +++ b/src/views/localization.rs @@ -67,7 +67,7 @@ impl StylePropValue for LocaleMap { let view = Stack::new(( format!("Languages ({count})").style(|s| { s.font_size(12.0) - .font_weight(floem_renderer::text::FontWeight::SEMI_BOLD) + .font_weight(crate::text::FontWeight::SEMI_BOLD) }), Stack::vertical_from_iter(languages.into_iter().map(|lang| { lang.style(|s| { diff --git a/src/views/mod.rs b/src/views/mod.rs index b62451853..eb570378e 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -142,8 +142,8 @@ pub use drag_window_area::*; mod drag_resize_window_area; pub use drag_resize_window_area::*; -mod img; -pub use img::*; +mod image; +pub use image::*; mod button; pub use button::*; diff --git a/src/views/resizable.rs b/src/views/resizable.rs index 53b47e543..e56e59d74 100644 --- a/src/views/resizable.rs +++ b/src/views/resizable.rs @@ -4,6 +4,7 @@ use crate::{ BoxTree, ElementId, ViewId, context::{EventCx, PaintCx, UpdateCx}, easing::Linear, + effects::Brush, event::{ DragEvent, DragSourceEvent, Event, EventPropagation, InteractionEvent, Phase, listener::UpdatePhaseLayout, @@ -20,7 +21,6 @@ use crate::{ }; use floem_reactive::Effect; use peniko::{ - Brush, color::palette::css, kurbo::{Axis, Rect}, }; @@ -211,9 +211,7 @@ impl Handle { } }; - self.box_tree - .borrow_mut() - .set_local_bounds(self.element_id.0, new_rect); + self.element_id.set_local_bounds(new_rect); self.box_tree .borrow_mut() .set_flags(self.element_id.0, NodeFlags::VISIBLE | NodeFlags::PICKABLE); @@ -362,7 +360,9 @@ impl Handle { } }; - cx.fill(&paint_rect, &self.handle_style.color(), 0.); + cx.painter + .fill(paint_rect, &self.handle_style.color()) + .draw(); } } diff --git a/src/views/rich_text.rs b/src/views/rich_text.rs index 42b41a17f..8360468c8 100644 --- a/src/views/rich_text.rs +++ b/src/views/rich_text.rs @@ -1,7 +1,11 @@ use std::{any::Any, cell::RefCell, rc::Rc}; +use crate::{ + prop_extractor, + style::TextBrushTransform, + text::{Attrs, AttrsList, AttrsOwned}, +}; use floem_reactive::Effect; -use floem_renderer::text::{Attrs, AttrsList, AttrsOwned}; use peniko::{Color, color::palette}; use smallvec::{SmallVec, smallvec}; use taffy::tree::NodeId; @@ -14,10 +18,17 @@ pub struct RichText { id: ViewId, /// Layout data containing text layouts and overflow handling logic layout_data: Rc>, + style: RichTextStyle, text_node: Option, layout_node: Option, } +prop_extractor! { + RichTextStyle { + text_brush_transform: TextBrushTransform, + } +} + pub fn rich_text( text: String, attrs_list: AttrsList, @@ -44,6 +55,7 @@ pub fn rich_text( let mut rich_text = RichText { id, layout_data, + style: RichTextStyle::default(), text_node: None, layout_node: None, }; @@ -127,6 +139,12 @@ impl View for RichText { } } + fn style_pass(&mut self, cx: &mut crate::context::StyleCx<'_>) { + if self.style.read(cx) { + self.id.request_paint(); + } + } + fn paint(&mut self, cx: &mut crate::context::PaintCx) { let (Some(parent_node), Some(text_node)) = (self.layout_node, self.text_node) else { return; @@ -141,7 +159,8 @@ impl View for RichText { self.layout_data .borrow() .with_effective_text_layout(|layout| { - layout.draw(cx, text_loc); + let text_brush_transform = Some(self.style.text_brush_transform()); + layout.draw_with_text_brush_transform(cx, text_loc, text_brush_transform); }); } } @@ -165,30 +184,27 @@ impl<'a> RichSpan<'a> { self } - pub fn family(mut self, family: &'a [floem_renderer::text::FamilyOwned]) -> RichSpan<'a> { + pub fn family(mut self, family: &'a [crate::text::FamilyOwned]) -> RichSpan<'a> { self.attrs = self.attrs.family(family); self } - pub fn font_width(mut self, stretch: floem_renderer::text::FontWidth) -> RichSpan<'a> { + pub fn font_width(mut self, stretch: crate::text::FontWidth) -> RichSpan<'a> { self.attrs = self.attrs.font_width(stretch); self } - pub fn text_style(mut self, style: floem_renderer::text::FontStyle) -> RichSpan<'a> { + pub fn text_style(mut self, style: crate::text::FontStyle) -> RichSpan<'a> { self.attrs = self.attrs.font_style(style); self } - pub fn weight(mut self, weight: floem_renderer::text::FontWeight) -> RichSpan<'a> { + pub fn weight(mut self, weight: crate::text::FontWeight) -> RichSpan<'a> { self.attrs = self.attrs.weight(weight); self } - pub fn line_height( - mut self, - line_height: floem_renderer::text::LineHeightValue, - ) -> RichSpan<'a> { + pub fn line_height(mut self, line_height: crate::text::LineHeightValue) -> RichSpan<'a> { self.attrs = self.attrs.line_height(line_height); self } diff --git a/src/views/scroll.rs b/src/views/scroll.rs index 3552c8e11..d916ac0b2 100644 --- a/src/views/scroll.rs +++ b/src/views/scroll.rs @@ -1,9 +1,10 @@ #![deny(missing_docs)] //! Scroll View +use crate::effects::Brush; use floem_reactive::Effect; +use peniko::Color; use peniko::kurbo::{Affine, Axis, Point, Rect, RoundedRect, RoundedRectRadii, Stroke, Vec2}; -use peniko::{Brush, Color}; use std::time::Duration; use std::{cell::RefCell, rc::Rc}; use taffy::Overflow; @@ -17,7 +18,7 @@ use crate::prelude::EventListenerTrait; use crate::prelude::el::UpdatePhaseLayout; use crate::style::ScrollbarWidth; use crate::{ - BoxTree, ElementId, Renderer, + BoxTree, ElementId, context::{EventCx, PaintCx, StyleCx}, event::{Event, EventPropagation, Phase}, prop, prop_extractor, @@ -107,7 +108,7 @@ impl ScrollHandle { let resolved = cx.resolve_nested_maps(Style::new(), &[Handle::class_ref()], self.element_id); if self.style.read_style_for(cx, &resolved, self.element_id) { - self.element_id.owning_id().request_paint(); + cx.window_state.request_paint(self.element_id); } } @@ -241,9 +242,7 @@ impl ScrollHandle { } }; - self.box_tree - .borrow_mut() - .set_local_bounds(self.element_id.0, rect); + self.element_id.set_local_bounds(rect); self.box_tree .borrow_mut() .set_flags(self.element_id.0, NodeFlags::VISIBLE | NodeFlags::PICKABLE); @@ -289,16 +288,19 @@ impl ScrollHandle { let rect_with_border = rect.inset(-edge_width / 2.0); let rounded_rect = rect_with_border.to_rounded_rect(radius); - cx.fill( - &rounded_rect, - &self.style.background().unwrap_or(HANDLE_COLOR), - 0.0, - ); + cx.painter + .fill( + rounded_rect, + &self.style.background().unwrap_or(HANDLE_COLOR), + ) + .draw(); if edge_width > 0.0 && let Some(color) = self.style.border_color().right { - cx.stroke(&rounded_rect, &color, &Stroke::new(edge_width)); + cx.painter + .stroke(rounded_rect, &Stroke::new(edge_width), &color) + .draw(); } } } @@ -329,7 +331,7 @@ impl ScrollTrack { fn style(&mut self, cx: &mut StyleCx) { let resolved = cx.resolve_nested_maps(Style::new(), &[Track::class_ref()], self.element_id); if self.style.read_style_for(cx, &resolved, self.element_id) { - self.element_id.owning_id().request_paint(); + cx.window_state.request_paint(self.element_id); } } @@ -428,9 +430,7 @@ impl ScrollTrack { } }; - self.box_tree - .borrow_mut() - .set_local_bounds(self.element_id.0, rect); + self.element_id.set_local_bounds(rect); self.box_tree .borrow_mut() .set_flags(self.element_id.0, NodeFlags::VISIBLE | NodeFlags::PICKABLE); @@ -441,7 +441,7 @@ impl ScrollTrack { let rect = box_tree.local_bounds(self.element_id.0).unwrap_or_default(); if let Some(color) = self.style.background() { - cx.fill(&rect, &color, 0.0); + cx.painter.fill(rect, &color).draw(); } } } @@ -585,6 +585,10 @@ impl Scroll { let child = child.into_any(); let child_id = child.id(); id.add_child(child); + id.set_retained_transform_boundary_for_element( + child_id.get_element_id(), + Some(crate::paint::display_list::TransformClass::TranslateOnly), + ); // we need to first set the clip rect to zero so that virtual items don't set a large initial size id.set_box_tree_clip(Some(RoundedRect::from_rect(Rect::ZERO, 0.))); @@ -687,6 +691,43 @@ impl Scroll { /// internal methods impl Scroll { + fn clamp_scroll_offset_to_content(&mut self) -> bool { + let viewport_size = self.id.get_content_rect_local().size(); + let content_size = self.child.get_layout_rect_local().size(); + + let mut max_scroll = + (content_size.to_vec2() - viewport_size.to_vec2()).max_by_component(Vec2::ZERO); + + if !matches!(self.scroll_style.overflow_x(), taffy::Overflow::Scroll) { + max_scroll.x = 0.0; + } + if !matches!(self.scroll_style.overflow_y(), taffy::Overflow::Scroll) { + max_scroll.y = 0.0; + } + + let old_scroll_offset = self.scroll_offset; + self.scroll_offset = self + .scroll_offset + .max_by_component(Vec2::ZERO) + .min_by_component(max_scroll); + let changed = old_scroll_offset != self.scroll_offset; + let translation_changed = self.id.set_child_translation(self.scroll_offset); + + if translation_changed { + self.id.route_event( + Event::new_custom(ScrollChanged { + offset: self.scroll_offset, + }), + RouteKind::Directed { + target: self.id.get_element_id(), + phases: crate::context::Phases::TARGET, + }, + ); + } + + changed + } + /// this applies a delta, set the viewport in the window state and returns the delta that was actually applied /// /// If the delta is positive, the view will scroll down, negative will scroll up. @@ -959,6 +1000,7 @@ impl View for Scroll { fn event(&mut self, cx: &mut EventCx) -> EventPropagation { // in order to use this we had to set `id.has_layout_listener`. if UpdatePhaseLayout::extract(&cx.event).is_some() { + self.clamp_scroll_offset_to_content(); self.set_positions(); return EventPropagation::Stop; } @@ -974,9 +1016,7 @@ impl View for Scroll { && self .apply_scroll_delta(new_offset - self.scroll_offset) .is_some() - { - cx.window_state.request_paint(self.id); - } + {} return result.propagation; } if cx.target == self.h_handle.element_id { @@ -985,9 +1025,7 @@ impl View for Scroll { && self .apply_scroll_delta(new_offset - self.scroll_offset) .is_some() - { - cx.window_state.request_paint(self.id); - } + {} return result.propagation; } if cx.target == self.v_track.element_id { @@ -996,9 +1034,7 @@ impl View for Scroll { && self .apply_scroll_delta(new_offset - self.scroll_offset) .is_some() - { - cx.window_state.request_paint(self.id); - } + {} return result.propagation; } if cx.target == self.h_track.element_id { @@ -1007,9 +1043,7 @@ impl View for Scroll { && self .apply_scroll_delta(new_offset - self.scroll_offset) .is_some() - { - cx.window_state.request_paint(self.id); - } + {} return result.propagation; } } @@ -1029,10 +1063,6 @@ impl View for Scroll { let change = self.apply_scroll_delta(delta); - if change.is_some() { - cx.window_state.request_paint(self.id); - } - return if self.scroll_style.propagate_pointer_wheel() && change.is_none() { EventPropagation::Continue } else { @@ -1047,7 +1077,6 @@ impl View for Scroll { // this apply scroll delta of zero is cheap. // it is here in the case that the available delta changed, this will catch it and update it to a better size self.apply_scroll_delta(Vec2::ZERO); - // Check which visual node we're painting // Scroll view creates multiple visual IDs for scrollbars/tracks if cx.target_id == self.id.get_element_id() { diff --git a/src/views/slider.rs b/src/views/slider.rs index beaac358f..fe34dcb53 100644 --- a/src/views/slider.rs +++ b/src/views/slider.rs @@ -2,10 +2,10 @@ use std::ops::RangeInclusive; +use crate::effects::Brush; use floem_reactive::{SignalGet, SignalUpdate, UpdaterEffect}; -use peniko::Brush; use peniko::color::palette; -use peniko::kurbo::{Circle, Point, RoundedRect, RoundedRectRadii}; +use peniko::kurbo::{Circle, Point, RoundedRect, RoundedRectRadii, Shape}; use ui_events::keyboard::{Key, KeyState, KeyboardEvent, NamedKey}; use ui_events::pointer::{PointerButtonEvent, PointerEvent}; @@ -13,7 +13,6 @@ use crate::custom_event; use crate::event::CustomEvent; use crate::unit::FontSizeCx; use crate::{ - Renderer, context::{LayoutChanged, LayoutChangedListener}, event::{Event, EventPropagation, FocusEvent}, prelude::*, @@ -371,28 +370,22 @@ impl View for Slider { } fn paint(&mut self, cx: &mut crate::context::PaintCx) { - cx.fill( - &self.base_bar, - &self - .base_bar_style - .color() - .unwrap_or(palette::css::BLACK.into()), - 0., - ); - // Apply temporary clip for accent bar - cx.clip(&self.base_bar); - cx.fill( - &self.accent_bar, - &self - .accent_bar_style - .color() - .unwrap_or(palette::css::TRANSPARENT.into()), - 0., - ); - cx.clear_clip(); + let base_bar = self + .base_bar_style + .color() + .unwrap_or(palette::css::BLACK.into()); + cx.painter.fill(self.base_bar, &base_bar).draw(); + + let accent_bar = self + .accent_bar_style + .color() + .unwrap_or(palette::css::TRANSPARENT.into()); + cx.painter.with_fill_clip(self.base_bar, |p| { + p.fill(self.accent_bar, &accent_bar).draw(); + }); if let Some(color) = self.style.foreground() { - cx.fill(&self.handle, &color, 0.); + cx.painter.fill(self.handle.to_path(0.1), &color).draw(); } } } diff --git a/src/views/svg.rs b/src/views/svg.rs index 4c28d5d5c..d6f093629 100644 --- a/src/views/svg.rs +++ b/src/views/svg.rs @@ -1,17 +1,15 @@ -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; use floem_reactive::Effect; -use floem_renderer::{ - Renderer, - usvg::{self, Tree}, -}; +use imaging::{MaskMode, Painter, SceneImage, SceneImageWeak, record::Scene}; use peniko::{ - Brush, GradientKind, LinearGradientPosition, - kurbo::{Point, Size}, + GradientKind, LinearGradientPosition, + kurbo::{Affine, Point, Rect, Size}, }; -use sha2::{Digest, Sha256}; +use svg_imaging::{ParseOptions, RenderOptions, SvgDocument}; use crate::{ + effects::{Brush, ImageBrush}, prop, prop_extractor, style::{Style, TextColor}, style_class, @@ -21,11 +19,18 @@ use crate::{ use super::Decorators; +thread_local! { + static SVG_SCENE_IMAGE_CACHE: RefCell> = + RefCell::new(HashMap::new()); +} + prop!(pub SvgColor: Option {} = None); +prop!(pub SvgCache: bool {} = true); prop_extractor! { SvgStyle { svg_color: SvgColor, + svg_cache: SvgCache, text_color: TextColor, } } @@ -137,8 +142,7 @@ impl SvgLayoutData { pub struct Svg { id: ViewId, - svg_tree: Option, - svg_hash: Option>, + svg_document: Option, svg_style: SvgStyle, svg_string: String, svg_css: Option, @@ -219,8 +223,7 @@ pub fn svg(svg_str_fn: impl Into + 'static) -> Svg { let layout_data = Rc::new(RefCell::new(SvgLayoutData::new())); let mut svg = Svg { id, - svg_tree: None, - svg_hash: None, + svg_document: None, svg_style: Default::default(), svg_string: Default::default(), css_prop: None, @@ -262,13 +265,22 @@ impl View for Svg { fn style_pass(&mut self, cx: &mut crate::context::StyleCx<'_>) { let style = cx.style(); + let previous_cache_enabled = self.svg_style.svg_cache(); + let previous_brush = self.svg_style.color_brush(); self.svg_style.read_style(cx, &style); - if let Some(tree) = &self.svg_tree { - let size = tree.size(); - let aspect_ratio = size.width() / size.height(); + let current_cache_enabled = self.svg_style.svg_cache(); + let current_brush = self.svg_style.color_brush(); + if previous_cache_enabled != current_cache_enabled { + self.id.request_paint(); + } + if previous_brush != current_brush { + self.id.request_paint(); + } + if let Some(document) = &self.svg_document { + let size = document.size(); + let aspect_ratio = (size.width / size.height) as f32; if self.aspect_ratio != aspect_ratio { self.aspect_ratio = aspect_ratio; - // self.id.request_style(); } } if let Some(prop_reader) = &mut self.css_prop @@ -293,9 +305,9 @@ impl View for Svg { self.svg_string = text.clone(); self.svg_css = style.clone(); - let svg_tree = Tree::from_str( + let svg_document = SvgDocument::from_str( text.as_str(), - &usvg::Options { + &ParseOptions { style_sheet: style, ..Default::default() }, @@ -303,29 +315,24 @@ impl View for Svg { .ok(); { let mut layout_data = self.layout_data.borrow_mut(); - if let Some(tree) = svg_tree.as_ref() { - let size = tree.size(); - layout_data.set_size(size.width(), size.height()); + if let Some(document) = svg_document.as_ref() { + let size = document.size(); + layout_data.set_size(size.width as f32, size.height as f32); } else { layout_data.set_size(0.0, 0.0); } } - self.aspect_ratio = svg_tree.as_ref().map_or(f32::NAN, |tree| { - let size = tree.size(); - let width = size.width(); - let height = size.height(); + self.aspect_ratio = svg_document.as_ref().map_or(f32::NAN, |document| { + let size = document.size(); + let width = size.width as f32; + let height = size.height as f32; if height == 0.0 { f32::NAN } else { width / height } }); - self.svg_tree = svg_tree; - - let mut hasher = Sha256::new(); - hasher.update(text); - let hash = hasher.finalize().to_vec(); - self.svg_hash = Some(hash); + self.svg_document = svg_document; self.id.request_layout(); self.id.request_paint(); @@ -333,20 +340,86 @@ impl View for Svg { } fn paint(&mut self, cx: &mut crate::context::PaintCx) { - if let Some(tree) = self.svg_tree.as_ref() { - let hash = self.svg_hash.as_ref().unwrap(); + if let Some(document) = self.svg_document.clone() { + let size = document.size(); + if size.width <= 0.0 || size.height <= 0.0 { + return; + } + let layout = self.id.get_layout().unwrap_or_default(); let rect = Size::new(layout.size.width as f64, layout.size.height as f64).to_rect(); - let color = if let Some(brush) = self.svg_style.svg_color() { - Some(brush) + let pixel_width = (rect.width() * cx.window_state.os_scale).round().max(1.0) as u32; + let pixel_height = (rect.height() * cx.window_state.os_scale).round().max(1.0) as u32; + let brush = self.svg_style.color_brush(); + let can_cache = self.svg_style.svg_cache() + && brush + .as_ref() + .is_none_or(svg_brush_can_render_to_imaging_scene); + if can_cache { + let cache_key = svg_scene_image_cache_key( + &self.svg_string, + self.svg_css.as_deref(), + brush.as_ref(), + pixel_width, + pixel_height, + ); + let image = cached_svg_scene_image( + &cache_key, + brush.as_ref(), + &document, + pixel_width, + pixel_height, + ); + let source_rect = Rect::new( + 0.0, + 0.0, + f64::from(image.width()), + f64::from(image.height()), + ); + let transform = Affine::translate((rect.x0, rect.y0)) + * Affine::scale_non_uniform( + rect.width() / f64::from(image.width()), + rect.height() / f64::from(image.height()), + ); + let image_brush = ImageBrush::new(image); + cx.painter + .fill(source_rect, &Brush::Image(image_brush)) + .transform(transform) + .draw(); } else { - self.svg_style.text_color().map(Brush::Solid) - }; - cx.draw_svg(crate::RendererSvg { tree, hash }, rect, color.as_ref()); + let transform = Affine::translate((rect.x0, rect.y0)) + * Affine::scale_non_uniform( + rect.width() / size.width, + rect.height() / size.height, + ); + if let Some(brush) = brush.as_ref() { + let bounds = document.size().to_rect(); + cx.painter.with_masked_group( + MaskMode::Alpha, + |mask| { + let _ = document.render(mask, &RenderOptions { transform }); + }, + |painter| { + painter.fill(bounds, brush).transform(transform).draw(); + }, + ); + } else { + let _ = document.render( + &mut cx.painter.as_imaging_painter(), + &RenderOptions { transform }, + ); + } + } } } } +impl SvgStyle { + fn color_brush(&self) -> Option { + self.svg_color().or_else(|| self.text_color()) + } +} + pub fn brush_to_css_string(brush: &Brush) -> String { match brush { Brush::Solid(color) => { @@ -381,7 +454,10 @@ pub fn brush_to_css_string(brush: &Brush) -> String { format!("#{r:02x}{g:02x}{b:02x}") }; - css.push_str(&format!("{} {}%", color_str, (stop.offset * 100.0).round())); + let crate::unit::Length::Pct(offset) = stop.offset else { + return "currentColor".to_string(); + }; + css.push_str(&format!("{} {}%", color_str, offset.round())); if i < gradient.stops.len() - 1 { css.push_str(", "); @@ -412,3 +488,108 @@ fn calculate_angle(start: &Point, end: &Point) -> f64 { angle_deg } + +fn svg_scene_image_cache_key( + svg: &str, + css: Option<&str>, + brush: Option<&Brush>, + pixel_width: u32, + pixel_height: u32, +) -> String { + let brush_key = brush.map_or_else(|| "none".to_string(), |brush| format!("{brush:?}")); + let css = css.unwrap_or_default(); + format!( + "{svg}\n\n\n" + ) +} + +fn cached_svg_scene_image( + key: &str, + brush: Option<&Brush>, + document: &SvgDocument, + pixel_width: u32, + pixel_height: u32, +) -> SceneImage { + SVG_SCENE_IMAGE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if let Some(weak) = cache.get(key).cloned() { + if let Some(image) = weak.upgrade() { + return image; + } + cache.remove(key); + } + + let image = build_svg_scene_image(brush, document, pixel_width, pixel_height); + cache.insert(key.to_string(), image.downgrade()); + image + }) +} + +fn build_svg_scene_image( + brush: Option<&Brush>, + document: &SvgDocument, + pixel_width: u32, + pixel_height: u32, +) -> SceneImage { + let document_size = document.size(); + let scale = Affine::scale_non_uniform( + f64::from(pixel_width) / document_size.width, + f64::from(pixel_height) / document_size.height, + ); + let mut scene = Scene::new(); + let mut painter = Painter::new(&mut scene); + if let Some(brush) = brush { + let bounds = Rect::new(0.0, 0.0, f64::from(pixel_width), f64::from(pixel_height)); + let brush = imaging_brush_from_svg_brush(brush, bounds) + .expect("cached SVG scene rendering requires a renderer-backed SVG brush"); + painter.with_masked_group( + MaskMode::Alpha, + |mask| { + let _ = document.render(mask, &RenderOptions { transform: scale }); + }, + |painter| { + painter.fill(bounds, &brush).draw(); + }, + ); + } else { + let _ = document.render(&mut painter, &RenderOptions { transform: scale }); + } + + SceneImage::new(scene, pixel_width.max(1), pixel_height.max(1)) +} + +fn svg_brush_can_render_to_imaging_scene(brush: &Brush) -> bool { + match brush { + Brush::Solid(_) | Brush::Gradient(_) => true, + Brush::Image(image_brush) => { + matches!( + &image_brush.image, + crate::effects::Image::Raster(_) | crate::effects::Image::Scene(_) + ) + } + } +} + +fn imaging_brush_from_svg_brush(brush: &Brush, bounds: Rect) -> Option { + match brush { + Brush::Solid(color) => Some(imaging::Brush::Solid(*color)), + Brush::Gradient(gradient) => Some(imaging::Brush::Gradient( + gradient.to_peniko(bounds, &crate::unit::FontSizeCx::new(14.0, 16.0)), + )), + Brush::Image(image_brush) => match &image_brush.image { + crate::effects::Image::Raster(image) => Some(imaging::Brush::Image( + imaging::ImageBrush(peniko::ImageBrush { + image: imaging::Image::Raster(image.image.clone()), + sampler: image_brush.sampler, + }), + )), + crate::effects::Image::Scene(image) => Some(imaging::Brush::Image( + imaging::ImageBrush(peniko::ImageBrush { + image: imaging::Image::Scene(image.image.clone()), + sampler: image_brush.sampler, + }), + )), + crate::effects::Image::Surface(_) | crate::effects::Image::Source(_) => None, + }, + } +} diff --git a/src/views/text_input.rs b/src/views/text_input.rs index d704622ba..bd6d6dfe1 100644 --- a/src/views/text_input.rs +++ b/src/views/text_input.rs @@ -15,7 +15,6 @@ use crate::{ }; use floem_reactive::{Effect, SignalGet, SignalTrack, SignalUpdate, SignalWith}; -use floem_renderer::Renderer; use taffy::Dimension; use ui_events::keyboard::{Key, KeyState, KeyboardEvent, Modifiers, NamedKey}; use ui_events::pointer::{PointerButton, PointerButtonEvent, PointerEvent}; @@ -42,7 +41,7 @@ use crate::{ text::{Affinity, Attrs, AttrsList, FamilyOwned, TextLayoutState, TextSelection}, }; -use peniko::Brush; +use crate::effects::Brush; use peniko::kurbo::{Point, Rect, Size}; use std::{cell::RefCell, rc::Rc}; @@ -123,6 +122,13 @@ pub struct TextInputEnter; custom_event!(TextInputEnter); +fn solid_text_color(brush: Option<&Brush>) -> peniko::Color { + match brush { + Some(Brush::Solid(color)) => *color, + _ => palette::css::BLACK, + } +} + /// Text Input View. pub struct TextInput { id: ViewId, @@ -729,10 +735,10 @@ impl TextInput { /// Retrieve attributes for the placeholder text. pub fn get_placeholder_text_attrs(&self) -> AttrsList { - let mut attrs = Attrs::new().color( + let mut attrs = Attrs::new().brush( self.placeholder_style .color() - .unwrap_or(palette::css::BLACK), + .unwrap_or(palette::css::BLACK.into()), ); //TODO: @@ -779,7 +785,8 @@ impl TextInput { /// Retrieve attributes for the text. pub fn get_text_attrs(&self) -> AttrsList { - let mut attrs = Attrs::new().color(self.style.color().unwrap_or(palette::css::BLACK)); + let mut attrs = + Attrs::new().brush(self.style.color().unwrap_or(palette::css::BLACK.into())); attrs = attrs.font_size(self.font_size()); @@ -1111,8 +1118,7 @@ impl TextInput { self.selection = self.selection_from_byte_positions(new_selection.start, new_selection.end); } - fn paint_selection_rect(&self, cx: &mut crate::context::PaintCx<'_>) { - let cursor_color = self.selection_style.selection_color(); + fn paint_selection_rects(&self, effective_scale: f64, mut paint_rect: impl FnMut(Rect)) { let selection = if let Some(selection) = self.selection { selection } else if let Some(cursor) = self.preedit.as_ref().and_then(|p| p.cursor) { @@ -1137,9 +1143,9 @@ impl TextInput { self.layout_data.borrow().selection_rects_for_selection( &selection, selection_origin, + effective_scale, |rect| { - // Clipping already handled by traversal. - cx.fill(&rect, &cursor_color, 0.0); + paint_rect(rect); }, ); } @@ -1153,12 +1159,12 @@ impl TextInput { fn replace_range(buff: &mut String, del_range: Range, replacement: Option<&str>) { assert!(del_range.start <= del_range.end); if !buff.is_char_boundary(del_range.end) { - eprintln!("[Floem] Tried to delete range with invalid end: {del_range:?}"); + crate::floem_debug_log!("[Floem] Tried to delete range with invalid end: {del_range:?}"); return; } if !buff.is_char_boundary(del_range.start) { - eprintln!("[Floem] Tried to delete range with invalid start: {del_range:?}"); + crate::floem_debug_log!("[Floem] Tried to delete range with invalid start: {del_range:?}"); return; } @@ -1221,7 +1227,7 @@ impl View for TextInput { format!("TextInput: {:?}", self.buffer.get_untracked()).into() } - fn update(&mut self, _cx: &mut UpdateCx, state: Box) { + fn update(&mut self, cx: &mut UpdateCx, state: Box) { if state.is::() { if let Ok(token) = state.downcast::() && *token == self.cursor_blink_timer @@ -1245,9 +1251,10 @@ impl View for TextInput { if text_updated { self.update_text_layout(); self.id.request_layout(); + cx.window_state.request_paint(self.id); } } else { - eprintln!("downcast failed"); + crate::floem_debug_log!("downcast failed"); } } @@ -1484,61 +1491,68 @@ impl View for TextInput { let text_start_point = self.get_text_origin(); - // Apply custom clip for text content area let mut clip_rect = self.id.get_layout_rect_local(); let content_rect = self.id.get_content_rect_local(); clip_rect.x0 = content_rect.x0; clip_rect.x1 = content_rect.x1; - cx.clip(&clip_rect); - - // Draw text using layout_data - self.layout_data - .borrow() - .with_effective_text_layout(|text_layout| { - text_layout.draw( - cx, - Point::new(text_start_point.x - self.scroll_offset, text_start_point.y), - ); - }); - - // underline the preedit text - if let Some(preedit) = &self.preedit { - let start_idx = self.cursor_glyph_idx; - let end_idx = start_idx + preedit.text.len(); - + cx.painter.with_fill_clip(clip_rect, |p| { self.layout_data .borrow() .with_effective_text_layout(|text_layout| { - let start_x = text_start_point.x - + text_layout.cursor_point(start_idx, Affinity::Upstream).x - - self.scroll_offset; - let end_x = text_start_point.x - + text_layout.cursor_point(end_idx, Affinity::Upstream).x - - self.scroll_offset; - - let color = self.style.color().unwrap_or(palette::css::BLACK); - let y = text_start_point.y - + text_layout - .line_metrics_at(start_idx, Affinity::Upstream) - .map(|metrics| metrics.ascent as f64) - .unwrap_or(0.0); - - cx.fill( - &Rect::new(start_x, y, end_x, y + 1.0), - &Brush::Solid(color), - 0.0, + text_layout.draw_with_painter( + p.as_dyn(), + Point::new(text_start_point.x - self.scroll_offset, text_start_point.y), + cx.font_embolden, + cx.window_state.effective_scale(), ); }); - } - // skip rendering selection / cursor if we don't have focus + if let Some(preedit) = &self.preedit { + let start_idx = self.cursor_glyph_idx; + let end_idx = start_idx + preedit.text.len(); + + self.layout_data + .borrow() + .with_effective_text_layout(|text_layout| { + let start_x = text_start_point.x + + text_layout.cursor_point(start_idx, Affinity::Upstream).x + - self.scroll_offset; + let end_x = text_start_point.x + + text_layout.cursor_point(end_idx, Affinity::Upstream).x + - self.scroll_offset; + + let color = solid_text_color(self.style.color().as_ref()); + let y = text_start_point.y + + text_layout + .line_metrics_at(start_idx, Affinity::Upstream) + .map(|metrics| metrics.ascent as f64) + .unwrap_or(0.0); + + p.fill(Rect::new(start_x, y, end_x, y + 1.0), &Brush::Solid(color)) + .draw(); + }); + } + + if cx.window_state.is_focused(self.id) { + let has_selection = self.selection.is_some() + || self + .preedit + .as_ref() + .is_some_and(|p| p.cursor.is_some_and(|c| c.0 != c.1)); + + if has_selection { + let selection_color = self.selection_style.selection_color(); + self.paint_selection_rects(cx.effective_scale, |rect| { + p.fill(rect, &selection_color).draw(); + }); + } + } + }); + if !cx.window_state.is_focused(self.id) { - // Clear the custom clip we applied - cx.clear_clip(); return; } - // see if we have a selection range let has_selection = self.selection.is_some() || self .preedit @@ -1546,15 +1560,9 @@ impl View for TextInput { .is_some_and(|p| p.cursor.is_some_and(|c| c.0 != c.1)); if has_selection { - // Clear the custom clip we applied - self.paint_selection_rect(cx); - cx.clear_clip(); return; } - // Clear the custom clip we applied - cx.clear_clip(); - // see if we should render the cursor let is_cursor_visible = (self.last_cursor_action_on.elapsed().as_millis() / CURSOR_BLINK_INTERVAL_MS as u128) @@ -1569,7 +1577,7 @@ impl View for TextInput { .builtin() .cursor_color(); let cursor_rect = self.get_cursor_rect(); - cx.fill(&cursor_rect, &cursor_color, 0.0); + cx.painter.fill(cursor_rect, &cursor_color).draw(); } // request paint either way if we're attempting draw a cursor diff --git a/src/views/toggle_button.rs b/src/views/toggle_button.rs index 4aca86b19..8cd3a903b 100644 --- a/src/views/toggle_button.rs +++ b/src/views/toggle_button.rs @@ -2,18 +2,18 @@ //! A toggle button widget. An example can be found in [widget-gallery/button](https://github.com/lapce/floem/tree/main/examples/widget-gallery) //! in the floem examples. -use std::{cell::RefCell, rc::Rc, time::Duration}; +use std::time::Duration; +use crate::effects::Brush; use floem_reactive::{Effect, SignalGet, SignalUpdate}; -use peniko::Brush; -use peniko::kurbo::{Point, Rect, Size}; +use peniko::kurbo::{Point, Rect, Shape, Size}; use ui_events::pointer::PointerEvent; use crate::context::Phases; use crate::custom_event; use crate::event::listener::EventListenerTrait; use crate::{ - BoxTree, ElementId, Renderer, + ElementId, context::{EventCx, PaintCx, UpdateCx}, easing::Linear, event::{ @@ -60,7 +60,6 @@ custom_event!(ToggleChanged, bool, ToggleChanged::extract_inner); struct Handle { element_id: ElementId, - box_tree: Rc>, position: f64, parent_id: ViewId, dragged: bool, @@ -72,7 +71,6 @@ impl Handle { Self { parent_id, element_id: parent_id.create_child_element_id(1), - box_tree: parent_id.box_tree(), position: 0.0, dragged: false, moved_on_down: false, @@ -93,8 +91,7 @@ impl Handle { self.position + radius, size.height, ); - let mut bt = self.box_tree.borrow_mut(); - bt.set_local_bounds(self.element_id.0, rect); + self.element_id.set_local_bounds(rect); } fn snap(&mut self, state: bool, size: Size, radius: f64, inset: f64) { @@ -155,7 +152,7 @@ impl Handle { let circle_point = Point::new(self.position, size.to_rect().center().y); let circle = crate::kurbo::Circle::new(circle_point, radius); if let Some(color) = color { - cx.fill(&circle, &color, 0.); + cx.painter.fill(circle.to_path(0.1), &color).draw(); } } } diff --git a/src/window/compositor.rs b/src/window/compositor.rs new file mode 100644 index 000000000..819a4cd93 --- /dev/null +++ b/src/window/compositor.rs @@ -0,0 +1,3396 @@ +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::window::render_plan::SceneRenderPlan; +use crate::{ + app::{Application, UserEvent}, + compositor_surface::{CompositorSurfaceContent, CompositorSurfaceId, ExternalTexture}, + effects::{Brush, CompositorShader, CompositorShaderPass, Image}, + frame::FrameRatePreference, + gpu_resources::GpuResources, + paint::{ + composition::{ + CompositionItem, CompositionKey, CompositionPlan, CompositorSurfaceLayer, + LayerSourceId, SceneExternalImage, SceneLayer, + }, + renderer::{ExternalImageResources, SceneFragmentRenderJob, SceneFragmentRendererPool}, + renderer::{SceneFragmentRenderCompletion, SceneFragmentRenderKind}, + }, +}; +use imaging::{ + Brush as ImagingBrush, Composite, ExternalImage, ExternalImageId, ImageBrush, + record::{Clip, Draw, Geometry, Scene}, +}; +use imaging_wgpu::ResolvedExternalImage; +use peniko::kurbo::{Affine, Rect, Size}; +use peniko::{BlendMode, Fill, ImageAlphaType}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; +use subduction_core::{ + layer::{FrameChanges, LayerId, LayerStore, SurfaceId}, + transform::Transform3d, +}; +use winit::window::WindowId; + +use super::compositor_surface::CompositorSurfaceEntry; + +static COMPOSITOR_RENDER_CALL_ID: AtomicU64 = AtomicU64::new(1); +const MAX_EXTERNAL_WRITER_TEXTURE_POOL_ENTRIES: usize = 4; + +#[derive(Default)] +pub(crate) struct WindowCompositor { + layers_by_key: FxHashMap, + visible_layers_by_key: FxHashMap, + layer_ids_by_key: FxHashMap, + layer_store: LayerStore, + root_layer: Option, + order: Vec, + next_surface_id: u32, + + layer_host: Option, + layer_host_failed: bool, + surface_pool_retained_extras: usize, + unsupported_publications: FxHashSet, + unused_resource_releases: Arc>>, + scene_content_by_key: FxHashMap, + scene_render_signatures: FxHashMap, + pending_scene_renders: FxHashMap, + pending_scene_publications: Vec<(subduction::SubmittedContentInfo, subduction::ResourceKey)>, + published_compositor_surface_versions: FxHashMap, + effect_renderer: ShaderRenderer, + external_writer_texture_pool: + FxHashMap>, + pending_layer_changes: Option, + user_scale: f64, + #[cfg(target_os = "macos")] + metal_capture_active: bool, + #[cfg(target_os = "macos")] + metal_capture_frame_had_scene_render: bool, + #[cfg(target_os = "macos")] + metal_capture_render_call_id: Option, +} + +/// Main-thread compositor runtime. +/// +/// This alias marks the ownership boundary described in +/// `docs/window-ui-thread-split.md`: this state owns the Subduction layer host, +/// layer store, scene render publications, and compositor commits. The concrete +/// implementation is still named `WindowCompositor` while the split is staged. +pub(crate) type CompositorRuntime = WindowCompositor; + +#[derive(Clone, Debug)] +pub(crate) struct PresentedLayer { + pub layer_id: LayerId, + pub source_element_id: Option, + pub debug_name: Option, + pub frame_rate: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct CompositorCommit { + pub layers: Vec, + pub active_layers: Vec, +} + +impl WindowCompositor { + pub(crate) fn set_surface_pool_retained_extras(&mut self, extras: usize) { + self.surface_pool_retained_extras = extras; + if let Some(layer_host) = &mut self.layer_host { + layer_host.set_max_idle_wgpu_surface_resources(extras); + } + } + + pub(crate) fn invalidate_compositor_surface_content( + &mut self, + surface_id: CompositorSurfaceId, + ) { + let keys = self + .layers_by_key + .iter() + .filter_map(|(key, state)| match state { + CompositorLayerState::Scene(layer) + if layer + .external_images + .iter() + .any(|image| image.surface_id == surface_id) => + { + Some(key.clone()) + } + _ => None, + }) + .collect::>(); + for key in &keys { + self.scene_content_by_key.remove(key); + self.scene_render_signatures.remove(key); + if let Some(pending) = self.pending_scene_renders.remove(key) { + self.release_pending_scene_textures(pending); + crate::floem_debug_log!( + "floem compositor pending scene cancel reason=external_surface_invalidate key={:?} surface={:?}", + key, + surface_id, + ); + } + } + } + + pub(crate) fn has_layer_host(&self) -> bool { + self.layer_host.is_some() + } + + #[cfg(target_os = "macos")] + pub(crate) fn mark_metal_capture_active(&mut self) { + self.metal_capture_active = true; + self.metal_capture_frame_had_scene_render = false; + self.metal_capture_render_call_id = None; + } + + pub(crate) fn ensure_platform_presenter( + &mut self, + window_id: WindowId, + window: &(impl raw_window_handle::HasWindowHandle + ?Sized), + ) { + if self.layer_host.is_some() || self.layer_host_failed { + return; + } + match subduction::LayerHost::from_window(window) { + Ok(mut layer_host) => { + layer_host.set_max_idle_wgpu_surface_resources(self.surface_pool_retained_extras); + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor layer host backend={}", + layer_host.backend_name() + ); + } + layer_host.set_commit_callback(Some(Box::new(move |_feedback| { + crate::Application::send_proxy_event(crate::app::UserEvent::LayerHostCommit { + window_id, + committed_at: crate::platform::Instant::now(), + }); + }))); + self.layer_host = Some(layer_host); + } + Err(err) => { + self.layer_host_failed = true; + crate::floem_debug_log!("floem compositor layer host unavailable: {err}"); + } + } + } + + pub(crate) fn apply_plan( + &mut self, + plan: &CompositionPlan, + compositor_surfaces: &FxHashMap, + user_scale: f64, + _gpu_resources: Option<&GpuResources>, + ) -> CompositorDiff { + self.user_scale = normalized_user_scale(user_scale); + let mut diff = CompositorDiff::default(); + let mut new_order = Vec::with_capacity(plan.items.len()); + let mut live_keys = FxHashSet::default(); + + for item in &plan.items { + let state = CompositorLayerState::from_item(item, compositor_surfaces); + let key = state.key().clone(); + live_keys.insert(key.clone()); + new_order.push(key.clone()); + + match self.layers_by_key.get(&key) { + Some(previous) if previous.equivalent(&state) => {} + Some(_) => diff.updated.push(key.clone()), + None => diff.created.push(key.clone()), + } + + self.layers_by_key.insert(key, state); + } + + let removed = self + .order + .iter() + .filter(|key| !live_keys.contains(*key)) + .cloned() + .collect::>(); + for key in &removed { + self.layers_by_key.remove(key); + self.visible_layers_by_key.remove(key); + self.scene_content_by_key.remove(key); + self.scene_render_signatures.remove(key); + if let Some(pending) = self.pending_scene_renders.remove(key) { + self.release_pending_scene_textures(pending); + crate::floem_debug_log!( + "floem compositor pending scene cancel reason=layer_removed key={:?}", + key, + ); + } + self.published_compositor_surface_versions.remove(key); + if let Some(layer_id) = self.layer_ids_by_key.remove(key) { + self.destroy_layer_recursive(layer_id); + } + } + diff.removed = removed; + + if self.order != new_order { + diff.order_changed = true; + } + self.order = new_order; + let changes = self.sync_layer_store(); + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor layer tree layers={} roots={} plan_items={} scene_layers={} external_layers={} flattened_external_images={}", + self.layer_store.len(), + usize::from(self.root_layer.is_some()), + self.order.len(), + self.layers_by_key + .values() + .filter(|state| matches!(state, CompositorLayerState::Scene(_))) + .count(), + self.layers_by_key + .values() + .filter(|state| matches!(state, CompositorLayerState::CompositorSurface(_))) + .count(), + self.layers_by_key + .values() + .map(|state| match state { + CompositorLayerState::Scene(scene) => scene.external_images.len(), + CompositorLayerState::CompositorSurface(_) => 0, + }) + .sum::(), + ); + } + self.stage_layer_changes(changes); + + diff + } + + fn stage_layer_changes(&mut self, changes: FrameChanges) { + let Some(pending) = &mut self.pending_layer_changes else { + self.pending_layer_changes = Some(changes); + return; + }; + merge_frame_changes(pending, changes); + } + + fn sync_layer_store(&mut self) -> FrameChanges { + self.ensure_root_layers(); + let root = self.root_layer.expect("root layer is initialized"); + + for key in self.order.clone() { + let Some(state) = self.layer_state_for_sync(&key) else { + continue; + }; + let layer_id = if let Some(layer_id) = self.layer_ids_by_key.get(&key).copied() { + layer_id + } else { + let layer_id = self.layer_store.create_layer(); + self.layer_store.add_child(root, layer_id); + self.layer_ids_by_key.insert(key.clone(), layer_id); + layer_id + }; + if self.layer_store.parent(layer_id) != Some(root) { + self.layer_store.reparent(layer_id, root); + } + match state { + CompositorLayerState::Scene(layer) => { + self.sync_scene_layer(layer_id, &layer); + } + CompositorLayerState::CompositorSurface(layer) => { + self.sync_external_layer(layer_id, &layer); + } + } + } + self.layer_store.evaluate() + } + + fn layer_state_for_sync(&mut self, key: &CompositionKey) -> Option { + let desired = self.layers_by_key.get(key)?.clone(); + self.visible_layers_by_key + .insert(key.clone(), desired.clone()); + Some(desired) + } + + fn ensure_root_layers(&mut self) { + if self.root_layer.is_some() { + return; + } + let root = self.layer_store.create_layer(); + self.root_layer = Some(root); + self.next_surface_id = 0; + } + + fn ensure_layer_content(&mut self, layer_id: LayerId) -> SurfaceId { + if let Some(surface_id) = self.layer_store.content(layer_id) { + return surface_id; + } + let surface_id = SurfaceId(self.next_surface_id); + self.next_surface_id += 1; + self.layer_store.set_content(layer_id, Some(surface_id)); + surface_id + } + + pub(crate) fn content_surface_for_key(&self, key: &CompositionKey) -> Option { + let layer_id = self.layer_ids_by_key.get(key).copied()?; + self.layer_store.content(layer_id) + } + + pub(crate) fn create_wgpu_surface_frame( + &mut self, + device: &wgpu::Device, + opportunity: subduction::wgpu::SurfaceFrameOpportunity, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + usage: wgpu::TextureUsages, + ) -> Result { + self.drain_unused_resource_releases(); + let Some(layer_host) = &mut self.layer_host else { + return Err(subduction::wgpu::SurfaceFrameError::Unsupported); + }; + let release_queue = self.unused_resource_releases.clone(); + layer_host + .create_wgpu_surface_frame(device, opportunity, size, format, usage) + .map(|lease| { + lease.with_release(Arc::new(move |resource_key| { + if let Ok(mut releases) = release_queue.lock() { + releases.push(resource_key); + } + })) + }) + .map_err(|err| subduction::wgpu::SurfaceFrameError::Producer(err.to_string())) + } + + fn drain_unused_resource_releases(&mut self) { + let Some(layer_host) = &mut self.layer_host else { + return; + }; + let Ok(mut releases) = self.unused_resource_releases.lock() else { + return; + }; + for resource_key in releases.drain(..) { + layer_host.release_wgpu_surface_resource(resource_key); + } + } + + fn sync_scene_layer(&mut self, layer_id: LayerId, layer: &SceneCompositorLayer) { + self.ensure_layer_content(layer_id); + let bounds = layer.bounds.size(); + if self.layer_store.bounds(layer_id) != bounds { + self.layer_store.set_bounds(layer_id, bounds); + } + let transform = presentation_transform(layer.transform, layer.bounds, self.user_scale); + if self.layer_store.local_transform(layer_id) != transform { + self.layer_store.set_transform(layer_id, transform); + } + if self.layer_store.clip(layer_id).is_some() { + self.layer_store.set_clip(layer_id, None); + } + if self.layer_store.local_opacity(layer_id) != layer.opacity { + self.layer_store.set_opacity(layer_id, layer.opacity); + } + } + + fn sync_external_layer(&mut self, layer_id: LayerId, layer: &CompositorSurfaceCompositorLayer) { + match layer.content { + CompositorSurfaceContent::Texture(_) => { + self.ensure_layer_content(layer_id); + } + CompositorSurfaceContent::Empty if layer.has_provider => { + self.ensure_layer_content(layer_id); + } + CompositorSurfaceContent::Empty + | CompositorSurfaceContent::NativeLayer(_) + | CompositorSurfaceContent::Image(_) + | CompositorSurfaceContent::Subduction(_) => { + if self.layer_store.content(layer_id).is_some() { + self.layer_store.set_content(layer_id, None); + } + } + } + let bounds = layer.rect.size(); + if self.layer_store.bounds(layer_id) != bounds { + self.layer_store.set_bounds(layer_id, bounds); + } + let transform = presentation_transform(layer.transform, layer.rect, self.user_scale); + if self.layer_store.local_transform(layer_id) != transform { + self.layer_store.set_transform(layer_id, transform); + } + if self.layer_store.clip(layer_id).is_some() { + self.layer_store.set_clip(layer_id, None); + } + if self.layer_store.local_opacity(layer_id) != layer.opacity { + self.layer_store.set_opacity(layer_id, layer.opacity); + } + } + + fn destroy_layer_recursive(&mut self, layer_id: LayerId) { + let children = self.layer_store.children(layer_id).collect::>(); + for child in children { + self.destroy_layer_recursive(child); + } + self.layer_store.destroy_layer(layer_id); + } + + pub(crate) fn render_scene_layers( + &mut self, + window_id: WindowId, + plan: &CompositionPlan, + compositor_surfaces: &FxHashMap, + gpu_resources: &GpuResources, + renderer_pool: &SceneFragmentRendererPool, + user_scale: f64, + effective_scale: f64, + ) -> usize { + self.user_scale = normalized_user_scale(user_scale); + let render_call_id = COMPOSITOR_RENDER_CALL_ID.fetch_add(1, Ordering::Relaxed); + if crate::frame_source::frame_pacing_diag_enabled() { + let render_plan = SceneRenderPlan::from_composition_plan(plan); + crate::floem_debug_log!( + "floem compositor render_scene_layers begin call={} plan_items={} scene_layers={} effect_scene_layers={} batches={}", + render_call_id, + plan.items.len(), + render_plan.node_count(), + render_plan.effect_node_count(), + render_plan.batches().len(), + ); + } + let scheduled_scene_frames = self.render_scene_content( + render_call_id, + window_id, + plan, + compositor_surfaces, + gpu_resources, + renderer_pool, + effective_scale, + ); + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor render_scene_layers end call={} scheduled_frames={}", + render_call_id, + scheduled_scene_frames, + ); + } + #[cfg(target_os = "macos")] + if self.metal_capture_active && scheduled_scene_frames > 0 { + self.metal_capture_frame_had_scene_render = true; + self.metal_capture_render_call_id + .get_or_insert(render_call_id); + } + scheduled_scene_frames + } + + pub(crate) fn has_pending_scene_renders(&self) -> bool { + !self.pending_scene_renders.is_empty() + } + + #[cfg(target_os = "macos")] + pub(crate) fn metal_capture_frame_ready_to_commit(&self) -> bool { + let captured_render_call_ready = + self.metal_capture_render_call_id + .is_some_and(|render_call_id| { + !self + .pending_scene_renders + .values() + .any(|pending| pending.render_call_id == render_call_id) + }); + self.metal_capture_active + && self.metal_capture_frame_had_scene_render + && captured_render_call_ready + && !self.pending_scene_publications.is_empty() + } + + #[cfg(target_os = "macos")] + pub(crate) fn metal_capture_waiting_for_rendered_frame(&self) -> bool { + self.metal_capture_active && self.metal_capture_frame_had_scene_render + } + + pub(crate) fn pending_scene_render_summary(&self) -> String { + let mut summary = String::new(); + for (key, pending) in &self.pending_scene_renders { + if !summary.is_empty() { + summary.push_str(", "); + } + let effect_ready = pending.scene_texture.is_none() || pending.effect_submitted; + summary.push_str(&format!( + "{:?}:rev{} call{} content={} mask={} effect_submitted={} effect_ready={} effects={}", + key, + pending.content_revision, + pending.render_call_id, + pending.content_ready, + pending.mask_ready, + pending.effect_submitted, + effect_ready, + pending.effects.len(), + )); + } + summary + } + + pub(crate) fn has_pending_commit_work(&self) -> bool { + self.pending_layer_changes + .as_ref() + .is_some_and(|changes| !frame_changes_empty(changes)) + || !self.pending_scene_publications.is_empty() + || self.has_pending_compositor_surface_publications(false) + } + + pub(crate) fn has_independent_compositor_surface_commit_work(&self) -> bool { + self.has_pending_compositor_surface_publications(true) + || !self.native_layer_attachments(true).is_empty() + } + + pub(crate) fn commit_ready_layer_tree( + &mut self, + queue: &wgpu::Queue, + ) -> Option { + if self.layer_host.is_none() { + return None; + } + let mut publications = std::mem::take(&mut self.pending_scene_publications); + publications.extend(self.submitted_content_publications(false)); + let native_layers = self.native_layer_attachments(false); + let presented_layers = self.pending_presented_layers(&publications); + let committed = self.commit_layer_tree_and_publications(&publications, queue); + let active_layers = self.active_presented_layers(); + self.attach_native_layers(&native_layers); + if committed { + self.mark_submitted_content_published(false); + } + if !native_layers.is_empty() { + self.mark_native_layer_content_attached(false); + } + if committed || !native_layers.is_empty() { + Some(CompositorCommit { + layers: presented_layers, + active_layers, + }) + } else { + None + } + } + + pub(crate) fn commit_independent_compositor_surface_work( + &mut self, + queue: &wgpu::Queue, + ) -> Option { + if self.layer_host.is_none() { + return None; + } + let publications = self.submitted_content_publications(true); + let native_layers = self.native_layer_attachments(true); + let presented_layers = self.pending_presented_layers(&publications); + let committed = self.commit_layer_tree_and_publications(&publications, queue); + let active_layers = self.active_presented_layers(); + self.attach_native_layers(&native_layers); + if committed { + self.mark_submitted_content_published(true); + } + if !native_layers.is_empty() { + self.mark_native_layer_content_attached(true); + } + if committed || !native_layers.is_empty() { + Some(CompositorCommit { + layers: presented_layers, + active_layers, + }) + } else { + None + } + } + + fn pending_presented_layers( + &self, + publications: &[(subduction::SubmittedContentInfo, subduction::ResourceKey)], + ) -> Vec { + let mut layers = FxHashSet::default(); + if let Some(changes) = &self.pending_layer_changes { + layers.extend(changes.transforms.iter().copied()); + layers.extend(changes.opacities.iter().copied()); + layers.extend(changes.clips.iter().copied()); + layers.extend(changes.content.iter().copied()); + layers.extend(changes.bounds.iter().copied()); + layers.extend(changes.hidden.iter().copied()); + layers.extend(changes.unhidden.iter().copied()); + layers.extend(changes.added.iter().copied()); + } + for (info, _) in publications { + if let Some(layer_id) = self.layer_for_content_surface(info.surface_id) { + layers.insert(layer_id.index()); + } + } + + let mut presented = Vec::new(); + for (key, layer_id) in &self.layer_ids_by_key { + if !layers.contains(&layer_id.index()) { + continue; + } + if let Some(layer) = self.presented_layer_for_key(key, *layer_id) { + presented.push(layer); + } + } + presented + } + + fn active_presented_layers(&self) -> Vec { + self.layer_ids_by_key + .iter() + .filter_map(|(key, layer_id)| self.presented_layer_for_key(key, *layer_id)) + .collect() + } + + pub(crate) fn pending_frame_rate_preferences(&self) -> Vec { + let Some(changes) = &self.pending_layer_changes else { + return Vec::new(); + }; + let mut changed_layers = FxHashSet::default(); + changed_layers.extend(changes.transforms.iter().copied()); + changed_layers.extend(changes.opacities.iter().copied()); + changed_layers.extend(changes.clips.iter().copied()); + changed_layers.extend(changes.content.iter().copied()); + changed_layers.extend(changes.bounds.iter().copied()); + changed_layers.extend(changes.hidden.iter().copied()); + changed_layers.extend(changes.unhidden.iter().copied()); + changed_layers.extend(changes.added.iter().copied()); + + self.layer_ids_by_key + .iter() + .filter_map(|(key, layer_id)| { + changed_layers + .contains(&layer_id.index()) + .then(|| self.presented_layer_for_key(key, *layer_id)) + .flatten() + .and_then(|layer| layer.frame_rate) + }) + .collect() + } + + fn presented_layer_for_key( + &self, + key: &CompositionKey, + layer_id: LayerId, + ) -> Option { + let (source_element_id, debug_name, frame_rate) = match self.layers_by_key.get(key) { + Some(CompositorLayerState::Scene(layer)) => ( + layer.source_element_id, + layer.debug_name.clone(), + layer.frame_rate, + ), + Some(CompositorLayerState::CompositorSurface(layer)) => ( + None, + Some(format!("CompositorSurface {:?}", layer.surface_id)), + Some(layer.frame_rate), + ), + None => (None, None, None), + }; + if source_element_id.is_none() && debug_name.as_ref().is_none_or(|name| name.is_empty()) { + return None; + } + Some(PresentedLayer { + layer_id, + source_element_id, + debug_name, + frame_rate, + }) + } + + fn layer_for_content_surface(&self, surface_id: SurfaceId) -> Option { + self.layer_ids_by_key + .values() + .copied() + .find(|layer_id| self.layer_store.content(*layer_id) == Some(surface_id)) + } + + fn acquire_external_writer_texture( + &mut self, + device: &wgpu::Device, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + label: &'static str, + ) -> TrackedEffectTexture { + let key = EffectIntermediateTextureKey { + width: size.width, + height: size.height, + format, + }; + if let Some(texture) = self + .external_writer_texture_pool + .get_mut(&key) + .and_then(|textures| textures.pop()) + { + return texture; + } + TrackedEffectTexture { + key, + texture: create_assumed_initialized_external_writer_texture( + device, size, format, label, + ), + } + } + + fn release_external_writer_texture(&mut self, texture: TrackedEffectTexture) { + let retained_count = self + .external_writer_texture_pool + .values() + .map(Vec::len) + .sum::(); + let entry = self + .external_writer_texture_pool + .entry(texture.key) + .or_default(); + if entry.is_empty() && retained_count < MAX_EXTERNAL_WRITER_TEXTURE_POOL_ENTRIES { + entry.push(texture); + } + } + + fn release_pending_scene_textures(&mut self, mut pending: PendingSceneRender) { + if let Some(texture) = pending.scene_texture.take() { + self.release_external_writer_texture(texture); + } + if let Some(texture) = pending.effect_mask_texture.take() { + self.release_external_writer_texture(texture); + } + } + + fn render_scene_content( + &mut self, + render_call_id: u64, + window_id: WindowId, + plan: &CompositionPlan, + compositor_surfaces: &FxHashMap, + gpu_resources: &GpuResources, + renderer_pool: &SceneFragmentRendererPool, + effective_scale: f64, + ) -> usize { + let mut scheduled_frames = 0; + let mut prepared_submissions = Vec::new(); + let render_plan = SceneRenderPlan::from_composition_plan(plan); + for batch in render_plan.batches() { + if crate::frame_source::frame_pacing_diag_enabled() && batch.len() > 1 { + crate::floem_debug_log!( + "floem compositor scene render batch call={} nodes={} key={:?} bounds={:?}", + render_call_id, + batch.len(), + batch.key(), + batch.bounds(), + ); + } + for node_id in batch.nodes() { + let node = render_plan.node(*node_id); + let layer = node.layer(); + debug_assert!(node.phases().content); + debug_assert!(node.phases().publish); + debug_assert_eq!(node.phases().effect, !layer.color_filters.is_empty()); + let _ = (node.id(), node.plan_index(), node.phases().clip_mask); + let Some(surface_id) = self.content_surface_for_key(&layer.key) else { + continue; + }; + let bounds = layer.bounds; + let width = (bounds.width() * effective_scale).ceil().max(1.0) as u32; + let height = (bounds.height() * effective_scale).ceil().max(1.0) as u32; + let max_texture_dimension = gpu_resources.device.limits().max_texture_dimension_2d; + if width > max_texture_dimension || height > max_texture_dimension { + let failure = UnsupportedPublication::Scene { + key: layer.key.clone(), + revision: layer.content_revision, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer {:?} target {}x{} exceeds max texture dimension {}", + layer.key, + width, + height, + max_texture_dimension, + ); + } + continue; + } + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + let Some(format) = renderer_pool.compositor_texture_format() else { + let failure = UnsupportedPublication::Scene { + key: layer.key.clone(), + revision: layer.content_revision, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer {:?} renderer has no Subduction wgpu target format", + layer.key, + ); + } + continue; + }; + let target_origin = (layer.transform * bounds.origin()).to_vec2() * effective_scale; + let base_transform = layer + .transform + .then_scale(effective_scale) + .then_translate(-target_origin); + let signature = scene_render_signature( + layer, + compositor_surfaces, + effective_scale, + format, + size, + base_transform, + render_call_id, + ); + if self.scene_render_signatures.get(&layer.key) == Some(&signature) + && self.scene_content_by_key.contains_key(&layer.key) + { + continue; + } + if self + .pending_scene_renders + .get(&layer.key) + .is_some_and(|pending| pending.signature == signature) + { + continue; + } + let Some(external_images) = + self.external_image_resources_for_scene(layer, compositor_surfaces) + else { + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor scene render skip key={:?} reason=external_image_unavailable", + layer.key, + ); + } + continue; + }; + let opportunity = subduction::wgpu::SurfaceFrameOpportunity { + surface_id, + frame_index: layer.content_revision, + now: subduction_core::time::HostTime(0), + target_timestamp: None, + target_present: None, + previous_present: None, + refresh_interval: None, + confidence: subduction_core::timing::TimingConfidence::PacingOnly, + }; + let Ok(lease) = self.create_wgpu_surface_frame( + &gpu_resources.device, + opportunity, + size, + format, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + ) else { + let failure = UnsupportedPublication::Scene { + key: layer.key.clone(), + revision: layer.content_revision, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer {:?} could not acquire a Subduction wgpu target", + layer.key, + ); + } + continue; + }; + let scene_texture = if layer.color_filters.is_empty() { + None + } else { + // Scene fragments are rendered asynchronously by the render pool. + // Do not reuse this texture across pending scene renders: another + // effect layer can otherwise clear or overwrite the input before + // this layer's shader chain consumes it. + let texture = self.acquire_external_writer_texture( + &gpu_resources.device, + size, + format, + "floem compositor effect scene input", + ); + Some(texture) + }; + let render_size = Size::new(f64::from(width), f64::from(height)); + let effect_clip_transform = Affine::translate((-bounds.x0, -bounds.y0)); + let render_effects = + transform_compositor_shader_passes(&layer.color_filters, effect_clip_transform); + let EffectClip { + analytic: analytic_clip, + mask_scene: effect_mask_scene, + } = if render_effects.is_empty() { + EffectClip::default() + } else { + classify_effect_clips(&render_effects, render_size) + }; + let effect_mask_texture = effect_mask_scene.as_ref().map(|_| { + let texture = self.acquire_external_writer_texture( + &gpu_resources.device, + size, + format, + "floem compositor effect clip mask", + ); + texture + }); + let render_texture = scene_texture + .as_ref() + .map(|texture| texture.texture.clone()) + .unwrap_or_else(|| lease.texture.clone()); + let mask_texture_for_submit = effect_mask_texture.clone(); + let has_effect_mask = effect_mask_texture.is_some(); + self.pending_scene_renders.insert( + layer.key.clone(), + PendingSceneRender { + signature: signature.clone(), + layer_state: SceneCompositorLayer::from_layer(layer, compositor_surfaces), + lease, + scene_texture, + effect_mask_texture, + analytic_clip, + content_ready: false, + mask_ready: !has_effect_mask, + effect_submitted: false, + effects: render_effects, + format, + size, + effective_scale, + render_call_id, + content_revision: layer.content_revision, + }, + ); + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor scene render call={} key={:?} revision={} size={}x{} bounds={:?} transform={:?} commands={} external_images={} color_filters={}", + render_call_id, + layer.key, + layer.content_revision, + width, + height, + layer.bounds, + layer.transform, + layer.scene.commands().len(), + layer.external_images.len(), + layer.color_filters.len(), + ); + } + let content_job = SceneFragmentRenderJob { + scene: layer.scene.clone(), + base_transform, + clip: layer.clip, + render_size, + texture: render_texture, + external_images, + }; + let content_completion = SceneFragmentRenderCompletion { + window_id, + key: layer.key.clone(), + signature: signature.clone(), + kind: SceneFragmentRenderKind::Content, + }; + let mask_job = if let (Some(mask_scene), Some(mask_texture)) = + (effect_mask_scene, mask_texture_for_submit.as_ref()) + { + Some(( + SceneFragmentRenderJob { + scene: mask_scene, + base_transform: Affine::scale(effective_scale), + clip: None, + render_size, + texture: mask_texture.texture.clone(), + external_images: ExternalImageResources::default(), + }, + SceneFragmentRenderCompletion { + window_id, + key: layer.key.clone(), + signature: signature.clone(), + kind: SceneFragmentRenderKind::ClipMask, + }, + )) + } else { + None + }; + prepared_submissions.push(PreparedSceneRenderSubmission { + key: layer.key.clone(), + revision: layer.content_revision, + surface_id, + width, + height, + content: (content_job, content_completion), + mask: mask_job, + }); + } + } + for prepared in prepared_submissions { + let render_submitted = renderer_pool.submit(prepared.content.0, prepared.content.1); + if !render_submitted { + let failure = UnsupportedPublication::Scene { + key: prepared.key.clone(), + revision: prepared.revision, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer {:?} renderer cannot render into a Subduction wgpu target", + prepared.key, + ); + } + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor scene render skip key={:?} reason=renderer_failed", + prepared.key, + ); + } + if let Some(pending) = self.pending_scene_renders.remove(&prepared.key) { + self.release_pending_scene_textures(pending); + } + continue; + } + if let Some((mask_job, mask_completion)) = prepared.mask + && !renderer_pool.submit(mask_job, mask_completion) + { + let failure = UnsupportedPublication::Scene { + key: prepared.key.clone(), + revision: prepared.revision, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer {:?} renderer cannot render effect clip mask", + prepared.key, + ); + } + if let Some(pending) = self.pending_scene_renders.remove(&prepared.key) { + self.release_pending_scene_textures(pending); + } + continue; + } + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor scene render scheduled key={:?} surface={:?} size={}x{}", + prepared.key, + prepared.surface_id, + prepared.width, + prepared.height, + ); + } + scheduled_frames += 1; + } + scheduled_frames + } + + pub(crate) fn complete_scene_render( + &mut self, + window_id: WindowId, + key: CompositionKey, + signature: SceneRenderSignature, + kind: SceneFragmentRenderKind, + rendered: bool, + gpu_resources: &GpuResources, + ) -> bool { + let Some(pending_signature) = self + .pending_scene_renders + .get(&key) + .map(|pending| pending.signature.clone()) + else { + return false; + }; + if pending_signature != signature { + return false; + } + if !rendered { + if let Some(pending) = self.pending_scene_renders.remove(&key) { + let failure = UnsupportedPublication::Scene { + key: key.clone(), + revision: pending.content_revision, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer render worker failed for revision {}", + pending.content_revision, + ); + } + self.release_pending_scene_textures(pending); + } + return false; + }; + + let Some(pending) = self.pending_scene_renders.get_mut(&key) else { + return false; + }; + match kind { + SceneFragmentRenderKind::Content => pending.content_ready = true, + SceneFragmentRenderKind::ClipMask => pending.mask_ready = true, + } + if !pending.content_ready || !pending.mask_ready { + return true; + } + + if self + .pending_scene_renders + .get(&key) + .is_some_and(|pending| pending.effect_submitted) + { + return true; + } + + let Some(pending) = self.pending_scene_renders.get_mut(&key) else { + return false; + }; + let effect_submission = if let Some(scene_texture) = &pending.scene_texture { + match self.effect_renderer.render_effect_chain( + &gpu_resources.device, + &gpu_resources.queue, + pending.render_call_id, + &key, + &scene_texture.texture, + &pending.lease.texture, + pending + .effect_mask_texture + .as_ref() + .map(|texture| &texture.texture), + pending.analytic_clip, + pending.format, + pending.size, + &pending.effects, + pending.content_revision, + pending.effective_scale, + ) { + Ok(submission) => submission, + Err(err) => { + let failure = UnsupportedPublication::SceneEffect { + key: key.clone(), + revision: pending.content_revision, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer failed compositor color filter pass: {err}", + ); + } + if let Some(pending) = self.pending_scene_renders.remove(&key) { + self.release_pending_scene_textures(pending); + } + return false; + } + } + } else { + None + }; + #[cfg(debug_assertions)] + if crate::frame_source::frame_pacing_diag_enabled() && !pending.effects.is_empty() { + crate::floem_debug_log!( + "floem compositor scene effect rendered call={} key={:?} revision={} shaders={}", + pending.render_call_id, + key, + pending.content_revision, + pending.effects.len(), + ); + } + if pending.scene_texture.is_some() { + pending.effect_submitted = true; + notify_after_gpu_queue_work( + &gpu_resources.device, + &gpu_resources.queue, + effect_submission, + { + let key = key.clone(); + let signature = signature.clone(); + move |gpu_end| { + Application::send_proxy_event(UserEvent::SceneEffectReady { + window_id, + key, + signature, + gpu_end, + }); + } + }, + ); + return true; + } + + self.publish_completed_scene_render(key, signature) + } + + pub(crate) fn complete_scene_effect( + &mut self, + key: CompositionKey, + signature: SceneRenderSignature, + gpu_end: crate::platform::Instant, + ) -> bool { + let Some(pending) = self.pending_scene_renders.get(&key) else { + return false; + }; + if pending.signature != signature || !pending.effect_submitted { + return false; + } + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor scene effect ready key={:?} revision={} gpu_end={:?}", + key, + pending.content_revision, + gpu_end, + ); + } + self.publish_completed_scene_render(key, signature) + } + + fn publish_completed_scene_render( + &mut self, + key: CompositionKey, + signature: SceneRenderSignature, + ) -> bool { + let Some(mut pending) = self.pending_scene_renders.remove(&key) else { + return false; + }; + let scene_texture = pending.scene_texture.take(); + let effect_mask_texture = pending.effect_mask_texture.take(); + let subduction::wgpu::SurfaceFrameCompletion::Submitted(frame) = pending.lease.submit() + else { + if let Some(texture) = scene_texture { + self.release_external_writer_texture(texture); + } + if let Some(texture) = effect_mask_texture { + self.release_external_writer_texture(texture); + } + return false; + }; + let publication = publication_for_frame(&frame); + frame.mark_published(); + self.scene_content_by_key.insert( + key.clone(), + ExternalTextureContent::from_submitted_frame(frame), + ); + self.scene_render_signatures.insert(key.clone(), signature); + self.visible_layers_by_key.insert( + key.clone(), + CompositorLayerState::Scene(pending.layer_state), + ); + if let Some(texture) = scene_texture { + self.release_external_writer_texture(texture); + } + if let Some(texture) = effect_mask_texture { + self.release_external_writer_texture(texture); + } + if let Some(publication) = publication { + let changes = self.sync_layer_store(); + self.stage_layer_changes(changes); + self.pending_scene_publications.push(publication); + } + true + } + + pub(crate) fn capture_scene( + &self, + plan: &CompositionPlan, + frame_size: Size, + effective_scale: f64, + background: Option, + ) -> Result { + let mut scene = Scene::new(); + if let Some(background) = background { + let background_bounds = frame_size.to_rect().expand(); + scene.draw(Draw::Fill { + transform: Affine::IDENTITY, + fill_rule: Fill::NonZero, + brush: imaging_brush_from_background(background, background_bounds)?, + brush_transform: None, + shape: Geometry::Rect(background_bounds), + composite: Composite::default(), + }); + } + + let mut resources = ExternalImageResources::default(); + let mut next_image_id = 1; + for item in &plan.items { + match item { + CompositionItem::Scene(layer) => { + let Some(content) = self.scene_content_by_key.get(&layer.key) else { + return Err(format!( + "compositor capture missing rendered scene layer {:?}", + layer.key + )); + }; + append_texture_layer( + &mut scene, + &mut resources, + &mut next_image_id, + content, + layer.transform, + layer.bounds, + layer.opacity, + layer.blend_mode, + effective_scale, + ); + } + CompositionItem::CompositorSurface(layer) => { + let Some(CompositorLayerState::CompositorSurface(state)) = + self.layers_by_key.get(&layer.key) + else { + return Err(format!( + "compositor capture missing compositor surface layer {:?}", + layer.key + )); + }; + let CompositorSurfaceContent::Texture(texture) = &state.content else { + return Err(format!( + "compositor capture compositor surface {:?} has no submitted texture", + state.surface_id + )); + }; + let Some(content) = ExternalTextureContent::from_external_texture(texture) + else { + return Err(format!( + "compositor capture compositor surface {:?} submitted non-Subduction texture", + state.surface_id + )); + }; + append_texture_layer( + &mut scene, + &mut resources, + &mut next_image_id, + &content, + layer.transform, + layer.rect, + layer.opacity, + layer.blend_mode, + effective_scale, + ); + } + } + } + + Ok(CompositorCaptureScene { scene, resources }) + } + + fn external_image_resources_for_scene( + &mut self, + layer: &SceneLayer, + compositor_surfaces: &FxHashMap, + ) -> Option { + let mut resources = ExternalImageResources::default(); + for external in &layer.external_images { + let content = compositor_surfaces + .get(&external.surface_id) + .map(|entry| entry.content.clone()) + .unwrap_or(CompositorSurfaceContent::Empty); + let CompositorSurfaceContent::Texture(texture) = content else { + let failure = UnsupportedPublication::SceneExternalTexture { + key: layer.key.clone(), + revision: layer.content_revision, + surface_id: external.surface_id, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer {:?} cannot flatten compositor surface {:?} without a submitted texture", + layer.key, + external.surface_id, + ); + } + return None; + }; + let Some(frame) = texture + .payload + .downcast_ref::() + else { + let failure = UnsupportedPublication::SceneExternalTexture { + key: layer.key.clone(), + revision: layer.content_revision, + surface_id: external.surface_id, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: scene layer {:?} cannot flatten non-Subduction compositor surface {:?}; refusing silent copy/fallback", + layer.key, + external.surface_id, + ); + } + return None; + }; + resources.insert( + external.image_id, + ResolvedExternalImage { + texture: frame.texture.clone(), + view: frame.view.clone(), + format: frame.format, + width: frame.size.width, + height: frame.size.height, + }, + ); + } + Some(resources) + } + + fn commit_layer_tree_and_publications( + &mut self, + publications: &[(subduction::SubmittedContentInfo, subduction::ResourceKey)], + _queue: &wgpu::Queue, + ) -> bool { + let Some(layer_host) = &mut self.layer_host else { + return false; + }; + let changes = self.pending_layer_changes.take().unwrap_or_default(); + let has_changes = !frame_changes_empty(&changes); + if !has_changes && publications.is_empty() { + #[cfg(target_os = "macos")] + self.stop_metal_capture_after_rendered_frame(); + return false; + } + layer_host.apply_and_publish_surface_resources(&self.layer_store, &changes, publications); + #[cfg(target_os = "macos")] + self.stop_metal_capture_after_rendered_frame(); + true + } + + #[cfg(target_os = "macos")] + fn stop_metal_capture_after_rendered_frame(&mut self) { + let captured_render_call_ready = + self.metal_capture_render_call_id + .is_some_and(|render_call_id| { + !self + .pending_scene_renders + .values() + .any(|pending| pending.render_call_id == render_call_id) + }); + if self.metal_capture_active + && self.metal_capture_frame_had_scene_render + && captured_render_call_ready + { + self.metal_capture_active = false; + self.metal_capture_frame_had_scene_render = false; + self.metal_capture_render_call_id = None; + subduction_backend_apple::stop_active_metal_capture(); + } + } + + fn submitted_content_publications( + &mut self, + without_transaction_only: bool, + ) -> Vec<(subduction::SubmittedContentInfo, subduction::ResourceKey)> { + let mut publications = Vec::new(); + for (key, state) in &self.layers_by_key { + let Some(layer_id) = self.layer_ids_by_key.get(key).copied() else { + continue; + }; + let Some(surface_id) = self.layer_store.content(layer_id) else { + continue; + }; + match state { + CompositorLayerState::Scene(_) => {} + CompositorLayerState::CompositorSurface(layer) => match &layer.content { + CompositorSurfaceContent::Texture(texture) => { + if without_transaction_only && !layer.presents_without_transaction { + continue; + } + if self.published_compositor_surface_versions.get(key) + == Some(&layer.content_version) + { + continue; + } + if let Some(frame) = texture + .payload + .downcast_ref::() + { + publications.extend(publication_for_frame_surface(frame, surface_id)); + } else { + let failure = UnsupportedPublication::ExternalTexture { + key: key.clone(), + version: layer.content_version, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: compositor surface {:?} submitted a non-Subduction texture {:?} for surface {:?}; refusing silent copy/fallback", + layer.surface_id, + texture.size, + surface_id, + ); + } + } + } + CompositorSurfaceContent::Image(image) => { + let failure = UnsupportedPublication::ExternalTexture { + key: key.clone(), + version: layer.content_version, + }; + if self.unsupported_publications.insert(failure) { + crate::floem_debug_log!( + "floem compositor: compositor surface {:?} submitted CPU image {}x{} for surface {:?}; refusing silent copy/fallback", + layer.surface_id, + image.width, + image.height, + surface_id, + ); + } + } + CompositorSurfaceContent::Empty + | CompositorSurfaceContent::NativeLayer(_) + | CompositorSurfaceContent::Subduction(_) => {} + }, + } + } + publications + } + + fn has_pending_compositor_surface_publications(&self, without_transaction_only: bool) -> bool { + self.layers_by_key.iter().any(|(key, state)| { + let CompositorLayerState::CompositorSurface(layer) = state else { + return false; + }; + if without_transaction_only && !layer.presents_without_transaction { + return false; + } + if self.published_compositor_surface_versions.get(key) == Some(&layer.content_version) { + return false; + } + compositor_surface_content_is_publishable(&layer.content) + }) + } + + fn native_layer_attachments( + &self, + without_transaction_only: bool, + ) -> Vec<(LayerId, subduction::NativeLayer)> { + let mut attachments = Vec::new(); + for (key, state) in &self.layers_by_key { + let CompositorLayerState::CompositorSurface(layer) = state else { + continue; + }; + if without_transaction_only && !layer.presents_without_transaction { + continue; + } + let CompositorSurfaceContent::NativeLayer(native_layer) = &layer.content else { + continue; + }; + let Some(layer_id) = self.layer_ids_by_key.get(key).copied() else { + continue; + }; + attachments.push((layer_id, native_layer.clone())); + } + attachments + } + + fn attach_native_layers(&mut self, attachments: &[(LayerId, subduction::NativeLayer)]) { + let Some(layer_host) = &mut self.layer_host else { + return; + }; + for (layer_id, native_layer) in attachments { + if let Err(err) = layer_host.attach_native_layer(*layer_id, native_layer) { + crate::floem_debug_log!( + "floem compositor: failed to attach native layer {:?}: {err}", + layer_id, + ); + } + } + } + + fn mark_submitted_content_published(&mut self, without_transaction_only: bool) { + for (key, state) in &self.layers_by_key { + let CompositorLayerState::CompositorSurface(layer) = state else { + continue; + }; + if without_transaction_only && !layer.presents_without_transaction { + continue; + } + let CompositorSurfaceContent::Texture(texture) = &layer.content else { + continue; + }; + if let Some(frame) = texture + .payload + .downcast_ref::() + { + frame.mark_published(); + self.published_compositor_surface_versions + .insert(key.clone(), layer.content_version); + } + } + } + + fn mark_native_layer_content_attached(&mut self, without_transaction_only: bool) { + for (key, state) in &self.layers_by_key { + let CompositorLayerState::CompositorSurface(layer) = state else { + continue; + }; + if without_transaction_only && !layer.presents_without_transaction { + continue; + } + if matches!(layer.content, CompositorSurfaceContent::NativeLayer(_)) { + self.published_compositor_surface_versions + .insert(key.clone(), layer.content_version); + } + } + } +} + +fn frame_changes_empty(changes: &FrameChanges) -> bool { + changes.transforms.is_empty() + && changes.opacities.is_empty() + && changes.clips.is_empty() + && changes.content.is_empty() + && changes.bounds.is_empty() + && changes.hidden.is_empty() + && changes.unhidden.is_empty() + && changes.added.is_empty() + && changes.removed.is_empty() + && !changes.topology_changed +} + +fn notify_after_gpu_queue_work( + device: &wgpu::Device, + queue: &wgpu::Queue, + submission_index: Option, + callback: impl FnOnce(crate::platform::Instant) + Send + 'static, +) { + let callback: Box = Box::new(callback); + #[cfg(target_os = "macos")] + { + let Err(callback) = + crate::gpu_completion::notify_after_metal_queue_completion(queue, callback) + else { + return; + }; + queue.on_submitted_work_done(move || { + callback(crate::platform::Instant::now()); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index, + timeout: None, + }); + } + #[cfg(not(target_os = "macos"))] + { + queue.on_submitted_work_done(move || { + callback(crate::platform::Instant::now()); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index, + timeout: None, + }); + } +} + +struct PendingSceneRender { + signature: SceneRenderSignature, + layer_state: SceneCompositorLayer, + lease: subduction::wgpu::SurfaceFrameLease, + scene_texture: Option, + effect_mask_texture: Option, + analytic_clip: AnalyticClipSet, + content_ready: bool, + mask_ready: bool, + effect_submitted: bool, + effects: Vec, + format: wgpu::TextureFormat, + size: wgpu::Extent3d, + effective_scale: f64, + render_call_id: u64, + content_revision: u64, +} + +struct PreparedSceneRenderSubmission { + key: CompositionKey, + revision: u64, + surface_id: SurfaceId, + width: u32, + height: u32, + content: (SceneFragmentRenderJob, SceneFragmentRenderCompletion), + mask: Option<(SceneFragmentRenderJob, SceneFragmentRenderCompletion)>, +} + +const MAX_ANALYTIC_EFFECT_CLIPS: usize = 4; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct AnalyticClipSet { + clips: [Option; MAX_ANALYTIC_EFFECT_CLIPS], +} + +impl AnalyticClipSet { + fn len(self) -> usize { + self.clips.iter().filter(|clip| clip.is_some()).count() + } + + fn scissor_rect( + self, + effective_scale: f64, + size: wgpu::Extent3d, + ) -> Option<(u32, u32, u32, u32)> { + let mut bounds: Option = None; + for clip in self.clips.into_iter().flatten() { + let rect = clip + .inverse_transform + .inverse() + .transform_rect_bbox(clip.rect); + bounds = Some(match bounds { + Some(bounds) => bounds.intersect(rect), + None => rect, + }); + } + let bounds = bounds?; + let x0 = (bounds.x0 * effective_scale) + .floor() + .clamp(0.0, f64::from(size.width)) as u32; + let y0 = (bounds.y0 * effective_scale) + .floor() + .clamp(0.0, f64::from(size.height)) as u32; + let x1 = (bounds.x1 * effective_scale) + .ceil() + .clamp(0.0, f64::from(size.width)) as u32; + let y1 = (bounds.y1 * effective_scale) + .ceil() + .clamp(0.0, f64::from(size.height)) as u32; + let width = x1.saturating_sub(x0); + let height = y1.saturating_sub(y0); + (width > 0 && height > 0).then_some((x0, y0, width, height)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct AnalyticClip { + rect: Rect, + radii: peniko::kurbo::RoundedRectRadii, + inverse_transform: Affine, +} + +#[derive(Default)] +struct EffectClip { + analytic: AnalyticClipSet, + mask_scene: Option, +} + +fn merge_frame_changes(target: &mut FrameChanges, source: FrameChanges) { + target.transforms.extend(source.transforms); + target.opacities.extend(source.opacities); + target.clips.extend(source.clips); + target.content.extend(source.content); + target.bounds.extend(source.bounds); + target.hidden.extend(source.hidden); + target.unhidden.extend(source.unhidden); + target.added.extend(source.added); + target.removed.extend(source.removed); + target.topology_changed |= source.topology_changed; +} + +fn publication_for_frame( + frame: &subduction::wgpu::SubmittedSurfaceFrame, +) -> Option<(subduction::SubmittedContentInfo, subduction::ResourceKey)> { + publication_for_frame_surface(frame, frame.opportunity.surface_id) +} + +fn publication_for_frame_surface( + frame: &subduction::wgpu::SubmittedSurfaceFrame, + surface_id: SurfaceId, +) -> Option<(subduction::SubmittedContentInfo, subduction::ResourceKey)> { + let resource_key = frame.resource_key?; + Some(( + subduction::SubmittedContentInfo { + surface_id, + revision: subduction_render::SurfaceContentRevision(frame.opportunity.frame_index), + width: frame.size.width, + height: frame.size.height, + }, + subduction::ResourceKey(resource_key), + )) +} + +fn compositor_surface_content_is_publishable(content: &CompositorSurfaceContent) -> bool { + match content { + CompositorSurfaceContent::Texture(texture) => texture + .payload + .downcast_ref::() + .is_some_and(|frame| frame.resource_key.is_some()), + CompositorSurfaceContent::NativeLayer(_) => true, + CompositorSurfaceContent::Empty + | CompositorSurfaceContent::Image(_) + | CompositorSurfaceContent::Subduction(_) => false, + } +} + +pub(crate) struct CompositorCaptureScene { + pub(crate) scene: Scene, + pub(crate) resources: ExternalImageResources, +} + +#[derive(Clone, Debug)] +struct ExternalTextureContent { + texture: wgpu::Texture, + view: wgpu::TextureView, + format: wgpu::TextureFormat, + size: wgpu::Extent3d, +} + +impl ExternalTextureContent { + fn from_submitted_frame(frame: subduction::wgpu::SubmittedSurfaceFrame) -> Self { + Self { + texture: frame.texture.clone(), + view: frame.view.clone(), + format: frame.format, + size: frame.size, + } + } + + fn from_external_texture(texture: &ExternalTexture) -> Option { + let frame = texture + .payload + .downcast_ref::()?; + Some(Self { + texture: frame.texture.clone(), + view: frame.view.clone(), + format: frame.format, + size: frame.size, + }) + } +} + +fn imaging_brush_from_background(brush: Brush, bounds: Rect) -> Result { + match brush { + Brush::Solid(color) => Ok(ImagingBrush::Solid(color)), + Brush::Gradient(gradient) => Ok(ImagingBrush::Gradient( + gradient.to_peniko(bounds, &crate::unit::FontSizeCx::new(14.0, 16.0)), + )), + Brush::Image(image_brush) => { + let peniko::ImageBrush { image, sampler } = image_brush.brush; + match image { + Image::Raster(image) => Ok(ImagingBrush::Image(ImageBrush(peniko::ImageBrush { + image: imaging::Image::Raster(image.image), + sampler, + }))), + Image::Scene(image) => Ok(ImagingBrush::Image(ImageBrush(peniko::ImageBrush { + image: imaging::Image::Scene(image.image), + sampler, + }))), + Image::Surface(_) => { + Err("capture background surface image brushes are not supported".to_string()) + } + Image::Source(_) => { + Err("capture background shader-source brushes are not supported".to_string()) + } + } + } + } +} + +fn append_texture_layer( + scene: &mut Scene, + resources: &mut ExternalImageResources, + next_image_id: &mut u64, + content: &ExternalTextureContent, + transform: Affine, + logical_bounds: Rect, + opacity: f32, + blend_mode: BlendMode, + effective_scale: f64, +) { + let width = content.size.width.max(1); + let height = content.size.height.max(1); + let image_id = ExternalImageId(*next_image_id); + *next_image_id += 1; + resources.insert( + image_id, + ResolvedExternalImage { + texture: content.texture.clone(), + view: content.view.clone(), + format: content.format, + width, + height, + }, + ); + + let origin = (transform * logical_bounds.origin()).to_vec2() * effective_scale; + let target_width = logical_bounds.width() * effective_scale; + let target_height = logical_bounds.height() * effective_scale; + let image = ExternalImage::new(image_id, width, height, ImageAlphaType::AlphaPremultiplied); + let brush = ImagingBrush::Image(ImageBrush::from(image).with_alpha(opacity)); + scene.draw(Draw::Fill { + transform: Affine::new([ + target_width / f64::from(width), + 0.0, + 0.0, + target_height / f64::from(height), + origin.x, + origin.y, + ]), + fill_rule: Fill::NonZero, + brush, + brush_transform: None, + shape: Geometry::Rect(Rect::new(0.0, 0.0, f64::from(width), f64::from(height))), + composite: Composite::new(blend_mode, 1.0), + }); +} + +fn normalized_user_scale(user_scale: f64) -> f64 { + if user_scale.is_finite() && user_scale > 0.0 { + user_scale + } else { + 1.0 + } +} + +fn presentation_transform(transform: Affine, bounds: Rect, user_scale: f64) -> Transform3d { + let user_scale = normalized_user_scale(user_scale); + let transform = + Affine::scale(user_scale) * transform * Affine::translate(bounds.origin().to_vec2()); + transform3d_from_affine(transform) +} + +fn transform3d_from_affine(transform: Affine) -> Transform3d { + let [a, b, c, d, e, f] = transform.as_coeffs(); + Transform3d::from_cols( + [a, b, 0.0, 0.0], + [c, d, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [e, f, 0.0, 1.0], + ) +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SceneRenderSignature { + content_revision: u64, + command_count: usize, + bounds: Rect, + content_bounds: Option, + render_transform: Affine, + clip: Option, + effective_scale_bits: u64, + format: wgpu::TextureFormat, + target_size: wgpu::Extent3d, + external_versions: Vec<(CompositorSurfaceId, u64)>, + color_filter_hashes: Vec, +} + +fn scene_render_signature( + layer: &SceneLayer, + compositor_surfaces: &FxHashMap, + effective_scale: f64, + format: wgpu::TextureFormat, + target_size: wgpu::Extent3d, + render_transform: Affine, + frame_index: u64, +) -> SceneRenderSignature { + SceneRenderSignature { + content_revision: layer.content_revision, + command_count: layer.scene.commands().len(), + bounds: layer.bounds, + content_bounds: layer.content_bounds, + render_transform, + clip: layer.clip, + effective_scale_bits: effective_scale.to_bits(), + format, + target_size, + external_versions: layer + .external_images + .iter() + .map(|image| { + ( + image.surface_id, + compositor_surfaces + .get(&image.surface_id) + .map(|entry| entry.version) + .unwrap_or(0), + ) + }) + .collect(), + color_filter_hashes: layer + .color_filters + .iter() + .map(|effect| compositor_effect_dependency_hash(effect, frame_index)) + .collect(), + } +} + +#[derive(Default)] +struct ShaderRenderer { + pipelines: FxHashMap, + intermediate_textures: FxHashMap, +} + +impl ShaderRenderer { + fn intermediate_texture( + &mut self, + device: &wgpu::Device, + role: EffectIntermediateTextureRole, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + label: &'static str, + ) -> wgpu::Texture { + let key = EffectIntermediateTextureKey { + width: size.width, + height: size.height, + format, + }; + let entry = + self.intermediate_textures + .entry(role) + .or_insert_with(|| EffectIntermediateTexture { + key, + texture: create_effect_intermediate_texture(device, size, format, label), + }); + if entry.key != key { + *entry = EffectIntermediateTexture { + key, + texture: create_effect_intermediate_texture(device, size, format, label), + }; + } + entry.texture.clone() + } + + fn render_effect_chain( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + render_call_id: u64, + key: &CompositionKey, + input: &wgpu::Texture, + output: &wgpu::Texture, + clip_mask: Option<&wgpu::Texture>, + analytic_clip: AnalyticClipSet, + format: wgpu::TextureFormat, + size: wgpu::Extent3d, + effects: &[CompositorShaderPass], + frame_index: u64, + effective_scale: f64, + ) -> Result, String> { + let Some((last, leading)) = effects.split_last() else { + return Ok(None); + }; + if crate::frame_source::frame_pacing_diag_enabled() { + crate::floem_debug_log!( + "floem compositor color filter chain call={} key={:?} revision={} shaders={} size={}x{}", + render_call_id, + key, + frame_index, + effects.len(), + size.width, + size.height, + ); + } + + let mut input_texture = input.clone(); + let mut ping_pong = Vec::new(); + if !leading.is_empty() { + ping_pong.push(self.intermediate_texture( + device, + EffectIntermediateTextureRole::Ping, + size, + format, + "floem compositor color filter ping", + )); + if leading.len() > 1 { + ping_pong.push(self.intermediate_texture( + device, + EffectIntermediateTextureRole::Pong, + size, + format, + "floem compositor color filter pong", + )); + } + } + + let encoder_label = format!( + "floem compositor color filter chain encoder call={render_call_id} key={key:?} revision={frame_index} passes={}", + effects.len() + ); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some(&encoder_label), + }); + + for (index, effect) in leading.iter().enumerate() { + let output_texture = &ping_pong[index % ping_pong.len()]; + self.encode_single_effect( + device, + queue, + &mut encoder, + render_call_id, + key, + index, + effects.len(), + &input_texture, + output_texture, + None, + AnalyticClipSet::default(), + format, + size, + effect, + frame_index, + effective_scale, + )?; + input_texture = output_texture.clone(); + } + + self.encode_single_effect( + device, + queue, + &mut encoder, + render_call_id, + key, + leading.len(), + effects.len(), + &input_texture, + output, + clip_mask, + analytic_clip, + format, + size, + last, + frame_index, + effective_scale, + )?; + Ok(Some(queue.submit([encoder.finish()]))) + } + + fn encode_single_effect( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + render_call_id: u64, + key: &CompositionKey, + pass_index: usize, + pass_count: usize, + input: &wgpu::Texture, + output: &wgpu::Texture, + clip_mask: Option<&wgpu::Texture>, + analytic_clip: AnalyticClipSet, + format: wgpu::TextureFormat, + size: wgpu::Extent3d, + effect: &CompositorShaderPass, + frame_index: u64, + effective_scale: f64, + ) -> Result<(), String> { + let pipeline = self.pipeline(device, format, &effect.shader)?; + let input_view = input.create_view(&wgpu::TextureViewDescriptor { + label: Some("floem compositor color filter input view"), + ..Default::default() + }); + let output_view = output.create_view(&wgpu::TextureViewDescriptor { + label: Some("floem compositor color filter output view"), + ..Default::default() + }); + let clip_mask_view = clip_mask + .unwrap_or(input) + .create_view(&wgpu::TextureViewDescriptor { + label: Some("floem compositor color filter clip mask view"), + ..Default::default() + }); + let args = padded_uniform_bytes(&effect_args(&effect.shader)); + let args_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("floem compositor color filter args"), + size: args.len() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&args_buffer, 0, &args); + let frame_bytes = color_filter_frame_bytes( + effective_scale, + size, + effect.position_transform, + clip_mask.is_some(), + analytic_clip, + ); + let frame_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("floem compositor color filter frame"), + size: frame_bytes.len() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&frame_buffer, 0, &frame_bytes); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("floem compositor color filter bind group"), + layout: &pipeline.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&input_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&pipeline.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: args_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: frame_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&clip_mask_view), + }, + ], + }); + let pass_number = pass_index + 1; + let pass_label = format!( + "floem compositor color filter pass call={render_call_id} key={key:?} revision={frame_index} pass={pass_number}/{pass_count}" + ); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&pass_label), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &output_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&pipeline.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.set_viewport(0.0, 0.0, size.width as f32, size.height as f32, 0.0, 1.0); + if clip_mask.is_none() + && let Some((x, y, width, height)) = + analytic_clip.scissor_rect(effective_scale, size) + { + pass.set_scissor_rect(x, y, width, height); + } + pass.draw(0..3, 0..1); + } + Ok(()) + } + + fn pipeline( + &mut self, + device: &wgpu::Device, + format: wgpu::TextureFormat, + effect: &CompositorShader, + ) -> Result<&ShaderPipeline, String> { + let shader_hash = color_filter_shader_hash(effect); + let key = ShaderPipelineKey { + shader_hash, + format, + }; + if !self.pipelines.contains_key(&key) { + let pipeline = ShaderPipeline::new(device, format, effect)?; + self.pipelines.insert(key, pipeline); + } + self.pipelines + .get(&key) + .ok_or_else(|| "failed to cache color filter pipeline".to_owned()) + } +} + +struct ShaderPipeline { + pipeline: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, +} + +impl ShaderPipeline { + fn new( + device: &wgpu::Device, + format: wgpu::TextureFormat, + effect: &CompositorShader, + ) -> Result { + let shader_source = color_filter_shader_source(effect)?; + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(color_filter_label(effect)), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("floem compositor color filter bind group layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(16), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new( + 16 * (4 + MAX_ANALYTIC_EFFECT_CLIPS as u64 * 4), + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("floem compositor color filter pipeline layout"), + bind_group_layouts: &[&bind_group_layout], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(color_filter_label(effect)), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("floem compositor color filter sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + ..Default::default() + }); + Ok(Self { + pipeline, + bind_group_layout, + sampler, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct ShaderPipelineKey { + shader_hash: u64, + format: wgpu::TextureFormat, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum EffectIntermediateTextureRole { + Ping, + Pong, +} + +struct EffectIntermediateTexture { + key: EffectIntermediateTextureKey, + texture: wgpu::Texture, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct EffectIntermediateTextureKey { + width: u32, + height: u32, + format: wgpu::TextureFormat, +} + +#[derive(Clone, Debug)] +struct TrackedEffectTexture { + key: EffectIntermediateTextureKey, + texture: wgpu::Texture, +} + +fn create_effect_intermediate_texture( + device: &wgpu::Device, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + label: &'static str, +) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) +} + +fn create_assumed_initialized_external_writer_texture( + device: &wgpu::Device, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + label: &'static str, +) -> wgpu::Texture { + create_assumed_initialized_external_writer_texture_inner(device, size, format, label) + .unwrap_or_else(|| create_effect_intermediate_texture(device, size, format, label)) +} + +#[cfg(target_os = "macos")] +fn create_assumed_initialized_external_writer_texture_inner( + device: &wgpu::Device, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + label: &'static str, +) -> Option { + let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; + let hal_usage = wgpu::TextureUses::COLOR_TARGET | wgpu::TextureUses::RESOURCE; + let hal_desc = wgpu::hal::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: hal_usage, + memory_flags: wgpu::hal::MemoryFlags::empty(), + view_formats: Vec::new(), + }; + let texture_desc = wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }; + // SAFETY: The HAL texture is created from this wgpu device's Metal handle and + // with a descriptor matching the wgpu descriptor below. `create_texture_from_hal` + // marks the texture initialized in wgpu-core; this is valid for Floem's + // external-writer path because the imaging renderer fully overwrites the + // texture before any later wgpu sampling/rendering observes it. + unsafe { + let hal_device = device.as_hal::()?; + let hal_texture = wgpu::hal::Device::create_texture(&*hal_device, &hal_desc).ok()?; + Some(device.create_texture_from_hal::(hal_texture, &texture_desc)) + } +} + +#[cfg(not(target_os = "macos"))] +fn create_assumed_initialized_external_writer_texture_inner( + _device: &wgpu::Device, + _size: wgpu::Extent3d, + _format: wgpu::TextureFormat, + _label: &'static str, +) -> Option { + None +} + +fn classify_effect_clips(effects: &[CompositorShaderPass], render_size: Size) -> EffectClip { + let clips = effects + .iter() + .filter_map(|effect| effect.clip.clone()) + .collect::>(); + if clips.is_empty() { + return EffectClip::default(); + } + + let analytic = analytic_clip_set(&clips); + if analytic.is_some() { + return EffectClip { + analytic: analytic.unwrap_or_default(), + mask_scene: None, + }; + } + + EffectClip { + analytic: AnalyticClipSet::default(), + mask_scene: effect_clip_mask_scene_from_clips(&clips, render_size), + } +} + +fn transform_compositor_shader_passes( + effects: &[CompositorShaderPass], + transform: Affine, +) -> Vec { + effects + .iter() + .cloned() + .map(|mut effect| { + if let Some(clip) = &mut effect.clip { + prepend_clip_transform(clip, transform); + } + effect + }) + .collect() +} + +fn prepend_clip_transform(clip: &mut Clip, prefix: Affine) { + match clip { + Clip::Fill { transform, .. } | Clip::Stroke { transform, .. } => { + *transform = prefix * *transform; + } + } +} + +fn analytic_clip_set(clips: &[Clip]) -> Option { + if clips.len() > MAX_ANALYTIC_EFFECT_CLIPS { + return None; + } + let mut out = AnalyticClipSet::default(); + for (index, clip) in clips.iter().enumerate() { + out.clips[index] = analytic_clip(clip)?; + } + Some(out) +} + +fn analytic_clip(clip: &Clip) -> Option> { + let Clip::Fill { + transform, + shape, + fill_rule: Fill::NonZero, + } = clip + else { + return None; + }; + let rounded = match shape { + Geometry::Rect(rect) => rect.to_rounded_rect(0.0), + Geometry::RoundedRect(rounded) => *rounded, + Geometry::Path(_) => return None, + }; + Some(Some(AnalyticClip { + rect: rounded.rect(), + radii: rounded.radii(), + inverse_transform: transform.inverse(), + })) +} + +fn effect_clip_mask_scene_from_clips(clips: &[Clip], render_size: Size) -> Option { + if clips.is_empty() { + return None; + } + + let mut scene = Scene::new(); + for clip in clips { + scene.push_clip(clip.clone()); + } + let _ = scene.draw(Draw::Fill { + transform: Affine::IDENTITY, + fill_rule: Fill::NonZero, + brush: ImagingBrush::Solid(peniko::Color::WHITE), + brush_transform: None, + shape: Geometry::Rect(Rect::from_origin_size( + peniko::kurbo::Point::ZERO, + render_size, + )), + composite: Composite::default(), + }); + for _ in clips { + scene.pop_clip(); + } + Some(scene) +} + +fn color_filter_shader_hash(effect: &CompositorShader) -> u64 { + let mut hasher = DefaultHasher::new(); + match effect { + CompositorShader::Color(effect) => { + "color".hash(&mut hasher); + match &effect.shader { + crate::effects::ColorFilterShader::Wgsl { + label, + fragment_body, + } => { + label.hash(&mut hasher); + fragment_body.hash(&mut hasher); + } + } + format!("{:?}", effect.color_space).hash(&mut hasher); + } + CompositorShader::Layer(effect) => { + "layer".hash(&mut hasher); + match &effect.shader { + crate::effects::LayerFilterShader::Wgsl { + label, + fragment_body, + } => { + label.hash(&mut hasher); + fragment_body.hash(&mut hasher); + } + } + format!("{:?}", effect.color_space).hash(&mut hasher); + } + CompositorShader::Source(effect) => { + "source".hash(&mut hasher); + match &effect.shader { + crate::effects::ShaderSourceShader::Wgsl { + label, + fragment_body, + } => { + label.hash(&mut hasher); + fragment_body.hash(&mut hasher); + } + } + format!("{:?}", effect.color_space).hash(&mut hasher); + } + } + hasher.finish() +} + +fn compositor_effect_dependency_hash(effect: &CompositorShaderPass, _frame_index: u64) -> u64 { + let mut hasher = DefaultHasher::new(); + color_filter_shader_hash(&effect.shader).hash(&mut hasher); + effect_args(&effect.shader).hash(&mut hasher); + format!("{:?}", effect.clip).hash(&mut hasher); + hash_affine_for_effect(&mut hasher, effect.position_transform); + match &effect.shader { + CompositorShader::Color(effect) => effect.args.revision().hash(&mut hasher), + CompositorShader::Layer(effect) => effect.args.revision().hash(&mut hasher), + CompositorShader::Source(effect) => effect.args.revision().hash(&mut hasher), + } + hasher.finish() +} + +fn hash_affine_for_effect(hasher: &mut DefaultHasher, transform: Affine) { + for value in transform.as_coeffs() { + value.to_bits().hash(hasher); + } +} + +fn effect_args(effect: &CompositorShader) -> Vec { + match effect { + CompositorShader::Color(effect) => effect.args.bytes(), + CompositorShader::Layer(effect) => effect.args.bytes(), + CompositorShader::Source(effect) => effect.args.bytes(), + } +} + +fn color_filter_label(effect: &CompositorShader) -> &str { + match effect { + CompositorShader::Color(effect) => match &effect.shader { + crate::effects::ColorFilterShader::Wgsl { label, .. } => { + label.as_deref().unwrap_or("floem compositor color filter") + } + }, + CompositorShader::Layer(effect) => match &effect.shader { + crate::effects::LayerFilterShader::Wgsl { label, .. } => { + label.as_deref().unwrap_or("floem compositor layer filter") + } + }, + CompositorShader::Source(effect) => match &effect.shader { + crate::effects::ShaderSourceShader::Wgsl { label, .. } => { + label.as_deref().unwrap_or("floem compositor shader source") + } + }, + } +} + +fn color_filter_shader_source(effect: &CompositorShader) -> Result { + let fragment_body = match effect { + CompositorShader::Color(effect) => match &effect.shader { + crate::effects::ColorFilterShader::Wgsl { fragment_body, .. } => fragment_body, + }, + CompositorShader::Layer(effect) => match &effect.shader { + crate::effects::LayerFilterShader::Wgsl { fragment_body, .. } => fragment_body, + }, + CompositorShader::Source(effect) => match &effect.shader { + crate::effects::ShaderSourceShader::Wgsl { fragment_body, .. } => fragment_body, + }, + }; + let effect_function = match effect { + CompositorShader::Color(_) => format!( + r#" +fn color_filter( + position: vec2, + uv: vec2, + color: vec4, + args: ShaderArgs, + frame: ShaderFrame, +) -> vec4 {{ +{} +}} +"#, + fragment_body + ), + CompositorShader::Layer(_) => format!( + r#" +fn layer_filter( + position: vec2, + uv: vec2, + color: vec4, + args: ShaderArgs, + frame: ShaderFrame, +) -> vec4 {{ +{} +}} +"#, + fragment_body + ), + CompositorShader::Source(_) => format!( + r#" +fn shader_source( + position: vec2, + uv: vec2, + args: ShaderArgs, + frame: ShaderFrame, +) -> vec4 {{ +{} +}} +"#, + fragment_body + ), + }; + let (input_texture_name, input_sampler_name) = match effect { + CompositorShader::Layer(_) => ("input_texture", "input_sampler"), + CompositorShader::Color(_) | CompositorShader::Source(_) => ( + "floem_internal_input_texture", + "floem_internal_input_sampler", + ), + }; + let fragment_return = match effect { + CompositorShader::Color(_) => format!( + "let color = textureSample({input_texture_name}, {input_sampler_name}, in.uv);\n let filtered = color_filter(logical_position, in.uv, color, args, frame);\n return filtered * effect_clip_coverage(target_position, in.uv);" + ), + CompositorShader::Layer(_) => format!( + "let color = textureSample({input_texture_name}, {input_sampler_name}, in.uv);\n let filtered = layer_filter(logical_position, in.uv, color, args, frame);\n return filtered * effect_clip_coverage(target_position, in.uv);" + ), + CompositorShader::Source(_) => format!( + "let color = textureSample({input_texture_name}, {input_sampler_name}, in.uv);\n return shader_source(logical_position, in.uv, args, frame) * color.a * effect_clip_coverage(target_position, in.uv);" + ), + }; + Ok(format!( + r#" +struct ShaderArgs {{ + data: vec4, +}}; + +struct ShaderFrame {{ + effective_scale: f32, + target_width: f32, + target_height: f32, + clip_mask_enabled: f32, + position_transform0: vec4, + position_transform1: vec4, + clip_count: vec4, + clip0_rect: vec4, + clip0_radii: vec4, + clip0_inv0: vec4, + clip0_inv1: vec4, + clip1_rect: vec4, + clip1_radii: vec4, + clip1_inv0: vec4, + clip1_inv1: vec4, + clip2_rect: vec4, + clip2_radii: vec4, + clip2_inv0: vec4, + clip2_inv1: vec4, + clip3_rect: vec4, + clip3_radii: vec4, + clip3_inv0: vec4, + clip3_inv1: vec4, +}}; + +@group(0) @binding(0) var {input_texture_name}: texture_2d; +@group(0) @binding(1) var {input_sampler_name}: sampler; +@group(0) @binding(2) var args: ShaderArgs; +@group(0) @binding(3) var frame: ShaderFrame; +@group(0) @binding(4) var clip_mask_texture: texture_2d; + +struct VsOut {{ + @builtin(position) position: vec4, + @location(0) uv: vec2, +}}; + +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {{ + var out: VsOut; + let x = f32(i32(vi & 1u)) * 4.0 - 1.0; + let y = f32(i32(vi >> 1u)) * 4.0 - 1.0; + out.position = vec4(x, y, 0.0, 1.0); + out.uv = vec2(x, -y) * 0.5 + vec2(0.5, 0.5); + return out; +}} + +fn analytic_clip_local_position(position: vec2, inv0: vec4, inv1: vec4) -> vec2 {{ + return vec2( + inv0.x * position.x + inv0.z * position.y + inv1.x, + inv0.y * position.x + inv0.w * position.y + inv1.y, + ); +}} + +fn analytic_rounded_rect_coverage(position: vec2, rect: vec4, radii: vec4) -> f32 {{ + let center = (rect.xy + rect.zw) * 0.5; + let half_size = max((rect.zw - rect.xy) * 0.5, vec2(0.0, 0.0)); + let p = position - center; + let radius = select( + select(radii.y, radii.z, p.y >= 0.0), + select(radii.x, radii.w, p.y >= 0.0), + p.x < 0.0, + ); + let q = abs(p) - max(half_size - vec2(radius, radius), vec2(0.0, 0.0)); + let d = length(max(q, vec2(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - radius; + return clamp(0.5 - d * frame.effective_scale, 0.0, 1.0); +}} + +fn analytic_clip_coverage_for( + position: vec2, + index: f32, + rect: vec4, + radii: vec4, + inv0: vec4, + inv1: vec4, +) -> f32 {{ + if (frame.clip_count.x <= index) {{ + return 1.0; + }} + let local = analytic_clip_local_position(position, inv0, inv1); + return analytic_rounded_rect_coverage(local, rect, radii); +}} + +fn effect_clip_coverage(position: vec2, uv: vec2) -> f32 {{ + let mask = mix(1.0, textureSample(clip_mask_texture, {input_sampler_name}, uv).a, frame.clip_mask_enabled); + return mask + * analytic_clip_coverage_for(position, 0.0, frame.clip0_rect, frame.clip0_radii, frame.clip0_inv0, frame.clip0_inv1) + * analytic_clip_coverage_for(position, 1.0, frame.clip1_rect, frame.clip1_radii, frame.clip1_inv0, frame.clip1_inv1) + * analytic_clip_coverage_for(position, 2.0, frame.clip2_rect, frame.clip2_radii, frame.clip2_inv0, frame.clip2_inv1) + * analytic_clip_coverage_for(position, 3.0, frame.clip3_rect, frame.clip3_radii, frame.clip3_inv0, frame.clip3_inv1); +}} + +{} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 {{ + let target_position = in.position.xy / vec2(frame.effective_scale); + let logical_position = vec2( + frame.position_transform0.x * target_position.x + frame.position_transform0.z * target_position.y + frame.position_transform1.x, + frame.position_transform0.y * target_position.x + frame.position_transform0.w * target_position.y + frame.position_transform1.y, + ); + {} +}} +"#, + effect_function, fragment_return + )) +} + +fn padded_uniform_bytes(bytes: &[u8]) -> Vec { + let len = bytes.len().max(16).next_multiple_of(16); + let mut padded = vec![0; len]; + padded[..bytes.len()].copy_from_slice(bytes); + padded +} + +fn color_filter_frame_bytes( + effective_scale: f64, + target_size: wgpu::Extent3d, + position_transform: Affine, + clip_mask_enabled: bool, + analytic_clip: AnalyticClipSet, +) -> Vec { + let effective_scale = effective_scale as f32; + let target_width = target_size.width as f32 / effective_scale; + let target_height = target_size.height as f32 / effective_scale; + let mut bytes = Vec::with_capacity(16 * (4 + MAX_ANALYTIC_EFFECT_CLIPS * 4)); + push_vec4( + &mut bytes, + [ + effective_scale, + target_width, + target_height, + u32::from(clip_mask_enabled) as f32, + ], + ); + let [a, b, c, d, e, f] = position_transform.as_coeffs(); + push_vec4(&mut bytes, [a as f32, b as f32, c as f32, d as f32]); + push_vec4(&mut bytes, [e as f32, f as f32, 0.0, 0.0]); + push_vec4(&mut bytes, [analytic_clip.len() as f32, 0.0, 0.0, 0.0]); + for clip in analytic_clip.clips { + if let Some(clip) = clip { + let rect = clip.rect; + let radii = clip.radii; + let [a, b, c, d, e, f] = clip.inverse_transform.as_coeffs(); + push_vec4( + &mut bytes, + [ + rect.x0 as f32, + rect.y0 as f32, + rect.x1 as f32, + rect.y1 as f32, + ], + ); + push_vec4( + &mut bytes, + [ + radii.top_left as f32, + radii.top_right as f32, + radii.bottom_right as f32, + radii.bottom_left as f32, + ], + ); + push_vec4(&mut bytes, [a as f32, b as f32, c as f32, d as f32]); + push_vec4(&mut bytes, [e as f32, f as f32, 0.0, 0.0]); + } else { + for _ in 0..4 { + push_vec4(&mut bytes, [0.0; 4]); + } + } + } + bytes +} + +fn push_vec4(bytes: &mut Vec, values: [f32; 4]) { + for value in values { + bytes.extend_from_slice(&value.to_ne_bytes()); + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum UnsupportedPublication { + Scene { + key: CompositionKey, + revision: u64, + }, + SceneEffect { + key: CompositionKey, + revision: u64, + }, + SceneExternalTexture { + key: CompositionKey, + revision: u64, + surface_id: CompositorSurfaceId, + }, + ExternalTexture { + key: CompositionKey, + version: u64, + }, +} + +#[derive(Clone, Debug)] +pub(crate) enum CompositorLayerState { + Scene(SceneCompositorLayer), + CompositorSurface(CompositorSurfaceCompositorLayer), +} + +impl CompositorLayerState { + fn key(&self) -> &CompositionKey { + match self { + Self::Scene(layer) => &layer.key, + Self::CompositorSurface(layer) => &layer.key, + } + } + + fn equivalent(&self, other: &Self) -> bool { + match (self, other) { + (Self::Scene(a), Self::Scene(b)) => a == b, + (Self::CompositorSurface(a), Self::CompositorSurface(b)) => a.equivalent(b), + _ => false, + } + } + + fn from_item( + item: &CompositionItem, + compositor_surfaces: &FxHashMap, + ) -> Self { + match item { + CompositionItem::Scene(layer) => { + Self::Scene(SceneCompositorLayer::from_layer(layer, compositor_surfaces)) + } + CompositionItem::CompositorSurface(layer) => Self::CompositorSurface( + CompositorSurfaceCompositorLayer::from_layer(layer, compositor_surfaces), + ), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SceneCompositorLayer { + pub key: CompositionKey, + pub source_element_id: Option, + pub debug_name: Option, + pub external_images: Vec, + pub color_filters: Vec, + pub transform: peniko::kurbo::Affine, + pub clip: Option, + pub bounds: peniko::kurbo::Rect, + pub content_bounds: Option, + pub opacity: f32, + pub blend_mode: BlendMode, + pub content_revision: u64, + pub command_count: usize, + pub promoted: bool, + pub frame_rate: Option, +} + +impl SceneCompositorLayer { + fn from_layer( + layer: &SceneLayer, + compositor_surfaces: &FxHashMap, + ) -> Self { + Self { + key: layer.key.clone(), + source_element_id: layer.source_element_id, + debug_name: layer.debug_name.clone(), + external_images: layer + .external_images + .iter() + .map(|image| { + SceneExternalImageCompositorLayer::from_image(image, compositor_surfaces) + }) + .collect(), + color_filters: layer.color_filters.clone(), + transform: layer.transform, + clip: layer.clip, + bounds: layer.bounds, + content_bounds: layer.content_bounds, + opacity: layer.opacity, + blend_mode: layer.blend_mode, + content_revision: layer.content_revision, + command_count: layer.scene.commands().len(), + promoted: layer.promoted, + frame_rate: layer.frame_rate, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct SceneExternalImageCompositorLayer { + pub image_id: imaging::ExternalImageId, + pub surface_id: CompositorSurfaceId, + pub content: CompositorSurfaceContent, + pub content_version: u64, +} + +impl PartialEq for SceneExternalImageCompositorLayer { + fn eq(&self, other: &Self) -> bool { + self.image_id == other.image_id + && self.surface_id == other.surface_id + && self.content_version == other.content_version + && external_content_key(&self.content) == external_content_key(&other.content) + } +} + +impl SceneExternalImageCompositorLayer { + fn from_image( + image: &SceneExternalImage, + compositor_surfaces: &FxHashMap, + ) -> Self { + Self { + image_id: image.image_id, + surface_id: image.surface_id, + content: compositor_surfaces + .get(&image.surface_id) + .map(|entry| entry.content.clone()) + .unwrap_or(CompositorSurfaceContent::Empty), + content_version: compositor_surfaces + .get(&image.surface_id) + .map(|entry| entry.version) + .unwrap_or(0), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct CompositorSurfaceCompositorLayer { + pub key: CompositionKey, + pub surface_id: CompositorSurfaceId, + pub rect: peniko::kurbo::Rect, + pub source_size: peniko::kurbo::Size, + pub transform: peniko::kurbo::Affine, + pub clip: Option, + pub opacity: f32, + pub blend_mode: BlendMode, + pub content: CompositorSurfaceContent, + pub content_version: u64, + pub presents_without_transaction: bool, + pub has_provider: bool, + pub frame_rate: FrameRatePreference, +} + +impl CompositorSurfaceCompositorLayer { + fn from_layer( + layer: &CompositorSurfaceLayer, + compositor_surfaces: &FxHashMap, + ) -> Self { + Self { + key: layer.key.clone(), + surface_id: layer.surface_id, + rect: layer.rect, + source_size: layer.source_size, + transform: layer.transform, + clip: layer.clip, + opacity: layer.opacity, + blend_mode: layer.blend_mode, + content: compositor_surfaces + .get(&layer.surface_id) + .map(|entry| entry.content.clone()) + .unwrap_or(CompositorSurfaceContent::Empty), + content_version: compositor_surfaces + .get(&layer.surface_id) + .map(|entry| entry.version) + .unwrap_or(0), + presents_without_transaction: compositor_surfaces + .get(&layer.surface_id) + .is_some_and(|entry| entry.presents_without_transaction), + has_provider: compositor_surfaces + .get(&layer.surface_id) + .is_some_and(|entry| entry.provider.is_some()), + frame_rate: compositor_surfaces + .get(&layer.surface_id) + .map(|entry| entry.frame_rate) + .unwrap_or_else(FrameRatePreference::full), + } + } + + fn equivalent(&self, other: &Self) -> bool { + self.key == other.key + && self.surface_id == other.surface_id + && self.rect == other.rect + && self.source_size == other.source_size + && self.transform == other.transform + && self.clip == other.clip + && self.opacity == other.opacity + && self.content_version == other.content_version + && self.presents_without_transaction == other.presents_without_transaction + && self.has_provider == other.has_provider + && self.frame_rate == other.frame_rate + && external_content_key(&self.content) == external_content_key(&other.content) + } +} + +fn external_content_key(content: &CompositorSurfaceContent) -> ExternalContentKey { + match content { + CompositorSurfaceContent::Empty => ExternalContentKey::Empty, + CompositorSurfaceContent::Texture(texture) => { + ExternalContentKey::Texture { size: texture.size } + } + CompositorSurfaceContent::NativeLayer(layer) => ExternalContentKey::Subduction { + ptr: layer.identity(), + }, + CompositorSurfaceContent::Image(image) => ExternalContentKey::Image { + size: peniko::kurbo::Size::new(image.width as f64, image.height as f64), + }, + CompositorSurfaceContent::Subduction(surface) => ExternalContentKey::Subduction { + ptr: std::sync::Arc::as_ptr(surface).cast::<()>() as usize, + }, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum ExternalContentKey { + Empty, + Texture { size: peniko::kurbo::Size }, + Image { size: peniko::kurbo::Size }, + Subduction { ptr: usize }, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct CompositorDiff { + pub created: Vec, + pub updated: Vec, + pub removed: Vec, + pub order_changed: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::effects::{ColorFilter, LayerFilter, ShaderSource}; + + fn layer_with_effect(effect: CompositorShader) -> SceneLayer { + SceneLayer { + key: CompositionKey::SceneRun { run_index: 0 }, + source_element_id: None, + debug_name: None, + scene: Scene::new(), + external_images: Vec::new(), + color_filters: vec![CompositorShaderPass { + shader: effect, + clip: None, + position_transform: Affine::IDENTITY, + }], + content_revision: 1, + transform: Affine::IDENTITY, + clip: None, + bounds: Rect::new(0.0, 0.0, 100.0, 80.0), + content_bounds: None, + opacity: 1.0, + blend_mode: BlendMode::default(), + promoted: false, + frame_rate: None, + } + } + + fn extent(width: u32, height: u32) -> wgpu::Extent3d { + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + } + } + + fn f32_at(bytes: &[u8], index: usize) -> f32 { + let start = index * std::mem::size_of::(); + f32::from_ne_bytes(bytes[start..start + 4].try_into().unwrap()) + } + + #[test] + fn presentation_transform_applies_user_scale_in_layer_coordinates() { + let transform = + presentation_transform(Affine::IDENTITY, Rect::new(10.0, 20.0, 110.0, 70.0), 2.0); + + assert_eq!(transform.col(0), [2.0, 0.0, 0.0, 0.0]); + assert_eq!(transform.col(1), [0.0, 2.0, 0.0, 0.0]); + assert_eq!(transform.col(3), [20.0, 40.0, 0.0, 1.0]); + } + + #[test] + fn presentation_transform_preserves_affine_linear_part() { + let transform = presentation_transform( + Affine::translate((5.0, 7.0)) * Affine::scale_non_uniform(3.0, 4.0), + Rect::new(10.0, 20.0, 110.0, 70.0), + 2.0, + ); + + assert_eq!(transform.col(0), [6.0, 0.0, 0.0, 0.0]); + assert_eq!(transform.col(1), [0.0, 8.0, 0.0, 0.0]); + assert_eq!(transform.col(3), [70.0, 174.0, 0.0, 1.0]); + } + + #[test] + fn effect_frame_bytes_include_position_transform() { + let position_transform = Affine::new([2.0, 0.25, 0.5, 3.0, 37.5, 12.25]); + let bytes = color_filter_frame_bytes( + 2.0, + extent(200, 160), + position_transform, + false, + AnalyticClipSet::default(), + ); + + assert_eq!(f32_at(&bytes, 0), 2.0); + assert_eq!(f32_at(&bytes, 1), 100.0); + assert_eq!(f32_at(&bytes, 2), 80.0); + assert_eq!(f32_at(&bytes, 4), 2.0); + assert_eq!(f32_at(&bytes, 5), 0.25); + assert_eq!(f32_at(&bytes, 6), 0.5); + assert_eq!(f32_at(&bytes, 7), 3.0); + assert_eq!(f32_at(&bytes, 8), 37.5); + assert_eq!(f32_at(&bytes, 9), 12.25); + } + + #[test] + fn shader_position_applies_transform_before_user_effect() { + let shader = color_filter_shader_source(&CompositorShader::Layer(LayerFilter::wgsl( + "return color;", + ))) + .unwrap(); + + assert!( + shader.contains( + "let target_position = in.position.xy / vec2(frame.effective_scale);" + ) + ); + assert!(shader.contains("frame.position_transform0.x * target_position.x")); + assert!(shader.contains("frame.position_transform1.y")); + assert!(shader.contains("layer_filter(logical_position, in.uv, color, args, frame)")); + assert!(shader.contains("effect_clip_coverage(target_position, in.uv)")); + } + + #[test] + fn timeless_effect_signature_ignores_frame_index() { + let effect = ColorFilter::wgsl("return color;"); + let layer = layer_with_effect(CompositorShader::Color(effect)); + let compositor_surfaces = FxHashMap::default(); + let a = scene_render_signature( + &layer, + &compositor_surfaces, + 2.0, + wgpu::TextureFormat::Bgra8Unorm, + extent(200, 160), + Affine::IDENTITY, + 10, + ); + let b = scene_render_signature( + &layer, + &compositor_surfaces, + 2.0, + wgpu::TextureFormat::Bgra8Unorm, + extent(200, 160), + Affine::IDENTITY, + 11, + ); + + assert_eq!(a, b); + } + + #[test] + fn effect_signature_tracks_target_size() { + let effect = ColorFilter::wgsl("return color;"); + let layer = layer_with_effect(CompositorShader::Color(effect)); + let compositor_surfaces = FxHashMap::default(); + let a = scene_render_signature( + &layer, + &compositor_surfaces, + 2.0, + wgpu::TextureFormat::Bgra8Unorm, + extent(200, 160), + Affine::IDENTITY, + 10, + ); + let b = scene_render_signature( + &layer, + &compositor_surfaces, + 2.0, + wgpu::TextureFormat::Bgra8Unorm, + extent(220, 160), + Affine::IDENTITY, + 10, + ); + + assert_ne!(a, b); + } + + #[test] + fn effect_signature_tracks_explicit_args_not_frame_index() { + let base = ShaderSource::wgsl("return vec4(uv, 0.0, 1.0);").with_args(vec![1]); + let changed = ShaderSource::wgsl("return vec4(uv, 0.0, 1.0);").with_args(vec![2]); + let compositor_surfaces = FxHashMap::default(); + + let base_layer = layer_with_effect(CompositorShader::Source(base)); + let base_a = scene_render_signature( + &base_layer, + &compositor_surfaces, + 2.0, + wgpu::TextureFormat::Bgra8Unorm, + extent(200, 160), + Affine::IDENTITY, + 10, + ); + let base_b = scene_render_signature( + &base_layer, + &compositor_surfaces, + 2.0, + wgpu::TextureFormat::Bgra8Unorm, + extent(200, 160), + Affine::IDENTITY, + 11, + ); + assert_eq!(base_a, base_b); + + let changed_layer = layer_with_effect(CompositorShader::Source(changed)); + let changed_signature = scene_render_signature( + &changed_layer, + &compositor_surfaces, + 2.0, + wgpu::TextureFormat::Bgra8Unorm, + extent(200, 160), + Affine::IDENTITY, + 11, + ); + assert_ne!(base_a, changed_signature); + } +} diff --git a/src/window/compositor_surface.rs b/src/window/compositor_surface.rs new file mode 100644 index 000000000..acde03b5c --- /dev/null +++ b/src/window/compositor_surface.rs @@ -0,0 +1,826 @@ +use rustc_hash::FxHashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::{ + compositor_surface::{ + CompositorSurfaceContent, CompositorSurfaceId, CompositorSurfaceOutcome, + CompositorSurfaceProviderHandle, + }, + frame::{FrameRatePreference, FrameTime, frame_rate_due}, + gpu_resources::GpuResources, + paint::composition::{CompositionItem, CompositionPlan}, +}; +use peniko::kurbo::{Rect, Size}; +use subduction_core::layer::SurfaceId; + +use super::compositor::WindowCompositor; + +pub(crate) struct WindowCompositorSurfaces { + entries: FxHashMap, + intermediate_pool: IntermediateTexturePool, + needs_frame_pull: bool, + pending_outcomes: Vec, +} + +impl Default for WindowCompositorSurfaces { + fn default() -> Self { + Self::new(0) + } +} + +impl WindowCompositorSurfaces { + pub(crate) fn new(surface_pool_retained_extras: usize) -> Self { + Self { + entries: FxHashMap::default(), + intermediate_pool: IntermediateTexturePool::new(surface_pool_retained_extras), + needs_frame_pull: false, + pending_outcomes: Vec::new(), + } + } + + pub(crate) fn from_entries( + entries: FxHashMap, + ) -> Self { + Self { + entries, + ..Self::default() + } + } + + pub(crate) fn entries(&self) -> &FxHashMap { + &self.entries + } + + pub(crate) fn set_content( + &mut self, + surface_id: CompositorSurfaceId, + content: CompositorSurfaceContent, + ) { + let entry = self.entries.entry(surface_id).or_default(); + entry.content = content; + entry.content_dirty = true; + entry.version = entry.version.wrapping_add(1); + } + + pub(crate) fn set_provider( + &mut self, + surface_id: CompositorSurfaceId, + provider: CompositorSurfaceProviderHandle, + ) { + let entry = self.entries.entry(surface_id).or_default(); + entry.provider = Some(provider); + entry.content_dirty = true; + entry.version = entry.version.wrapping_add(1); + } + + pub(crate) fn set_frame_rate_preference( + &mut self, + surface_id: CompositorSurfaceId, + frame_rate: FrameRatePreference, + ) { + let entry = self.entries.entry(surface_id).or_default(); + entry.frame_rate = frame_rate; + entry.last_delivered_frame_index = None; + } + + pub(crate) fn set_presents_with_transaction( + &mut self, + surface_id: CompositorSurfaceId, + presents_with_transaction: bool, + ) { + let entry = self.entries.entry(surface_id).or_default(); + entry.presents_without_transaction = !presents_with_transaction; + entry.content_dirty = true; + } + + pub(crate) fn reset_pacing_state(&mut self) { + for entry in self.entries.values_mut() { + entry.last_delivered_frame_index = None; + } + self.needs_frame_pull = true; + } + + pub(crate) fn request_frame(&mut self, surface_id: CompositorSurfaceId) { + self.entries.entry(surface_id).or_default(); + self.needs_frame_pull = true; + } + + pub(crate) fn pending_frame_rate_preferences(&self) -> Vec { + self.entries + .values() + .map(|entry| entry.frame_rate) + .collect() + } + + pub(crate) fn pending_frame_rate_preference_debug( + &self, + ) -> Vec<(CompositorSurfaceId, FrameRatePreference)> { + self.entries + .iter() + .map(|(surface_id, entry)| (*surface_id, entry.frame_rate)) + .collect() + } + + pub(crate) fn update_providers( + &mut self, + plan: &CompositionPlan, + compositor: Option<&mut WindowCompositor>, + effective_scale: f64, + gpu_resources: Option<&GpuResources>, + is_resize_frame: bool, + frame_time_for_surface: &mut impl FnMut(CompositorSurfaceId) -> FrameTime, + ) -> WindowCompositorSurfaceFrameUpdate { + let mut compositor = compositor; + let mut frame_update = WindowCompositorSurfaceFrameUpdate::default(); + self.pending_outcomes.clear(); + for planned_surface in planned_compositor_surfaces(plan) { + let Some(entry) = self.entries.get_mut(&planned_surface.surface_id) else { + continue; + }; + let Some(provider) = entry.provider.clone() else { + continue; + }; + let Ok(mut provider) = provider.lock() else { + continue; + }; + let target_size = + provider.target_size(planned_surface.requested_source_size, effective_scale); + let poll_update = provider.poll_current_content(); + frame_update.request_next_frame |= poll_update.request_next_frame; + if poll_update.content_changed { + frame_update.mark_content_changed(planned_surface.surface_id); + entry.content = provider + .current_content() + .unwrap_or(CompositorSurfaceContent::Empty); + entry.content_dirty = true; + entry.version = entry.version.wrapping_add(1); + } + if let Some(latency) = poll_update.producer_observed_latency { + entry.record_producer_latency(latency); + frame_update.record_producer_latency(planned_surface.surface_id, latency); + } + let frame_time = frame_time_for_surface(planned_surface.surface_id); + if !entry.should_deliver_frame_opportunity(planned_surface.surface_id, frame_time) { + frame_update.request_next_frame = true; + continue; + } + let target = match (provider.can_accept_frame_target(), gpu_resources) { + (false, _) => None, + (true, Some(gpu_resources)) => { + let surface_id = if let Some(key) = planned_surface.key.as_ref() { + compositor + .as_deref() + .and_then(|compositor| compositor.content_surface_for_key(key)) + } else { + Some(SurfaceId(planned_surface.surface_id.get() as u32)) + }; + let Some(surface_id) = surface_id else { + continue; + }; + let size = wgpu::Extent3d { + width: target_size.width.ceil().max(1.0) as u32, + height: target_size.height.ceil().max(1.0) as u32, + depth_or_array_layers: 1, + }; + let opportunity = subduction::wgpu::SurfaceFrameOpportunity { + surface_id, + frame_index: frame_time.frame_index, + now: subduction_core::time::HostTime(0), + target_timestamp: None, + target_present: None, + previous_present: None, + refresh_interval: None, + confidence: subduction_core::timing::TimingConfidence::PacingOnly, + }; + let format = subduction::wgpu::ExternalSurfaceConfig::default().format; + let usage = wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING; + if planned_surface.key.is_some() { + let Some(compositor) = compositor.as_deref_mut() else { + continue; + }; + compositor + .create_wgpu_surface_frame( + &gpu_resources.device, + opportunity, + size, + format, + usage, + ) + .ok() + } else { + Some(create_intermediate_wgpu_surface_frame( + &mut self.intermediate_pool, + &gpu_resources.device, + opportunity, + size, + format, + usage, + )) + } + } + _ => None, + }; + let args = crate::compositor_surface::CompositorSurfaceFrameArgs { + surface_id: planned_surface.surface_id, + frame_index: frame_time.frame_index, + frame_time, + interval: frame_time.interval, + is_window_resize: is_resize_frame, + visible: true, + rect: planned_surface.rect, + size_px: target_size, + gpu_resources: gpu_resources.cloned(), + target, + previous_outcome: entry.previous_outcome, + }; + let update = provider.update_current_content(args); + frame_update.request_next_frame |= update.request_next_frame; + if update.content_changed { + frame_update.mark_content_changed(planned_surface.surface_id); + entry.content = provider + .current_content() + .unwrap_or(CompositorSurfaceContent::Empty); + entry.content_dirty = true; + entry.version = entry.version.wrapping_add(1); + } + if let Some(latency) = update.producer_observed_latency { + entry.record_producer_latency(latency); + frame_update.record_producer_latency(planned_surface.surface_id, latency); + } + let post_update = provider.poll_current_content(); + frame_update.request_next_frame |= post_update.request_next_frame; + if post_update.content_changed { + frame_update.mark_content_changed(planned_surface.surface_id); + entry.content = provider + .current_content() + .unwrap_or(CompositorSurfaceContent::Empty); + entry.content_dirty = true; + entry.version = entry.version.wrapping_add(1); + } + if let Some(latency) = post_update.producer_observed_latency { + entry.record_producer_latency(latency); + frame_update.record_producer_latency(planned_surface.surface_id, latency); + } + self.pending_outcomes.push(CompositorSurfaceOutcome { + surface_id: planned_surface.surface_id, + frame_index: frame_time.frame_index, + visible: true, + outcome: crate::frame::FrameOutcome { + draw_attempted: true, + draw_completed: false, + missed_deadline: None, + }, + }); + } + if frame_update.request_next_frame { + self.needs_frame_pull = true; + } + frame_update + } + + pub(crate) fn has_frame_pull(&self) -> bool { + self.needs_frame_pull + } + + pub(crate) fn take_frame_pull(&mut self) -> bool { + std::mem::take(&mut self.needs_frame_pull) + } + + pub(crate) fn pull_frame( + &mut self, + mut frame_time_for_surface: impl FnMut(CompositorSurfaceId) -> FrameTime, + plan: &CompositionPlan, + compositor: &mut WindowCompositor, + user_scale: f64, + effective_scale: f64, + gpu_resources: Option<&GpuResources>, + is_resize_frame: bool, + ) -> WindowCompositorSurfaceFrameUpdate { + self.take_frame_pull(); + let _composition_diff = + compositor.apply_plan(plan, &self.entries, user_scale, gpu_resources); + let update = self.update_providers( + plan, + Some(compositor), + effective_scale, + gpu_resources, + is_resize_frame, + &mut frame_time_for_surface, + ); + if update.content_changed { + let _composition_diff = + compositor.apply_plan(plan, &self.entries, user_scale, gpu_resources); + } + update + } + + pub(crate) fn release_outcomes( + &mut self, + mut update: impl FnMut(&mut CompositorSurfaceOutcome), + ) { + for mut outcome in std::mem::take(&mut self.pending_outcomes) { + update(&mut outcome); + if let Some(entry) = self.entries.get_mut(&outcome.surface_id) { + entry.previous_outcome = Some(outcome); + if let Some(provider) = &entry.provider + && let Ok(mut provider) = provider.lock() + { + provider.release_current_content(outcome); + } + } + } + } + + pub(crate) fn max_producer_work_estimate(&self) -> Duration { + self.entries + .values() + .map(|entry| entry.producer_work_estimate) + .max() + .unwrap_or_default() + } +} + +#[derive(Debug, Default)] +pub(crate) struct WindowCompositorSurfaceFrameUpdate { + pub content_changed: bool, + pub request_next_frame: bool, + pub changed_surfaces: Vec, + pub producer_latency: Vec<(CompositorSurfaceId, Duration)>, +} + +impl WindowCompositorSurfaceFrameUpdate { + fn mark_content_changed(&mut self, surface_id: CompositorSurfaceId) { + self.content_changed = true; + if !self.changed_surfaces.contains(&surface_id) { + self.changed_surfaces.push(surface_id); + } + } + + fn record_producer_latency(&mut self, surface_id: CompositorSurfaceId, latency: Duration) { + self.producer_latency.push((surface_id, latency)); + } +} + +#[derive(Clone)] +struct PlannedCompositorSurface { + surface_id: CompositorSurfaceId, + rect: Rect, + requested_source_size: Size, + key: Option, +} + +fn planned_compositor_surfaces(plan: &CompositionPlan) -> Vec { + let mut surfaces = Vec::new(); + let mut requested = FxHashMap::default(); + for item in &plan.items { + match item { + CompositionItem::CompositorSurface(layer) => { + if let Some(index) = requested.get(&layer.surface_id).copied() { + let planned: &mut PlannedCompositorSurface = &mut surfaces[index]; + planned.requested_source_size = + max_size(planned.requested_source_size, layer.source_size); + if planned.key.is_none() { + planned.key = Some(layer.key.clone()); + planned.rect = layer.rect; + } + } else { + requested.insert(layer.surface_id, surfaces.len()); + surfaces.push(PlannedCompositorSurface { + surface_id: layer.surface_id, + rect: layer.rect, + requested_source_size: layer.source_size, + key: Some(layer.key.clone()), + }); + } + } + CompositionItem::Scene(layer) => { + for image in &layer.external_images { + if let Some(index) = requested.get(&image.surface_id).copied() { + let planned: &mut PlannedCompositorSurface = &mut surfaces[index]; + planned.requested_source_size = + max_size(planned.requested_source_size, image.source_size); + } else { + requested.insert(image.surface_id, surfaces.len()); + surfaces.push(PlannedCompositorSurface { + surface_id: image.surface_id, + rect: image.rect, + requested_source_size: image.source_size, + key: None, + }); + } + } + } + } + } + surfaces +} + +fn max_size(a: Size, b: Size) -> Size { + Size::new(a.width.max(b.width), a.height.max(b.height)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + compositor_surface::CompositorSurfaceId, + paint::composition::{ + CompositionKey, CompositorSurfaceLayer, SceneExternalImage, SceneLayer, + }, + }; + + #[test] + fn planned_compositor_surfaces_dedupes_repeated_direct_placements() { + let surface_id = CompositorSurfaceId::test_new(42); + let mut plan = CompositionPlan::new(); + plan.items + .push(CompositionItem::CompositorSurface(CompositorSurfaceLayer { + key: CompositionKey::CompositorSurface { + surface_id, + occurrence: 0, + }, + surface_id, + rect: Rect::new(0.0, 0.0, 100.0, 50.0), + source_size: Size::new(100.0, 50.0), + transform: peniko::kurbo::Affine::IDENTITY, + clip: None, + opacity: 1.0, + blend_mode: peniko::BlendMode::default(), + })); + plan.items + .push(CompositionItem::CompositorSurface(CompositorSurfaceLayer { + key: CompositionKey::CompositorSurface { + surface_id, + occurrence: 1, + }, + surface_id, + rect: Rect::new(200.0, 0.0, 300.0, 50.0), + source_size: Size::new(100.0, 50.0), + transform: peniko::kurbo::Affine::IDENTITY, + clip: None, + opacity: 1.0, + blend_mode: peniko::BlendMode::default(), + })); + + let planned = planned_compositor_surfaces(&plan); + assert_eq!(planned.len(), 1); + assert_eq!(planned[0].surface_id, surface_id); + assert_eq!(planned[0].requested_source_size, Size::new(100.0, 50.0)); + assert_eq!( + planned[0].key, + Some(CompositionKey::CompositorSurface { + surface_id, + occurrence: 0, + }) + ); + } + + #[test] + fn planned_compositor_surfaces_upgrades_scene_request_to_direct_layer_request() { + let surface_id = CompositorSurfaceId::test_new(43); + let mut plan = CompositionPlan::new(); + plan.items.push(CompositionItem::Scene(SceneLayer { + key: CompositionKey::SceneRun { run_index: 0 }, + source_element_id: None, + debug_name: None, + scene: imaging::record::Scene::new(), + external_images: vec![SceneExternalImage { + image_id: imaging::ExternalImageId(43), + surface_id, + rect: Rect::new(0.0, 0.0, 100.0, 50.0), + source_size: Size::new(100.0, 50.0), + }], + color_filters: Vec::new(), + content_revision: 0, + transform: peniko::kurbo::Affine::IDENTITY, + clip: None, + bounds: Rect::new(0.0, 0.0, 100.0, 50.0), + content_bounds: None, + opacity: 1.0, + blend_mode: peniko::BlendMode::default(), + promoted: false, + frame_rate: None, + })); + plan.items + .push(CompositionItem::CompositorSurface(CompositorSurfaceLayer { + key: CompositionKey::CompositorSurface { + surface_id, + occurrence: 0, + }, + surface_id, + rect: Rect::new(200.0, 0.0, 300.0, 50.0), + source_size: Size::new(100.0, 50.0), + transform: peniko::kurbo::Affine::IDENTITY, + clip: None, + opacity: 1.0, + blend_mode: peniko::BlendMode::default(), + })); + + let planned = planned_compositor_surfaces(&plan); + assert_eq!(planned.len(), 1); + assert_eq!( + planned[0].key, + Some(CompositionKey::CompositorSurface { + surface_id, + occurrence: 0, + }) + ); + assert_eq!(planned[0].rect, Rect::new(200.0, 0.0, 300.0, 50.0)); + } + + #[test] + fn planned_compositor_surfaces_dedupes_repeated_scene_images() { + let surface_id = CompositorSurfaceId::test_new(44); + let mut plan = CompositionPlan::new(); + plan.items.push(CompositionItem::Scene(SceneLayer { + key: CompositionKey::SceneRun { run_index: 0 }, + source_element_id: None, + debug_name: None, + scene: imaging::record::Scene::new(), + external_images: vec![ + SceneExternalImage { + image_id: imaging::ExternalImageId(44), + surface_id, + rect: Rect::new(0.0, 0.0, 100.0, 50.0), + source_size: Size::new(100.0, 50.0), + }, + SceneExternalImage { + image_id: imaging::ExternalImageId(45), + surface_id, + rect: Rect::new(200.0, 0.0, 360.0, 90.0), + source_size: Size::new(160.0, 90.0), + }, + ], + color_filters: Vec::new(), + content_revision: 0, + transform: peniko::kurbo::Affine::IDENTITY, + clip: None, + bounds: Rect::new(0.0, 0.0, 360.0, 90.0), + content_bounds: None, + opacity: 1.0, + blend_mode: peniko::BlendMode::default(), + promoted: false, + frame_rate: None, + })); + + let planned = planned_compositor_surfaces(&plan); + assert_eq!(planned.len(), 1); + assert_eq!(planned[0].surface_id, surface_id); + assert_eq!(planned[0].requested_source_size, Size::new(160.0, 90.0)); + assert_eq!(planned[0].key, None); + } +} + +fn create_intermediate_wgpu_surface_frame( + pool: &mut IntermediateTexturePool, + device: &wgpu::Device, + opportunity: subduction::wgpu::SurfaceFrameOpportunity, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + usage: wgpu::TextureUsages, +) -> subduction::wgpu::SurfaceFrameLease { + pool.drain_releases(); + let key = pool.acquire(device, size, format, usage); + let resource = pool + .resources + .get(&key) + .expect("acquired intermediate texture resource exists"); + let release_queue = pool.releases.clone(); + subduction::wgpu::SurfaceFrameLease::with_resource_key( + opportunity, + size, + format, + resource.texture.clone(), + resource.view.clone(), + key, + ) + .with_release(Arc::new(move |resource_key| { + if let Ok(mut releases) = release_queue.lock() { + releases.push(resource_key); + } + })) +} + +struct IntermediateTexturePool { + resources: FxHashMap, + next_key: u64, + clock: u64, + releases: Arc>>, + max_idle_resources: usize, +} + +struct IntermediateTextureResource { + texture: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, + format: wgpu::TextureFormat, + usage: wgpu::TextureUsages, + checked_out: bool, + last_used: u64, +} + +impl IntermediateTexturePool { + fn new(max_idle_resources: usize) -> Self { + Self { + resources: FxHashMap::default(), + next_key: 0, + clock: 0, + releases: Arc::new(Mutex::new(Vec::new())), + max_idle_resources, + } + } + + fn acquire( + &mut self, + device: &wgpu::Device, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + usage: wgpu::TextureUsages, + ) -> u64 { + let width = size.width.max(1); + let height = size.height.max(1); + self.clock = self.clock.wrapping_add(1).max(1); + if let Some((&key, resource)) = self.resources.iter_mut().find(|(_, resource)| { + !resource.checked_out + && resource.width == width + && resource.height == height + && resource.format == format + && resource.usage == usage + }) { + resource.checked_out = true; + resource.last_used = self.clock; + return key; + } + + self.prune_idle_for_key(width, height, format, usage); + self.prune_idle_total(); + let key = self.next_key; + self.next_key = self.next_key.wrapping_add(1).max(1); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("floem flattened external image intermediate texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor { + label: Some("floem flattened external image intermediate texture view"), + ..Default::default() + }); + self.resources.insert( + key, + IntermediateTextureResource { + texture, + view, + width, + height, + format, + usage, + checked_out: true, + last_used: self.clock, + }, + ); + key + } + + fn drain_releases(&mut self) { + let Ok(mut releases) = self.releases.lock() else { + return; + }; + for key in releases.drain(..) { + self.clock = self.clock.wrapping_add(1).max(1); + if let Some(resource) = self.resources.get_mut(&key) { + resource.checked_out = false; + resource.last_used = self.clock; + } + } + drop(releases); + self.prune_idle_total(); + } + + fn prune_idle_for_key( + &mut self, + width: u32, + height: u32, + format: wgpu::TextureFormat, + usage: wgpu::TextureUsages, + ) { + let mut idle = self + .resources + .iter() + .filter_map(|(&key, resource)| { + (!resource.checked_out + && resource.width == width + && resource.height == height + && resource.format == format + && resource.usage == usage) + .then_some(key) + }) + .collect::>(); + let remove_count = idle.len().saturating_sub(self.max_idle_resources); + idle.truncate(remove_count); + for key in idle { + self.resources.remove(&key); + } + } + + fn prune_idle_total(&mut self) { + let mut idle = self + .resources + .iter() + .filter_map(|(&key, resource)| { + (!resource.checked_out).then_some((key, resource.last_used)) + }) + .collect::>(); + let remove_count = idle.len().saturating_sub(self.max_idle_resources); + if remove_count == 0 { + return; + } + idle.sort_by_key(|(_, last_used)| *last_used); + idle.truncate(remove_count); + for (key, _) in idle { + self.resources.remove(&key); + } + } +} + +#[derive(Clone)] +pub(crate) struct CompositorSurfaceEntry { + pub content: CompositorSurfaceContent, + pub provider: Option, + pub presents_without_transaction: bool, + pub content_dirty: bool, + pub version: u64, + pub previous_outcome: Option, + pub frame_rate: FrameRatePreference, + last_delivered_frame_index: Option, + producer_work_estimate: Duration, +} + +impl Default for CompositorSurfaceEntry { + fn default() -> Self { + Self { + content: CompositorSurfaceContent::Empty, + provider: None, + presents_without_transaction: false, + content_dirty: false, + version: 0, + previous_outcome: None, + frame_rate: FrameRatePreference::full(), + last_delivered_frame_index: None, + producer_work_estimate: Duration::ZERO, + } + } +} + +impl CompositorSurfaceEntry { + fn record_producer_latency(&mut self, observed: Duration) { + self.producer_work_estimate = + smooth_duration_estimate(self.producer_work_estimate, observed); + } + + fn should_deliver_frame_opportunity( + &mut self, + surface_id: CompositorSurfaceId, + frame_time: FrameTime, + ) -> bool { + let should_deliver = frame_rate_due(frame_time, self.frame_rate) + || self + .last_delivered_frame_index + .is_some_and(|last| frame_time.frame_index <= last); + if crate::frame_source::frame_pacing_diag_enabled() && !self.frame_rate.is_full() { + crate::floem_debug_log!( + "floem compositor surface pacing surface={:?} frame={} source_interval={:.3}ms display_interval={:.3}ms preference={:?} deliver={}", + surface_id, + frame_time.frame_index, + crate::frame_source::duration_ms(frame_time.source_interval), + crate::frame_source::duration_ms(frame_time.frame_interval), + self.frame_rate, + should_deliver, + ); + } + if should_deliver { + self.last_delivered_frame_index = Some(frame_time.frame_index); + } + should_deliver + } +} + +fn smooth_duration_estimate(previous: Duration, observed: Duration) -> Duration { + if observed >= previous { + return observed; + } + let previous_ns = previous.as_nanos(); + let observed_ns = observed.as_nanos(); + Duration::from_nanos(((previous_ns * 7 + observed_ns) / 8).min(u64::MAX as u128) as u64) +} diff --git a/src/window/handle.rs b/src/window/handle.rs index aa56361bc..312e3ea8b 100644 --- a/src/window/handle.rs +++ b/src/window/handle.rs @@ -1,20 +1,17 @@ -#[cfg(not(target_arch = "wasm32"))] -use std::collections::HashMap; use std::{cell::RefCell, mem, rc::Rc, sync::Arc}; -#[cfg(feature = "crossbeam")] -use crossbeam::channel::bounded as sync_channel; -#[cfg(not(feature = "crossbeam"))] -use std::sync::mpsc::sync_channel; - -use crate::event::{CustomEvent, RouteKind, ScrollTo, UpdatePhaseEvent}; -use crate::platform::menu_types::{Menu as MudaMenu, MenuId}; +use crate::event::{PaintPresentInfo, PaintPresentLayer, PaintPresentTiming}; +use crate::paint::{PaintState, renderer::SharedSceneFragmentRendererPool}; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] +use crate::platform::menu::MenuSpec; +use crate::platform::menu::{MenuId, MudaMenu}; use crate::style::recalc::StyleReason; -use crate::style::{StyleSelector, StyleSelectors}; #[cfg(target_os = "windows")] use muda::MenuTheme as MudaMenuTheme; use crate::platform::{Duration, Instant}; +#[cfg(target_os = "macos")] +use subduction_backend_apple::{request_next_metal_capture, try_start_armed_metal_capture}; use ui_events::keyboard::{Key, KeyboardEvent, Modifiers, NamedKey}; use ui_events::pointer::PointerEvent; use ui_events_winit::WindowEventReducer; @@ -23,169 +20,457 @@ use winit::window::{ ImeCapabilities, ImeEnableRequest, ImeHint, ImePurpose, ImeRequest, ImeRequestData, }; -use floem_reactive::{RwSignal, Scope, SignalGet, SignalUpdate}; -use floem_renderer::Renderer; -use floem_renderer::gpu_resources::GpuResources; +use crate::action::TimerToken; +use crate::effects::Brush; +#[cfg(test)] +use crate::frame::FrameCallbackRepeat; +use crate::frame_source::{FrameSource, new_window_frame_source}; +use crate::gpu_resources::GpuResources; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] +use floem_reactive::RwSignal; +use floem_reactive::Scope; use peniko::color::palette; -use peniko::kurbo::{self, Point, Size}; +use peniko::kurbo::{Point, Size}; +use understory_frame_pacing::{ + BeginFrameTiming, CompositorCommitReason, CompositorCommitResult, CompositorFrameAction, + CompositorFrameScheduler, CompositorWorkStatus, DisplayTiming as PacingDisplayTiming, + Duration as PacingDuration, FrameDemand as PacingFrameDemand, FrameOpportunity, + FrameTimingEstimate, Time as PacingTime, plan_frame, +}; use winit::{ - cursor::CursorIcon, - dpi::{LogicalPosition, LogicalSize}, + dpi::{LogicalPosition, LogicalSize, PhysicalSize}, event::Ime, window::{Window, WindowId}, }; -use super::state::WindowState; -use super::tracking::{remove_window_id_mapping, store_window_id_mapping}; -use crate::app::{MenuWrapper, add_app_update_event}; +use super::tracking::{store_platform_window_mapping, store_window_id_mapping}; +use super::{ + compositor::CompositorRuntime, + compositor_surface::WindowCompositorSurfaces, + state::WindowState, + ui_driver::{PlatformRequest, UiPlatformEvent, UiSceneSubmission}, + ui_runtime::WindowUiRuntime, +}; #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] use crate::platform::context_menu::context_menu_view; #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] use crate::reactive::SignalWith; #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] use crate::unit::UnitExt; -use crate::view::{LayoutTree, VIEW_STORAGE}; +use crate::view::LayoutTree; #[cfg(any(target_os = "linux", target_os = "freebsd", target_arch = "wasm32"))] use crate::views::{Container, Decorators, Stack}; use crate::{ Application, app::UserEvent, - context::{FrameUpdate, LayoutChanged, PaintState, StyleCx, UpdateCx, VisualChanged}, - event::{ - Event, GlobalEventCx, ImeEvent, WindowEvent, clear_hit_test_cache, - dropped_file::FileDragEvent, + event::{Event, clear_hit_test_cache, dropped_file::FileDragEvent}, + frame::{ + FrameDemand, FrameRatePreference, FrameTime, frame_rate_interval, + frame_rate_source_interval, + }, + inspector::{ + self, Capture, CaptureState, CapturedView, TimingKind, TimingReport, TimingThread, + profiler::Profile, }, - inspector::{self, Capture, CaptureState, CapturedView, profiler::Profile}, message::{ CENTRAL_DEFERRED_UPDATE_MESSAGES, CENTRAL_UPDATE_MESSAGES, CURRENT_RUNNING_VIEW_HANDLE, - DEFERRED_UPDATE_MESSAGES, UPDATE_MESSAGES, UpdateMessage, + DEFERRED_UPDATE_MESSAGES, UPDATE_MESSAGES, }, - style::{CursorStyle, Style}, + style::Style, theme::default_theme, view::{IntoView, View, ViewId}, }; +const COMPOSITOR_DEADLINE_FUDGE: Duration = Duration::from_millis(1); +const DEFAULT_FRAME_WORK_ESTIMATE: Duration = Duration::from_millis(2); +const DEFAULT_GPU_WORK_ESTIMATE: Duration = Duration::ZERO; +const DEFAULT_TIMER_WAKEUP_ESTIMATE: Duration = Duration::from_millis(1); +const DEFAULT_LAYER_HOST_COMMIT_ESTIMATE: Duration = Duration::from_millis(1); +const MAX_TIMER_WAKEUP_SAMPLE: Duration = Duration::from_millis(16); +const MISSED_SCENE_FRAME_WORK_ESTIMATE_STEP: Duration = Duration::from_micros(500); /// The top-level window handle that owns the winit `Window`. /// Meant only for use with the root view of the application. -/// Owns the [`WindowState`] and is responsible for -/// - processing all requests to update the [`WindowState`] from the reactive system +/// Owns the UI driver and is responsible for +/// - processing all requests to update UI state from the reactive system /// - processing all requests to update the animation state from the reactive system /// - requesting a new animation frame from the backend pub(crate) struct WindowHandle { pub(crate) window: Arc, window_id: WindowId, - /// The root view ID for this window. - pub(crate) id: ViewId, - /// Reactive Scope for this `WindowHandle` - scope: Scope, - pub(crate) window_state: WindowState, + pub(crate) ui: WindowUiRuntime, + compositor_runtime: CompositorRuntime, + compositor_surfaces: WindowCompositorSurfaces, pub(crate) paint_state: PaintState, - size: RwSignal, + size: Size, default_theme: Option