diff --git a/Cargo.lock b/Cargo.lock index 9f0b3440..3b97b642 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,7 +164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8054396c448cd2fb78b7b719b9fe616eac0b6b0e824f1cbcc0804617124a3d3f" dependencies = [ "enum-as-inner", - "indexmap 2.1.0", + "indexmap", "miette", "paste", "serde", @@ -557,13 +557,16 @@ dependencies = [ "clang-sys", "lazy_static", "lazycell", + "log", "peeking_take_while", + "prettyplease", "proc-macro2", "quote", "regex", "rustc-hash", "shlex", "syn 2.0.39", + "which", ] [[package]] @@ -784,6 +787,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "clang-sys" version = "1.6.1" @@ -1134,6 +1146,41 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -1313,45 +1360,40 @@ dependencies = [ ] [[package]] -name = "ecolor" -version = "0.23.0" +name = "dyn-clonable" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfdf4e52dbbb615cfd30cf5a5265335c217b5fd8d669593cea74a517d9c605af" +checksum = "4e9232f0e607a262ceb9bd5141a3dfb3e4db6994b31989bbfd845878cba59fd4" dependencies = [ - "bytemuck", - "serde", + "dyn-clonable-impl", + "dyn-clone", +] + +[[package]] +name = "dyn-clonable-impl" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558e40ea573c374cf53507fd240b7ee2f5477df7cfebdb97323ec61c719399c5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] -name = "eframe" +name = "dyn-clone" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" + +[[package]] +name = "ecolor" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d9efede6c8905d3fc51a5ec9a506d4da4011bbcae0253d0304580fe40af3f5" +checksum = "cfdf4e52dbbb615cfd30cf5a5265335c217b5fd8d669593cea74a517d9c605af" dependencies = [ "bytemuck", - "cocoa", - "directories-next", - "egui", - "egui-wgpu", - "egui-winit", - "image 0.24.7", - "js-sys", - "log", - "objc", - "parking_lot", - "percent-encoding", - "pollster", - "raw-window-handle", - "ron", "serde", - "static_assertions", - "thiserror", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu", - "winapi", - "winit", ] [[package]] @@ -1365,6 +1407,7 @@ dependencies = [ "epaint", "log", "nohash-hasher", + "puffin", "ron", "serde", ] @@ -1378,21 +1421,6 @@ dependencies = [ "egui", ] -[[package]] -name = "egui-wgpu" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62d4c9ab93d9528c184ef1d695c8c99b2e6d50833696ec3f513063efeee0fe77" -dependencies = [ - "bytemuck", - "epaint", - "log", - "thiserror", - "type-map", - "wgpu", - "winit", -] - [[package]] name = "egui-winit" version = "0.23.0" @@ -1403,6 +1431,7 @@ dependencies = [ "arboard", "egui", "log", + "puffin", "raw-window-handle", "serde", "smithay-clipboard", @@ -1439,6 +1468,23 @@ dependencies = [ "usvg", ] +[[package]] +name = "egui_glow" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6726c08798822280038bbad2e32f4fc3cbed800cd51c6e34e99cd2d60cc1bc" +dependencies = [ + "bytemuck", + "egui", + "egui-winit", + "glow 0.12.3", + "log", + "memoffset 0.6.5", + "puffin", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "either" version = "1.9.0" @@ -1702,8 +1748,7 @@ checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" [[package]] name = "flume" version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +source = "git+https://github.com/Astrabit-ST/flume?rev=d323799efea329c87a3a5a5b45cc76f46da278c2#d323799efea329c87a3a5a5b45cc76f46da278c2" dependencies = [ "futures-core", "futures-sink", @@ -2011,6 +2056,17 @@ dependencies = [ "syn 2.0.39", ] +[[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 = "glam" version = "0.24.2" @@ -2048,6 +2104,91 @@ dependencies = [ "web-sys", ] +[[package]] +name = "glow" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886c2a30b160c4c6fec8f987430c26b526b7988ca71f664e6a699ddf6f9601e4" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc93b03242719b8ad39fb26ed2b01737144ce7bd4bfc7adadcef806596760fe" +dependencies = [ + "bitflags 1.3.2", + "cfg_aliases", + "cgl", + "core-foundation", + "dispatch", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys 0.4.0", + "libloading 0.7.4", + "objc2", + "once_cell", + "raw-window-handle", + "wayland-sys 0.30.1", + "windows-sys 0.45.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629a873fc04062830bfe8f97c03773bcd7b371e23bcc465d0a61448cd1588fa4" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af784eb26c5a68ec85391268e074f0aa618c096eadb5d6330b0911cf34fe57c5" +dependencies = [ + "gl_generator", + "windows-sys 0.45.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b53cb5fe568964aa066a3ba91eac5ecbac869fb0842cd0dc9e412434f1a1494" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef89398e90033fc6bc65e9bd42fd29bbbfd483bda5b56dc5562f455550618165" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2080,15 +2221,16 @@ dependencies = [ [[package]] name = "gpu-allocator" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce95f9e2e11c2c6fadfce42b5af60005db06576f231f5c92550fdded43c423e8" +checksum = "40fe17c8a05d60c38c0a4e5a3c802f2f1ceb66b76c67d96ffb34bef0475a7fad" dependencies = [ "backtrace", "log", + "presser", "thiserror", "winapi", - "windows 0.44.0", + "windows 0.51.1", ] [[package]] @@ -2141,7 +2283,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap 2.1.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -2310,6 +2452,12 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.4.0" @@ -2401,16 +2549,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - [[package]] name = "indexmap" version = "2.1.0" @@ -2554,15 +2692,21 @@ dependencies = [ [[package]] name = "khronos-egl" -version = "4.1.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c2352bd1d0bceb871cb9d40f24360c8133c11d7486b68b5381c1dd1a32015e3" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading 0.7.4", + "libloading 0.8.1", "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.8.3" @@ -2740,7 +2884,6 @@ dependencies = [ "camino", "color-backtrace", "crc", - "eframe", "egui", "egui_extras", "epaint", @@ -2748,11 +2891,12 @@ dependencies = [ "image 0.24.7", "include-bytes-zstd", "js-sys", - "luminol-app", "luminol-audio", "luminol-config", "luminol-core", "luminol-data", + "luminol-eframe", + "luminol-egui-wgpu", "luminol-filesystem", "luminol-graphics", "luminol-term", @@ -2776,14 +2920,6 @@ dependencies = [ "winres", ] -[[package]] -name = "luminol-app" -version = "0.4.0" -dependencies = [ - "eframe", - "egui", -] - [[package]] name = "luminol-audio" version = "0.4.0" @@ -2809,13 +2945,13 @@ version = "0.4.0" dependencies = [ "anyhow", "egui", - "egui-wgpu", "glam", "itertools", "luminol-audio", "luminol-config", "luminol-core", "luminol-data", + "luminol-egui-wgpu", "luminol-filesystem", "luminol-graphics", "once_cell", @@ -2880,6 +3016,63 @@ dependencies = [ "strum", ] +[[package]] +name = "luminol-eframe" +version = "0.23.0" +dependencies = [ + "bytemuck", + "cocoa", + "directories-next", + "document-features", + "egui", + "egui-winit", + "egui_glow", + "flume", + "glow 0.12.3", + "glutin", + "glutin-winit", + "image 0.24.7", + "js-sys", + "log", + "luminol-egui-wgpu", + "luminol-web", + "objc", + "once_cell", + "oneshot", + "parking_lot", + "percent-encoding", + "pollster", + "portable-atomic", + "puffin", + "raw-window-handle", + "ron", + "serde", + "static_assertions", + "thiserror", + "tts", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu", + "winapi", + "winit", +] + +[[package]] +name = "luminol-egui-wgpu" +version = "0.23.0" +dependencies = [ + "bytemuck", + "document-features", + "epaint", + "log", + "puffin", + "thiserror", + "type-map", + "wgpu", + "winit", +] + [[package]] name = "luminol-filesystem" version = "0.4.0" @@ -2921,12 +3114,12 @@ dependencies = [ "crossbeam", "dashmap", "egui", - "egui-wgpu", "egui_extras", "glam", "image 0.24.7", "itertools", "luminol-data", + "luminol-egui-wgpu", "luminol-filesystem", "naga", "naga_oil", @@ -2991,17 +3184,9 @@ dependencies = [ name = "luminol-web" version = "0.4.0" dependencies = [ - "eframe", "egui", - "egui-wgpu", - "flume", "js-sys", - "luminol-app", - "once_cell", - "oneshot", - "portable-atomic", - "ron", - "tracing", + "luminol-egui-wgpu", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -3142,9 +3327,9 @@ dependencies = [ [[package]] name = "metal" -version = "0.26.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "623b5e6cefd76e58f774bd3cc0c6f5c7615c58c03a97815245a25c3c9bdee318" +checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" dependencies = [ "bitflags 2.4.1", "block", @@ -3234,15 +3419,15 @@ dependencies = [ [[package]] name = "naga" -version = "0.13.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ceaaa4eedaece7e4ec08c55c640ba03dbb73fb812a6570a59bcf1930d0f70e" +checksum = "6cd05939c491da968a42986204b7431678be21fdcd4b10cc84997ba130ada5a4" dependencies = [ "bit-set", "bitflags 2.4.1", "codespan-reporting", "hexf-parse", - "indexmap 1.9.3", + "indexmap", "log", "num-traits", "pp-rs", @@ -3255,14 +3440,14 @@ dependencies = [ [[package]] name = "naga_oil" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac54c77b3529887f9668d3dd81e955e58f252b31a333f836e3548c06460b958" +checksum = "fff3f369dd665ee365daeab786466a6f70ff53e4a95a76117363b1077e1b0492" dependencies = [ "bit-set", "codespan-reporting", "data-encoding", - "indexmap 1.9.3", + "indexmap", "naga", "once_cell", "regex", @@ -3320,6 +3505,35 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-glue" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0434fabdd2c15e0aab768ca31d5b7b333717f03cf02037d5a0a3ff3c278ed67f" +dependencies = [ + "libc", + "log", + "ndk", + "ndk-context", + "ndk-macro", + "ndk-sys", + "once_cell", + "parking_lot", +] + +[[package]] +name = "ndk-macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0df7ac00c4672f9d5aece54ee3347520b7e20f158656c7db2e6de01902eb7a6c" +dependencies = [ + "darling", + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "ndk-sys" version = "0.4.1+23.1.7779620" @@ -3755,6 +3969,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "oxilangtag" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d91edf4fbb970279443471345a4e8c491bf05bb283b3e6c88e4e606fd8c181b" + [[package]] name = "pango-sys" version = "0.18.0" @@ -3869,7 +4089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" dependencies = [ "fixedbitset", - "indexmap 2.1.0", + "indexmap", ] [[package]] @@ -3984,7 +4204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5699cc8a63d1aa2b1ee8e12b9ad70ac790d65788cd36101fa37f87ea46c4cef" dependencies = [ "base64 0.21.5", - "indexmap 2.1.0", + "indexmap", "line-wrap", "quick-xml", "serde", @@ -4100,6 +4320,22 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" +dependencies = [ + "proc-macro2", + "syn 2.0.39", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4158,6 +4394,19 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f89dff0959d98c9758c88826cc002e2c3d0b9dfac4139711d1f30de442f1139b" +[[package]] +name = "puffin" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76425abd4e1a0ad4bd6995dd974b52f414fca9974171df8e3708b3e660d05a21" +dependencies = [ + "anyhow", + "byteorder", + "cfg-if", + "instant", + "once_cell", +] + [[package]] name = "qoi" version = "0.4.1" @@ -4570,6 +4819,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda4e97be1fd174ccc2aae81c8b694e803fa99b34e8fd0f057a9d70698e3ed09" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -4896,6 +5158,26 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "speech-dispatcher" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727d53c474ba5ada07784ad7d203cf896a74854cfee0eb32376b00759eb2972" +dependencies = [ + "lazy_static", + "libc", + "speech-dispatcher-sys", +] + +[[package]] +name = "speech-dispatcher-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c3e8acdf2b1f4bb13f1813b40b52f3edf4cc94d8a55fe713a584f672a10388d" +dependencies = [ + "bindgen", +] + [[package]] name = "spin" version = "0.9.8" @@ -5448,7 +5730,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.1.0", + "indexmap", "toml_datetime", "winnow", ] @@ -5459,7 +5741,7 @@ version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ - "indexmap 2.1.0", + "indexmap", "toml_datetime", "winnow", ] @@ -5470,7 +5752,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" dependencies = [ - "indexmap 2.1.0", + "indexmap", "serde", "serde_spanned", "toml_datetime", @@ -5578,6 +5860,30 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" +[[package]] +name = "tts" +version = "0.25.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee57eae77c7059f02e9ae166cd3ef4973e62c859b1eeaf9a738032a5b1c38e4" +dependencies = [ + "cocoa-foundation", + "core-foundation", + "dyn-clonable", + "jni 0.21.1", + "lazy_static", + "libc", + "log", + "ndk-context", + "ndk-glue", + "objc", + "oxilangtag", + "speech-dispatcher", + "thiserror", + "wasm-bindgen", + "web-sys", + "windows 0.51.1", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -5863,7 +6169,7 @@ dependencies = [ "scoped-tls", "wayland-commons", "wayland-scanner", - "wayland-sys", + "wayland-sys 0.29.5", ] [[package]] @@ -5875,7 +6181,7 @@ dependencies = [ "nix 0.24.3", "once_cell", "smallvec", - "wayland-sys", + "wayland-sys 0.29.5", ] [[package]] @@ -5923,11 +6229,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "wayland-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b2a02ac608e07132978689a6f9bf4214949c85998c247abadd4f4129b1aa06" +dependencies = [ + "dlib", + "lazy_static", + "log", + "pkg-config", +] + [[package]] name = "web-sys" -version = "0.3.65" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" dependencies = [ "js-sys", "wasm-bindgen", @@ -6050,12 +6368,13 @@ dependencies = [ [[package]] name = "wgpu" -version = "0.17.2" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "752e44d3998ef35f71830dd1ad3da513e628e2e4d4aedb0ab580f850827a0b41" +checksum = "30e7d227c9f961f2061c26f4cb0fbd4df0ef37e056edd0931783599d6c94ef24" dependencies = [ "arrayvec", "cfg-if", + "flume", "js-sys", "log", "naga", @@ -6074,9 +6393,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "0.17.1" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f8a44dd301a30ceeed3c27d8c0090433d3da04d7b2a4042738095a424d12ae7" +checksum = "ef91c1d62d1e9e81c79e600131a258edf75c9531cbdbde09c44a011a47312726" dependencies = [ "arrayvec", "bit-vec", @@ -6097,9 +6416,9 @@ dependencies = [ [[package]] name = "wgpu-hal" -version = "0.17.2" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a80bf0e3c77399bb52850cb0830af9bad073d5cfcb9dd8253bef8125c42db17" +checksum = "1e30b9a8155c83868e82a8c5d3ce899de6c3961d2ef595de8fc168a1677fc2d8" dependencies = [ "android_system_properties", "arrayvec", @@ -6109,7 +6428,8 @@ dependencies = [ "block", "core-graphics-types", "d3d12", - "glow", + "glow 0.13.0", + "glutin_wgl_sys 0.5.0", "gpu-alloc", "gpu-allocator", "gpu-descriptor", @@ -6122,6 +6442,7 @@ dependencies = [ "metal", "naga", "objc", + "once_cell", "parking_lot", "profiling", "range-alloc", @@ -6138,15 +6459,27 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee64d7398d0c2f9ca48922c902ef69c42d000c759f3db41e355f4a570b052b67" +checksum = "0d5ed5f0edf0de351fe311c53304986315ce866f394a2e6df0c4b3c70774bcdd" dependencies = [ "bitflags 2.4.1", "js-sys", "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.25", +] + [[package]] name = "widestring" version = "1.0.2" @@ -6193,15 +6526,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows" version = "0.46.0" @@ -6222,6 +6546,25 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" +dependencies = [ + "windows-core", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-implement" version = "0.48.0" @@ -6399,6 +6742,7 @@ dependencies = [ "percent-encoding", "raw-window-handle", "redox_syscall 0.3.5", + "sctk-adwaita", "smithay-client-toolkit", "wasm-bindgen", "wayland-client", diff --git a/Cargo.toml b/Cargo.toml index 8d95e695..6f4ba269 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ egui = "0.23.0" egui_extras = { version = "0.23.0", features = ["svg", "image"] } epaint = "0.23.0" -eframe = { version = "0.23.0", features = [ +luminol-eframe = { version = "0.23.0", path = "crates/eframe/", features = [ "wgpu", "accesskit", "persistence", @@ -72,9 +72,11 @@ eframe = { version = "0.23.0", features = [ "x11", "wayland", ], default-features = false } -egui-wgpu = "0.23.0" +luminol-egui-wgpu = { version = "0.23.0", path = "crates/egui-wgpu/" } +egui_glow = "0.23.0" +egui-winit = "0.23.0" -wgpu = { version = "0.17.2", features = ["naga"] } +wgpu = { version = "0.18.0", features = ["naga"] } glam = { version = "0.24.2", features = ["bytemuck"] } image = "0.24.7" @@ -119,7 +121,6 @@ rand = "0.8.5" getrandom = { version = "0.2", features = ["js"] } luminol-audio = { version = "0.4.0", path = "crates/audio/" } -luminol-app = { version = "0.4.0", path = "crates/app/" } luminol-components = { version = "0.4.0", path = "crates/components/" } luminol-config = { version = "0.4.0", path = "crates/config/" } luminol-core = { version = "0.4.0", path = "crates/core/" } @@ -134,11 +135,14 @@ luminol-ui = { version = "0.4.0", path = "crates/ui/" } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -eframe.workspace = true +luminol-eframe.workspace = true +luminol-egui-wgpu.workspace = true egui.workspace = true egui_extras.workspace = true epaint.workspace = true +wgpu.workspace = true + rfd.workspace = true parking_lot.workspace = true @@ -151,7 +155,6 @@ crc = { version = "3.0.1", optional = true } tracing-subscriber = "0.3.17" color-backtrace = "0.6.0" -luminol-app.workspace = true luminol-audio.workspace = true luminol-core.workspace = true luminol-config.workspace = true @@ -209,7 +212,12 @@ tracing-wasm = "0.2" tracing-log = "0.1.3" tracing.workspace = true -web-sys = { version = "0.3", features = ["Window"] } +web-sys = { version = "0.3", features = [ + "Window", + "Worker", + "WorkerOptions", + "WorkerType", +] } # Enable wgpu's `webgl` feature if Luminol's `webgl` feature is enabled, # enabling its WebGL backend and disabling its WebGPU backend @@ -246,7 +254,7 @@ opt-level = 1 [profile.dev.package.egui] opt-level = 3 -[profile.dev.package.eframe] +[profile.dev.package.luminol-eframe] opt-level = 3 # Audio @@ -275,6 +283,11 @@ opt-level = 3 # is merged. cpal = { git = "https://github.com/DouglasDwyer/cpal.git", rev = "91aeb4d6b02c25791f636fdf92a73637597c077a" } +# flume's global spinlock uses `thread::sleep` which doesn't work in the main thread in WebAssembly. +# This is a patched version with `thread::sleep` removed in WebAssembly builds. +# See https://github.com/zesterer/flume/issues/137. +flume = { git = "https://github.com/Astrabit-ST/flume", rev = "d323799efea329c87a3a5a5b45cc76f46da278c2" } + # If you want to use the bleeding edge version of egui and eframe: # egui = { git = "https://github.com/emilk/egui", branch = "master" } # eframe = { git = "https://github.com/emilk/egui", branch = "master" } diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml deleted file mode 100644 index eaee4f8a..00000000 --- a/crates/app/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "luminol-app" -version.workspace = true -authors.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -readme.workspace = true -repository.workspace = true -keywords.workspace = true -categories.workspace = true - -[lints] -workspace = true - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -egui.workspace = true -eframe.workspace = true diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs deleted file mode 100644 index 99b4a2ab..00000000 --- a/crates/app/src/lib.rs +++ /dev/null @@ -1,42 +0,0 @@ -/// Custom implementation of `eframe::Frame` for Luminol. -/// We need this because the normal `eframe::App` uses a struct with private fields in its -/// definition of `update()`, and that prevents us from implementing custom app runners. -pub struct CustomFrame<'a>( - #[cfg(not(target_arch = "wasm32"))] pub &'a mut eframe::Frame, - #[cfg(target_arch = "wasm32")] pub std::marker::PhantomData<&'a ()>, -); - -#[cfg(not(target_arch = "wasm32"))] -impl std::ops::Deref for CustomFrame<'_> { - type Target = eframe::Frame; - fn deref(&self) -> &Self::Target { - self.0 - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl std::ops::DerefMut for CustomFrame<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.0 - } -} - -/// Custom implementation of `eframe::App` for Luminol. -/// We need this because the normal `eframe::App` uses a struct with private fields in its -/// definition of `update()`, and that prevents us from implementing custom app runners. -pub trait CustomApp -where - Self: eframe::App, -{ - fn custom_update(&mut self, ctx: &egui::Context, frame: &mut CustomFrame<'_>); -} - -#[macro_export] -macro_rules! app_use_custom_update { - () => { - fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - #[cfg(not(target_arch = "wasm32"))] - $crate::CustomApp::custom_update(self, ctx, &mut $crate::CustomFrame(frame)) - } - }; -} diff --git a/crates/components/Cargo.toml b/crates/components/Cargo.toml index 4fd7c5af..166db9fc 100644 --- a/crates/components/Cargo.toml +++ b/crates/components/Cargo.toml @@ -27,7 +27,7 @@ luminol-filesystem.workspace = true egui.workspace = true strum.workspace = true -egui-wgpu.workspace = true +luminol-egui-wgpu.workspace = true wgpu.workspace = true glam.workspace = true diff --git a/crates/components/src/tilepicker.rs b/crates/components/src/tilepicker.rs index 1ec999ac..91f9b71d 100644 --- a/crates/components/src/tilepicker.rs +++ b/crates/components/src/tilepicker.rs @@ -52,12 +52,12 @@ unsafe impl Send for Callback {} #[allow(unsafe_code)] unsafe impl Sync for Callback {} -impl egui_wgpu::CallbackTrait for Callback { +impl luminol_egui_wgpu::CallbackTrait for Callback { fn paint<'a>( &'a self, _info: egui::PaintCallbackInfo, render_pass: &mut wgpu::RenderPass<'a>, - _callback_resources: &'a egui_wgpu::CallbackResources, + _callback_resources: &'a luminol_egui_wgpu::CallbackResources, ) { self.resources .tiles @@ -234,14 +234,15 @@ impl Tilepicker { ), ); // FIXME: move this into graphics - ui.painter().add(egui_wgpu::Callback::new_paint_callback( - absolute_scroll_rect, - Callback { - resources: self.resources.clone(), - graphics_state: graphics_state.clone(), - coll_enabled, - }, - )); + ui.painter() + .add(luminol_egui_wgpu::Callback::new_paint_callback( + absolute_scroll_rect, + Callback { + resources: self.resources.clone(), + graphics_state: graphics_state.clone(), + coll_enabled, + }, + )); let rect = egui::Rect::from_x_y_ranges( (self.selected_tiles_left * 32) as f32..=((self.selected_tiles_right + 1) * 32) as f32, diff --git a/crates/eframe/CHANGELOG.md b/crates/eframe/CHANGELOG.md new file mode 100644 index 00000000..2a0b5136 --- /dev/null +++ b/crates/eframe/CHANGELOG.md @@ -0,0 +1,299 @@ +# Changelog for eframe +All notable changes to the `eframe` crate. + +NOTE: [`egui-winit`](../egui-winit/CHANGELOG.md), [`egui_glium`](../egui_glium/CHANGELOG.md), [`egui_glow`](../egui_glow/CHANGELOG.md),and [`egui-wgpu`](../egui-wgpu/CHANGELOG.md) have their own changelogs! + +This file is updated upon each release. +Changes since the last release can be found by running the `scripts/generate_changelog.py` script. + +## 0.23.0 - 2023-09-27 +* Update MSRV to Rust 1.70.0 [#3310](https://github.com/emilk/egui/pull/3310) +* Update to puffin 0.16 [#3144](https://github.com/emilk/egui/pull/3144) +* Update to `wgpu` 0.17.0 [#3170](https://github.com/emilk/egui/pull/3170) (thanks [@Aaron1011](https://github.com/Aaron1011)!) +* Improved wgpu callbacks [#3253](https://github.com/emilk/egui/pull/3253) (thanks [@Wumpf](https://github.com/Wumpf)!) +* Improve documentation of `eframe`, especially for wasm32 [#3295](https://github.com/emilk/egui/pull/3295) +* `eframe::Frame::info` returns a reference [#3301](https://github.com/emilk/egui/pull/3301) (thanks [@Barugon](https://github.com/Barugon)!) +* Move `App::persist_window` to `NativeOptions` and `App::max_size_points` to `WebOptions` [#3397](https://github.com/emilk/egui/pull/3397) + +#### Desktop/Native: +* Only show on-screen-keyboard and IME when editing text [#3362](https://github.com/emilk/egui/pull/3362) (thanks [@Barugon](https://github.com/Barugon)!) +* Add `eframe::storage_dir` [#3286](https://github.com/emilk/egui/pull/3286) +* Add `NativeOptions::window_builder` for more customization [#3390](https://github.com/emilk/egui/pull/3390) (thanks [@twop](https://github.com/twop)!) +* Better restore Window position on Mac when on secondary monitor [#3239](https://github.com/emilk/egui/pull/3239) +* Fix iOS support in `eframe` [#3241](https://github.com/emilk/egui/pull/3241) (thanks [@lucasmerlin](https://github.com/lucasmerlin)!) +* Speed up `eframe` state storage [#3353](https://github.com/emilk/egui/pull/3353) (thanks [@sebbert](https://github.com/sebbert)!) +* Allow users to opt-out of default `winit` features [#3228](https://github.com/emilk/egui/pull/3228) +* Expose Raw Window and Display Handles [#3073](https://github.com/emilk/egui/pull/3073) (thanks [@bash](https://github.com/bash)!) +* Use window title as fallback when app_id is not set [#3107](https://github.com/emilk/egui/pull/3107) (thanks [@jacekpoz](https://github.com/jacekpoz)!) +* Sleep a bit only when minimized [#3139](https://github.com/emilk/egui/pull/3139) (thanks [@icedrocket](https://github.com/icedrocket)!) +* Prevent text from being cleared when selected due to winit IME [#3376](https://github.com/emilk/egui/pull/3376) (thanks [@YgorSouza](https://github.com/YgorSouza)!) +* Fix android app quit on resume with glow backend [#3080](https://github.com/emilk/egui/pull/3080) (thanks [@tkkcc](https://github.com/tkkcc)!) +* Fix panic with persistence without window [#3167](https://github.com/emilk/egui/pull/3167) (thanks [@sagebind](https://github.com/sagebind)!) +* Only call `run_return` twice on Windows [#3053](https://github.com/emilk/egui/pull/3053) (thanks [@pan93412](https://github.com/pan93412)!) +* Gracefully catch error saving state to disk [#3230](https://github.com/emilk/egui/pull/3230) +* Recognize numpad enter/plus/minus [#3285](https://github.com/emilk/egui/pull/3285) +* Add more puffin profile scopes to `eframe` [#3330](https://github.com/emilk/egui/pull/3330) [#3332](https://github.com/emilk/egui/pull/3332) + +#### Web: +* Update to wasm-bindgen 0.2.87 [#3237](https://github.com/emilk/egui/pull/3237) +* Remove `Function()` invocation from eframe text_agent to bypass "unsafe-eval" restrictions in Chrome browser extensions. [#3349](https://github.com/emilk/egui/pull/3349) (thanks [@aspect](https://github.com/aspect)!) +* Fix docs about web [#3026](https://github.com/emilk/egui/pull/3026) (thanks [@kerryeon](https://github.com/kerryeon)!) + + +## 0.22.0 - 2023-05-23 +* Fix: `request_repaint_after` works even when called from background thread [#2939](https://github.com/emilk/egui/pull/2939) +* Clear all keys and modifies on focus change [#2857](https://github.com/emilk/egui/pull/2857) [#2933](https://github.com/emilk/egui/pull/2933) +* Remove dark-light dependency [#2929](https://github.com/emilk/egui/pull/2929) +* Replace `tracing` with `log` [#2928](https://github.com/emilk/egui/pull/2928) +* Update accesskit to 0.11 [#3012](https://github.com/emilk/egui/pull/3012) + +#### Desktop/Native: +* Automatically change theme when system dark/light mode changes [#2750](https://github.com/emilk/egui/pull/2750) (thanks [@bash](https://github.com/bash)!) +* Enabled wayland feature for winit when running native [#2751](https://github.com/emilk/egui/pull/2751) (thanks [@ItsEthra](https://github.com/ItsEthra)!) +* Fix eframe window position bug (pixels vs points) [#2763](https://github.com/emilk/egui/pull/2763) (thanks [@get200](https://github.com/get200)!) +* Add `Frame::request_screenshot` and `Frame::screenshot` to communicate to the backend that a screenshot of the current frame should be exposed by `Frame` during `App::post_rendering` ([#2676](https://github.com/emilk/egui/pull/2676)). +* Add `eframe::run_simple_native` * a simple API for simple apps ([#2453](https://github.com/emilk/egui/pull/2453)). +* Add `NativeOptions::app_id` which allows to set the Wayland application ID under Linux ([#1600](https://github.com/emilk/egui/issues/1600)). +* Add `NativeOptions::active` [#2813](https://github.com/emilk/egui/pull/2813) (thanks [@Dixeran](https://github.com/Dixeran)!) +* Remove `android-activity` dependency + add `Activity` backend features [#2863](https://github.com/emilk/egui/pull/2863) (thanks [@rib](https://github.com/rib)!) +* Fix bug where the eframe window is never destroyed on Linux when using `run_and_return` ([#2892](https://github.com/emilk/egui/issues/2892)) +* Fix state persisting when exiting on Linux [#2895](https://github.com/emilk/egui/pull/2895) (thanks [@flukejones](https://github.com/flukejones)!) +* Allow for requesting the user's attention to the window [#2905](https://github.com/emilk/egui/pull/2905) (thanks [@TicClick](https://github.com/TicClick)!) +* Read and request window focus [#2900](https://github.com/emilk/egui/pull/2900) (thanks [@TicClick](https://github.com/TicClick)!) +* Set app icon on Mac and Windows [#2940](https://github.com/emilk/egui/pull/2940) +* Set a default icon for all eframe apps: a white `e` on black background [#2996](https://github.com/emilk/egui/pull/2996) +* Add `NativeOptions::app_id` for the persistence location [#3014](https://github.com/emilk/egui/pull/3014) and for Wayland [#3007](https://github.com/emilk/egui/pull/3007) (thanks [@thomaskrause](https://github.com/thomaskrause)!) +* capture a screenshot using `Frame::request_screenshot` [870264b](https://github.com/emilk/egui/commit/870264b00577a95d3fd9bdf36efaf87fd351de62) + + +#### Web: +* ⚠️ BREAKING: `eframe::start_web` has been replaced with `eframe::WebRunner`, which also installs a nice panic hook (no need for `console_error_panic_hook`). +* ⚠️ BREAKING: WebGPU is now the default web renderer when using the `wgpu` feature of `eframe`. To use WebGL with `wgpu`, you need to add `wgpu = { version = "0.16.0", features = ["webgl"] }` to your own `Cargo.toml`. ([#2945](https://github.com/emilk/egui/pull/2945)) +* Add `eframe::WebLogger` for redirecting `log` calls to the web console (`console.log`). +* Prefer the client width/height for the canvas parent [#2804](https://github.com/emilk/egui/pull/2804) (thanks [@samitbasu](https://github.com/samitbasu)!) +* eframe web: Persist app state to local storage when leaving site [#2927](https://github.com/emilk/egui/pull/2927) +* Better panic handling [#2942](https://github.com/emilk/egui/pull/2942) [#2992](https://github.com/emilk/egui/pull/2992) +* Update wasm-bindgen to 0.2.86 [#2995](https://github.com/emilk/egui/pull/2995) +* Properly unsubscribe from events on destroy [4d360f6](https://github.com/emilk/egui/commit/4d360f67a4ae2314fbc8b83b01b701ec8e9cea5b) + + +## 0.21.3 - 2023-02-15 +* Fix typing the letter 'P' on web ([#2740](https://github.com/emilk/egui/pull/2740)). + + +## 0.21.2 - 2023-02-12 +* Allow compiling `eframe` with `--no-default-features` ([#2728](https://github.com/emilk/egui/pull/2728)). + + +## 0.21.1 - 2023-02-12 +* Fixed crash when native window position is in an invalid state, which could happen e.g. due to changes in monitor size or DPI ([#2722](https://github.com/emilk/egui/issues/2722)). + + +## 0.21.0 - 2023-02-08 - Update to `winit` 0.28 +* ⚠️ BREAKING: `App::clear_color` now expects you to return a raw float array ([#2666](https://github.com/emilk/egui/pull/2666)). +* The `screen_reader` feature has now been renamed `web_screen_reader` and only work on web. On other platforms, use the `accesskit` feature flag instead ([#2669](https://github.com/emilk/egui/pull/2669)). + +#### Desktop/Native: +* `eframe::run_native` now returns a `Result` ([#2433](https://github.com/emilk/egui/pull/2433)). +* Update to `winit` 0.28, adding support for mac trackpad zoom ([#2654](https://github.com/emilk/egui/pull/2654)). +* Fix bug where the cursor could get stuck using the wrong icon. +* `NativeOptions::transparent` now works with the wgpu backend ([#2684](https://github.com/emilk/egui/pull/2684)). +* Add `Frame::set_minimized` and `set_maximized` ([#2292](https://github.com/emilk/egui/pull/2292), [#2672](https://github.com/emilk/egui/pull/2672)). +* Fixed persistence of native window position on Windows OS ([#2583](https://github.com/emilk/egui/issues/2583)). + +#### Web: +* Prevent ctrl-P/cmd-P from opening the print dialog ([#2598](https://github.com/emilk/egui/pull/2598)). + + +## 0.20.1 - 2022-12-11 +* Fix [docs.rs](https://docs.rs/eframe) build ([#2420](https://github.com/emilk/egui/pull/2420)). + + +## 0.20.0 - 2022-12-08 - AccessKit integration and `wgpu` web support +* MSRV (Minimum Supported Rust Version) is now `1.65.0` ([#2314](https://github.com/emilk/egui/pull/2314)). +* Allow empty textures with the glow renderer. + +#### Desktop/Native: +* Don't repaint when just moving window ([#1980](https://github.com/emilk/egui/pull/1980)). +* Added `NativeOptions::event_loop_builder` hook for apps to change platform specific event loop options ([#1952](https://github.com/emilk/egui/pull/1952)). +* Enabled deferred render state initialization to support Android ([#1952](https://github.com/emilk/egui/pull/1952)). +* Added `shader_version` to `NativeOptions` for cross compiling support on different target OpenGL | ES versions (on native `glow` renderer only) ([#1993](https://github.com/emilk/egui/pull/1993)). +* Fix: app state is now saved when user presses Cmd-Q on Mac ([#2013](https://github.com/emilk/egui/pull/2013)). +* Added `center` to `NativeOptions` and `monitor_size` to `WindowInfo` on desktop ([#2035](https://github.com/emilk/egui/pull/2035)). +* Improve IME support ([#2046](https://github.com/emilk/egui/pull/2046)). +* Added mouse-passthrough option ([#2080](https://github.com/emilk/egui/pull/2080)). +* Added `NativeOptions::fullsize_content` option on Mac to build titlebar-less windows with floating window controls ([#2049](https://github.com/emilk/egui/pull/2049)). +* Wgpu device/adapter/surface creation has now various configuration options exposed via `NativeOptions/WebOptions::wgpu_options` ([#2207](https://github.com/emilk/egui/pull/2207)). +* Fix: Make sure that `native_pixels_per_point` is updated ([#2256](https://github.com/emilk/egui/pull/2256)). +* Added optional, but enabled by default, integration with [AccessKit](https://accesskit.dev/) for implementing platform accessibility APIs ([#2294](https://github.com/emilk/egui/pull/2294)). +* Fix: Less flickering on resize on Windows ([#2280](https://github.com/emilk/egui/pull/2280)). + +#### Web: +* ⚠️ BREAKING: `start_web` is a now `async` ([#2107](https://github.com/emilk/egui/pull/2107)). +* Web: You can now use WebGL on top of `wgpu` by enabling the `wgpu` feature (and disabling `glow` via disabling default features) ([#2107](https://github.com/emilk/egui/pull/2107)). +* Web: Add `WebInfo::user_agent` ([#2202](https://github.com/emilk/egui/pull/2202)). +* Web: you can access your application from JS using `AppRunner::app_mut`. See `crates/egui_demo_app/src/lib.rs` ([#1886](https://github.com/emilk/egui/pull/1886)). + + +## 0.19.0 - 2022-08-20 +* MSRV (Minimum Supported Rust Version) is now `1.61.0` ([#1846](https://github.com/emilk/egui/pull/1846)). +* Added `wgpu` rendering backed ([#1564](https://github.com/emilk/egui/pull/1564)): + * Added features `wgpu` and `glow`. + * Added `NativeOptions::renderer` to switch between the rendering backends. +* `egui_glow`: remove calls to `gl.get_error` in release builds to speed up rendering ([#1583](https://github.com/emilk/egui/pull/1583)). +* Added `App::post_rendering` for e.g. reading the framebuffer ([#1591](https://github.com/emilk/egui/pull/1591)). +* Use `Arc` for `glow::Context` instead of `Rc` ([#1640](https://github.com/emilk/egui/pull/1640)). +* Fixed bug where the result returned from `App::on_exit_event` would sometimes be ignored ([#1696](https://github.com/emilk/egui/pull/1696)). +* Added `NativeOptions::follow_system_theme` and `NativeOptions::default_theme` ([#1726](https://github.com/emilk/egui/pull/1726)). +* Selectively expose parts of the API based on target arch (`wasm32` or not) ([#1867](https://github.com/emilk/egui/pull/1867)). + +#### Desktop/Native: +* Fixed clipboard on Wayland ([#1613](https://github.com/emilk/egui/pull/1613)). +* Added ability to read window position and size with `frame.info().window_info` ([#1617](https://github.com/emilk/egui/pull/1617)). +* Allow running on native without hardware accelerated rendering. Change with `NativeOptions::hardware_acceleration` ([#1681](https://github.com/emilk/egui/pull/1681), [#1693](https://github.com/emilk/egui/pull/1693)). +* Fixed window position persistence ([#1745](https://github.com/emilk/egui/pull/1745)). +* Fixed mouse cursor change on Linux ([#1747](https://github.com/emilk/egui/pull/1747)). +* Added `Frame::set_visible` ([#1808](https://github.com/emilk/egui/pull/1808)). +* Added fullscreen support ([#1866](https://github.com/emilk/egui/pull/1866)). +* You can now continue execution after closing the native desktop window ([#1889](https://github.com/emilk/egui/pull/1889)). +* `Frame::quit` has been renamed to `Frame::close` and `App::on_exit_event` is now `App::on_close_event` ([#1943](https://github.com/emilk/egui/pull/1943)). + +#### Web: +* Added ability to stop/re-run web app from JavaScript. ⚠️ You need to update your CSS with `html, body: { height: 100%; width: 100%; }` ([#1803](https://github.com/emilk/egui/pull/1650)). +* Added `WebOptions::follow_system_theme` and `WebOptions::default_theme` ([#1726](https://github.com/emilk/egui/pull/1726)). +* Added option to select WebGL version ([#1803](https://github.com/emilk/egui/pull/1803)). + + +## 0.18.0 - 2022-04-30 +* MSRV (Minimum Supported Rust Version) is now `1.60.0` ([#1467](https://github.com/emilk/egui/pull/1467)). +* Removed `eframe::epi` - everything is now in `eframe` (`eframe::App`, `eframe::Frame` etc) ([#1545](https://github.com/emilk/egui/pull/1545)). +* Removed `Frame::request_repaint` - just call `egui::Context::request_repaint` for the same effect ([#1366](https://github.com/emilk/egui/pull/1366)). +* Changed app creation/setup ([#1363](https://github.com/emilk/egui/pull/1363)): + * Removed `App::setup` and `App::name`. + * Provide `CreationContext` when creating app with egui context, storage, integration info and glow context. + * Change interface of `run_native` and `start_web`. +* Added `Frame::storage()` and `Frame::storage_mut()` ([#1418](https://github.com/emilk/egui/pull/1418)). + * You can now load/save state in `App::update` + * Changed `App::update` to take `&mut Frame` instead of `&Frame`. + * `Frame` is no longer `Clone` or `Sync`. +* Added `glow` (OpenGL) context to `Frame` ([#1425](https://github.com/emilk/egui/pull/1425)). + +#### Desktop/Native: +* Remove the `egui_glium` feature. `eframe` will now always use `egui_glow` as the native backend ([#1357](https://github.com/emilk/egui/pull/1357)). +* Change default for `NativeOptions::drag_and_drop_support` to `true` ([#1329](https://github.com/emilk/egui/pull/1329)). +* Added new `NativeOptions`: `vsync`, `multisampling`, `depth_buffer`, `stencil_buffer`. +* `dark-light` (dark mode detection) is now an opt-in feature ([#1437](https://github.com/emilk/egui/pull/1437)). +* Fixed potential scale bug when DPI scaling changes (e.g. when dragging a window between different displays) ([#1441](https://github.com/emilk/egui/pull/1441)). +* Added new feature `puffin` to add [`puffin profiler`](https://github.com/EmbarkStudios/puffin) scopes ([#1483](https://github.com/emilk/egui/pull/1483)). +* Moved app persistence to a background thread, allowing for smoother frame rates (on native). +* Added `Frame::set_window_pos` ([#1505](https://github.com/emilk/egui/pull/1505)). + +#### Web: +* Use full browser width by default ([#1378](https://github.com/emilk/egui/pull/1378)). +* egui code will no longer be called after panic ([#1306](https://github.com/emilk/egui/pull/1306)). + + +## 0.17.0 - 2022-02-22 +* Removed `Frame::alloc_texture`. Use `egui::Context::load_texture` instead ([#1110](https://github.com/emilk/egui/pull/1110)). +* Shift-scroll will now result in horizontal scrolling on all platforms ([#1136](https://github.com/emilk/egui/pull/1136)). +* Log using the `tracing` crate. Log to stdout by adding `tracing_subscriber::fmt::init();` to your `main` ([#1192](https://github.com/emilk/egui/pull/1192)). + +#### Desktop/Native: +* The default native backend is now `egui_glow` (instead of `egui_glium`) ([#1020](https://github.com/emilk/egui/pull/1020)). +* Automatically detect and apply dark or light mode from system ([#1045](https://github.com/emilk/egui/pull/1045)). +* Fixed horizontal scrolling direction on Linux. +* Added `App::on_exit_event` ([#1038](https://github.com/emilk/egui/pull/1038)) +* Added `NativeOptions::initial_window_pos`. +* Fixed `enable_drag` for Windows OS ([#1108](https://github.com/emilk/egui/pull/1108)). + +#### Web: +* The default web painter is now `egui_glow` (instead of WebGL) ([#1020](https://github.com/emilk/egui/pull/1020)). +* Fixed glow failure on Chromium ([#1092](https://github.com/emilk/egui/pull/1092)). +* Updated `eframe::IntegrationInfo::web_location_hash` on `hashchange` event ([#1140](https://github.com/emilk/egui/pull/1140)). +* Expose all parts of the location/url in `frame.info().web_info` ([#1258](https://github.com/emilk/egui/pull/1258)). + + +## 0.16.0 - 2021-12-29 +* `Frame` can now be cloned, saved, and passed to background threads ([#999](https://github.com/emilk/egui/pull/999)). +* Added `Frame::request_repaint` to replace `repaint_signal` ([#999](https://github.com/emilk/egui/pull/999)). +* Added `Frame::alloc_texture/free_texture` to replace `tex_allocator` ([#999](https://github.com/emilk/egui/pull/999)). + +#### Web: +* Fixed [dark rendering in WebKitGTK](https://github.com/emilk/egui/issues/794) ([#888](https://github.com/emilk/egui/pull/888/)). +* Added feature `glow` to switch to a [`glow`](https://github.com/grovesNL/glow) based painter ([#868](https://github.com/emilk/egui/pull/868)). + + +## 0.15.0 - 2021-10-24 +* `Frame` now provides `set_window_title` to set window title dynamically ([#828](https://github.com/emilk/egui/pull/828)). +* `Frame` now provides `set_decorations` to set whether to show window decorations. +* Remove "http" feature (use https://github.com/emilk/ehttp instead!). +* Added `App::persist_native_window` and `App::persist_egui_memory` to control what gets persisted. + +#### Desktop/Native: +* Increase native scroll speed. +* Added new backend `egui_glow` as an alternative to `egui_glium`. Enable with `default-features = false, features = ["default_fonts", "egui_glow"]`. + +#### Web: +* Implement `eframe::NativeTexture` trait for the WebGL painter. +* Deprecate `Painter::register_webgl_texture. +* Fixed multiline paste. +* Fixed painting with non-opaque backgrounds. +* Improve text input on mobile and for IME. + + +## 0.14.0 - 2021-08-24 +* Added dragging and dropping files into egui. +* Improve http fetch API. +* `run_native` now returns when the app is closed. +* Web: Made text thicker and less pixelated. + + +## 0.13.1 - 2021-06-24 +* Fixed `http` feature flag and docs + + +## 0.13.0 - 2021-06-24 +* `App::setup` now takes a `Frame` and `Storage` by argument. +* `App::load` has been removed. Implement `App::setup` instead. +* Web: Default to light visuals unless the system reports a preference for dark mode. +* Web: Improve alpha blending, making fonts look much better (especially in light mode) +* Web: Fix double-paste bug + + +## 0.12.0 - 2021-05-10 +* Moved options out of `trait App` into new `NativeOptions`. +* Added option for `always_on_top`. +* Web: Scroll faster when scrolling with mouse wheel. + + +## 0.11.0 - 2021-04-05 +* You can now turn your window transparent with the `App::transparent` option. +* You can now disable window decorations with the `App::decorated` option. +* Web: [Fix mobile and IME text input](https://github.com/emilk/egui/pull/253) +* Web: Hold down a modifier key when clicking a link to open it in a new tab. + +Contributors: [n2](https://github.com/n2) + + +## 0.10.0 - 2021-02-28 +* [You can now set your own app icons](https://github.com/emilk/egui/pull/193). +* You can control the initial size of the native window with `App::initial_window_size`. +* You can control the maximum egui web canvas size with `App::max_size_points`. +* `Frame::tex_allocator()` no longer returns an `Option` (there is always a texture allocator). + + +## 0.9.0 - 2021-02-07 +* [Added support for HTTP body](https://github.com/emilk/egui/pull/139). +* Web: Right-clicks will no longer open browser context menu. +* Web: Fix a bug where one couldn't select items in a combo box on a touch screen. + + +## 0.8.0 - 2021-01-17 +* Simplify `TextureAllocator` interface. +* WebGL2 is now supported, with improved texture sampler. WebGL1 will be used as a fallback. +* Web: Slightly improved alpha-blending (work-around for non-existing linear-space blending). +* Web: Call `prevent_default` for arrow keys when entering text + + +## 0.7.0 - 2021-01-04 +* Initial release of `eframe` diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml new file mode 100644 index 00000000..b801b4e3 --- /dev/null +++ b/crates/eframe/Cargo.toml @@ -0,0 +1,221 @@ +[package] +name = "luminol-eframe" +version = "0.23.0" +authors = ["Emil Ernerfeldt "] +description = "egui framework - write GUI apps that compiles to web and/or natively" +edition.workspace = true +rust-version.workspace = true +homepage = "https://github.com/emilk/egui/tree/master/crates/eframe" +license.workspace = true +readme = "README.md" +repository = "https://github.com/emilk/egui/tree/master/crates/eframe" +categories = ["gui", "game-development"] +keywords = ["egui", "gui", "gamedev"] +include = [ + "LICENSE-APACHE", + "LICENSE-MIT", + "**/*.rs", + "Cargo.toml", + "data/icon.png", +] + +[package.metadata.docs.rs] +all-features = true +targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] + +[lib] + + +[features] +default = [ + "accesskit", + "default_fonts", + "wgpu", + "wayland", + "winit/default", + "x11", +] + +## Enable platform accessibility API implementations through [AccessKit](https://accesskit.dev/). +accesskit = ["egui/accesskit", "egui-winit/accesskit"] + +# Allow crates to choose an android-activity backend via Winit +# - It's important that most applications should not have to depend on android-activity directly, and can +# rely on Winit to pull in a suitable version (unlike most Rust crates, any version conflicts won't link) +# - It's also important that we don't impose an android-activity backend by taking this choice away from applications. + +## Enable the `game-activity` backend via `egui-winit` on Android +android-game-activity = ["egui-winit/android-game-activity"] +## Enable the `native-activity` backend via `egui-winit` on Android +android-native-activity = ["egui-winit/android-native-activity"] + +## If set, egui will use `include_bytes!` to bundle some fonts. +## If you plan on specifying your own fonts you may disable this feature. +default_fonts = ["egui/default_fonts"] + +## Use [`glow`](https://github.com/grovesNL/glow) for painting, via [`egui_glow`](https://github.com/emilk/egui/tree/master/crates/egui_glow). +#glow = ["dep:glow", "dep:egui_glow", "dep:glutin", "dep:glutin-winit"] + +## Enable saving app state to disk. +persistence = [ + "directories-next", + "egui-winit/serde", + "egui/persistence", + "ron", + "serde", +] + +## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate. +## +## `eframe` will call `puffin::GlobalProfiler::lock().new_frame()` for you +## +## Only enabled on native, because of the low resolution (1ms) of clocks in browsers. +puffin = ["dep:puffin", "egui/puffin", "egui_glow?/puffin", "luminol-egui-wgpu?/puffin"] + +## Enables wayland support and fixes clipboard issue. +wayland = ["egui-winit/wayland"] + +## Enable screen reader support (requires `ctx.options_mut(|o| o.screen_reader = true);`) on web. +## +## For other platforms, use the `accesskit` feature instead. +web_screen_reader = ["tts"] + +## Use [`wgpu`](https://docs.rs/wgpu) for painting (via [`egui-wgpu`](https://github.com/emilk/egui/tree/master/crates/egui-wgpu)). +## This overrides the `glow` feature. +wgpu = ["dep:wgpu", "dep:luminol-egui-wgpu", "dep:pollster", "dep:raw-window-handle"] + +## Enables compiling for x11. +x11 = ["egui-winit/x11"] + +## If set, eframe will look for the env-var `EFRAME_SCREENSHOT_TO` and write a screenshot to that location, and then quit. +## This is used to generate images for examples. +__screenshot = [] + +[dependencies] +egui = { workspace = true, features = [ + "bytemuck", + "log", +] } +log = { version = "0.4", features = ["std"] } +parking_lot = "0.12" +static_assertions = "1.1.0" +thiserror.workspace = true + +#! ### Optional dependencies +## Enable this when generating docs. +document-features = { version = "0.2", optional = true } + +egui_glow = { workspace = true, optional = true } +glow = { version = "0.12", optional = true } +ron = { workspace = true, optional = true, features = ["integer128"] } +serde = { version = "1", optional = true, features = ["derive"] } + +# ------------------------------------------- +# native: +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +egui-winit = { workspace = true, features = [ + "clipboard", + "links", +] } +image = { version = "0.24", default-features = false, features = [ + "png", +] } # Needed for app icon +raw-window-handle = { version = "0.5.0" } +winit = { version = "0.28.1", default-features = false } + +# optional native: +directories-next = { version = "2", optional = true } +luminol-egui-wgpu = { workspace = true, optional = true, features = [ + "winit", +] } # if wgpu is used, use it with winit +pollster = { version = "0.3", optional = true } # needed for wgpu + +# we can expose these to user so that they can select which backends they want to enable to avoid compiling useless deps. +# this can be done at the same time we expose x11/wayland features of winit crate. +glutin = { version = "0.30", optional = true } +glutin-winit = { version = "0.3.0", optional = true } +puffin = { version = "0.16", optional = true } +wgpu = { workspace = true, optional = true } + +# mac: +[target.'cfg(any(target_os = "macos"))'.dependencies] +cocoa = "0.24.1" # Stuck on old version until we update to winit 0.29 +objc = "0.2.7" + +# windows: +[target.'cfg(any(target_os = "windows"))'.dependencies] +winapi = "0.3.9" + +# ------------------------------------------- +# web: +[target.'cfg(target_arch = "wasm32")'.dependencies] +bytemuck = "1.7" +js-sys = "0.3" +percent-encoding = "2.1" +wasm-bindgen = "0.2.87" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3.58", features = [ + "BinaryType", + "Blob", + "Clipboard", + "ClipboardEvent", + "CompositionEvent", + "console", + "CssStyleDeclaration", + "DataTransfer", + "DataTransferItem", + "DataTransferItemList", + "Document", + "DomRect", + "DragEvent", + "Element", + "Event", + "EventListener", + "EventTarget", + "ExtSRgb", + "File", + "FileList", + "FocusEvent", + "HtmlCanvasElement", + "HtmlElement", + "HtmlInputElement", + "InputEvent", + "KeyboardEvent", + "Location", + "MediaQueryList", + "MediaQueryListEvent", + "MouseEvent", + "Navigator", + "Performance", + "Storage", + "Touch", + "TouchEvent", + "TouchList", + "WebGl2RenderingContext", + "WebglDebugRendererInfo", + "WebGlRenderingContext", + "WheelEvent", + "Window", + + # Unique to Luminol + "MutationObserver", + "MutationObserverInit", + "MutationRecord", + "WorkerLocation", + "WorkerNavigator", +] } + +once_cell.workspace = true + +flume.workspace = true +oneshot.workspace = true + +portable-atomic = { version = "1.5.1", features = ["float"] } + +luminol-web = { version = "0.4.0", path = "../web/" } + +# optional web: +luminol-egui-wgpu = { workspace = true, optional = true } # if wgpu is used, use it without (!) winit +raw-window-handle = { version = "0.5.2", optional = true } +tts = { version = "0.25", optional = true, default-features = false } +wgpu = { workspace = true, optional = true } diff --git a/crates/eframe/LICENSE-APACHE b/crates/eframe/LICENSE-APACHE new file mode 100644 index 00000000..11069edd --- /dev/null +++ b/crates/eframe/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/crates/eframe/LICENSE-MIT b/crates/eframe/LICENSE-MIT new file mode 100644 index 00000000..673ea5f0 --- /dev/null +++ b/crates/eframe/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2018-2021 Emil Ernerfeldt + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/crates/eframe/README.md b/crates/eframe/README.md new file mode 100644 index 00000000..46f7218d --- /dev/null +++ b/crates/eframe/README.md @@ -0,0 +1,89 @@ +> [!IMPORTANT] +> luminol-eframe is currently based on emilk/egui@0.23.0 + +> [!NOTE] +> This is Luminol's modified version of eframe. The original version is dual-licensed under MIT and Apache 2.0. +> +> To merge changes from upstream into this crate, first add egui as a remote: +> +> ``` +> git remote add -f --no-tags egui https://github.com/emilk/egui +> ``` +> +> Now, decide on which upstream egui commit you want to merge from and figure out the egui commit that the previous upstream merge was based on. The basis of the previous upstream merge should be written at the top of this README. **Please update the top of this README after merging.** +> +> In this example, we are merging from commit `bd087ffb8d7467e0b5aa06d17dd600d511d6a5e8` (egui 0.24.0) and the previous merge was based on commit `5a0186fa2b2324ab437099e456e55e281234ca99` (egui 0.23.0). +> +> ``` +> git diff \ +> 5a0186fa2b2324ab437099e456e55e281234ca99:crates/eframe \ +> bd087ffb8d7467e0b5aa06d17dd600d511d6a5e8:crates/eframe | +> git apply -3 --directory=crates/eframe +> ``` +> +> Fix any merge conflicts, and then do `git commit`. + +# eframe: the [`egui`](https://github.com/emilk/egui) framework + +[![Latest version](https://img.shields.io/crates/v/eframe.svg)](https://crates.io/crates/eframe) +[![Documentation](https://docs.rs/eframe/badge.svg)](https://docs.rs/eframe) +![MIT](https://img.shields.io/badge/license-MIT-blue.svg) +![Apache](https://img.shields.io/badge/license-Apache-blue.svg) + +`eframe` is the official framework library for writing apps using [`egui`](https://github.com/emilk/egui). The app can be compiled both to run natively (cross platform) or be compiled to a web app (using WASM). + +To get started, see the [examples](https://github.com/emilk/egui/tree/master/examples). +To learn how to set up `eframe` for web and native, go to and follow the instructions there! + +There is also a tutorial video at . + +For how to use `egui`, see [the egui docs](https://docs.rs/egui). + +--- + +`eframe` uses [`egui_glow`](https://github.com/emilk/egui/tree/master/crates/egui_glow) for rendering, and on native it uses [`egui-winit`](https://github.com/emilk/egui/tree/master/crates/egui-winit). + +To use on Linux, first run: + +``` +sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev +``` + +You need to either use `edition = "2021"`, or set `resolver = "2"` in the `[workspace]` section of your to-level `Cargo.toml`. See [this link](https://doc.rust-lang.org/edition-guide/rust-2021/default-cargo-resolver.html) for more info. + +You can opt-in to the using [`egui_wgpu`](https://github.com/emilk/egui/tree/master/crates/egui_wgpu) for rendering by enabling the `wgpu` feature and setting `NativeOptions::renderer` to `Renderer::Wgpu`. + +To get copy-paste working on web, you need to compile with `export RUSTFLAGS=--cfg=web_sys_unstable_apis`. + +## Alternatives +`eframe` is not the only way to write an app using `egui`! You can also try [`egui-miniquad`](https://github.com/not-fl3/egui-miniquad), [`bevy_egui`](https://github.com/mvlabat/bevy_egui), [`egui_sdl2_gl`](https://github.com/ArjunNair/egui_sdl2_gl), and others. + +You can also use `egui_glow` and [`winit`](https://github.com/rust-windowing/winit) to build your own app as demonstrated in . + + +## Problems with running egui on the web +`eframe` uses WebGL (via [`glow`](https://crates.io/crates/glow)) and WASM, and almost nothing else from the web tech stack. This has some benefits, but also produces some challenges and serious downsides. + +* Rendering: Getting pixel-perfect rendering right on the web is very difficult. +* Search: you cannot search an egui web page like you would a normal web page. +* Bringing up an on-screen keyboard on mobile: there is no JS function to do this, so `eframe` fakes it by adding some invisible DOM elements. It doesn't always work. +* Mobile text editing is not as good as for a normal web app. +* Accessibility: There is an experimental screen reader for `eframe`, but it has to be enabled explicitly. There is no JS function to ask "Does the user want a screen reader?" (and there should probably not be such a function, due to user tracking/integrity concerns). +* No integration with browser settings for colors and fonts. + +In many ways, `eframe` is trying to make the browser do something it wasn't designed to do (though there are many things browser vendors could do to improve how well libraries like egui work). + +The suggested use for `eframe` are for web apps where performance and responsiveness are more important than accessibility and mobile text editing. + + +## Companion crates +Not all rust crates work when compiled to WASM, but here are some useful crates have been designed to work well both natively and as WASM: + +* Audio: [`cpal`](https://github.com/RustAudio/cpal). +* HTTP client: [`ehttp`](https://github.com/emilk/ehttp) and [`reqwest`](https://github.com/seanmonstar/reqwest). +* Time: [`chrono`](https://github.com/chronotope/chrono). +* WebSockets: [`ewebsock`](https://github.com/rerun-io/ewebsock). + + +## Name +The _frame_ in `eframe` stands both for the frame in which your `egui` app resides and also for "framework" (`eframe` is a framework, `egui` is a library). diff --git a/crates/eframe/data/icon.png b/crates/eframe/data/icon.png new file mode 100644 index 00000000..cf1e6c3e Binary files /dev/null and b/crates/eframe/data/icon.png differ diff --git a/crates/eframe/src/epi/icon_data.rs b/crates/eframe/src/epi/icon_data.rs new file mode 100644 index 00000000..078b5771 --- /dev/null +++ b/crates/eframe/src/epi/icon_data.rs @@ -0,0 +1,76 @@ +/// Image data for an application icon. +/// +/// Use a square image, e.g. 256x256 pixels. +/// You can use a transparent background. +#[derive(Clone)] +pub struct IconData { + /// RGBA pixels, with separate/unmultiplied alpha. + pub rgba: Vec, + + /// Image width. This should be a multiple of 4. + pub width: u32, + + /// Image height. This should be a multiple of 4. + pub height: u32, +} + +impl std::fmt::Debug for IconData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IconData") + .field("width", &self.width) + .field("height", &self.height) + .finish_non_exhaustive() + } +} + +impl IconData { + /// Convert into [`image::RgbaImage`] + /// + /// # Errors + /// If this is not a valid png. + pub fn try_from_png_bytes(png_bytes: &[u8]) -> Result { + crate::profile_function!(); + let image = image::load_from_memory(png_bytes)?; + Ok(Self::from_image(image)) + } + + fn from_image(image: image::DynamicImage) -> Self { + let image = image.into_rgba8(); + Self { + width: image.width(), + height: image.height(), + rgba: image.into_raw(), + } + } + + /// Convert into [`image::RgbaImage`] + /// + /// # Errors + /// If `width*height != 4 * rgba.len()`, or if the image is too big. + pub fn to_image(&self) -> Result { + crate::profile_function!(); + let Self { + rgba, + width, + height, + } = self.clone(); + image::RgbaImage::from_raw(width, height, rgba).ok_or_else(|| "Invalid IconData".to_owned()) + } + + /// Encode as PNG. + /// + /// # Errors + /// The image is invalid, or the PNG encoder failed. + pub fn to_png_bytes(&self) -> Result, String> { + crate::profile_function!(); + let image = self.to_image()?; + let mut png_bytes: Vec = Vec::new(); + image + .write_to( + &mut std::io::Cursor::new(&mut png_bytes), + image::ImageOutputFormat::Png, + ) + .map_err(|err| err.to_string())?; + Ok(png_bytes) + } +} diff --git a/crates/eframe/src/epi/mod.rs b/crates/eframe/src/epi/mod.rs new file mode 100644 index 00000000..40f23317 --- /dev/null +++ b/crates/eframe/src/epi/mod.rs @@ -0,0 +1,1274 @@ +//! Platform-agnostic interface for writing apps using [`egui`] (epi = egui programming interface). +//! +//! `epi` provides interfaces for window management and serialization. +//! +//! Start by looking at the [`App`] trait, and implement [`App::update`]. + +#![warn(missing_docs)] // Let's keep `epi` well-documented. + +#[cfg(not(target_arch = "wasm32"))] +mod icon_data; + +#[cfg(not(target_arch = "wasm32"))] +pub use icon_data::IconData; + +#[cfg(target_arch = "wasm32")] +use std::any::Any; + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu"))] +pub use crate::native::run::UserEvent; + +#[cfg(not(target_arch = "wasm32"))] +use raw_window_handle::{ + HasRawDisplayHandle, HasRawWindowHandle, RawDisplayHandle, RawWindowHandle, +}; +#[cfg(not(target_arch = "wasm32"))] +use static_assertions::assert_not_impl_any; + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu"))] +pub use winit::{event_loop::EventLoopBuilder, window::WindowBuilder}; + +/// Hook into the building of an event loop before it is run +/// +/// You can configure any platform specific details required on top of the default configuration +/// done by `EFrame`. +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu"))] +pub type EventLoopBuilderHook = Box)>; + +/// Hook into the building of a the native window. +/// +/// You can configure any platform specific details required on top of the default configuration +/// done by `eframe`. +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu"))] +pub type WindowBuilderHook = Box WindowBuilder>; + +/// This is how your app is created. +/// +/// You can use the [`CreationContext`] to setup egui, restore state, setup OpenGL things, etc. +pub type AppCreator = Box) -> Box>; + +/// Data that is passed to [`AppCreator`] that can be used to setup and initialize your app. +pub struct CreationContext<'s> { + /// The egui Context. + /// + /// You can use this to customize the look of egui, e.g to call [`egui::Context::set_fonts`], + /// [`egui::Context::set_visuals`] etc. + pub egui_ctx: egui::Context, + + /// Information about the surrounding environment. + pub integration_info: IntegrationInfo, + + /// You can use the storage to restore app state(requires the "persistence" feature). + pub storage: Option<&'s dyn Storage>, + + /// The [`glow::Context`] allows you to initialize OpenGL resources (e.g. shaders) that + /// you might want to use later from a [`egui::PaintCallback`]. + /// + /// Only available when compiling with the `glow` feature and using [`Renderer::Glow`]. + #[cfg(feature = "glow")] + pub gl: Option>, + + /// The underlying WGPU render state. + /// + /// Only available when compiling with the `wgpu` feature and using [`Renderer::Wgpu`]. + /// + /// Can be used to manage GPU resources for custom rendering with WGPU using [`egui::PaintCallback`]s. + #[cfg(feature = "wgpu")] + pub wgpu_render_state: Option, + + /// Raw platform window handle + #[cfg(not(target_arch = "wasm32"))] + pub(crate) raw_window_handle: RawWindowHandle, + + /// Raw platform display handle for window + #[cfg(not(target_arch = "wasm32"))] + pub(crate) raw_display_handle: RawDisplayHandle, +} + +// Implementing `Clone` would violate the guarantees of `HasRawWindowHandle` and `HasRawDisplayHandle`. +#[cfg(not(target_arch = "wasm32"))] +assert_not_impl_any!(CreationContext<'_>: Clone); + +#[allow(unsafe_code)] +#[cfg(not(target_arch = "wasm32"))] +unsafe impl HasRawWindowHandle for CreationContext<'_> { + fn raw_window_handle(&self) -> RawWindowHandle { + self.raw_window_handle + } +} + +#[allow(unsafe_code)] +#[cfg(not(target_arch = "wasm32"))] +unsafe impl HasRawDisplayHandle for CreationContext<'_> { + fn raw_display_handle(&self) -> RawDisplayHandle { + self.raw_display_handle + } +} + +// ---------------------------------------------------------------------------- + +/// Implement this trait to write apps that can be compiled for both web/wasm and desktop/native using [`eframe`](https://github.com/emilk/egui/tree/master/crates/eframe). +pub trait App { + /// Called each time the UI needs repainting, which may be many times per second. + /// + /// Put your widgets into a [`egui::SidePanel`], [`egui::TopBottomPanel`], [`egui::CentralPanel`], [`egui::Window`] or [`egui::Area`]. + /// + /// The [`egui::Context`] can be cloned and saved if you like. + /// + /// To force a repaint, call [`egui::Context::request_repaint`] at any time (e.g. from another thread). + fn update(&mut self, ctx: &egui::Context, frame: &mut Frame); + + /// Get a handle to the app. + /// + /// Can be used from web to interact or other external context. + /// + /// You need to implement this if you want to be able to access the application from JS using [`crate::WebRunner::app_mut`]. + /// + /// This is needed because downcasting `Box` -> `Box` to get &`ConcreteApp` is not simple in current rust. + /// + /// Just copy-paste this as your implementation: + /// ```ignore + /// #[cfg(target_arch = "wasm32")] + /// fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + /// Some(&mut *self) + /// } + /// ``` + #[cfg(target_arch = "wasm32")] + fn as_any_mut(&mut self) -> Option<&mut dyn Any> { + None + } + + /// Called on shutdown, and perhaps at regular intervals. Allows you to save state. + /// + /// Only called when the "persistence" feature is enabled. + /// + /// On web the state is stored to "Local Storage". + /// + /// On native the path is picked using [`crate::storage_dir`]. + fn save(&mut self, _storage: &mut dyn Storage) {} + + /// Called when the user attempts to close the desktop window and/or quit the application. + /// + /// By returning `false` the closing will be aborted. To continue the closing return `true`. + /// + /// A scenario where this method will be run is after pressing the close button on a native + /// window, which allows you to ask the user whether they want to do something before exiting. + /// See the example at for practical usage. + /// + /// It will _not_ be called on the web or when the window is forcefully closed. + #[cfg(not(target_arch = "wasm32"))] + #[doc(alias = "exit")] + #[doc(alias = "quit")] + fn on_close_event(&mut self) -> bool { + true + } + + /// Called once on shutdown, after [`Self::save`]. + /// + /// If you need to abort an exit use [`Self::on_close_event`]. + /// + /// To get a [`glow`] context you need to compile with the `glow` feature flag, + /// and run eframe with the glow backend. + #[cfg(feature = "glow")] + fn on_exit(&mut self, _gl: Option<&glow::Context>) {} + + /// Called once on shutdown, after [`Self::save`]. + /// + /// If you need to abort an exit use [`Self::on_close_event`]. + #[cfg(not(feature = "glow"))] + fn on_exit(&mut self) {} + + // --------- + // Settings: + + /// Time between automatic calls to [`Self::save`] + fn auto_save_interval(&self) -> std::time::Duration { + std::time::Duration::from_secs(30) + } + + /// Background color values for the app, e.g. what is sent to `gl.clearColor`. + /// + /// This is the background of your windows if you don't set a central panel. + /// + /// ATTENTION: + /// Since these float values go to the render as-is, any color space conversion as done + /// e.g. by converting from [`egui::Color32`] to [`egui::Rgba`] may cause incorrect results. + /// egui recommends that rendering backends use a normal "gamma-space" (non-sRGB-aware) blending, + /// which means the values you return here should also be in `sRGB` gamma-space in the 0-1 range. + /// You can use [`egui::Color32::to_normalized_gamma_f32`] for this. + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + // NOTE: a bright gray makes the shadows of the windows look weird. + // We use a bit of transparency so that if the user switches on the + // `transparent()` option they get immediate results. + egui::Color32::from_rgba_unmultiplied(12, 12, 12, 180).to_normalized_gamma_f32() + + // _visuals.window_fill() would also be a natural choice + } + + /// Controls whether or not the egui memory (window positions etc) will be + /// persisted (only if the "persistence" feature is enabled). + fn persist_egui_memory(&self) -> bool { + true + } + + /// If `true` a warm-up call to [`Self::update`] will be issued where + /// `ctx.memory(|mem| mem.everything_is_visible())` will be set to `true`. + /// + /// This can help pre-caching resources loaded by different parts of the UI, preventing stutter later on. + /// + /// In this warm-up call, all painted shapes will be ignored. + /// + /// The default is `false`, and it is unlikely you will want to change this. + fn warm_up_enabled(&self) -> bool { + false + } + + /// Called each time after the rendering the UI. + /// + /// Can be used to access pixel data with [`Frame::screenshot`] + fn post_rendering(&mut self, _window_size_px: [u32; 2], _frame: &Frame) {} +} + +/// Selects the level of hardware graphics acceleration. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum HardwareAcceleration { + /// Require graphics acceleration. + Required, + + /// Prefer graphics acceleration, but fall back to software. + Preferred, + + /// Do NOT use graphics acceleration. + /// + /// On some platforms (MacOS) this is ignored and treated the same as [`Self::Preferred`]. + Off, +} + +/// Options controlling the behavior of a native window. +/// +/// Only a single native window is currently supported. +#[cfg(not(target_arch = "wasm32"))] +pub struct NativeOptions { + /// Sets whether or not the window will always be on top of other windows at initialization. + pub always_on_top: bool, + + /// Show window in maximized mode + pub maximized: bool, + + /// On desktop: add window decorations (i.e. a frame around your app)? + /// If false it will be difficult to move and resize the app. + pub decorated: bool, + + /// Start in (borderless) fullscreen? + /// + /// Default: `false`. + pub fullscreen: bool, + + /// On Mac: the window doesn't have a titlebar, but floating window buttons. + /// + /// See [winit's documentation][with_fullsize_content_view] for information on Mac-specific options. + /// + /// [with_fullsize_content_view]: https://docs.rs/winit/latest/x86_64-apple-darwin/winit/platform/macos/trait.WindowBuilderExtMacOS.html#tymethod.with_fullsize_content_view + #[cfg(target_os = "macos")] + pub fullsize_content: bool, + + /// On Windows: enable drag and drop support. Drag and drop can + /// not be disabled on other platforms. + /// + /// See [winit's documentation][drag_and_drop] for information on why you + /// might want to disable this on windows. + /// + /// [drag_and_drop]: https://docs.rs/winit/latest/x86_64-pc-windows-msvc/winit/platform/windows/trait.WindowBuilderExtWindows.html#tymethod.with_drag_and_drop + pub drag_and_drop_support: bool, + + /// The application icon, e.g. in the Windows task bar or the alt-tab menu. + /// + /// The default icon is a white `e` on a black background (for "egui" or "eframe"). + /// If you prefer the OS default, set this to `None`. + pub icon_data: Option, + + /// The initial (inner) position of the native window in points (logical pixels). + pub initial_window_pos: Option, + + /// The initial inner size of the native window in points (logical pixels). + pub initial_window_size: Option, + + /// The minimum inner window size in points (logical pixels). + pub min_window_size: Option, + + /// The maximum inner window size in points (logical pixels). + pub max_window_size: Option, + + /// Should the app window be resizable? + pub resizable: bool, + + /// On desktop: make the window transparent. + /// + /// You control the transparency with [`App::clear_color()`]. + /// You should avoid having a [`egui::CentralPanel`], or make sure its frame is also transparent. + pub transparent: bool, + + /// On desktop: mouse clicks pass through the window, used for non-interactable overlays + /// Generally you would use this in conjunction with always_on_top + pub mouse_passthrough: bool, + + /// Whether grant focus when window initially opened. True by default. + pub active: bool, + + /// Turn on vertical syncing, limiting the FPS to the display refresh rate. + /// + /// The default is `true`. + pub vsync: bool, + + /// Set the level of the multisampling anti-aliasing (MSAA). + /// + /// Must be a power-of-two. Higher = more smooth 3D. + /// + /// A value of `0` turns it off (default). + /// + /// `egui` already performs anti-aliasing via "feathering" + /// (controlled by [`egui::epaint::TessellationOptions`]), + /// but if you are embedding 3D in egui you may want to turn on multisampling. + pub multisampling: u16, + + /// Sets the number of bits in the depth buffer. + /// + /// `egui` doesn't need the depth buffer, so the default value is 0. + pub depth_buffer: u8, + + /// Sets the number of bits in the stencil buffer. + /// + /// `egui` doesn't need the stencil buffer, so the default value is 0. + pub stencil_buffer: u8, + + /// Specify whether or not hardware acceleration is preferred, required, or not. + /// + /// Default: [`HardwareAcceleration::Preferred`]. + pub hardware_acceleration: HardwareAcceleration, + + /// What rendering backend to use. + #[cfg(any(feature = "glow", feature = "wgpu"))] + pub renderer: Renderer, + + /// Try to detect and follow the system preferred setting for dark vs light mode. + /// + /// The theme will automatically change when the dark vs light mode preference is changed. + /// + /// Does not work on Linux (see ). + /// + /// See also [`Self::default_theme`]. + pub follow_system_theme: bool, + + /// Which theme to use in case [`Self::follow_system_theme`] is `false` + /// or eframe fails to detect the system theme. + /// + /// Default: [`Theme::Dark`]. + pub default_theme: Theme, + + /// This controls what happens when you close the main eframe window. + /// + /// If `true`, execution will continue after the eframe window is closed. + /// If `false`, the app will close once the eframe window is closed. + /// + /// This is `true` by default, and the `false` option is only there + /// so we can revert if we find any bugs. + /// + /// This feature was introduced in . + /// + /// When `true`, [`winit::platform::run_return::EventLoopExtRunReturn::run_return`] is used. + /// When `false`, [`winit::event_loop::EventLoop::run`] is used. + pub run_and_return: bool, + + /// Hook into the building of an event loop before it is run. + /// + /// Specify a callback here in case you need to make platform specific changes to the + /// event loop before it is run. + /// + /// Note: A [`NativeOptions`] clone will not include any `event_loop_builder` hook. + #[cfg(any(feature = "glow", feature = "wgpu"))] + pub event_loop_builder: Option, + + /// Hook into the building of a window. + /// + /// Specify a callback here in case you need to make platform specific changes to the + /// window appearance. + /// + /// Note: A [`NativeOptions`] clone will not include any `window_builder` hook. + #[cfg(any(feature = "glow", feature = "wgpu"))] + pub window_builder: Option, + + #[cfg(feature = "glow")] + /// Needed for cross compiling for VirtualBox VMSVGA driver with OpenGL ES 2.0 and OpenGL 2.1 which doesn't support SRGB texture. + /// See . + /// + /// For OpenGL ES 2.0: set this to [`egui_glow::ShaderVersion::Es100`] to solve blank texture problem (by using the "fallback shader"). + pub shader_version: Option, + + /// On desktop: make the window position to be centered at initialization. + /// + /// Platform specific: + /// + /// Wayland desktop currently not supported. + pub centered: bool, + + /// Configures wgpu instance/device/adapter/surface creation and renderloop. + #[cfg(feature = "wgpu")] + pub wgpu_options: luminol_egui_wgpu::WgpuConfiguration, + + /// The application id, used for determining the folder to persist the app to. + /// + /// On native the path is picked using [`crate::storage_dir`]. + /// + /// If you don't set [`Self::app_id`], the title argument to [`crate::run_native`] + /// will be used as app id instead. + /// + /// ### On Wayland + /// On Wayland this sets the Application ID for the window. + /// + /// The application ID is used in several places of the compositor, e.g. for + /// grouping windows of the same application. It is also important for + /// connecting the configuration of a `.desktop` file with the window, by + /// using the application ID as file name. This allows e.g. a proper icon + /// handling under Wayland. + /// + /// See [Waylands XDG shell documentation][xdg-shell] for more information + /// on this Wayland-specific option. + /// + /// [xdg-shell]: https://wayland.app/protocols/xdg-shell#xdg_toplevel:request:set_app_id + /// + /// # Example + /// ``` no_run + /// fn main() -> luminol_eframe::Result<()> { + /// + /// let mut options = luminol_eframe::NativeOptions::default(); + /// // Set the application ID for Wayland only on Linux + /// #[cfg(target_os = "linux")] + /// { + /// options.app_id = Some("egui-example".to_string()); + /// } + /// + /// luminol_eframe::run_simple_native("My egui App", options, move |ctx, _frame| { + /// egui::CentralPanel::default().show(ctx, |ui| { + /// ui.heading("My egui Application"); + /// }); + /// }) + /// } + /// ``` + pub app_id: Option, + + /// Controls whether or not the native window position and size will be + /// persisted (only if the "persistence" feature is enabled). + pub persist_window: bool, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Clone for NativeOptions { + fn clone(&self) -> Self { + Self { + icon_data: self.icon_data.clone(), + + #[cfg(any(feature = "glow", feature = "wgpu"))] + event_loop_builder: None, // Skip any builder callbacks if cloning + + #[cfg(any(feature = "glow", feature = "wgpu"))] + window_builder: None, // Skip any builder callbacks if cloning + + #[cfg(feature = "wgpu")] + wgpu_options: self.wgpu_options.clone(), + + app_id: self.app_id.clone(), + + ..*self + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Default for NativeOptions { + fn default() -> Self { + Self { + always_on_top: false, + maximized: false, + decorated: true, + fullscreen: false, + + #[cfg(target_os = "macos")] + fullsize_content: false, + + // We set a default "egui" or "eframe" icon, which is usually more distinctive than the default OS icon. + icon_data: Some( + IconData::try_from_png_bytes(&include_bytes!("../../data/icon.png")[..]).unwrap(), + ), + + drag_and_drop_support: true, + initial_window_pos: None, + initial_window_size: None, + min_window_size: None, + max_window_size: None, + resizable: true, + transparent: false, + mouse_passthrough: false, + + active: true, + + vsync: true, + multisampling: 0, + depth_buffer: 0, + stencil_buffer: 0, + hardware_acceleration: HardwareAcceleration::Preferred, + + #[cfg(any(feature = "glow", feature = "wgpu"))] + renderer: Renderer::default(), + + follow_system_theme: cfg!(target_os = "macos") || cfg!(target_os = "windows"), + default_theme: Theme::Dark, + run_and_return: true, + + #[cfg(any(feature = "glow", feature = "wgpu"))] + event_loop_builder: None, + + #[cfg(any(feature = "glow", feature = "wgpu"))] + window_builder: None, + + #[cfg(feature = "glow")] + shader_version: None, + + centered: false, + + #[cfg(feature = "wgpu")] + wgpu_options: luminol_egui_wgpu::WgpuConfiguration::default(), + + app_id: None, + + persist_window: true, + } + } +} + +// ---------------------------------------------------------------------------- + +/// Options when using `eframe` in a web page. +#[cfg(target_arch = "wasm32")] +pub struct WebOptions { + /// Try to detect and follow the system preferred setting for dark vs light mode. + /// + /// See also [`Self::default_theme`]. + /// + /// Default: `true`. + pub follow_system_theme: bool, + + /// Which theme to use in case [`Self::follow_system_theme`] is `false` + /// or system theme detection fails. + /// + /// Default: `Theme::Dark`. + pub default_theme: Theme, + + /// Sets the number of bits in the depth buffer. + /// + /// `egui` doesn't need the depth buffer, so the default value is 0. + /// Unused by webgl context as of writing. + pub depth_buffer: u8, + + /// Which version of WebGl context to select + /// + /// Default: [`WebGlContextOption::BestFirst`]. + #[cfg(feature = "glow")] + pub webgl_context_option: WebGlContextOption, + + /// Configures wgpu instance/device/adapter/surface creation and renderloop. + #[cfg(feature = "wgpu")] + pub wgpu_options: luminol_egui_wgpu::WgpuConfiguration, + + /// The size limit of the web app canvas. + /// + /// By default the max size is [`egui::Vec2::INFINITY`], i.e. unlimited. + pub max_size_points: egui::Vec2, +} + +#[cfg(target_arch = "wasm32")] +impl Default for WebOptions { + fn default() -> Self { + Self { + follow_system_theme: true, + default_theme: Theme::Dark, + depth_buffer: 0, + + #[cfg(feature = "glow")] + webgl_context_option: WebGlContextOption::BestFirst, + + #[cfg(feature = "wgpu")] + wgpu_options: luminol_egui_wgpu::WgpuConfiguration::default(), + + max_size_points: egui::Vec2::INFINITY, + } + } +} + +// ---------------------------------------------------------------------------- + +/// Dark or Light theme. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum Theme { + /// Dark mode: light text on a dark background. + Dark, + + /// Light mode: dark text on a light background. + Light, +} + +impl Theme { + /// Get the egui visuals corresponding to this theme. + /// + /// Use with [`egui::Context::set_visuals`]. + pub fn egui_visuals(self) -> egui::Visuals { + match self { + Self::Dark => egui::Visuals::dark(), + Self::Light => egui::Visuals::light(), + } + } +} + +// ---------------------------------------------------------------------------- + +/// WebGL Context options +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum WebGlContextOption { + /// Force Use WebGL1. + WebGl1, + + /// Force use WebGL2. + WebGl2, + + /// Use WebGl2 first. + BestFirst, + + /// Use WebGl1 first + CompatibilityFirst, +} + +// ---------------------------------------------------------------------------- + +/// What rendering backend to use. +/// +/// You need to enable the "glow" and "wgpu" features to have a choice. +#[cfg(any(feature = "glow", feature = "wgpu"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum Renderer { + /// Use [`egui_glow`] renderer for [`glow`](https://github.com/grovesNL/glow). + #[cfg(feature = "glow")] + Glow, + + /// Use [`egui_wgpu`] renderer for [`wgpu`](https://github.com/gfx-rs/wgpu). + #[cfg(feature = "wgpu")] + Wgpu, +} + +#[cfg(any(feature = "glow", feature = "wgpu"))] +impl Default for Renderer { + fn default() -> Self { + #[cfg(feature = "glow")] + return Self::Glow; + + #[cfg(not(feature = "glow"))] + #[cfg(feature = "wgpu")] + return Self::Wgpu; + + #[cfg(not(feature = "glow"))] + #[cfg(not(feature = "wgpu"))] + compile_error!("eframe: you must enable at least one of the rendering backend features: 'glow' or 'wgpu'"); + } +} + +#[cfg(any(feature = "glow", feature = "wgpu"))] +impl std::fmt::Display for Renderer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + #[cfg(feature = "glow")] + Self::Glow => "glow".fmt(f), + + #[cfg(feature = "wgpu")] + Self::Wgpu => "wgpu".fmt(f), + } + } +} + +#[cfg(any(feature = "glow", feature = "wgpu"))] +impl std::str::FromStr for Renderer { + type Err = String; + + fn from_str(name: &str) -> Result { + match name.to_lowercase().as_str() { + #[cfg(feature = "glow")] + "glow" => Ok(Self::Glow), + + #[cfg(feature = "wgpu")] + "wgpu" => Ok(Self::Wgpu), + + _ => Err(format!("eframe renderer {name:?} is not available. Make sure that the corresponding eframe feature is enabled.")) + } + } +} + +// ---------------------------------------------------------------------------- + +/// Represents the surroundings of your app. +/// +/// It provides methods to inspect the surroundings (are we on the web?), +/// allocate textures, and change settings (e.g. window size). +pub struct Frame { + /// Information about the integration. + pub(crate) info: IntegrationInfo, + + /// Where the app can issue commands back to the integration. + pub(crate) output: backend::AppOutput, + + /// A place where you can store custom data in a way that persists when you restart the app. + pub(crate) storage: Option>, + + /// A reference to the underlying [`glow`] (OpenGL) context. + #[cfg(feature = "glow")] + pub(crate) gl: Option>, + + /// Can be used to manage GPU resources for custom rendering with WGPU using [`egui::PaintCallback`]s. + #[cfg(feature = "wgpu")] + pub(crate) wgpu_render_state: Option, + + /// If [`Frame::request_screenshot`] was called during a frame, this field will store the screenshot + /// such that it can be retrieved during [`App::post_rendering`] with [`Frame::screenshot`] + #[cfg(not(target_arch = "wasm32"))] + pub(crate) screenshot: std::cell::Cell>, + + /// Raw platform window handle + #[cfg(not(target_arch = "wasm32"))] + pub(crate) raw_window_handle: RawWindowHandle, + + /// Raw platform display handle for window + #[cfg(not(target_arch = "wasm32"))] + pub(crate) raw_display_handle: RawDisplayHandle, +} + +// Implementing `Clone` would violate the guarantees of `HasRawWindowHandle` and `HasRawDisplayHandle`. +#[cfg(not(target_arch = "wasm32"))] +assert_not_impl_any!(Frame: Clone); + +#[allow(unsafe_code)] +#[cfg(not(target_arch = "wasm32"))] +unsafe impl HasRawWindowHandle for Frame { + fn raw_window_handle(&self) -> RawWindowHandle { + self.raw_window_handle + } +} + +#[allow(unsafe_code)] +#[cfg(not(target_arch = "wasm32"))] +unsafe impl HasRawDisplayHandle for Frame { + fn raw_display_handle(&self) -> RawDisplayHandle { + self.raw_display_handle + } +} + +impl Frame { + /// True if you are in a web environment. + /// + /// Equivalent to `cfg!(target_arch = "wasm32")` + #[allow(clippy::unused_self)] + pub fn is_web(&self) -> bool { + cfg!(target_arch = "wasm32") + } + + /// Information about the integration. + pub fn info(&self) -> &IntegrationInfo { + &self.info + } + + /// A place where you can store custom data in a way that persists when you restart the app. + pub fn storage(&self) -> Option<&dyn Storage> { + self.storage.as_deref() + } + + /// Request the current frame's pixel data. Needs to be retrieved by calling [`Frame::screenshot`] + /// during [`App::post_rendering`]. + #[cfg(not(target_arch = "wasm32"))] + pub fn request_screenshot(&mut self) { + self.output.screenshot_requested = true; + } + + /// Cancel a request made with [`Frame::request_screenshot`]. + #[cfg(not(target_arch = "wasm32"))] + pub fn cancel_screenshot_request(&mut self) { + self.output.screenshot_requested = false; + } + + /// During [`App::post_rendering`], use this to retrieve the pixel data that was requested during + /// [`App::update`] via [`Frame::request_screenshot`]. + /// + /// Returns None if: + /// * Called in [`App::update`] + /// * [`Frame::request_screenshot`] wasn't called on this frame during [`App::update`] + /// * The rendering backend doesn't support this feature (yet). Currently implemented for wgpu and glow, but not with wasm as target. + /// * Wgpu's GL target is active (not yet supported) + /// * Retrieving the data was unsuccessful in some way. + /// + /// See also [`egui::ColorImage::region`] + /// + /// ## Example generating a capture of everything within a square of 100 pixels located at the top left of the app and saving it with the [`image`](crates.io/crates/image) crate: + /// ``` + /// struct MyApp; + /// + /// impl luminol_eframe::App for MyApp { + /// fn update(&mut self, ctx: &egui::Context, frame: &mut luminol_eframe::Frame) { + /// // In real code the app would render something here + /// frame.request_screenshot(); + /// // Things that are added to the frame after the call to + /// // request_screenshot() will still be included. + /// } + /// + /// fn post_rendering(&mut self, _window_size: [u32; 2], frame: &luminol_eframe::Frame) { + /// if let Some(screenshot) = frame.screenshot() { + /// let pixels_per_point = frame.info().native_pixels_per_point; + /// let region = egui::Rect::from_two_pos( + /// egui::Pos2::ZERO, + /// egui::Pos2{ x: 100., y: 100. }, + /// ); + /// let top_left_corner = screenshot.region(®ion, pixels_per_point); + /// image::save_buffer( + /// "top_left.png", + /// top_left_corner.as_raw(), + /// top_left_corner.width() as u32, + /// top_left_corner.height() as u32, + /// image::ColorType::Rgba8, + /// ).unwrap(); + /// } + /// } + /// } + /// ``` + #[cfg(not(target_arch = "wasm32"))] + pub fn screenshot(&self) -> Option { + self.screenshot.take() + } + + /// A place where you can store custom data in a way that persists when you restart the app. + pub fn storage_mut(&mut self) -> Option<&mut (dyn Storage + 'static)> { + self.storage.as_deref_mut() + } + + /// A reference to the underlying [`glow`] (OpenGL) context. + /// + /// This can be used, for instance, to: + /// * Render things to offscreen buffers. + /// * Read the pixel buffer from the previous frame (`glow::Context::read_pixels`). + /// * Render things behind the egui windows. + /// + /// Note that all egui painting is deferred to after the call to [`App::update`] + /// ([`egui`] only collects [`egui::Shape`]s and then eframe paints them all in one go later on). + /// + /// To get a [`glow`] context you need to compile with the `glow` feature flag, + /// and run eframe using [`Renderer::Glow`]. + #[cfg(feature = "glow")] + pub fn gl(&self) -> Option<&std::sync::Arc> { + self.gl.as_ref() + } + + /// The underlying WGPU render state. + /// + /// Only available when compiling with the `wgpu` feature and using [`Renderer::Wgpu`]. + /// + /// Can be used to manage GPU resources for custom rendering with WGPU using [`egui::PaintCallback`]s. + #[cfg(feature = "wgpu")] + pub fn wgpu_render_state(&self) -> Option<&luminol_egui_wgpu::RenderState> { + self.wgpu_render_state.as_ref() + } + + /// Tell `eframe` to close the desktop window. + /// + /// The window will not close immediately, but at the end of the this frame. + /// + /// Calling this will likely result in the app quitting, unless + /// you have more code after the call to [`crate::run_native`]. + #[cfg(not(target_arch = "wasm32"))] + #[doc(alias = "exit")] + #[doc(alias = "quit")] + pub fn close(&mut self) { + log::debug!("eframe::Frame::close called"); + self.output.close = true; + } + + /// Minimize or unminimize window. (native only) + #[cfg(not(target_arch = "wasm32"))] + pub fn set_minimized(&mut self, minimized: bool) { + self.output.minimized = Some(minimized); + } + + /// Bring the window into focus (native only). Has no effect on Wayland, or if the window is minimized or invisible. + /// + /// This method puts the window on top of other applications and takes input focus away from them, + /// which, if unexpected, will disturb the user. + #[cfg(not(target_arch = "wasm32"))] + pub fn focus(&mut self) { + self.output.focus = Some(true); + } + + /// If the window is unfocused, attract the user's attention (native only). + /// + /// Typically, this means that the window will flash on the taskbar, or bounce, until it is interacted with. + /// + /// When the window comes into focus, or if `None` is passed, the attention request will be automatically reset. + /// + /// See [winit's documentation][user_attention_details] for platform-specific effect details. + /// + /// [user_attention_details]: https://docs.rs/winit/latest/winit/window/enum.UserAttentionType.html + #[cfg(not(target_arch = "wasm32"))] + pub fn request_user_attention(&mut self, kind: egui::UserAttentionType) { + self.output.attention = Some(kind); + } + + /// Maximize or unmaximize window. (native only) + #[cfg(not(target_arch = "wasm32"))] + pub fn set_maximized(&mut self, maximized: bool) { + self.output.maximized = Some(maximized); + } + + /// Tell `eframe` to close the desktop window. + #[cfg(not(target_arch = "wasm32"))] + #[deprecated = "Renamed `close`"] + pub fn quit(&mut self) { + self.close(); + } + + /// Set the desired inner size of the window (in egui points). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_window_size(&mut self, size: egui::Vec2) { + self.output.window_size = Some(size); + self.info.window_info.size = size; // so that subsequent calls see the updated value + } + + /// Set the desired title of the window. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_window_title(&mut self, title: &str) { + self.output.window_title = Some(title.to_owned()); + } + + /// Set whether to show window decorations (i.e. a frame around you app). + /// + /// If false it will be difficult to move and resize the app. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_decorations(&mut self, decorated: bool) { + self.output.decorated = Some(decorated); + } + + /// Turn borderless fullscreen on/off (native only). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_fullscreen(&mut self, fullscreen: bool) { + self.output.fullscreen = Some(fullscreen); + self.info.window_info.fullscreen = fullscreen; // so that subsequent calls see the updated value + } + + /// set the position of the outer window. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_window_pos(&mut self, pos: egui::Pos2) { + self.output.window_pos = Some(pos); + self.info.window_info.position = Some(pos); // so that subsequent calls see the updated value + } + + /// When called, the native window will follow the + /// movement of the cursor while the primary mouse button is down. + /// + /// Does not work on the web. + #[cfg(not(target_arch = "wasm32"))] + pub fn drag_window(&mut self) { + self.output.drag_window = true; + } + + /// Set the visibility of the window. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_visible(&mut self, visible: bool) { + self.output.visible = Some(visible); + } + + /// On desktop: Set the window always on top. + /// + /// (Wayland desktop currently not supported) + #[cfg(not(target_arch = "wasm32"))] + pub fn set_always_on_top(&mut self, always_on_top: bool) { + self.output.always_on_top = Some(always_on_top); + } + + /// On desktop: Set the window to be centered. + /// + /// (Wayland desktop currently not supported) + #[cfg(not(target_arch = "wasm32"))] + pub fn set_centered(&mut self) { + if let Some(monitor_size) = self.info.window_info.monitor_size { + let inner_size = self.info.window_info.size; + if monitor_size.x > 1.0 && monitor_size.y > 1.0 { + let x = (monitor_size.x - inner_size.x) / 2.0; + let y = (monitor_size.y - inner_size.y) / 2.0; + self.set_window_pos(egui::Pos2 { x, y }); + } + } + } + + /// for integrations only: call once per frame + #[cfg(any(feature = "glow", feature = "wgpu"))] + pub(crate) fn take_app_output(&mut self) -> backend::AppOutput { + std::mem::take(&mut self.output) + } +} + +/// Information about the web environment (if applicable). +#[derive(Clone, Debug)] +#[cfg(target_arch = "wasm32")] +pub struct WebInfo { + /// The browser user agent. + pub user_agent: String, + + /// Information about the URL. + pub location: Location, +} + +/// Information about the application's main window, if available. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +pub struct WindowInfo { + /// Coordinates of the window's outer top left corner, relative to the top left corner of the first display. + /// + /// Unit: egui points (logical pixels). + /// + /// `None` = unknown. + pub position: Option, + + /// Are we in fullscreen mode? + pub fullscreen: bool, + + /// Are we minimized? + pub minimized: bool, + + /// Are we maximized? + pub maximized: bool, + + /// Is the window focused and able to receive input? + /// + /// This should be the same as [`egui::InputState::focused`]. + pub focused: bool, + + /// Window inner size in egui points (logical pixels). + pub size: egui::Vec2, + + /// Current monitor size in egui points (logical pixels) + pub monitor_size: Option, +} + +/// Information about the URL. +/// +/// Everything has been percent decoded (`%20` -> ` ` etc). +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Debug)] +pub struct Location { + /// The full URL (`location.href`) without the hash. + /// + /// Example: `"http://www.example.com:80/index.html?foo=bar"`. + pub url: String, + + /// `location.protocol` + /// + /// Example: `"http:"`. + pub protocol: String, + + /// `location.host` + /// + /// Example: `"example.com:80"`. + pub host: String, + + /// `location.hostname` + /// + /// Example: `"example.com"`. + pub hostname: String, + + /// `location.port` + /// + /// Example: `"80"`. + pub port: String, + + /// The "#fragment" part of "www.example.com/index.html?query#fragment". + /// + /// Note that the leading `#` is included in the string. + /// Also known as "hash-link" or "anchor". + pub hash: String, + + /// The "query" part of "www.example.com/index.html?query#fragment". + /// + /// Note that the leading `?` is NOT included in the string. + /// + /// Use [`Self::query_map`] to get the parsed version of it. + pub query: String, + + /// The parsed "query" part of "www.example.com/index.html?query#fragment". + /// + /// "foo=42&bar%20" is parsed as `{"foo": "42", "bar ": ""}` + pub query_map: std::collections::BTreeMap, + + /// `location.origin` + /// + /// Example: `"http://www.example.com:80"`. + pub origin: String, +} + +/// Information about the integration passed to the use app each frame. +#[derive(Clone, Debug)] +pub struct IntegrationInfo { + /// Information about the surrounding web environment. + #[cfg(target_arch = "wasm32")] + pub web_info: WebInfo, + + /// Does the OS use dark or light mode? + /// + /// `None` means "don't know". + pub system_theme: Option, + + /// Seconds of cpu usage (in seconds) of UI code on the previous frame. + /// `None` if this is the first frame. + pub cpu_usage: Option, + + /// The OS native pixels-per-point + pub native_pixels_per_point: Option, + + /// The position and size of the native window. + #[cfg(not(target_arch = "wasm32"))] + pub window_info: WindowInfo, +} + +// ---------------------------------------------------------------------------- + +/// A place where you can store custom data in a way that persists when you restart the app. +/// +/// On the web this is backed by [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). +/// On desktop this is backed by the file system. +/// +/// See [`CreationContext::storage`] and [`App::save`]. +pub trait Storage { + /// Get the value for the given key. + fn get_string(&self, key: &str) -> Option; + + /// Set the value for the given key. + fn set_string(&mut self, key: &str, value: String); + + /// write-to-disk or similar + fn flush(&mut self); +} + +/// Stores nothing. +#[derive(Clone, Default)] +pub(crate) struct DummyStorage {} + +impl Storage for DummyStorage { + fn get_string(&self, _key: &str) -> Option { + None + } + + fn set_string(&mut self, _key: &str, _value: String) {} + + fn flush(&mut self) {} +} + +/// Get and deserialize the [RON](https://github.com/ron-rs/ron) stored at the given key. +#[cfg(feature = "ron")] +pub fn get_value(storage: &dyn Storage, key: &str) -> Option { + crate::profile_function!(key); + storage + .get_string(key) + .and_then(|value| match ron::from_str(&value) { + Ok(value) => Some(value), + Err(err) => { + // This happens on when we break the format, e.g. when updating egui. + log::debug!("Failed to decode RON: {err}"); + None + } + }) +} + +/// Serialize the given value as [RON](https://github.com/ron-rs/ron) and store with the given key. +#[cfg(feature = "ron")] +pub fn set_value(storage: &mut dyn Storage, key: &str, value: &T) { + crate::profile_function!(key); + match ron::ser::to_string(value) { + Ok(string) => storage.set_string(key, string), + Err(err) => log::error!("eframe failed to encode data using ron: {}", err), + } +} + +/// [`Storage`] key used for app +pub const APP_KEY: &str = "app"; + +// ---------------------------------------------------------------------------- + +/// You only need to look here if you are writing a backend for `epi`. +pub(crate) mod backend { + /// Action that can be taken by the user app. + #[derive(Clone, Debug, Default)] + #[must_use] + pub struct AppOutput { + /// Set to `true` to close the native window (which often quits the app). + #[cfg(not(target_arch = "wasm32"))] + pub close: bool, + + /// Set to some size to resize the outer window (e.g. glium window) to this size. + #[cfg(not(target_arch = "wasm32"))] + pub window_size: Option, + + /// Set to some string to rename the outer window (e.g. glium window) to this title. + #[cfg(not(target_arch = "wasm32"))] + pub window_title: Option, + + /// Set to some bool to change window decorations. + #[cfg(not(target_arch = "wasm32"))] + pub decorated: Option, + + /// Set to some bool to change window fullscreen. + #[cfg(not(target_arch = "wasm32"))] // TODO: implement fullscreen on web + pub fullscreen: Option, + + /// Set to true to drag window while primary mouse button is down. + #[cfg(not(target_arch = "wasm32"))] + pub drag_window: bool, + + /// Set to some position to move the outer window (e.g. glium window) to this position + #[cfg(not(target_arch = "wasm32"))] + pub window_pos: Option, + + /// Set to some bool to change window visibility. + #[cfg(not(target_arch = "wasm32"))] + pub visible: Option, + + /// Set to some bool to tell the window always on top. + #[cfg(not(target_arch = "wasm32"))] + pub always_on_top: Option, + + /// Set to some bool to minimize or unminimize window. + #[cfg(not(target_arch = "wasm32"))] + pub minimized: Option, + + /// Set to some bool to maximize or unmaximize window. + #[cfg(not(target_arch = "wasm32"))] + pub maximized: Option, + + /// Set to some bool to focus window. + #[cfg(not(target_arch = "wasm32"))] + pub focus: Option, + + /// Set to request a user's attention to the native window. + #[cfg(not(target_arch = "wasm32"))] + pub attention: Option, + + #[cfg(not(target_arch = "wasm32"))] + pub screenshot_requested: bool, + } +} diff --git a/crates/eframe/src/lib.rs b/crates/eframe/src/lib.rs new file mode 100644 index 00000000..07176ede --- /dev/null +++ b/crates/eframe/src/lib.rs @@ -0,0 +1,355 @@ +//! eframe - the [`egui`] framework crate +//! +//! If you are planning to write an app for web or native, +//! and want to use [`egui`] for everything, then `eframe` is for you! +//! +//! To get started, see the [examples](https://github.com/emilk/egui/tree/master/examples). +//! To learn how to set up `eframe` for web and native, go to and follow the instructions there! +//! +//! In short, you implement [`App`] (especially [`App::update`]) and then +//! call [`crate::run_native`] from your `main.rs`, and/or use `luminol_eframe::WebRunner` from your `lib.rs`. +//! +//! ## Usage, native: +//! ``` no_run +//! use luminol_eframe::egui; +//! +//! fn main() { +//! let native_options = luminol_eframe::NativeOptions::default(); +//! luminol_eframe::run_native("My egui App", native_options, Box::new(|cc| Box::new(MyEguiApp::new(cc)))); +//! } +//! +//! #[derive(Default)] +//! struct MyEguiApp {} +//! +//! impl MyEguiApp { +//! fn new(cc: &luminol_eframe::CreationContext<'_>) -> Self { +//! // Customize egui here with cc.egui_ctx.set_fonts and cc.egui_ctx.set_visuals. +//! // Restore app state using cc.storage (requires the "persistence" feature). +//! // Use the cc.gl (a glow::Context) to create graphics shaders and buffers that you can use +//! // for e.g. egui::PaintCallback. +//! Self::default() +//! } +//! } +//! +//! impl luminol_eframe::App for MyEguiApp { +//! fn update(&mut self, ctx: &egui::Context, frame: &mut luminol_eframe::Frame) { +//! egui::CentralPanel::default().show(ctx, |ui| { +//! ui.heading("Hello World!"); +//! }); +//! } +//! } +//! ``` +//! +//! ## Usage, web: +//! ``` no_run +//! # #[cfg(target_arch = "wasm32")] +//! use wasm_bindgen::prelude::*; +//! +//! /// Your handle to the web app from JavaScript. +//! # #[cfg(target_arch = "wasm32")] +//! #[derive(Clone)] +//! #[wasm_bindgen] +//! pub struct WebHandle { +//! runner: luminol_eframe::WebRunner, +//! } +//! +//! # #[cfg(target_arch = "wasm32")] +//! #[wasm_bindgen] +//! impl WebHandle { +//! /// Installs a panic hook, then returns. +//! #[allow(clippy::new_without_default)] +//! #[wasm_bindgen(constructor)] +//! pub fn new() -> Self { +//! // Redirect [`log`] message to `console.log` and friends: +//! luminol_eframe::WebLogger::init(log::LevelFilter::Debug).ok(); +//! +//! Self { +//! runner: luminol_eframe::WebRunner::new(), +//! } +//! } +//! +//! /// Call this once from JavaScript to start your app. +//! #[wasm_bindgen] +//! pub async fn start(&self, canvas_id: &str) -> Result<(), wasm_bindgen::JsValue> { +//! self.runner +//! .start( +//! canvas_id, +//! luminol_eframe::WebOptions::default(), +//! Box::new(|cc| Box::new(MyEguiApp::new(cc))), +//! ) +//! .await +//! } +//! +//! // The following are optional: +//! +//! /// Shut down eframe and clean up resources. +//! #[wasm_bindgen] +//! pub fn destroy(&self) { +//! self.runner.destroy(); +//! } +//! +//! /// Example on how to call into your app from JavaScript. +//! #[wasm_bindgen] +//! pub fn example(&self) { +//! if let Some(app) = self.runner.app_mut::() { +//! app.example(); +//! } +//! } +//! +//! /// The JavaScript can check whether or not your app has crashed: +//! #[wasm_bindgen] +//! pub fn has_panicked(&self) -> bool { +//! self.runner.has_panicked() +//! } +//! +//! #[wasm_bindgen] +//! pub fn panic_message(&self) -> Option { +//! self.runner.panic_summary().map(|s| s.message()) +//! } +//! +//! #[wasm_bindgen] +//! pub fn panic_callstack(&self) -> Option { +//! self.runner.panic_summary().map(|s| s.callstack()) +//! } +//! } +//! ``` +//! +//! ## Simplified usage +//! If your app is only for native, and you don't need advanced features like state persistence, +//! then you can use the simpler function [`run_simple_native`]. +//! +//! ## Feature flags +#![cfg_attr(feature = "document-features", doc = document_features::document_features!())] +//! + +#![warn(missing_docs)] // let's keep eframe well-documented +#![allow(clippy::needless_doctest_main)] +// Luminol doesn't need everything from eframe, but we're leaving everything here to reduce merge conflicts +#![allow(dead_code, unused_imports)] + +// Re-export all useful libraries: +pub use {egui, egui::emath, egui::epaint}; + +#[cfg(feature = "glow")] +pub use {egui_glow, glow}; + +#[cfg(feature = "wgpu")] +pub use {luminol_egui_wgpu, wgpu}; + +mod epi; + +// Re-export everything in `epi` so `eframe` users don't have to care about what `epi` is: +pub use epi::*; + +// ---------------------------------------------------------------------------- +// When compiling for web + +#[cfg(target_arch = "wasm32")] +pub use wasm_bindgen; + +#[cfg(target_arch = "wasm32")] +pub use web_sys; + +#[cfg(target_arch = "wasm32")] +pub mod web; + +#[cfg(target_arch = "wasm32")] +pub use web::{WebLogger, WebRunner}; + +// ---------------------------------------------------------------------------- +// When compiling natively + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu"))] +mod native; + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(feature = "persistence")] +pub use native::file_storage::storage_dir; + +/// This is how you start a native (desktop) app. +/// +/// The first argument is name of your app, used for the title bar of the native window +/// and the save location of persistence (see [`App::save`]). +/// +/// Call from `fn main` like this: +/// ``` no_run +/// use luminol_eframe::egui; +/// +/// fn main() -> luminol_eframe::Result<()> { +/// let native_options = luminol_eframe::NativeOptions::default(); +/// luminol_eframe::run_native("MyApp", native_options, Box::new(|cc| Box::new(MyEguiApp::new(cc)))) +/// } +/// +/// #[derive(Default)] +/// struct MyEguiApp {} +/// +/// impl MyEguiApp { +/// fn new(cc: &luminol_eframe::CreationContext<'_>) -> Self { +/// // Customize egui here with cc.egui_ctx.set_fonts and cc.egui_ctx.set_visuals. +/// // Restore app state using cc.storage (requires the "persistence" feature). +/// // Use the cc.gl (a glow::Context) to create graphics shaders and buffers that you can use +/// // for e.g. egui::PaintCallback. +/// Self::default() +/// } +/// } +/// +/// impl luminol_eframe::App for MyEguiApp { +/// fn update(&mut self, ctx: &egui::Context, frame: &mut luminol_eframe::Frame) { +/// egui::CentralPanel::default().show(ctx, |ui| { +/// ui.heading("Hello World!"); +/// }); +/// } +/// } +/// ``` +/// +/// # Errors +/// This function can fail if we fail to set up a graphics context. +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu"))] +#[allow(clippy::needless_pass_by_value)] +pub fn run_native( + app_name: &str, + native_options: NativeOptions, + app_creator: AppCreator, +) -> Result<()> { + let renderer = native_options.renderer; + + #[cfg(not(feature = "__screenshot"))] + assert!( + std::env::var("EFRAME_SCREENSHOT_TO").is_err(), + "EFRAME_SCREENSHOT_TO found without compiling with the '__screenshot' feature" + ); + + match renderer { + #[cfg(feature = "glow")] + Renderer::Glow => { + log::debug!("Using the glow renderer"); + native::run::run_glow(app_name, native_options, app_creator) + } + + #[cfg(feature = "wgpu")] + Renderer::Wgpu => { + log::debug!("Using the wgpu renderer"); + native::run::run_wgpu(app_name, native_options, app_creator) + } + } +} + +// ---------------------------------------------------------------------------- + +/// The simplest way to get started when writing a native app. +/// +/// This does NOT support persistence. For that you need to use [`run_native`]. +/// +/// # Example +/// ``` no_run +/// fn main() -> luminol_eframe::Result<()> { +/// // Our application state: +/// let mut name = "Arthur".to_owned(); +/// let mut age = 42; +/// +/// let options = luminol_eframe::NativeOptions::default(); +/// luminol_eframe::run_simple_native("My egui App", options, move |ctx, _frame| { +/// egui::CentralPanel::default().show(ctx, |ui| { +/// ui.heading("My egui Application"); +/// ui.horizontal(|ui| { +/// let name_label = ui.label("Your name: "); +/// ui.text_edit_singleline(&mut name) +/// .labelled_by(name_label.id); +/// }); +/// ui.add(egui::Slider::new(&mut age, 0..=120).text("age")); +/// if ui.button("Click each year").clicked() { +/// age += 1; +/// } +/// ui.label(format!("Hello '{name}', age {age}")); +/// }); +/// }) +/// } +/// ``` +/// +/// # Errors +/// This function can fail if we fail to set up a graphics context. +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu"))] +pub fn run_simple_native( + app_name: &str, + native_options: NativeOptions, + update_fun: impl FnMut(&egui::Context, &mut Frame) + 'static, +) -> Result<()> { + struct SimpleApp { + update_fun: U, + } + + impl App for SimpleApp { + fn update(&mut self, ctx: &egui::Context, frame: &mut Frame) { + (self.update_fun)(ctx, frame); + } + } + + run_native( + app_name, + native_options, + Box::new(|_cc| Box::new(SimpleApp { update_fun })), + ) +} + +// ---------------------------------------------------------------------------- + +/// The different problems that can occur when trying to run `eframe`. +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// An error from [`winit`]. + #[cfg(not(target_arch = "wasm32"))] + #[error("winit error: {0}")] + Winit(#[from] winit::error::OsError), + + /// An error from [`glutin`] when using [`glow`]. + #[cfg(all(feature = "glow", not(target_arch = "wasm32")))] + #[error("glutin error: {0}")] + Glutin(#[from] glutin::error::Error), + + /// An error from [`glutin`] when using [`glow`]. + #[cfg(all(feature = "glow", not(target_arch = "wasm32")))] + #[error("Found no glutin configs matching the template: {0:?}. Error: {1:?}")] + NoGlutinConfigs(glutin::config::ConfigTemplate, Box), + + /// An error from [`wgpu`]. + #[cfg(feature = "wgpu")] + #[error("WGPU error: {0}")] + Wgpu(#[from] luminol_egui_wgpu::WgpuError), +} + +/// Short for `Result`. +pub type Result = std::result::Result; + +// --------------------------------------------------------------------------- + +mod profiling_scopes { + #![allow(unused_macros)] + #![allow(unused_imports)] + + /// Profiling macro for feature "puffin" + macro_rules! profile_function { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_function!($($arg)*); + }; + } + pub(crate) use profile_function; + + /// Profiling macro for feature "puffin" + macro_rules! profile_scope { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_scope!($($arg)*); + }; + } + pub(crate) use profile_scope; +} + +#[allow(unused_imports)] +pub(crate) use profiling_scopes::*; diff --git a/crates/eframe/src/native/app_icon.rs b/crates/eframe/src/native/app_icon.rs new file mode 100644 index 00000000..61713055 --- /dev/null +++ b/crates/eframe/src/native/app_icon.rs @@ -0,0 +1,244 @@ +//! Set the native app icon at runtime. +//! +//! TODO(emilk): port this to [`winit`]. + +use crate::IconData; + +pub struct AppTitleIconSetter { + title: String, + icon_data: Option, + status: AppIconStatus, +} + +impl AppTitleIconSetter { + pub fn new(title: String, icon_data: Option) -> Self { + Self { + title, + icon_data, + status: AppIconStatus::NotSetTryAgain, + } + } + + /// Call once per frame; we will set the icon when we can. + pub fn update(&mut self) { + if self.status == AppIconStatus::NotSetTryAgain { + self.status = set_title_and_icon(&self.title, self.icon_data.as_ref()); + } + } +} + +/// In which state the app icon is (as far as we know). +#[derive(PartialEq, Eq)] +enum AppIconStatus { + /// We did not set it or failed to do it. In any case we won't try again. + NotSetIgnored, + + /// We haven't set the icon yet, we should try again next frame. + /// + /// This can happen repeatedly due to lazy window creation on some platforms. + NotSetTryAgain, + + /// We successfully set the icon and it should be visible now. + #[allow(dead_code)] // Not used on Linux + Set, +} + +/// Sets app icon at runtime. +/// +/// By setting the icon at runtime and not via resource files etc. we ensure that we'll get the chance +/// to set the same icon when the process/window is started from python (which sets its own icon ahead of us!). +/// +/// Since window creation can be lazy, call this every frame until it's either successfully or gave up. +/// (See [`AppIconStatus`]) +fn set_title_and_icon(_title: &str, _icon_data: Option<&IconData>) -> AppIconStatus { + crate::profile_function!(); + + #[cfg(target_os = "windows")] + { + if let Some(icon_data) = _icon_data { + return set_app_icon_windows(icon_data); + } + } + + #[cfg(target_os = "macos")] + return set_title_and_icon_mac(_title, _icon_data); + + #[allow(unreachable_code)] + AppIconStatus::NotSetIgnored +} + +/// Set icon for Windows applications. +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +fn set_app_icon_windows(icon_data: &IconData) -> AppIconStatus { + use winapi::um::winuser; + + // We would get fairly far already with winit's `set_window_icon` (which is exposed to eframe) actually! + // However, it only sets ICON_SMALL, i.e. doesn't allow us to set a higher resolution icon for the task bar. + // Also, there is scaling issues, detailed below. + + // TODO(andreas): This does not set the task bar icon for when our application is started from python. + // Things tried so far: + // * Querying for an owning window and setting icon there (there doesn't seem to be an owning window) + // * using undocumented SetConsoleIcon method (successfully queried via GetProcAddress) + + // SAFETY: WinApi function without side-effects. + let window_handle = unsafe { winuser::GetActiveWindow() }; + if window_handle.is_null() { + // The Window isn't available yet. Try again later! + return AppIconStatus::NotSetTryAgain; + } + + fn create_hicon_with_scale( + unscaled_image: &image::RgbaImage, + target_size: i32, + ) -> winapi::shared::windef::HICON { + let image_scaled = image::imageops::resize( + unscaled_image, + target_size as _, + target_size as _, + image::imageops::Lanczos3, + ); + + // Creating transparent icons with WinApi is a huge mess. + // We'd need to go through CreateIconIndirect's ICONINFO struct which then + // takes a mask HBITMAP and a color HBITMAP and creating each of these is pain. + // Instead we workaround this by creating a png which CreateIconFromResourceEx magically understands. + // This is a pretty horrible hack as we spend a lot of time encoding, but at least the code is a lot shorter. + let mut image_scaled_bytes: Vec = Vec::new(); + if image_scaled + .write_to( + &mut std::io::Cursor::new(&mut image_scaled_bytes), + image::ImageOutputFormat::Png, + ) + .is_err() + { + return std::ptr::null_mut(); + } + + // SAFETY: Creating an HICON which should be readonly on our data. + unsafe { + winuser::CreateIconFromResourceEx( + image_scaled_bytes.as_mut_ptr(), + image_scaled_bytes.len() as u32, + 1, // Means this is an icon, not a cursor. + 0x00030000, // Version number of the HICON + target_size, // Note that this method can scale, but it does so *very* poorly. So let's avoid that! + target_size, + winuser::LR_DEFAULTCOLOR, + ) + } + } + + let unscaled_image = match icon_data.to_image() { + Ok(unscaled_image) => unscaled_image, + Err(err) => { + log::warn!("Invalid icon: {err}"); + return AppIconStatus::NotSetIgnored; + } + }; + + // Only setting ICON_BIG with the icon size for big icons (SM_CXICON) works fine + // but the scaling it does then for the small icon is pretty bad. + // Instead we set the correct sizes manually and take over the scaling ourselves. + // For this to work we first need to set the big icon and then the small one. + // + // Note that ICON_SMALL may be used even if we don't render a title bar as it may be used in alt+tab! + { + // SAFETY: WinAPI getter function with no known side effects. + let icon_size_big = unsafe { winuser::GetSystemMetrics(winuser::SM_CXICON) }; + let icon_big = create_hicon_with_scale(&unscaled_image, icon_size_big); + if icon_big.is_null() { + log::warn!("Failed to create HICON (for big icon) from embedded png data."); + return AppIconStatus::NotSetIgnored; // We could try independently with the small icon but what's the point, it would look bad! + } else { + // SAFETY: Unsafe WinApi function, takes objects previously created with WinAPI, all checked for null prior. + unsafe { + winuser::SendMessageW( + window_handle, + winuser::WM_SETICON, + winuser::ICON_BIG as usize, + icon_big as isize, + ); + } + } + } + { + // SAFETY: WinAPI getter function with no known side effects. + let icon_size_small = unsafe { winuser::GetSystemMetrics(winuser::SM_CXSMICON) }; + let icon_small = create_hicon_with_scale(&unscaled_image, icon_size_small); + if icon_small.is_null() { + log::warn!("Failed to create HICON (for small icon) from embedded png data."); + return AppIconStatus::NotSetIgnored; + } else { + // SAFETY: Unsafe WinApi function, takes objects previously created with WinAPI, all checked for null prior. + unsafe { + winuser::SendMessageW( + window_handle, + winuser::WM_SETICON, + winuser::ICON_SMALL as usize, + icon_small as isize, + ); + } + } + } + + // It _probably_ worked out. + AppIconStatus::Set +} + +/// Set icon & app title for `MacOS` applications. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconStatus { + crate::profile_function!(); + + use cocoa::{ + appkit::{NSApp, NSApplication, NSImage, NSMenu, NSWindow}, + base::{id, nil}, + foundation::{NSData, NSString}, + }; + use objc::{msg_send, sel, sel_impl}; + + let png_bytes = if let Some(icon_data) = icon_data { + match icon_data.to_png_bytes() { + Ok(png_bytes) => Some(png_bytes), + Err(err) => { + log::warn!("Failed to convert IconData to png: {err}"); + return AppIconStatus::NotSetIgnored; + } + } + } else { + None + }; + + // SAFETY: Accessing raw data from icon in a read-only manner. Icon data is static! + unsafe { + let app = NSApp(); + + if let Some(png_bytes) = png_bytes { + let data = NSData::dataWithBytes_length_( + nil, + png_bytes.as_ptr().cast::(), + png_bytes.len() as u64, + ); + let app_icon = NSImage::initWithData_(NSImage::alloc(nil), data); + + crate::profile_scope!("setApplicationIconImage_"); + app.setApplicationIconImage_(app_icon); + } + + // Change the title in the top bar - for python processes this would be again "python" otherwise. + let main_menu = app.mainMenu(); + let app_menu: id = msg_send![main_menu.itemAtIndex_(0), submenu]; + crate::profile_scope!("setTitle_"); + app_menu.setTitle_(NSString::alloc(nil).init_str(title)); + + // The title in the Dock apparently can't be changed. + // At least these people didn't figure it out either: + // https://stackoverflow.com/questions/69831167/qt-change-application-title-dynamically-on-macos + // https://stackoverflow.com/questions/28808226/changing-cocoa-app-icon-title-and-menu-labels-at-runtime + } + + AppIconStatus::Set +} diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs new file mode 100644 index 00000000..6748cb68 --- /dev/null +++ b/crates/eframe/src/native/epi_integration.rs @@ -0,0 +1,659 @@ +use winit::event_loop::EventLoopWindowTarget; + +#[cfg(target_os = "macos")] +use winit::platform::macos::WindowBuilderExtMacOS as _; + +use raw_window_handle::{HasRawDisplayHandle as _, HasRawWindowHandle as _}; + +#[cfg(feature = "accesskit")] +use egui::accesskit; +use egui::NumExt as _; +#[cfg(feature = "accesskit")] +use egui_winit::accesskit_winit; +use egui_winit::{native_pixels_per_point, EventResponse, WindowSettings}; + +use crate::{epi, Theme, WindowInfo}; + +#[derive(Default)] +pub struct WindowState { + // We cannot simply call `winit::Window::is_minimized/is_maximized` + // because that deadlocks on mac. + pub minimized: bool, + pub maximized: bool, +} + +pub fn points_to_size(points: egui::Vec2) -> winit::dpi::LogicalSize { + winit::dpi::LogicalSize { + width: points.x as f64, + height: points.y as f64, + } +} + +pub fn read_window_info( + window: &winit::window::Window, + pixels_per_point: f32, + window_state: &WindowState, +) -> WindowInfo { + let position = window + .outer_position() + .ok() + .map(|pos| pos.to_logical::(pixels_per_point.into())) + .map(|pos| egui::Pos2 { x: pos.x, y: pos.y }); + + let monitor = window.current_monitor().is_some(); + let monitor_size = if monitor { + let size = window + .current_monitor() + .unwrap() + .size() + .to_logical::(pixels_per_point.into()); + Some(egui::vec2(size.width, size.height)) + } else { + None + }; + + let size = window + .inner_size() + .to_logical::(pixels_per_point.into()); + + // NOTE: calling window.is_minimized() or window.is_maximized() deadlocks on Mac. + + WindowInfo { + position, + fullscreen: window.fullscreen().is_some(), + minimized: window_state.minimized, + maximized: window_state.maximized, + focused: window.has_focus(), + size: egui::Vec2 { + x: size.width, + y: size.height, + }, + monitor_size, + } +} + +pub fn window_builder( + event_loop: &EventLoopWindowTarget, + title: &str, + native_options: &mut epi::NativeOptions, + window_settings: Option, +) -> winit::window::WindowBuilder { + let epi::NativeOptions { + maximized, + decorated, + fullscreen, + #[cfg(target_os = "macos")] + fullsize_content, + drag_and_drop_support, + icon_data, + initial_window_pos, + initial_window_size, + min_window_size, + max_window_size, + resizable, + transparent, + centered, + active, + .. + } = native_options; + + let window_icon = icon_data.clone().and_then(load_icon); + + let mut window_builder = winit::window::WindowBuilder::new() + .with_title(title) + .with_decorations(*decorated) + .with_fullscreen(fullscreen.then(|| winit::window::Fullscreen::Borderless(None))) + .with_maximized(*maximized) + .with_resizable(*resizable) + .with_transparent(*transparent) + .with_window_icon(window_icon) + .with_active(*active) + // Keep hidden until we've painted something. See https://github.com/emilk/egui/pull/2279 + // We must also keep the window hidden until AccessKit is initialized. + .with_visible(false); + + #[cfg(target_os = "macos")] + if *fullsize_content { + window_builder = window_builder + .with_title_hidden(true) + .with_titlebar_transparent(true) + .with_fullsize_content_view(true); + } + + #[cfg(all(feature = "wayland", target_os = "linux"))] + { + use winit::platform::wayland::WindowBuilderExtWayland as _; + match &native_options.app_id { + Some(app_id) => window_builder = window_builder.with_name(app_id, ""), + None => window_builder = window_builder.with_name(title, ""), + } + } + + if let Some(min_size) = *min_window_size { + window_builder = window_builder.with_min_inner_size(points_to_size(min_size)); + } + if let Some(max_size) = *max_window_size { + window_builder = window_builder.with_max_inner_size(points_to_size(max_size)); + } + + window_builder = window_builder_drag_and_drop(window_builder, *drag_and_drop_support); + + // Always use the default window size / position on iOS. Trying to restore the previous position + // causes the window to be shown too small. + #[cfg(not(target_os = "ios"))] + let inner_size_points = if let Some(mut window_settings) = window_settings { + // Restore pos/size from previous session + + window_settings.clamp_size_to_sane_values(largest_monitor_point_size(event_loop)); + window_settings.clamp_position_to_monitors(event_loop); + + window_builder = window_settings.initialize_window_builder(window_builder); + window_settings.inner_size_points() + } else { + if let Some(pos) = *initial_window_pos { + window_builder = window_builder.with_position(winit::dpi::LogicalPosition { + x: pos.x as f64, + y: pos.y as f64, + }); + } + + if let Some(initial_window_size) = *initial_window_size { + let initial_window_size = + initial_window_size.at_most(largest_monitor_point_size(event_loop)); + window_builder = window_builder.with_inner_size(points_to_size(initial_window_size)); + } + + *initial_window_size + }; + + #[cfg(not(target_os = "ios"))] + if *centered { + if let Some(monitor) = event_loop.available_monitors().next() { + let monitor_size = monitor.size().to_logical::(monitor.scale_factor()); + let inner_size = inner_size_points.unwrap_or(egui::Vec2 { x: 800.0, y: 600.0 }); + if monitor_size.width > 0.0 && monitor_size.height > 0.0 { + let x = (monitor_size.width - inner_size.x as f64) / 2.0; + let y = (monitor_size.height - inner_size.y as f64) / 2.0; + window_builder = window_builder.with_position(winit::dpi::LogicalPosition { x, y }); + } + } + } + + match std::mem::take(&mut native_options.window_builder) { + Some(hook) => hook(window_builder), + None => window_builder, + } +} + +pub fn apply_native_options_to_window( + window: &winit::window::Window, + native_options: &crate::NativeOptions, + window_settings: Option, +) { + crate::profile_function!(); + use winit::window::WindowLevel; + window.set_window_level(if native_options.always_on_top { + WindowLevel::AlwaysOnTop + } else { + WindowLevel::Normal + }); + + if let Some(window_settings) = window_settings { + window_settings.initialize_window(window); + } +} + +fn largest_monitor_point_size(event_loop: &EventLoopWindowTarget) -> egui::Vec2 { + let mut max_size = egui::Vec2::ZERO; + + for monitor in event_loop.available_monitors() { + let size = monitor.size().to_logical::(monitor.scale_factor()); + let size = egui::vec2(size.width, size.height); + max_size = max_size.max(size); + } + + if max_size == egui::Vec2::ZERO { + egui::Vec2::splat(16000.0) + } else { + max_size + } +} + +fn load_icon(icon_data: epi::IconData) -> Option { + winit::window::Icon::from_rgba(icon_data.rgba, icon_data.width, icon_data.height).ok() +} + +#[cfg(target_os = "windows")] +fn window_builder_drag_and_drop( + window_builder: winit::window::WindowBuilder, + enable: bool, +) -> winit::window::WindowBuilder { + use winit::platform::windows::WindowBuilderExtWindows as _; + window_builder.with_drag_and_drop(enable) +} + +#[cfg(not(target_os = "windows"))] +fn window_builder_drag_and_drop( + window_builder: winit::window::WindowBuilder, + _enable: bool, +) -> winit::window::WindowBuilder { + // drag and drop can only be disabled on windows + window_builder +} + +pub fn handle_app_output( + window: &winit::window::Window, + current_pixels_per_point: f32, + app_output: epi::backend::AppOutput, + window_state: &mut WindowState, +) { + let epi::backend::AppOutput { + close: _, + window_size, + window_title, + decorated, + fullscreen, + drag_window, + window_pos, + visible: _, // handled in post_present + always_on_top, + screenshot_requested: _, // handled by the rendering backend, + minimized, + maximized, + focus, + attention, + } = app_output; + + if let Some(decorated) = decorated { + window.set_decorations(decorated); + } + + if let Some(window_size) = window_size { + window.set_inner_size( + winit::dpi::PhysicalSize { + width: (current_pixels_per_point * window_size.x).round(), + height: (current_pixels_per_point * window_size.y).round(), + } + .to_logical::(native_pixels_per_point(window) as f64), + ); + } + + if let Some(fullscreen) = fullscreen { + window.set_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))); + } + + if let Some(window_title) = window_title { + window.set_title(&window_title); + } + + if let Some(window_pos) = window_pos { + window.set_outer_position(winit::dpi::LogicalPosition { + x: window_pos.x as f64, + y: window_pos.y as f64, + }); + } + + if drag_window { + let _ = window.drag_window(); + } + + if let Some(always_on_top) = always_on_top { + use winit::window::WindowLevel; + window.set_window_level(if always_on_top { + WindowLevel::AlwaysOnTop + } else { + WindowLevel::Normal + }); + } + + if let Some(minimized) = minimized { + window.set_minimized(minimized); + window_state.minimized = minimized; + } + + if let Some(maximized) = maximized { + window.set_maximized(maximized); + window_state.maximized = maximized; + } + + if !window.has_focus() { + if focus == Some(true) { + window.focus_window(); + } else if let Some(attention) = attention { + use winit::window::UserAttentionType; + window.request_user_attention(match attention { + egui::UserAttentionType::Reset => None, + egui::UserAttentionType::Critical => Some(UserAttentionType::Critical), + egui::UserAttentionType::Informational => Some(UserAttentionType::Informational), + }); + } + } +} + +// ---------------------------------------------------------------------------- + +/// For loading/saving app state and/or egui memory to disk. +pub fn create_storage(_app_name: &str) -> Option> { + #[cfg(feature = "persistence")] + if let Some(storage) = super::file_storage::FileStorage::from_app_id(_app_name) { + return Some(Box::new(storage)); + } + None +} + +// ---------------------------------------------------------------------------- + +/// Everything needed to make a winit-based integration for [`epi`]. +pub struct EpiIntegration { + pub frame: epi::Frame, + last_auto_save: std::time::Instant, + pub egui_ctx: egui::Context, + pending_full_output: egui::FullOutput, + egui_winit: egui_winit::State, + + /// When set, it is time to close the native window. + close: bool, + + can_drag_window: bool, + window_state: WindowState, + follow_system_theme: bool, + #[cfg(feature = "persistence")] + persist_window: bool, + app_icon_setter: super::app_icon::AppTitleIconSetter, +} + +impl EpiIntegration { + #[allow(clippy::too_many_arguments)] + pub fn new( + event_loop: &EventLoopWindowTarget, + max_texture_side: usize, + window: &winit::window::Window, + system_theme: Option, + app_name: &str, + native_options: &crate::NativeOptions, + storage: Option>, + #[cfg(feature = "glow")] gl: Option>, + #[cfg(feature = "wgpu")] wgpu_render_state: Option, + ) -> Self { + let egui_ctx = egui::Context::default(); + + let memory = load_egui_memory(storage.as_deref()).unwrap_or_default(); + egui_ctx.memory_mut(|mem| *mem = memory); + + let native_pixels_per_point = window.scale_factor() as f32; + + let window_state = WindowState { + minimized: window.is_minimized().unwrap_or(false), + maximized: window.is_maximized(), + }; + + let frame = epi::Frame { + info: epi::IntegrationInfo { + system_theme, + cpu_usage: None, + native_pixels_per_point: Some(native_pixels_per_point), + window_info: read_window_info(window, egui_ctx.pixels_per_point(), &window_state), + }, + output: epi::backend::AppOutput { + visible: Some(true), + ..Default::default() + }, + storage, + #[cfg(feature = "glow")] + gl, + #[cfg(feature = "wgpu")] + wgpu_render_state, + screenshot: std::cell::Cell::new(None), + raw_display_handle: window.raw_display_handle(), + raw_window_handle: window.raw_window_handle(), + }; + + let mut egui_winit = egui_winit::State::new(event_loop); + egui_winit.set_max_texture_side(max_texture_side); + egui_winit.set_pixels_per_point(native_pixels_per_point); + + let app_icon_setter = super::app_icon::AppTitleIconSetter::new( + app_name.to_owned(), + native_options.icon_data.clone(), + ); + + Self { + frame, + last_auto_save: std::time::Instant::now(), + egui_ctx, + egui_winit, + pending_full_output: Default::default(), + close: false, + can_drag_window: false, + window_state, + follow_system_theme: native_options.follow_system_theme, + #[cfg(feature = "persistence")] + persist_window: native_options.persist_window, + app_icon_setter, + } + } + + #[cfg(feature = "accesskit")] + pub fn init_accesskit + Send>( + &mut self, + window: &winit::window::Window, + event_loop_proxy: winit::event_loop::EventLoopProxy, + ) { + let egui_ctx = self.egui_ctx.clone(); + self.egui_winit + .init_accesskit(window, event_loop_proxy, move || { + // This function is called when an accessibility client + // (e.g. screen reader) makes its first request. If we got here, + // we know that an accessibility tree is actually wanted. + egui_ctx.enable_accesskit(); + // Enqueue a repaint so we'll receive a full tree update soon. + egui_ctx.request_repaint(); + egui_ctx.accesskit_placeholder_tree_update() + }); + } + + pub fn warm_up(&mut self, app: &mut dyn epi::App, window: &winit::window::Window) { + crate::profile_function!(); + let saved_memory: egui::Memory = self.egui_ctx.memory(|mem| mem.clone()); + self.egui_ctx + .memory_mut(|mem| mem.set_everything_is_visible(true)); + let full_output = self.update(app, window); + self.pending_full_output.append(full_output); // Handle it next frame + self.egui_ctx.memory_mut(|mem| *mem = saved_memory); // We don't want to remember that windows were huge. + self.egui_ctx.clear_animations(); + } + + /// If `true`, it is time to close the native window. + pub fn should_close(&self) -> bool { + self.close + } + + pub fn on_event( + &mut self, + app: &mut dyn epi::App, + event: &winit::event::WindowEvent<'_>, + ) -> EventResponse { + crate::profile_function!(); + + use winit::event::{ElementState, MouseButton, WindowEvent}; + + match event { + WindowEvent::CloseRequested => { + log::debug!("Received WindowEvent::CloseRequested"); + self.close = app.on_close_event(); + log::debug!("App::on_close_event returned {}", self.close); + } + WindowEvent::Destroyed => { + log::debug!("Received WindowEvent::Destroyed"); + self.close = true; + } + WindowEvent::MouseInput { + button: MouseButton::Left, + state: ElementState::Pressed, + .. + } => self.can_drag_window = true, + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + self.frame.info.native_pixels_per_point = Some(*scale_factor as _); + } + WindowEvent::ThemeChanged(winit_theme) if self.follow_system_theme => { + let theme = theme_from_winit_theme(*winit_theme); + self.frame.info.system_theme = Some(theme); + self.egui_ctx.set_visuals(theme.egui_visuals()); + } + _ => {} + } + + self.egui_winit.on_event(&self.egui_ctx, event) + } + + #[cfg(feature = "accesskit")] + pub fn on_accesskit_action_request(&mut self, request: accesskit::ActionRequest) { + self.egui_winit.on_accesskit_action_request(request); + } + + pub fn update( + &mut self, + app: &mut dyn epi::App, + window: &winit::window::Window, + ) -> egui::FullOutput { + let frame_start = std::time::Instant::now(); + + self.app_icon_setter.update(); + + self.frame.info.window_info = + read_window_info(window, self.egui_ctx.pixels_per_point(), &self.window_state); + let raw_input = self.egui_winit.take_egui_input(window); + + // Run user code: + let full_output = self.egui_ctx.run(raw_input, |egui_ctx| { + crate::profile_scope!("App::update"); + app.update(egui_ctx, &mut self.frame); + }); + + self.pending_full_output.append(full_output); + let full_output = std::mem::take(&mut self.pending_full_output); + + { + let mut app_output = self.frame.take_app_output(); + app_output.drag_window &= self.can_drag_window; // Necessary on Windows; see https://github.com/emilk/egui/pull/1108 + self.can_drag_window = false; + if app_output.close { + self.close = app.on_close_event(); + log::debug!("App::on_close_event returned {}", self.close); + } + self.frame.output.visible = app_output.visible; // this is handled by post_present + self.frame.output.screenshot_requested = app_output.screenshot_requested; + if self.frame.output.attention.is_some() { + self.frame.output.attention = None; + } + handle_app_output( + window, + self.egui_ctx.pixels_per_point(), + app_output, + &mut self.window_state, + ); + } + + let frame_time = frame_start.elapsed().as_secs_f64() as f32; + self.frame.info.cpu_usage = Some(frame_time); + + full_output + } + + pub fn post_rendering(&mut self, app: &mut dyn epi::App, window: &winit::window::Window) { + let inner_size = window.inner_size(); + let window_size_px = [inner_size.width, inner_size.height]; + + app.post_rendering(window_size_px, &self.frame); + } + + pub fn post_present(&mut self, window: &winit::window::Window) { + if let Some(visible) = self.frame.output.visible.take() { + window.set_visible(visible); + } + } + + pub fn handle_platform_output( + &mut self, + window: &winit::window::Window, + platform_output: egui::PlatformOutput, + ) { + self.egui_winit + .handle_platform_output(window, &self.egui_ctx, platform_output); + } + + // ------------------------------------------------------------------------ + // Persistence stuff: + + pub fn maybe_autosave(&mut self, app: &mut dyn epi::App, window: &winit::window::Window) { + let now = std::time::Instant::now(); + if now - self.last_auto_save > app.auto_save_interval() { + self.save(app, Some(window)); + self.last_auto_save = now; + } + } + + #[allow(clippy::unused_self)] + pub fn save(&mut self, _app: &mut dyn epi::App, _window: Option<&winit::window::Window>) { + #[cfg(feature = "persistence")] + if let Some(storage) = self.frame.storage_mut() { + crate::profile_function!(); + + if let Some(window) = _window { + if self.persist_window { + crate::profile_scope!("native_window"); + epi::set_value( + storage, + STORAGE_WINDOW_KEY, + &WindowSettings::from_display(window), + ); + } + } + if _app.persist_egui_memory() { + crate::profile_scope!("egui_memory"); + self.egui_ctx + .memory(|mem| epi::set_value(storage, STORAGE_EGUI_MEMORY_KEY, mem)); + } + { + crate::profile_scope!("App::save"); + _app.save(storage); + } + + crate::profile_scope!("Storage::flush"); + storage.flush(); + } + } +} + +#[cfg(feature = "persistence")] +const STORAGE_EGUI_MEMORY_KEY: &str = "egui"; + +#[cfg(feature = "persistence")] +const STORAGE_WINDOW_KEY: &str = "window"; + +pub fn load_window_settings(_storage: Option<&dyn epi::Storage>) -> Option { + crate::profile_function!(); + #[cfg(feature = "persistence")] + { + epi::get_value(_storage?, STORAGE_WINDOW_KEY) + } + #[cfg(not(feature = "persistence"))] + None +} + +pub fn load_egui_memory(_storage: Option<&dyn epi::Storage>) -> Option { + crate::profile_function!(); + #[cfg(feature = "persistence")] + { + epi::get_value(_storage?, STORAGE_EGUI_MEMORY_KEY) + } + #[cfg(not(feature = "persistence"))] + None +} + +pub(crate) fn theme_from_winit_theme(theme: winit::window::Theme) -> Theme { + match theme { + winit::window::Theme::Dark => Theme::Dark, + winit::window::Theme::Light => Theme::Light, + } +} diff --git a/crates/eframe/src/native/file_storage.rs b/crates/eframe/src/native/file_storage.rs new file mode 100644 index 00000000..4c44430f --- /dev/null +++ b/crates/eframe/src/native/file_storage.rs @@ -0,0 +1,171 @@ +use std::{ + collections::HashMap, + io::Write, + path::{Path, PathBuf}, +}; + +/// The folder where `eframe` will store its state. +/// +/// The given `app_id` is either [`crate::NativeOptions::app_id`] or +/// the title argument to [`crate::run_native`]. +/// +/// On native the path is picked using [`directories_next::ProjectDirs::data_dir`](https://docs.rs/directories-next/2.0.0/directories_next/struct.ProjectDirs.html#method.data_dir) which is: +/// * Linux: `/home/UserName/.local/share/APP_ID` +/// * macOS: `/Users/UserName/Library/Application Support/APP_ID` +/// * Windows: `C:\Users\UserName\AppData\Roaming\APP_ID` +pub fn storage_dir(app_id: &str) -> Option { + directories_next::ProjectDirs::from("", "", app_id) + .map(|proj_dirs| proj_dirs.data_dir().to_path_buf()) +} + +// ---------------------------------------------------------------------------- + +/// A key-value store backed by a [RON](https://github.com/ron-rs/ron) file on disk. +/// Used to restore egui state, glium window position/size and app state. +pub struct FileStorage { + ron_filepath: PathBuf, + kv: HashMap, + dirty: bool, + last_save_join_handle: Option>, +} + +impl Drop for FileStorage { + fn drop(&mut self) { + if let Some(join_handle) = self.last_save_join_handle.take() { + crate::profile_scope!("wait_for_save"); + join_handle.join().ok(); + } + } +} + +impl FileStorage { + /// Store the state in this .ron file. + fn from_ron_filepath(ron_filepath: impl Into) -> Self { + crate::profile_function!(); + let ron_filepath: PathBuf = ron_filepath.into(); + log::debug!("Loading app state from {:?}…", ron_filepath); + Self { + kv: read_ron(&ron_filepath).unwrap_or_default(), + ron_filepath, + dirty: false, + last_save_join_handle: None, + } + } + + /// Find a good place to put the files that the OS likes. + pub fn from_app_id(app_id: &str) -> Option { + crate::profile_function!(app_id); + if let Some(data_dir) = storage_dir(app_id) { + if let Err(err) = std::fs::create_dir_all(&data_dir) { + log::warn!( + "Saving disabled: Failed to create app path at {:?}: {}", + data_dir, + err + ); + None + } else { + Some(Self::from_ron_filepath(data_dir.join("app.ron"))) + } + } else { + log::warn!("Saving disabled: Failed to find path to data_dir."); + None + } + } +} + +impl crate::Storage for FileStorage { + fn get_string(&self, key: &str) -> Option { + self.kv.get(key).cloned() + } + + fn set_string(&mut self, key: &str, value: String) { + if self.kv.get(key) != Some(&value) { + self.kv.insert(key.to_owned(), value); + self.dirty = true; + } + } + + fn flush(&mut self) { + if self.dirty { + crate::profile_function!(); + self.dirty = false; + + let file_path = self.ron_filepath.clone(); + let kv = self.kv.clone(); + + if let Some(join_handle) = self.last_save_join_handle.take() { + // wait for previous save to complete. + join_handle.join().ok(); + } + + match std::thread::Builder::new() + .name("eframe_persist".to_owned()) + .spawn(move || { + save_to_disk(&file_path, &kv); + }) { + Ok(join_handle) => { + self.last_save_join_handle = Some(join_handle); + } + Err(err) => { + log::warn!("Failed to spawn thread to save app state: {err}"); + } + } + } + } +} + +fn save_to_disk(file_path: &PathBuf, kv: &HashMap) { + crate::profile_function!(); + + if let Some(parent_dir) = file_path.parent() { + if !parent_dir.exists() { + if let Err(err) = std::fs::create_dir_all(parent_dir) { + log::warn!("Failed to create directory {parent_dir:?}: {err}"); + } + } + } + + match std::fs::File::create(file_path) { + Ok(file) => { + let mut writer = std::io::BufWriter::new(file); + let config = Default::default(); + + crate::profile_scope!("ron::serialize"); + if let Err(err) = ron::ser::to_writer_pretty(&mut writer, &kv, config) + .and_then(|_| writer.flush().map_err(|err| err.into())) + { + log::warn!("Failed to serialize app state: {}", err); + } else { + log::trace!("Persisted to {:?}", file_path); + } + } + Err(err) => { + log::warn!("Failed to create file {file_path:?}: {err}"); + } + } +} + +// ---------------------------------------------------------------------------- + +fn read_ron(ron_path: impl AsRef) -> Option +where + T: serde::de::DeserializeOwned, +{ + crate::profile_function!(); + match std::fs::File::open(ron_path) { + Ok(file) => { + let reader = std::io::BufReader::new(file); + match ron::de::from_reader(reader) { + Ok(value) => Some(value), + Err(err) => { + log::warn!("Failed to parse RON: {}", err); + None + } + } + } + Err(_err) => { + // File probably doesn't exist. That's fine. + None + } + } +} diff --git a/crates/eframe/src/native/mod.rs b/crates/eframe/src/native/mod.rs new file mode 100644 index 00000000..8b606155 --- /dev/null +++ b/crates/eframe/src/native/mod.rs @@ -0,0 +1,7 @@ +mod app_icon; +mod epi_integration; +pub mod run; + +/// File storage which can be used by native backends. +#[cfg(feature = "persistence")] +pub mod file_storage; diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs new file mode 100644 index 00000000..5776e316 --- /dev/null +++ b/crates/eframe/src/native/run.rs @@ -0,0 +1,1666 @@ +//! Note that this file contains two similar paths - one for [`glow`], one for [`wgpu`]. +//! When making changes to one you often also want to apply it to the other. + +use std::time::Instant; + +use raw_window_handle::{HasRawDisplayHandle as _, HasRawWindowHandle as _}; +use winit::event_loop::{ + ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy, EventLoopWindowTarget, +}; + +#[cfg(feature = "accesskit")] +use egui_winit::accesskit_winit; +use egui_winit::winit; + +use crate::{epi, Result}; + +use super::epi_integration::{self, EpiIntegration}; + +// ---------------------------------------------------------------------------- + +/// The custom even `eframe` uses with the [`winit`] event loop. +#[derive(Debug)] +pub enum UserEvent { + /// A repaint is requested. + RequestRepaint { + /// When to repaint. + when: Instant, + + /// What the frame number was when the repaint was _requested_. + frame_nr: u64, + }, + + /// A request related to [`accesskit`](https://accesskit.dev/). + #[cfg(feature = "accesskit")] + AccessKitActionRequest(accesskit_winit::ActionRequestEvent), +} + +#[cfg(feature = "accesskit")] +impl From for UserEvent { + fn from(inner: accesskit_winit::ActionRequestEvent) -> Self { + Self::AccessKitActionRequest(inner) + } +} + +// ---------------------------------------------------------------------------- + +pub use epi::NativeOptions; + +#[derive(Debug)] +enum EventResult { + Wait, + + /// Causes a synchronous repaint inside the event handler. This should only + /// be used in special situations if the window must be repainted while + /// handling a specific event. This occurs on Windows when handling resizes. + /// + /// `RepaintNow` creates a new frame synchronously, and should therefore + /// only be used for extremely urgent repaints. + RepaintNow, + + /// Queues a repaint for once the event loop handles its next redraw. Exists + /// so that multiple input events can be handled in one frame. Does not + /// cause any delay like `RepaintNow`. + RepaintNext, + + RepaintAt(Instant), + + Exit, +} + +trait WinitApp { + /// The current frame number, as reported by egui. + fn frame_nr(&self) -> u64; + + fn is_focused(&self) -> bool; + + fn integration(&self) -> Option<&EpiIntegration>; + + fn window(&self) -> Option<&winit::window::Window>; + + fn save_and_destroy(&mut self); + + fn run_ui_and_paint(&mut self) -> EventResult; + + fn on_event( + &mut self, + event_loop: &EventLoopWindowTarget, + event: &winit::event::Event<'_, UserEvent>, + ) -> Result; +} + +fn create_event_loop_builder( + native_options: &mut epi::NativeOptions, +) -> EventLoopBuilder { + crate::profile_function!(); + let mut event_loop_builder = winit::event_loop::EventLoopBuilder::with_user_event(); + + if let Some(hook) = std::mem::take(&mut native_options.event_loop_builder) { + hook(&mut event_loop_builder); + } + + event_loop_builder +} + +fn create_event_loop(native_options: &mut epi::NativeOptions) -> EventLoop { + crate::profile_function!(); + let mut builder = create_event_loop_builder(native_options); + + crate::profile_scope!("EventLoopBuilder::build"); + builder.build() +} + +/// Access a thread-local event loop. +/// +/// We reuse the event-loop so we can support closing and opening an eframe window +/// multiple times. This is just a limitation of winit. +fn with_event_loop( + mut native_options: epi::NativeOptions, + f: impl FnOnce(&mut EventLoop, NativeOptions) -> R, +) -> R { + use std::cell::RefCell; + thread_local!(static EVENT_LOOP: RefCell>> = RefCell::new(None)); + + EVENT_LOOP.with(|event_loop| { + // Since we want to reference NativeOptions when creating the EventLoop we can't + // do that as part of the lazy thread local storage initialization and so we instead + // create the event loop lazily here + let mut event_loop = event_loop.borrow_mut(); + let event_loop = event_loop.get_or_insert_with(|| create_event_loop(&mut native_options)); + f(event_loop, native_options) + }) +} + +#[cfg(not(target_os = "ios"))] +fn run_and_return( + event_loop: &mut EventLoop, + mut winit_app: impl WinitApp, +) -> Result<()> { + use winit::platform::run_return::EventLoopExtRunReturn as _; + + log::debug!("Entering the winit event loop (run_return)…"); + + let mut next_repaint_time = Instant::now(); + + let mut returned_result = Ok(()); + + event_loop.run_return(|event, event_loop, control_flow| { + crate::profile_scope!("winit_event", short_event_description(&event)); + + let event_result = match &event { + winit::event::Event::LoopDestroyed => { + // On Mac, Cmd-Q we get here and then `run_return` doesn't return (despite its name), + // so we need to save state now: + log::debug!("Received Event::LoopDestroyed - saving app state…"); + winit_app.save_and_destroy(); + *control_flow = ControlFlow::Exit; + return; + } + + // Platform-dependent event handlers to workaround a winit bug + // See: https://github.com/rust-windowing/winit/issues/987 + // See: https://github.com/rust-windowing/winit/issues/1619 + winit::event::Event::RedrawEventsCleared if cfg!(target_os = "windows") => { + next_repaint_time = extremely_far_future(); + winit_app.run_ui_and_paint() + } + winit::event::Event::RedrawRequested(_) if !cfg!(target_os = "windows") => { + next_repaint_time = extremely_far_future(); + winit_app.run_ui_and_paint() + } + + winit::event::Event::UserEvent(UserEvent::RequestRepaint { when, frame_nr }) => { + if winit_app.frame_nr() == *frame_nr { + log::trace!("UserEvent::RequestRepaint scheduling repaint at {when:?}"); + EventResult::RepaintAt(*when) + } else { + log::trace!("Got outdated UserEvent::RequestRepaint"); + EventResult::Wait // old request - we've already repainted + } + } + + winit::event::Event::NewEvents(winit::event::StartCause::ResumeTimeReached { + .. + }) => { + log::trace!("Woke up to check next_repaint_time"); + EventResult::Wait + } + + winit::event::Event::WindowEvent { window_id, .. } + if winit_app.window().is_none() + || *window_id != winit_app.window().unwrap().id() => + { + // This can happen if we close a window, and then reopen a new one, + // or if we have multiple windows open. + EventResult::Wait + } + + event => match winit_app.on_event(event_loop, event) { + Ok(event_result) => event_result, + Err(err) => { + log::error!("Exiting because of error: {err:?} on event {event:?}"); + returned_result = Err(err); + EventResult::Exit + } + }, + }; + + match event_result { + EventResult::Wait => {} + EventResult::RepaintNow => { + log::trace!("Repaint caused by winit::Event: {:?}", event); + if cfg!(target_os = "windows") { + // Fix flickering on Windows, see https://github.com/emilk/egui/pull/2280 + next_repaint_time = extremely_far_future(); + winit_app.run_ui_and_paint(); + } else { + // Fix for https://github.com/emilk/egui/issues/2425 + next_repaint_time = Instant::now(); + } + } + EventResult::RepaintNext => { + log::trace!("Repaint caused by winit::Event: {:?}", event); + next_repaint_time = Instant::now(); + } + EventResult::RepaintAt(repaint_time) => { + next_repaint_time = next_repaint_time.min(repaint_time); + } + EventResult::Exit => { + log::debug!("Asking to exit event loop…"); + winit_app.save_and_destroy(); + *control_flow = ControlFlow::Exit; + return; + } + } + + *control_flow = if next_repaint_time <= Instant::now() { + if let Some(window) = winit_app.window() { + log::trace!("request_redraw"); + window.request_redraw(); + } + next_repaint_time = extremely_far_future(); + ControlFlow::Poll + } else { + let time_until_next = next_repaint_time.saturating_duration_since(Instant::now()); + if time_until_next < std::time::Duration::from_secs(10_000) { + log::trace!("WaitUntil {time_until_next:?}"); + } + ControlFlow::WaitUntil(next_repaint_time) + }; + }); + + log::debug!("eframe window closed"); + + drop(winit_app); + + // On Windows this clears out events so that we can later create another window. + // See https://github.com/emilk/egui/pull/1889 for details. + // + // Note that this approach may cause issues on macOS (emilk/egui#2768); therefore, + // we only apply this approach on Windows to minimize the affect. + #[cfg(target_os = "windows")] + { + event_loop.run_return(|_, _, control_flow| { + control_flow.set_exit(); + }); + } + + returned_result +} + +fn run_and_exit(event_loop: EventLoop, mut winit_app: impl WinitApp + 'static) -> ! { + log::debug!("Entering the winit event loop (run)…"); + + let mut next_repaint_time = Instant::now(); + + event_loop.run(move |event, event_loop, control_flow| { + crate::profile_scope!("winit_event", short_event_description(&event)); + + let event_result = match event { + winit::event::Event::LoopDestroyed => { + log::debug!("Received Event::LoopDestroyed"); + EventResult::Exit + } + + // Platform-dependent event handlers to workaround a winit bug + // See: https://github.com/rust-windowing/winit/issues/987 + // See: https://github.com/rust-windowing/winit/issues/1619 + winit::event::Event::RedrawEventsCleared if cfg!(target_os = "windows") => { + next_repaint_time = extremely_far_future(); + winit_app.run_ui_and_paint() + } + winit::event::Event::RedrawRequested(_) if !cfg!(target_os = "windows") => { + next_repaint_time = extremely_far_future(); + winit_app.run_ui_and_paint() + } + + winit::event::Event::UserEvent(UserEvent::RequestRepaint { when, frame_nr }) => { + if winit_app.frame_nr() == frame_nr { + EventResult::RepaintAt(when) + } else { + EventResult::Wait // old request - we've already repainted + } + } + + winit::event::Event::NewEvents(winit::event::StartCause::ResumeTimeReached { + .. + }) => EventResult::Wait, // We just woke up to check next_repaint_time + + event => match winit_app.on_event(event_loop, &event) { + Ok(event_result) => event_result, + Err(err) => { + panic!("eframe encountered a fatal error: {err}"); + } + }, + }; + + match event_result { + EventResult::Wait => {} + EventResult::RepaintNow => { + if cfg!(target_os = "windows") { + // Fix flickering on Windows, see https://github.com/emilk/egui/pull/2280 + next_repaint_time = extremely_far_future(); + winit_app.run_ui_and_paint(); + } else { + // Fix for https://github.com/emilk/egui/issues/2425 + next_repaint_time = Instant::now(); + } + } + EventResult::RepaintNext => { + next_repaint_time = Instant::now(); + } + EventResult::RepaintAt(repaint_time) => { + next_repaint_time = next_repaint_time.min(repaint_time); + } + EventResult::Exit => { + log::debug!("Quitting - saving app state…"); + winit_app.save_and_destroy(); + #[allow(clippy::exit)] + std::process::exit(0); + } + } + + *control_flow = if next_repaint_time <= Instant::now() { + if let Some(window) = winit_app.window() { + window.request_redraw(); + } + next_repaint_time = extremely_far_future(); + ControlFlow::Poll + } else { + // WaitUntil seems to not work on iOS + #[cfg(target_os = "ios")] + if let Some(window) = winit_app.window() { + window.request_redraw(); + } + ControlFlow::WaitUntil(next_repaint_time) + }; + }) +} + +// ---------------------------------------------------------------------------- +/// Run an egui app +#[cfg(feature = "glow")] +mod glow_integration { + use std::sync::Arc; + + use egui::NumExt as _; + use glutin::{ + display::GetGlDisplay, + prelude::{GlDisplay, NotCurrentGlContextSurfaceAccessor, PossiblyCurrentGlContext}, + surface::GlSurface, + }; + + use super::*; + + // Note: that the current Glutin API design tightly couples the GL context with + // the Window which means it's not practically possible to just destroy the + // window and re-create a new window while continuing to use the same GL context. + // + // For now this means it's not possible to support Android as well as we can with + // wgpu because we're basically forced to destroy and recreate _everything_ when + // the application suspends and resumes. + // + // There is work in progress to improve the Glutin API so it has a separate Surface + // API that would allow us to just destroy a Window/Surface when suspending, see: + // https://github.com/rust-windowing/glutin/pull/1435 + // + + /// State that is initialized when the application is first starts running via + /// a Resumed event. On Android this ensures that any graphics state is only + /// initialized once the application has an associated `SurfaceView`. + struct GlowWinitRunning { + gl: Arc, + painter: egui_glow::Painter, + integration: epi_integration::EpiIntegration, + app: Box, + // Conceptually this will be split out eventually so that the rest of the state + // can be persistent. + gl_window: GlutinWindowContext, + } + + /// This struct will contain both persistent and temporary glutin state. + /// + /// Platform Quirks: + /// * Microsoft Windows: requires that we create a window before opengl context. + /// * Android: window and surface should be destroyed when we receive a suspend event. recreate on resume event. + /// + /// winit guarantees that we will get a Resumed event on startup on all platforms. + /// * Before Resumed event: `gl_config`, `gl_context` can be created at any time. on windows, a window must be created to get `gl_context`. + /// * Resumed: `gl_surface` will be created here. `window` will be re-created here for android. + /// * Suspended: on android, we drop window + surface. on other platforms, we don't get Suspended event. + /// + /// The setup is divided between the `new` fn and `on_resume` fn. we can just assume that `on_resume` is a continuation of + /// `new` fn on all platforms. only on android, do we get multiple resumed events because app can be suspended. + struct GlutinWindowContext { + builder: winit::window::WindowBuilder, + swap_interval: glutin::surface::SwapInterval, + gl_config: glutin::config::Config, + current_gl_context: Option, + gl_surface: Option>, + not_current_gl_context: Option, + window: Option, + } + + impl GlutinWindowContext { + /// There is a lot of complexity with opengl creation, so prefer extensive logging to get all the help we can to debug issues. + /// + #[allow(unsafe_code)] + unsafe fn new( + winit_window_builder: winit::window::WindowBuilder, + native_options: &epi::NativeOptions, + event_loop: &EventLoopWindowTarget, + ) -> Result { + crate::profile_function!(); + + use glutin::prelude::*; + // convert native options to glutin options + let hardware_acceleration = match native_options.hardware_acceleration { + crate::HardwareAcceleration::Required => Some(true), + crate::HardwareAcceleration::Preferred => None, + crate::HardwareAcceleration::Off => Some(false), + }; + let swap_interval = if native_options.vsync { + glutin::surface::SwapInterval::Wait(std::num::NonZeroU32::new(1).unwrap()) + } else { + glutin::surface::SwapInterval::DontWait + }; + /* opengl setup flow goes like this: + 1. we create a configuration for opengl "Display" / "Config" creation + 2. choose between special extensions like glx or egl or wgl and use them to create config/display + 3. opengl context configuration + 4. opengl context creation + */ + // start building config for gl display + let config_template_builder = glutin::config::ConfigTemplateBuilder::new() + .prefer_hardware_accelerated(hardware_acceleration) + .with_depth_size(native_options.depth_buffer) + .with_stencil_size(native_options.stencil_buffer) + .with_transparency(native_options.transparent); + // we don't know if multi sampling option is set. so, check if its more than 0. + let config_template_builder = if native_options.multisampling > 0 { + config_template_builder.with_multisampling( + native_options + .multisampling + .try_into() + .expect("failed to fit multisamples option of native_options into u8"), + ) + } else { + config_template_builder + }; + + log::debug!( + "trying to create glutin Display with config: {:?}", + &config_template_builder + ); + + // Create GL display. This may probably create a window too on most platforms. Definitely on `MS windows`. Never on Android. + let display_builder = glutin_winit::DisplayBuilder::new() + // we might want to expose this option to users in the future. maybe using an env var or using native_options. + .with_preference(glutin_winit::ApiPrefence::FallbackEgl) // https://github.com/emilk/egui/issues/2520#issuecomment-1367841150 + .with_window_builder(Some(winit_window_builder.clone())); + + let (window, gl_config) = { + crate::profile_scope!("DisplayBuilder::build"); + + display_builder + .build( + event_loop, + config_template_builder.clone(), + |mut config_iterator| { + let config = config_iterator.next().expect( + "failed to find a matching configuration for creating glutin config", + ); + log::debug!( + "using the first config from config picker closure. config: {:?}", + &config + ); + config + }, + ) + .map_err(|e| { + crate::Error::NoGlutinConfigs(config_template_builder.build(), e) + })? + }; + + let gl_display = gl_config.display(); + log::debug!( + "successfully created GL Display with version: {} and supported features: {:?}", + gl_display.version_string(), + gl_display.supported_features() + ); + let raw_window_handle = window.as_ref().map(|w| w.raw_window_handle()); + log::debug!( + "creating gl context using raw window handle: {:?}", + raw_window_handle + ); + + // create gl context. if core context cannot be created, try gl es context as fallback. + let context_attributes = + glutin::context::ContextAttributesBuilder::new().build(raw_window_handle); + let fallback_context_attributes = glutin::context::ContextAttributesBuilder::new() + .with_context_api(glutin::context::ContextApi::Gles(None)) + .build(raw_window_handle); + + let gl_context_result = { + crate::profile_scope!("create_context"); + gl_config + .display() + .create_context(&gl_config, &context_attributes) + }; + + let gl_context = match gl_context_result { + Ok(it) => it, + Err(err) => { + log::warn!("failed to create context using default context attributes {context_attributes:?} due to error: {err}"); + log::debug!("retrying with fallback context attributes: {fallback_context_attributes:?}"); + gl_config + .display() + .create_context(&gl_config, &fallback_context_attributes)? + } + }; + let not_current_gl_context = Some(gl_context); + + // the fun part with opengl gl is that we never know whether there is an error. the context creation might have failed, but + // it could keep working until we try to make surface current or swap buffers or something else. future glutin improvements might + // help us start from scratch again if we fail context creation and go back to preferEgl or try with different config etc.. + // https://github.com/emilk/egui/pull/2541#issuecomment-1370767582 + Ok(GlutinWindowContext { + builder: winit_window_builder, + swap_interval, + gl_config, + current_gl_context: None, + window, + gl_surface: None, + not_current_gl_context, + }) + } + + /// This will be run after `new`. on android, it might be called multiple times over the course of the app's lifetime. + /// roughly, + /// 1. check if window already exists. otherwise, create one now. + /// 2. create attributes for surface creation. + /// 3. create surface. + /// 4. make surface and context current. + /// + /// we presently assume that we will + #[allow(unsafe_code)] + fn on_resume(&mut self, event_loop: &EventLoopWindowTarget) -> Result<()> { + crate::profile_function!(); + + if self.gl_surface.is_some() { + log::warn!("on_resume called even thought we already have a surface. early return"); + return Ok(()); + } + log::debug!("running on_resume fn."); + // make sure we have a window or create one. + let window = self.window.take().unwrap_or_else(|| { + log::debug!("window doesn't exist yet. creating one now with finalize_window"); + glutin_winit::finalize_window(event_loop, self.builder.clone(), &self.gl_config) + .expect("failed to finalize glutin window") + }); + // surface attributes + let (width, height): (u32, u32) = window.inner_size().into(); + let width = std::num::NonZeroU32::new(width.at_least(1)).unwrap(); + let height = std::num::NonZeroU32::new(height.at_least(1)).unwrap(); + let surface_attributes = + glutin::surface::SurfaceAttributesBuilder::::new() + .build(window.raw_window_handle(), width, height); + log::debug!( + "creating surface with attributes: {:?}", + &surface_attributes + ); + // create surface + let gl_surface = unsafe { + self.gl_config + .display() + .create_window_surface(&self.gl_config, &surface_attributes)? + }; + log::debug!("surface created successfully: {gl_surface:?}.making context current"); + // make surface and context current. + let not_current_gl_context = self + .not_current_gl_context + .take() + .expect("failed to get not current context after resume event. impossible!"); + let current_gl_context = not_current_gl_context.make_current(&gl_surface)?; + // try setting swap interval. but its not absolutely necessary, so don't panic on failure. + log::debug!("made context current. setting swap interval for surface"); + if let Err(e) = gl_surface.set_swap_interval(¤t_gl_context, self.swap_interval) { + log::error!("failed to set swap interval due to error: {e:?}"); + } + // we will reach this point only once in most platforms except android. + // create window/surface/make context current once and just use them forever. + self.gl_surface = Some(gl_surface); + self.current_gl_context = Some(current_gl_context); + self.window = Some(window); + Ok(()) + } + + /// only applies for android. but we basically drop surface + window and make context not current + fn on_suspend(&mut self) -> Result<()> { + log::debug!("received suspend event. dropping window and surface"); + self.gl_surface.take(); + self.window.take(); + if let Some(current) = self.current_gl_context.take() { + log::debug!("context is current, so making it non-current"); + self.not_current_gl_context = Some(current.make_not_current()?); + } else { + log::debug!("context is already not current??? could be duplicate suspend event"); + } + Ok(()) + } + + fn window(&self) -> &winit::window::Window { + self.window.as_ref().expect("winit window doesn't exist") + } + + fn resize(&self, physical_size: winit::dpi::PhysicalSize) { + let width = std::num::NonZeroU32::new(physical_size.width.at_least(1)).unwrap(); + let height = std::num::NonZeroU32::new(physical_size.height.at_least(1)).unwrap(); + self.gl_surface + .as_ref() + .expect("failed to get surface to resize") + .resize( + self.current_gl_context + .as_ref() + .expect("failed to get current context to resize surface"), + width, + height, + ); + } + + fn swap_buffers(&self) -> glutin::error::Result<()> { + self.gl_surface + .as_ref() + .expect("failed to get surface to swap buffers") + .swap_buffers( + self.current_gl_context + .as_ref() + .expect("failed to get current context to swap buffers"), + ) + } + + fn get_proc_address(&self, addr: &std::ffi::CStr) -> *const std::ffi::c_void { + self.gl_config.display().get_proc_address(addr) + } + } + + struct GlowWinitApp { + repaint_proxy: Arc>>, + app_name: String, + native_options: epi::NativeOptions, + running: Option, + + // Note that since this `AppCreator` is FnOnce we are currently unable to support + // re-initializing the `GlowWinitRunning` state on Android if the application + // suspends and resumes. + app_creator: Option, + is_focused: bool, + } + + impl GlowWinitApp { + fn new( + event_loop: &EventLoop, + app_name: &str, + native_options: epi::NativeOptions, + app_creator: epi::AppCreator, + ) -> Self { + crate::profile_function!(); + Self { + repaint_proxy: Arc::new(egui::mutex::Mutex::new(event_loop.create_proxy())), + app_name: app_name.to_owned(), + native_options, + running: None, + app_creator: Some(app_creator), + is_focused: true, + } + } + + #[allow(unsafe_code)] + fn create_glutin_windowed_context( + event_loop: &EventLoopWindowTarget, + storage: Option<&dyn epi::Storage>, + title: &str, + native_options: &mut NativeOptions, + ) -> Result<(GlutinWindowContext, glow::Context)> { + crate::profile_function!(); + + let window_settings = epi_integration::load_window_settings(storage); + + let winit_window_builder = + epi_integration::window_builder(event_loop, title, native_options, window_settings); + let mut glutin_window_context = unsafe { + GlutinWindowContext::new(winit_window_builder, native_options, event_loop)? + }; + glutin_window_context.on_resume(event_loop)?; + + if let Some(window) = &glutin_window_context.window { + epi_integration::apply_native_options_to_window( + window, + native_options, + window_settings, + ); + } + + let gl = unsafe { + crate::profile_scope!("glow::Context::from_loader_function"); + glow::Context::from_loader_function(|s| { + let s = std::ffi::CString::new(s) + .expect("failed to construct C string from string for gl proc address"); + + glutin_window_context.get_proc_address(&s) + }) + }; + + Ok((glutin_window_context, gl)) + } + + fn init_run_state(&mut self, event_loop: &EventLoopWindowTarget) -> Result<()> { + crate::profile_function!(); + let storage = epi_integration::create_storage( + self.native_options + .app_id + .as_ref() + .unwrap_or(&self.app_name), + ); + + let (gl_window, gl) = Self::create_glutin_windowed_context( + event_loop, + storage.as_deref(), + &self.app_name, + &mut self.native_options, + )?; + let gl = Arc::new(gl); + + let painter = + egui_glow::Painter::new(gl.clone(), "", self.native_options.shader_version) + .unwrap_or_else(|err| panic!("An OpenGL error occurred: {err}\n")); + + let system_theme = system_theme(gl_window.window(), &self.native_options); + let mut integration = epi_integration::EpiIntegration::new( + event_loop, + painter.max_texture_side(), + gl_window.window(), + system_theme, + &self.app_name, + &self.native_options, + storage, + Some(gl.clone()), + #[cfg(feature = "wgpu")] + None, + ); + #[cfg(feature = "accesskit")] + { + integration.init_accesskit(gl_window.window(), self.repaint_proxy.lock().clone()); + } + let theme = system_theme.unwrap_or(self.native_options.default_theme); + integration.egui_ctx.set_visuals(theme.egui_visuals()); + + if self.native_options.mouse_passthrough { + gl_window.window().set_cursor_hittest(false).unwrap(); + } + + { + let event_loop_proxy = self.repaint_proxy.clone(); + integration + .egui_ctx + .set_request_repaint_callback(move |info| { + log::trace!("request_repaint_callback: {info:?}"); + let when = Instant::now() + info.after; + let frame_nr = info.current_frame_nr; + event_loop_proxy + .lock() + .send_event(UserEvent::RequestRepaint { when, frame_nr }) + .ok(); + }); + } + + let app_creator = std::mem::take(&mut self.app_creator) + .expect("Single-use AppCreator has unexpectedly already been taken"); + let mut app = app_creator(&epi::CreationContext { + egui_ctx: integration.egui_ctx.clone(), + integration_info: integration.frame.info().clone(), + storage: integration.frame.storage(), + gl: Some(gl.clone()), + #[cfg(feature = "wgpu")] + wgpu_render_state: None, + raw_display_handle: gl_window.window().raw_display_handle(), + raw_window_handle: gl_window.window().raw_window_handle(), + }); + + if app.warm_up_enabled() { + integration.warm_up(app.as_mut(), gl_window.window()); + } + + self.running = Some(GlowWinitRunning { + gl_window, + gl, + painter, + integration, + app, + }); + + Ok(()) + } + } + + impl WinitApp for GlowWinitApp { + fn frame_nr(&self) -> u64 { + self.running + .as_ref() + .map_or(0, |r| r.integration.egui_ctx.frame_nr()) + } + + fn is_focused(&self) -> bool { + self.is_focused + } + + fn integration(&self) -> Option<&EpiIntegration> { + self.running.as_ref().map(|r| &r.integration) + } + + fn window(&self) -> Option<&winit::window::Window> { + self.running.as_ref().map(|r| r.gl_window.window()) + } + + fn save_and_destroy(&mut self) { + if let Some(mut running) = self.running.take() { + crate::profile_function!(); + running + .integration + .save(running.app.as_mut(), running.gl_window.window.as_ref()); + running.app.on_exit(Some(&running.gl)); + running.painter.destroy(); + } + } + + fn run_ui_and_paint(&mut self) -> EventResult { + let Some(running) = &mut self.running else { + return EventResult::Wait; + }; + + if running.gl_window.window.is_none() { + return EventResult::Wait; + } + + #[cfg(feature = "puffin")] + puffin::GlobalProfiler::lock().new_frame(); + crate::profile_scope!("frame"); + + let GlowWinitRunning { + gl_window, + gl, + app, + integration, + painter, + } = running; + + let window = gl_window.window(); + + let screen_size_in_pixels: [u32; 2] = window.inner_size().into(); + + egui_glow::painter::clear( + gl, + screen_size_in_pixels, + app.clear_color(&integration.egui_ctx.style().visuals), + ); + + let egui::FullOutput { + platform_output, + repaint_after, + textures_delta, + shapes, + } = integration.update(app.as_mut(), window); + + integration.handle_platform_output(window, platform_output); + + let clipped_primitives = { + crate::profile_scope!("tessellate"); + integration.egui_ctx.tessellate(shapes) + }; + + painter.paint_and_update_textures( + screen_size_in_pixels, + integration.egui_ctx.pixels_per_point(), + &clipped_primitives, + &textures_delta, + ); + + let screenshot_requested = &mut integration.frame.output.screenshot_requested; + + if *screenshot_requested { + *screenshot_requested = false; + let screenshot = painter.read_screen_rgba(screen_size_in_pixels); + integration.frame.screenshot.set(Some(screenshot)); + } + + integration.post_rendering(app.as_mut(), window); + + { + crate::profile_scope!("swap_buffers"); + gl_window.swap_buffers().unwrap(); + } + + integration.post_present(window); + + #[cfg(feature = "__screenshot")] + // give it time to settle: + if integration.egui_ctx.frame_nr() == 2 { + if let Ok(path) = std::env::var("EFRAME_SCREENSHOT_TO") { + assert!( + path.ends_with(".png"), + "Expected EFRAME_SCREENSHOT_TO to end with '.png', got {path:?}" + ); + let screenshot = painter.read_screen_rgba(screen_size_in_pixels); + image::save_buffer( + &path, + screenshot.as_raw(), + screenshot.width() as u32, + screenshot.height() as u32, + image::ColorType::Rgba8, + ) + .unwrap_or_else(|err| { + panic!("Failed to save screenshot to {path:?}: {err}"); + }); + eprintln!("Screenshot saved to {path:?}."); + std::process::exit(0); + } + } + + let control_flow = if integration.should_close() { + EventResult::Exit + } else if repaint_after.is_zero() { + EventResult::RepaintNext + } else if let Some(repaint_after_instant) = + std::time::Instant::now().checked_add(repaint_after) + { + // if repaint_after is something huge and can't be added to Instant, + // we will use `ControlFlow::Wait` instead. + // technically, this might lead to some weird corner cases where the user *WANTS* + // winit to use `WaitUntil(MAX_INSTANT)` explicitly. they can roll their own + // egui backend impl i guess. + EventResult::RepaintAt(repaint_after_instant) + } else { + EventResult::Wait + }; + + integration.maybe_autosave(app.as_mut(), window); + + if window.is_minimized() == Some(true) { + // On Mac, a minimized Window uses up all CPU: + // https://github.com/emilk/egui/issues/325 + crate::profile_scope!("minimized_sleep"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + control_flow + } + + fn on_event( + &mut self, + event_loop: &EventLoopWindowTarget, + event: &winit::event::Event<'_, UserEvent>, + ) -> Result { + crate::profile_function!(); + + Ok(match event { + winit::event::Event::Resumed => { + // first resume event. + // we can actually move this outside of event loop. + // and just run the on_resume fn of gl_window + if self.running.is_none() { + self.init_run_state(event_loop)?; + } else { + // not the first resume event. create whatever you need. + self.running + .as_mut() + .unwrap() + .gl_window + .on_resume(event_loop)?; + } + EventResult::RepaintNow + } + winit::event::Event::Suspended => { + self.running.as_mut().unwrap().gl_window.on_suspend()?; + + EventResult::Wait + } + + winit::event::Event::WindowEvent { event, .. } => { + if let Some(running) = &mut self.running { + // On Windows, if a window is resized by the user, it should repaint synchronously, inside the + // event handler. + // + // If this is not done, the compositor will assume that the window does not want to redraw, + // and continue ahead. + // + // In eframe's case, that causes the window to rapidly flicker, as it struggles to deliver + // new frames to the compositor in time. + // + // The flickering is technically glutin or glow's fault, but we should be responding properly + // to resizes anyway, as doing so avoids dropping frames. + // + // See: https://github.com/emilk/egui/issues/903 + let mut repaint_asap = false; + + match &event { + winit::event::WindowEvent::Focused(new_focused) => { + self.is_focused = *new_focused; + } + winit::event::WindowEvent::Resized(physical_size) => { + repaint_asap = true; + + // Resize with 0 width and height is used by winit to signal a minimize event on Windows. + // See: https://github.com/rust-windowing/winit/issues/208 + // This solves an issue where the app would panic when minimizing on Windows. + if 0 < physical_size.width && 0 < physical_size.height { + running.gl_window.resize(*physical_size); + } + } + winit::event::WindowEvent::ScaleFactorChanged { + new_inner_size, + .. + } => { + repaint_asap = true; + running.gl_window.resize(**new_inner_size); + } + winit::event::WindowEvent::CloseRequested + if running.integration.should_close() => + { + log::debug!("Received WindowEvent::CloseRequested"); + return Ok(EventResult::Exit); + } + _ => {} + } + + let event_response = + running.integration.on_event(running.app.as_mut(), event); + + if running.integration.should_close() { + EventResult::Exit + } else if event_response.repaint { + if repaint_asap { + EventResult::RepaintNow + } else { + EventResult::RepaintNext + } + } else { + EventResult::Wait + } + } else { + EventResult::Wait + } + } + + #[cfg(feature = "accesskit")] + winit::event::Event::UserEvent(UserEvent::AccessKitActionRequest( + accesskit_winit::ActionRequestEvent { request, .. }, + )) => { + if let Some(running) = &mut self.running { + crate::profile_scope!("on_accesskit_action_request"); + running + .integration + .on_accesskit_action_request(request.clone()); + // As a form of user input, accessibility actions should + // lead to a repaint. + EventResult::RepaintNext + } else { + EventResult::Wait + } + } + _ => EventResult::Wait, + }) + } + } + + pub fn run_glow( + app_name: &str, + mut native_options: epi::NativeOptions, + app_creator: epi::AppCreator, + ) -> Result<()> { + #[cfg(not(target_os = "ios"))] + if native_options.run_and_return { + with_event_loop(native_options, |event_loop, native_options| { + let glow_eframe = + GlowWinitApp::new(event_loop, app_name, native_options, app_creator); + run_and_return(event_loop, glow_eframe) + }) + } else { + let event_loop = create_event_loop(&mut native_options); + let glow_eframe = GlowWinitApp::new(&event_loop, app_name, native_options, app_creator); + run_and_exit(event_loop, glow_eframe); + } + + #[cfg(target_os = "ios")] + { + let event_loop = create_event_loop(&mut native_options); + let glow_eframe = GlowWinitApp::new(&event_loop, app_name, native_options, app_creator); + run_and_exit(event_loop, glow_eframe); + } + } +} + +#[cfg(feature = "glow")] +pub use glow_integration::run_glow; +// ---------------------------------------------------------------------------- + +#[cfg(feature = "wgpu")] +mod wgpu_integration { + use std::sync::Arc; + + use parking_lot::Mutex; + + use super::*; + + /// State that is initialized when the application is first starts running via + /// a Resumed event. On Android this ensures that any graphics state is only + /// initialized once the application has an associated `SurfaceView`. + struct WgpuWinitRunning { + painter: luminol_egui_wgpu::winit::Painter, + integration: epi_integration::EpiIntegration, + app: Box, + } + + struct WgpuWinitApp { + repaint_proxy: Arc>>, + app_name: String, + native_options: epi::NativeOptions, + app_creator: Option, + running: Option, + + /// Window surface state that's initialized when the app starts running via a Resumed event + /// and on Android will also be destroyed if the application is paused. + window: Option, + is_focused: bool, + } + + impl WgpuWinitApp { + fn new( + event_loop: &EventLoop, + app_name: &str, + native_options: epi::NativeOptions, + app_creator: epi::AppCreator, + ) -> Self { + crate::profile_function!(); + #[cfg(feature = "__screenshot")] + assert!( + std::env::var("EFRAME_SCREENSHOT_TO").is_err(), + "EFRAME_SCREENSHOT_TO not yet implemented for wgpu backend" + ); + + Self { + repaint_proxy: Arc::new(Mutex::new(event_loop.create_proxy())), + app_name: app_name.to_owned(), + native_options, + running: None, + window: None, + app_creator: Some(app_creator), + is_focused: true, + } + } + + fn create_window( + event_loop: &EventLoopWindowTarget, + storage: Option<&dyn epi::Storage>, + title: &str, + native_options: &mut NativeOptions, + ) -> std::result::Result { + crate::profile_function!(); + + let window_settings = epi_integration::load_window_settings(storage); + let window_builder = + epi_integration::window_builder(event_loop, title, native_options, window_settings); + let window = { + crate::profile_scope!("WindowBuilder::build"); + window_builder.build(event_loop)? + }; + epi_integration::apply_native_options_to_window( + &window, + native_options, + window_settings, + ); + Ok(window) + } + + #[allow(unsafe_code)] + fn set_window( + &mut self, + window: winit::window::Window, + ) -> std::result::Result<(), luminol_egui_wgpu::WgpuError> { + self.window = Some(window); + if let Some(running) = &mut self.running { + crate::profile_function!(); + pollster::block_on(running.painter.set_window(self.window.as_ref()))?; + } + Ok(()) + } + + #[allow(unsafe_code)] + #[cfg(target_os = "android")] + fn drop_window(&mut self) -> std::result::Result<(), luminol_egui_wgpu::WgpuError> { + self.window = None; + if let Some(running) = &mut self.running { + pollster::block_on(running.painter.set_window(None))?; + } + Ok(()) + } + + fn init_run_state( + &mut self, + event_loop: &EventLoopWindowTarget, + storage: Option>, + window: winit::window::Window, + ) -> std::result::Result<(), luminol_egui_wgpu::WgpuError> { + crate::profile_function!(); + + #[allow(unsafe_code, unused_mut, unused_unsafe)] + let mut painter = luminol_egui_wgpu::winit::Painter::new( + self.native_options.wgpu_options.clone(), + self.native_options.multisampling.max(1) as _, + luminol_egui_wgpu::depth_format_from_bits( + self.native_options.depth_buffer, + self.native_options.stencil_buffer, + ), + self.native_options.transparent, + ); + pollster::block_on(painter.set_window(Some(&window)))?; + + let wgpu_render_state = painter.render_state(); + + let system_theme = system_theme(&window, &self.native_options); + let mut integration = epi_integration::EpiIntegration::new( + event_loop, + painter.max_texture_side().unwrap_or(2048), + &window, + system_theme, + &self.app_name, + &self.native_options, + storage, + #[cfg(feature = "glow")] + None, + wgpu_render_state.clone(), + ); + #[cfg(feature = "accesskit")] + { + integration.init_accesskit(&window, self.repaint_proxy.lock().clone()); + } + let theme = system_theme.unwrap_or(self.native_options.default_theme); + integration.egui_ctx.set_visuals(theme.egui_visuals()); + + { + let event_loop_proxy = self.repaint_proxy.clone(); + integration + .egui_ctx + .set_request_repaint_callback(move |info| { + log::trace!("request_repaint_callback: {info:?}"); + let when = Instant::now() + info.after; + let frame_nr = info.current_frame_nr; + event_loop_proxy + .lock() + .send_event(UserEvent::RequestRepaint { when, frame_nr }) + .ok(); + }); + } + + let app_creator = std::mem::take(&mut self.app_creator) + .expect("Single-use AppCreator has unexpectedly already been taken"); + let cc = epi::CreationContext { + egui_ctx: integration.egui_ctx.clone(), + integration_info: integration.frame.info().clone(), + storage: integration.frame.storage(), + #[cfg(feature = "glow")] + gl: None, + wgpu_render_state, + raw_display_handle: window.raw_display_handle(), + raw_window_handle: window.raw_window_handle(), + }; + let mut app = { + crate::profile_scope!("user_app_creator"); + app_creator(&cc) + }; + + if app.warm_up_enabled() { + integration.warm_up(app.as_mut(), &window); + } + + self.running = Some(WgpuWinitRunning { + painter, + integration, + app, + }); + self.window = Some(window); + + Ok(()) + } + } + + impl WinitApp for WgpuWinitApp { + fn frame_nr(&self) -> u64 { + self.running + .as_ref() + .map_or(0, |r| r.integration.egui_ctx.frame_nr()) + } + + fn is_focused(&self) -> bool { + self.is_focused + } + + fn integration(&self) -> Option<&EpiIntegration> { + self.running.as_ref().map(|r| &r.integration) + } + + fn window(&self) -> Option<&winit::window::Window> { + self.window.as_ref() + } + + fn save_and_destroy(&mut self) { + if let Some(mut running) = self.running.take() { + crate::profile_function!(); + running + .integration + .save(running.app.as_mut(), self.window.as_ref()); + + #[cfg(feature = "glow")] + running.app.on_exit(None); + + #[cfg(not(feature = "glow"))] + running.app.on_exit(); + + running.painter.destroy(); + } + } + + fn run_ui_and_paint(&mut self) -> EventResult { + let (Some(running), Some(window)) = (&mut self.running, &self.window) else { + return EventResult::Wait; + }; + + #[cfg(feature = "puffin")] + puffin::GlobalProfiler::lock().new_frame(); + crate::profile_scope!("frame"); + + let WgpuWinitRunning { + app, + integration, + painter, + } = running; + + let egui::FullOutput { + platform_output, + repaint_after, + textures_delta, + shapes, + } = integration.update(app.as_mut(), window); + + integration.handle_platform_output(window, platform_output); + + let clipped_primitives = { + crate::profile_scope!("tessellate"); + integration.egui_ctx.tessellate(shapes) + }; + + let screenshot_requested = &mut integration.frame.output.screenshot_requested; + + let screenshot = painter.paint_and_update_textures( + integration.egui_ctx.pixels_per_point(), + app.clear_color(&integration.egui_ctx.style().visuals), + &clipped_primitives, + &textures_delta, + *screenshot_requested, + ); + *screenshot_requested = false; + integration.frame.screenshot.set(screenshot); + + integration.post_rendering(app.as_mut(), window); + integration.post_present(window); + + let control_flow = if integration.should_close() { + EventResult::Exit + } else if repaint_after.is_zero() { + EventResult::RepaintNext + } else if let Some(repaint_after_instant) = + std::time::Instant::now().checked_add(repaint_after) + { + // if repaint_after is something huge and can't be added to Instant, + // we will use `ControlFlow::Wait` instead. + // technically, this might lead to some weird corner cases where the user *WANTS* + // winit to use `WaitUntil(MAX_INSTANT)` explicitly. they can roll their own + // egui backend impl i guess. + EventResult::RepaintAt(repaint_after_instant) + } else { + EventResult::Wait + }; + + integration.maybe_autosave(app.as_mut(), window); + + if window.is_minimized() == Some(true) { + // On Mac, a minimized Window uses up all CPU: + // https://github.com/emilk/egui/issues/325 + crate::profile_scope!("minimized_sleep"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + control_flow + } + + fn on_event( + &mut self, + event_loop: &EventLoopWindowTarget, + event: &winit::event::Event<'_, UserEvent>, + ) -> Result { + crate::profile_function!(); + + Ok(match event { + winit::event::Event::Resumed => { + if let Some(running) = &self.running { + if self.window.is_none() { + let window = Self::create_window( + event_loop, + running.integration.frame.storage(), + &self.app_name, + &mut self.native_options, + )?; + self.set_window(window)?; + } + } else { + let storage = epi_integration::create_storage( + self.native_options + .app_id + .as_ref() + .unwrap_or(&self.app_name), + ); + let window = Self::create_window( + event_loop, + storage.as_deref(), + &self.app_name, + &mut self.native_options, + )?; + self.init_run_state(event_loop, storage, window)?; + } + EventResult::RepaintNow + } + winit::event::Event::Suspended => { + #[cfg(target_os = "android")] + self.drop_window()?; + EventResult::Wait + } + + winit::event::Event::WindowEvent { event, .. } => { + if let Some(running) = &mut self.running { + // On Windows, if a window is resized by the user, it should repaint synchronously, inside the + // event handler. + // + // If this is not done, the compositor will assume that the window does not want to redraw, + // and continue ahead. + // + // In eframe's case, that causes the window to rapidly flicker, as it struggles to deliver + // new frames to the compositor in time. + // + // The flickering is technically glutin or glow's fault, but we should be responding properly + // to resizes anyway, as doing so avoids dropping frames. + // + // See: https://github.com/emilk/egui/issues/903 + let mut repaint_asap = false; + + match &event { + winit::event::WindowEvent::Focused(new_focused) => { + self.is_focused = *new_focused; + } + winit::event::WindowEvent::Resized(physical_size) => { + repaint_asap = true; + + // Resize with 0 width and height is used by winit to signal a minimize event on Windows. + // See: https://github.com/rust-windowing/winit/issues/208 + // This solves an issue where the app would panic when minimizing on Windows. + if 0 < physical_size.width && 0 < physical_size.height { + running.painter.on_window_resized( + physical_size.width, + physical_size.height, + ); + } + } + winit::event::WindowEvent::ScaleFactorChanged { + new_inner_size, + .. + } => { + repaint_asap = true; + running + .painter + .on_window_resized(new_inner_size.width, new_inner_size.height); + } + winit::event::WindowEvent::CloseRequested + if running.integration.should_close() => + { + log::debug!("Received WindowEvent::CloseRequested"); + return Ok(EventResult::Exit); + } + _ => {} + }; + + let event_response = + running.integration.on_event(running.app.as_mut(), event); + if running.integration.should_close() { + EventResult::Exit + } else if event_response.repaint { + if repaint_asap { + EventResult::RepaintNow + } else { + EventResult::RepaintNext + } + } else { + EventResult::Wait + } + } else { + EventResult::Wait + } + } + #[cfg(feature = "accesskit")] + winit::event::Event::UserEvent(UserEvent::AccessKitActionRequest( + accesskit_winit::ActionRequestEvent { request, .. }, + )) => { + if let Some(running) = &mut self.running { + running + .integration + .on_accesskit_action_request(request.clone()); + // As a form of user input, accessibility actions should + // lead to a repaint. + EventResult::RepaintNext + } else { + EventResult::Wait + } + } + _ => EventResult::Wait, + }) + } + } + + pub fn run_wgpu( + app_name: &str, + mut native_options: epi::NativeOptions, + app_creator: epi::AppCreator, + ) -> Result<()> { + #[cfg(not(target_os = "ios"))] + if native_options.run_and_return { + with_event_loop(native_options, |event_loop, native_options| { + let wgpu_eframe = + WgpuWinitApp::new(event_loop, app_name, native_options, app_creator); + run_and_return(event_loop, wgpu_eframe) + }) + } else { + let event_loop = create_event_loop(&mut native_options); + let wgpu_eframe = WgpuWinitApp::new(&event_loop, app_name, native_options, app_creator); + run_and_exit(event_loop, wgpu_eframe); + } + + #[cfg(target_os = "ios")] + { + let event_loop = create_event_loop(&mut native_options); + let wgpu_eframe = WgpuWinitApp::new(&event_loop, app_name, native_options, app_creator); + run_and_exit(event_loop, wgpu_eframe); + } + } +} + +#[cfg(feature = "wgpu")] +pub use wgpu_integration::run_wgpu; + +// ---------------------------------------------------------------------------- + +fn system_theme(window: &winit::window::Window, options: &NativeOptions) -> Option { + if options.follow_system_theme { + window + .theme() + .map(super::epi_integration::theme_from_winit_theme) + } else { + None + } +} + +// ---------------------------------------------------------------------------- + +fn extremely_far_future() -> std::time::Instant { + std::time::Instant::now() + std::time::Duration::from_secs(10_000_000_000) +} + +// For the puffin profiler! +#[allow(dead_code)] // Only used for profiling +fn short_event_description(event: &winit::event::Event<'_, UserEvent>) -> &'static str { + use winit::event::{DeviceEvent, Event, StartCause, WindowEvent}; + + match event { + Event::Suspended => "Event::Suspended", + Event::Resumed => "Event::Resumed", + Event::MainEventsCleared => "Event::MainEventsCleared", + Event::RedrawRequested(_) => "Event::RedrawRequested", + Event::RedrawEventsCleared => "Event::RedrawEventsCleared", + Event::LoopDestroyed => "Event::LoopDestroyed", + Event::UserEvent(user_event) => match user_event { + UserEvent::RequestRepaint { .. } => "UserEvent::RequestRepaint", + #[cfg(feature = "accesskit")] + UserEvent::AccessKitActionRequest(_) => "UserEvent::AccessKitActionRequest", + }, + Event::DeviceEvent { event, .. } => match event { + DeviceEvent::Added { .. } => "DeviceEvent::Added", + DeviceEvent::Removed { .. } => "DeviceEvent::Removed", + DeviceEvent::MouseMotion { .. } => "DeviceEvent::MouseMotion", + DeviceEvent::MouseWheel { .. } => "DeviceEvent::MouseWheel", + DeviceEvent::Motion { .. } => "DeviceEvent::Motion", + DeviceEvent::Button { .. } => "DeviceEvent::Button", + DeviceEvent::Key { .. } => "DeviceEvent::Key", + DeviceEvent::Text { .. } => "DeviceEvent::Text", + }, + Event::NewEvents(start_cause) => match start_cause { + StartCause::ResumeTimeReached { .. } => "NewEvents::ResumeTimeReached", + StartCause::WaitCancelled { .. } => "NewEvents::WaitCancelled", + StartCause::Poll => "NewEvents::Poll", + StartCause::Init => "NewEvents::Init", + }, + Event::WindowEvent { event, .. } => match event { + WindowEvent::Resized { .. } => "WindowEvent::Resized", + WindowEvent::Moved { .. } => "WindowEvent::Moved", + WindowEvent::CloseRequested { .. } => "WindowEvent::CloseRequested", + WindowEvent::Destroyed { .. } => "WindowEvent::Destroyed", + WindowEvent::DroppedFile { .. } => "WindowEvent::DroppedFile", + WindowEvent::HoveredFile { .. } => "WindowEvent::HoveredFile", + WindowEvent::HoveredFileCancelled { .. } => "WindowEvent::HoveredFileCancelled", + WindowEvent::ReceivedCharacter { .. } => "WindowEvent::ReceivedCharacter", + WindowEvent::Focused { .. } => "WindowEvent::Focused", + WindowEvent::KeyboardInput { .. } => "WindowEvent::KeyboardInput", + WindowEvent::ModifiersChanged { .. } => "WindowEvent::ModifiersChanged", + WindowEvent::Ime { .. } => "WindowEvent::Ime", + WindowEvent::CursorMoved { .. } => "WindowEvent::CursorMoved", + WindowEvent::CursorEntered { .. } => "WindowEvent::CursorEntered", + WindowEvent::CursorLeft { .. } => "WindowEvent::CursorLeft", + WindowEvent::MouseWheel { .. } => "WindowEvent::MouseWheel", + WindowEvent::MouseInput { .. } => "WindowEvent::MouseInput", + WindowEvent::TouchpadMagnify { .. } => "WindowEvent::TouchpadMagnify", + WindowEvent::SmartMagnify { .. } => "WindowEvent::SmartMagnify", + WindowEvent::TouchpadRotate { .. } => "WindowEvent::TouchpadRotate", + WindowEvent::TouchpadPressure { .. } => "WindowEvent::TouchpadPressure", + WindowEvent::AxisMotion { .. } => "WindowEvent::AxisMotion", + WindowEvent::Touch { .. } => "WindowEvent::Touch", + WindowEvent::ScaleFactorChanged { .. } => "WindowEvent::ScaleFactorChanged", + WindowEvent::ThemeChanged { .. } => "WindowEvent::ThemeChanged", + WindowEvent::Occluded { .. } => "WindowEvent::Occluded", + }, + } +} diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs new file mode 100644 index 00000000..671c9d43 --- /dev/null +++ b/crates/eframe/src/web/app_runner.rs @@ -0,0 +1,332 @@ +use egui::TexturesDelta; +use wasm_bindgen::JsValue; + +use crate::{epi, App}; + +use super::{now_sec, web_painter::WebPainter, NeedRepaint}; + +pub struct AppRunner { + web_options: crate::WebOptions, + pub(crate) frame: epi::Frame, + egui_ctx: egui::Context, + pub(crate) painter: super::ActiveWebPainter, + pub(crate) input: super::WebInput, + app: Box, + pub(crate) needs_repaint: std::sync::Arc, + last_save_time: f64, + pub(crate) text_cursor_pos: Option, + pub(crate) mutable_text_under_cursor: bool, + textures_delta: TexturesDelta, + + pub(super) canvas: web_sys::OffscreenCanvas, + pub(super) worker_options: super::WorkerOptions, +} + +impl Drop for AppRunner { + fn drop(&mut self) { + log::debug!("AppRunner has fully dropped"); + } +} + +impl AppRunner { + /// # Errors + /// Failure to initialize WebGL renderer. + pub async fn new( + canvas: web_sys::OffscreenCanvas, + web_options: crate::WebOptions, + app_creator: epi::AppCreator, + worker_options: super::WorkerOptions, + ) -> Result { + let Some(worker) = luminol_web::bindings::worker() else { + panic!("cannot create a web runner outside of a web worker"); + }; + let location = worker.location(); + let user_agent = worker.navigator().user_agent().unwrap_or_default(); + + let painter = super::ActiveWebPainter::new(canvas.clone(), &web_options).await?; + + let system_theme = if web_options.follow_system_theme { + worker_options.prefers_color_scheme_dark.map(|x| { + if x { + crate::Theme::Dark + } else { + crate::Theme::Light + } + }) + } else { + None + }; + + let info = epi::IntegrationInfo { + web_info: epi::WebInfo { + user_agent: user_agent.clone(), + location: crate::Location { + url: location + .href() + .strip_suffix("/worker.js") + .unwrap_or(location.href().as_str()) + .to_string(), + protocol: location.protocol(), + host: location.host(), + hostname: location.hostname(), + port: location.port(), + hash: Default::default(), + query: Default::default(), + query_map: Default::default(), + origin: location.origin(), + }, + }, + system_theme, + cpu_usage: None, + native_pixels_per_point: Some(1.), + }; + let storage = LocalStorage { + channels: worker_options.channels.clone(), + }; + + let egui_ctx = egui::Context::default(); + egui_ctx.set_os(egui::os::OperatingSystem::from_user_agent(&user_agent)); + super::storage::load_memory(&egui_ctx, &worker_options.channels).await; + + let theme = system_theme.unwrap_or(web_options.default_theme); + egui_ctx.set_visuals(theme.egui_visuals()); + + let app = app_creator(&epi::CreationContext { + egui_ctx: egui_ctx.clone(), + integration_info: info.clone(), + storage: Some(&storage), + + #[cfg(feature = "glow")] + gl: Some(painter.gl().clone()), + + #[cfg(all(feature = "wgpu", not(feature = "glow")))] + wgpu_render_state: painter.render_state(), + #[cfg(all(feature = "wgpu", feature = "glow"))] + wgpu_render_state: None, + }); + + let frame = epi::Frame { + info, + output: Default::default(), + storage: Some(Box::new(storage)), + + #[cfg(feature = "glow")] + gl: Some(painter.gl().clone()), + + #[cfg(all(feature = "wgpu", not(feature = "glow")))] + wgpu_render_state: painter.render_state(), + #[cfg(all(feature = "wgpu", feature = "glow"))] + wgpu_render_state: None, + }; + + let needs_repaint: std::sync::Arc = Default::default(); + { + let needs_repaint = needs_repaint.clone(); + egui_ctx.set_request_repaint_callback(move |info| { + needs_repaint.repaint_after(info.after.as_secs_f64()); + }); + } + + let mut runner = Self { + web_options, + frame, + egui_ctx, + painter, + input: Default::default(), + app, + needs_repaint, + last_save_time: now_sec(), + text_cursor_pos: None, + mutable_text_under_cursor: false, + textures_delta: Default::default(), + + worker_options, + canvas, + }; + + runner.input.raw.max_texture_side = Some(runner.painter.max_texture_side()); + + Ok(runner) + } + + pub fn egui_ctx(&self) -> &egui::Context { + &self.egui_ctx + } + + /// Get mutable access to the concrete [`App`] we enclose. + /// + /// This will panic if your app does not implement [`App::as_any_mut`]. + pub fn app_mut(&mut self) -> &mut ConcreteApp { + self.app + .as_any_mut() + .expect("Your app must implement `as_any_mut`, but it doesn't") + .downcast_mut::() + .expect("app_mut got the wrong type of App") + } + + pub fn auto_save_if_needed(&mut self) { + let time_since_last_save = now_sec() - self.last_save_time; + if time_since_last_save > self.app.auto_save_interval().as_secs_f64() { + self.save(); + } + } + + pub fn save(&mut self) { + if self.app.persist_egui_memory() { + super::storage::save_memory(&self.egui_ctx, &self.worker_options.channels); + } + if let Some(storage) = self.frame.storage_mut() { + self.app.save(storage); + } + self.last_save_time = now_sec(); + } + + pub fn warm_up(&mut self) { + if self.app.warm_up_enabled() { + let saved_memory: egui::Memory = self.egui_ctx.memory(|m| m.clone()); + self.egui_ctx + .memory_mut(|m| m.set_everything_is_visible(true)); + self.logic(); + self.egui_ctx.memory_mut(|m| *m = saved_memory); // We don't want to remember that windows were huge. + self.egui_ctx.clear_animations(); + } + } + + pub fn destroy(mut self) { + log::debug!("Destroying AppRunner"); + self.painter.destroy(); + } + + /// Returns how long to wait until the next repaint. + /// + /// Call [`Self::paint`] later to paint + pub fn logic(&mut self) -> (std::time::Duration, Vec) { + let frame_start = now_sec(); + + let raw_input = self.input.new_frame( + egui::vec2(self.painter.width as f32, self.painter.height as f32), + self.painter.pixel_ratio, + ); + + let full_output = self.egui_ctx.run(raw_input, |egui_ctx| { + self.app.update(egui_ctx, &mut self.frame); + }); + let egui::FullOutput { + platform_output, + repaint_after, + textures_delta, + shapes, + } = full_output; + + self.mutable_text_under_cursor = platform_output.mutable_text_under_cursor; + self.worker_options + .channels + .send(super::WebRunnerOutput::PlatformOutput( + platform_output, + self.egui_ctx.options(|o| o.screen_reader), + self.egui_ctx.wants_keyboard_input(), + )); + self.textures_delta.append(textures_delta); + let clipped_primitives = self.egui_ctx.tessellate(shapes); + + { + let app_output = self.frame.take_app_output(); + let epi::backend::AppOutput {} = app_output; + } + + self.frame.info.cpu_usage = Some((now_sec() - frame_start) as f32); + + (repaint_after, clipped_primitives) + } + + /// Paint the results of the last call to [`Self::logic`]. + pub fn paint(&mut self, clipped_primitives: &[egui::ClippedPrimitive]) -> Result<(), JsValue> { + let textures_delta = std::mem::take(&mut self.textures_delta); + + self.painter.paint_and_update_textures( + self.app.clear_color(&self.egui_ctx.style().visuals), + clipped_primitives, + self.egui_ctx.pixels_per_point(), + &textures_delta, + )?; + + Ok(()) + } + + pub(super) fn handle_platform_output( + state: &super::MainState, + platform_output: egui::PlatformOutput, + screen_reader_enabled: bool, + wants_keyboard_input: bool, + ) { + if screen_reader_enabled { + if let Some(screen_reader) = &mut state.inner.borrow_mut().screen_reader { + screen_reader.speak(&platform_output.events_description()); + } + } + + let egui::PlatformOutput { + cursor_icon, + open_url, + copied_text, + events: _, // already handled + mutable_text_under_cursor, + text_cursor_pos, + #[cfg(feature = "accesskit")] + accesskit_update: _, // not currently implemented + } = platform_output; + + super::set_cursor_icon(cursor_icon); + if let Some(open) = open_url { + super::open_url(&open.url, open.new_tab); + } + + #[cfg(web_sys_unstable_apis)] + if !copied_text.is_empty() { + super::set_clipboard_text(&copied_text); + } + + #[cfg(not(web_sys_unstable_apis))] + let _ = copied_text; + + { + let mut inner = state.inner.borrow_mut(); + inner.mutable_text_under_cursor = mutable_text_under_cursor; + inner.wants_keyboard_input = wants_keyboard_input; + + if inner.text_cursor_pos != text_cursor_pos { + super::text_agent::move_text_cursor(text_cursor_pos, &state.canvas); + inner.text_cursor_pos = text_cursor_pos; + } + } + } +} + +// ---------------------------------------------------------------------------- + +struct LocalStorage { + channels: super::WorkerChannels, +} + +impl epi::Storage for LocalStorage { + fn get_string(&self, key: &str) -> Option { + let (oneshot_tx, oneshot_rx) = oneshot::channel(); + self.channels.send(super::WebRunnerOutput::StorageGet( + key.to_string(), + oneshot_tx, + )); + oneshot_rx.recv().ok().flatten() + } + + fn set_string(&mut self, key: &str, value: String) { + let (oneshot_tx, oneshot_rx) = oneshot::channel(); + self.channels.send(super::WebRunnerOutput::StorageSet( + key.to_string(), + value, + oneshot_tx, + )); + let _ = oneshot_rx.recv(); + } + + fn flush(&mut self) {} +} diff --git a/crates/eframe/src/web/backend.rs b/crates/eframe/src/web/backend.rs new file mode 100644 index 00000000..8036dc6f --- /dev/null +++ b/crates/eframe/src/web/backend.rs @@ -0,0 +1,153 @@ +use std::collections::BTreeMap; + +use egui::mutex::Mutex; + +use crate::epi; + +use super::percent_decode; + +// ---------------------------------------------------------------------------- + +/// Data gathered between frames. +#[derive(Default)] +pub(crate) struct WebInput { + /// Required because we don't get a position on touched + pub latest_touch_pos: Option, + + /// Required to maintain a stable touch position for multi-touch gestures. + pub latest_touch_pos_id: Option, + + /// The raw input to `egui`. + pub raw: egui::RawInput, +} + +impl WebInput { + pub fn new_frame(&mut self, canvas_size: egui::Vec2, pixels_per_point: f32) -> egui::RawInput { + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Default::default(), canvas_size)), + pixels_per_point: Some(pixels_per_point), + time: Some(super::now_sec()), + ..self.raw.take() + } + } +} + +// ---------------------------------------------------------------------------- + +// ensure that AtomicF64 is using atomic ops (otherwise it would use global locks, and that would be bad) +const _: [(); 0 - !{ + const ASSERT: bool = portable_atomic::AtomicF64::is_always_lock_free(); + ASSERT +} as usize] = []; + +/// Stores when to do the next repaint. +pub(crate) struct NeedRepaint(portable_atomic::AtomicF64); + +impl Default for NeedRepaint { + fn default() -> Self { + Self(portable_atomic::AtomicF64::new(f64::NEG_INFINITY)) // start with a repaint + } +} + +impl NeedRepaint { + /// Returns the time (in [`now_sec`] scale) when + /// we should next repaint. + pub fn when_to_repaint(&self) -> f64 { + self.0.load(portable_atomic::Ordering::Relaxed) + } + + /// Unschedule repainting. + pub fn clear(&self) { + self.0 + .store(f64::INFINITY, portable_atomic::Ordering::Relaxed); + } + + pub fn repaint_after(&self, num_seconds: f64) { + self.0.fetch_min( + super::now_sec() + num_seconds, + portable_atomic::Ordering::Relaxed, + ); + } + + pub fn repaint_asap(&self) { + self.0 + .store(f64::NEG_INFINITY, portable_atomic::Ordering::Relaxed); + } +} + +// ---------------------------------------------------------------------------- + +/// The User-Agent of the user's browser. +pub fn user_agent() -> Option { + web_sys::window()?.navigator().user_agent().ok() +} + +/// Get the [`epi::Location`] from the browser. +pub fn web_location() -> epi::Location { + let location = web_sys::window().unwrap().location(); + + let hash = percent_decode(&location.hash().unwrap_or_default()); + + let query = location + .search() + .unwrap_or_default() + .strip_prefix('?') + .map(percent_decode) + .unwrap_or_default(); + + let query_map = parse_query_map(&query) + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + + epi::Location { + url: percent_decode(&location.href().unwrap_or_default()), + protocol: percent_decode(&location.protocol().unwrap_or_default()), + host: percent_decode(&location.host().unwrap_or_default()), + hostname: percent_decode(&location.hostname().unwrap_or_default()), + port: percent_decode(&location.port().unwrap_or_default()), + hash, + query, + query_map, + origin: percent_decode(&location.origin().unwrap_or_default()), + } +} + +fn parse_query_map(query: &str) -> BTreeMap<&str, &str> { + query + .split('&') + .filter_map(|pair| { + if pair.is_empty() { + None + } else { + Some(if let Some((key, value)) = pair.split_once('=') { + (key, value) + } else { + (pair, "") + }) + } + }) + .collect() +} + +#[test] +fn test_parse_query() { + assert_eq!(parse_query_map(""), BTreeMap::default()); + assert_eq!(parse_query_map("foo"), BTreeMap::from_iter([("foo", "")])); + assert_eq!( + parse_query_map("foo=bar"), + BTreeMap::from_iter([("foo", "bar")]) + ); + assert_eq!( + parse_query_map("foo=bar&baz=42"), + BTreeMap::from_iter([("foo", "bar"), ("baz", "42")]) + ); + assert_eq!( + parse_query_map("foo&baz=42"), + BTreeMap::from_iter([("foo", ""), ("baz", "42")]) + ); + assert_eq!( + parse_query_map("foo&baz&&"), + BTreeMap::from_iter([("foo", ""), ("baz", "")]) + ); +} diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs new file mode 100644 index 00000000..3df25f0a --- /dev/null +++ b/crates/eframe/src/web/events.rs @@ -0,0 +1,726 @@ +use super::*; + +// ------------------------------------------------------------------------ + +/// Calls `request_animation_frame` to schedule repaint. +/// +/// It will only paint if needed, but will always call `request_animation_frame` immediately. +fn paint_and_schedule(runner_ref: &WebRunner) -> Result<(), JsValue> { + // Only paint and schedule if there has been no panic + if let Some(mut runner_lock) = runner_ref.try_lock() { + let mut width = runner_lock.painter.width; + let mut height = runner_lock.painter.height; + let mut pixel_ratio = runner_lock.painter.pixel_ratio; + let mut modifiers = runner_lock.input.raw.modifiers; + let mut should_save = false; + let mut touch = None; + + for event in runner_lock + .worker_options + .channels + .custom_event_rx + .try_iter() + { + match event { + WebRunnerCustomEvent::ScreenResize(new_width, new_height, new_pixel_ratio) => { + width = new_width; + height = new_height; + pixel_ratio = new_pixel_ratio; + } + + WebRunnerCustomEvent::Modifiers(new_modifiers) => { + modifiers = new_modifiers; + } + + WebRunnerCustomEvent::Save => { + should_save = true; + } + + WebRunnerCustomEvent::Touch(touch_id, touch_pos) => { + touch = Some((touch_id, touch_pos)); + } + } + } + + // If a touch event has been detected, put it into the input and trigger a rerender + if let Some((touch_id, touch_pos)) = touch { + runner_lock.input.latest_touch_pos_id = touch_id; + runner_lock.input.latest_touch_pos = Some(touch_pos); + runner_lock.needs_repaint.repaint_asap(); + } + + // If the modifiers have changed, trigger a rerender + if runner_lock.input.raw.modifiers != modifiers { + runner_lock.input.raw.modifiers = modifiers; + runner_lock.needs_repaint.repaint_asap(); + } + + runner_lock.input.raw.events = runner_lock + .worker_options + .channels + .event_rx + .try_iter() + .collect(); + if !runner_lock.input.raw.events.is_empty() { + // Render immediately if there are any pending events + runner_lock.needs_repaint.repaint_asap(); + } + + // Save and rerender immediately if saving was requested + if should_save { + runner_lock.save(); + runner_lock.needs_repaint.repaint_asap(); + } + + // Resize the canvas if the screen size has changed + if runner_lock.painter.width != width + || runner_lock.painter.height != height + || runner_lock.painter.pixel_ratio != pixel_ratio + { + // Make sure that the height and width are always even numbers. + // otherwise, the page renders blurry on some platforms. + // See https://github.com/emilk/egui/issues/103 + fn round_to_even(v: f32) -> f32 { + (v / 2.0).round() * 2.0 + } + runner_lock.painter.pixel_ratio = pixel_ratio; + runner_lock.painter.width = width; + runner_lock.painter.height = height; + runner_lock.painter.surface_configuration.width = + round_to_even(width as f32 * pixel_ratio) as u32; + runner_lock.painter.surface_configuration.height = + round_to_even(height as f32 * pixel_ratio) as u32; + runner_lock + .canvas + .set_width(runner_lock.painter.surface_configuration.width); + runner_lock + .canvas + .set_height(runner_lock.painter.surface_configuration.height); + runner_lock.painter.needs_resize = true; + // Also trigger a rerender immediately + runner_lock.needs_repaint.repaint_asap(); + } + + paint_if_needed(&mut runner_lock)?; + drop(runner_lock); + request_animation_frame(runner_ref.clone())?; + } + + Ok(()) +} + +fn paint_if_needed(runner: &mut AppRunner) -> Result<(), JsValue> { + if runner.needs_repaint.when_to_repaint() <= now_sec() { + runner.needs_repaint.clear(); + let (repaint_after, clipped_primitives) = runner.logic(); + runner.paint(&clipped_primitives)?; + runner + .needs_repaint + .repaint_after(repaint_after.as_secs_f64()); + runner.auto_save_if_needed(); + } + Ok(()) +} + +pub(crate) fn request_animation_frame(runner_ref: WebRunner) -> Result<(), JsValue> { + let worker = luminol_web::bindings::worker().unwrap(); + let closure = Closure::once(move || paint_and_schedule(&runner_ref)); + worker.request_animation_frame(closure.as_ref().unchecked_ref())?; + closure.forget(); // We must forget it, or else the callback is canceled on drop + Ok(()) +} + +// ------------------------------------------------------------------------ + +pub(crate) fn install_document_events(state: &MainState) -> Result<(), JsValue> { + let document = web_sys::window().unwrap().document().unwrap(); + + { + // Avoid sticky modifier keys on alt-tab: + for event_name in ["blur", "focus"] { + let closure = move |event: web_sys::MouseEvent, state: &MainState| { + let has_focus = event_name == "focus"; + + if !has_focus { + // We lost focus - good idea to save + state.channels.send_custom(WebRunnerCustomEvent::Save); + } + + //runner.input.on_web_page_focus_change(has_focus); + //runner.egui_ctx().request_repaint(); + // log::debug!("{event_name:?}"); + + state.channels.send_custom(WebRunnerCustomEvent::Modifiers( + modifiers_from_mouse_event(&event), + )); + }; + + state.add_event_listener(&document, event_name, closure)?; + } + } + + state.add_event_listener( + &document, + "keydown", + |event: web_sys::KeyboardEvent, state| { + if event.is_composing() || event.key_code() == 229 { + // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + return; + } + + let modifiers = modifiers_from_event(&event); + state + .channels + .send_custom(WebRunnerCustomEvent::Modifiers(modifiers)); + + let key = event.key(); + let egui_key = translate_key(&key); + + if let Some(key) = egui_key { + state.channels.send(egui::Event::Key { + key, + pressed: true, + repeat: false, // egui will fill this in for us! + modifiers, + }); + } + if !modifiers.ctrl + && !modifiers.command + && !should_ignore_key(&key) + // When text agent is shown, it sends text event instead. + && text_agent::text_agent().hidden() + { + state.channels.send(egui::Event::Text(key)); + } + //runner.needs_repaint.repaint_asap(); + + let egui_wants_keyboard = state.inner.borrow().wants_keyboard_input; + + #[allow(clippy::if_same_then_else)] + let prevent_default = if egui_key == Some(egui::Key::Tab) { + // Always prevent moving cursor to url bar. + // egui wants to use tab to move to the next text field. + true + } else if matches!( + egui_key, + Some(egui::Key::P | egui::Key::S | egui::Key::O | egui::Key::F) + ) { + #[allow(clippy::needless_bool)] + if modifiers.ctrl || modifiers.command || modifiers.mac_cmd { + true // Prevent ctrl-P opening the print dialog. Users may want to use it for a command palette. + } else { + false // let normal P:s through + } + } else if egui_wants_keyboard { + matches!( + event.key().as_str(), + "Backspace" // so we don't go back to previous page when deleting text + | "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp" // cmd-left is "back" on Mac (https://github.com/emilk/egui/issues/58) + ) + } else { + // We never want to prevent: + // * F5 / cmd-R (refresh) + // * cmd-shift-C (debug tools) + // * cmd/ctrl-c/v/x (or we stop copy/past/cut events) + false + }; + + // log::debug!( + // "On key-down {:?}, egui_wants_keyboard: {}, prevent_default: {}", + // event.key().as_str(), + // egui_wants_keyboard, + // prevent_default + // ); + + if prevent_default { + event.prevent_default(); + // event.stop_propagation(); + } + }, + )?; + + state.add_event_listener( + &document, + "keyup", + |event: web_sys::KeyboardEvent, state| { + let modifiers = modifiers_from_event(&event); + state + .channels + .send_custom(WebRunnerCustomEvent::Modifiers(modifiers)); + if let Some(key) = translate_key(&event.key()) { + state.channels.send(egui::Event::Key { + key, + pressed: false, + repeat: false, + modifiers, + }); + } + //runner.needs_repaint.repaint_asap(); + }, + )?; + + #[cfg(web_sys_unstable_apis)] + state.add_event_listener( + &document, + "paste", + |event: web_sys::ClipboardEvent, state| { + if let Some(data) = event.clipboard_data() { + if let Ok(text) = data.get_data("text") { + let text = text.replace("\r\n", "\n"); + if !text.is_empty() { + state.channels.send(egui::Event::Paste(text)); + //runner.needs_repaint.repaint_asap(); + } + event.stop_propagation(); + event.prevent_default(); + } + } + }, + )?; + + #[cfg(web_sys_unstable_apis)] + state.add_event_listener(&document, "cut", |_: web_sys::ClipboardEvent, state| { + state.channels.send(egui::Event::Cut); + //runner.needs_repaint.repaint_asap(); + })?; + + #[cfg(web_sys_unstable_apis)] + state.add_event_listener(&document, "copy", |_: web_sys::ClipboardEvent, state| { + state.channels.send(egui::Event::Copy); + //runner.needs_repaint.repaint_asap(); + })?; + + Ok(()) +} + +pub(crate) fn install_window_events(state: &MainState) -> Result<(), JsValue> { + let window = web_sys::window().unwrap(); + + /* + + // Save-on-close + runner_ref.add_event_listener(&window, "onbeforeunload", |_: web_sys::Event, runner| { + runner.save(); + })?; + + for event_name in &["load", "pagehide", "pageshow", "resize"] { + runner_ref.add_event_listener(&window, event_name, |_: web_sys::Event, runner| { + runner.needs_repaint.repaint_asap(); + })?; + } + + runner_ref.add_event_listener(&window, "hashchange", |_: web_sys::Event, runner| { + // `epi::Frame::info(&self)` clones `epi::IntegrationInfo`, but we need to modify the original here + runner.frame.info.web_info.location.hash = location_hash(); + })?; + + */ + + let closure = { + let window = window.clone(); + move |_event: web_sys::Event, state: &MainState| { + let pixel_ratio = window.device_pixel_ratio(); + let pixel_ratio = if pixel_ratio > 0. && pixel_ratio.is_finite() { + pixel_ratio as f32 + } else { + 1. + }; + let width = window.inner_width().unwrap().as_f64().unwrap() as u32; + let height = window.inner_height().unwrap().as_f64().unwrap() as u32; + let _ = state + .canvas + .set_attribute("width", width.to_string().as_str()); + let _ = state + .canvas + .set_attribute("height", height.to_string().as_str()); + state + .channels + .send_custom(WebRunnerCustomEvent::ScreenResize( + width, + height, + pixel_ratio, + )); + } + }; + closure(web_sys::Event::new("")?, state); + state.add_event_listener(&window, "resize", closure)?; + + Ok(()) +} + +pub(crate) fn install_color_scheme_change_event(runner_ref: &WebRunner) -> Result<(), JsValue> { + let window = web_sys::window().unwrap(); + + if let Some(media_query_list) = prefers_color_scheme_dark(&window)? { + runner_ref.add_event_listener::( + &media_query_list, + "change", + |event, runner| { + let theme = theme_from_dark_mode(event.matches()); + runner.frame.info.system_theme = Some(theme); + runner.egui_ctx().set_visuals(theme.egui_visuals()); + runner.needs_repaint.repaint_asap(); + }, + )?; + } + + Ok(()) +} + +pub(crate) fn install_canvas_events(state: &MainState) -> Result<(), JsValue> { + let window = web_sys::window().unwrap(); + + { + let prevent_default_events = [ + // By default, right-clicks open a context menu. + // We don't want to do that (right clicks is handled by egui): + "contextmenu", + // Allow users to use ctrl-p for e.g. a command palette: + "afterprint", + ]; + + for event_name in prevent_default_events { + let closure = move |event: web_sys::MouseEvent, _state: &_| { + event.prevent_default(); + // event.stop_propagation(); + // log::debug!("Preventing event {event_name:?}"); + }; + + state.add_event_listener(&state.canvas, event_name, closure)?; + } + } + + state.add_event_listener( + &state.canvas, + "mousedown", + |event: web_sys::MouseEvent, state| { + if let Some(button) = button_from_mouse_event(&event) { + let pos = pos_from_mouse_event(&state.canvas, &event); + let modifiers = modifiers_from_mouse_event(&event); + state.channels.send(egui::Event::PointerButton { + pos, + button, + pressed: true, + modifiers, + }); + + // In Safari we are only allowed to write to the clipboard during the + // event callback, which is why we run the app logic here and now: + //runner.logic(); + + // Make sure we paint the output of the above logic call asap: + //runner.needs_repaint.repaint_asap(); + } + event.stop_propagation(); + // Note: prevent_default breaks VSCode tab focusing, hence why we don't call it here. + }, + )?; + + state.add_event_listener( + &state.canvas, + "mousemove", + |event: web_sys::MouseEvent, state| { + let pos = pos_from_mouse_event(&state.canvas, &event); + state.channels.send(egui::Event::PointerMoved(pos)); + //runner.needs_repaint.repaint_asap(); + event.stop_propagation(); + event.prevent_default(); + }, + )?; + + state.add_event_listener( + &state.canvas, + "mouseup", + |event: web_sys::MouseEvent, state| { + if let Some(button) = button_from_mouse_event(&event) { + let pos = pos_from_mouse_event(&state.canvas, &event); + state.channels.send(egui::Event::PointerButton { + pos, + button, + pressed: false, + modifiers: modifiers_from_mouse_event(&event), + }); + + // In Safari we are only allowed to write to the clipboard during the + // event callback, which is why we run the app logic here and now: + //runner.logic(); + + // Make sure we paint the output of the above logic call asap: + //runner.needs_repaint.repaint_asap(); + + text_agent::update_text_agent(state); + } + event.stop_propagation(); + event.prevent_default(); + }, + )?; + + state.add_event_listener( + &state.canvas, + "mouseleave", + |event: web_sys::MouseEvent, state| { + state.channels.send_custom(WebRunnerCustomEvent::Save); + + state.channels.send(egui::Event::PointerGone); + //runner.needs_repaint.repaint_asap(); + event.stop_propagation(); + event.prevent_default(); + }, + )?; + + state.add_event_listener( + &state.canvas, + "touchstart", + |event: web_sys::TouchEvent, state| { + let mut inner = state.inner.borrow_mut(); + + inner.touch_pos = pos_from_touch_event(&state.canvas, &event, &mut inner.touch_id); + state + .channels + .send_custom(WebRunnerCustomEvent::Touch(inner.touch_id, inner.touch_pos)); + let modifiers = modifiers_from_touch_event(&event); + state.channels.send(egui::Event::PointerButton { + pos: inner.touch_pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers, + }); + + push_touches(state, egui::TouchPhase::Start, &event); + //runner.needs_repaint.repaint_asap(); + event.stop_propagation(); + event.prevent_default(); + }, + )?; + + state.add_event_listener( + &state.canvas, + "touchmove", + |event: web_sys::TouchEvent, state| { + let mut inner = state.inner.borrow_mut(); + + inner.touch_pos = pos_from_touch_event(&state.canvas, &event, &mut inner.touch_id); + state + .channels + .send_custom(WebRunnerCustomEvent::Touch(inner.touch_id, inner.touch_pos)); + state + .channels + .send(egui::Event::PointerMoved(inner.touch_pos)); + + push_touches(state, egui::TouchPhase::Move, &event); + //runner.needs_repaint.repaint_asap(); + event.stop_propagation(); + event.prevent_default(); + }, + )?; + + state.add_event_listener( + &state.canvas, + "touchend", + |event: web_sys::TouchEvent, state| { + let inner = state.inner.borrow(); + + if inner.touch_id.is_some() { + let modifiers = modifiers_from_touch_event(&event); + // First release mouse to click: + state.channels.send(egui::Event::PointerButton { + pos: inner.touch_pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers, + }); + // Then remove hover effect: + state.channels.send(egui::Event::PointerGone); + + push_touches(state, egui::TouchPhase::End, &event); + //runner.needs_repaint.repaint_asap(); + } + event.stop_propagation(); + event.prevent_default(); + + // Finally, focus or blur text agent to toggle mobile keyboard: + text_agent::update_text_agent(state); + }, + )?; + + state.add_event_listener( + &state.canvas, + "touchcancel", + |event: web_sys::TouchEvent, state| { + push_touches(state, egui::TouchPhase::Cancel, &event); + event.stop_propagation(); + event.prevent_default(); + }, + )?; + + state.add_event_listener( + &state.canvas, + "wheel", + |event: web_sys::WheelEvent, state| { + let unit = match event.delta_mode() { + web_sys::WheelEvent::DOM_DELTA_PIXEL => egui::MouseWheelUnit::Point, + web_sys::WheelEvent::DOM_DELTA_LINE => egui::MouseWheelUnit::Line, + web_sys::WheelEvent::DOM_DELTA_PAGE => egui::MouseWheelUnit::Page, + _ => return, + }; + // delta sign is flipped to match native (winit) convention. + let delta = -egui::vec2(event.delta_x() as f32, event.delta_y() as f32); + let modifiers = modifiers_from_wheel_event(&event); + + state.channels.send(egui::Event::MouseWheel { + unit, + delta, + modifiers, + }); + + let scroll_multiplier = match unit { + egui::MouseWheelUnit::Page => canvas_size_in_points(&state.canvas).y, + egui::MouseWheelUnit::Line => { + #[allow(clippy::let_and_return)] + let points_per_scroll_line = 8.0; // Note that this is intentionally different from what we use in winit. + points_per_scroll_line + } + egui::MouseWheelUnit::Point => 1.0, + }; + + let mut delta = scroll_multiplier * delta; + + // Report a zoom event in case CTRL (on Windows or Linux) or CMD (on Mac) is pressed. + // This if-statement is equivalent to how `Modifiers.command` is determined in + // `modifiers_from_event()`, but we cannot directly use that fn for a [`WheelEvent`]. + if event.ctrl_key() || event.meta_key() { + let factor = (delta.y / 200.0).exp(); + state.channels.send(egui::Event::Zoom(factor)); + } else { + if event.shift_key() { + // Treat as horizontal scrolling. + // Note: one Mac we already get horizontal scroll events when shift is down. + delta = egui::vec2(delta.x + delta.y, 0.0); + } + + state.channels.send(egui::Event::Scroll(delta)); + } + + //runner.needs_repaint.repaint_asap(); + event.stop_propagation(); + event.prevent_default(); + }, + )?; + + /* Luminol's web filesystem can't read files from egui's file drag and drop system + + runner_ref.add_event_listener(&canvas, "dragover", |event: web_sys::DragEvent, runner| { + if let Some(data_transfer) = event.data_transfer() { + runner.input.raw.hovered_files.clear(); + for i in 0..data_transfer.items().length() { + if let Some(item) = data_transfer.items().get(i) { + runner.input.raw.hovered_files.push(egui::HoveredFile { + mime: item.type_(), + ..Default::default() + }); + } + } + runner.needs_repaint.repaint_asap(); + event.stop_propagation(); + event.prevent_default(); + } + })?; + + runner_ref.add_event_listener(&canvas, "dragleave", |event: web_sys::DragEvent, runner| { + runner.input.raw.hovered_files.clear(); + runner.needs_repaint.repaint_asap(); + event.stop_propagation(); + event.prevent_default(); + })?; + + runner_ref.add_event_listener(&canvas, "drop", { + let runner_ref = runner_ref.clone(); + + move |event: web_sys::DragEvent, runner| { + if let Some(data_transfer) = event.data_transfer() { + runner.input.raw.hovered_files.clear(); + runner.needs_repaint.repaint_asap(); + + if let Some(files) = data_transfer.files() { + for i in 0..files.length() { + if let Some(file) = files.get(i) { + let name = file.name(); + let mime = file.type_(); + let last_modified = std::time::UNIX_EPOCH + + std::time::Duration::from_millis(file.last_modified() as u64); + + log::debug!("Loading {:?} ({} bytes)…", name, file.size()); + + let future = wasm_bindgen_futures::JsFuture::from(file.array_buffer()); + + let runner_ref = runner_ref.clone(); + let future = async move { + match future.await { + Ok(array_buffer) => { + let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec(); + log::debug!("Loaded {:?} ({} bytes).", name, bytes.len()); + + if let Some(mut runner_lock) = runner_ref.try_lock() { + runner_lock.input.raw.dropped_files.push( + egui::DroppedFile { + name, + mime, + last_modified: Some(last_modified), + bytes: Some(bytes.into()), + ..Default::default() + }, + ); + runner_lock.needs_repaint.repaint_asap(); + } + } + Err(err) => { + log::error!("Failed to read file: {:?}", err); + } + } + }; + wasm_bindgen_futures::spawn_local(future); + } + } + } + event.stop_propagation(); + event.prevent_default(); + } + } + })?; + + */ + + { + // The canvas automatically resizes itself whenever a frame is drawn. + // The resizing does not take window.devicePixelRatio into account, + // so this mutation observer is to detect canvas resizes and correct them. + let window = window.clone(); + let callback: Closure = Closure::new(move |mutations: js_sys::Array| { + if PANIC_LOCK.get().is_some() { + return; + } + let width = window.inner_width().unwrap().as_f64().unwrap() as u32; + let height = window.inner_height().unwrap().as_f64().unwrap() as u32; + mutations.for_each(&mut |mutation, _, _| { + let mutation = mutation.unchecked_into::(); + if mutation.type_().as_str() == "attributes" { + let canvas = mutation + .target() + .unwrap() + .unchecked_into::(); + if canvas.width() != width || canvas.height() != height { + let _ = canvas.set_attribute("width", width.to_string().as_str()); + let _ = canvas.set_attribute("height", height.to_string().as_str()); + } + } + }); + }); + let observer = web_sys::MutationObserver::new(callback.as_ref().unchecked_ref())?; + let mut options = web_sys::MutationObserverInit::new(); + options.attributes(true); + observer.observe_with_options(&state.canvas, &options)?; + callback.forget(); + } + + Ok(()) +} diff --git a/crates/eframe/src/web/input.rs b/crates/eframe/src/web/input.rs new file mode 100644 index 00000000..1d348064 --- /dev/null +++ b/crates/eframe/src/web/input.rs @@ -0,0 +1,240 @@ +use super::{canvas_element, canvas_origin, AppRunner}; + +pub fn pos_from_mouse_event( + canvas: &web_sys::HtmlCanvasElement, + event: &web_sys::MouseEvent, +) -> egui::Pos2 { + let rect = canvas.get_bounding_client_rect(); + egui::Pos2 { + x: event.client_x() as f32 - rect.left() as f32, + y: event.client_y() as f32 - rect.top() as f32, + } +} + +pub fn button_from_mouse_event(event: &web_sys::MouseEvent) -> Option { + match event.button() { + 0 => Some(egui::PointerButton::Primary), + 1 => Some(egui::PointerButton::Middle), + 2 => Some(egui::PointerButton::Secondary), + 3 => Some(egui::PointerButton::Extra1), + 4 => Some(egui::PointerButton::Extra2), + _ => None, + } +} + +/// A single touch is translated to a pointer movement. When a second touch is added, the pointer +/// should not jump to a different position. Therefore, we do not calculate the average position +/// of all touches, but we keep using the same touch as long as it is available. +/// +/// `touch_id_for_pos` is the [`TouchId`](egui::TouchId) of the [`Touch`](web_sys::Touch) we previously used to determine the +/// pointer position. +pub fn pos_from_touch_event( + canvas: &web_sys::HtmlCanvasElement, + event: &web_sys::TouchEvent, + touch_id_for_pos: &mut Option, +) -> egui::Pos2 { + let touch_for_pos = if let Some(touch_id_for_pos) = touch_id_for_pos { + // search for the touch we previously used for the position + // (unfortunately, `event.touches()` is not a rust collection): + (0..event.touches().length()) + .map(|i| event.touches().get(i).unwrap()) + .find(|touch| egui::TouchId::from(touch.identifier()) == *touch_id_for_pos) + } else { + None + }; + // Use the touch found above or pick the first, or return a default position if there is no + // touch at all. (The latter is not expected as the current method is only called when there is + // at least one touch.) + touch_for_pos + .or_else(|| event.touches().get(0)) + .map_or(Default::default(), |touch| { + *touch_id_for_pos = Some(egui::TouchId::from(touch.identifier())); + pos_from_touch(canvas_origin(canvas), &touch) + }) +} + +fn pos_from_touch(canvas_origin: egui::Pos2, touch: &web_sys::Touch) -> egui::Pos2 { + egui::Pos2 { + x: touch.page_x() as f32 - canvas_origin.x, + y: touch.page_y() as f32 - canvas_origin.y, + } +} + +pub fn push_touches( + state: &super::MainState, + phase: egui::TouchPhase, + event: &web_sys::TouchEvent, +) { + let canvas_origin = canvas_origin(&state.canvas); + for touch_idx in 0..event.changed_touches().length() { + if let Some(touch) = event.changed_touches().item(touch_idx) { + state.channels.send(egui::Event::Touch { + device_id: egui::TouchDeviceId(0), + id: egui::TouchId::from(touch.identifier()), + phase, + pos: pos_from_touch(canvas_origin, &touch), + force: Some(touch.force()), + }); + } + } +} + +/// Web sends all keys as strings, so it is up to us to figure out if it is +/// a real text input or the name of a key. +pub fn should_ignore_key(key: &str) -> bool { + let is_function_key = key.starts_with('F') && key.len() > 1; + is_function_key + || matches!( + key, + "Alt" + | "ArrowDown" + | "ArrowLeft" + | "ArrowRight" + | "ArrowUp" + | "Backspace" + | "CapsLock" + | "ContextMenu" + | "Control" + | "Delete" + | "End" + | "Enter" + | "Esc" + | "Escape" + | "GroupNext" // https://github.com/emilk/egui/issues/510 + | "Help" + | "Home" + | "Insert" + | "Meta" + | "NumLock" + | "PageDown" + | "PageUp" + | "Pause" + | "ScrollLock" + | "Shift" + | "Tab" + ) +} + +/// Web sends all all keys as strings, so it is up to us to figure out if it is +/// a real text input or the name of a key. +pub fn translate_key(key: &str) -> Option { + use egui::Key; + + match key { + "ArrowDown" => Some(Key::ArrowDown), + "ArrowLeft" => Some(Key::ArrowLeft), + "ArrowRight" => Some(Key::ArrowRight), + "ArrowUp" => Some(Key::ArrowUp), + + "Esc" | "Escape" => Some(Key::Escape), + "Tab" => Some(Key::Tab), + "Backspace" => Some(Key::Backspace), + "Enter" => Some(Key::Enter), + "Space" | " " => Some(Key::Space), + + "Help" | "Insert" => Some(Key::Insert), + "Delete" => Some(Key::Delete), + "Home" => Some(Key::Home), + "End" => Some(Key::End), + "PageUp" => Some(Key::PageUp), + "PageDown" => Some(Key::PageDown), + + "-" => Some(Key::Minus), + "+" | "=" => Some(Key::PlusEquals), + + "0" => Some(Key::Num0), + "1" => Some(Key::Num1), + "2" => Some(Key::Num2), + "3" => Some(Key::Num3), + "4" => Some(Key::Num4), + "5" => Some(Key::Num5), + "6" => Some(Key::Num6), + "7" => Some(Key::Num7), + "8" => Some(Key::Num8), + "9" => Some(Key::Num9), + + "a" | "A" => Some(Key::A), + "b" | "B" => Some(Key::B), + "c" | "C" => Some(Key::C), + "d" | "D" => Some(Key::D), + "e" | "E" => Some(Key::E), + "f" | "F" => Some(Key::F), + "g" | "G" => Some(Key::G), + "h" | "H" => Some(Key::H), + "i" | "I" => Some(Key::I), + "j" | "J" => Some(Key::J), + "k" | "K" => Some(Key::K), + "l" | "L" => Some(Key::L), + "m" | "M" => Some(Key::M), + "n" | "N" => Some(Key::N), + "o" | "O" => Some(Key::O), + "p" | "P" => Some(Key::P), + "q" | "Q" => Some(Key::Q), + "r" | "R" => Some(Key::R), + "s" | "S" => Some(Key::S), + "t" | "T" => Some(Key::T), + "u" | "U" => Some(Key::U), + "v" | "V" => Some(Key::V), + "w" | "W" => Some(Key::W), + "x" | "X" => Some(Key::X), + "y" | "Y" => Some(Key::Y), + "z" | "Z" => Some(Key::Z), + + "F1" => Some(Key::F1), + "F2" => Some(Key::F2), + "F3" => Some(Key::F3), + "F4" => Some(Key::F4), + "F5" => Some(Key::F5), + "F6" => Some(Key::F6), + "F7" => Some(Key::F7), + "F8" => Some(Key::F8), + "F9" => Some(Key::F9), + "F10" => Some(Key::F10), + "F11" => Some(Key::F11), + "F12" => Some(Key::F12), + "F13" => Some(Key::F13), + "F14" => Some(Key::F14), + "F15" => Some(Key::F15), + "F16" => Some(Key::F16), + "F17" => Some(Key::F17), + "F18" => Some(Key::F18), + "F19" => Some(Key::F19), + "F20" => Some(Key::F20), + + _ => None, + } +} + +macro_rules! modifiers { + ($event:ident) => { + egui::Modifiers { + alt: $event.alt_key(), + ctrl: $event.ctrl_key(), + shift: $event.shift_key(), + + // Ideally we should know if we are running or mac or not, + // but this works good enough for now. + mac_cmd: $event.meta_key(), + + // Ideally we should know if we are running or mac or not, + // but this works good enough for now. + command: $event.ctrl_key() || $event.meta_key(), + } + }; +} + +pub fn modifiers_from_event(event: &web_sys::KeyboardEvent) -> egui::Modifiers { + modifiers!(event) +} + +pub(super) fn modifiers_from_mouse_event(event: &web_sys::MouseEvent) -> egui::Modifiers { + modifiers!(event) +} + +pub(super) fn modifiers_from_wheel_event(event: &web_sys::WheelEvent) -> egui::Modifiers { + modifiers!(event) +} + +pub(super) fn modifiers_from_touch_event(event: &web_sys::TouchEvent) -> egui::Modifiers { + modifiers!(event) +} diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs new file mode 100644 index 00000000..fa35213b --- /dev/null +++ b/crates/eframe/src/web/mod.rs @@ -0,0 +1,432 @@ +//! [`egui`] bindings for web apps (compiling to WASM). + +#![allow(clippy::missing_errors_doc)] // So many `-> Result<_, JsValue>` + +mod app_runner; +mod backend; +mod events; +mod input; +mod panic_handler; +mod text_agent; +mod web_logger; +mod web_runner; + +/// Access to the browser screen reader. +pub mod screen_reader; + +/// Access to local browser storage. +pub mod storage; + +pub(crate) use app_runner::AppRunner; +pub use panic_handler::{PanicHandler, PanicSummary}; +pub use web_logger::WebLogger; +pub use web_runner::WebRunner; + +#[cfg(not(any(feature = "glow", feature = "wgpu")))] +compile_error!("You must enable either the 'glow' or 'wgpu' feature"); + +mod web_painter; + +#[cfg(feature = "glow")] +mod web_painter_glow; +#[cfg(feature = "glow")] +pub(crate) type ActiveWebPainter = web_painter_glow::WebPainterGlow; + +#[cfg(feature = "wgpu")] +mod web_painter_wgpu; +#[cfg(all(feature = "wgpu", not(feature = "glow")))] +pub(crate) type ActiveWebPainter = web_painter_wgpu::WebPainterWgpu; + +pub use backend::*; + +use egui::Vec2; +use wasm_bindgen::prelude::*; +use web_sys::MediaQueryList; + +use input::*; + +use crate::Theme; + +// ---------------------------------------------------------------------------- + +/// Current time in seconds (since undefined point in time). +/// +/// Monotonically increasing. +pub fn now_sec() -> f64 { + luminol_web::bindings::performance( + &luminol_web::bindings::worker().expect("should have a DedicatedWorkerGlobalScope"), + ) + .now() + / 1000.0 +} + +/// The native GUI scale factor, taking into account the browser zoom. +/// +/// Corresponds to [`window.devicePixelRatio`](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio) in JavaScript. +pub fn native_pixels_per_point() -> f32 { + let pixels_per_point = web_sys::window().unwrap().device_pixel_ratio() as f32; + if pixels_per_point > 0.0 && pixels_per_point.is_finite() { + pixels_per_point + } else { + 1.0 + } +} + +/// Ask the browser about the preferred system theme. +/// +/// `None` means unknown. +pub fn system_theme() -> Option { + let dark_mode = prefers_color_scheme_dark(&web_sys::window()?) + .ok()?? + .matches(); + Some(theme_from_dark_mode(dark_mode)) +} + +fn prefers_color_scheme_dark(window: &web_sys::Window) -> Result, JsValue> { + window.match_media("(prefers-color-scheme: dark)") +} + +fn theme_from_dark_mode(dark_mode: bool) -> Theme { + if dark_mode { + Theme::Dark + } else { + Theme::Light + } +} + +fn canvas_element(canvas_id: &str) -> Option { + let document = web_sys::window()?.document()?; + let canvas = document.get_element_by_id(canvas_id)?; + canvas.dyn_into::().ok() +} + +fn canvas_element_or_die(canvas_id: &str) -> web_sys::HtmlCanvasElement { + canvas_element(canvas_id) + .unwrap_or_else(|| panic!("Failed to find canvas with id {canvas_id:?}")) +} + +fn canvas_origin(canvas: &web_sys::HtmlCanvasElement) -> egui::Pos2 { + let rect = canvas.get_bounding_client_rect(); + egui::pos2(rect.left() as f32, rect.top() as f32) +} + +fn canvas_size_in_points(canvas: &web_sys::HtmlCanvasElement) -> egui::Vec2 { + let pixels_per_point = native_pixels_per_point(); + egui::vec2( + canvas.width() as f32 / pixels_per_point, + canvas.height() as f32 / pixels_per_point, + ) +} + +fn resize_canvas_to_screen_size( + canvas: &web_sys::HtmlCanvasElement, + max_size_points: egui::Vec2, +) -> Option<()> { + let parent = canvas.parent_element()?; + + // Prefer the client width and height so that if the parent + // element is resized that the egui canvas resizes appropriately. + let width = parent.client_width(); + let height = parent.client_height(); + + let canvas_real_size = Vec2 { + x: width as f32, + y: height as f32, + }; + + if width <= 0 || height <= 0 { + log::error!("egui canvas parent size is {}x{}. Try adding `html, body {{ height: 100%; width: 100% }}` to your CSS!", width, height); + } + + let pixels_per_point = native_pixels_per_point(); + + let max_size_pixels = pixels_per_point * max_size_points; + + let canvas_size_pixels = pixels_per_point * canvas_real_size; + let canvas_size_pixels = canvas_size_pixels.min(max_size_pixels); + let canvas_size_points = canvas_size_pixels / pixels_per_point; + + // Make sure that the height and width are always even numbers. + // otherwise, the page renders blurry on some platforms. + // See https://github.com/emilk/egui/issues/103 + fn round_to_even(v: f32) -> f32 { + (v / 2.0).round() * 2.0 + } + + canvas + .style() + .set_property( + "width", + &format!("{}px", round_to_even(canvas_size_points.x)), + ) + .ok()?; + canvas + .style() + .set_property( + "height", + &format!("{}px", round_to_even(canvas_size_points.y)), + ) + .ok()?; + canvas.set_width(round_to_even(canvas_size_pixels.x) as u32); + canvas.set_height(round_to_even(canvas_size_pixels.y) as u32); + + Some(()) +} + +// ---------------------------------------------------------------------------- + +/// Set the cursor icon. +fn set_cursor_icon(cursor: egui::CursorIcon) -> Option<()> { + let document = web_sys::window()?.document()?; + document + .body()? + .style() + .set_property("cursor", cursor_web_name(cursor)) + .ok() +} + +/// Set the clipboard text. +#[cfg(web_sys_unstable_apis)] +fn set_clipboard_text(s: &str) { + if let Some(window) = web_sys::window() { + if let Some(clipboard) = window.navigator().clipboard() { + let promise = clipboard.write_text(s); + let future = wasm_bindgen_futures::JsFuture::from(promise); + let future = async move { + if let Err(err) = future.await { + log::error!("Copy/cut action failed: {err:?}"); + } + }; + wasm_bindgen_futures::spawn_local(future); + } + } +} + +fn cursor_web_name(cursor: egui::CursorIcon) -> &'static str { + match cursor { + egui::CursorIcon::Alias => "alias", + egui::CursorIcon::AllScroll => "all-scroll", + egui::CursorIcon::Cell => "cell", + egui::CursorIcon::ContextMenu => "context-menu", + egui::CursorIcon::Copy => "copy", + egui::CursorIcon::Crosshair => "crosshair", + egui::CursorIcon::Default => "default", + egui::CursorIcon::Grab => "grab", + egui::CursorIcon::Grabbing => "grabbing", + egui::CursorIcon::Help => "help", + egui::CursorIcon::Move => "move", + egui::CursorIcon::NoDrop => "no-drop", + egui::CursorIcon::None => "none", + egui::CursorIcon::NotAllowed => "not-allowed", + egui::CursorIcon::PointingHand => "pointer", + egui::CursorIcon::Progress => "progress", + egui::CursorIcon::ResizeHorizontal => "ew-resize", + egui::CursorIcon::ResizeNeSw => "nesw-resize", + egui::CursorIcon::ResizeNwSe => "nwse-resize", + egui::CursorIcon::ResizeVertical => "ns-resize", + + egui::CursorIcon::ResizeEast => "e-resize", + egui::CursorIcon::ResizeSouthEast => "se-resize", + egui::CursorIcon::ResizeSouth => "s-resize", + egui::CursorIcon::ResizeSouthWest => "sw-resize", + egui::CursorIcon::ResizeWest => "w-resize", + egui::CursorIcon::ResizeNorthWest => "nw-resize", + egui::CursorIcon::ResizeNorth => "n-resize", + egui::CursorIcon::ResizeNorthEast => "ne-resize", + egui::CursorIcon::ResizeColumn => "col-resize", + egui::CursorIcon::ResizeRow => "row-resize", + + egui::CursorIcon::Text => "text", + egui::CursorIcon::VerticalText => "vertical-text", + egui::CursorIcon::Wait => "wait", + egui::CursorIcon::ZoomIn => "zoom-in", + egui::CursorIcon::ZoomOut => "zoom-out", + } +} + +/// Open the given url in the browser. +pub fn open_url(url: &str, new_tab: bool) -> Option<()> { + let name = if new_tab { "_blank" } else { "_self" }; + + web_sys::window()? + .open_with_url_and_target(url, name) + .ok()?; + Some(()) +} + +/// e.g. "#fragment" part of "www.example.com/index.html#fragment", +/// +/// Percent decoded +pub fn location_hash() -> String { + percent_decode( + &web_sys::window() + .unwrap() + .location() + .hash() + .unwrap_or_default(), + ) +} + +/// Percent-decodes a string. +pub fn percent_decode(s: &str) -> String { + percent_encoding::percent_decode_str(s) + .decode_utf8_lossy() + .to_string() +} + +// ---------------------------------------------------------------------------- + +/// Options and state that will be sent to the web worker part of the web runner. +#[derive(Clone)] +pub struct WorkerOptions { + /// Whether or not the user's browser prefers dark mode. + /// `Some(true)` means dark mode is preferred. + /// `Some(false)` means light mode is preferred. + /// `None` means no preference was detected. + pub prefers_color_scheme_dark: Option, + /// The halves of the web runner channels that are used in the web worker. + pub channels: WorkerChannels, +} + +/// The halves of the web runner channels that are used in the web worker. +#[derive(Clone)] +pub struct WorkerChannels { + /// The receiver used to receive egui events from the main thread. + event_rx: flume::Receiver, + /// The receiver used to receive custom events from the main thread. + custom_event_rx: flume::Receiver, + /// The sender used to send outputs to the main thread. + output_tx: flume::Sender, +} + +impl WorkerChannels { + /// Send an output to the main thread. + fn send(&self, output: WebRunnerOutput) { + let _ = self.output_tx.send(output); + } +} + +/// The state of the web runner that is accessible to the main thread. +#[derive(Clone)] +pub struct MainState { + /// The state of the web runner that is accessible to the main thread. + pub inner: std::rc::Rc>, + /// The HTML canvas element that this runner renders onto. + pub canvas: web_sys::HtmlCanvasElement, + /// The halves of the web runner channels that are used in the main thread. + pub channels: MainChannels, +} + +/// The state of the web runner that is accessible to the main thread. +#[derive(Default)] +pub struct MainStateInner { + /// If the user is currently interacting with the touchscreen, this is the ID of the touch, + /// measured with `Touch.identifier` in JavaScript. + touch_id: Option, + /// The position relative to the canvas of the last received touch event. If no touch event has + /// been received yet, this will be (0, 0). + touch_pos: egui::Pos2, + /// If the user is typing something, the position of the text cursor (for IME) in screen + /// coordinates. + text_cursor_pos: Option, + /// Whether or not the user is editing a mutable egui text box. + mutable_text_under_cursor: bool, + /// The screen reader used for reading text aloud. + screen_reader: Option, + /// Whether or not egui is trying to receive text input. + wants_keyboard_input: bool, +} + +/// The halves of the web runner channels that are used in the main thread. +#[derive(Clone)] +pub struct MainChannels { + /// The sender used to send egui events to the worker thread. + event_tx: flume::Sender, + /// The sender used to send custom events to the worker thread. + custom_event_tx: flume::Sender, + /// The receiver used to receive outputs from the worker thread. + output_rx: flume::Receiver, +} + +impl MainState { + /// Add an event listener to the given JavaScript `EventTarget`. + fn add_event_listener( + &self, + target: &web_sys::EventTarget, + event_name: &'static str, + mut closure: impl FnMut(E, &MainState) + 'static, + ) -> Result<(), wasm_bindgen::JsValue> { + let state = self.clone(); + + // Create a JS closure based on the FnMut provided + let closure = Closure::wrap(Box::new(move |event: web_sys::Event| { + // Only call the wrapped closure if the egui code has not panicked + if PANIC_LOCK.get().is_none() { + // Cast the event to the expected event type + let event = event.unchecked_into::(); + closure(event, &state); + } + }) as Box); + + // Add the event listener to the target + target.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())?; + + closure.forget(); + Ok(()) + } +} + +impl MainChannels { + /// Send an egui event to the worker thread. + fn send(&self, event: egui::Event) { + let _ = self.event_tx.send(event); + } + + /// Send a custom event to the worker thread. + fn send_custom(&self, event: WebRunnerCustomEvent) { + let _ = self.custom_event_tx.send(event); + } +} + +/// Create a new connected `(WorkerChannels, MainChannels)` pair for initializing a web runner. +pub fn channels() -> (WorkerChannels, MainChannels) { + let (event_tx, event_rx) = flume::unbounded(); + let (custom_event_tx, custom_event_rx) = flume::unbounded(); + let (output_tx, output_rx) = flume::unbounded(); + ( + WorkerChannels { + event_rx, + custom_event_rx, + output_tx, + }, + MainChannels { + event_tx, + custom_event_tx, + output_rx, + }, + ) +} + +/// A custom event that can be sent from the main thread to the worker thread. +enum WebRunnerCustomEvent { + /// (window.innerWidth, window.innerHeight, window.devicePixelRatio) + ScreenResize(u32, u32, f32), + /// This should be sent whenever the modifiers change + Modifiers(egui::Modifiers), + /// This should be sent whenever the app needs to save immediately + Save, + /// The browser detected a touchstart or touchmove event with this ID and position in canvas coordinates + Touch(Option, egui::Pos2), +} + +/// A custom output that can be sent from the worker thread to the main thread. +enum WebRunnerOutput { + /// Miscellaneous egui output events + PlatformOutput(egui::PlatformOutput, bool, bool), + /// The runner wants to read a key from storage + StorageGet(String, oneshot::Sender>), + /// The runner wants to write a key to storage + StorageSet(String, String, oneshot::Sender), +} + +static PANIC_LOCK: once_cell::sync::OnceCell<()> = once_cell::sync::OnceCell::new(); diff --git a/crates/eframe/src/web/panic_handler.rs b/crates/eframe/src/web/panic_handler.rs new file mode 100644 index 00000000..9656ba28 --- /dev/null +++ b/crates/eframe/src/web/panic_handler.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use egui::mutex::Mutex; +use wasm_bindgen::prelude::*; + +/// Detects panics, logs them using `console.error`, and stores the panics message and callstack. +/// +/// This lets you query `PanicHandler` for the panic message (if any) so you can show it in the HTML. +/// +/// Chep to clone (ref-counted). +#[derive(Clone)] +pub struct PanicHandler(Arc>); + +impl PanicHandler { + /// Install a panic hook. + pub fn install() -> Self { + let handler = Self(Arc::new(Mutex::new(Default::default()))); + + let handler_clone = handler.clone(); + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = super::PANIC_LOCK.set(()); + + let summary = PanicSummary::new(panic_info); + + // Log it using console.error + error(format!( + "{}\n\nStack:\n\n{}", + summary.message(), + summary.callstack() + )); + + // Remember the summary: + handler_clone.0.lock().summary = Some(summary); + + // Propagate panic info to the previously registered panic hook + previous_hook(panic_info); + })); + + handler + } + + /// Has there been a panic? + pub fn has_panicked(&self) -> bool { + self.0.lock().summary.is_some() + } + + /// What was the panic message and callstack? + pub fn panic_summary(&self) -> Option { + self.0.lock().summary.clone() + } +} + +#[derive(Clone, Default)] +struct PanicHandlerInner { + summary: Option, +} + +/// Contains a summary about a panics. +/// +/// This is basically a human-readable version of [`std::panic::PanicInfo`] +/// with an added callstack. +#[derive(Clone, Debug)] +pub struct PanicSummary { + message: String, + callstack: String, +} + +impl PanicSummary { + /// Construct a summary from a panic. + pub fn new(info: &std::panic::PanicInfo<'_>) -> Self { + let message = info.to_string(); + let callstack = Error::new().stack(); + Self { message, callstack } + } + + /// The panic message. + pub fn message(&self) -> String { + self.message.clone() + } + + /// The backtrace. + pub fn callstack(&self) -> String { + self.callstack.clone() + } +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + fn error(msg: String); + + type Error; + + #[wasm_bindgen(constructor)] + fn new() -> Error; + + #[wasm_bindgen(structural, method, getter)] + fn stack(error: &Error) -> String; +} diff --git a/crates/eframe/src/web/screen_reader.rs b/crates/eframe/src/web/screen_reader.rs new file mode 100644 index 00000000..f4b37f20 --- /dev/null +++ b/crates/eframe/src/web/screen_reader.rs @@ -0,0 +1,52 @@ +/// Screen reader support. +pub struct ScreenReader { + #[cfg(feature = "tts")] + tts: Option, +} + +#[cfg(not(feature = "tts"))] +#[allow(clippy::derivable_impls)] // False positive +impl Default for ScreenReader { + fn default() -> Self { + Self {} + } +} + +#[cfg(feature = "tts")] +impl Default for ScreenReader { + fn default() -> Self { + let tts = match tts::Tts::default() { + Ok(screen_reader) => { + log::debug!("Initialized screen reader."); + Some(screen_reader) + } + Err(err) => { + log::warn!("Failed to load screen reader: {}", err); + None + } + }; + Self { tts } + } +} + +impl ScreenReader { + /// Speak the given text out loud. + #[cfg(not(feature = "tts"))] + #[allow(clippy::unused_self)] + pub fn speak(&mut self, _text: &str) {} + + /// Speak the given text out loud. + #[cfg(feature = "tts")] + pub fn speak(&mut self, text: &str) { + if text.is_empty() { + return; + } + if let Some(tts) = &mut self.tts { + log::debug!("Speaking: {:?}", text); + let interrupt = true; + if let Err(err) = tts.speak(text, interrupt) { + log::warn!("Failed to read: {}", err); + } + } + } +} diff --git a/crates/eframe/src/web/storage.rs b/crates/eframe/src/web/storage.rs new file mode 100644 index 00000000..09af42a2 --- /dev/null +++ b/crates/eframe/src/web/storage.rs @@ -0,0 +1,54 @@ +pub(super) fn local_storage() -> Option { + web_sys::window()?.local_storage().ok()? +} + +/// Read data from local storage. +pub fn local_storage_get(key: &str) -> Option { + local_storage().map(|storage| storage.get_item(key).ok())?? +} + +/// Write data to local storage. +pub fn local_storage_set(key: &str, value: &str) { + local_storage().map(|storage| storage.set_item(key, value)); +} + +#[cfg(feature = "persistence")] +pub(crate) async fn load_memory(ctx: &egui::Context, channels: &super::WorkerChannels) { + let (oneshot_tx, oneshot_rx) = oneshot::channel(); + channels.send(super::WebRunnerOutput::StorageGet( + String::from("egui_memory_ron"), + oneshot_tx, + )); + if let Some(memory) = oneshot_rx.await.ok().flatten() { + match ron::from_str(&memory) { + Ok(memory) => { + ctx.memory_mut(|m| *m = memory); + } + Err(err) => log::warn!("Failed to parse memory RON: {err}"), + } + } +} + +#[cfg(not(feature = "persistence"))] +pub(crate) async fn load_memory(_: &egui::Context, _: &super::WorkerChannels) {} + +#[cfg(feature = "persistence")] +pub(crate) fn save_memory(ctx: &egui::Context, channels: &super::WorkerChannels) { + match ctx.memory(|mem| ron::to_string(mem)) { + Ok(ron) => { + let (oneshot_tx, oneshot_rx) = oneshot::channel(); + channels.send(super::WebRunnerOutput::StorageSet( + String::from("egui_memory_ron"), + ron, + oneshot_tx, + )); + let _ = oneshot_rx.recv(); + } + Err(err) => { + log::warn!("Failed to serialize memory as RON: {err}"); + } + } +} + +#[cfg(not(feature = "persistence"))] +pub(crate) fn save_memory(_: &egui::Context, _: &super::WorkerChannels) {} diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs new file mode 100644 index 00000000..55801813 --- /dev/null +++ b/crates/eframe/src/web/text_agent.rs @@ -0,0 +1,235 @@ +//! The text agent is an `` element used to trigger +//! mobile keyboard and IME input. +//! +use std::{cell::Cell, rc::Rc}; + +use wasm_bindgen::prelude::*; + +use super::{canvas_element, AppRunner, WebRunner}; + +static AGENT_ID: &str = "egui_text_agent"; + +pub fn text_agent() -> web_sys::HtmlInputElement { + web_sys::window() + .unwrap() + .document() + .unwrap() + .get_element_by_id(AGENT_ID) + .unwrap() + .dyn_into() + .unwrap() +} + +/// Text event handler, +pub fn install_text_agent(state: &super::MainState) -> Result<(), JsValue> { + let window = web_sys::window().unwrap(); + let document = window.document().unwrap(); + let body = document.body().expect("document should have a body"); + let input = document + .create_element("input")? + .dyn_into::()?; + let input = std::rc::Rc::new(input); + input.set_id(AGENT_ID); + let is_composing = Rc::new(Cell::new(false)); + { + let style = input.style(); + // Transparent + style.set_property("opacity", "0").unwrap(); + // Hide under canvas + style.set_property("z-index", "-1").unwrap(); + } + // Set size as small as possible, in case user may click on it. + input.set_size(1); + input.set_autofocus(true); + input.set_hidden(true); + + // When IME is off + state.add_event_listener(&input, "input", { + let input_clone = input.clone(); + let is_composing = is_composing.clone(); + + move |_event: web_sys::InputEvent, state| { + let text = input_clone.value(); + if !text.is_empty() && !is_composing.get() { + input_clone.set_value(""); + state.channels.send(egui::Event::Text(text)); + //runner.needs_repaint.repaint_asap(); + } + } + })?; + + { + // When IME is on, handle composition event + state.add_event_listener(&input, "compositionstart", { + let input_clone = input.clone(); + let is_composing = is_composing.clone(); + + move |_event: web_sys::CompositionEvent, state| { + is_composing.set(true); + input_clone.set_value(""); + + state.channels.send(egui::Event::CompositionStart); + //runner.needs_repaint.repaint_asap(); + } + })?; + + state.add_event_listener( + &input, + "compositionupdate", + move |event: web_sys::CompositionEvent, state| { + if let Some(event) = event.data().map(egui::Event::CompositionUpdate) { + state.channels.send(event); + //runner.needs_repaint.repaint_asap(); + } + }, + )?; + + state.add_event_listener(&input, "compositionend", { + let input_clone = input.clone(); + + move |event: web_sys::CompositionEvent, state| { + is_composing.set(false); + input_clone.set_value(""); + + if let Some(event) = event.data().map(egui::Event::CompositionEnd) { + state.channels.send(event); + //runner.needs_repaint.repaint_asap(); + } + } + })?; + } + + // When input lost focus, focus on it again. + // It is useful when user click somewhere outside canvas. + let input_refocus = input.clone(); + state.add_event_listener( + &input, + "focusout", + move |_event: web_sys::MouseEvent, _state| { + // Delay 10 ms, and focus again. + let input_refocus = input_refocus.clone(); + call_after_delay(std::time::Duration::from_millis(10), move || { + input_refocus.focus().ok(); + }); + }, + )?; + + body.append_child(&input)?; + + Ok(()) +} + +/// Focus or blur text agent to toggle mobile keyboard. +pub fn update_text_agent(state: &super::MainState) -> Option<()> { + let inner = state.inner.borrow(); + + use web_sys::HtmlInputElement; + let window = web_sys::window()?; + let document = window.document()?; + let input: HtmlInputElement = document.get_element_by_id(AGENT_ID)?.dyn_into().unwrap(); + let canvas_style = state.canvas.style(); + + if inner.mutable_text_under_cursor { + let is_already_editing = input.hidden(); + if is_already_editing { + input.set_hidden(false); + input.focus().ok()?; + + // Move up canvas so that text edit is shown at ~30% of screen height. + // Only on touch screens, when keyboard popups. + if inner.touch_id.is_some() { + let window_height = window.inner_height().ok()?.as_f64()? as f32; + let current_rel = inner.touch_pos.y / window_height; + + // estimated amount of screen covered by keyboard + let keyboard_fraction = 0.5; + + if current_rel > keyboard_fraction { + // below the keyboard + + let target_rel = 0.3; + + // Note: `delta` is negative, since we are moving the canvas UP + let delta = target_rel - current_rel; + + let delta = delta.max(-keyboard_fraction); // Don't move it crazy much + + let new_pos_percent = format!("{}%", (delta * 100.0).round()); + + canvas_style.set_property("position", "absolute").ok()?; + canvas_style.set_property("top", &new_pos_percent).ok()?; + } + } + } + } else { + // Holding the runner lock while calling input.blur() causes a panic. + // This is most probably caused by the browser running the event handler + // for the triggered blur event synchronously, meaning that the mutex + // lock does not get dropped by the time another event handler is called. + // + // Why this didn't exist before #1290 is a mystery to me, but it exists now + // and this apparently is the fix for it + // + // ¯\_(ツ)_/¯ - @DusterTheFirst + + // So since we are inside a runner lock here, we just postpone the blur/hide: + + call_after_delay(std::time::Duration::from_millis(0), move || { + input.blur().ok(); + input.set_hidden(true); + canvas_style.set_property("position", "absolute").ok(); + canvas_style.set_property("top", "0%").ok(); // move back to normal position + }); + } + Some(()) +} + +fn call_after_delay(delay: std::time::Duration, f: impl FnOnce() + 'static) { + use wasm_bindgen::prelude::*; + let window = web_sys::window().unwrap(); + let closure = Closure::once(f); + let delay_ms = delay.as_millis() as _; + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + closure.as_ref().unchecked_ref(), + delay_ms, + ) + .unwrap(); + closure.forget(); // We must forget it, or else the callback is canceled on drop +} + +/// If context is running under mobile device? +fn is_mobile() -> Option { + const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"]; + + let user_agent = web_sys::window()?.navigator().user_agent().ok()?; + let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name)); + Some(is_mobile) +} + +// Move text agent to text cursor's position, on desktop/laptop, +// candidate window moves following text element (agent), +// so it appears that the IME candidate window moves with text cursor. +// On mobile devices, there is no need to do that. +pub fn move_text_cursor( + cursor: Option, + canvas: &web_sys::HtmlCanvasElement, +) -> Option<()> { + let style = text_agent().style(); + // Note: movint agent on mobile devices will lead to unpredictable scroll. + if is_mobile() == Some(false) { + cursor.as_ref().and_then(|&egui::Pos2 { x, y }| { + let bounding_rect = text_agent().get_bounding_client_rect(); + let y = (y + (canvas.scroll_top() + canvas.offset_top()) as f32) + .min(canvas.client_height() as f32 - bounding_rect.height() as f32); + let x = x + (canvas.scroll_left() + canvas.offset_left()) as f32; + style.set_property("position", "absolute").ok()?; + style.set_property("top", &format!("{y}px")).ok()?; + style.set_property("left", &format!("{x}px")).ok() + }) + } else { + style.set_property("position", "absolute").ok()?; + style.set_property("top", "0px").ok()?; + style.set_property("left", "0px").ok() + } +} diff --git a/crates/eframe/src/web/web_logger.rs b/crates/eframe/src/web/web_logger.rs new file mode 100644 index 00000000..90dfc1b3 --- /dev/null +++ b/crates/eframe/src/web/web_logger.rs @@ -0,0 +1,118 @@ +/// Implements [`log::Log`] to log messages to `console.log`, `console.warn`, etc. +pub struct WebLogger { + filter: log::LevelFilter, +} + +impl WebLogger { + /// Install a new `WebLogger`, piping all [`log`] events to the web console. + pub fn init(filter: log::LevelFilter) -> Result<(), log::SetLoggerError> { + log::set_max_level(filter); + log::set_boxed_logger(Box::new(WebLogger::new(filter))) + } + + /// Create a new [`WebLogger`] with the given filter, + /// but don't install it. + pub fn new(filter: log::LevelFilter) -> Self { + Self { filter } + } +} + +impl log::Log for WebLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + + let msg = if let (Some(file), Some(line)) = (record.file(), record.line()) { + let file = shorten_file_path(file); + format!("[{}] {file}:{line}: {}", record.target(), record.args()) + } else { + format!("[{}] {}", record.target(), record.args()) + }; + + match record.level() { + log::Level::Trace => console::trace(&msg), + log::Level::Debug => console::debug(&msg), + log::Level::Info => console::info(&msg), + log::Level::Warn => console::warn(&msg), + + // Using console.error causes crashes for unknown reason + // https://github.com/emilk/egui/pull/2961 + // log::Level::Error => console::error(&msg), + log::Level::Error => console::warn(&format!("ERROR: {msg}")), + } + } + + fn flush(&self) {} +} + +/// js-bindings for console.log, console.warn, etc +mod console { + use wasm_bindgen::prelude::*; + + #[wasm_bindgen] + extern "C" { + /// `console.trace` + #[wasm_bindgen(js_namespace = console)] + pub fn trace(s: &str); + + /// `console.debug` + #[wasm_bindgen(js_namespace = console)] + pub fn debug(s: &str); + + /// `console.info` + #[wasm_bindgen(js_namespace = console)] + pub fn info(s: &str); + + /// `console.warn` + #[wasm_bindgen(js_namespace = console)] + pub fn warn(s: &str); + + // Using console.error causes crashes for unknown reason + // https://github.com/emilk/egui/pull/2961 + // /// `console.error` + // #[wasm_bindgen(js_namespace = console)] + // pub fn error(s: &str); + } +} + +/// Shorten a path to a Rust source file. +/// +/// Example input: +/// * `/Users/emilk/.cargo/registry/src/github.com-1ecc6299db9ec823/tokio-1.24.1/src/runtime/runtime.rs` +/// * `crates/rerun/src/main.rs` +/// * `/rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/core/src/ops/function.rs` +/// +/// Example output: +/// * `tokio-1.24.1/src/runtime/runtime.rs` +/// * `rerun/src/main.rs` +/// * `core/src/ops/function.rs` +#[allow(dead_code)] // only used on web and in tests +fn shorten_file_path(file_path: &str) -> &str { + if let Some(i) = file_path.rfind("/src/") { + if let Some(prev_slash) = file_path[..i].rfind('/') { + &file_path[prev_slash + 1..] + } else { + file_path + } + } else { + file_path + } +} + +#[test] +fn test_shorten_file_path() { + for (before, after) in [ + ("/Users/emilk/.cargo/registry/src/github.com-1ecc6299db9ec823/tokio-1.24.1/src/runtime/runtime.rs", "tokio-1.24.1/src/runtime/runtime.rs"), + ("crates/rerun/src/main.rs", "rerun/src/main.rs"), + ("/rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/core/src/ops/function.rs", "core/src/ops/function.rs"), + ("/weird/path/file.rs", "/weird/path/file.rs"), + ] + { + assert_eq!(shorten_file_path(before), after); + } +} diff --git a/crates/eframe/src/web/web_painter.rs b/crates/eframe/src/web/web_painter.rs new file mode 100644 index 00000000..9050a5c8 --- /dev/null +++ b/crates/eframe/src/web/web_painter.rs @@ -0,0 +1,26 @@ +use wasm_bindgen::JsValue; + +/// Renderer for a browser canvas. +/// As of writing we're not allowing to decide on the painter at runtime, +/// therefore this trait is merely there for specifying and documenting the interface. +pub(crate) trait WebPainter { + // Create a new web painter targeting a given canvas. + // fn new(canvas_id: &str, options: &WebOptions) -> Result + // where + // Self: Sized; + + /// Maximum size of a texture in one direction. + fn max_texture_side(&self) -> usize; + + /// Update all internal textures and paint gui. + fn paint_and_update_textures( + &mut self, + clear_color: [f32; 4], + clipped_primitives: &[egui::ClippedPrimitive], + pixels_per_point: f32, + textures_delta: &egui::TexturesDelta, + ) -> Result<(), JsValue>; + + /// Destroy all resources. + fn destroy(&mut self); +} diff --git a/crates/eframe/src/web/web_painter_glow.rs b/crates/eframe/src/web/web_painter_glow.rs new file mode 100644 index 00000000..b12ac1ce --- /dev/null +++ b/crates/eframe/src/web/web_painter_glow.rs @@ -0,0 +1,184 @@ +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; +use web_sys::HtmlCanvasElement; + +use egui_glow::glow; + +use crate::{WebGlContextOption, WebOptions}; + +use super::web_painter::WebPainter; + +pub(crate) struct WebPainterGlow { + canvas: HtmlCanvasElement, + canvas_id: String, + painter: egui_glow::Painter, +} + +impl WebPainterGlow { + pub fn gl(&self) -> &std::sync::Arc { + self.painter.gl() + } + + pub async fn new(canvas_id: &str, options: &WebOptions) -> Result { + let canvas = super::canvas_element_or_die(canvas_id); + + let (gl, shader_prefix) = + init_glow_context_from_canvas(&canvas, options.webgl_context_option)?; + let gl = std::sync::Arc::new(gl); + + let painter = egui_glow::Painter::new(gl, shader_prefix, None) + .map_err(|err| format!("Error starting glow painter: {err}"))?; + + Ok(Self { + canvas, + canvas_id: canvas_id.to_owned(), + painter, + }) + } +} + +impl WebPainter for WebPainterGlow { + fn max_texture_side(&self) -> usize { + self.painter.max_texture_side() + } + + fn canvas_id(&self) -> &str { + &self.canvas_id + } + + fn paint_and_update_textures( + &mut self, + clear_color: [f32; 4], + clipped_primitives: &[egui::ClippedPrimitive], + pixels_per_point: f32, + textures_delta: &egui::TexturesDelta, + ) -> Result<(), JsValue> { + let canvas_dimension = [self.canvas.width(), self.canvas.height()]; + + for (id, image_delta) in &textures_delta.set { + self.painter.set_texture(*id, image_delta); + } + + egui_glow::painter::clear(self.painter.gl(), canvas_dimension, clear_color); + self.painter + .paint_primitives(canvas_dimension, pixels_per_point, clipped_primitives); + + for &id in &textures_delta.free { + self.painter.free_texture(id); + } + + Ok(()) + } + + fn destroy(&mut self) { + self.painter.destroy(); + } +} + +/// Returns glow context and shader prefix. +fn init_glow_context_from_canvas( + canvas: &HtmlCanvasElement, + options: WebGlContextOption, +) -> Result<(glow::Context, &'static str), String> { + let result = match options { + // Force use WebGl1 + WebGlContextOption::WebGl1 => init_webgl1(canvas), + // Force use WebGl2 + WebGlContextOption::WebGl2 => init_webgl2(canvas), + // Trying WebGl2 first + WebGlContextOption::BestFirst => init_webgl2(canvas).or_else(|| init_webgl1(canvas)), + // Trying WebGl1 first (useful for testing). + WebGlContextOption::CompatibilityFirst => { + init_webgl1(canvas).or_else(|| init_webgl2(canvas)) + } + }; + + if let Some(result) = result { + Ok(result) + } else { + Err("WebGL isn't supported".into()) + } +} + +fn init_webgl1(canvas: &HtmlCanvasElement) -> Option<(glow::Context, &'static str)> { + let gl1_ctx = canvas + .get_context("webgl") + .expect("Failed to query about WebGL2 context"); + + let gl1_ctx = gl1_ctx?; + log::debug!("WebGL1 selected."); + + let gl1_ctx = gl1_ctx + .dyn_into::() + .unwrap(); + + let shader_prefix = if webgl1_requires_brightening(&gl1_ctx) { + log::debug!("Enabling webkitGTK brightening workaround."); + "#define APPLY_BRIGHTENING_GAMMA" + } else { + "" + }; + + let gl = glow::Context::from_webgl1_context(gl1_ctx); + + Some((gl, shader_prefix)) +} + +fn init_webgl2(canvas: &HtmlCanvasElement) -> Option<(glow::Context, &'static str)> { + let gl2_ctx = canvas + .get_context("webgl2") + .expect("Failed to query about WebGL2 context"); + + let gl2_ctx = gl2_ctx?; + log::debug!("WebGL2 selected."); + + let gl2_ctx = gl2_ctx + .dyn_into::() + .unwrap(); + let gl = glow::Context::from_webgl2_context(gl2_ctx); + let shader_prefix = ""; + + Some((gl, shader_prefix)) +} + +fn webgl1_requires_brightening(gl: &web_sys::WebGlRenderingContext) -> bool { + // See https://github.com/emilk/egui/issues/794 + + // detect WebKitGTK + + // WebKitGTK use WebKit default unmasked vendor and renderer + // but safari use same vendor and renderer + // so exclude "Mac OS X" user-agent. + let user_agent = web_sys::window().unwrap().navigator().user_agent().unwrap(); + !user_agent.contains("Mac OS X") && is_safari_and_webkit_gtk(gl) +} + +/// detecting Safari and `webkitGTK`. +/// +/// Safari and `webkitGTK` use unmasked renderer :Apple GPU +/// +/// If we detect safari or `webkitGTKs` returns true. +/// +/// This function used to avoid displaying linear color with `sRGB` supported systems. +fn is_safari_and_webkit_gtk(gl: &web_sys::WebGlRenderingContext) -> bool { + // This call produces a warning in Firefox ("WEBGL_debug_renderer_info is deprecated in Firefox and will be removed.") + // but unless we call it we get errors in Chrome when we call `get_parameter` below. + // TODO(emilk): do something smart based on user agent? + if gl + .get_extension("WEBGL_debug_renderer_info") + .unwrap() + .is_some() + { + if let Ok(renderer) = + gl.get_parameter(web_sys::WebglDebugRendererInfo::UNMASKED_RENDERER_WEBGL) + { + if let Some(renderer) = renderer.as_string() { + if renderer.contains("Apple") { + return true; + } + } + } + } + + false +} diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs new file mode 100644 index 00000000..7416e7d6 --- /dev/null +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -0,0 +1,286 @@ +use std::sync::Arc; + +use wasm_bindgen::JsValue; + +use luminol_egui_wgpu::{renderer::ScreenDescriptor, RenderState, SurfaceErrorAction}; + +use crate::WebOptions; + +use super::web_painter::WebPainter; + +struct EguiWebWindow(u32); + +#[allow(unsafe_code)] +unsafe impl raw_window_handle::HasRawWindowHandle for EguiWebWindow { + fn raw_window_handle(&self) -> raw_window_handle::RawWindowHandle { + let mut window_handle = raw_window_handle::WebWindowHandle::empty(); + window_handle.id = self.0; + raw_window_handle::RawWindowHandle::Web(window_handle) + } +} + +#[allow(unsafe_code)] +unsafe impl raw_window_handle::HasRawDisplayHandle for EguiWebWindow { + fn raw_display_handle(&self) -> raw_window_handle::RawDisplayHandle { + raw_window_handle::RawDisplayHandle::Web(raw_window_handle::WebDisplayHandle::empty()) + } +} + +pub(crate) struct WebPainterWgpu { + canvas: web_sys::OffscreenCanvas, + surface: wgpu::Surface, + pub(super) surface_configuration: wgpu::SurfaceConfiguration, + render_state: Option, + on_surface_error: Arc SurfaceErrorAction>, + depth_format: Option, + depth_texture_view: Option, + + /// Width of the canvas in points. `surface_configuration.width` is the width in pixels. + pub(super) width: u32, + /// Height of the canvas in points. `surface_configuration.height` is the height in pixels. + pub(super) height: u32, + /// Length of a pixel divided by length of a point. + pub(super) pixel_ratio: f32, + pub(super) needs_resize: bool, +} + +impl WebPainterWgpu { + #[allow(unused)] // only used if `wgpu` is the only active feature. + pub fn render_state(&self) -> Option { + self.render_state.clone() + } + + pub fn generate_depth_texture_view( + &self, + render_state: &RenderState, + width_in_pixels: u32, + height_in_pixels: u32, + ) -> Option { + let device = &render_state.device; + self.depth_format.map(|depth_format| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some("egui_depth_texture"), + size: wgpu::Extent3d { + width: width_in_pixels, + height: height_in_pixels, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: depth_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[depth_format], + }) + .create_view(&wgpu::TextureViewDescriptor::default()) + }) + } + + #[allow(unused)] // only used if `wgpu` is the only active feature. + pub async fn new( + canvas: web_sys::OffscreenCanvas, + options: &WebOptions, + ) -> Result { + log::debug!("Creating wgpu painter"); + + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: options.wgpu_options.supported_backends, + ..Default::default() + }); + + let surface = instance + .create_surface_from_offscreen_canvas(canvas.clone()) + .map_err(|err| format!("failed to create wgpu surface: {err}"))?; + + let depth_format = luminol_egui_wgpu::depth_format_from_bits(options.depth_buffer, 0); + let render_state = + RenderState::create(&options.wgpu_options, &instance, &surface, depth_format, 1) + .await + .map_err(|err| err.to_string())?; + + let surface_configuration = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: render_state.target_format, + width: 0, + height: 0, + present_mode: options.wgpu_options.present_mode, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![render_state.target_format], + }; + + log::debug!("wgpu painter initialized."); + + Ok(Self { + canvas, + render_state: Some(render_state), + surface, + surface_configuration, + depth_format, + depth_texture_view: None, + on_surface_error: options.wgpu_options.on_surface_error.clone(), + + width: 0, + height: 0, + pixel_ratio: 1., + needs_resize: false, + }) + } +} + +impl WebPainter for WebPainterWgpu { + fn max_texture_side(&self) -> usize { + self.render_state.as_ref().map_or(0, |state| { + state.device.limits().max_texture_dimension_2d as _ + }) + } + + fn paint_and_update_textures( + &mut self, + clear_color: [f32; 4], + clipped_primitives: &[egui::ClippedPrimitive], + pixels_per_point: f32, + textures_delta: &egui::TexturesDelta, + ) -> Result<(), JsValue> { + let size_in_pixels = [ + self.surface_configuration.width, + self.surface_configuration.height, + ]; + + let Some(render_state) = &self.render_state else { + return Err(JsValue::from_str( + "Can't paint, wgpu renderer was already disposed", + )); + }; + + let mut encoder = + render_state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("egui_webpainter_paint_and_update_textures"), + }); + + // Upload all resources for the GPU. + let screen_descriptor = ScreenDescriptor { + size_in_pixels, + pixels_per_point, + }; + + let user_cmd_bufs = { + let mut renderer = render_state.renderer.write(); + for (id, image_delta) in &textures_delta.set { + renderer.update_texture( + &render_state.device, + &render_state.queue, + *id, + image_delta, + ); + } + + renderer.update_buffers( + &render_state.device, + &render_state.queue, + &mut encoder, + clipped_primitives, + &screen_descriptor, + ) + }; + + // Resize surface if needed + let is_zero_sized_surface = size_in_pixels[0] == 0 || size_in_pixels[1] == 0; + let frame = if is_zero_sized_surface { + None + } else { + if self.needs_resize { + self.needs_resize = false; + self.surface + .configure(&render_state.device, &self.surface_configuration); + self.depth_texture_view = self.generate_depth_texture_view( + render_state, + size_in_pixels[0], + size_in_pixels[1], + ); + } + + let frame = match self.surface.get_current_texture() { + Ok(frame) => frame, + #[allow(clippy::single_match_else)] + Err(e) => match (*self.on_surface_error)(e) { + SurfaceErrorAction::RecreateSurface => { + self.surface + .configure(&render_state.device, &self.surface_configuration); + return Ok(()); + } + SurfaceErrorAction::SkipFrame => { + return Ok(()); + } + }, + }; + + { + let renderer = render_state.renderer.read(); + let frame_view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &frame_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: clear_color[0] as f64, + g: clear_color[1] as f64, + b: clear_color[2] as f64, + a: clear_color[3] as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: self.depth_texture_view.as_ref().map(|view| { + wgpu::RenderPassDepthStencilAttachment { + view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + // It is very unlikely that the depth buffer is needed after egui finished rendering + // so no need to store it. (this can improve performance on tiling GPUs like mobile chips or Apple Silicon) + store: wgpu::StoreOp::Discard, + }), + stencil_ops: None, + } + }), + label: Some("egui_render"), + occlusion_query_set: None, + timestamp_writes: None, + }); + + renderer.render(&mut render_pass, clipped_primitives, &screen_descriptor); + } + + Some(frame) + }; + + { + let mut renderer = render_state.renderer.write(); + for id in &textures_delta.free { + renderer.free_texture(id); + } + } + + // Submit the commands: both the main buffer and user-defined ones. + render_state.queue.submit( + user_cmd_bufs + .into_iter() + .chain(std::iter::once(encoder.finish())), + ); + + if let Some(frame) = frame { + frame.present(); + } + + Ok(()) + } + + fn destroy(&mut self) { + self.render_state = None; + } +} diff --git a/crates/eframe/src/web/web_runner.rs b/crates/eframe/src/web/web_runner.rs new file mode 100644 index 00000000..fabb38ff --- /dev/null +++ b/crates/eframe/src/web/web_runner.rs @@ -0,0 +1,269 @@ +use std::{cell::RefCell, rc::Rc}; + +use wasm_bindgen::prelude::*; + +use crate::{epi, App}; + +use super::{events, AppRunner, PanicHandler}; + +/// This is how `eframe` runs your wepp application +/// +/// This is cheap to clone. +/// +/// See [the crate level docs](crate) for an example. +#[derive(Clone)] +pub struct WebRunner { + /// Have we ever panicked? + panic_handler: PanicHandler, + + /// If we ever panic during running, this RefCell is poisoned. + /// So before we use it, we need to check [`Self::panic_handler`]. + runner: Rc>>, + + /// In case of a panic, unsubscribe these. + /// They have to be in a separate `Rc` so that we don't need to pass them to + /// the panic handler, since they aren't `Send`. + events_to_unsubscribe: Rc>>, +} + +impl WebRunner { + /// Will install a panic handler that will catch and log any panics + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + #[cfg(not(web_sys_unstable_apis))] + log::warn!( + "eframe compiled without RUSTFLAGS='--cfg=web_sys_unstable_apis'. Copying text won't work." + ); + + let panic_handler = PanicHandler::install(); + + Self { + panic_handler, + runner: Rc::new(RefCell::new(None)), + events_to_unsubscribe: Rc::new(RefCell::new(Default::default())), + } + } + + /// Set up the event listeners on the main thread in order to do things like respond to + /// mouse events and resize the canvas to fill the screen. + pub fn setup_main_thread_hooks(state: super::MainState) -> Result<(), JsValue> { + { + let mut inner = state.inner.borrow_mut(); + if inner.screen_reader.is_none() { + inner.screen_reader = Some(Default::default()); + } + } + + { + events::install_canvas_events(&state)?; + events::install_document_events(&state)?; + events::install_window_events(&state)?; + super::text_agent::install_text_agent(&state)?; + } + + wasm_bindgen_futures::spawn_local(async move { + loop { + let Ok(command) = state.channels.output_rx.recv_async().await else { + log::warn!( + "Web runner main thread loop is stopping! This is not supposed to happen." + ); + return; + }; + + match command { + super::WebRunnerOutput::PlatformOutput( + output, + screen_reader_enabled, + wants_keyboard_input, + ) => { + AppRunner::handle_platform_output( + &state, + output, + screen_reader_enabled, + wants_keyboard_input, + ); + } + + super::WebRunnerOutput::StorageGet(key, oneshot_tx) => { + let _ = oneshot_tx.send(super::storage::local_storage_get(&key)); + } + + super::WebRunnerOutput::StorageSet(key, value, oneshot_tx) => { + if super::storage::local_storage().is_none() { + let _ = oneshot_tx.send(false); + } else { + super::storage::local_storage_set(&key, &value); + let _ = oneshot_tx.send(true); + } + } + } + } + }); + + Ok(()) + } + + /// Create the application, install callbacks, and start running the app. + /// + /// # Errors + /// Failing to initialize graphics. + pub async fn start( + &self, + canvas: web_sys::OffscreenCanvas, + web_options: crate::WebOptions, + app_creator: epi::AppCreator, + worker_options: super::WorkerOptions, + ) -> Result<(), JsValue> { + self.destroy(); + + let mut runner = AppRunner::new(canvas, web_options, app_creator, worker_options).await?; + runner.warm_up(); + self.runner.replace(Some(runner)); + + { + events::request_animation_frame(self.clone())?; + } + + Ok(()) + } + + /// Has there been a panic? + pub fn has_panicked(&self) -> bool { + self.panic_handler.has_panicked() + } + + /// What was the panic message and callstack? + pub fn panic_summary(&self) -> Option { + self.panic_handler.panic_summary() + } + + fn unsubscribe_from_all_events(&self) { + let events_to_unsubscribe: Vec<_> = + std::mem::take(&mut *self.events_to_unsubscribe.borrow_mut()); + + if !events_to_unsubscribe.is_empty() { + log::debug!("Unsubscribing from {} events", events_to_unsubscribe.len()); + for x in events_to_unsubscribe { + if let Err(err) = x.unsubscribe() { + log::warn!("Failed to unsubscribe from event: {err:?}"); + } + } + } + } + + /// Shut down eframe and clean up resources. + pub fn destroy(&self) { + self.unsubscribe_from_all_events(); + + if let Some(runner) = self.runner.replace(None) { + runner.destroy(); + } + } + + /// Returns `None` if there has been a panic, or if we have been destroyed. + /// In that case, just return to JS. + pub(crate) fn try_lock(&self) -> Option> { + if self.panic_handler.has_panicked() { + // Unsubscribe from all events so that we don't get any more callbacks + // that will try to access the poisoned runner. + self.unsubscribe_from_all_events(); + None + } else { + let lock = self.runner.try_borrow_mut().ok()?; + std::cell::RefMut::filter_map(lock, |lock| -> Option<&mut AppRunner> { lock.as_mut() }) + .ok() + } + } + + /// Get mutable access to the concrete [`App`] we enclose. + /// + /// This will panic if your app does not implement [`App::as_any_mut`], + /// and return `None` if this runner has panicked. + pub fn app_mut( + &self, + ) -> Option> { + self.try_lock() + .map(|lock| std::cell::RefMut::map(lock, |runner| runner.app_mut::())) + } + + /// Convenience function to reduce boilerplate and ensure that all event handlers + /// are dealt with in the same way. + /// + /// All events added with this method will automatically be unsubscribed on panic, + /// or when [`Self::destroy`] is called. + pub fn add_event_listener( + &self, + target: &web_sys::EventTarget, + event_name: &'static str, + mut closure: impl FnMut(E, &mut AppRunner) + 'static, + ) -> Result<(), wasm_bindgen::JsValue> { + let runner_ref = self.clone(); + + // Create a JS closure based on the FnMut provided + let closure = Closure::wrap(Box::new(move |event: web_sys::Event| { + // Only call the wrapped closure if the egui code has not panicked + if let Some(mut runner_lock) = runner_ref.try_lock() { + // Cast the event to the expected event type + let event = event.unchecked_into::(); + closure(event, &mut runner_lock); + } + }) as Box); + + // Add the event listener to the target + target.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())?; + + let handle = TargetEvent { + target: target.clone(), + event_name: event_name.to_owned(), + closure, + }; + + // Remember it so we unsubscribe on panic. + // Otherwise we get calls into `self.runner` after it has been poisoned by a panic. + self.events_to_unsubscribe + .borrow_mut() + .push(EventToUnsubscribe::TargetEvent(handle)); + + Ok(()) + } +} + +// ---------------------------------------------------------------------------- + +struct TargetEvent { + target: web_sys::EventTarget, + event_name: String, + closure: Closure, +} + +#[allow(unused)] +struct IntervalHandle { + handle: i32, + closure: Closure, +} + +enum EventToUnsubscribe { + TargetEvent(TargetEvent), + + #[allow(unused)] + IntervalHandle(IntervalHandle), +} + +impl EventToUnsubscribe { + pub fn unsubscribe(self) -> Result<(), JsValue> { + match self { + EventToUnsubscribe::TargetEvent(handle) => { + handle.target.remove_event_listener_with_callback( + handle.event_name.as_str(), + handle.closure.as_ref().unchecked_ref(), + )?; + Ok(()) + } + EventToUnsubscribe::IntervalHandle(handle) => { + let window = web_sys::window().unwrap(); + window.clear_interval_with_handle(handle.handle); + Ok(()) + } + } + } +} diff --git a/crates/egui-wgpu/CHANGELOG.md b/crates/egui-wgpu/CHANGELOG.md new file mode 100644 index 00000000..6d8ba691 --- /dev/null +++ b/crates/egui-wgpu/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog for egui-wgpu +All notable changes to the `egui-wgpu` integration will be noted in this file. + + +This file is updated upon each release. +Changes since the last release can be found by running the `scripts/generate_changelog.py` script. + + +## 0.23.0 - 2023-09-27 +* Update to `wgpu` 0.17.0 [#3170](https://github.com/emilk/egui/pull/3170) (thanks [@Aaron1011](https://github.com/Aaron1011)!) +* Improved wgpu callbacks [#3253](https://github.com/emilk/egui/pull/3253) (thanks [@Wumpf](https://github.com/Wumpf)!) +* Fix depth texture init with multisampling [#3207](https://github.com/emilk/egui/pull/3207) (thanks [@mauliu](https://github.com/mauliu)!) +* Fix panic on wgpu GL backend due to new screenshot capability [#3078](https://github.com/emilk/egui/pull/3078) (thanks [@amfaber](https://github.com/amfaber)!) + + +## 0.22.0 - 2023-05-23 +* Update to wgpu 0.16 [#2884](https://github.com/emilk/egui/pull/2884) (thanks [@niklaskorz](https://github.com/niklaskorz)!) +* Device configuration is now dependent on adapter [#2951](https://github.com/emilk/egui/pull/2951) (thanks [@Wumpf](https://github.com/Wumpf)!) +* Expose `wgpu::Adapter` via `RenderState` [#2954](https://github.com/emilk/egui/pull/2954) (thanks [@Wumpf](https://github.com/Wumpf)!) +* Add `read_screen_rgba` to the egui-wgpu `Painter`, to allow for capturing the current frame when using wgpu. Used in conjunction with `Frame::request_screenshot` [#2676](https://github.com/emilk/egui/pull/2676) +* Improve performance of `update_buffers` [#2820](https://github.com/emilk/egui/pull/2820) (thanks [@Wumpf](https://github.com/Wumpf)!) +* Added support for multisampling (MSAA) [#2878](https://github.com/emilk/egui/pull/2878) (thanks [@PPakalns](https://github.com/PPakalns)!) + + +## 0.21.0 - 2023-02-08 +* Update to `wgpu` 0.15 ([#2629](https://github.com/emilk/egui/pull/2629)) +* Return `Err` instead of panic if we can't find a device ([#2428](https://github.com/emilk/egui/pull/2428)). +* `winit::Painter::set_window` is now `async` ([#2434](https://github.com/emilk/egui/pull/2434)). +* `egui-wgpu` now only depends on `epaint` instead of the entire `egui` ([#2438](https://github.com/emilk/egui/pull/2438)). +* `winit::Painter` now supports transparent backbuffer ([#2684](https://github.com/emilk/egui/pull/2684)). + + +## 0.20.0 - 2022-12-08 - web support +* Renamed `RenderPass` to `Renderer`. +* Renamed `RenderPass::execute` to `RenderPass::render`. +* Renamed `RenderPass::execute_with_renderpass` to `Renderer::render` (replacing existing `Renderer::render`) +* Reexported `Renderer`. +* You can now use `egui-wgpu` on web, using WebGL ([#2107](https://github.com/emilk/egui/pull/2107)). +* `Renderer` no longer handles pass creation and depth buffer creation ([#2136](https://github.com/emilk/egui/pull/2136)) +* `PrepareCallback` now passes `wgpu::CommandEncoder` ([#2136](https://github.com/emilk/egui/pull/2136)) +* `PrepareCallback` can now returns `wgpu::CommandBuffer` that are bundled into a single `wgpu::Queue::submit` call ([#2230](https://github.com/emilk/egui/pull/2230)) +* Only a single vertex & index buffer is now created and resized when necessary (previously, vertex/index buffers were allocated for every mesh) ([#2148](https://github.com/emilk/egui/pull/2148)). +* `Renderer::update_texture` no longer creates a new `wgpu::Sampler` with every new texture ([#2198](https://github.com/emilk/egui/pull/2198)) +* `Painter`'s instance/device/adapter/surface creation is now configurable via `WgpuConfiguration` ([#2207](https://github.com/emilk/egui/pull/2207)) +* Fix panic on using a depth buffer ([#2316](https://github.com/emilk/egui/pull/2316)) + + +## 0.19.0 - 2022-08-20 +* Enables deferred render + surface state initialization for Android ([#1634](https://github.com/emilk/egui/pull/1634)). +* Make `RenderPass` `Send` and `Sync` ([#1883](https://github.com/emilk/egui/pull/1883)). + + +## 0.18.0 - 2022-05-15 +First published version since moving the code into the `egui` repository from . diff --git a/crates/egui-wgpu/Cargo.toml b/crates/egui-wgpu/Cargo.toml new file mode 100644 index 00000000..e800452b --- /dev/null +++ b/crates/egui-wgpu/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "luminol-egui-wgpu" +version = "0.23.0" +description = "Bindings for using egui natively using the wgpu library" +authors = [ + "Nils Hasenbanck ", + "embotech ", + "Emil Ernerfeldt ", +] +edition.workspace = true +rust-version.workspace = true +homepage = "https://github.com/emilk/egui/tree/master/crates/egui-wgpu" +license.workspace = true +readme = "README.md" +repository = "https://github.com/emilk/egui/tree/master/crates/egui-wgpu" +categories = ["gui", "game-development"] +keywords = ["wgpu", "egui", "gui", "gamedev"] +include = [ + "LICENSE-APACHE", + "LICENSE-MIT", + "**/*.rs", + "**/*.wgsl", + "Cargo.toml", +] + +[package.metadata.docs.rs] +all-features = true + + +[features] +## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate. +puffin = ["dep:puffin"] + +## Enable [`winit`](https://docs.rs/winit) integration. +winit = ["dep:winit"] + + +[dependencies] +epaint = { workspace = true, features = [ + "bytemuck", +] } + +bytemuck = "1.7" +log = { version = "0.4", features = ["std"] } +thiserror.workspace = true +type-map = "0.5.0" +wgpu.workspace = true + +#! ### Optional dependencies +## Enable this when generating docs. +document-features = { version = "0.2", optional = true } + +winit = { version = "0.28", default-features = false, optional = true } + +# Native: +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +puffin = { version = "0.16", optional = true } diff --git a/crates/egui-wgpu/LICENSE-APACHE b/crates/egui-wgpu/LICENSE-APACHE new file mode 100644 index 00000000..11069edd --- /dev/null +++ b/crates/egui-wgpu/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/crates/egui-wgpu/LICENSE-MIT b/crates/egui-wgpu/LICENSE-MIT new file mode 100644 index 00000000..673ea5f0 --- /dev/null +++ b/crates/egui-wgpu/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2018-2021 Emil Ernerfeldt + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/crates/egui-wgpu/README.md b/crates/egui-wgpu/README.md new file mode 100644 index 00000000..caa31bec --- /dev/null +++ b/crates/egui-wgpu/README.md @@ -0,0 +1,35 @@ +> [!IMPORTANT] +> luminol-egui-wgpu is currently based on emilk/egui@0.23.0 + +> [!NOTE] +> This is Luminol's modified version of egui-wgpu. The original version is dual-licensed under MIT and Apache 2.0. +> +> To merge changes from upstream into this crate, first add egui as a remote: +> +> ``` +> git remote add -f --no-tags egui https://github.com/emilk/egui +> ``` +> +> Now, decide on which upstream egui commit you want to merge from and figure out the egui commit that the previous upstream merge was based on. The basis of the previous upstream merge should be written at the top of this README. **Please update the top of this README after merging.** +> +> In this example, we are merging from commit `bd087ffb8d7467e0b5aa06d17dd600d511d6a5e8` (egui 0.24.0) and the previous merge was based on commit `5a0186fa2b2324ab437099e456e55e281234ca99` (egui 0.23.0). +> +> ``` +> git diff \ +> 5a0186fa2b2324ab437099e456e55e281234ca99:crates/egui-wgpu \ +> bd087ffb8d7467e0b5aa06d17dd600d511d6a5e8:crates/egui-wgpu | +> git apply -3 --directory=crates/egui-wgpu +> ``` +> +> Fix any merge conflicts, and then do `git commit`. + +# egui-wgpu + +[![Latest version](https://img.shields.io/crates/v/egui-wgpu.svg)](https://crates.io/crates/egui-wgpu) +[![Documentation](https://docs.rs/egui-wgpu/badge.svg)](https://docs.rs/egui-wgpu) +![MIT](https://img.shields.io/badge/license-MIT-blue.svg) +![Apache](https://img.shields.io/badge/license-Apache-blue.svg) + +This crates provides bindings between [`egui`](https://github.com/emilk/egui) and [wgpu](https://crates.io/crates/wgpu). + +This was originally hosted at https://github.com/hasenbanck/egui_wgpu_backend diff --git a/crates/egui-wgpu/src/egui.wgsl b/crates/egui-wgpu/src/egui.wgsl new file mode 100644 index 00000000..552bcbbf --- /dev/null +++ b/crates/egui-wgpu/src/egui.wgsl @@ -0,0 +1,91 @@ +// Vertex shader bindings + +struct VertexOutput { + @location(0) tex_coord: vec2, + @location(1) color: vec4, // gamma 0-1 + @builtin(position) position: vec4, +}; + +struct Locals { + screen_size: vec2, + // Uniform buffers need to be at least 16 bytes in WebGL. + // See https://github.com/gfx-rs/wgpu/issues/2072 + _padding: vec2, +}; +@group(0) @binding(0) var r_locals: Locals; + +// 0-1 linear from 0-1 sRGB gamma +fn linear_from_gamma_rgb(srgb: vec3) -> vec3 { + let cutoff = srgb < vec3(0.04045); + let lower = srgb / vec3(12.92); + let higher = pow((srgb + vec3(0.055)) / vec3(1.055), vec3(2.4)); + return select(higher, lower, cutoff); +} + +// 0-1 sRGB gamma from 0-1 linear +fn gamma_from_linear_rgb(rgb: vec3) -> vec3 { + let cutoff = rgb < vec3(0.0031308); + let lower = rgb * vec3(12.92); + let higher = vec3(1.055) * pow(rgb, vec3(1.0 / 2.4)) - vec3(0.055); + return select(higher, lower, cutoff); +} + +// 0-1 sRGBA gamma from 0-1 linear +fn gamma_from_linear_rgba(linear_rgba: vec4) -> vec4 { + return vec4(gamma_from_linear_rgb(linear_rgba.rgb), linear_rgba.a); +} + +// [u8; 4] SRGB as u32 -> [r, g, b, a] in 0.-1 +fn unpack_color(color: u32) -> vec4 { + return vec4( + f32(color & 255u), + f32((color >> 8u) & 255u), + f32((color >> 16u) & 255u), + f32((color >> 24u) & 255u), + ) / 255.0; +} + +fn position_from_screen(screen_pos: vec2) -> vec4 { + return vec4( + 2.0 * screen_pos.x / r_locals.screen_size.x - 1.0, + 1.0 - 2.0 * screen_pos.y / r_locals.screen_size.y, + 0.0, + 1.0, + ); +} + +@vertex +fn vs_main( + @location(0) a_pos: vec2, + @location(1) a_tex_coord: vec2, + @location(2) a_color: u32, +) -> VertexOutput { + var out: VertexOutput; + out.tex_coord = a_tex_coord; + out.color = unpack_color(a_color); + out.position = position_from_screen(a_pos); + return out; +} + +// Fragment shader bindings + +@group(1) @binding(0) var r_tex_color: texture_2d; +@group(1) @binding(1) var r_tex_sampler: sampler; + +@fragment +fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4 { + // We always have an sRGB aware texture at the moment. + let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); + let tex_gamma = gamma_from_linear_rgba(tex_linear); + let out_color_gamma = in.color * tex_gamma; + return vec4(linear_from_gamma_rgb(out_color_gamma.rgb), out_color_gamma.a); +} + +@fragment +fn fs_main_gamma_framebuffer(in: VertexOutput) -> @location(0) vec4 { + // We always have an sRGB aware texture at the moment. + let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); + let tex_gamma = gamma_from_linear_rgba(tex_linear); + let out_color_gamma = in.color * tex_gamma; + return out_color_gamma; +} diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs new file mode 100644 index 00000000..a46e185e --- /dev/null +++ b/crates/egui-wgpu/src/lib.rs @@ -0,0 +1,242 @@ +//! This crates provides bindings between [`egui`](https://github.com/emilk/egui) and [wgpu](https://crates.io/crates/wgpu). +//! +//! ## Feature flags +#![cfg_attr(feature = "document-features", doc = document_features::document_features!())] +//! + +#![allow(unsafe_code)] + +pub use wgpu; + +/// Low-level painting of [`egui`](https://github.com/emilk/egui) on [`wgpu`]. +pub mod renderer; +pub use renderer::Renderer; +pub use renderer::{Callback, CallbackResources, CallbackTrait}; + +/// Module for painting [`egui`](https://github.com/emilk/egui) with [`wgpu`] on [`winit`]. +#[cfg(feature = "winit")] +pub mod winit; + +use std::sync::Arc; + +use epaint::mutex::RwLock; + +#[derive(thiserror::Error, Debug)] +pub enum WgpuError { + #[error("Failed to create wgpu adapter, no suitable adapter found.")] + NoSuitableAdapterFound, + + #[error("There was no valid format for the surface at all.")] + NoSurfaceFormatsAvailable, + + #[error(transparent)] + RequestDeviceError(#[from] wgpu::RequestDeviceError), + + #[error(transparent)] + CreateSurfaceError(#[from] wgpu::CreateSurfaceError), +} + +/// Access to the render state for egui. +#[derive(Clone)] +pub struct RenderState { + /// Wgpu adapter used for rendering. + pub adapter: Arc, + + /// Wgpu device used for rendering, created from the adapter. + pub device: Arc, + + /// Wgpu queue used for rendering, created from the adapter. + pub queue: Arc, + + /// The target texture format used for presenting to the window. + pub target_format: wgpu::TextureFormat, + + /// Egui renderer responsible for drawing the UI. + pub renderer: Arc>, +} + +impl RenderState { + /// Creates a new `RenderState`, containing everything needed for drawing egui with wgpu. + /// + /// # Errors + /// Wgpu initialization may fail due to incompatible hardware or driver for a given config. + pub async fn create( + config: &WgpuConfiguration, + instance: &wgpu::Instance, + surface: &wgpu::Surface, + depth_format: Option, + msaa_samples: u32, + ) -> Result { + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: config.power_preference, + compatible_surface: Some(surface), + force_fallback_adapter: false, + }) + .await + .ok_or(WgpuError::NoSuitableAdapterFound)?; + + let target_format = + crate::preferred_framebuffer_format(&surface.get_capabilities(&adapter).formats)?; + + let (device, queue) = adapter + .request_device(&(*config.device_descriptor)(&adapter), None) + .await?; + + let renderer = Renderer::new(&device, target_format, depth_format, msaa_samples); + + Ok(RenderState { + adapter: Arc::new(adapter), + device: Arc::new(device), + queue: Arc::new(queue), + target_format, + renderer: Arc::new(RwLock::new(renderer)), + }) + } +} + +/// Specifies which action should be taken as consequence of a [`wgpu::SurfaceError`] +pub enum SurfaceErrorAction { + /// Do nothing and skip the current frame. + SkipFrame, + + /// Instructs egui to recreate the surface, then skip the current frame. + RecreateSurface, +} + +/// Configuration for using wgpu with eframe or the egui-wgpu winit feature. +#[derive(Clone)] +pub struct WgpuConfiguration { + /// Backends that should be supported (wgpu will pick one of these) + pub supported_backends: wgpu::Backends, + + /// Configuration passed on device request, given an adapter + pub device_descriptor: Arc wgpu::DeviceDescriptor<'static>>, + + /// Present mode used for the primary surface. + pub present_mode: wgpu::PresentMode, + + /// Power preference for the adapter. + pub power_preference: wgpu::PowerPreference, + + /// Callback for surface errors. + pub on_surface_error: Arc SurfaceErrorAction>, +} + +impl std::fmt::Debug for WgpuConfiguration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WgpuConfiguration") + .field("supported_backends", &self.supported_backends) + .field("present_mode", &self.present_mode) + .field("power_preference", &self.power_preference) + .finish_non_exhaustive() + } +} + +impl Default for WgpuConfiguration { + fn default() -> Self { + Self { + // Add GL backend, primarily because WebGPU is not stable enough yet. + // (note however, that the GL backend needs to be opted-in via a wgpu feature flag) + supported_backends: wgpu::util::backend_bits_from_env() + .unwrap_or(wgpu::Backends::PRIMARY | wgpu::Backends::GL), + device_descriptor: Arc::new(|adapter| { + let base_limits = if adapter.get_info().backend == wgpu::Backend::Gl { + wgpu::Limits::downlevel_webgl2_defaults() + } else { + wgpu::Limits::default() + }; + + wgpu::DeviceDescriptor { + label: Some("egui wgpu device"), + features: wgpu::Features::default(), + limits: wgpu::Limits { + // When using a depth buffer, we have to be able to create a texture + // large enough for the entire surface, and we want to support 4k+ displays. + max_texture_dimension_2d: 8192, + ..base_limits + }, + } + }), + present_mode: wgpu::PresentMode::AutoVsync, + power_preference: wgpu::util::power_preference_from_env() + .unwrap_or(wgpu::PowerPreference::HighPerformance), + + on_surface_error: Arc::new(|err| { + if err == wgpu::SurfaceError::Outdated { + // This error occurs when the app is minimized on Windows. + // Silently return here to prevent spamming the console with: + // "The underlying surface has changed, and therefore the swap chain must be updated" + } else { + log::warn!("Dropped frame with error: {err}"); + } + SurfaceErrorAction::SkipFrame + }), + } + } +} + +/// Find the framebuffer format that egui prefers +/// +/// # Errors +/// Returns [`WgpuError::NoSurfaceFormatsAvailable`] if the given list of formats is empty. +pub fn preferred_framebuffer_format( + formats: &[wgpu::TextureFormat], +) -> Result { + for &format in formats { + if matches!( + format, + wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Bgra8Unorm + ) { + return Ok(format); + } + } + + formats + .get(0) + .copied() + .ok_or(WgpuError::NoSurfaceFormatsAvailable) +} + +/// Take's epi's depth/stencil bits and returns the corresponding wgpu format. +pub fn depth_format_from_bits(depth_buffer: u8, stencil_buffer: u8) -> Option { + match (depth_buffer, stencil_buffer) { + (0, 8) => Some(wgpu::TextureFormat::Stencil8), + (16, 0) => Some(wgpu::TextureFormat::Depth16Unorm), + (24, 0) => Some(wgpu::TextureFormat::Depth24Plus), + (24, 8) => Some(wgpu::TextureFormat::Depth24PlusStencil8), + (32, 0) => Some(wgpu::TextureFormat::Depth32Float), + (32, 8) => Some(wgpu::TextureFormat::Depth32FloatStencil8), + _ => None, + } +} + +// --------------------------------------------------------------------------- + +mod profiling_scopes { + #![allow(unused_macros)] + #![allow(unused_imports)] + + /// Profiling macro for feature "puffin" + macro_rules! profile_function { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_function!($($arg)*); + }; + } + pub(crate) use profile_function; + + /// Profiling macro for feature "puffin" + macro_rules! profile_scope { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_scope!($($arg)*); + }; + } + pub(crate) use profile_scope; +} + +#[allow(unused_imports)] +pub(crate) use profiling_scopes::*; diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs new file mode 100644 index 00000000..62d96983 --- /dev/null +++ b/crates/egui-wgpu/src/renderer.rs @@ -0,0 +1,983 @@ +#![allow(unsafe_code)] + +use std::{borrow::Cow, num::NonZeroU64, ops::Range}; + +use epaint::{ahash::HashMap, emath::NumExt, PaintCallbackInfo, Primitive, Vertex}; + +use wgpu; +use wgpu::util::DeviceExt as _; + +// Only implements Send + Sync on wasm32 in order to allow storing wgpu resources on the type map. +#[cfg(not(target_arch = "wasm32"))] +pub type CallbackResources = type_map::concurrent::TypeMap; +#[cfg(target_arch = "wasm32")] +pub type CallbackResources = type_map::TypeMap; + +pub struct Callback(Box); + +impl Callback { + /// Creates a new [`epaint::PaintCallback`] from a callback trait instance. + pub fn new_paint_callback( + rect: epaint::emath::Rect, + callback: impl CallbackTrait + 'static, + ) -> epaint::PaintCallback { + epaint::PaintCallback { + rect, + callback: std::sync::Arc::new(Self(Box::new(callback))), + } + } +} + +/// A callback trait that can be used to compose an [`epaint::PaintCallback`] via [`Callback`] +/// for custom WGPU rendering. +/// +/// Callbacks in [`Renderer`] are done in three steps: +/// * [`CallbackTrait::prepare`]: called for all registered callbacks before the main egui render pass. +/// * [`CallbackTrait::finish_prepare`]: called for all registered callbacks after all callbacks finished calling prepare. +/// * [`CallbackTrait::paint`]: called for all registered callbacks during the main egui render pass. +/// +/// Each callback has access to an instance of [`CallbackResources`] that is stored in the [`Renderer`]. +/// This can be used to store wgpu resources that need to be accessed during the [`CallbackTrait::paint`] step. +/// +/// The callbacks implementing [`CallbackTrait`] itself must always be Send + Sync, but resources stored in +/// [`Renderer::callback_resources`] are not required to implement Send + Sync when building for wasm. +/// (this is because wgpu stores references to the JS heap in most of its resources which can not be shared with other threads). +/// +/// +/// # Command submission +/// +/// ## Command Encoder +/// +/// The passed-in `CommandEncoder` is egui's and can be used directly to register +/// wgpu commands for simple use cases. +/// This allows reusing the same [`wgpu::CommandEncoder`] for all callbacks and egui +/// rendering itself. +/// +/// ## Command Buffers +/// +/// For more complicated use cases, one can also return a list of arbitrary +/// `CommandBuffer`s and have complete control over how they get created and fed. +/// In particular, this gives an opportunity to parallelize command registration and +/// prevents a faulty callback from poisoning the main wgpu pipeline. +/// +/// When using eframe, the main egui command buffer, as well as all user-defined +/// command buffers returned by this function, are guaranteed to all be submitted +/// at once in a single call. +/// +/// Command Buffers returned by [`CallbackTrait::finish_prepare`] will always be issued *after* +/// those returned by [`CallbackTrait::prepare`]. +/// Order within command buffers returned by [`CallbackTrait::prepare`] is dependent +/// on the order the respective [`epaint::Shape::Callback`]s were submitted in. +/// +/// # Example +/// +/// See the [`custom3d_wgpu`](https://github.com/emilk/egui/blob/master/crates/egui_demo_app/src/apps/custom3d_wgpu.rs) demo source for a detailed usage example. +pub trait CallbackTrait: Send + Sync { + fn prepare( + &self, + _device: &wgpu::Device, + _queue: &wgpu::Queue, + _egui_encoder: &mut wgpu::CommandEncoder, + _callback_resources: &mut CallbackResources, + ) -> Vec { + Vec::new() + } + + /// Called after all [`CallbackTrait::prepare`] calls are done. + fn finish_prepare( + &self, + _device: &wgpu::Device, + _queue: &wgpu::Queue, + _egui_encoder: &mut wgpu::CommandEncoder, + _callback_resources: &mut CallbackResources, + ) -> Vec { + Vec::new() + } + + /// Called after all [`CallbackTrait::finish_prepare`] calls are done. + /// + /// It is given access to the [`wgpu::RenderPass`] so that it can issue draw commands + /// into the same [`wgpu::RenderPass`] that is used for all other egui elements. + fn paint<'a>( + &'a self, + info: PaintCallbackInfo, + render_pass: &mut wgpu::RenderPass<'a>, + callback_resources: &'a CallbackResources, + ); +} + +/// Information about the screen used for rendering. +pub struct ScreenDescriptor { + /// Size of the window in physical pixels. + pub size_in_pixels: [u32; 2], + + /// HiDPI scale factor (pixels per point). + pub pixels_per_point: f32, +} + +impl ScreenDescriptor { + /// size in "logical" points + fn screen_size_in_points(&self) -> [f32; 2] { + [ + self.size_in_pixels[0] as f32 / self.pixels_per_point, + self.size_in_pixels[1] as f32 / self.pixels_per_point, + ] + } +} + +/// Uniform buffer used when rendering. +#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +struct UniformBuffer { + screen_size_in_points: [f32; 2], + // Uniform buffers need to be at least 16 bytes in WebGL. + // See https://github.com/gfx-rs/wgpu/issues/2072 + _padding: [u32; 2], +} + +impl PartialEq for UniformBuffer { + fn eq(&self, other: &Self) -> bool { + self.screen_size_in_points == other.screen_size_in_points + } +} + +struct SlicedBuffer { + buffer: wgpu::Buffer, + slices: Vec>, + capacity: wgpu::BufferAddress, +} + +/// Renderer for a egui based GUI. +pub struct Renderer { + pipeline: wgpu::RenderPipeline, + + index_buffer: SlicedBuffer, + vertex_buffer: SlicedBuffer, + + uniform_buffer: wgpu::Buffer, + previous_uniform_buffer_content: UniformBuffer, + uniform_bind_group: wgpu::BindGroup, + texture_bind_group_layout: wgpu::BindGroupLayout, + + /// Map of egui texture IDs to textures and their associated bindgroups (texture view + + /// sampler). The texture may be None if the TextureId is just a handle to a user-provided + /// sampler. + textures: HashMap, wgpu::BindGroup)>, + next_user_texture_id: u64, + samplers: HashMap, + + /// Storage for resources shared with all invocations of [`CallbackTrait`]'s methods. + /// + /// See also [`CallbackTrait`]. + pub callback_resources: CallbackResources, +} + +impl Renderer { + /// Creates a renderer for a egui UI. + /// + /// `output_color_format` should preferably be [`wgpu::TextureFormat::Rgba8Unorm`] or + /// [`wgpu::TextureFormat::Bgra8Unorm`], i.e. in gamma-space. + pub fn new( + device: &wgpu::Device, + output_color_format: wgpu::TextureFormat, + output_depth_format: Option, + msaa_samples: u32, + ) -> Self { + crate::profile_function!(); + + let shader = wgpu::ShaderModuleDescriptor { + label: Some("egui"), + source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("egui.wgsl"))), + }; + let module = device.create_shader_module(shader); + + let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("egui_uniform_buffer"), + contents: bytemuck::cast_slice(&[UniformBuffer { + screen_size_in_points: [0.0, 0.0], + _padding: Default::default(), + }]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + let uniform_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("egui_uniform_bind_group_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(std::mem::size_of::() as _), + ty: wgpu::BufferBindingType::Uniform, + }, + count: None, + }], + }); + + let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("egui_uniform_bind_group"), + layout: &uniform_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &uniform_buffer, + offset: 0, + size: None, + }), + }], + }); + + let texture_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("egui_texture_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("egui_pipeline_layout"), + bind_group_layouts: &[&uniform_bind_group_layout, &texture_bind_group_layout], + push_constant_ranges: &[], + }); + + let depth_stencil = output_depth_format.map(|format| wgpu::DepthStencilState { + format, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::Always, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("egui_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + entry_point: "vs_main", + module: &module, + buffers: &[wgpu::VertexBufferLayout { + array_stride: 5 * 4, + step_mode: wgpu::VertexStepMode::Vertex, + // 0: vec2 position + // 1: vec2 texture coordinates + // 2: uint color + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32], + }], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + unclipped_depth: false, + conservative: false, + cull_mode: None, + front_face: wgpu::FrontFace::default(), + polygon_mode: wgpu::PolygonMode::default(), + strip_index_format: None, + }, + depth_stencil, + multisample: wgpu::MultisampleState { + alpha_to_coverage_enabled: false, + count: msaa_samples, + mask: !0, + }, + + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: if output_color_format.is_srgb() { + log::warn!("Detected a linear (sRGBA aware) framebuffer {:?}. egui prefers Rgba8Unorm or Bgra8Unorm", output_color_format); + "fs_main_linear_framebuffer" + } else { + "fs_main_gamma_framebuffer" // this is what we prefer + }, + targets: &[Some(wgpu::ColorTargetState { + format: output_color_format, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::OneMinusDstAlpha, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + }); + + const VERTEX_BUFFER_START_CAPACITY: wgpu::BufferAddress = + (std::mem::size_of::() * 1024) as _; + const INDEX_BUFFER_START_CAPACITY: wgpu::BufferAddress = + (std::mem::size_of::() * 1024 * 3) as _; + + Self { + pipeline, + vertex_buffer: SlicedBuffer { + buffer: create_vertex_buffer(device, VERTEX_BUFFER_START_CAPACITY), + slices: Vec::with_capacity(64), + capacity: VERTEX_BUFFER_START_CAPACITY, + }, + index_buffer: SlicedBuffer { + buffer: create_index_buffer(device, INDEX_BUFFER_START_CAPACITY), + slices: Vec::with_capacity(64), + capacity: INDEX_BUFFER_START_CAPACITY, + }, + uniform_buffer, + // Buffers on wgpu are zero initialized, so this is indeed its current state! + previous_uniform_buffer_content: UniformBuffer { + screen_size_in_points: [0.0, 0.0], + _padding: [0, 0], + }, + uniform_bind_group, + texture_bind_group_layout, + textures: HashMap::default(), + next_user_texture_id: 0, + samplers: HashMap::default(), + callback_resources: CallbackResources::default(), + } + } + + /// Executes the egui renderer onto an existing wgpu renderpass. + pub fn render<'rp>( + &'rp self, + render_pass: &mut wgpu::RenderPass<'rp>, + paint_jobs: &'rp [epaint::ClippedPrimitive], + screen_descriptor: &ScreenDescriptor, + ) { + crate::profile_function!(); + + let pixels_per_point = screen_descriptor.pixels_per_point; + let size_in_pixels = screen_descriptor.size_in_pixels; + + // Whether or not we need to reset the render pass because a paint callback has just + // run. + let mut needs_reset = true; + + let mut index_buffer_slices = self.index_buffer.slices.iter(); + let mut vertex_buffer_slices = self.vertex_buffer.slices.iter(); + + for epaint::ClippedPrimitive { + clip_rect, + primitive, + } in paint_jobs + { + if needs_reset { + render_pass.set_viewport( + 0.0, + 0.0, + size_in_pixels[0] as f32, + size_in_pixels[1] as f32, + 0.0, + 1.0, + ); + render_pass.set_pipeline(&self.pipeline); + render_pass.set_bind_group(0, &self.uniform_bind_group, &[]); + needs_reset = false; + } + + { + let rect = ScissorRect::new(clip_rect, pixels_per_point, size_in_pixels); + + if rect.width == 0 || rect.height == 0 { + // Skip rendering zero-sized clip areas. + if let Primitive::Mesh(_) = primitive { + // If this is a mesh, we need to advance the index and vertex buffer iterators: + index_buffer_slices.next().unwrap(); + vertex_buffer_slices.next().unwrap(); + } + continue; + } + + render_pass.set_scissor_rect(rect.x, rect.y, rect.width, rect.height); + } + + match primitive { + Primitive::Mesh(mesh) => { + let index_buffer_slice = index_buffer_slices.next().unwrap(); + let vertex_buffer_slice = vertex_buffer_slices.next().unwrap(); + + if let Some((_texture, bind_group)) = self.textures.get(&mesh.texture_id) { + render_pass.set_bind_group(1, bind_group, &[]); + render_pass.set_index_buffer( + self.index_buffer.buffer.slice( + index_buffer_slice.start as u64..index_buffer_slice.end as u64, + ), + wgpu::IndexFormat::Uint32, + ); + render_pass.set_vertex_buffer( + 0, + self.vertex_buffer.buffer.slice( + vertex_buffer_slice.start as u64..vertex_buffer_slice.end as u64, + ), + ); + render_pass.draw_indexed(0..mesh.indices.len() as u32, 0, 0..1); + } else { + log::warn!("Missing texture: {:?}", mesh.texture_id); + } + } + Primitive::Callback(callback) => { + let Some(cbfn) = callback.callback.downcast_ref::() else { + // We already warned in the `prepare` callback + continue; + }; + + if callback.rect.is_positive() { + crate::profile_scope!("callback"); + + needs_reset = true; + + let info = PaintCallbackInfo { + viewport: callback.rect, + clip_rect: *clip_rect, + pixels_per_point, + screen_size_px: size_in_pixels, + }; + + { + // We're setting a default viewport for the render pass as a + // courtesy for the user, so that they don't have to think about + // it in the simple case where they just want to fill the whole + // paint area. + // + // The user still has the possibility of setting their own custom + // viewport during the paint callback, effectively overriding this + // one. + + let viewport_px = info.viewport_in_pixels(); + + render_pass.set_viewport( + viewport_px.left_px, + viewport_px.top_px, + viewport_px.width_px, + viewport_px.height_px, + 0.0, + 1.0, + ); + } + + cbfn.0.paint(info, render_pass, &self.callback_resources); + } + } + } + } + + render_pass.set_scissor_rect(0, 0, size_in_pixels[0], size_in_pixels[1]); + } + + /// Should be called before `render()`. + pub fn update_texture( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + id: epaint::TextureId, + image_delta: &epaint::ImageDelta, + ) { + crate::profile_function!(); + + let width = image_delta.image.width() as u32; + let height = image_delta.image.height() as u32; + + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + + let data_color32 = match &image_delta.image { + epaint::ImageData::Color(image) => { + assert_eq!( + width as usize * height as usize, + image.pixels.len(), + "Mismatch between texture size and texel count" + ); + Cow::Borrowed(&image.pixels) + } + epaint::ImageData::Font(image) => { + assert_eq!( + width as usize * height as usize, + image.pixels.len(), + "Mismatch between texture size and texel count" + ); + Cow::Owned(image.srgba_pixels(None).collect::>()) + } + }; + let data_bytes: &[u8] = bytemuck::cast_slice(data_color32.as_slice()); + + let queue_write_data_to_texture = |texture, origin| { + queue.write_texture( + wgpu::ImageCopyTexture { + texture, + mip_level: 0, + origin, + aspect: wgpu::TextureAspect::All, + }, + data_bytes, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(4 * width), + rows_per_image: Some(height), + }, + size, + ); + }; + + if let Some(pos) = image_delta.pos { + // update the existing texture + let (texture, _bind_group) = self + .textures + .get(&id) + .expect("Tried to update a texture that has not been allocated yet."); + let origin = wgpu::Origin3d { + x: pos[0] as u32, + y: pos[1] as u32, + z: 0, + }; + queue_write_data_to_texture( + texture.as_ref().expect("Tried to update user texture."), + origin, + ); + } else { + // allocate a new texture + // Use same label for all resources associated with this texture id (no point in retyping the type) + let label_str = format!("egui_texid_{id:?}"); + let label = Some(label_str.as_str()); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label, + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, // Minspec for wgpu WebGL emulation is WebGL2, so this should always be supported. + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb], + }); + let sampler = self + .samplers + .entry(image_delta.options) + .or_insert_with(|| create_sampler(image_delta.options, device)); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label, + layout: &self.texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &texture.create_view(&wgpu::TextureViewDescriptor::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }); + let origin = wgpu::Origin3d::ZERO; + queue_write_data_to_texture(&texture, origin); + self.textures.insert(id, (Some(texture), bind_group)); + }; + } + + pub fn free_texture(&mut self, id: &epaint::TextureId) { + self.textures.remove(id); + } + + /// Get the WGPU texture and bind group associated to a texture that has been allocated by egui. + /// + /// This could be used by custom paint hooks to render images that have been added through + /// [`epaint::Context::load_texture`](https://docs.rs/egui/latest/egui/struct.Context.html#method.load_texture). + pub fn texture( + &self, + id: &epaint::TextureId, + ) -> Option<&(Option, wgpu::BindGroup)> { + self.textures.get(id) + } + + /// Registers a `wgpu::Texture` with a `epaint::TextureId`. + /// + /// This enables the application to reference the texture inside an image ui element. + /// This effectively enables off-screen rendering inside the egui UI. Texture must have + /// the texture format `TextureFormat::Rgba8UnormSrgb` and + /// Texture usage `TextureUsage::SAMPLED`. + pub fn register_native_texture( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + texture_filter: wgpu::FilterMode, + ) -> epaint::TextureId { + self.register_native_texture_with_sampler_options( + device, + texture, + wgpu::SamplerDescriptor { + label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()), + mag_filter: texture_filter, + min_filter: texture_filter, + ..Default::default() + }, + ) + } + + /// Registers a `wgpu::Texture` with an existing `epaint::TextureId`. + /// + /// This enables applications to reuse `TextureId`s. + pub fn update_egui_texture_from_wgpu_texture( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + texture_filter: wgpu::FilterMode, + id: epaint::TextureId, + ) { + self.update_egui_texture_from_wgpu_texture_with_sampler_options( + device, + texture, + wgpu::SamplerDescriptor { + label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()), + mag_filter: texture_filter, + min_filter: texture_filter, + ..Default::default() + }, + id, + ); + } + + /// Registers a `wgpu::Texture` with a `epaint::TextureId` while also accepting custom + /// `wgpu::SamplerDescriptor` options. + /// + /// This allows applications to specify individual minification/magnification filters as well as + /// custom mipmap and tiling options. + /// + /// The `Texture` must have the format `TextureFormat::Rgba8UnormSrgb` and usage + /// `TextureUsage::SAMPLED`. Any compare function supplied in the `SamplerDescriptor` will be + /// ignored. + #[allow(clippy::needless_pass_by_value)] // false positive + pub fn register_native_texture_with_sampler_options( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + sampler_descriptor: wgpu::SamplerDescriptor<'_>, + ) -> epaint::TextureId { + crate::profile_function!(); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + compare: None, + ..sampler_descriptor + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()), + layout: &self.texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(texture), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + let id = epaint::TextureId::User(self.next_user_texture_id); + self.textures.insert(id, (None, bind_group)); + self.next_user_texture_id += 1; + + id + } + + /// Registers a `wgpu::Texture` with an existing `epaint::TextureId` while also accepting custom + /// `wgpu::SamplerDescriptor` options. + /// + /// This allows applications to reuse `TextureId`s created with custom sampler options. + #[allow(clippy::needless_pass_by_value)] // false positive + pub fn update_egui_texture_from_wgpu_texture_with_sampler_options( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + sampler_descriptor: wgpu::SamplerDescriptor<'_>, + id: epaint::TextureId, + ) { + crate::profile_function!(); + + let (_user_texture, user_texture_binding) = self + .textures + .get_mut(&id) + .expect("Tried to update a texture that has not been allocated yet."); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + compare: None, + ..sampler_descriptor + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()), + layout: &self.texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(texture), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + *user_texture_binding = bind_group; + } + + /// Uploads the uniform, vertex and index data used by the renderer. + /// Should be called before `render()`. + /// + /// Returns all user-defined command buffers gathered from [`CallbackTrait::prepare`] & [`CallbackTrait::finish_prepare`] callbacks. + pub fn update_buffers( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + paint_jobs: &[epaint::ClippedPrimitive], + screen_descriptor: &ScreenDescriptor, + ) -> Vec { + crate::profile_function!(); + + let screen_size_in_points = screen_descriptor.screen_size_in_points(); + + let uniform_buffer_content = UniformBuffer { + screen_size_in_points, + _padding: Default::default(), + }; + if uniform_buffer_content != self.previous_uniform_buffer_content { + crate::profile_scope!("update uniforms"); + queue.write_buffer( + &self.uniform_buffer, + 0, + bytemuck::cast_slice(&[uniform_buffer_content]), + ); + self.previous_uniform_buffer_content = uniform_buffer_content; + } + + // Determine how many vertices & indices need to be rendered, and gather prepare callbacks + let mut callbacks = Vec::new(); + let (vertex_count, index_count) = { + crate::profile_scope!("count_vertices_indices"); + paint_jobs.iter().fold((0, 0), |acc, clipped_primitive| { + match &clipped_primitive.primitive { + Primitive::Mesh(mesh) => { + (acc.0 + mesh.vertices.len(), acc.1 + mesh.indices.len()) + } + Primitive::Callback(callback) => { + if let Some(c) = callback.callback.downcast_ref::() { + callbacks.push(c.0.as_ref()); + } else { + log::warn!("Unknown paint callback: expected `egui_wgpu::Callback`"); + }; + acc + } + } + }) + }; + + if index_count > 0 { + crate::profile_scope!("indices"); + + self.index_buffer.slices.clear(); + let required_index_buffer_size = (std::mem::size_of::() * index_count) as u64; + if self.index_buffer.capacity < required_index_buffer_size { + // Resize index buffer if needed. + self.index_buffer.capacity = + (self.index_buffer.capacity * 2).at_least(required_index_buffer_size); + self.index_buffer.buffer = create_index_buffer(device, self.index_buffer.capacity); + } + + let mut index_buffer_staging = queue + .write_buffer_with( + &self.index_buffer.buffer, + 0, + NonZeroU64::new(required_index_buffer_size).unwrap(), + ) + .expect("Failed to create staging buffer for index data"); + let mut index_offset = 0; + for epaint::ClippedPrimitive { primitive, .. } in paint_jobs { + match primitive { + Primitive::Mesh(mesh) => { + let size = mesh.indices.len() * std::mem::size_of::(); + let slice = index_offset..(size + index_offset); + index_buffer_staging[slice.clone()] + .copy_from_slice(bytemuck::cast_slice(&mesh.indices)); + self.index_buffer.slices.push(slice); + index_offset += size; + } + Primitive::Callback(_) => {} + } + } + } + if vertex_count > 0 { + crate::profile_scope!("vertices"); + + self.vertex_buffer.slices.clear(); + let required_vertex_buffer_size = (std::mem::size_of::() * vertex_count) as u64; + if self.vertex_buffer.capacity < required_vertex_buffer_size { + // Resize vertex buffer if needed. + self.vertex_buffer.capacity = + (self.vertex_buffer.capacity * 2).at_least(required_vertex_buffer_size); + self.vertex_buffer.buffer = + create_vertex_buffer(device, self.vertex_buffer.capacity); + } + + let mut vertex_buffer_staging = queue + .write_buffer_with( + &self.vertex_buffer.buffer, + 0, + NonZeroU64::new(required_vertex_buffer_size).unwrap(), + ) + .expect("Failed to create staging buffer for vertex data"); + let mut vertex_offset = 0; + for epaint::ClippedPrimitive { primitive, .. } in paint_jobs { + match primitive { + Primitive::Mesh(mesh) => { + let size = mesh.vertices.len() * std::mem::size_of::(); + let slice = vertex_offset..(size + vertex_offset); + vertex_buffer_staging[slice.clone()] + .copy_from_slice(bytemuck::cast_slice(&mesh.vertices)); + self.vertex_buffer.slices.push(slice); + vertex_offset += size; + } + Primitive::Callback(_) => {} + } + } + } + + let mut user_cmd_bufs = Vec::new(); + { + crate::profile_scope!("prepare callbacks"); + for callback in &callbacks { + user_cmd_bufs.extend(callback.prepare( + device, + queue, + encoder, + &mut self.callback_resources, + )); + } + } + { + crate::profile_scope!("finish prepare callbacks"); + for callback in &callbacks { + user_cmd_bufs.extend(callback.finish_prepare( + device, + queue, + encoder, + &mut self.callback_resources, + )); + } + } + + user_cmd_bufs + } +} + +fn create_sampler( + options: epaint::textures::TextureOptions, + device: &wgpu::Device, +) -> wgpu::Sampler { + let mag_filter = match options.magnification { + epaint::textures::TextureFilter::Nearest => wgpu::FilterMode::Nearest, + epaint::textures::TextureFilter::Linear => wgpu::FilterMode::Linear, + }; + let min_filter = match options.minification { + epaint::textures::TextureFilter::Nearest => wgpu::FilterMode::Nearest, + epaint::textures::TextureFilter::Linear => wgpu::FilterMode::Linear, + }; + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some(&format!( + "egui sampler (mag: {mag_filter:?}, min {min_filter:?})" + )), + mag_filter, + min_filter, + ..Default::default() + }) +} + +fn create_vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer { + crate::profile_function!(); + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("egui_vertex_buffer"), + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + size, + mapped_at_creation: false, + }) +} + +fn create_index_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer { + crate::profile_function!(); + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("egui_index_buffer"), + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + size, + mapped_at_creation: false, + }) +} + +/// A Rect in physical pixel space, used for setting clipping rectangles. +struct ScissorRect { + x: u32, + y: u32, + width: u32, + height: u32, +} + +impl ScissorRect { + fn new(clip_rect: &epaint::Rect, pixels_per_point: f32, target_size: [u32; 2]) -> Self { + // Transform clip rect to physical pixels: + let clip_min_x = pixels_per_point * clip_rect.min.x; + let clip_min_y = pixels_per_point * clip_rect.min.y; + let clip_max_x = pixels_per_point * clip_rect.max.x; + let clip_max_y = pixels_per_point * clip_rect.max.y; + + // Round to integer: + let clip_min_x = clip_min_x.round() as u32; + let clip_min_y = clip_min_y.round() as u32; + let clip_max_x = clip_max_x.round() as u32; + let clip_max_y = clip_max_y.round() as u32; + + // Clamp: + let clip_min_x = clip_min_x.clamp(0, target_size[0]); + let clip_min_y = clip_min_y.clamp(0, target_size[1]); + let clip_max_x = clip_max_x.clamp(clip_min_x, target_size[0]); + let clip_max_y = clip_max_y.clamp(clip_min_y, target_size[1]); + + Self { + x: clip_min_x, + y: clip_min_y, + width: clip_max_x - clip_min_x, + height: clip_max_y - clip_min_y, + } + } +} + +// Wgpu objects contain references to the JS heap on the web, therefore they are not Send/Sync. +// It follows that egui_wgpu::Renderer can not be Send/Sync either when building with wasm. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn renderer_impl_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs new file mode 100644 index 00000000..91eb1a43 --- /dev/null +++ b/crates/egui-wgpu/src/winit.rs @@ -0,0 +1,602 @@ +use std::sync::Arc; + +use crate::{renderer, RenderState, SurfaceErrorAction, WgpuConfiguration}; + +struct SurfaceState { + surface: wgpu::Surface, + alpha_mode: wgpu::CompositeAlphaMode, + width: u32, + height: u32, + supports_screenshot: bool, +} + +/// A texture and a buffer for reading the rendered frame back to the cpu. +/// The texture is required since [`wgpu::TextureUsages::COPY_DST`] is not an allowed +/// flag for the surface texture on all platforms. This means that anytime we want to +/// capture the frame, we first render it to this texture, and then we can copy it to +/// both the surface texture and the buffer, from where we can pull it back to the cpu. +struct CaptureState { + texture: wgpu::Texture, + buffer: wgpu::Buffer, + padding: BufferPadding, +} + +impl CaptureState { + fn new(device: &Arc, surface_texture: &wgpu::Texture) -> Self { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("egui_screen_capture_texture"), + size: surface_texture.size(), + mip_level_count: surface_texture.mip_level_count(), + sample_count: surface_texture.sample_count(), + dimension: surface_texture.dimension(), + format: surface_texture.format(), + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let padding = BufferPadding::new(surface_texture.width()); + + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("egui_screen_capture_buffer"), + size: (padding.padded_bytes_per_row * texture.height()) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + Self { + texture, + buffer, + padding, + } + } +} + +struct BufferPadding { + unpadded_bytes_per_row: u32, + padded_bytes_per_row: u32, +} + +impl BufferPadding { + fn new(width: u32) -> Self { + let bytes_per_pixel = std::mem::size_of::() as u32; + let unpadded_bytes_per_row = width * bytes_per_pixel; + let padded_bytes_per_row = + wgpu::util::align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + Self { + unpadded_bytes_per_row, + padded_bytes_per_row, + } + } +} + +/// Everything you need to paint egui with [`wgpu`] on [`winit`]. +/// +/// Alternatively you can use [`crate::renderer`] directly. +pub struct Painter { + configuration: WgpuConfiguration, + msaa_samples: u32, + support_transparent_backbuffer: bool, + depth_format: Option, + depth_texture_view: Option, + msaa_texture_view: Option, + screen_capture_state: Option, + + instance: wgpu::Instance, + render_state: Option, + surface_state: Option, +} + +impl Painter { + /// Manages [`wgpu`] state, including surface state, required to render egui. + /// + /// Only the [`wgpu::Instance`] is initialized here. Device selection and the initialization + /// of render + surface state is deferred until the painter is given its first window target + /// via [`set_window()`](Self::set_window). (Ensuring that a device that's compatible with the + /// native window is chosen) + /// + /// Before calling [`paint_and_update_textures()`](Self::paint_and_update_textures) a + /// [`wgpu::Surface`] must be initialized (and corresponding render state) by calling + /// [`set_window()`](Self::set_window) once you have + /// a [`winit::window::Window`] with a valid `.raw_window_handle()` + /// associated. + pub fn new( + configuration: WgpuConfiguration, + msaa_samples: u32, + depth_format: Option, + support_transparent_backbuffer: bool, + ) -> Self { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: configuration.supported_backends, + ..Default::default() + }); + + Self { + configuration, + msaa_samples, + support_transparent_backbuffer, + depth_format, + depth_texture_view: None, + screen_capture_state: None, + + instance, + render_state: None, + surface_state: None, + msaa_texture_view: None, + } + } + + /// Get the [`RenderState`]. + /// + /// Will return [`None`] if the render state has not been initialized yet. + pub fn render_state(&self) -> Option { + self.render_state.clone() + } + + fn configure_surface( + surface_state: &SurfaceState, + render_state: &RenderState, + present_mode: wgpu::PresentMode, + ) { + crate::profile_function!(); + let usage = if surface_state.supports_screenshot { + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST + } else { + wgpu::TextureUsages::RENDER_ATTACHMENT + }; + surface_state.surface.configure( + &render_state.device, + &wgpu::SurfaceConfiguration { + usage, + format: render_state.target_format, + width: surface_state.width, + height: surface_state.height, + present_mode, + alpha_mode: surface_state.alpha_mode, + view_formats: vec![render_state.target_format], + }, + ); + } + + /// Updates (or clears) the [`winit::window::Window`] associated with the [`Painter`] + /// + /// This creates a [`wgpu::Surface`] for the given Window (as well as initializing render + /// state if needed) that is used for egui rendering. + /// + /// This must be called before trying to render via + /// [`paint_and_update_textures`](Self::paint_and_update_textures) + /// + /// # Portability + /// + /// _In particular it's important to note that on Android a it's only possible to create + /// a window surface between `Resumed` and `Paused` lifecycle events, and Winit will panic on + /// attempts to query the raw window handle while paused._ + /// + /// On Android [`set_window`](Self::set_window) should be called with `Some(window)` for each + /// `Resumed` event and `None` for each `Paused` event. Currently, on all other platforms + /// [`set_window`](Self::set_window) may be called with `Some(window)` as soon as you have a + /// valid [`winit::window::Window`]. + /// + /// # Errors + /// If the provided wgpu configuration does not match an available device. + pub async fn set_window( + &mut self, + window: Option<&winit::window::Window>, + ) -> Result<(), crate::WgpuError> { + crate::profile_function!(); + match window { + Some(window) => { + let surface = unsafe { self.instance.create_surface(&window)? }; + + let render_state = if let Some(render_state) = &self.render_state { + render_state + } else { + let render_state = RenderState::create( + &self.configuration, + &self.instance, + &surface, + self.depth_format, + self.msaa_samples, + ) + .await?; + self.render_state.get_or_insert(render_state) + }; + + let alpha_mode = if self.support_transparent_backbuffer { + let supported_alpha_modes = + surface.get_capabilities(&render_state.adapter).alpha_modes; + + // Prefer pre multiplied over post multiplied! + if supported_alpha_modes.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { + wgpu::CompositeAlphaMode::PreMultiplied + } else if supported_alpha_modes + .contains(&wgpu::CompositeAlphaMode::PostMultiplied) + { + wgpu::CompositeAlphaMode::PostMultiplied + } else { + log::warn!("Transparent window was requested, but the active wgpu surface does not support a `CompositeAlphaMode` with transparency."); + wgpu::CompositeAlphaMode::Auto + } + } else { + wgpu::CompositeAlphaMode::Auto + }; + + let supports_screenshot = + !matches!(render_state.adapter.get_info().backend, wgpu::Backend::Gl); + + let size = window.inner_size(); + self.surface_state = Some(SurfaceState { + surface, + width: size.width, + height: size.height, + alpha_mode, + supports_screenshot, + }); + self.resize_and_generate_depth_texture_view_and_msaa_view(size.width, size.height); + } + None => { + self.surface_state = None; + } + } + Ok(()) + } + + /// Returns the maximum texture dimension supported if known + /// + /// This API will only return a known dimension after `set_window()` has been called + /// at least once, since the underlying device and render state are initialized lazily + /// once we have a window (that may determine the choice of adapter/device). + pub fn max_texture_side(&self) -> Option { + self.render_state + .as_ref() + .map(|rs| rs.device.limits().max_texture_dimension_2d as usize) + } + + fn resize_and_generate_depth_texture_view_and_msaa_view( + &mut self, + width_in_pixels: u32, + height_in_pixels: u32, + ) { + crate::profile_function!(); + let render_state = self.render_state.as_ref().unwrap(); + let surface_state = self.surface_state.as_mut().unwrap(); + + surface_state.width = width_in_pixels; + surface_state.height = height_in_pixels; + + Self::configure_surface(surface_state, render_state, self.configuration.present_mode); + + self.depth_texture_view = self.depth_format.map(|depth_format| { + render_state + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("egui_depth_texture"), + size: wgpu::Extent3d { + width: width_in_pixels, + height: height_in_pixels, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: self.msaa_samples, + dimension: wgpu::TextureDimension::D2, + format: depth_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[depth_format], + }) + .create_view(&wgpu::TextureViewDescriptor::default()) + }); + + self.msaa_texture_view = (self.msaa_samples > 1) + .then_some(self.render_state.as_ref()) + .flatten() + .map(|render_state| { + let texture_format = render_state.target_format; + render_state + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("egui_msaa_texture"), + size: wgpu::Extent3d { + width: width_in_pixels, + height: height_in_pixels, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: self.msaa_samples, + dimension: wgpu::TextureDimension::D2, + format: texture_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[texture_format], + }) + .create_view(&wgpu::TextureViewDescriptor::default()) + }); + } + + pub fn on_window_resized(&mut self, width_in_pixels: u32, height_in_pixels: u32) { + crate::profile_function!(); + if self.surface_state.is_some() { + self.resize_and_generate_depth_texture_view_and_msaa_view( + width_in_pixels, + height_in_pixels, + ); + } else { + log::warn!("Ignoring window resize notification with no surface created via Painter::set_window()"); + } + } + + // CaptureState only needs to be updated when the size of the two textures don't match and we want to + // capture a frame + fn update_capture_state( + screen_capture_state: &mut Option, + surface_texture: &wgpu::SurfaceTexture, + render_state: &RenderState, + ) { + let surface_texture = &surface_texture.texture; + match screen_capture_state { + Some(capture_state) => { + if capture_state.texture.size() != surface_texture.size() { + *capture_state = CaptureState::new(&render_state.device, surface_texture); + } + } + None => { + *screen_capture_state = + Some(CaptureState::new(&render_state.device, surface_texture)); + } + } + } + + // Handles copying from the CaptureState texture to the surface texture and the cpu + fn read_screen_rgba( + screen_capture_state: &CaptureState, + render_state: &RenderState, + output_frame: &wgpu::SurfaceTexture, + ) -> Option { + let CaptureState { + texture: tex, + buffer, + padding, + } = screen_capture_state; + + let device = &render_state.device; + let queue = &render_state.queue; + + let tex_extent = tex.size(); + + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + tex.as_image_copy(), + wgpu::ImageCopyBuffer { + buffer, + layout: wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(padding.padded_bytes_per_row), + rows_per_image: None, + }, + }, + tex_extent, + ); + + encoder.copy_texture_to_texture( + tex.as_image_copy(), + output_frame.texture.as_image_copy(), + tex.size(), + ); + + let id = queue.submit(Some(encoder.finish())); + let buffer_slice = buffer.slice(..); + let (sender, receiver) = std::sync::mpsc::channel(); + buffer_slice.map_async(wgpu::MapMode::Read, move |v| { + drop(sender.send(v)); + }); + device.poll(wgpu::Maintain::WaitForSubmissionIndex(id)); + receiver.recv().ok()?.ok()?; + + let to_rgba = match tex.format() { + wgpu::TextureFormat::Rgba8Unorm => [0, 1, 2, 3], + wgpu::TextureFormat::Bgra8Unorm => [2, 1, 0, 3], + _ => { + log::error!("Screen can't be captured unless the surface format is Rgba8Unorm or Bgra8Unorm. Current surface format is {:?}", tex.format()); + return None; + } + }; + + let mut pixels = Vec::with_capacity((tex.width() * tex.height()) as usize); + for padded_row in buffer_slice + .get_mapped_range() + .chunks(padding.padded_bytes_per_row as usize) + { + let row = &padded_row[..padding.unpadded_bytes_per_row as usize]; + for color in row.chunks(4) { + pixels.push(epaint::Color32::from_rgba_premultiplied( + color[to_rgba[0]], + color[to_rgba[1]], + color[to_rgba[2]], + color[to_rgba[3]], + )); + } + } + buffer.unmap(); + + Some(epaint::ColorImage { + size: [tex.width() as usize, tex.height() as usize], + pixels, + }) + } + + // Returns a vector with the frame's pixel data if it was requested. + pub fn paint_and_update_textures( + &mut self, + pixels_per_point: f32, + clear_color: [f32; 4], + clipped_primitives: &[epaint::ClippedPrimitive], + textures_delta: &epaint::textures::TexturesDelta, + capture: bool, + ) -> Option { + crate::profile_function!(); + + let render_state = self.render_state.as_mut()?; + let surface_state = self.surface_state.as_ref()?; + + let output_frame = { + crate::profile_scope!("get_current_texture"); + // This is what vsync-waiting happens, at least on Mac. + surface_state.surface.get_current_texture() + }; + + let output_frame = match output_frame { + Ok(frame) => frame, + #[allow(clippy::single_match_else)] + Err(e) => match (*self.configuration.on_surface_error)(e) { + SurfaceErrorAction::RecreateSurface => { + Self::configure_surface( + surface_state, + render_state, + self.configuration.present_mode, + ); + return None; + } + SurfaceErrorAction::SkipFrame => { + return None; + } + }, + }; + + let mut encoder = + render_state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("encoder"), + }); + + // Upload all resources for the GPU. + let screen_descriptor = renderer::ScreenDescriptor { + size_in_pixels: [surface_state.width, surface_state.height], + pixels_per_point, + }; + + let user_cmd_bufs = { + let mut renderer = render_state.renderer.write(); + for (id, image_delta) in &textures_delta.set { + renderer.update_texture( + &render_state.device, + &render_state.queue, + *id, + image_delta, + ); + } + + renderer.update_buffers( + &render_state.device, + &render_state.queue, + &mut encoder, + clipped_primitives, + &screen_descriptor, + ) + }; + + let capture = match (capture, surface_state.supports_screenshot) { + (false, _) => false, + (true, true) => true, + (true, false) => { + log::error!("The active render surface doesn't support taking screenshots."); + false + } + }; + + { + let renderer = render_state.renderer.read(); + let frame_view = if capture { + Self::update_capture_state( + &mut self.screen_capture_state, + &output_frame, + render_state, + ); + self.screen_capture_state + .as_ref()? + .texture + .create_view(&wgpu::TextureViewDescriptor::default()) + } else { + output_frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()) + }; + + let (view, resolve_target) = (self.msaa_samples > 1) + .then_some(self.msaa_texture_view.as_ref()) + .flatten() + .map_or((&frame_view, None), |texture_view| { + (texture_view, Some(&frame_view)) + }); + + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("egui_render"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: clear_color[0] as f64, + g: clear_color[1] as f64, + b: clear_color[2] as f64, + a: clear_color[3] as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: self.depth_texture_view.as_ref().map(|view| { + wgpu::RenderPassDepthStencilAttachment { + view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + // It is very unlikely that the depth buffer is needed after egui finished rendering + // so no need to store it. (this can improve performance on tiling GPUs like mobile chips or Apple Silicon) + store: wgpu::StoreOp::Discard, + }), + stencil_ops: None, + } + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + + renderer.render(&mut render_pass, clipped_primitives, &screen_descriptor); + } + + { + let mut renderer = render_state.renderer.write(); + for id in &textures_delta.free { + renderer.free_texture(id); + } + } + + let encoded = { + crate::profile_scope!("CommandEncoder::finish"); + encoder.finish() + }; + + // Submit the commands: both the main buffer and user-defined ones. + { + crate::profile_scope!("Queue::submit"); + render_state + .queue + .submit(user_cmd_bufs.into_iter().chain(std::iter::once(encoded))); + }; + + let screenshot = if capture { + let screen_capture_state = self.screen_capture_state.as_ref()?; + Self::read_screen_rgba(screen_capture_state, render_state, &output_frame) + } else { + None + }; + + { + crate::profile_scope!("present"); + output_frame.present(); + } + screenshot + } + + #[allow(clippy::unused_self)] + pub fn destroy(&mut self) { + // TODO(emilk): something here? + } +} diff --git a/crates/filesystem/src/project.rs b/crates/filesystem/src/project.rs index 152f8d3b..b9f86392 100644 --- a/crates/filesystem/src/project.rs +++ b/crates/filesystem/src/project.rs @@ -483,7 +483,7 @@ impl FileSystem { .filter(|(_, k)| k.as_str() != idb_key.as_str()) .cloned() .collect(); - projects.push_front((root_path.join(&entry.path).to_string(), idb_key)); + projects.push_front((root_path.to_string(), idb_key)); global_config.recent_projects = projects; } diff --git a/crates/filesystem/src/web.rs b/crates/filesystem/src/web.rs deleted file mode 100644 index a7073322..00000000 --- a/crates/filesystem/src/web.rs +++ /dev/null @@ -1,1107 +0,0 @@ -// Copyright (C) 2023 Lily Lyons -// -// This file is part of Luminol. -// -// Luminol is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Luminol is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Luminol. If not, see . -use indexed_db_futures::prelude::*; -use rand::Rng; -use std::future::IntoFuture; -use wasm_bindgen::prelude::*; - -use super::FileSystem as FileSystemTrait; -use super::{DirEntry, Error, Metadata, OpenFlags, Result}; - -static FILESYSTEM_TX: once_cell::sync::OnceCell> = - once_cell::sync::OnceCell::new(); - -#[derive(Debug)] -pub struct FileSystem { - key: usize, - name: String, - idb_key: Option, -} - -#[derive(Debug)] -pub struct File { - key: usize, -} - -#[derive(Debug)] -pub struct FileSystemCommand(FileSystemCommandInner); - -#[derive(Debug)] -enum FileSystemCommandInner { - Supported(oneshot::Sender), - DirEntryMetadata( - usize, - camino::Utf8PathBuf, - oneshot::Sender>, - ), - DirPicker(oneshot::Sender)>>), - DirFromIdb(String, oneshot::Sender>), - DirSubdir( - usize, - camino::Utf8PathBuf, - oneshot::Sender)>>, - ), - DirIdbDrop(String, oneshot::Sender), - DirOpenFile( - usize, - camino::Utf8PathBuf, - OpenFlags, - oneshot::Sender>, - ), - DirEntryExists(usize, camino::Utf8PathBuf, oneshot::Sender), - DirCreateDir(usize, camino::Utf8PathBuf, oneshot::Sender>), - DirRemoveDir(usize, camino::Utf8PathBuf, oneshot::Sender>), - DirRemoveFile(usize, camino::Utf8PathBuf, oneshot::Sender>), - DirReadDir( - usize, - camino::Utf8PathBuf, - oneshot::Sender>>, - ), - DirDrop(usize, oneshot::Sender), - DirClone(usize, oneshot::Sender), - FileRead(usize, usize, oneshot::Sender>>), - FileWrite(usize, Vec, oneshot::Sender>), - FileFlush(usize, oneshot::Sender>), - FileSeek( - usize, - std::io::SeekFrom, - oneshot::Sender>, - ), - FileDrop(usize, oneshot::Sender), - FileSize(usize, oneshot::Sender), -} - -fn filesystem_tx_or_die() -> &'static flume::Sender { - FILESYSTEM_TX.get().expect("FileSystem sender has not been initialized! Please call `FileSystem::initialize_sender` before calling this function.") -} - -impl FileSystem { - /// Initializes the sender that we use to send filesystem commands to the main thread. - /// This must be called before performing any filesystem operations. - pub fn setup_filesystem_sender(filesystem_tx: flume::Sender) { - FILESYSTEM_TX - .set(filesystem_tx) - .expect("FileSystem sender cannot be initialized twice"); - } - - /// Returns whether or not the user's browser supports the JavaScript File System API. - pub fn filesystem_supported() -> bool { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::Supported( - oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } - - /// Attempts to prompt the user to choose a directory from their local machine using the - /// JavaScript File System API. - /// Then creates a `FileSystem` allowing read-write access to that directory if they chose one - /// successfully. - /// If the File System API is not supported, this always returns `None` without doing anything. - pub async fn from_folder_picker() -> Result { - if !Self::filesystem_supported() { - return Err(Error::Wasm32FilesystemNotSupported); - } - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirPicker( - oneshot_tx, - ))) - .unwrap(); - oneshot_rx - .await - .unwrap() - .map(|(key, name, idb_key)| FileSystem { key, name, idb_key }) - .ok_or(Error::CancelledLoading) - } - - /// Attempts to restore a previously created `FileSystem` using its `.idb_key()`. - pub async fn from_idb_key(idb_key: String) -> Result { - if !Self::filesystem_supported() { - return Err(Error::Wasm32FilesystemNotSupported); - } - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirFromIdb( - idb_key.clone(), - oneshot_tx, - ))) - .unwrap(); - oneshot_rx - .await - .unwrap() - .map(|(key, name)| FileSystem { - key, - name, - idb_key: Some(idb_key), - }) - .ok_or(Error::MissingIDB) - } - - /// Creates a new `FileSystem` from a subdirectory of this one. - pub fn subdir(&self, path: impl AsRef) -> Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirSubdir( - self.key, - path.as_ref().to_path_buf(), - oneshot_tx, - ))) - .unwrap(); - oneshot_rx - .recv() - .unwrap() - .map(|(key, name, idb_key)| FileSystem { key, name, idb_key }) - } - - /// Drops the directory with the given key from IndexedDB if it exists in there. - pub fn idb_drop(idb_key: String) -> bool { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirIdbDrop( - idb_key, oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } - - /// Returns a path consisting of a single element: the name of the root directory of this - /// filesystem. - pub fn root_path(&self) -> &camino::Utf8Path { - self.name.as_str().into() - } - - /// Returns the key needed to restore this `FileSystem` using `FileSystem::from_idb()`. - pub fn idb_key(&self) -> Option<&str> { - self.idb_key.as_deref() - } -} - -impl Drop for FileSystem { - fn drop(&mut self) { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirDrop( - self.key, oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap(); - } -} - -impl Clone for FileSystem { - fn clone(&self) -> Self { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirClone( - self.key, oneshot_tx, - ))) - .unwrap(); - Self { - key: oneshot_rx.recv().unwrap(), - name: self.name.clone(), - idb_key: self.idb_key.clone(), - } - } -} - -impl FileSystemTrait for FileSystem { - type File = File; - - fn open_file( - &self, - path: impl AsRef, - mut flags: OpenFlags, - ) -> Result { - if flags.contains(OpenFlags::Truncate) || flags.contains(OpenFlags::Create) { - flags |= OpenFlags::Write; - } - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirOpenFile( - self.key, - path.as_ref().to_path_buf(), - flags, - oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap().map(|key| File { key }) - } - - fn metadata(&self, path: impl AsRef) -> Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirEntryMetadata( - self.key, - path.as_ref().to_path_buf(), - oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } - - fn rename( - &self, - from: impl AsRef, - to: impl AsRef, - ) -> Result<()> { - Err(Error::NotSupported) - } - - fn exists(&self, path: impl AsRef) -> Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirEntryExists( - self.key, - path.as_ref().to_path_buf(), - oneshot_tx, - ))) - .unwrap(); - Ok(oneshot_rx.recv().unwrap()) - } - - fn create_dir(&self, path: impl AsRef) -> Result<()> { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirCreateDir( - self.key, - path.as_ref().to_path_buf(), - oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } - - fn remove_dir(&self, path: impl AsRef) -> Result<()> { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirRemoveDir( - self.key, - path.as_ref().to_path_buf(), - oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } - - fn remove_file(&self, path: impl AsRef) -> Result<()> { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirRemoveFile( - self.key, - path.as_ref().to_path_buf(), - oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } - - fn read_dir(&self, path: impl AsRef) -> Result> { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::DirReadDir( - self.key, - path.as_ref().to_path_buf(), - oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } -} - -impl Drop for File { - fn drop(&mut self) { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::FileDrop( - self.key, oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap(); - } -} - -impl crate::File for File { - fn metadata(&self) -> crate::Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::FileSize( - self.key, oneshot_tx, - ))) - .unwrap(); - let size = oneshot_rx.recv().unwrap(); - Ok(Metadata { - is_file: true, - size, - }) - } -} - -impl std::io::Read for File { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::FileRead( - self.key, - buf.len(), - oneshot_tx, - ))) - .unwrap(); - let vec = oneshot_rx.recv().unwrap()?; - let length = vec.len(); - buf[..length].copy_from_slice(&vec[..]); - Ok(length) - } -} - -impl std::io::Write for File { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::FileWrite( - self.key, - buf.to_vec(), - oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap()?; - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::FileFlush( - self.key, oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } -} - -impl std::io::Seek for File { - fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - filesystem_tx_or_die() - .send(FileSystemCommand(FileSystemCommandInner::FileSeek( - self.key, pos, oneshot_tx, - ))) - .unwrap(); - oneshot_rx.recv().unwrap() - } -} - -pub fn setup_main_thread_hooks(mut filesystem_rx: flume::Receiver) { - wasm_bindgen_futures::spawn_local(async move { - web_sys::window().expect("cannot run `setup_main_thread_hooks()` outside of main thread"); - - struct FileHandle { - offset: usize, - file_handle: web_sys::FileSystemFileHandle, - read_allowed: bool, - write_handle: Option, - } - - let mut dirs: slab::Slab = slab::Slab::new(); - let mut files: slab::Slab = slab::Slab::new(); - - async fn to_future(promise: js_sys::Promise) -> std::result::Result - where - T: JsCast, - { - wasm_bindgen_futures::JsFuture::from(promise) - .await - .map(|t| t.unchecked_into()) - .map_err(|e| e.unchecked_into()) - } - - async fn get_subdir( - dir: &web_sys::FileSystemDirectoryHandle, - path_iter: &mut camino::Iter<'_>, - ) -> Option { - let mut dir = dir.clone(); - loop { - let Some(path_element) = path_iter.next() else { - return Some(dir); - }; - if let Ok(subdir) = to_future(dir.get_directory_handle(path_element)).await { - dir = subdir; - } else { - return None; - } - } - } - - async fn idb( - mode: IdbTransactionMode, - f: impl Fn(IdbObjectStore<'_>) -> std::result::Result, - ) -> std::result::Result { - let mut db_req = IdbDatabase::open_u32("astrabit.luminol", 1)?; - - // Create store for our directory handles if it doesn't exist - db_req.set_on_upgrade_needed(Some(|e: &IdbVersionChangeEvent| { - if e.db() - .object_store_names() - .find(|n| n == "filesystem.dir_handles") - .is_none() - { - e.db().create_object_store("filesystem.dir_handles")?; - } - Ok(()) - })); - - let db = db_req.into_future().await?; - let tx = db.transaction_on_one_with_mode("filesystem.dir_handles", mode)?; - let store = tx.object_store("filesystem.dir_handles")?; - let r = f(store); - tx.await.into_result()?; - r - } - - loop { - let Ok(command) = filesystem_rx.recv_async().await else { - tracing::warn!( - "FileSystem main thread loop is stopping! This is not supposed to happen." - ); - return; - }; - tracing::debug!("Main thread received FS command: {:?}", command.0); - - match command.0 { - FileSystemCommandInner::Supported(oneshot_tx) => { - oneshot_tx - .send(luminol_web::bindings::filesystem_supported()) - .unwrap(); - } - - FileSystemCommandInner::DirEntryMetadata(key, path, oneshot_tx) => { - let mut iter = path.iter(); - let Some(name) = iter.next_back() else { - oneshot_tx - .send(Ok(Metadata { - is_file: false, - size: 0, - })) - .unwrap(); - continue; - }; - let Some(subdir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - continue; - }; - if let Ok(file) = - to_future::(subdir.get_file_handle(name)) - .await - { - if let Ok(blob) = to_future::(file.get_file()).await { - oneshot_tx - .send(Ok(Metadata { - is_file: true, - size: blob.size() as u64, - })) - .unwrap(); - } else { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } - } else if to_future::( - subdir.get_directory_handle(name), - ) - .await - .is_ok() - { - oneshot_tx - .send(Ok(Metadata { - is_file: false, - size: 0, - })) - .unwrap(); - } else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - } - } - - FileSystemCommandInner::DirPicker(oneshot_tx) => { - if let Ok(dir) = luminol_web::bindings::show_directory_picker().await { - // Try to insert the handle into IndexedDB - let idb_key = rand::thread_rng() - .sample_iter(rand::distributions::Alphanumeric) - .take(42) // This should be enough to avoid collisions - .map(char::from) - .collect::(); - let idb_ok = { - let idb_key = idb_key.as_str(); - idb(IdbTransactionMode::Readwrite, |store| { - store.put_key_val_owned(idb_key, &dir) - }) - .await - .is_ok() - }; - - let name = dir.name(); - oneshot_tx - .send(Some(( - dirs.insert(dir), - name, - if idb_ok { Some(idb_key) } else { None }, - ))) - .unwrap(); - } else { - oneshot_tx.send(None).unwrap(); - } - } - - FileSystemCommandInner::DirFromIdb(idb_key, oneshot_tx) => { - let idb_key = idb_key.as_str(); - if let Ok(future) = idb(IdbTransactionMode::Readonly, |store| { - store.get_owned(idb_key) - }) - .await - { - if let Some(dir) = future.await.ok().flatten() { - let dir = dir.unchecked_into::(); - if luminol_web::bindings::request_permission(&dir).await { - let name = dir.name(); - oneshot_tx.send(Some((dirs.insert(dir), name))).unwrap(); - } else { - oneshot_tx.send(None).unwrap(); - } - } else { - oneshot_tx.send(None).unwrap(); - } - } else { - oneshot_tx.send(None).unwrap(); - } - } - - FileSystemCommandInner::DirSubdir(key, path, oneshot_tx) => { - let mut iter = path.iter(); - let Some(dir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - continue; - }; - - // Try to insert the handle into IndexedDB - let idb_key = rand::thread_rng() - .sample_iter(rand::distributions::Alphanumeric) - .take(42) // This should be enough to avoid collisions - .map(char::from) - .collect::(); - let idb_ok = { - let idb_key = idb_key.as_str(); - idb(IdbTransactionMode::Readwrite, |store| { - store.put_key_val_owned(idb_key, &dir) - }) - .await - .is_ok() - }; - - let name = dir.name(); - oneshot_tx - .send(Ok(( - dirs.insert(dir), - name, - if idb_ok { Some(idb_key) } else { None }, - ))) - .unwrap(); - } - - FileSystemCommandInner::DirIdbDrop(idb_key, oneshot_tx) => { - let idb_key = idb_key.as_str(); - oneshot_tx - .send( - idb(IdbTransactionMode::Readwrite, |store| { - store.delete_owned(idb_key) - }) - .await - .is_ok(), - ) - .unwrap(); - } - - FileSystemCommandInner::DirOpenFile(key, path, flags, oneshot_tx) => { - let mut iter = path.iter(); - let Some(filename) = iter.next_back() else { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - continue; - }; - let Some(subdir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - continue; - }; - // If write and create permissions were both requested, then the file should be - // created if the file does not exist but all the parent directories do - let mut options = web_sys::FileSystemGetFileOptions::new(); - if flags.contains(OpenFlags::Write) && flags.contains(OpenFlags::Create) { - options.create(true); - } - if let Ok(file_handle) = to_future::( - subdir.get_file_handle_with_options(filename, &options), - ) - .await - { - let mut handle = FileHandle { - offset: 0, - file_handle, - read_allowed: flags.contains(OpenFlags::Read), - write_handle: None, - }; - // If write permissions were requested, try to get a write handle on the - // file, with truncation if requested - let mut options = web_sys::FileSystemCreateWritableOptions::new(); - options.keep_existing_data(!flags.contains(OpenFlags::Truncate)); - handle.write_handle = if flags.contains(OpenFlags::Write) { - to_future(handle.file_handle.create_writable_with_options(&options)) - .await - .ok() - } else { - None - }; - // If write and truncate permissions were both requested, try to flush the - // write handle (by closing and reopening) to perform the truncation - // immediately - let close_result = !flags.contains(OpenFlags::Truncate) - || if let Some(write_handle) = &handle.write_handle { - to_future::(write_handle.close()).await.is_ok() - } else { - true - }; - let mut options = web_sys::FileSystemCreateWritableOptions::new(); - options.keep_existing_data(true); - if flags.contains(OpenFlags::Truncate) && handle.write_handle.is_some() { - handle.write_handle = - to_future(handle.file_handle.create_writable_with_options(&options)) - .await - .ok() - } - - if (flags.contains(OpenFlags::Write) && handle.write_handle.is_none()) - || !close_result - { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } else { - oneshot_tx.send(Ok(files.insert(handle))).unwrap(); - } - } else if to_future::( - subdir.get_directory_handle(filename), - ) - .await - .is_ok() - { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - } - } - - FileSystemCommandInner::DirEntryExists(key, path, oneshot_tx) => { - let mut iter = path.iter(); - let Some(name) = iter.next_back() else { - oneshot_tx.send(true).unwrap(); - continue; - }; - let Some(subdir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await else { - oneshot_tx.send(false).unwrap(); - continue; - }; - if to_future::(subdir.get_file_handle(name)) - .await - .is_ok() - || to_future::( - subdir.get_directory_handle(name), - ) - .await - .is_ok() - { - oneshot_tx.send(true).unwrap(); - } else { - oneshot_tx.send(false).unwrap(); - } - } - - FileSystemCommandInner::DirCreateDir(key, path, oneshot_tx) => { - let mut iter = path.iter(); - let Some(dirname) = iter.next_back() else { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::AlreadyExists.into(), - ))) - .unwrap(); - continue; - }; - let Some(subdir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - continue; - }; - if to_future::(subdir.get_file_handle(dirname)) - .await - .is_ok() - || to_future::( - subdir.get_directory_handle(dirname), - ) - .await - .is_ok() - { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } else { - let mut options = web_sys::FileSystemGetDirectoryOptions::new(); - options.create(true); - if to_future::( - subdir.get_directory_handle_with_options(dirname, &options), - ) - .await - .is_ok() - { - oneshot_tx.send(Ok(())).unwrap(); - } else { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } - } - } - - FileSystemCommandInner::DirRemoveDir(key, path, oneshot_tx) => { - let mut iter = path.iter(); - let Some(dirname) = iter.next_back() else { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - continue; - }; - let Some(subdir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - continue; - }; - if to_future::(subdir.get_file_handle(dirname)) - .await - .is_ok() - { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } else if let Ok(dir) = to_future::( - subdir.get_directory_handle(dirname), - ) - .await - { - let mut options = web_sys::FileSystemRemoveOptions::new(); - options.recursive(true); - if to_future::(subdir.remove_entry_with_options(dirname, &options)) - .await - .is_ok() - { - oneshot_tx.send(Ok(())).unwrap(); - } else { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } - } else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - } - } - - FileSystemCommandInner::DirRemoveFile(key, path, oneshot_tx) => { - let mut iter = path.iter(); - let Some(filename) = iter.next_back() else { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - continue; - }; - let Some(subdir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - continue; - }; - if let Ok(file) = - to_future::(subdir.get_file_handle(filename)) - .await - { - if to_future::(subdir.remove_entry(filename)) - .await - .is_ok() - { - oneshot_tx.send(Ok(())).unwrap(); - } else { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } - } else if to_future::( - subdir.get_directory_handle(filename), - ) - .await - .is_ok() - { - oneshot_tx - .send(Err(Error::IoError( - std::io::ErrorKind::PermissionDenied.into(), - ))) - .unwrap(); - } else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - } - } - - FileSystemCommandInner::DirReadDir(key, path, oneshot_tx) => { - let mut iter = path.iter(); - let Some(subdir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await else { - oneshot_tx.send(Err(Error::NotExist)).unwrap(); - continue; - }; - let entry_iter = luminol_web::bindings::dir_values(&subdir); - let mut vec = Vec::new(); - loop { - let Ok(entry) = - to_future::(entry_iter.next().unwrap()).await - else { - break; - }; - if entry.done() { - break; - } - let entry = entry.value().unchecked_into::(); - match entry.kind() { - web_sys::FileSystemHandleKind::File => { - let entry = entry.unchecked_into::(); - if let Ok(blob) = to_future::(entry.get_file()).await - { - vec.push(DirEntry::new( - path.join(entry.name()), - Metadata { - is_file: true, - size: blob.size() as u64, - }, - )); - } - } - web_sys::FileSystemHandleKind::Directory => { - vec.push(DirEntry::new( - path.join(entry.name()), - Metadata { - is_file: false, - size: 0, - }, - )); - } - _ => (), - } - } - oneshot_tx.send(Ok(vec)).unwrap(); - } - - FileSystemCommandInner::DirDrop(key, oneshot_tx) => { - if dirs.contains(key) { - dirs.remove(key); - oneshot_tx.send(true).unwrap(); - } else { - oneshot_tx.send(false).unwrap(); - } - } - - FileSystemCommandInner::DirClone(key, oneshot_tx) => { - oneshot_tx - .send(dirs.insert(dirs.get(key).unwrap().clone())) - .unwrap(); - } - - FileSystemCommandInner::FileRead(key, max_length, oneshot_tx) => { - let file = files.get_mut(key).unwrap(); - let Some(read_handle) = (if file.read_allowed { - to_future::(file.file_handle.get_file()) - .await - .ok() - } else { - None - }) else { - oneshot_tx - .send(Err(std::io::ErrorKind::PermissionDenied.into())) - .unwrap(); - continue; - }; - let blob = read_handle - .slice_with_f64_and_f64( - file.offset as f64, - (file.offset + max_length) as f64, - ) - .unwrap(); - let Ok(buffer) = to_future::(blob.array_buffer()).await - else { - oneshot_tx - .send(Err(std::io::ErrorKind::PermissionDenied.into())) - .unwrap(); - continue; - }; - let u8_array = js_sys::Uint8Array::new(&buffer); - let vec = u8_array.to_vec(); - file.offset += vec.len(); - oneshot_tx.send(Ok(vec)).unwrap(); - } - - FileSystemCommandInner::FileWrite(key, vec, oneshot_tx) => { - let file = files.get_mut(key).unwrap(); - let Some(write_handle) = &file.write_handle else { - oneshot_tx - .send(Err(std::io::ErrorKind::PermissionDenied.into())) - .unwrap(); - continue; - }; - // TODO: `write_handle.write_with_u8_array()` will not work here when - // theading is enabled. Possible wasm_bindgen bug? - // We are using `write_handle.write_with_buffer_source()` here as a workaround - // that does the same thing but with an extra memory allocation. - // Check if this is fixed in newer versions of wasm_bindgen. - let u8_array = js_sys::Uint8Array::new(&JsValue::from_f64(vec.len() as f64)); - u8_array.copy_from(&vec[..]); - if to_future::(write_handle.seek_with_f64(file.offset as f64).unwrap()) - .await - .is_ok() - && to_future::( - write_handle.write_with_buffer_source(&u8_array).unwrap(), - ) - .await - .is_ok() - { - file.offset += vec.len(); - oneshot_tx.send(Ok(())).unwrap(); - } else { - oneshot_tx - .send(Err(std::io::ErrorKind::PermissionDenied.into())) - .unwrap(); - } - } - - FileSystemCommandInner::FileFlush(key, oneshot_tx) => { - let file = files.get_mut(key).unwrap(); - if file.write_handle.is_none() { - oneshot_tx - .send(Err(std::io::ErrorKind::PermissionDenied.into())) - .unwrap(); - continue; - } - // Closing and reopening the handle is the only way to flush - if to_future::(file.write_handle.as_ref().unwrap().close()) - .await - .is_err() - { - oneshot_tx - .send(Err(std::io::ErrorKind::PermissionDenied.into())) - .unwrap(); - continue; - } - let mut options = web_sys::FileSystemCreateWritableOptions::new(); - options.keep_existing_data(true); - if let Ok(write_handle) = - to_future(file.file_handle.create_writable_with_options(&options)).await - { - file.write_handle = Some(write_handle); - oneshot_tx.send(Ok(())).unwrap(); - } else { - oneshot_tx - .send(Err(std::io::ErrorKind::PermissionDenied.into())) - .unwrap(); - } - } - - FileSystemCommandInner::FileSeek(key, seek_from, oneshot_tx) => { - let file = files.get_mut(key).unwrap(); - let Some(read_handle) = (if file.read_allowed { - to_future::(file.file_handle.get_file()) - .await - .ok() - } else { - None - }) else { - oneshot_tx - .send(Err(std::io::ErrorKind::PermissionDenied.into())) - .unwrap(); - continue; - }; - let size = read_handle.size(); - let new_offset = match seek_from { - std::io::SeekFrom::Start(i) => i as i64, - std::io::SeekFrom::End(i) => i + size as i64, - std::io::SeekFrom::Current(i) => i + file.offset as i64, - }; - if new_offset >= 0 { - file.offset = new_offset as usize; - oneshot_tx.send(Ok(new_offset as u64)).unwrap(); - } else { - oneshot_tx - .send(Err(std::io::ErrorKind::InvalidInput.into())) - .unwrap(); - } - } - - FileSystemCommandInner::FileDrop(key, oneshot_tx) => { - if files.contains(key) { - let file = files.remove(key); - // We need to close the write handle to flush any changes that the user - // made to the file - if let Some(write_handle) = &file.write_handle { - let _ = to_future::(write_handle.close()).await; - } - oneshot_tx.send(true).unwrap(); - } else { - oneshot_tx.send(false).unwrap(); - } - } - FileSystemCommandInner::FileSize(key, oneshot_tx) => { - let file = files.get_mut(key).unwrap(); - if let Ok(file) = to_future::(file.file_handle.get_file()).await - { - oneshot_tx.send(file.size() as u64).unwrap(); - } - } - } - } - }); -} diff --git a/crates/filesystem/src/web/events.rs b/crates/filesystem/src/web/events.rs new file mode 100644 index 00000000..ac1eb71d --- /dev/null +++ b/crates/filesystem/src/web/events.rs @@ -0,0 +1,610 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . + +use super::util::{generate_key, get_subdir, handle_event, idb, to_future}; +use super::FileSystemCommand; +use crate::{DirEntry, Error, Metadata, OpenFlags}; +use indexed_db_futures::prelude::*; +use std::io::ErrorKind::{AlreadyExists, InvalidInput, PermissionDenied}; +use wasm_bindgen::prelude::*; + +pub fn setup_main_thread_hooks(main_channels: super::MainChannels) { + wasm_bindgen_futures::spawn_local(async move { + web_sys::window().expect("cannot run `setup_main_thread_hooks()` outside of main thread"); + + struct FileHandle { + offset: usize, + file_handle: web_sys::FileSystemFileHandle, + read_allowed: bool, + write_handle: Option, + } + + let mut dirs: slab::Slab = slab::Slab::new(); + let mut files: slab::Slab = slab::Slab::new(); + + loop { + let Ok(command) = main_channels.command_rx.recv_async().await else { + tracing::warn!( + "FileSystem main thread loop is stopping! This is not supposed to happen." + ); + return; + }; + tracing::debug!("Main thread received FS command: {:?}", command); + + match command { + FileSystemCommand::Supported(tx) => { + handle_event(tx, async { luminol_web::bindings::filesystem_supported() }).await; + } + + FileSystemCommand::DirEntryMetadata(key, path, tx) => { + handle_event(tx, async { + let mut iter = path.iter(); + let Some(name) = iter.next_back() else { + return Ok(Metadata { + is_file: false, + size: 0, + }); + }; + let subdir = get_subdir(dirs.get(key).unwrap(), &mut iter) + .await + .ok_or(Error::NotExist)?; + + if let Ok(file) = + to_future::(subdir.get_file_handle(name)) + .await + { + // If the path is a file + to_future::(file.get_file()) + .await + .map(|blob| Metadata { + is_file: true, + size: blob.size() as u64, + }) + .map_err(|_| Error::IoError(PermissionDenied.into())) + } else if to_future::( + subdir.get_directory_handle(name), + ) + .await + .is_ok() + { + // If the path is a directory + Ok(Metadata { + is_file: false, + size: 0, + }) + } else { + // If the path is neither a file nor a directory + Err(Error::NotExist) + } + }) + .await; + } + + FileSystemCommand::DirPicker(tx) => { + handle_event(tx, async { + let dir = luminol_web::bindings::show_directory_picker().await.ok()?; + + // Try to insert the handle into IndexedDB + let idb_key = generate_key(); + let idb_ok = { + let idb_key = idb_key.as_str(); + idb(IdbTransactionMode::Readwrite, |store| { + store.put_key_val_owned(idb_key, &dir) + }) + .await + .is_ok() + }; + + let name = dir.name(); + Some((dirs.insert(dir), name, idb_ok.then_some(idb_key))) + }) + .await; + } + + FileSystemCommand::DirFromIdb(idb_key, tx) => { + handle_event(tx, async { + let dir = idb(IdbTransactionMode::Readonly, |store| { + store.get_owned(&idb_key) + }) + .await + .ok()? + .await + .ok() + .flatten()?; + let dir = dir.unchecked_into::(); + luminol_web::bindings::request_permission(&dir) + .await + .then(|| { + let name = dir.name(); + (dirs.insert(dir), name) + }) + }) + .await; + } + + FileSystemCommand::DirSubdir(key, path, tx) => { + handle_event(tx, async { + let mut iter = path.iter(); + let dir = get_subdir(dirs.get(key).unwrap(), &mut iter) + .await + .ok_or(Error::NotExist)?; + + // Try to insert the handle into IndexedDB + let idb_key = generate_key(); + let idb_ok = { + let idb_key = idb_key.as_str(); + idb(IdbTransactionMode::Readwrite, |store| { + store.put_key_val_owned(idb_key, &dir) + }) + .await + .is_ok() + }; + + let name = dir.name(); + Ok((dirs.insert(dir), name, idb_ok.then_some(idb_key))) + }) + .await; + } + + FileSystemCommand::DirIdbDrop(idb_key, tx) => { + handle_event(tx, async { + idb(IdbTransactionMode::Readwrite, |store| { + store.delete_owned(&idb_key) + }) + .await + .is_ok() + }) + .await; + } + + FileSystemCommand::DirOpenFile(key, path, flags, tx) => { + handle_event(tx, async { + let mut iter = path.iter(); + let filename = iter + .next_back() + .ok_or(Error::IoError(PermissionDenied.into()))?; + let subdir = get_subdir(dirs.get(key).unwrap(), &mut iter) + .await + .ok_or(Error::NotExist)?; + + // If write and create permissions were both requested, then the file should be + // created if the file does not exist but all the parent directories do + let mut options = web_sys::FileSystemGetFileOptions::new(); + if flags.contains(OpenFlags::Write) && flags.contains(OpenFlags::Create) { + options.create(true); + } + + if let Ok(file_handle) = to_future::( + subdir.get_file_handle_with_options(filename, &options), + ) + .await + { + // If the path is a file + + let mut handle = FileHandle { + offset: 0, + file_handle, + read_allowed: flags.contains(OpenFlags::Read), + write_handle: None, + }; + // If write permissions were requested, try to get a write handle on the + // file, with truncation if requested + let mut options = web_sys::FileSystemCreateWritableOptions::new(); + options.keep_existing_data(!flags.contains(OpenFlags::Truncate)); + handle.write_handle = if flags.contains(OpenFlags::Write) { + to_future(handle.file_handle.create_writable_with_options(&options)) + .await + .ok() + } else { + None + }; + // If write and truncate permissions were both requested, try to flush the + // write handle (by closing and reopening) to perform the truncation + // immediately + let close_result = !flags.contains(OpenFlags::Truncate) + || if let Some(write_handle) = &handle.write_handle { + to_future::(write_handle.close()).await.is_ok() + } else { + true + }; + let mut options = web_sys::FileSystemCreateWritableOptions::new(); + options.keep_existing_data(true); + if flags.contains(OpenFlags::Truncate) && handle.write_handle.is_some() + { + handle.write_handle = to_future( + handle.file_handle.create_writable_with_options(&options), + ) + .await + .ok() + } + + if (flags.contains(OpenFlags::Write) && handle.write_handle.is_none()) + || !close_result + { + Err(Error::IoError(std::io::ErrorKind::PermissionDenied.into())) + } else { + Ok(files.insert(handle)) + } + } else if to_future::( + subdir.get_directory_handle(filename), + ) + .await + .is_ok() + { + // If the path is a directory + Err(Error::IoError(PermissionDenied.into())) + } else { + // If the path is neither a file nor a directory + Err(Error::NotExist) + } + }) + .await; + } + + FileSystemCommand::DirEntryExists(key, path, tx) => { + handle_event(tx, async { + let mut iter = path.iter(); + let Some(name) = iter.next_back() else { + return true; + }; + let Some(subdir) = get_subdir(dirs.get(key).unwrap(), &mut iter).await + else { + return false; + }; + to_future::(subdir.get_file_handle(name)) + .await + .is_ok() + || to_future::( + subdir.get_directory_handle(name), + ) + .await + .is_ok() + }) + .await; + } + + FileSystemCommand::DirCreateDir(key, path, tx) => { + handle_event(tx, async { + let mut iter = path.iter(); + let dirname = iter + .next_back() + .ok_or(Error::IoError(AlreadyExists.into()))?; + let subdir = get_subdir(dirs.get(key).unwrap(), &mut iter) + .await + .ok_or(Error::NotExist)?; + + if to_future::( + subdir.get_file_handle(dirname), + ) + .await + .is_ok() + || to_future::( + subdir.get_directory_handle(dirname), + ) + .await + .is_ok() + { + // If there is already a file or directory at the given path + return Err(Error::IoError(PermissionDenied.into())); + } + + let mut options = web_sys::FileSystemGetDirectoryOptions::new(); + options.create(true); + to_future::( + subdir.get_directory_handle_with_options(dirname, &options), + ) + .await + .map(|_| ()) + .map_err(|_| Error::IoError(PermissionDenied.into())) + }) + .await; + } + + FileSystemCommand::DirRemoveDir(key, path, tx) => { + handle_event(tx, async { + let mut iter = path.iter(); + let dirname = iter + .next_back() + .ok_or(Error::IoError(PermissionDenied.into()))?; + let subdir = get_subdir(dirs.get(key).unwrap(), &mut iter) + .await + .ok_or(Error::NotExist)?; + + if to_future::( + subdir.get_file_handle(dirname), + ) + .await + .is_ok() + { + // If the path is a file + Err(Error::IoError(PermissionDenied.into())) + } else if to_future::( + subdir.get_directory_handle(dirname), + ) + .await + .is_ok() + { + // If the path is a directory + let mut options = web_sys::FileSystemRemoveOptions::new(); + options.recursive(true); + to_future::( + subdir.remove_entry_with_options(dirname, &options), + ) + .await + .map(|_| ()) + .map_err(|_| Error::IoError(PermissionDenied.into())) + } else { + // If the path is neither a file nor a directory + Err(Error::NotExist) + } + }) + .await; + } + + FileSystemCommand::DirRemoveFile(key, path, tx) => { + handle_event(tx, async { + let mut iter = path.iter(); + let filename = iter + .next_back() + .ok_or(Error::IoError(PermissionDenied.into()))?; + let subdir = get_subdir(dirs.get(key).unwrap(), &mut iter) + .await + .ok_or(Error::NotExist)?; + + if to_future::( + subdir.get_file_handle(filename), + ) + .await + .is_ok() + { + // If the path is a file + to_future::(subdir.remove_entry(filename)) + .await + .map(|_| ()) + .map_err(|_| Error::IoError(PermissionDenied.into())) + } else if to_future::( + subdir.get_directory_handle(filename), + ) + .await + .is_ok() + { + // If the path is a directory + Err(Error::IoError(PermissionDenied.into())) + } else { + // If the path is neither a file nor a directory + Err(Error::NotExist) + } + }) + .await; + } + + FileSystemCommand::DirReadDir(key, path, tx) => { + handle_event(tx, async { + let mut iter = path.iter(); + let subdir = get_subdir(dirs.get(key).unwrap(), &mut iter) + .await + .ok_or(Error::NotExist)?; + let entry_iter = luminol_web::bindings::dir_values(&subdir); + + let mut vec = Vec::new(); + loop { + let Ok(entry) = + to_future::(entry_iter.next().unwrap()).await + else { + break; + }; + if entry.done() { + break; + } + + let entry = entry.value().unchecked_into::(); + match entry.kind() { + web_sys::FileSystemHandleKind::File => { + let entry = + entry.unchecked_into::(); + if let Ok(blob) = + to_future::(entry.get_file()).await + { + vec.push(DirEntry::new( + path.join(entry.name()), + Metadata { + is_file: true, + size: blob.size() as u64, + }, + )); + } + } + web_sys::FileSystemHandleKind::Directory => { + vec.push(DirEntry::new( + path.join(entry.name()), + Metadata { + is_file: false, + size: 0, + }, + )); + } + _ => (), + } + } + + Ok(vec) + }) + .await; + } + + FileSystemCommand::DirDrop(key, tx) => { + handle_event(tx, async { + if dirs.contains(key) { + dirs.remove(key); + true + } else { + false + } + }) + .await; + } + + FileSystemCommand::DirClone(key, tx) => { + handle_event(tx, async { dirs.insert(dirs.get(key).unwrap().clone()) }).await; + } + + FileSystemCommand::FileRead(key, max_length, tx) => { + handle_event(tx, async { + let file = files.get_mut(key).unwrap(); + + let read_handle = (if file.read_allowed { + to_future::(file.file_handle.get_file()) + .await + .ok() + } else { + None + }) + .ok_or(PermissionDenied)?; + + let blob = read_handle + .slice_with_f64_and_f64( + file.offset as f64, + (file.offset + max_length) as f64, + ) + .map_err(|_| PermissionDenied)?; + + let buffer = to_future::(blob.array_buffer()) + .await + .map_err(|_| PermissionDenied)?; + + let u8_array = js_sys::Uint8Array::new(&buffer); + let vec = u8_array.to_vec(); + file.offset += vec.len(); + Ok(vec) + }) + .await; + } + + FileSystemCommand::FileWrite(key, vec, tx) => { + handle_event(tx, async { + let file = files.get_mut(key).unwrap(); + let write_handle = file.write_handle.as_ref().ok_or(PermissionDenied)?; + + // We can't use `write_handle.write_with_u8_array()` when shared memory is enabled + let u8_array = + js_sys::Uint8Array::new(&JsValue::from_f64(vec.len() as f64)); + u8_array.copy_from(&vec[..]); + if to_future::( + write_handle.seek_with_f64(file.offset as f64).unwrap(), + ) + .await + .is_ok() + && to_future::( + write_handle.write_with_buffer_source(&u8_array).unwrap(), + ) + .await + .is_ok() + { + file.offset += vec.len(); + Ok(()) + } else { + Err(PermissionDenied.into()) + } + }) + .await; + } + + FileSystemCommand::FileFlush(key, tx) => { + handle_event(tx, async { + let file = files.get_mut(key).unwrap(); + + // Closing and reopening the handle is the only way to flush + if file.write_handle.is_none() + || to_future::(file.write_handle.as_ref().unwrap().close()) + .await + .is_err() + { + return Err(PermissionDenied.into()); + } + let mut options = web_sys::FileSystemCreateWritableOptions::new(); + options.keep_existing_data(true); + if let Ok(write_handle) = + to_future(file.file_handle.create_writable_with_options(&options)).await + { + file.write_handle = Some(write_handle); + Ok(()) + } else { + Err(PermissionDenied.into()) + } + }) + .await; + } + + FileSystemCommand::FileSeek(key, seek_from, tx) => { + handle_event(tx, async { + let file = files.get_mut(key).unwrap(); + let read_handle = (if file.read_allowed { + to_future::(file.file_handle.get_file()) + .await + .ok() + } else { + None + }) + .ok_or(PermissionDenied)?; + + let size = read_handle.size(); + let new_offset = match seek_from { + std::io::SeekFrom::Start(i) => i as i64, + std::io::SeekFrom::End(i) => i + size as i64, + std::io::SeekFrom::Current(i) => i + file.offset as i64, + }; + if new_offset >= 0 { + file.offset = new_offset as usize; + Ok(new_offset as u64) + } else { + Err(InvalidInput.into()) + } + }) + .await; + } + + FileSystemCommand::FileSize(key, tx) => { + handle_event(tx, async { + let file = files.get_mut(key).unwrap(); + to_future::(file.file_handle.get_file()) + .await + .map(|file| file.size() as u64) + .map_err(|_| PermissionDenied.into()) + }) + .await; + } + + FileSystemCommand::FileDrop(key, tx) => { + handle_event(tx, async { + if files.contains(key) { + let file = files.remove(key); + // We need to close the write handle to flush any changes that the user + // made to the file + if let Some(write_handle) = &file.write_handle { + let _ = to_future::(write_handle.close()).await; + } + true + } else { + false + } + }) + .await; + } + } + } + }); +} diff --git a/crates/filesystem/src/web/mod.rs b/crates/filesystem/src/web/mod.rs new file mode 100644 index 00000000..6e1e07d1 --- /dev/null +++ b/crates/filesystem/src/web/mod.rs @@ -0,0 +1,292 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . + +mod events; +mod util; +pub use events::setup_main_thread_hooks; + +use super::FileSystem as FileSystemTrait; +use super::{DirEntry, Error, Metadata, OpenFlags, Result}; +use util::{send_and_await, send_and_recv}; + +static WORKER_CHANNELS: once_cell::sync::OnceCell = + once_cell::sync::OnceCell::new(); + +#[derive(Debug)] +pub struct WorkerChannels { + command_tx: flume::Sender, +} + +impl WorkerChannels { + fn send(&self, command: FileSystemCommand) { + self.command_tx.send(command).unwrap(); + } +} + +#[derive(Debug)] +pub struct MainChannels { + command_rx: flume::Receiver, +} + +/// Creates a new connected `(WorkerChannels, MainChannels)` pair for initializing filesystems. +pub fn channels() -> (WorkerChannels, MainChannels) { + let (command_tx, command_rx) = flume::unbounded(); + (WorkerChannels { command_tx }, MainChannels { command_rx }) +} + +#[derive(Debug)] +pub struct FileSystem { + key: usize, + name: String, + idb_key: Option, +} + +#[derive(Debug)] +pub struct File { + key: usize, +} + +#[derive(Debug)] +enum FileSystemCommand { + Supported(oneshot::Sender), + DirEntryMetadata( + usize, + camino::Utf8PathBuf, + oneshot::Sender>, + ), + DirPicker(oneshot::Sender)>>), + DirFromIdb(String, oneshot::Sender>), + DirSubdir( + usize, + camino::Utf8PathBuf, + oneshot::Sender)>>, + ), + DirIdbDrop(String, oneshot::Sender), + DirOpenFile( + usize, + camino::Utf8PathBuf, + OpenFlags, + oneshot::Sender>, + ), + DirEntryExists(usize, camino::Utf8PathBuf, oneshot::Sender), + DirCreateDir(usize, camino::Utf8PathBuf, oneshot::Sender>), + DirRemoveDir(usize, camino::Utf8PathBuf, oneshot::Sender>), + DirRemoveFile(usize, camino::Utf8PathBuf, oneshot::Sender>), + DirReadDir( + usize, + camino::Utf8PathBuf, + oneshot::Sender>>, + ), + DirDrop(usize, oneshot::Sender), + DirClone(usize, oneshot::Sender), + FileRead(usize, usize, oneshot::Sender>>), + FileWrite(usize, Vec, oneshot::Sender>), + FileFlush(usize, oneshot::Sender>), + FileSeek( + usize, + std::io::SeekFrom, + oneshot::Sender>, + ), + FileSize(usize, oneshot::Sender>), + FileDrop(usize, oneshot::Sender), +} + +fn worker_channels_or_die() -> &'static WorkerChannels { + WORKER_CHANNELS.get().expect("FileSystem worker channels have not been initialized! Please call `FileSystem::setup_worker_channels` before calling this function.") +} + +impl FileSystem { + /// Initializes the channels that we use to send filesystem commands to the main thread. + /// This must be called before performing any filesystem operations. + pub fn setup_worker_channels(worker_channels: WorkerChannels) { + WORKER_CHANNELS + .set(worker_channels) + .expect("FileSystem worker channels cannot be initialized twice"); + } + + /// Returns whether or not the user's browser supports the JavaScript File System API. + pub fn filesystem_supported() -> bool { + send_and_recv(|tx| FileSystemCommand::Supported(tx)) + } + + /// Attempts to prompt the user to choose a directory from their local machine using the + /// JavaScript File System API. + /// Then creates a `FileSystem` allowing read-write access to that directory if they chose one + /// successfully. + /// If the File System API is not supported, this always returns `None` without doing anything. + pub async fn from_folder_picker() -> Result { + if !Self::filesystem_supported() { + return Err(Error::Wasm32FilesystemNotSupported); + } + send_and_await(|tx| FileSystemCommand::DirPicker(tx)) + .await + .map(|(key, name, idb_key)| FileSystem { key, name, idb_key }) + .ok_or(Error::CancelledLoading) + } + + /// Attempts to restore a previously created `FileSystem` using its `.idb_key()`. + pub async fn from_idb_key(idb_key: String) -> Result { + if !Self::filesystem_supported() { + return Err(Error::Wasm32FilesystemNotSupported); + } + send_and_await(|tx| FileSystemCommand::DirFromIdb(idb_key.clone(), tx)) + .await + .map(|(key, name)| FileSystem { + key, + name, + idb_key: Some(idb_key), + }) + .ok_or(Error::MissingIDB) + } + + /// Creates a new `FileSystem` from a subdirectory of this one. + pub fn subdir(&self, path: impl AsRef) -> Result { + send_and_recv(|tx| FileSystemCommand::DirSubdir(self.key, path.as_ref().to_path_buf(), tx)) + .map(|(key, name, idb_key)| FileSystem { key, name, idb_key }) + } + + /// Drops the directory with the given key from IndexedDB if it exists in there. + pub fn idb_drop(idb_key: String) -> bool { + send_and_recv(|tx| FileSystemCommand::DirIdbDrop(idb_key, tx)) + } + + /// Returns a path consisting of a single element: the name of the root directory of this + /// filesystem. + pub fn root_path(&self) -> &camino::Utf8Path { + self.name.as_str().into() + } + + /// Returns the key needed to restore this `FileSystem` using `FileSystem::from_idb()`. + pub fn idb_key(&self) -> Option<&str> { + self.idb_key.as_deref() + } +} + +impl Drop for FileSystem { + fn drop(&mut self) { + let _ = send_and_recv(|tx| FileSystemCommand::DirDrop(self.key, tx)); + } +} + +impl Clone for FileSystem { + fn clone(&self) -> Self { + Self { + key: send_and_recv(|tx| FileSystemCommand::DirClone(self.key, tx)), + name: self.name.clone(), + idb_key: self.idb_key.clone(), + } + } +} + +impl FileSystemTrait for FileSystem { + type File = File; + + fn open_file( + &self, + path: impl AsRef, + flags: OpenFlags, + ) -> Result { + send_and_recv(|tx| { + FileSystemCommand::DirOpenFile(self.key, path.as_ref().to_path_buf(), flags, tx) + }) + .map(|key| File { key }) + } + + fn metadata(&self, path: impl AsRef) -> Result { + send_and_recv(|tx| { + FileSystemCommand::DirEntryMetadata(self.key, path.as_ref().to_path_buf(), tx) + }) + } + + fn rename( + &self, + _from: impl AsRef, + _to: impl AsRef, + ) -> Result<()> { + Err(Error::NotSupported) + } + + fn exists(&self, path: impl AsRef) -> Result { + Ok(send_and_recv(|tx| { + FileSystemCommand::DirEntryExists(self.key, path.as_ref().to_path_buf(), tx) + })) + } + + fn create_dir(&self, path: impl AsRef) -> Result<()> { + send_and_recv(|tx| { + FileSystemCommand::DirCreateDir(self.key, path.as_ref().to_path_buf(), tx) + }) + } + + fn remove_dir(&self, path: impl AsRef) -> Result<()> { + send_and_recv(|tx| { + FileSystemCommand::DirRemoveDir(self.key, path.as_ref().to_path_buf(), tx) + }) + } + + fn remove_file(&self, path: impl AsRef) -> Result<()> { + send_and_recv(|tx| { + FileSystemCommand::DirRemoveFile(self.key, path.as_ref().to_path_buf(), tx) + }) + } + + fn read_dir(&self, path: impl AsRef) -> Result> { + send_and_recv(|tx| FileSystemCommand::DirReadDir(self.key, path.as_ref().to_path_buf(), tx)) + } +} + +impl Drop for File { + fn drop(&mut self) { + let _ = send_and_recv(|tx| FileSystemCommand::FileDrop(self.key, tx)); + } +} + +impl crate::File for File { + fn metadata(&self) -> crate::Result { + let size = send_and_recv(|tx| FileSystemCommand::FileSize(self.key, tx))?; + Ok(Metadata { + is_file: true, + size, + }) + } +} + +impl std::io::Read for File { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let vec = send_and_recv(|tx| FileSystemCommand::FileRead(self.key, buf.len(), tx))?; + let length = vec.len(); + buf[..length].copy_from_slice(&vec[..]); + Ok(length) + } +} + +impl std::io::Write for File { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + send_and_recv(|tx| FileSystemCommand::FileWrite(self.key, buf.to_vec(), tx))?; + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + send_and_recv(|tx| FileSystemCommand::FileFlush(self.key, tx)) + } +} + +impl std::io::Seek for File { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + send_and_recv(|tx| FileSystemCommand::FileSeek(self.key, pos, tx)) + } +} diff --git a/crates/filesystem/src/web/util.rs b/crates/filesystem/src/web/util.rs new file mode 100644 index 00000000..60335cd5 --- /dev/null +++ b/crates/filesystem/src/web/util.rs @@ -0,0 +1,118 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . + +use indexed_db_futures::prelude::*; +use rand::Rng; +use std::future::IntoFuture; +use wasm_bindgen::prelude::*; + +/// Casts a `js_sys::Promise` into a future. +pub(super) async fn to_future(promise: js_sys::Promise) -> std::result::Result +where + T: JsCast, +{ + wasm_bindgen_futures::JsFuture::from(promise) + .await + .map(|t| t.unchecked_into()) + .map_err(|e| e.unchecked_into()) +} + +/// Returns a subdirectory of a given directory given the relative path of the subdirectory. +pub(super) async fn get_subdir( + dir: &web_sys::FileSystemDirectoryHandle, + path_iter: &mut camino::Iter<'_>, +) -> Option { + let mut dir = dir.clone(); + loop { + let Some(path_element) = path_iter.next() else { + return Some(dir); + }; + if let Ok(subdir) = to_future(dir.get_directory_handle(path_element)).await { + dir = subdir; + } else { + return None; + } + } +} + +/// Generates a random string suitable for use as a unique identifier. +pub(super) fn generate_key() -> String { + rand::thread_rng() + .sample_iter(rand::distributions::Alphanumeric) + .take(42) // This should be enough to avoid collisions + .map(char::from) + .collect() +} + +/// Helper function for performing IndexedDB operations on an `IdbObjectStore` with a given +/// `IdbTransactionMode`. +pub(super) async fn idb( + mode: IdbTransactionMode, + f: impl Fn(IdbObjectStore<'_>) -> std::result::Result, +) -> std::result::Result { + let mut db_req = IdbDatabase::open_u32("astrabit.luminol", 1)?; + + // Create store for our directory handles if it doesn't exist + db_req.set_on_upgrade_needed(Some(|e: &IdbVersionChangeEvent| { + if e.db() + .object_store_names() + .find(|n| n == "filesystem.dir_handles") + .is_none() + { + e.db().create_object_store("filesystem.dir_handles")?; + } + Ok(()) + })); + + let db = db_req.into_future().await?; + let tx = db.transaction_on_one_with_mode("filesystem.dir_handles", mode)?; + let store = tx.object_store("filesystem.dir_handles")?; + let r = f(store); + tx.await.into_result()?; + r +} + +/// Wrapper function for handling filesystem events on the worker thread. +/// You can insert logging into this function for debug purposes. +pub(super) async fn handle_event( + tx: oneshot::Sender, + f: impl std::future::Future, +) { + tx.send(f.await).unwrap(); +} + +fn send(f: impl FnOnce(oneshot::Sender) -> super::FileSystemCommand) -> oneshot::Receiver { + let (oneshot_tx, oneshot_rx) = oneshot::channel(); + super::worker_channels_or_die().send(f(oneshot_tx)); + oneshot_rx +} + +/// Helper function to send a filesystem command from the worker thread to the main thread and then +/// block the worker thread to wait for the result. +pub(super) fn send_and_recv( + f: impl FnOnce(oneshot::Sender) -> super::FileSystemCommand, +) -> R { + send(f).recv().unwrap() +} + +/// Helper function to send a filesystem command from the worker thread to the main thread and then +/// wait asynchronously on the worker thread for the result. +pub(super) async fn send_and_await( + f: impl FnOnce(oneshot::Sender) -> super::FileSystemCommand, +) -> R { + send(f).await.unwrap() +} diff --git a/crates/graphics/Cargo.toml b/crates/graphics/Cargo.toml index a0c61b5d..c3c45876 100644 --- a/crates/graphics/Cargo.toml +++ b/crates/graphics/Cargo.toml @@ -21,12 +21,12 @@ image = "0.24.7" egui.workspace = true egui_extras.workspace = true -egui-wgpu.workspace = true +luminol-egui-wgpu.workspace = true wgpu.workspace = true glam.workspace = true -naga_oil = "0.10.1" -naga = "0.13.0" +naga_oil = "0.11.0" +naga = "0.14.1" once_cell.workspace = true crossbeam.workspace = true diff --git a/crates/graphics/src/collision/instance.rs b/crates/graphics/src/collision/instance.rs index 28d354d3..48b4babe 100644 --- a/crates/graphics/src/collision/instance.rs +++ b/crates/graphics/src/collision/instance.rs @@ -36,7 +36,10 @@ struct Instance { } impl Instances { - pub fn new(render_state: &egui_wgpu::RenderState, passages: &luminol_data::Table2) -> Self { + pub fn new( + render_state: &luminol_egui_wgpu::RenderState, + passages: &luminol_data::Table2, + ) -> Self { let instances = Self::calculate_instances(passages); let instance_buffer = render_state @@ -68,7 +71,7 @@ impl Instances { pub fn set_passage( &self, - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, passage: i16, position: (usize, usize), ) { diff --git a/crates/graphics/src/collision/mod.rs b/crates/graphics/src/collision/mod.rs index 5b430d73..4be6b41c 100644 --- a/crates/graphics/src/collision/mod.rs +++ b/crates/graphics/src/collision/mod.rs @@ -173,7 +173,7 @@ impl Collision { pub fn set_passage( &self, - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, passage: i16, position: (usize, usize), ) { @@ -203,7 +203,9 @@ impl Collision { } } -pub fn create_bind_group_layout(render_state: &egui_wgpu::RenderState) -> wgpu::BindGroupLayout { +pub fn create_bind_group_layout( + render_state: &luminol_egui_wgpu::RenderState, +) -> wgpu::BindGroupLayout { let mut builder = BindGroupLayoutBuilder::new(); if !crate::push_constants_supported(render_state) { diff --git a/crates/graphics/src/collision/shader.rs b/crates/graphics/src/collision/shader.rs index 4715aeb5..a6153cfd 100644 --- a/crates/graphics/src/collision/shader.rs +++ b/crates/graphics/src/collision/shader.rs @@ -19,7 +19,7 @@ use super::instance::Instances; use super::Vertex; pub fn create_render_pipeline( - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, bind_group_layouts: &crate::BindGroupLayouts, ) -> wgpu::RenderPipeline { let push_constants_supported = crate::push_constants_supported(render_state); diff --git a/crates/graphics/src/event.rs b/crates/graphics/src/event.rs index 21c1304c..91ae1488 100644 --- a/crates/graphics/src/event.rs +++ b/crates/graphics/src/event.rs @@ -38,12 +38,12 @@ unsafe impl Send for Callback {} #[allow(unsafe_code)] unsafe impl Sync for Callback {} -impl egui_wgpu::CallbackTrait for Callback { +impl luminol_egui_wgpu::CallbackTrait for Callback { fn paint<'a>( &'a self, _info: egui::PaintCallbackInfo, render_pass: &mut wgpu::RenderPass<'a>, - _callback_resources: &'a egui_wgpu::CallbackResources, + _callback_resources: &'a luminol_egui_wgpu::CallbackResources, ) { self.sprite.draw(&self.graphics_state, render_pass); } @@ -127,7 +127,7 @@ impl Event { &self.sprite } - pub fn set_proj(&self, render_state: &egui_wgpu::RenderState, proj: glam::Mat4) { + pub fn set_proj(&self, render_state: &luminol_egui_wgpu::RenderState, proj: glam::Mat4) { self.viewport.set_proj(render_state, proj); } @@ -137,7 +137,7 @@ impl Event { painter: &egui::Painter, rect: egui::Rect, ) { - painter.add(egui_wgpu::Callback::new_paint_callback( + painter.add(luminol_egui_wgpu::Callback::new_paint_callback( rect, Callback { sprite: self.sprite.clone(), diff --git a/crates/graphics/src/lib.rs b/crates/graphics/src/lib.rs index 013f659a..2882aaf2 100644 --- a/crates/graphics/src/lib.rs +++ b/crates/graphics/src/lib.rs @@ -42,7 +42,7 @@ pub use texture_loader::Texture; pub struct GraphicsState { pub texture_loader: texture_loader::Loader, pub atlas_loader: atlas_loader::Loader, - pub render_state: egui_wgpu::RenderState, + pub render_state: luminol_egui_wgpu::RenderState, pub nearest_sampler: wgpu::Sampler, @@ -63,7 +63,7 @@ pub struct Pipelines { } impl GraphicsState { - pub fn new(render_state: egui_wgpu::RenderState) -> Self { + pub fn new(render_state: luminol_egui_wgpu::RenderState) -> Self { let bind_group_layouts = BindGroupLayouts { sprite: sprite::create_bind_group_layout(&render_state), tiles: tiles::create_bind_group_layout(&render_state), @@ -110,7 +110,7 @@ impl GraphicsState { } } -pub fn push_constants_supported(render_state: &egui_wgpu::RenderState) -> bool { +pub fn push_constants_supported(render_state: &luminol_egui_wgpu::RenderState) -> bool { let feature_supported = render_state .device .features() diff --git a/crates/graphics/src/map.rs b/crates/graphics/src/map.rs index 08e865b4..a2e8b658 100644 --- a/crates/graphics/src/map.rs +++ b/crates/graphics/src/map.rs @@ -68,12 +68,12 @@ unsafe impl Send for OverlayCallback {} #[allow(unsafe_code)] unsafe impl Sync for OverlayCallback {} -impl egui_wgpu::CallbackTrait for Callback { +impl luminol_egui_wgpu::CallbackTrait for Callback { fn paint<'a>( &'a self, _info: egui::PaintCallbackInfo, render_pass: &mut wgpu::RenderPass<'a>, - _callback_resources: &'a egui_wgpu::CallbackResources, + _callback_resources: &'a luminol_egui_wgpu::CallbackResources, ) { if self.pano_enabled { if let Some(panorama) = &self.resources.panorama { @@ -90,12 +90,12 @@ impl egui_wgpu::CallbackTrait for Callback { } } -impl egui_wgpu::CallbackTrait for OverlayCallback { +impl luminol_egui_wgpu::CallbackTrait for OverlayCallback { fn paint<'a>( &'a self, _info: egui::PaintCallbackInfo, render_pass: &mut wgpu::RenderPass<'a>, - _callback_resources: &'a egui_wgpu::CallbackResources, + _callback_resources: &'a luminol_egui_wgpu::CallbackResources, ) { if self.fog_enabled { if let Some(fog) = &self.resources.fog { @@ -191,7 +191,7 @@ impl Map { pub fn set_tile( &self, - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, tile_id: i16, position: (usize, usize, usize), ) { @@ -202,7 +202,7 @@ impl Map { pub fn set_passage( &self, - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, passage: i16, position: (usize, usize), ) { @@ -211,7 +211,7 @@ impl Map { .set_passage(render_state, passage, position); } - pub fn set_proj(&self, render_state: &egui_wgpu::RenderState, proj: glam::Mat4) { + pub fn set_proj(&self, render_state: &luminol_egui_wgpu::RenderState, proj: glam::Mat4) { self.viewport.set_proj(render_state, proj); } @@ -239,7 +239,7 @@ impl Map { .ctx() .request_repaint_after(Duration::from_secs_f64(16. / 60.)); - painter.add(egui_wgpu::Callback::new_paint_callback( + painter.add(luminol_egui_wgpu::Callback::new_paint_callback( rect, Callback { resources: self.resources.clone(), @@ -258,7 +258,7 @@ impl Map { painter: &egui::Painter, rect: egui::Rect, ) { - painter.add(egui_wgpu::Callback::new_paint_callback( + painter.add(luminol_egui_wgpu::Callback::new_paint_callback( rect, OverlayCallback { resources: self.resources.clone(), diff --git a/crates/graphics/src/quad.rs b/crates/graphics/src/quad.rs index 00a23f9b..79b9e3dc 100644 --- a/crates/graphics/src/quad.rs +++ b/crates/graphics/src/quad.rs @@ -107,7 +107,7 @@ impl Quad { } pub fn into_buffer( - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, this: &[Self], extents: wgpu::Extent3d, ) -> (wgpu::Buffer, usize) { diff --git a/crates/graphics/src/sprite/graphic.rs b/crates/graphics/src/sprite/graphic.rs index 015bea1f..df74b081 100644 --- a/crates/graphics/src/sprite/graphic.rs +++ b/crates/graphics/src/sprite/graphic.rs @@ -66,7 +66,7 @@ impl Graphic { (self.data.load().hue * 360.) as i32 } - pub fn set_hue(&self, render_state: &egui_wgpu::RenderState, hue: i32) { + pub fn set_hue(&self, render_state: &luminol_egui_wgpu::RenderState, hue: i32) { let hue = (hue % 360) as f32 / 360.0; let data = self.data.load(); @@ -80,7 +80,7 @@ impl Graphic { (self.data.load().opacity * 255.) as i32 } - pub fn set_opacity(&self, render_state: &egui_wgpu::RenderState, opacity: i32) { + pub fn set_opacity(&self, render_state: &luminol_egui_wgpu::RenderState, opacity: i32) { let opacity = opacity as f32 / 255.0; let data = self.data.load(); @@ -96,7 +96,7 @@ impl Graphic { pub fn set_opacity_multiplier( &self, - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, opacity_multiplier: f32, ) { let data = self.data.load(); @@ -118,7 +118,7 @@ impl Graphic { self.uniform.as_ref() } - fn regen_buffer(&self, render_state: &egui_wgpu::RenderState) { + fn regen_buffer(&self, render_state: &luminol_egui_wgpu::RenderState) { if let Some(uniform) = &self.uniform { render_state .queue diff --git a/crates/graphics/src/sprite/mod.rs b/crates/graphics/src/sprite/mod.rs index 73ea0a02..ec9097bf 100644 --- a/crates/graphics/src/sprite/mod.rs +++ b/crates/graphics/src/sprite/mod.rs @@ -76,7 +76,7 @@ impl Sprite { } } - pub fn reupload_verts(&self, render_state: &egui_wgpu::RenderState, quads: &[Quad]) { + pub fn reupload_verts(&self, render_state: &luminol_egui_wgpu::RenderState, quads: &[Quad]) { let vertices = Quad::into_vertices(quads, self.texture.size()); render_state.queue.write_buffer( &self.vertices.vertex_buffer, @@ -112,7 +112,9 @@ impl Sprite { } } -pub fn create_bind_group_layout(render_state: &egui_wgpu::RenderState) -> wgpu::BindGroupLayout { +pub fn create_bind_group_layout( + render_state: &luminol_egui_wgpu::RenderState, +) -> wgpu::BindGroupLayout { let mut builder = BindGroupLayoutBuilder::new(); builder .append( diff --git a/crates/graphics/src/sprite/shader.rs b/crates/graphics/src/sprite/shader.rs index d3c72d72..fd705c7f 100644 --- a/crates/graphics/src/sprite/shader.rs +++ b/crates/graphics/src/sprite/shader.rs @@ -18,7 +18,7 @@ use crate::{vertex::Vertex, BindGroupLayouts}; fn create_shader( - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, bind_group_layouts: &BindGroupLayouts, target: wgpu::BlendState, ) -> wgpu::RenderPipeline { @@ -109,7 +109,7 @@ fn create_shader( } pub fn create_sprite_shaders( - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, bind_group_layouts: &BindGroupLayouts, ) -> std::collections::HashMap { [ diff --git a/crates/graphics/src/sprite/vertices.rs b/crates/graphics/src/sprite/vertices.rs index 87fedbed..85f39592 100644 --- a/crates/graphics/src/sprite/vertices.rs +++ b/crates/graphics/src/sprite/vertices.rs @@ -24,7 +24,7 @@ pub struct Vertices { impl Vertices { pub fn from_quads( - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, quads: &[Quad], extents: wgpu::Extent3d, ) -> Self { diff --git a/crates/graphics/src/texture_loader.rs b/crates/graphics/src/texture_loader.rs index 2f657616..e5efcd60 100644 --- a/crates/graphics/src/texture_loader.rs +++ b/crates/graphics/src/texture_loader.rs @@ -31,7 +31,7 @@ use wgpu::util::DeviceExt; pub struct Loader { loaded_textures: DashMap>, - render_state: egui_wgpu::RenderState, + render_state: luminol_egui_wgpu::RenderState, } pub struct Texture { @@ -39,7 +39,7 @@ pub struct Texture { pub view: wgpu::TextureView, pub texture_id: egui::TextureId, - render_state: egui_wgpu::RenderState, + render_state: luminol_egui_wgpu::RenderState, } impl Drop for Texture { @@ -99,7 +99,7 @@ impl Texture { } impl Loader { - pub fn new(render_state: egui_wgpu::RenderState) -> Self { + pub fn new(render_state: luminol_egui_wgpu::RenderState) -> Self { Self { loaded_textures: DashMap::with_capacity(64), diff --git a/crates/graphics/src/tiles/atlas.rs b/crates/graphics/src/tiles/atlas.rs index cda0398d..f974e965 100644 --- a/crates/graphics/src/tiles/atlas.rs +++ b/crates/graphics/src/tiles/atlas.rs @@ -326,7 +326,7 @@ impl Atlas { } fn write_texture_region

( - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, texture: &wgpu::Texture, image: image::SubImage<&image::ImageBuffer>>, (dest_x, dest_y): (u32, u32), diff --git a/crates/graphics/src/tiles/autotiles.rs b/crates/graphics/src/tiles/autotiles.rs index c184b66f..b76ad107 100644 --- a/crates/graphics/src/tiles/autotiles.rs +++ b/crates/graphics/src/tiles/autotiles.rs @@ -62,7 +62,7 @@ impl Autotiles { } } - pub fn inc_ani_index(&self, render_state: &egui_wgpu::RenderState) { + pub fn inc_ani_index(&self, render_state: &luminol_egui_wgpu::RenderState) { let data = self.data.load(); self.data.store(Data { ani_index: data.ani_index.wrapping_add(1), @@ -79,7 +79,7 @@ impl Autotiles { self.uniform.as_ref() } - fn regen_buffer(&self, render_state: &egui_wgpu::RenderState) { + fn regen_buffer(&self, render_state: &luminol_egui_wgpu::RenderState) { if let Some(uniform) = &self.uniform { render_state .queue diff --git a/crates/graphics/src/tiles/instance.rs b/crates/graphics/src/tiles/instance.rs index c2336e19..deab1c2d 100644 --- a/crates/graphics/src/tiles/instance.rs +++ b/crates/graphics/src/tiles/instance.rs @@ -46,7 +46,7 @@ const TILE_QUAD: Quad = Quad::new( impl Instances { pub fn new( - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, map_data: &luminol_data::Table3, atlas_size: wgpu::Extent3d, ) -> Self { @@ -74,7 +74,7 @@ impl Instances { // I thought we didn't need the z? Well.. we do! To calculate the offset into the instance buffer. pub fn set_tile( &self, - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, tile_id: i16, position: (usize, usize, usize), ) { diff --git a/crates/graphics/src/tiles/mod.rs b/crates/graphics/src/tiles/mod.rs index 74d62f99..22f89d75 100644 --- a/crates/graphics/src/tiles/mod.rs +++ b/crates/graphics/src/tiles/mod.rs @@ -89,7 +89,7 @@ impl Tiles { pub fn set_tile( &self, - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, tile_id: i16, position: (usize, usize, usize), ) { @@ -149,7 +149,9 @@ impl Tiles { } } -pub fn create_bind_group_layout(render_state: &egui_wgpu::RenderState) -> wgpu::BindGroupLayout { +pub fn create_bind_group_layout( + render_state: &luminol_egui_wgpu::RenderState, +) -> wgpu::BindGroupLayout { let mut builder = BindGroupLayoutBuilder::new(); builder .append( diff --git a/crates/graphics/src/tiles/opacity.rs b/crates/graphics/src/tiles/opacity.rs index 991ed544..65721a11 100644 --- a/crates/graphics/src/tiles/opacity.rs +++ b/crates/graphics/src/tiles/opacity.rs @@ -54,7 +54,12 @@ impl Opacity { self.uniform.as_ref() } - pub fn set_opacity(&self, render_state: &egui_wgpu::RenderState, layer: usize, opacity: f32) { + pub fn set_opacity( + &self, + render_state: &luminol_egui_wgpu::RenderState, + layer: usize, + opacity: f32, + ) { let mut data = self.data.load(); if data[layer] != opacity { data[layer] = opacity; @@ -63,7 +68,7 @@ impl Opacity { } } - fn regen_buffer(&self, render_state: &egui_wgpu::RenderState) { + fn regen_buffer(&self, render_state: &luminol_egui_wgpu::RenderState) { if let Some(uniform) = &self.uniform { render_state .queue diff --git a/crates/graphics/src/tiles/shader.rs b/crates/graphics/src/tiles/shader.rs index 19aa45bf..2e9c8306 100644 --- a/crates/graphics/src/tiles/shader.rs +++ b/crates/graphics/src/tiles/shader.rs @@ -19,7 +19,7 @@ use super::instance::Instances; use crate::{vertex::Vertex, BindGroupLayouts}; pub fn create_render_pipeline( - render_state: &egui_wgpu::RenderState, + render_state: &luminol_egui_wgpu::RenderState, bind_group_layouts: &BindGroupLayouts, ) -> wgpu::RenderPipeline { let push_constants_supported = crate::push_constants_supported(render_state); diff --git a/crates/graphics/src/viewport.rs b/crates/graphics/src/viewport.rs index 1beffe36..93744412 100644 --- a/crates/graphics/src/viewport.rs +++ b/crates/graphics/src/viewport.rs @@ -51,14 +51,19 @@ impl Viewport { } } - pub fn set_width_height(&self, render_state: &egui_wgpu::RenderState, width: f32, height: f32) { + pub fn set_width_height( + &self, + render_state: &luminol_egui_wgpu::RenderState, + width: f32, + height: f32, + ) { self.set_proj( render_state, glam::Mat4::orthographic_rh(0.0, width, height, 0.0, -1.0, 1.0), ) } - pub fn set_proj(&self, render_state: &egui_wgpu::RenderState, proj: glam::Mat4) { + pub fn set_proj(&self, render_state: &luminol_egui_wgpu::RenderState, proj: glam::Mat4) { let data = self.data.load(); if data != proj { self.data.store(proj); @@ -74,7 +79,7 @@ impl Viewport { self.uniform.as_ref() } - fn regen_buffer(&self, render_state: &egui_wgpu::RenderState) { + fn regen_buffer(&self, render_state: &luminol_egui_wgpu::RenderState) { if let Some(uniform) = &self.uniform { render_state .queue diff --git a/crates/ui/src/windows/command_gen/parameter_ui.rs b/crates/ui/src/windows/command_gen/parameter_ui.rs index f7eb7de0..29b28cb4 100644 --- a/crates/ui/src/windows/command_gen/parameter_ui.rs +++ b/crates/ui/src/windows/command_gen/parameter_ui.rs @@ -23,7 +23,6 @@ // Program grant you additional permission to convey the resulting work. use command_lib::{Index, Parameter, ParameterKind}; -use eframe::egui; use strum::IntoEnumIterator; diff --git a/crates/ui/src/windows/command_gen/ui_example.rs b/crates/ui/src/windows/command_gen/ui_example.rs index a7e19a10..3db39cc8 100644 --- a/crates/ui/src/windows/command_gen/ui_example.rs +++ b/crates/ui/src/windows/command_gen/ui_example.rs @@ -23,7 +23,6 @@ // Program grant you additional permission to convey the resulting work. use command_lib::{CommandDescription, CommandKind, Parameter, ParameterKind}; -use eframe::egui; pub struct UiExample { command: CommandDescription, diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 4bbfb637..a5346621 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -17,66 +17,16 @@ workspace = true [dependencies] egui.workspace = true -eframe.workspace = true -egui-wgpu.workspace = true +luminol-egui-wgpu.workspace = true wgpu.workspace = true -ron.workspace = true - -once_cell.workspace = true - -tracing.workspace = true - -luminol-app.workspace = true - -flume.workspace = true -oneshot.workspace = true - wasm-bindgen = "0.2.87" wasm-bindgen-futures = "0.4" -portable-atomic = { version = "1.5.1", features = ["float"] } - js-sys = "0.3" web-sys = { version = "0.3", features = [ - "console", - "Window", - "Document", - "Element", - "DomException", - - "EventTarget", - "EventListener", - "AddEventListenerOptions", - - "Event", - "MouseEvent", - "WheelEvent", - "DedicatedWorkerGlobalScope", - "WorkerGlobalScope", - - "Worker", - "WorkerOptions", - "WorkerType", - + "FileSystemDirectoryHandle", + "FileSystemHandle", "Performance", - - "HtmlCanvasElement", - "OffscreenCanvas", - "OffscreenCanvasRenderingContext2d", - - "MutationObserver", - "MutationObserverInit", - "MutationRecord", - "Node", - - "Clipboard", - - "Storage", - - "WorkerLocation", - "Location", - "WorkerNavigator", - "Navigator", ] } diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index a417d0fc..98f36505 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -17,11 +17,3 @@ #[cfg(target_arch = "wasm32")] pub mod bindings; -#[cfg(target_arch = "wasm32")] -pub mod web_worker_runner; -#[cfg(target_arch = "wasm32")] -pub use web_worker_runner::WebWorkerRunner; -#[cfg(target_arch = "wasm32")] -pub use web_worker_runner::WebWorkerRunnerEvent; -#[cfg(target_arch = "wasm32")] -pub use web_worker_runner::WebWorkerRunnerOutput; diff --git a/crates/web/src/web_worker_runner.rs b/crates/web/src/web_worker_runner.rs deleted file mode 100644 index 9780ead0..00000000 --- a/crates/web/src/web_worker_runner.rs +++ /dev/null @@ -1,1093 +0,0 @@ -// Copyright (C) 2023 Lily Lyons -// -// This file is part of Luminol. -// -// Luminol is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Luminol is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Luminol. If not, see . -use super::bindings; - -use portable_atomic::{AtomicF64, Ordering}; -use std::sync::Arc; - -// ensure that AtomicF64 is using atomic ops (otherwise it would use global locks, and that would be bad) -const _: [(); 0 - !{ - const ASSERT: bool = AtomicF64::is_always_lock_free(); - ASSERT -} as usize] = []; - -use eframe::{egui_wgpu, wgpu}; -use wasm_bindgen::prelude::*; - -static PANIC_LOCK: once_cell::sync::OnceCell<()> = once_cell::sync::OnceCell::new(); - -#[derive(Default)] -struct Storage { - output_tx: Option>, -} - -impl eframe::Storage for Storage { - fn get_string(&self, key: &str) -> Option { - if let Some(output_tx) = &self.output_tx { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - output_tx - .send(WebWorkerRunnerOutput( - WebWorkerRunnerOutputInner::StorageGet(key.to_string(), oneshot_tx), - )) - .unwrap(); - oneshot_rx.recv().unwrap() - } else { - None - } - } - - fn set_string(&mut self, key: &str, value: String) { - if let Some(output_tx) = &self.output_tx { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - output_tx - .send(WebWorkerRunnerOutput( - WebWorkerRunnerOutputInner::StorageSet( - key.to_string(), - value.to_string(), - oneshot_tx, - ), - )) - .unwrap(); - if !oneshot_rx.recv().unwrap() { - tracing::warn!("Failed to save to local storage key {key}"); - } - } - } - - fn flush(&mut self) {} -} - -pub struct WebWorkerRunnerEvent(WebWorkerRunnerEventInner); - -enum WebWorkerRunnerEventInner { - /// (window.innerWidth, window.innerHeight, window.devicePixelRatio) - ScreenResize(u32, u32, f32), - /// This should be sent whenever the modifiers change - Modifiers(egui::Modifiers), - /// This should be sent whenever the app needs to save immediately - Save, -} - -pub struct WebWorkerRunnerOutput(WebWorkerRunnerOutputInner); - -enum WebWorkerRunnerOutputInner { - PlatformOutput(egui::PlatformOutput), - StorageGet(String, oneshot::Sender>), - StorageSet(String, String, oneshot::Sender), -} - -struct WebWorkerRunnerState { - app: Box, - app_id: String, - save_time: f64, - render_state: egui_wgpu::RenderState, - canvas: web_sys::OffscreenCanvas, - surface: wgpu::Surface, - surface_configuration: wgpu::SurfaceConfiguration, - modifiers: egui::Modifiers, - - /// Width of the canvas in points. `surface_configuration.width` is the width in pixels. - width: u32, - /// Height of the canvas in points. `surface_configuration.height` is the height in pixels. - height: u32, - /// Length of a pixel divided by length of a point. - pixel_ratio: f32, - - event_rx: Option>, - custom_event_rx: Option>, - output_tx: Option>, -} - -/// A runner for wgpu egui applications intended to be run in a web worker. -#[derive(Clone)] -pub struct WebWorkerRunner { - state: std::rc::Rc>, - storage: std::rc::Rc>, - context: egui::Context, - // FIXME: no idea what the orderings should be here - time_lock: Arc, -} - -type AppCreateFn = dyn FnOnce(&eframe::CreationContext<'_>) -> Box; - -impl WebWorkerRunner { - /// Creates a new `WebWorkerRunner` to render onto the given `OffscreenCanvas` with the - /// given configuration options. - /// - /// This function MUST be run in a web worker. - pub async fn new( - app_creator: Box, - canvas: web_sys::OffscreenCanvas, - web_options: eframe::WebOptions, - app_id: &str, - prefers_color_scheme_dark: Option, - event_rx: Option>, - custom_event_rx: Option>, - output_tx: Option>, - ) -> Self { - let Some(worker) = bindings::worker() else { - panic!("cannot use `WebWorkerRunner::new()` outside of a web worker"); - }; - - let time_lock = Arc::new(AtomicF64::new(0.)); - - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: web_options.wgpu_options.supported_backends, - dx12_shader_compiler: Default::default(), - }); - let surface = instance - .create_surface_from_offscreen_canvas(canvas.clone()) - .unwrap_or_else(|e| panic!("failed to create surface: {e}")); - - let depth_format = egui_wgpu::depth_format_from_bits(0, 0); - let render_state = egui_wgpu::RenderState::create( - &web_options.wgpu_options, - &instance, - &surface, - depth_format, - 1, - ) - .await - .unwrap_or_else(|e| panic!("failed to initialize renderer: {e}")); - - let width = canvas.width(); - let height = canvas.height(); - let surface_configuration = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format: render_state.target_format, - present_mode: web_options.wgpu_options.present_mode, - alpha_mode: wgpu::CompositeAlphaMode::Auto, - view_formats: vec![render_state.target_format], - width, - height, - }; - surface.configure(&render_state.device, &surface_configuration); - - let location = worker.location(); - let integration_info = eframe::IntegrationInfo { - system_theme: if web_options.follow_system_theme { - prefers_color_scheme_dark.map(|x| { - if x { - eframe::Theme::Dark - } else { - eframe::Theme::Light - } - }) - } else { - None - }, - web_info: eframe::WebInfo { - user_agent: worker.navigator().user_agent().unwrap_or_default(), - location: eframe::Location { - url: location - .href() - .strip_suffix("/worker.js") - .unwrap_or(location.href().as_str()) - .to_string(), - protocol: location.protocol(), - host: location.host(), - hostname: location.hostname(), - port: location.port(), - hash: Default::default(), - query: Default::default(), - query_map: Default::default(), - origin: location.origin(), - }, - }, - native_pixels_per_point: Some(1.), - cpu_usage: None, - }; - - let context = egui::Context::default(); - context.set_os(egui::os::OperatingSystem::from_user_agent( - integration_info.web_info.user_agent.as_str(), - )); - context.set_visuals( - integration_info - .system_theme - .unwrap_or(web_options.default_theme) - .egui_visuals(), - ); - { - let time_lock = time_lock.clone(); - context.set_request_repaint_callback(move |i| { - time_lock.store( - bindings::performance(&bindings::worker().unwrap()).now() / 1000. - + i.after.as_secs_f64(), - Ordering::Relaxed, - ) - }); - } - - if let Some(output_tx) = &output_tx { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - output_tx - .send(WebWorkerRunnerOutput( - WebWorkerRunnerOutputInner::StorageGet(app_id.to_string(), oneshot_tx), - )) - .unwrap(); - if let Some(memory) = oneshot_rx.await.ok().flatten() { - match ron::from_str(&memory) { - Ok(memory) => { - context.memory_mut(|m| *m = memory); - tracing::info!("Successfully restored memory for {app_id}"); - } - Err(e) => tracing::warn!("Failed to restore memory for {app_id}: {e}"), - } - } else { - tracing::warn!("No memory found for {app_id}"); - } - } - - let storage = Storage { - output_tx: output_tx.clone(), - }; - - Self { - state: std::rc::Rc::new(std::cell::RefCell::new(WebWorkerRunnerState { - app: app_creator(&eframe::CreationContext { - egui_ctx: context.clone(), - integration_info: integration_info.clone(), - wgpu_render_state: Some(render_state.clone()), - storage: Some(&storage), - }), - app_id: app_id.to_string(), - save_time: 0., - render_state, - canvas, - surface, - surface_configuration, - modifiers: Default::default(), - width, - height, - pixel_ratio: 1., - event_rx, - custom_event_rx, - output_tx, - })), - storage: std::rc::Rc::new(std::cell::RefCell::new(storage)), - context, - time_lock, - } - } - - /// Sets up the hook to render the app to the canvas. - pub fn setup_render_hooks(self) { - let callback = Closure::once(move || { - if PANIC_LOCK.get().is_some() { - return; - } - let mut state = self.state.borrow_mut(); - let worker = bindings::worker().unwrap(); - - let mut width = state.width; - let mut height = state.height; - let mut pixel_ratio = state.pixel_ratio; - let mut modifiers = state.modifiers; - let mut should_save = false; - - let now = bindings::performance(&worker).now() / 1000.; - - if let Some(custom_event_rx) = &mut state.custom_event_rx { - for event in std::iter::from_fn(|| custom_event_rx.try_recv().ok()) { - match event.0 { - WebWorkerRunnerEventInner::ScreenResize( - new_width, - new_height, - new_pixel_ratio, - ) => { - width = new_width; - height = new_height; - pixel_ratio = new_pixel_ratio; - } - - WebWorkerRunnerEventInner::Modifiers(new_modifiers) => { - modifiers = new_modifiers; - } - - WebWorkerRunnerEventInner::Save => { - should_save = true; - } - } - } - } - - if should_save || now >= state.save_time + state.app.auto_save_interval().as_secs_f64() - { - state.save_time = now; - state.app.save(&mut *self.storage.borrow_mut()); - if let Some(output_tx) = &state.output_tx { - match self.context.memory(ron::to_string) { - Ok(ron) => { - let (oneshot_tx, oneshot_rx) = oneshot::channel(); - output_tx - .send(WebWorkerRunnerOutput( - WebWorkerRunnerOutputInner::StorageSet( - state.app_id.clone(), - ron, - oneshot_tx, - ), - )) - .unwrap(); - if !oneshot_rx.recv().unwrap() { - tracing::warn!("Failed to save memory for {}", state.app_id); - } - } - Err(e) => { - tracing::warn!("Failed to serialize memory for {}: {e}", state.app_id) - } - } - } - } - - // Resize the canvas if the screen size has changed - if width != state.width || height != state.height { - state.pixel_ratio = pixel_ratio; - state.width = width; - state.height = height; - state.surface_configuration.width = (width as f32 * pixel_ratio).round() as u32; - state.surface_configuration.height = (height as f32 * pixel_ratio).round() as u32; - state.canvas.set_width(state.surface_configuration.width); - state.canvas.set_height(state.surface_configuration.height); - state - .surface - .configure(&state.render_state.device, &state.surface_configuration); - - // Also trigger a rerender immediately - self.time_lock.store(0., Ordering::Relaxed); - } - - // If the modifiers have changed, trigger a rerender - if modifiers != state.modifiers { - state.modifiers = modifiers; - self.time_lock.store(0., Ordering::Relaxed); - } - - let events = if let Some(event_rx) = &mut state.event_rx { - std::iter::from_fn(|| event_rx.try_recv().ok()).collect() - } else { - vec![] - }; - if !events.is_empty() { - // Render immediately if there are any pending events - self.time_lock.store(0., Ordering::Relaxed); - } - - // Render only if sufficient time has passed since the last render - if now >= self.time_lock.load(Ordering::Relaxed) { - // Ask the app to paint the next frame - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_max( - egui::pos2(0., 0.), - egui::pos2(state.width as f32, state.height as f32), - )), - pixels_per_point: Some(state.pixel_ratio), - time: Some(bindings::performance(&worker).now() / 1000.), - max_texture_side: Some( - state.render_state.device.limits().max_texture_dimension_2d as usize, - ), - events, - modifiers, - ..Default::default() - }; - let output = self.context.run(input, |_| { - state.app.custom_update( - &self.context, - &mut luminol_app::CustomFrame(std::marker::PhantomData), - ) - }); - if let Some(output_tx) = &state.output_tx { - let _ = output_tx.send(WebWorkerRunnerOutput( - WebWorkerRunnerOutputInner::PlatformOutput(output.platform_output), - )); - } - let clear_color = state.app.clear_color(&self.context.style().visuals); - let paint_jobs = self.context.tessellate(output.shapes); - - let mut encoder = state.render_state.device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { - label: Some("Luminol WebWorkerRunner Encoder"), - }, - ); - let screen_descriptor = egui_wgpu::renderer::ScreenDescriptor { - size_in_pixels: [ - state.surface_configuration.width, - state.surface_configuration.height, - ], - pixels_per_point: state.pixel_ratio, - }; - - // Upload textures to GPU that are newly created in the current frame - let command_buffers = { - let mut renderer = state.render_state.renderer.write(); - for (id, delta) in output.textures_delta.set.iter() { - renderer.update_texture( - &state.render_state.device, - &state.render_state.queue, - *id, - delta, - ); - } - renderer.update_buffers( - &state.render_state.device, - &state.render_state.queue, - &mut encoder, - &paint_jobs[..], - &screen_descriptor, - ) - }; - - // Create texture to render onto - // This variable needs to live for the entire remaining duration we use - // `state.render_state` or WebGL will break - let render_texture = state.surface.get_current_texture().unwrap(); - - // Execute egui's render pass - { - let renderer = state.render_state.renderer.read(); - let view = render_texture.texture.create_view(&Default::default()); - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: clear_color[0].into(), - g: clear_color[1].into(), - b: clear_color[2].into(), - a: clear_color[3].into(), - }), - store: true, - }, - })], - depth_stencil_attachment: None, - label: Some("Luminol WebWorkerRunner Renderer"), - }); - renderer.render(&mut render_pass, &paint_jobs[..], &screen_descriptor); - } - - // Remove textures that are no longer needed after this frame - { - let mut renderer = state.render_state.renderer.write(); - for id in output.textures_delta.free.iter() { - renderer.free_texture(id); - } - } - - // Copy from the internal drawing buffer onto the HTML canvas - state.render_state.queue.submit( - command_buffers - .into_iter() - .chain(std::iter::once(encoder.finish())), - ); - render_texture.present(); - - self.time_lock.store( - bindings::performance(&worker).now() / 1000. - + output.repaint_after.as_secs_f64(), - Ordering::Relaxed, - ); - } - - self.clone().setup_render_hooks(); - }); - - let _ = bindings::worker() - .unwrap() - .request_animation_frame(callback.as_ref().unchecked_ref()); - callback.forget(); - } -} - -/// Sets up the event listeners on the main thread in order to do things like respond to -/// mouse events and resize the canvas to fill the screen. -pub fn setup_main_thread_hooks( - canvas: web_sys::HtmlCanvasElement, - event_tx: flume::Sender, - custom_event_tx: flume::Sender, - output_rx: flume::Receiver, -) { - let window = - web_sys::window().expect("cannot run `setup_main_thread_hooks()` outside of main thread"); - let document = window.document().unwrap(); - - let is_mac = matches!( - egui::os::OperatingSystem::from_user_agent( - window.navigator().user_agent().unwrap_or_default().as_str() - ), - egui::os::OperatingSystem::Mac | egui::os::OperatingSystem::IOS - ); - - { - let f = { - let custom_event_tx = custom_event_tx.clone(); - let window = window.clone(); - let document = document.clone(); - let canvas = canvas.clone(); - move || { - if PANIC_LOCK.get().is_some() { - return; - } - let pixel_ratio = window.device_pixel_ratio(); - let pixel_ratio = if pixel_ratio > 0. && pixel_ratio.is_finite() { - pixel_ratio as f32 - } else { - 1. - }; - let width = window.inner_width().unwrap().as_f64().unwrap() as u32; - let height = window.inner_height().unwrap().as_f64().unwrap() as u32; - let _ = canvas.set_attribute("width", width.to_string().as_str()); - let _ = canvas.set_attribute("height", height.to_string().as_str()); - let _ = custom_event_tx.send(WebWorkerRunnerEvent( - WebWorkerRunnerEventInner::ScreenResize(width, height, pixel_ratio), - )); - } - }; - f(); - let callback: Closure = Closure::new(f); - window - .add_event_listener_with_callback("resize", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for screen resizing"); - callback.forget(); - } - - { - let f = |pressed| { - let event_tx = event_tx.clone(); - let custom_event_tx = custom_event_tx.clone(); - move |e: web_sys::MouseEvent| { - if PANIC_LOCK.get().is_some() { - return; - } - let ctrl = e.ctrl_key(); - let modifiers = egui::Modifiers { - alt: e.alt_key(), - ctrl: !is_mac && ctrl, - shift: e.shift_key(), - mac_cmd: is_mac && ctrl, - command: ctrl, - }; - let _ = custom_event_tx.send(WebWorkerRunnerEvent( - WebWorkerRunnerEventInner::Modifiers(modifiers), - )); - if let Some(button) = match e.button() { - 0 => Some(egui::PointerButton::Primary), - 1 => Some(egui::PointerButton::Middle), - 2 => Some(egui::PointerButton::Secondary), - 3 => Some(egui::PointerButton::Extra1), - 4 => Some(egui::PointerButton::Extra2), - _ => None, - } { - let _ = event_tx.send(egui::Event::PointerButton { - pos: egui::pos2(e.client_x() as f32, e.client_y() as f32), - button, - pressed, - modifiers, - }); - } - e.stop_propagation(); - if !pressed { - e.prevent_default(); - } - } - }; - - let callback: Closure = Closure::new(f(true)); - canvas - .add_event_listener_with_callback("mousedown", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for mouse button presses"); - callback.forget(); - - let callback: Closure = Closure::new(f(false)); - canvas - .add_event_listener_with_callback("mouseup", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for mouse button releases"); - callback.forget(); - } - - { - let event_tx = event_tx.clone(); - let callback: Closure = Closure::new(move |e: web_sys::MouseEvent| { - if PANIC_LOCK.get().is_some() { - return; - } - let _ = event_tx.send(egui::Event::PointerMoved(egui::pos2( - e.client_x() as f32, - e.client_y() as f32, - ))); - e.stop_propagation(); - e.prevent_default(); - }); - canvas - .add_event_listener_with_callback("mousemove", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for mouse movement"); - callback.forget(); - } - - { - let event_tx = event_tx.clone(); - let custom_event_tx = custom_event_tx.clone(); - let callback: Closure = Closure::new(move |e: web_sys::MouseEvent| { - if PANIC_LOCK.get().is_some() { - return; - } - let _ = custom_event_tx.send(WebWorkerRunnerEvent(WebWorkerRunnerEventInner::Save)); - let _ = event_tx.send(egui::Event::PointerGone); - e.stop_propagation(); - e.prevent_default(); - }); - canvas - .add_event_listener_with_callback("mouseleave", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for mouse leaving"); - callback.forget(); - } - - { - let window = window.clone(); - let event_tx = event_tx.clone(); - let custom_event_tx = custom_event_tx.clone(); - let callback: Closure = Closure::new(move |e: web_sys::WheelEvent| { - if PANIC_LOCK.get().is_some() { - return; - } - let ctrl = e.ctrl_key(); - let modifiers = egui::Modifiers { - alt: e.alt_key(), - ctrl: !is_mac && ctrl, - shift: e.shift_key(), - mac_cmd: is_mac && ctrl, - command: ctrl, - }; - let _ = custom_event_tx.send(WebWorkerRunnerEvent( - WebWorkerRunnerEventInner::Modifiers(modifiers), - )); - - let unit = match e.delta_mode() { - web_sys::WheelEvent::DOM_DELTA_LINE => egui::MouseWheelUnit::Line, - web_sys::WheelEvent::DOM_DELTA_PAGE => egui::MouseWheelUnit::Page, - _ => egui::MouseWheelUnit::Point, - }; - let delta = -egui::vec2(e.delta_x() as f32, e.delta_y() as f32); - let _ = event_tx.send(egui::Event::MouseWheel { - unit, - delta, - modifiers, - }); - - let delta = delta - * match unit { - egui::MouseWheelUnit::Point => 1., - egui::MouseWheelUnit::Line => 8., - egui::MouseWheelUnit::Page => { - window.inner_height().unwrap().as_f64().unwrap() as f32 - } - }; - let _ = if ctrl { - event_tx.send(egui::Event::Zoom((delta.y / 200.).exp())) - } else if modifiers.shift { - event_tx.send(egui::Event::Scroll(egui::vec2(delta.x + delta.y, 0.))) - } else { - event_tx.send(egui::Event::Scroll(delta)) - }; - - e.stop_propagation(); - e.prevent_default(); - }); - canvas - .add_event_listener_with_callback("wheel", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for mouse scrolling"); - callback.forget(); - } - - { - let callback: Closure = Closure::new(move |e: web_sys::Event| { - e.prevent_default(); - }); - canvas - .add_event_listener_with_callback("contextmenu", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for context menu"); - canvas - .add_event_listener_with_callback("afterprint", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for print shortcut keypress"); - callback.forget(); - } - - { - let f = |pressed| { - let event_tx = event_tx.clone(); - let custom_event_tx = custom_event_tx.clone(); - move |e: web_sys::KeyboardEvent| { - if PANIC_LOCK.get().is_some() { - return; - } - let ctrl = e.ctrl_key(); - let modifiers = egui::Modifiers { - alt: e.alt_key(), - ctrl: !is_mac && ctrl, - shift: e.shift_key(), - mac_cmd: is_mac && ctrl, - command: ctrl, - }; - let _ = custom_event_tx.send(WebWorkerRunnerEvent( - WebWorkerRunnerEventInner::Modifiers(modifiers), - )); - if e.is_composing() || e.key_code() == 229 { - return; - } - let key = e.key(); - let matched_key = match key.as_str() { - // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values - "Enter" => Some(egui::Key::Enter), - "Tab" => Some(egui::Key::Tab), - " " => Some(egui::Key::Space), - - "ArrowDown" => Some(egui::Key::ArrowDown), - "ArrowLeft" => Some(egui::Key::ArrowLeft), - "ArrowRight" => Some(egui::Key::ArrowRight), - "ArrowUp" => Some(egui::Key::ArrowUp), - "End" => Some(egui::Key::End), - "Home" => Some(egui::Key::Home), - "PageDown" => Some(egui::Key::PageDown), - "PageUp" => Some(egui::Key::PageUp), - - "Backspace" => Some(egui::Key::Backspace), - "Delete" => Some(egui::Key::Delete), - "Insert" => Some(egui::Key::Insert), - - "Escape" => Some(egui::Key::Escape), - - "F1" => Some(egui::Key::F1), - "F2" => Some(egui::Key::F2), - "F3" => Some(egui::Key::F3), - "F4" => Some(egui::Key::F4), - "F5" => Some(egui::Key::F5), - "F6" => Some(egui::Key::F6), - "F7" => Some(egui::Key::F7), - "F8" => Some(egui::Key::F8), - "F9" => Some(egui::Key::F9), - "F10" => Some(egui::Key::F10), - "F11" => Some(egui::Key::F11), - "F12" => Some(egui::Key::F12), - "F13" => Some(egui::Key::F13), - "F14" => Some(egui::Key::F14), - "F15" => Some(egui::Key::F15), - "F16" => Some(egui::Key::F16), - "F17" => Some(egui::Key::F17), - "F18" => Some(egui::Key::F18), - "F19" => Some(egui::Key::F19), - "F20" => Some(egui::Key::F20), - - "-" => Some(egui::Key::Minus), - "+" | "=" => Some(egui::Key::PlusEquals), - - "0" => Some(egui::Key::Num0), - "1" => Some(egui::Key::Num1), - "2" => Some(egui::Key::Num2), - "3" => Some(egui::Key::Num3), - "4" => Some(egui::Key::Num4), - "5" => Some(egui::Key::Num5), - "6" => Some(egui::Key::Num6), - "7" => Some(egui::Key::Num7), - "8" => Some(egui::Key::Num8), - "9" => Some(egui::Key::Num9), - - "A" | "a" => Some(egui::Key::A), - "B" | "b" => Some(egui::Key::B), - "C" | "c" => Some(egui::Key::C), - "D" | "d" => Some(egui::Key::D), - "E" | "e" => Some(egui::Key::E), - "F" | "f" => Some(egui::Key::F), - "G" | "g" => Some(egui::Key::G), - "H" | "h" => Some(egui::Key::H), - "I" | "i" => Some(egui::Key::I), - "J" | "j" => Some(egui::Key::J), - "K" | "k" => Some(egui::Key::K), - "L" | "l" => Some(egui::Key::L), - "M" | "m" => Some(egui::Key::M), - "N" | "n" => Some(egui::Key::N), - "O" | "o" => Some(egui::Key::O), - "P" | "p" => Some(egui::Key::P), - "Q" | "q" => Some(egui::Key::Q), - "R" | "r" => Some(egui::Key::R), - "S" | "s" => Some(egui::Key::S), - "T" | "t" => Some(egui::Key::T), - "U" | "u" => Some(egui::Key::U), - "V" | "v" => Some(egui::Key::V), - "W" | "w" => Some(egui::Key::W), - "X" | "x" => Some(egui::Key::X), - "Y" | "y" => Some(egui::Key::Y), - "Z" | "z" => Some(egui::Key::Z), - - _ => None, - }; - if pressed && !ctrl && key.len() == 1 { - let _ = event_tx.send(egui::Event::Text(key)); - } - if let Some(key) = matched_key { - let _ = event_tx.send(egui::Event::Key { - key, - pressed, - repeat: pressed, - modifiers, - }); - if pressed - && (matches!( - key, - egui::Key::Tab - | egui::Key::Backspace - | egui::Key::ArrowDown - | egui::Key::ArrowLeft - | egui::Key::ArrowRight - | egui::Key::ArrowUp - ) || (ctrl && matches!(key, egui::Key::P | egui::Key::S))) - { - e.prevent_default(); - } - } - } - }; - - let callback: Closure = Closure::new(f(true)); - document - .add_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for keyboard key presses"); - callback.forget(); - - let callback: Closure = Closure::new(f(false)); - document - .add_event_listener_with_callback("keyup", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for keyboard key releases"); - callback.forget(); - } - - { - let event_tx = event_tx.clone(); - let callback: Closure = Closure::new(move |e: web_sys::ClipboardEvent| { - if PANIC_LOCK.get().is_some() { - return; - } - if let Some(data) = e.clipboard_data() { - if let Ok(text) = data.get_data("text") { - if !text.is_empty() { - let _ = event_tx.send(egui::Event::Paste(text.replace("\r\n", "\n"))); - } - } - } - e.stop_propagation(); - e.prevent_default(); - }); - document - .add_event_listener_with_callback("paste", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for clipboard pasting"); - callback.forget(); - } - - { - let event_tx = event_tx.clone(); - let callback: Closure = Closure::new(move |_: web_sys::ClipboardEvent| { - if PANIC_LOCK.get().is_some() { - return; - } - let _ = event_tx.send(egui::Event::Copy); - }); - document - .add_event_listener_with_callback("copy", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for clipboard copying"); - callback.forget(); - } - - { - let event_tx = event_tx.clone(); - let callback: Closure = Closure::new(move |_: web_sys::ClipboardEvent| { - if PANIC_LOCK.get().is_some() { - return; - } - let _ = event_tx.send(egui::Event::Cut); - }); - document - .add_event_listener_with_callback("cut", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for clipboard cutting"); - callback.forget(); - } - - { - let custom_event_tx = custom_event_tx.clone(); - let callback: Closure = Closure::new(move |_: web_sys::Event| { - let _ = custom_event_tx.send(WebWorkerRunnerEvent(WebWorkerRunnerEventInner::Save)); - }); - document - .add_event_listener_with_callback("onbeforeunload", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for window unloading"); - document - .add_event_listener_with_callback("blur", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for window blur"); - callback.forget(); - } - - { - let custom_event_tx = custom_event_tx.clone(); - let callback: Closure = Closure::new(move |_: web_sys::Event| { - // Currently we just save on fullscreen change, the application doesn't need the - // fullscreen state - let _ = custom_event_tx.send(WebWorkerRunnerEvent(WebWorkerRunnerEventInner::Save)); - }); - document - .add_event_listener_with_callback("fullscreenchange", callback.as_ref().unchecked_ref()) - .expect("failed to register event listener for fullscreen"); - callback.forget(); - } - - { - // The canvas automatically resizes itself whenever a frame is drawn. - // The resizing does not take window.devicePixelRatio into account, - // so this mutation observer is to detect canvas resizes and correct them. - let window = window.clone(); - let callback: Closure = Closure::new(move |mutations: js_sys::Array| { - if PANIC_LOCK.get().is_some() { - return; - } - let width = window.inner_width().unwrap().as_f64().unwrap() as u32; - let height = window.inner_height().unwrap().as_f64().unwrap() as u32; - mutations.for_each(&mut |mutation, _, _| { - let mutation = mutation.unchecked_into::(); - if mutation.type_().as_str() == "attributes" { - let canvas = mutation - .target() - .unwrap() - .unchecked_into::(); - if canvas.width() != width || canvas.height() != height { - let _ = canvas.set_attribute("width", width.to_string().as_str()); - let _ = canvas.set_attribute("height", height.to_string().as_str()); - } - } - }); - }); - let observer = web_sys::MutationObserver::new(callback.as_ref().unchecked_ref()) - .expect("failed to create canvas mutation observer"); - let mut options = web_sys::MutationObserverInit::new(); - options.attributes(true); - observer - .observe_with_options(&canvas, &options) - .expect("failed to register canvas mutation observer"); - callback.forget(); - } - - wasm_bindgen_futures::spawn_local(async move { - let body_style = window.document().unwrap().body().unwrap().style(); - let local_storage = window.local_storage().unwrap().unwrap(); - loop { - let Ok(command) = output_rx.recv_async().await else { - tracing::warn!( - "WebWorkerRunner main thread loop is stopping! This is not supposed to happen." - ); - return; - }; - - match command.0 { - WebWorkerRunnerOutputInner::PlatformOutput(output) => { - let _ = body_style.set_property( - "cursor", - match output.cursor_icon { - egui::CursorIcon::Default => "default", - egui::CursorIcon::None => "none", - - egui::CursorIcon::ContextMenu => "context-menu", - egui::CursorIcon::Help => "help", - egui::CursorIcon::PointingHand => "pointer", - egui::CursorIcon::Progress => "progress", - egui::CursorIcon::Wait => "wait", - - egui::CursorIcon::Cell => "cell", - egui::CursorIcon::Crosshair => "crosshair", - egui::CursorIcon::Text => "text", - egui::CursorIcon::VerticalText => "vertical-text", - - egui::CursorIcon::Alias => "alias", - egui::CursorIcon::Copy => "copy", - egui::CursorIcon::Move => "move", - egui::CursorIcon::NoDrop => "no-drop", - egui::CursorIcon::NotAllowed => "not-allowed", - egui::CursorIcon::Grab => "grab", - egui::CursorIcon::Grabbing => "grabbing", - - egui::CursorIcon::AllScroll => "all-scroll", - egui::CursorIcon::ResizeColumn => "col-resize", - egui::CursorIcon::ResizeRow => "row-resize", - egui::CursorIcon::ResizeNorth => "n-resize", - egui::CursorIcon::ResizeEast => "e-resize", - egui::CursorIcon::ResizeSouth => "s-resize", - egui::CursorIcon::ResizeWest => "w-resize", - egui::CursorIcon::ResizeNorthEast => "ne-resize", - egui::CursorIcon::ResizeNorthWest => "nw-resize", - egui::CursorIcon::ResizeSouthEast => "se-resize", - egui::CursorIcon::ResizeSouthWest => "sw-resize", - egui::CursorIcon::ResizeHorizontal => "ew-resize", - egui::CursorIcon::ResizeVertical => "ns-resize", - egui::CursorIcon::ResizeNwSe => "nwse-resize", - egui::CursorIcon::ResizeNeSw => "nesw-resize", - - egui::CursorIcon::ZoomIn => "zoom-in", - egui::CursorIcon::ZoomOut => "zoom-out", - }, - ); - - if !output.copied_text.is_empty() { - if let Err(e) = wasm_bindgen_futures::JsFuture::from( - window - .navigator() - .clipboard() - .unwrap() - .write_text(&output.copied_text), - ) - .await - { - tracing::warn!( - "Failed to copy to clipboard: {}", - e.unchecked_into::().to_string() - ); - } - } - - if let Some(url) = output.open_url { - if let Err(e) = window.open_with_url_and_target( - &url.url, - if url.new_tab { "_blank" } else { "_self" }, - ) { - tracing::warn!( - "Failed to open URL: {}", - e.unchecked_into::().to_string() - ); - } - } - } - - WebWorkerRunnerOutputInner::StorageGet(key, oneshot_tx) => { - let _ = oneshot_tx.send(local_storage.get(&key).ok().flatten()); - } - - WebWorkerRunnerOutputInner::StorageSet(key, value, oneshot_tx) => { - let _ = oneshot_tx.send(local_storage.set(&key, &value).is_ok()); - } - } - } - }); -} - -/// This should be called when the application panics to stop the renderer and event listeners. -pub fn panic_hook() { - let _ = PANIC_LOCK.set(()); -} diff --git a/src/app/mod.rs b/src/app/mod.rs index 5eac9800..687fa125 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -66,7 +66,7 @@ impl App { /// Called once before the first frame. #[must_use] pub fn new( - cc: &eframe::CreationContext<'_>, + cc: &luminol_eframe::CreationContext<'_>, #[cfg(not(target_arch = "wasm32"))] try_load_path: Option, #[cfg(target_arch = "wasm32")] audio: luminol_audio::AudioWrapper, #[cfg(feature = "steamworks")] steamworks: Steamworks, @@ -103,11 +103,11 @@ impl App { let mut message_description = String::new(); match e { - eframe::wgpu::Error::OutOfMemory { source } => { + wgpu::Error::OutOfMemory { source } => { message_description.push_str("wgpu error: Out of memory\n"); writeln!(message_description, "{source:#?}").unwrap(); } - eframe::wgpu::Error::Validation { + wgpu::Error::Validation { source, description, } => { @@ -143,7 +143,8 @@ impl App { let storage = cc.storage.unwrap(); - let mut global_config = eframe::get_value(storage, "SavedState").unwrap_or_default(); + let mut global_config = + luminol_eframe::get_value(storage, "SavedState").unwrap_or_default(); let mut project_config = None; let mut filesystem = luminol_filesystem::project::FileSystem::new(); @@ -166,8 +167,8 @@ impl App { } } - let style = - eframe::get_value(storage, "EguiStyle").map_or_else(|| cc.egui_ctx.style(), |s| s); + let style = luminol_eframe::get_value(storage, "EguiStyle") + .map_or_else(|| cc.egui_ctx.style(), |s| s); cc.egui_ctx.set_style(style.clone()); let bytes_loader = Arc::new(luminol_filesystem::egui_bytes_loader::Loader::new()); @@ -220,13 +221,9 @@ impl App { } } -impl luminol_app::CustomApp for App { +impl luminol_eframe::App for App { /// Called each time the UI needs repainting, which may be many times per second. - fn custom_update( - &mut self, - ctx: &eframe::egui::Context, - frame: &mut luminol_app::CustomFrame<'_>, - ) { + fn update(&mut self, ctx: &egui::Context, frame: &mut luminol_eframe::Frame) { #[cfg(not(target_arch = "wasm32"))] ctx.input(|i| { if let Some(f) = i.raw.dropped_files.first() { @@ -308,14 +305,10 @@ impl luminol_app::CustomApp for App { #[cfg(feature = "steamworks")] self.steamworks.update() } -} - -impl eframe::App for App { - luminol_app::app_use_custom_update!(); /// Called by the frame work to save state before shutdown. - fn save(&mut self, storage: &mut dyn eframe::Storage) { - eframe::set_value(storage, "SavedState", &self.global_config); + fn save(&mut self, storage: &mut dyn luminol_eframe::Storage) { + luminol_eframe::set_value(storage, "SavedState", &self.global_config); } fn persist_egui_memory(&self) -> bool { diff --git a/src/app/top_bar.rs b/src/app/top_bar.rs index 116218b7..cf638726 100644 --- a/src/app/top_bar.rs +++ b/src/app/top_bar.rs @@ -40,7 +40,7 @@ impl TopBar { pub fn ui( &mut self, ui: &mut egui::Ui, - frame: &mut luminol_app::CustomFrame<'_>, + frame: &mut luminol_eframe::Frame, update_state: &mut luminol_core::UpdateState<'_>, ) { egui::widgets::global_dark_light_mode_switch(ui); diff --git a/src/main.rs b/src/main.rs index f765df5a..46de2c08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,23 +99,23 @@ fn main() { let image = image::load_from_memory(ICON).expect("Failed to load Icon data."); - let native_options = eframe::NativeOptions { + let native_options = luminol_eframe::NativeOptions { drag_and_drop_support: true, transparent: true, - icon_data: Some(eframe::IconData { + icon_data: Some(luminol_eframe::IconData { width: image.width(), height: image.height(), rgba: image.to_rgba8().into_vec(), }), - wgpu_options: eframe::egui_wgpu::WgpuConfiguration { - supported_backends: eframe::wgpu::util::backend_bits_from_env() - .unwrap_or(eframe::wgpu::Backends::PRIMARY), - device_descriptor: std::sync::Arc::new(|_| eframe::wgpu::DeviceDescriptor { + wgpu_options: luminol_egui_wgpu::WgpuConfiguration { + supported_backends: wgpu::util::backend_bits_from_env() + .unwrap_or(wgpu::Backends::PRIMARY), + device_descriptor: std::sync::Arc::new(|_| wgpu::DeviceDescriptor { label: Some("luminol device descriptor"), - features: eframe::wgpu::Features::PUSH_CONSTANTS, - limits: eframe::wgpu::Limits { + features: wgpu::Features::PUSH_CONSTANTS, + limits: wgpu::Limits { max_push_constant_size: 128, - ..eframe::wgpu::Limits::default() + ..wgpu::Limits::default() }, }), ..Default::default() @@ -125,7 +125,7 @@ fn main() { ..Default::default() }; - eframe::run_native( + luminol_eframe::run_native( "Luminol", native_options, Box::new(|cc| { @@ -150,19 +150,17 @@ const CANVAS_ID: &str = "luminol-canvas"; struct WorkerData { audio: luminol_audio::AudioWrapper, prefers_color_scheme_dark: Option, - filesystem_tx: flume::Sender, - output_tx: flume::Sender, - event_rx: flume::Receiver, - custom_event_rx: flume::Receiver, + fs_worker_channels: luminol_filesystem::web::WorkerChannels, + runner_worker_channels: luminol_eframe::web::WorkerChannels, } #[cfg(target_arch = "wasm32")] -static WORKER_DATA: parking_lot::RwLock> = parking_lot::RwLock::new(None); +static WORKER_DATA: parking_lot::Mutex> = parking_lot::Mutex::new(None); #[cfg(target_arch = "wasm32")] #[wasm_bindgen] pub fn luminol_main_start(fallback: bool) { - let (panic_tx, mut panic_rx) = flume::unbounded::<()>(); + let (panic_tx, panic_rx) = flume::unbounded(); wasm_bindgen_futures::spawn_local(async move { if panic_rx.recv_async().await.is_ok() { @@ -171,12 +169,10 @@ pub fn luminol_main_start(fallback: bool) { }); std::panic::set_hook(Box::new(move |info| { - luminol_web::web_worker_runner::panic_hook(); - let backtrace_printer = color_backtrace::BacktracePrinter::new().verbosity(color_backtrace::Verbosity::Full); let mut buffer = color_backtrace::termcolor::Ansi::new(vec![]); - backtrace_printer.print_panic_info(info, &mut buffer); + let _ = backtrace_printer.print_panic_info(info, &mut buffer); let report = String::from_utf8(buffer.into_inner()).expect("panic report not valid utf-8"); web_sys::console::log_1(&js_sys::JsString::from(report)); @@ -215,26 +211,22 @@ pub fn luminol_main_start(fallback: bool) { return; } - let (filesystem_tx, filesystem_rx) = flume::unbounded(); - let (output_tx, output_rx) = flume::unbounded(); - let (event_tx, event_rx) = flume::unbounded(); - let (custom_event_tx, custom_event_rx) = flume::unbounded(); - - luminol_filesystem::host::setup_main_thread_hooks(filesystem_rx); - luminol_web::web_worker_runner::setup_main_thread_hooks( - canvas, - event_tx.clone(), - custom_event_tx.clone(), - output_rx, - ); + let (fs_worker_channels, fs_main_channels) = luminol_filesystem::web::channels(); + let (runner_worker_channels, runner_main_channels) = luminol_eframe::web::channels(); - *WORKER_DATA.write() = Some(WorkerData { + luminol_filesystem::host::setup_main_thread_hooks(fs_main_channels); + luminol_eframe::WebRunner::setup_main_thread_hooks(luminol_eframe::web::MainState { + inner: Default::default(), + canvas: canvas.clone(), + channels: runner_main_channels, + }) + .expect("unable to setup web runner main thread hooks"); + + *WORKER_DATA.lock() = Some(WorkerData { audio: luminol_audio::Audio::default().into(), prefers_color_scheme_dark, - filesystem_tx, - output_tx, - event_rx, - custom_event_rx, + fs_worker_channels, + runner_worker_channels, }); let mut worker_options = web_sys::WorkerOptions::new(); @@ -260,26 +252,24 @@ pub async fn luminol_worker_start(canvas: web_sys::OffscreenCanvas) { let WorkerData { audio, prefers_color_scheme_dark, - filesystem_tx, - output_tx, - event_rx, - custom_event_rx, - } = WORKER_DATA.write().take().unwrap(); - - luminol_filesystem::host::FileSystem::setup_filesystem_sender(filesystem_tx); - - let web_options = eframe::WebOptions::default(); - - let runner = luminol_web::WebWorkerRunner::new( - Box::new(|cc| Box::new(app::App::new(cc, audio))), - canvas, - web_options, - "astrabit.luminol", - prefers_color_scheme_dark, - Some(event_rx), - Some(custom_event_rx), - Some(output_tx), - ) - .await; - runner.setup_render_hooks(); + fs_worker_channels, + runner_worker_channels, + } = WORKER_DATA.lock().take().unwrap(); + + luminol_filesystem::host::FileSystem::setup_worker_channels(fs_worker_channels); + + let web_options = luminol_eframe::WebOptions::default(); + + luminol_eframe::WebRunner::new() + .start( + canvas, + web_options, + Box::new(|cc| Box::new(app::App::new(cc, audio))), + luminol_eframe::web::WorkerOptions { + prefers_color_scheme_dark, + channels: runner_worker_channels, + }, + ) + .await + .expect("failed to start eframe"); }