diff --git a/Cargo.lock b/Cargo.lock index 942ddc6e741e..1740978b419b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -267,9 +276,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.92" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f37166d7d48a0284b99dd824694c26119c700b53bf0d1540cdb147dbdaaf13" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arboard" @@ -805,9 +814,9 @@ dependencies = [ [[package]] name = "color" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18ef4657441fb193b65f34dc39b3781f0dfec23d3bd94d0eeb4e88cde421edb" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" dependencies = [ "bytemuck", ] @@ -949,10 +958,11 @@ dependencies = [ [[package]] name = "criterion" -version = "0.7.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", @@ -961,6 +971,7 @@ dependencies = [ "itertools 0.13.0", "num-traits", "oorandom", + "page_size", "regex", "serde", "serde_json", @@ -970,9 +981,9 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.6.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", "itertools 0.13.0", @@ -1163,6 +1174,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + [[package]] name = "document-features" version = "0.2.12" @@ -1208,6 +1225,7 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", + "egui_inspection", "glow", "glutin", "glutin-winit", @@ -1246,6 +1264,7 @@ dependencies = [ "document-features", "emath", "epaint", + "itertools 0.14.0", "log", "nohash-hasher", "profiling", @@ -1352,6 +1371,7 @@ dependencies = [ "ehttp", "enum-map", "image", + "itertools 0.14.0", "jiff", "log", "mime_guess2", @@ -1378,6 +1398,20 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_inspection" +version = "0.34.2" +dependencies = [ + "document-features", + "egui", + "image", + "interprocess", + "rmp-serde", + "serde", + "serde_bytes", + "tempfile", +] + [[package]] name = "egui_kittest" version = "0.34.2" @@ -1388,6 +1422,7 @@ dependencies = [ "egui", "egui-wgpu", "egui_extras", + "egui_inspection", "image", "kittest", "open", @@ -1714,14 +1749,22 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73829a7b5c91198af28a99159b7ae4afbb252fb906159ff7f189f3a2ceaa3df2" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" dependencies = [ "bytemuck", "serde", ] +[[package]] +name = "font_variations" +version = "0.1.0" +dependencies = [ + "eframe", + "env_logger", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -1904,6 +1947,22 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9d48c6d81526ad2f9d5d6e5fddf5f6949ffbc46fcac0db0d061a6e65097019" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + [[package]] name = "glow" version = "0.17.0" @@ -2038,13 +2097,12 @@ dependencies = [ [[package]] name = "harfrust" -version = "0.5.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" dependencies = [ "bitflags 2.9.4", "bytemuck", - "core_maths", "read-fonts", "smallvec", ] @@ -2069,6 +2127,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hello_android" version = "0.1.0" @@ -2311,6 +2378,19 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "interprocess" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069323743400cb7ab06a8fe5c1ed911d36b6919ec531661d034c89083629595b" +dependencies = [ + "doctest-file", + "libc", + "recvmsg", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2338,18 +2418,18 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -2517,12 +2597,13 @@ dependencies = [ [[package]] name = "kurbo" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] @@ -2628,9 +2709,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lz4_flex" -version = "0.11.6" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" [[package]] name = "memchr" @@ -3221,6 +3302,16 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -3258,13 +3349,13 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "peniko" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2b6aadb221872732e87d465213e9be5af2849b0e8cc5300a8ba98fffa2e00a" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ "bytemuck", "color", - "kurbo 0.13.0", + "kurbo 0.13.1", "linebender_resource_handle", "smallvec", ] @@ -3478,6 +3569,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "popups" version = "0.34.2" @@ -3551,9 +3651,9 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", "puffin", @@ -3561,9 +3661,9 @@ dependencies = [ [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", "syn", @@ -3571,26 +3671,25 @@ dependencies = [ [[package]] name = "puffin" -version = "0.19.1" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa9dae7b05c02ec1a6bc9bcf20d8bc64a7dcbf57934107902a872014899b741f" +checksum = "84b514d95a258be801fde8a1ff1c974f4a4841d9750f5d1d6690fc07a5ad4049" dependencies = [ "anyhow", "bincode", "byteorder", "cfg-if", - "itertools 0.10.5", + "itertools 0.14.0", "lz4_flex", - "once_cell", "parking_lot", "serde", ] [[package]] name = "puffin_http" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "739a3c7f56604713b553d7addd7718c226e88d598979ae3450320800bd0e9810" +checksum = "4f912991aab1adae69d2be9455e8db0f41e5ad3da87706ab2de64908678c2c76" dependencies = [ "anyhow", "crossbeam-channel", @@ -3747,15 +3846,20 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.37.0" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", - "core_maths", "font-types", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.4.1" @@ -3884,6 +3988,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "ron" version = "0.12.0" @@ -4081,6 +4204,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4217,9 +4350,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.40.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" dependencies = [ "bytemuck", "read-fonts", @@ -4925,42 +5058,31 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vello_api" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5088cd0113bc5332c753f24503825e3bc93e26c7883c9dc3ad9637bb62c4634" -dependencies = [ - "bytemuck", - "peniko", -] - [[package]] name = "vello_common" -version = "0.0.7" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986dc49a501a683477614bf07b8e7b6c79ae4828efce3bf22e51850f4a0a8a4c" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" dependencies = [ "bytemuck", "fearless_simd", "guillotiere", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "log", "peniko", - "skrifa", "smallvec", "thiserror 2.0.18", ] [[package]] name = "vello_cpu" -version = "0.0.7" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a678f91c7524a3a9ac9a19df9f83552866ec70b2ca26441b916a6b219b6aa2de" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" dependencies = [ "bytemuck", - "hashbrown 0.16.1", - "vello_api", + "glifo", + "hashbrown 0.17.1", "vello_common", ] @@ -5391,6 +5513,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -5400,6 +5544,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index b924a7db37a1..e46fbb61182a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/egui_demo_lib", "crates/egui_extras", "crates/egui_glow", + "crates/egui_inspection", "crates/egui_kittest", "crates/egui-wgpu", "crates/egui-winit", @@ -65,6 +66,7 @@ egui_extras = { version = "0.34.2", path = "crates/egui_extras", default-feature egui-wgpu = { version = "0.34.2", path = "crates/egui-wgpu", default-features = false } egui_demo_lib = { version = "0.34.2", path = "crates/egui_demo_lib", default-features = false } egui_glow = { version = "0.34.2", path = "crates/egui_glow", default-features = false } +egui_inspection = { version = "0.34.2", path = "crates/egui_inspection", default-features = false } egui_kittest = { version = "0.34.2", path = "crates/egui_kittest", default-features = false } eframe = { version = "0.34.2", path = "crates/eframe", default-features = false } @@ -82,7 +84,7 @@ bitflags = "2.9.4" bytemuck = "1.24.0" cint = "0.3.1" color-hex = "0.2.0" -criterion = { version = "0.7.0", default-features = false } +criterion = { version = "0.8.2", default-features = false } dify = { version = "0.8", default-features = false } directories = "6.0.0" document-features = "0.2.11" @@ -93,9 +95,10 @@ font-types = { version = "0.11.0", default-features = false, features = ["std"] glow = "0.17.0" glutin = { version = "0.32.3", default-features = false } glutin-winit = { version = "0.5.0", default-features = false } -harfrust = "0.5.2" +harfrust = "0.7.0" home = "0.5.9" image = { version = "0.25.6", default-features = false } +itertools = "0.14.0" jiff = { version = "0.2.23", default-features = false } js-sys = "0.3.77" kittest = { version = "0.4.0" } @@ -114,19 +117,20 @@ parking_lot = "0.12.5" percent-encoding = "2.3.2" poll-promise = { version = "0.3.0", default-features = false } pollster = "0.4.0" -profiling = { version = "1.0.17", default-features = false } -puffin = "0.19.1" -puffin_http = "0.16.1" +profiling = { version = "1.0.18", default-features = false } +puffin = "0.20.0" +puffin_http = "0.17.0" rand = "0.9.2" raw-window-handle = "0.6.2" rayon = "1.11.0" resvg = { version = "0.45.1", default-features = false } rfd = "0.17.2" +rmp-serde = "1.3.1" ron = "0.12.0" self_cell = "1.2.1" serde = { version = "1.0.228", features = ["derive"] } similar-asserts = "1.7.0" -skrifa = { version = "0.40.0", default-features = false, features = ["std", "autohint_shaping"] } +skrifa = { version = "0.42.1", default-features = false, features = ["std", "autohint_shaping"] } smallvec = "1.15.1" smithay-clipboard = "0.7.2" static_assertions = "1.1.0" @@ -139,7 +143,7 @@ type-map = "0.5.1" unicode_names2 = { version = "2.0.0", default-features = false } unicode-general-category = "1.1.0" unicode-segmentation = "1.12.0" -vello_cpu = { version = "0.0.7", default-features = false, features = [ +vello_cpu = { version = "0.0.8", default-features = false, features = [ "std", "u8_pipeline", "f32_pipeline", diff --git a/crates/ecolor/src/color32.rs b/crates/ecolor/src/color32.rs index b9de323e07bb..f444b860d464 100644 --- a/crates/ecolor/src/color32.rs +++ b/crates/ecolor/src/color32.rs @@ -489,7 +489,7 @@ mod test { } else { // There will be small rounding errors whenever the alpha is not 0 or 255, // because we multiply and then unmultiply the alpha. - for (&a, &b) in in_rgba.iter().zip(out_rgba.iter()) { + for (&a, &b) in std::iter::zip(&in_rgba, &out_rgba) { assert!(a.abs_diff(b) <= 3, "{in_rgba:?} != {out_rgba:?}"); } } diff --git a/crates/ecolor/src/rgba.rs b/crates/ecolor/src/rgba.rs index 3e40af2b0050..98c3ce408610 100644 --- a/crates/ecolor/src/rgba.rs +++ b/crates/ecolor/src/rgba.rs @@ -336,7 +336,7 @@ mod test { } else { // There will be small rounding errors whenever the alpha is not 0 or 255, // because we multiply and then unmultiply the alpha. - for (&a, &b) in in_rgba.iter().zip(out_rgba.iter()) { + for (&a, &b) in std::iter::zip(&in_rgba, &out_rgba) { assert!(a.abs_diff(b) <= 3, "{in_rgba:?} != {out_rgba:?}"); } } diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index 0530b64a859a..a20dc1512aab 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -116,6 +116,12 @@ x11 = [ ## This is used to generate images for examples. __screenshot = [] +## Enable the [`egui_inspection`] plugin. When the `EGUI_INSPECTION_SOCKET` env var points +## at a unix socket, eframe attaches an `InspectionPlugin` to the egui context on startup +## that streams the AccessKit tree and applies received commands. Unix-only; no-op on +## non-unix targets. +inspection = ["dep:egui_inspection", "accesskit"] + [dependencies] egui = { workspace = true, default-features = false, features = ["bytemuck"] } @@ -131,6 +137,7 @@ web-time.workspace = true # Optional dependencies egui_glow = { workspace = true, optional = true, default-features = false } +egui_inspection = { workspace = true, optional = true, features = ["plugin"] } glow = { workspace = true, optional = true } ron = { workspace = true, optional = true, features = ["integer128"] } serde = { workspace = true, optional = true } diff --git a/crates/eframe/src/lib.rs b/crates/eframe/src/lib.rs index 30adf12daf11..35d68e231fd4 100644 --- a/crates/eframe/src/lib.rs +++ b/crates/eframe/src/lib.rs @@ -45,7 +45,7 @@ //! //! impl eframe::App for MyEguiApp { //! fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { -//! egui::CentralPanel::default().show_inside(ui, |ui| { +//! egui::CentralPanel::default().show(ui, |ui| { //! ui.heading("Hello World!"); //! }); //! } @@ -209,6 +209,33 @@ pub use native::file_storage::storage_dir; #[cfg(not(target_arch = "wasm32"))] pub mod icon_data; +// ---------------------------------------------------------------------------- + +/// Attach an [`egui_inspection::InspectionPlugin`] to `ctx` if the +/// `EGUI_INSPECTION_SOCKET` env var points at a reachable unix socket. +/// +/// No-op when: +/// - the `inspection` feature isn't enabled, +/// - the target isn't unix, or +/// - the env var is unset. +/// +/// Connection failures are logged via `log::warn!` but do not abort startup β€” running +/// without an inspector is always valid. +#[cfg(all(feature = "inspection", unix, not(target_arch = "wasm32")))] +pub(crate) fn maybe_attach_inspection_plugin(ctx: &egui::Context, label: Option) { + match egui_inspection::InspectionPlugin::from_env(label) { + Ok(Some(plugin)) => { + log::info!("eframe: attaching egui_inspection plugin"); + ctx.add_plugin(plugin); + } + Ok(None) => {} + Err(err) => log::warn!("eframe: egui_inspection attach failed: {err}"), + } +} + +#[cfg(not(all(feature = "inspection", unix, not(target_arch = "wasm32"))))] +pub(crate) fn maybe_attach_inspection_plugin(_ctx: &egui::Context, _label: Option) {} + /// This is how you start a native (desktop) app. /// /// The first argument is name of your app, which is an identifier @@ -244,7 +271,7 @@ pub mod icon_data; /// /// impl eframe::App for MyEguiApp { /// fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { -/// egui::CentralPanel::default().show_inside(ui, |ui| { +/// egui::CentralPanel::default().show(ui, |ui| { /// ui.heading("Hello World!"); /// }); /// } @@ -334,7 +361,7 @@ pub fn run_native_ext( /// /// impl eframe::App for MyEguiApp { /// fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { -/// egui::CentralPanel::default().show_inside(ui, |ui| { +/// egui::CentralPanel::default().show(ui, |ui| { /// ui.heading("Hello World!"); /// }); /// } @@ -425,7 +452,7 @@ fn init_native(app_name: &str, native_options: &mut NativeOptions) -> Renderer { /// let options = eframe::NativeOptions::default(); /// eframe::run_ui_native("My egui App", options, move |ui, _frame| { /// // Wrap everything in a CentralPanel so we get some margins and a background color: -/// egui::CentralPanel::default().show_inside(ui, |ui| { +/// egui::CentralPanel::default().show(ui, |ui| { /// ui.heading("My egui Application"); /// ui.horizontal(|ui| { /// let name_label = ui.label("Your name: "); diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index fd61b0cd1724..1a2849a84e9b 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -299,6 +299,8 @@ impl<'app> GlowWinitApp<'app> { let app_creator = std::mem::take(&mut self.app_creator) .expect("Single-use AppCreator has unexpectedly already been taken"); + crate::maybe_attach_inspection_plugin(&integration.egui_ctx, Some(self.app_name.clone())); + let app: Box = { // Use latest raw_window_handle for eframe compatibility use raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _}; @@ -679,7 +681,7 @@ impl GlowWinitRunning<'_> { let gl_surface = viewport.gl_surface.as_ref().unwrap(); let egui_winit = viewport.egui_winit.as_mut().unwrap(); - egui_winit.handle_platform_output(&window, platform_output); + egui_winit.handle_platform_output_with_event_loop(&window, event_loop, platform_output); if is_visible { let clipped_primitives = integration.egui_ctx.tessellate(shapes, pixels_per_point); diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index 161c3f84b5ba..e68cef737d10 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -291,6 +291,9 @@ impl<'app> WgpuWinitApp<'app> { let app_creator = std::mem::take(&mut self.app_creator) .expect("Single-use AppCreator has unexpectedly already been taken"); + + crate::maybe_attach_inspection_plugin(&egui_ctx, Some(self.app_name.clone())); + let cc = CreationContext { egui_ctx: egui_ctx.clone(), integration_info: integration.frame.info().clone(), @@ -409,7 +412,7 @@ impl WinitApp for WgpuWinitApp<'_> { self.initialized_all_windows(event_loop); if let Some(running) = &mut self.running { - running.run_ui_and_paint(window_id) + running.run_ui_and_paint(window_id, event_loop) } else { Ok(EventResult::Wait) } @@ -569,7 +572,11 @@ impl WgpuWinitRunning<'_> { } /// This is called both for the root viewport, and all deferred viewports - fn run_ui_and_paint(&mut self, window_id: WindowId) -> Result { + fn run_ui_and_paint( + &mut self, + window_id: WindowId, + event_loop: &ActiveEventLoop, + ) -> Result { profiling::function_scope!(); let Some(viewport_id) = self @@ -710,7 +717,7 @@ impl WgpuWinitRunning<'_> { return Ok(EventResult::Wait); }; - egui_winit.handle_platform_output(window, platform_output); + egui_winit.handle_platform_output_with_event_loop(window, event_loop, platform_output); let vsync_secs = if is_visible { let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point); diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index c15e78d68692..5388bf023916 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -368,7 +368,8 @@ impl AppRunner { let egui::PlatformOutput { commands, cursor_icon, - events: _, // already handled + cursor_image: _, // TODO(alextournai): support custom bitmap cursors on the web (via CSS `url(...)`) + events: _, // already handled mutable_text_under_cursor: _, // TODO(#4569): https://github.com/emilk/egui/issues/4569 ime, accesskit_update: _, // not currently implemented diff --git a/crates/eframe/src/web/input.rs b/crates/eframe/src/web/input.rs index c27897090b88..62723e8fad96 100644 --- a/crates/eframe/src/web/input.rs +++ b/crates/eframe/src/web/input.rs @@ -31,11 +31,12 @@ pub fn primary_touch_pos( runner: &mut AppRunner, event: &web_sys::TouchEvent, ) -> Option<(egui::Pos2, web_sys::Touch)> { - let all_touches: Vec<_> = (0..event.touches().length()) - .filter_map(|i| event.touches().get(i)) - // On touchend we don't get anything in `touches`, but we still get `changed_touches`, so include those: - .chain((0..event.changed_touches().length()).filter_map(|i| event.changed_touches().get(i))) - .collect(); + // On touchend we don't get anything in `touches`, but we still get `changed_touches`, so include those: + let all_touches: Vec<_> = std::iter::chain( + (0..event.touches().length()).filter_map(|i| event.touches().get(i)), + (0..event.changed_touches().length()).filter_map(|i| event.changed_touches().get(i)), + ) + .collect(); if let Some(primary_touch) = runner.input.primary_touch { // Is the primary touch is gone? diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs index a7c6bded22d2..b16f98a9b5dc 100644 --- a/crates/eframe/src/web/web_painter_wgpu.rs +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -365,7 +365,7 @@ impl WebPainter for WebPainterWgpu { // Submit the commands: both the main buffer and user-defined ones. render_state .queue - .submit(user_cmd_bufs.into_iter().chain([encoder.finish()])); + .submit(std::iter::chain(user_cmd_bufs, [encoder.finish()])); if let Some((frame, capture_buffer)) = frame_and_capture_buffer { if let Some(capture_buffer) = capture_buffer diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index 96ab6e29c758..df9245ec94a8 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -715,7 +715,7 @@ impl Painter { let start = web_time::Instant::now(); render_state .queue - .submit(user_cmd_bufs.into_iter().chain([encoded])); + .submit(std::iter::chain(user_cmd_bufs, [encoded])); vsync_sec += start.elapsed().as_secs_f32(); }; diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 331c1f5260c6..166a38c28a19 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -32,7 +32,7 @@ use winit::{ dpi::{PhysicalPosition, PhysicalSize}, event::ElementState, event_loop::ActiveEventLoop, - window::{CursorGrabMode, Window, WindowButtons, WindowLevel}, + window::{CursorGrabMode, CustomCursor, Window, WindowButtons, WindowLevel}, }; pub fn screen_size_in_pixels(window: &Window) -> egui::Vec2 { @@ -88,6 +88,14 @@ pub struct State { any_pointer_button_down: bool, current_cursor_icon: Option, + /// Cached `CustomCursor` for the last RGBA bitmap pushed through + /// `PlatformOutput::cursor_image`. We dedupe by `Arc::as_ptr` so the + /// integration only re-uploads the bitmap to the OS when the app + /// switches sprite, not every frame the cursor moves. `usize` is the + /// raw pointer of the source `Arc<[u8]>` β€” opaque, only used as a + /// cache key. + current_custom_cursor: Option<(usize, CustomCursor)>, + clipboard: clipboard::Clipboard, /// If `true`, mouse inputs will be treated as touches. @@ -141,6 +149,7 @@ impl State { pointer_pos_in_points: None, any_pointer_button_down: false, current_cursor_icon: None, + current_custom_cursor: None, clipboard: clipboard::Clipboard::new( display_target.display_handle().ok().map(|h| h.as_raw()), @@ -1020,12 +1029,38 @@ impl State { &mut self, window: &Window, platform_output: egui::PlatformOutput, + ) { + self.handle_platform_output_inner(window, None, platform_output); + } + + /// Same as [`Self::handle_platform_output`] but threads the + /// `ActiveEventLoop` so we can register a `winit::CustomCursor` from + /// `PlatformOutput::cursor_image`. Integration paths that don't have + /// access to the event loop (e.g. immediate viewports) should call + /// [`Self::handle_platform_output`] instead β€” any custom cursor + /// request is silently dropped there and the standard `cursor_icon` + /// path still runs. + pub fn handle_platform_output_with_event_loop( + &mut self, + window: &Window, + event_loop: &ActiveEventLoop, + platform_output: egui::PlatformOutput, + ) { + self.handle_platform_output_inner(window, Some(event_loop), platform_output); + } + + fn handle_platform_output_inner( + &mut self, + window: &Window, + event_loop: Option<&ActiveEventLoop>, + platform_output: egui::PlatformOutput, ) { profiling::function_scope!(); let egui::PlatformOutput { commands, cursor_icon, + cursor_image, events: _, // handled elsewhere mutable_text_under_cursor: _, // only used in eframe web ime, @@ -1048,7 +1083,7 @@ impl State { } } - self.set_cursor_icon(window, cursor_icon); + self.apply_cursor(window, event_loop, cursor_icon, cursor_image.as_ref()); let allow_ime = ime.is_some(); let is_toggling_ime = self.allow_ime != allow_ime; @@ -1111,26 +1146,92 @@ impl State { let _ = accesskit_update; } - fn set_cursor_icon(&mut self, window: &Window, cursor_icon: egui::CursorIcon) { + /// Apply either a bitmap cursor (preferred when both `cursor_image` + /// and `event_loop` are `Some`) or the standard `cursor_icon` to the + /// window. Mirrors the no-flicker dedupe the old `set_cursor_icon` + /// did, on the appropriate cache key for whichever path is active. + fn apply_cursor( + &mut self, + window: &Window, + event_loop: Option<&ActiveEventLoop>, + cursor_icon: egui::CursorIcon, + cursor_image: Option<&egui::CustomCursorImage>, + ) { + let is_pointer_in_window = self.pointer_pos_in_points.is_some(); + if !is_pointer_in_window { + // Drop both caches so the cursor gets re-applied (and the + // bitmap re-checked for staleness) once the pointer comes + // back. Same contract the old `set_cursor_icon` followed. + self.current_cursor_icon = None; + self.current_custom_cursor = None; + return; + } + + // Bitmap cursor wins over CursorIcon when both are present and we + // have an event loop to register it with. Otherwise the bitmap is + // dropped and we fall through to the icon path β€” this is the + // documented fallback for integrations that didn't opt in. + if let (Some(image), Some(event_loop)) = (cursor_image, event_loop) { + let key = std::sync::Arc::as_ptr(&image.rgba).cast::() as usize; + let cached = self + .current_custom_cursor + .as_ref() + .filter(|(k, _)| *k == key) + .map(|(_, c)| c.clone()); + + let custom = match cached { + Some(c) => c, + None => match winit::window::CustomCursor::from_rgba( + image.rgba.to_vec(), + image.size[0], + image.size[1], + image.hotspot[0], + image.hotspot[1], + ) { + Ok(source) => { + let c = event_loop.create_custom_cursor(source); + self.current_custom_cursor = Some((key, c.clone())); + c + } + Err(err) => { + log::warn!( + "egui-winit: invalid cursor bitmap, falling back to cursor_icon: {err:?}" + ); + self.current_custom_cursor = None; + self.set_cursor_icon_inner(window, cursor_icon); + return; + } + }, + }; + + window.set_cursor_visible(true); + window.set_cursor(custom); + // Resync `current_cursor_icon` so the next icon-only path + // notices a real change rather than dedupe-skipping it. + self.current_cursor_icon = None; + return; + } + + self.current_custom_cursor = None; + self.set_cursor_icon_inner(window, cursor_icon); + } + + /// Icon-only path, factored out so `apply_cursor` can fall back to it + /// when the bitmap path bails. Preserves the original dedupe. + fn set_cursor_icon_inner(&mut self, window: &Window, cursor_icon: egui::CursorIcon) { if self.current_cursor_icon == Some(cursor_icon) { // Prevent flickering near frame boundary when Windows OS tries to control cursor icon for window resizing. // On other platforms: just early-out to save CPU. return; } - let is_pointer_in_window = self.pointer_pos_in_points.is_some(); - if is_pointer_in_window { - self.current_cursor_icon = Some(cursor_icon); + self.current_cursor_icon = Some(cursor_icon); - if let Some(winit_cursor_icon) = translate_cursor(cursor_icon) { - window.set_cursor_visible(true); - window.set_cursor(winit_cursor_icon); - } else { - window.set_cursor_visible(false); - } + if let Some(winit_cursor_icon) = translate_cursor(cursor_icon) { + window.set_cursor_visible(true); + window.set_cursor(winit_cursor_icon); } else { - // Remember to set the cursor again once the cursor returns to the screen: - self.current_cursor_icon = None; + window.set_cursor_visible(false); } } } diff --git a/crates/egui-winit/src/window_settings.rs b/crates/egui-winit/src/window_settings.rs index d15712d4cafe..5e77061f0f34 100644 --- a/crates/egui-winit/src/window_settings.rs +++ b/crates/egui-winit/src/window_settings.rs @@ -158,23 +158,30 @@ fn find_active_monitor( return None; // no monitors 🀷 }; + let mut active_monitor_overlap = 0.0; for monitor in monitors { let window_size_px = window_size_pts * (egui_zoom_factor * monitor.scale_factor() as f32); - let monitor_x_range = (monitor.position().x - window_size_px.x as i32) - ..(monitor.position().x + monitor.size().width as i32); - let monitor_y_range = (monitor.position().y - window_size_px.y as i32) - ..(monitor.position().y + monitor.size().height as i32); - - if monitor_x_range.contains(&(position_px.x as i32)) - && monitor_y_range.contains(&(position_px.y as i32)) - { + let window_rect = egui::Rect::from_min_size(*position_px, window_size_px); + let overlap = window_rect.intersect(monitor_rect_px(&monitor)).area(); + + if active_monitor_overlap < overlap { active_monitor = monitor; + active_monitor_overlap = overlap; } } Some(active_monitor) } +fn monitor_rect_px(monitor: &winit::monitor::MonitorHandle) -> egui::Rect { + let pos = monitor.position(); + let size = monitor.size(); + egui::Rect::from_min_size( + egui::pos2(pos.x as f32, pos.y as f32), + egui::vec2(size.width as f32, size.height as f32), + ) +} + fn clamp_pos_to_monitors( egui_zoom_factor: f32, event_loop: &winit::event_loop::ActiveEventLoop, @@ -198,19 +205,12 @@ fn clamp_pos_to_monitors( 32.0 * egui_zoom_factor * active_monitor.scale_factor() as f32, ); } - let monitor_position = egui::Pos2::new( - active_monitor.position().x as f32, - active_monitor.position().y as f32, - ); - let monitor_size_px = egui::Vec2::new( - active_monitor.size().width as f32, - active_monitor.size().height as f32, - ); + let monitor_rect = monitor_rect_px(&active_monitor); // Window size cannot be negative or the subsequent `clamp` will panic. - let window_size = (monitor_size_px - window_size_px).max(egui::Vec2::ZERO); + let window_size = (monitor_rect.size() - window_size_px).max(egui::Vec2::ZERO); // To get the maximum position, we get the rightmost corner of the display, then // subtract the size of the window to get the bottom right most value window.position // can have. - *position_px = position_px.clamp(monitor_position, monitor_position + window_size); + *position_px = position_px.clamp(monitor_rect.min, monitor_rect.min + window_size); } diff --git a/crates/egui/Cargo.toml b/crates/egui/Cargo.toml index 3a22bf5295eb..fadc27c6cf79 100644 --- a/crates/egui/Cargo.toml +++ b/crates/egui/Cargo.toml @@ -74,6 +74,7 @@ epaint = { workspace = true, default-features = false } accesskit.workspace = true ahash.workspace = true bitflags.workspace = true +itertools.workspace = true log.workspace = true nohash-hasher.workspace = true profiling.workspace = true diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 10be6307a2b5..4b3a4f722b9f 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -583,7 +583,7 @@ impl Area { } } -fn round_area_position(ctx: &Context, pos: Pos2) -> Pos2 { +pub(crate) fn round_area_position(ctx: &Context, pos: Pos2) -> Pos2 { // We round a lot of rendering to pixels, so we round the whole // area positions to pixels too, so avoid widgets appearing to float // around independently of each other when the area is dragged. diff --git a/crates/egui/src/containers/collapsing_header.rs b/crates/egui/src/containers/collapsing_header.rs index cad9d5b3ee30..3e49a3bb07c6 100644 --- a/crates/egui/src/containers/collapsing_header.rs +++ b/crates/egui/src/containers/collapsing_header.rs @@ -1,9 +1,7 @@ -use std::hash::Hash; - use crate::{ - Context, Id, InnerResponse, NumExt as _, Rect, Response, Sense, Stroke, TextStyle, - TextWrapMode, Ui, UiBuilder, UiKind, UiStackInfo, WidgetInfo, WidgetText, WidgetType, emath, - epaint, pos2, remap, remap_clamp, vec2, + AsIdSalt, Context, Id, IdSalt, InnerResponse, NumExt as _, Rect, Response, Sense, Stroke, + TextStyle, TextWrapMode, Ui, UiBuilder, UiKind, UiStackInfo, WidgetInfo, WidgetText, + WidgetType, emath, epaint, pos2, remap, remap_clamp, vec2, }; use emath::GuiRounding as _; use epaint::{Shape, StrokeKind}; @@ -189,22 +187,31 @@ impl CollapsingState { self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring None } else if openness < 1.0 { - ui.add_space((openness - 1.0) * ui.spacing().item_spacing.y); // animate spacing too + // The spacing between the header and the body. We animate this too. + let item_spacing = ui.spacing().item_spacing.y; - Some(ui.scope_builder(builder, |child_ui| { - let max_height = if self.state.open && self.state.open_height.is_none() { - // First frame of expansion. - // We don't know full height yet, but we will next frame. - // Just use a placeholder value that shows some movement: - 10.0 - } else { - let full_height = self.state.open_height.unwrap_or_default(); - remap_clamp(openness, 0.0..=1.0, 0.0..=full_height).round_ui() - }; + let fallback_height_guess = 10.0; // Just use a placeholder value that shows some movement for the first frame + let full_height = self.state.open_height.unwrap_or(fallback_height_guess); + + let clipped_child_height = + (remap_clamp(openness, 0.0..=1.0, 0.0..=full_height + item_spacing) - item_spacing) + .round_ui(); - let mut clip_rect = child_ui.clip_rect(); - clip_rect.max.y = clip_rect.max.y.min(child_ui.max_rect().top() + max_height); - child_ui.set_clip_rect(clip_rect); + if clipped_child_height < 0.0 { + ui.add_space(clipped_child_height); // animate the spacing! + } + + Some(ui.scope_builder(builder, |child_ui| { + let clipped_child_height = clipped_child_height.at_least(0.0); + + { + let mut clip_rect = child_ui.clip_rect(); + clip_rect.max.y = f32::min( + clip_rect.max.y, + child_ui.max_rect().top() + clipped_child_height, + ); + child_ui.set_clip_rect(clip_rect); + } let ret = add_body(child_ui); @@ -215,8 +222,8 @@ impl CollapsingState { } self.store(child_ui.ctx()); // remember the height - // Pretend children took up at most `max_height` space: - min_rect.max.y = min_rect.max.y.at_most(min_rect.top() + max_height); + // Pretend children took up at most `clipped_child_height` space: + min_rect.max.y = f32::min(min_rect.max.y, min_rect.top() + clipped_child_height); child_ui.force_set_min_rect(min_rect); ret })) @@ -371,7 +378,7 @@ pub struct CollapsingHeader { text: WidgetText, default_open: bool, open: Option, - id_salt: Id, + id_salt: IdSalt, enabled: bool, selectable: bool, selected: bool, @@ -388,7 +395,7 @@ impl CollapsingHeader { /// you need to provide a unique id source with [`Self::id_salt`]. pub fn new(text: impl Into) -> Self { let text = text.into(); - let id_salt = Id::new(text.text()); + let id_salt = IdSalt::new(text.text()); Self { text, default_open: false, @@ -424,8 +431,8 @@ impl CollapsingHeader { /// Explicitly set the source of the [`Id`] of this widget, instead of using title label. /// This is useful if the title label is dynamic or not unique. #[inline] - pub fn id_salt(mut self, id_salt: impl Hash) -> Self { - self.id_salt = Id::new(id_salt); + pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self { + self.id_salt = IdSalt::new(id_salt); self } diff --git a/crates/egui/src/containers/combo_box.rs b/crates/egui/src/containers/combo_box.rs index adbda583c77e..a5d925827329 100644 --- a/crates/egui/src/containers/combo_box.rs +++ b/crates/egui/src/containers/combo_box.rs @@ -1,9 +1,11 @@ use epaint::Shape; use crate::{ - Align2, Context, Id, InnerResponse, NumExt as _, Painter, Popup, PopupCloseBehavior, Rect, - Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui, UiBuilder, Vec2, WidgetInfo, - WidgetText, WidgetType, epaint, style::StyleModifier, style::WidgetVisuals, vec2, + Align2, AsIdSalt, Context, Id, IdSalt, InnerResponse, NumExt as _, Painter, Popup, + PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui, + UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, epaint, + style::{StyleModifier, WidgetVisuals}, + vec2, }; #[expect(unused_imports)] // Documentation @@ -36,7 +38,7 @@ pub type IconPainter = Box; /// ``` #[must_use = "You should call .show*"] pub struct ComboBox { - id_salt: Id, + id_salt: IdSalt, label: Option, selected_text: WidgetText, width: Option, @@ -49,9 +51,9 @@ pub struct ComboBox { impl ComboBox { /// Create new [`ComboBox`] with id and label - pub fn new(id_salt: impl std::hash::Hash, label: impl Into) -> Self { + pub fn new(id_salt: impl AsIdSalt, label: impl Into) -> Self { Self { - id_salt: Id::new(id_salt), + id_salt: IdSalt::new(id_salt), label: Some(label.into()), selected_text: Default::default(), width: None, @@ -67,7 +69,7 @@ impl ComboBox { pub fn from_label(label: impl Into) -> Self { let label = label.into(); Self { - id_salt: Id::new(label.text()), + id_salt: IdSalt::new(label.text()), label: Some(label), selected_text: Default::default(), width: None, @@ -80,9 +82,9 @@ impl ComboBox { } /// Without label. - pub fn from_id_salt(id_salt: impl std::hash::Hash) -> Self { + pub fn from_id_salt(id_salt: impl AsIdSalt) -> Self { Self { - id_salt: Id::new(id_salt), + id_salt: IdSalt::new(id_salt), label: Default::default(), selected_text: Default::default(), width: None, diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index d828ce0f83c0..cb66eb1f266e 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -32,5 +32,5 @@ pub use { scroll_area::ScrollArea, sides::Sides, tooltip::*, - window::Window, + window::{Window, WindowDrag}, }; diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index 0f1e8cd95da7..1b8e9f32079d 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -18,8 +18,8 @@ use emath::GuiRounding as _; use crate::{ - Align, Context, CursorIcon, Frame, Id, InnerResponse, Layout, NumExt as _, Rangef, Rect, Sense, - Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp, + Align, Context, CursorIcon, Frame, Id, InnerResponse, Layout, NumExt as _, Rangef, Rect, + Response, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp, }; fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 { @@ -31,6 +31,9 @@ fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct PanelState { /// The _outer_ rect of the panel, i.e. including the [`Frame`] margin & border. + /// + /// When animating, this will be a shifted in the animation direction, + /// so it is really only the size that you can count on. #[cfg_attr(feature = "serde", serde(alias = "rect"))] pub outer_rect: Rect, } @@ -157,14 +160,23 @@ impl PanelSide { /// /// See the [module level docs](crate::containers::panel) for more details. /// +/// # Showing the panel +/// +/// Pick the variant that matches the behavior you want: +/// +/// * [`Panel::show`]: always show the panel. +/// * [`Panel::show_collapsible`]: show or hide the panel, with a slide animation in between. +/// * [`Panel::show_switched`]: animate between two different panels: +/// a thin/collapsed one and a thick/expanded one. +/// /// ``` /// # egui::__run_test_ui(|ui| { -/// egui::Panel::left("my_left_panel").show_inside(ui, |ui| { +/// egui::Panel::left("my_left_panel").show(ui, |ui| { /// ui.label("Hello World!"); /// }); /// # }); /// ``` -#[must_use = "You should call .show_inside()"] +#[must_use = "You should call .show()"] pub struct Panel { side: PanelSide, id: Id, @@ -183,9 +195,24 @@ pub struct Panel { /// `1.0` = panel fully visible (the normal case), /// `0.0` = panel fully slid off-screen toward its fixed edge. /// - /// Used by [`Self::show_animated_inside`] to animate a panel sliding in/out. + /// Used by [`Self::show_collapsible`] to animate a panel sliding in/out. /// While `slide_fraction != 1.0` the panel does _not_ persist its [`PanelState`]. slide_fraction: f32, + + /// Override for the [`Id`] under which the resize-handle widget is registered. + /// + /// Used by [`Self::show_switched`] so the collapsed and + /// expanded panels share a single resize widget β€” that way a drag on either + /// one can flip `is_expanded` and the gesture survives the swap. + resize_id_source: Option, + + /// Size below which drag-to-collapse fires, when set. + /// + /// Defaults to `outer_size_range.min`. Used by + /// [`Self::show_switched`] to set the threshold at the + /// collapsed panel's size, so the swap happens exactly when the slide + /// matches the collapsed size visually. + collapse_threshold: Option, } impl Panel { @@ -244,6 +271,8 @@ impl Panel { default_outer_size, outer_size_range, slide_fraction: 1.0, + resize_id_source: None, + collapse_threshold: None, } } @@ -331,43 +360,84 @@ impl Panel { // Public showing methods impl Panel { /// Show the panel inside a [`Ui`]. + pub fn show(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + self.show_inside_dyn(ui, None, Box::new(add_contents)) + } + + /// Renamed to [`Self::show`]. + #[deprecated = "Renamed to `show`"] pub fn show_inside( self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse { - self.show_inside_dyn(ui, Box::new(add_contents)) + self.show(ui, add_contents) } - /// Show the panel if `is_expanded` is `true`, + /// Show the panel if `*is_expanded` is `true`, /// otherwise hide it, with a slide animation in between. /// /// During the animation `add_contents` runs against the real panel, and the /// panel slides off-screen toward its fixed edge (clipped against the parent). /// The parent only reserves the _visible_ portion, so neighboring widgets follow. - pub fn show_animated_inside( + /// + /// `is_expanded` is taken by `&mut` so the panel can flip it to `false` when + /// the user drags the resize handle past the panel's minimum size, and back + /// to `true` if the user drags the handle outward while the panel is closed. + /// When [`Self::resizable`] is `true`, double-clicking the resize edge also + /// flips `*is_expanded`. + pub fn show_collapsible( self, ui: &mut Ui, - is_expanded: bool, + is_expanded: &mut bool, add_contents: impl FnOnce(&mut Ui) -> R, ) -> Option> { - let how_expanded = animate_expansion(ui, self.id.with("animation"), is_expanded); + let how_expanded = animate_expansion(ui, self.id.with("animation"), *is_expanded); if how_expanded == 0.0 { + // Panel is fully closed. If the user is still dragging the resize handle + // from a previous frame, keep its widget id alive so they can drag the + // panel back out without releasing. + self.keep_drag_alive_for_reopen(ui, is_expanded); + // Make sure the ids of the next widgets are the same whether we show the panel or not: ui.skip_ahead_auto_ids(1); return None; } + // Don't lose the drag during the slide-back-open animation: + let drag_in_progress = ui + .read_response(self.id.with("__resize")) + .is_some_and(|r| r.dragged()); + let panel = if how_expanded < 1.0 { - // Mid-animation: slide the panel toward its fixed edge. - // Resizing a moving boundary is too awkward, so disable it during the slide. - self.with_slide_fraction(how_expanded).resizable(false) + if drag_in_progress { + // Mid-animation but the user is dragging β€” keep resize live so the + // drag-to-reopen gesture flows straight into a normal resize. + self.with_slide_fraction(how_expanded) + } else { + self.with_slide_fraction(how_expanded).resizable(false) // avoid flicker when the handle moved under the pointer during the animation + } } else { self }; - Some(panel.show_inside(ui, add_contents)) + Some(panel.show_inside_dyn(ui, Some(is_expanded), Box::new(add_contents))) + } + + /// Renamed to [`Self::show_collapsible`]. + /// + /// Note: [`Self::show_collapsible`] takes `is_expanded` by `&mut` so it can + /// flip it to `false` when the user drags the panel closed. To opt in, + /// migrate to the new name. + #[deprecated = "Renamed to `show_collapsible`"] + pub fn show_animated_inside( + self, + ui: &mut Ui, + mut is_expanded: bool, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + self.show_collapsible(ui, &mut is_expanded, add_contents) } /// Show either a collapsed or expanded panel, with a nice slide animation between. @@ -378,19 +448,41 @@ impl Panel { /// `add_contents` receives `expanded = true` whenever the expanded panel is /// rendered (including mid-animation), and `false` for the collapsed view. /// - /// Give the two panels distinct ids so their persisted sizes don't + /// **Give the two panels distinct ids** so their persisted sizes don't /// overwrite each other. /// + /// # Drag-to-collapse / drag-to-expand + /// + /// The user can resize the panel by dragging its edge. Pulling that edge + /// past the size limits flips `*is_expanded`: + /// + /// * `.resizable(true)` on the **expanded** panel enables **drag-to-collapse**: + /// shrinking past `min_size` sets `*is_expanded = false`. + /// * `.resizable(true)` on the **collapsed** panel enables **drag-to-expand**: + /// growing past `max_size` sets `*is_expanded = true`. (Use + /// [`Self::exact_size`] or [`Self::max_size`] to set a tight cap so a small + /// outward drag is enough to trigger the swap.) + /// + /// Both panels share a single resize-handle widget under the hood (keyed to + /// the expanded panel's id), so a single uninterrupted drag can collapse and + /// re-expand the panel without releasing. + /// + /// Double-clicking the resize edge also flips `*is_expanded` (whichever + /// panel is currently shown is the one whose edge you click). + /// /// ``` /// # egui::__run_test_ui(|ui| { /// let mut is_expanded = true; - /// let collapsed = egui::Panel::top("top_collapsed").exact_size(28.0); + /// // `.resizable(true)` on both panels enables drag-to-collapse + drag-to-expand: + /// let collapsed = egui::Panel::top("top_collapsed") + /// .resizable(true) + /// .default_size(20.0); /// let expanded = egui::Panel::top("top_expanded") /// .resizable(true) /// .default_size(120.0); - /// egui::Panel::show_animated_between_inside( + /// egui::Panel::show_switched( /// ui, - /// is_expanded, + /// &mut is_expanded, /// collapsed, /// expanded, /// |ui, expanded| { @@ -405,83 +497,234 @@ impl Panel { /// ui.toggle_value(&mut is_expanded, "Expand"); /// # }); /// ``` - pub fn show_animated_between_inside( + pub fn show_switched( ui: &mut Ui, - is_expanded: bool, + is_expanded: &mut bool, collapsed_panel: Self, expanded_panel: Self, add_contents: impl FnOnce(&mut Ui, bool) -> R, ) -> InnerResponse { - let how_expanded = animate_expansion(ui, expanded_panel.id.with("animation"), is_expanded); + debug_assert!( + collapsed_panel.id != expanded_panel.id, + "show_switched: the collapsed and expanded panels must have distinct ids \ + (their persisted sizes are stored per-id, and sharing one id would let the collapsed \ + size overwrite the expanded size)." + ); + // Share one resize-handle widget across the collapsed and expanded panels + // by routing both through the expanded panel's id. A drag that starts on + // either panel survives the swap to the other view. + let resize_id_source = expanded_panel.id; + // Drag-to-collapse fires when the drag crosses the collapsed panel's + // size, so the swap lines up with the visual size at that moment. + let collapse_threshold = collapsed_panel.outer_size(ui); + + // Is the resize handle currently being dragged? + let drag_in_progress = ui + .read_response(resize_id_source.with("__resize")) + .is_some_and(|r| r.dragged()); + + let animation_id = expanded_panel.id.with("animation"); + // While the user is dragging, snap the animation to the target so the + // drag (which sets `outer_size` directly from the pointer) doesn't fight + // a simultaneous slide. Without this, drag-to-expand visibly jumps as + // the slide animation tries to grow from 0 while the pointer is already + // at the expanded size. + let how_expanded = if drag_in_progress { + ui.animate_bool_with_time(animation_id, *is_expanded, 0.0) + } else { + animate_expansion(ui, animation_id, *is_expanded) + }; // When expanding, the user sees the expanded content the moment animation starts. // When collapsing, keep showing the expanded content until past the midpoint, // then swap to the collapsed content for the rest of the slide-out. - let show_expanded_contents = if is_expanded { - true - } else { - 0.5 < how_expanded - }; + let show_expanded_contents = *is_expanded || 0.5 < how_expanded; if how_expanded == 0.0 { - collapsed_panel.show_inside(ui, |ui| add_contents(ui, false)) + // Fully collapsed. The collapsed panel registers the shared resize + // widget so drag-to-expand works, and `is_expanded` is flipped to + // `true` when the user drags past its `max_size`. + collapsed_panel + .with_resize_id_source(resize_id_source) + .show_inside_dyn( + ui, + Some(is_expanded), + Box::new(|ui| add_contents(ui, false)), + ) } else { + let expanded_panel = expanded_panel.with_collapse_threshold(collapse_threshold); let panel = if how_expanded < 1.0 { // Animate the visible size from collapsed_size to expanded_size, // so the slide picks up where the collapsed panel left off. - let collapsed_size = collapsed_panel.outer_size(ui); let expanded_size = expanded_panel.outer_size(ui); - let visible_size = lerp(collapsed_size..=expanded_size, how_expanded); + let visible_size = lerp(collapse_threshold..=expanded_size, how_expanded); let slide_fraction = if 0.0 < expanded_size { visible_size / expanded_size } else { 1.0 }; - expanded_panel - .with_slide_fraction(slide_fraction) - .resizable(false) + let panel = expanded_panel.with_slide_fraction(slide_fraction); + // Keep the resize handle live during the slide if the drag is + // ongoing β€” otherwise disabling it would kill the gesture. + if drag_in_progress { + panel + } else { + panel.resizable(false) // avoid flicker when the handle moved under the pointer during the animation + } } else { expanded_panel }; - panel.show_inside(ui, |ui| add_contents(ui, show_expanded_contents)) + // Pass `is_expanded` so dragging the resize handle past the + // collapsed panel's size collapses to `collapsed_panel`. + panel.show_inside_dyn( + ui, + Some(is_expanded), + Box::new(|ui| add_contents(ui, show_expanded_contents)), + ) } } + + /// Renamed to [`Self::show_switched`]. + /// + /// Note: [`Self::show_switched`] takes `is_expanded` by `&mut` (to allow + /// drag-to-collapse / drag-to-expand to flip it) and passes a `bool` to + /// `add_contents` instead of an `f32` animation fraction. To opt in, + /// migrate to the new name. + #[deprecated = "Renamed to `show_switched`"] + pub fn show_animated_between_inside( + ui: &mut Ui, + is_expanded: bool, + collapsed_panel: Self, + expanded_panel: Self, + add_contents: impl FnOnce(&mut Ui, f32) -> R, + ) -> InnerResponse { + let mut is_expanded = is_expanded; + Self::show_switched( + ui, + &mut is_expanded, + collapsed_panel, + expanded_panel, + |ui, expanded| add_contents(ui, if expanded { 1.0 } else { 0.0 }), + ) + } } // Private methods to support the various show methods impl Panel { /// Show the panel inside a [`Ui`]. + /// + /// `is_expanded` is `Some` for the animated entry points + /// ([`Self::show_collapsible`], [`Self::show_switched`]); + /// when present, dragging the resize handle past the minimum size collapses + /// the panel by setting `*is_expanded = false`. fn show_inside_dyn<'c, R>( - self, + mut self, parent_ui: &mut Ui, + mut is_expanded: Option<&mut bool>, add_contents: Box R + 'c>, ) -> InnerResponse { let side = self.side; let id = self.id; let resizable = self.resizable; let show_separator_line = self.show_separator_line; - let outer_size_range = self.outer_size_range; - let frame = self.resolve_frame(parent_ui); let available_rect = parent_ui.available_rect_before_wrap(); - let mut outer_size = self.outer_size(parent_ui); - let mut outer_rect = self.compute_outer_rect(available_rect, outer_size); + + { + // Never overflow out parent's available width: + self.outer_size_range = self.outer_size_range.as_positive(); + self.outer_size_range.max = f32::min( + self.outer_size_range.max, + available_rect.size_along(side.axis()), + ); + } + + let frame = self.resolve_frame(parent_ui); + + // We are NEVER allowed to overflow over this. + // If we do, we do so by clipping the contents, + // without reporting that extra size to the parent! + let max_rect = { + let mut max_rect = available_rect; + self.side + .set_rect_size(&mut max_rect, self.outer_size_range.max); + max_rect + }; + + let mut outer_size = self + .outer_size(parent_ui) + .at_most(available_rect.size_along(self.side.axis())); + + let mut outer_rect = { + let mut outer_rect = available_rect; + self.side.set_rect_size(&mut outer_rect, outer_size); + outer_rect + }; // Check for duplicate id parent_ui.check_for_id_clash(id, outer_rect, "Panel"); + // True iff the user is currently dragging the resize handle (set in the block below). + let mut resize_drag_in_progress = false; + if resizable { // Resolve the resize interaction first to avoid frame latency in the resize. - let resize_id = id.with("__resize"); - if let Some(resize_response) = parent_ui.read_response(resize_id) - && resize_response.dragged() + // We also recompute the size on the release frame (`drag_stopped`) so the + // released size gets persisted into [`PanelState`] β€” without this the + // store-skipped-during-drag rule would leave the stored size at the + // pre-drag value. + let resize_id = self.resize_id_source.unwrap_or(id).with("__resize"); + let resize_response = parent_ui.read_response(resize_id); + + // Double-click on the resize edge toggles `*is_expanded` for the + // animated entry points (`show_collapsible` / `show_switched`). + if let Some(resize_response) = resize_response.as_ref() + && resize_response.double_clicked() + && let Some(is_expanded) = is_expanded.as_deref_mut() + { + *is_expanded = !*is_expanded; + } + + if let Some(resize_response) = resize_response + && (resize_response.dragged() || resize_response.drag_stopped()) && let Some(pointer) = resize_response.interact_pointer_pos() { + resize_drag_in_progress = resize_response.dragged(); let axis = side.axis(); - outer_size = (pointer[axis] - side.fixed_pos(outer_rect)).abs(); - outer_size = clamp_to_range(outer_size, outer_size_range) + let prev_outer_size = outer_size; + // Signed distance from the fixed edge to the pointer along the + // panel's axis. Going past the fixed edge yields a negative size, + // which `clamp_to_range` then snaps up to `min` β€” DON'T use + // `.abs()` here, that would mirror the drag and spuriously + // trigger drag-to-expand once the pointer crosses the edge. + let raw_outer_size = -side.sign() * (pointer[axis] - side.fixed_pos(outer_rect)); + outer_size = clamp_to_range(raw_outer_size, self.outer_size_range) .at_most(available_rect.size_along(axis)); side.set_rect_size(&mut outer_rect, outer_size); + + if let Some(is_expanded) = is_expanded { + // Drag-to-collapse: shrink past the threshold β†’ close. + // The threshold defaults to `min_size`, but + // `show_switched` overrides it to the + // collapsed panel's size so the swap happens exactly when + // the drag visually crosses the collapsed size. + // Use `raw_outer_size` (pre-clamp) so a tight `exact_size` + // panel can still detect inward overshoot. + let collapse_threshold = + self.collapse_threshold.unwrap_or(self.outer_size_range.min); + if raw_outer_size < collapse_threshold && raw_outer_size < prev_outer_size { + *is_expanded = false; + } + // Drag-to-expand: pointer pulled outward past `max_size` β†’ open. + // Triggers when this panel is acting as the collapsed view of + // `show_switched`, with `resize_id_source` set + // to the expanded panel's id. `raw_outer_size` is required + // because `outer_size` is clamped to `max` and would never + // exceed it (so `exact_size` panels couldn't otherwise expand). + if self.outer_size_range.max < raw_outer_size { + *is_expanded = true; + } + } } } @@ -499,9 +742,10 @@ impl Panel { .translate(slide_distance * side.dir_vec2()) .round_ui() }; + // The portion of the panel actually visible inside the parent's available area. // The parent only allocates this much; neighbors follow the slide. - let visible_outer_rect = shifted_outer_rect.intersect(available_rect); + let visible_outer_rect = shifted_outer_rect.intersect(max_rect); let mut panel_ui = parent_ui.new_child( UiBuilder::new() @@ -515,8 +759,8 @@ impl Panel { let axis = side.axis(); let panel_axis_min = - (outer_size_range.min - frame.total_margin().sum()[axis]).at_least(0.0); - let inner_response = frame.show(&mut panel_ui, |content_ui| { + (self.outer_size_range.min - frame.total_margin().sum()[axis]).at_least(0.0); + let mut inner_response = frame.show(&mut panel_ui, |content_ui| { // Make sure the frame fills the cross-axis fully: let cross_axis_size = content_ui.max_rect().size_along(side.cross_axis()); if axis == 0 { @@ -530,9 +774,14 @@ impl Panel { add_contents(content_ui) }); + if self.outer_size_range.max < inner_response.response.rect.size_along(axis) { + self.side + .set_rect_size(&mut inner_response.response.rect, self.outer_size_range.max); + } + // `Frame::show` returns the panel's (shifted) _outer_ rect, including margin & border. let shifted_outer_rect = inner_response.response.rect; - let visible_outer_rect = shifted_outer_rect.intersect(available_rect); + let visible_outer_rect = shifted_outer_rect.intersect(max_rect); { let mut cursor = parent_ui.cursor(); @@ -553,7 +802,8 @@ impl Panel { // Now we do the actual resize interaction, on top of all the contents, // otherwise its input could be eaten by the contents, e.g. a // `ScrollArea` on either side of the panel boundary. - self.resize_panel(shifted_outer_rect, parent_ui) + let resize_response = self.resize_panel(shifted_outer_rect, parent_ui); + (resize_response.hovered(), resize_response.dragged()) } else { (false, false) }; @@ -562,9 +812,19 @@ impl Panel { parent_ui.set_cursor_icon(self.cursor_icon(outer_size)); } - if self.slide_fraction == 1.0 { - // Only persist the panel's rect when it's fully expanded β€” - // skip while sliding so the stored rect always reflects the real layout. + let is_animating = 0.0 < self.slide_fraction && self.slide_fraction < 1.0; + if !resize_drag_in_progress && !is_animating || PanelState::load(parent_ui, id).is_none() { + // We skip stoing state during a drag, so that the + // stored size reflects the panel's pre-drag size. + // This is so that drag-to-close followed by a drag-to-reopen restores the original size. + + // Skipping when `!persist_state` keeps interpolated sizes (set by the + // collapse animation in `show_switched`) from polluting the panel's + // natural persisted size. + + // Finally, we always store the state if it's not already stored, + // so we get a good estimate for the final size when first expanding a panel. + PanelState { outer_rect: shifted_outer_rect, } @@ -603,30 +863,70 @@ impl Panel { .unwrap_or_else(|| Frame::side_top_panel(ui.style())) } + /// Panel is fully closed. If the user is still dragging the resize handle + /// from the frame the panel closed on, keep its widget id registered so the + /// drag survives, and reopen if they drag back past the minimum size. + fn keep_drag_alive_for_reopen(&self, ui: &Ui, is_expanded: &mut bool) { + let resize_id = self.id.with("__resize"); + let Some(resize_response) = ui.read_response(resize_id) else { + return; + }; + if !resize_response.dragged() { + return; + } + let Some(pointer) = resize_response.interact_pointer_pos() else { + return; + }; + + // Re-register the resize widget at the (now collapsed) fixed edge so its + // id stays alive in egui's interaction state. + let available_rect = ui.available_rect_before_wrap(); + let fixed_edge_pos = self.side.fixed_pos(available_rect); + let cross_range = available_rect.range_along(self.side.cross_axis()); + let resize_rect = if self.side.axis() == 0 { + Rect::from_x_y_ranges(Rangef::point(fixed_edge_pos), cross_range) + } else { + Rect::from_x_y_ranges(cross_range, Rangef::point(fixed_edge_pos)) + }; + let grab = ui.style().interaction.resize_grab_radius_side; + let resize_rect = resize_rect.expand2(grab * self.side.axis_unit()); + ui.interact(resize_rect, resize_id, Sense::drag()); + + // Keep the resize cursor while the user is still holding the drag. + // Otherwise the cursor would snap back to the default the moment the + // panel closed, even though the gesture is still ongoing. + ui.set_cursor_icon(self.cursor_icon(0.0)); + + // Signed distance from the fixed edge to the pointer along the panel's + // axis. Only counts as "pulled outward" while positive β€” going past the + // fixed edge gives a negative value, NOT a mirrored positive one (no + // `.abs()`), so dragging past the screen edge can't spuriously reopen. + let dragged_size = -self.side.sign() * (pointer[self.side.axis()] - fixed_edge_pos); + if self.outer_size_range.min < dragged_size { + *is_expanded = true; + } + } + /// Get the current _outer_ width or height of the panel (from previous frame), /// including the [`Frame`] margin & border, or fall back to some default. + /// + /// Always clamped to [`Self::outer_size_range`] so callers get the size the + /// panel would actually render at β€” never a stale persisted size from a + /// previous build with a different range. fn outer_size(&self, ui: &Ui) -> f32 { let axis = self.side.axis(); - if let Some(state) = PanelState::load(ui, self.id) { + let raw = if let Some(state) = PanelState::load(ui, self.id) { state.outer_rect.size_along(axis) } else if let Some(default_outer_size) = self.default_outer_size { default_outer_size } else { let frame = self.resolve_frame(ui); ui.style().spacing.interact_size[axis] + frame.total_margin().sum()[axis] - } - } - - /// Clamp `outer_size` to the allowed range / available space, then compute the panel rect. - fn compute_outer_rect(&self, available_rect: Rect, mut outer_size: f32) -> Rect { - let mut outer_rect = available_rect; - outer_size = clamp_to_range(outer_size, self.outer_size_range) - .at_most(available_rect.size_along(self.side.axis())); - self.side.set_rect_size(&mut outer_rect, outer_size); - outer_rect + }; + clamp_to_range(raw, self.outer_size_range) } - fn resize_panel(&self, outer_rect: Rect, ui: &Ui) -> (bool, bool) { + fn resize_panel(&self, outer_rect: Rect, ui: &Ui) -> Response { let resize_pos = self.side.resize_pos(outer_rect); let panel_axis_range = Rangef::point(resize_pos); let cross_range = outer_rect.range_along(self.side.cross_axis()); @@ -637,14 +937,26 @@ impl Panel { }; let amount = ui.style().interaction.resize_grab_radius_side * self.side.axis_unit(); - let resize_id = self.id.with("__resize"); + // Use `resize_id_source` so collapsed/expanded panels in + // `show_switched` share one resize widget. + let resize_id = self.resize_id_source.unwrap_or(self.id).with("__resize"); let resize_rect = Rect::from_x_y_ranges(resize_x, resize_y).expand2(amount); - let resize_response = ui.interact(resize_rect, resize_id, Sense::drag()); - - (resize_response.hovered(), resize_response.dragged()) + ui.interact(resize_rect, resize_id, Sense::click_and_drag()) } fn cursor_icon(&self, outer_size: f32) -> CursorIcon { + // When this panel is the collapsed view of `show_switched` + // (`resize_id_source` is set), dragging past `max_size` triggers + // drag-to-expand β€” so the user can always grow further. Treat the cap + // as `INFINITY` for cursor purposes, otherwise we'd advertise + // "can only shrink" while sitting on a drag-to-expand affordance. + let can_drag_to_expand = self.resize_id_source.is_some(); + let max_for_cursor = if can_drag_to_expand { + f32::INFINITY + } else { + self.outer_size_range.max + }; + if outer_size <= self.outer_size_range.min { // Can only grow (toward the resizable side): match self.side { @@ -653,7 +965,7 @@ impl Panel { PanelSide::Top => CursorIcon::ResizeSouth, PanelSide::Bottom => CursorIcon::ResizeNorth, } - } else if outer_size < self.outer_size_range.max { + } else if outer_size < max_for_cursor { if self.side.axis() == 0 { CursorIcon::ResizeHorizontal } else { @@ -676,6 +988,23 @@ impl Panel { self.slide_fraction = slide_fraction; self } + + /// Register the resize-handle widget under this `Id` instead of `self.id`. + /// + /// Used by [`Self::show_switched`] to share one widget across + /// the collapsed and expanded panels. + #[inline] + fn with_resize_id_source(mut self, id: Id) -> Self { + self.resize_id_source = Some(id); + self + } + + /// Override the drag-to-collapse threshold (defaults to `min_size`). + #[inline] + fn with_collapse_threshold(mut self, threshold: f32) -> Self { + self.collapse_threshold = Some(threshold); + self + } } // ---------------------------------------------------------------------------- @@ -697,15 +1026,15 @@ impl Panel { /// /// ``` /// # egui::__run_test_ui(|ui| { -/// egui::Panel::top("my_panel").show_inside(ui, |ui| { +/// egui::Panel::top("my_panel").show(ui, |ui| { /// ui.label("Hello World! From `Panel`, that must be before `CentralPanel`!"); /// }); -/// egui::CentralPanel::default().show_inside(ui, |ui| { +/// egui::CentralPanel::default().show(ui, |ui| { /// ui.label("Hello World!"); /// }); /// # }); /// ``` -#[must_use = "You should call .show_inside()"] +#[must_use = "You should call .show()"] #[derive(Default)] pub struct CentralPanel { frame: Option, @@ -732,12 +1061,18 @@ impl CentralPanel { } /// Show the panel inside a [`Ui`]. + pub fn show(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + self.show_inside_dyn(ui, Box::new(add_contents)) + } + + /// Renamed to [`Self::show`]. + #[deprecated = "Renamed to `show`"] pub fn show_inside( self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse { - self.show_inside_dyn(ui, Box::new(add_contents)) + self.show(ui, add_contents) } /// Show the panel inside a [`Ui`]. diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index cf78a6650e4e..2a9335edecd6 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -472,17 +472,15 @@ impl<'a> Popup<'a> { RectAlign::find_best_align( #[expect(clippy::iter_on_empty_collections)] #[expect(clippy::or_fun_call)] - once(self.rect_align).chain( + std::iter::chain( + once(self.rect_align), self.alternative_aligns // Need the empty slice so the iters have the same type so we can unwrap_or - .map(|a| a.iter().copied().chain([].iter().copied())) - .unwrap_or( - self.rect_align - .symmetries() - .iter() - .copied() - .chain(RectAlign::MENU_ALIGNS.iter().copied()), - ), + .map(|a| std::iter::chain(a.iter().copied(), [].iter().copied())) + .unwrap_or(std::iter::chain( + self.rect_align.symmetries().iter().copied(), + RectAlign::MENU_ALIGNS.iter().copied(), + )), ), self.ctx.content_rect(), anchor_rect, diff --git a/crates/egui/src/containers/resize.rs b/crates/egui/src/containers/resize.rs index 4c21930ae900..b6c086aca1bb 100644 --- a/crates/egui/src/containers/resize.rs +++ b/crates/egui/src/containers/resize.rs @@ -1,6 +1,6 @@ use crate::{ - Align2, Color32, Context, CursorIcon, Id, NumExt as _, Rect, Response, Sense, Shape, Ui, - UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b, pos2, vec2, + Align2, AsIdSalt, Color32, Context, CursorIcon, Id, IdSalt, NumExt as _, Rect, Response, Sense, + Shape, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b, pos2, vec2, }; #[derive(Clone, Copy, Debug)] @@ -41,7 +41,7 @@ impl State { #[must_use = "You should call .show()"] pub struct Resize { id: Option, - id_salt: Option, + id_salt: Option, /// If false, we are no enabled resizable: Vec2b, @@ -78,8 +78,8 @@ impl Resize { /// A source for the unique [`Id`], e.g. `.id_salt("second_resize_area")` or `.id_salt(loop_index)`. #[inline] - pub fn id_salt(mut self, id_salt: impl std::hash::Hash) -> Self { - self.id_salt = Some(Id::new(id_salt)); + pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self { + self.id_salt = Some(IdSalt::new(id_salt)); self } @@ -209,7 +209,7 @@ impl Resize { fn begin(&self, ui: &mut Ui) -> Prepared { let position = ui.available_rect_before_wrap().min; let id = self.id.unwrap_or_else(|| { - let id_salt = self.id_salt.unwrap_or_else(|| Id::new("resize")); + let id_salt = self.id_salt.unwrap_or_else(|| IdSalt::new("resize")); ui.make_persistent_id(id_salt) }); diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 0d1187b95540..2672683c90c8 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -8,9 +8,9 @@ use emath::GuiRounding as _; use epaint::{Color32, Direction, Margin, Shape}; use crate::{ - Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Response, Sense, Ui, UiBuilder, - UiKind, UiStackInfo, Vec2, Vec2b, WidgetInfo, emath, epaint, lerp, pass_state, pos2, remap, - remap_clamp, + AsIdSalt, Context, CursorIcon, Id, IdSalt, NumExt as _, Pos2, Rangef, Rect, Response, Sense, + Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b, WidgetInfo, emath, epaint, lerp, pass_state, + pos2, remap, remap_clamp, }; #[derive(Clone, Copy, Debug)] @@ -344,7 +344,7 @@ pub struct ScrollArea { min_scrolled_size: Vec2, scroll_bar_visibility: ScrollBarVisibility, scroll_bar_rect: Option, - id_salt: Option, + id_salt: Option, offset_x: Option, offset_y: Option, on_hover_cursor: Option, @@ -479,8 +479,8 @@ impl ScrollArea { /// A source for the unique [`Id`], e.g. `.id_salt("second_scroll_area")` or `.id_salt(loop_index)`. #[inline] - pub fn id_salt(mut self, id_salt: impl std::hash::Hash) -> Self { - self.id_salt = Some(Id::new(id_salt)); + pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self { + self.id_salt = Some(IdSalt::new(id_salt)); self } @@ -730,7 +730,7 @@ impl ScrollArea { let ctx = ui.ctx().clone(); - let id_salt = id_salt.unwrap_or_else(|| Id::new("scroll_area")); + let id_salt = id_salt.unwrap_or_else(|| IdSalt::new("scroll_area")); let id = ui.make_persistent_id(id_salt); ctx.check_for_id_clash( id, diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index e3546813e2ce..cf6c58f888ca 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -9,6 +9,54 @@ use crate::*; use super::scroll_area::{DragScroll, ScrollBarVisibility, ScrollSource}; use super::{Area, Frame, Resize, ScrollArea, area, resize}; +/// Where the user can drag to move a [`Window`]. +/// +/// See [`Window::drag_area`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum WindowDrag { + /// Window cannot be moved by dragging. + /// + /// [`Window::movable(false)`](Window::movable) forces this regardless of + /// what was passed to [`Window::drag_area`]. + Off, + + /// The user can drag the window from anywhere on its surface. + /// + /// Good for touch screens, but can interfere with selecting / dragging + /// content inside the window when used with a mouse. + Anywhere, + + /// Only the title bar accepts the move-drag gesture. + /// + /// Windows without a title bar (see [`Window::title_bar`]) silently fall + /// back to [`Self::Anywhere`] β€” otherwise they'd be unmovable. + TitleBar, + + /// [`Self::Anywhere`] when a touch screen is detected (see + /// [`crate::InputState::has_touch_screen`]); [`Self::TitleBar`] otherwise. + /// The recommended default. + #[default] + OnTouch, +} + +impl WindowDrag { + /// Resolve [`Self::OnTouch`] to either [`Self::Anywhere`] or [`Self::TitleBar`] + /// based on whether a touch screen was detected. + fn resolve(self, ctx: &Context) -> Self { + match self { + Self::OnTouch => { + if ctx.input(|i| i.has_touch_screen()) { + Self::Anywhere + } else { + Self::TitleBar + } + } + other => other, + } + } +} + /// Builder for a floating window which can be dragged, closed, collapsed, resized and scrolled (off by default). /// /// You can customize: @@ -43,6 +91,7 @@ pub struct Window<'a> { with_title_bar: bool, fade_out: bool, auto_sized: bool, + drag_area: WindowDrag, } impl<'a> Window<'a> { @@ -67,6 +116,7 @@ impl<'a> Window<'a> { with_title_bar: true, fade_out: true, auto_sized: false, + drag_area: WindowDrag::default(), } } @@ -142,12 +192,29 @@ impl<'a> Window<'a> { } /// If `false` the window will be immovable. + /// + /// If `true`, you can move the window by dragging it. + /// Where you can drag to move the window is determined by [`Self::drag_area`]. #[inline] pub fn movable(mut self, movable: bool) -> Self { self.area = self.area.movable(movable); self } + /// Where the user can grab the window to move it. + /// + /// Defaults to [`WindowDrag::OnTouch`]: drag anywhere on touch screens, + /// title bar only otherwise. See [`WindowDrag`] for details. + /// + /// [`Self::movable(false)`](Self::movable) forces [`WindowDrag::Off`] + /// regardless of this setting. Windows without a title bar (see + /// [`Self::title_bar`]) fall back to [`WindowDrag::Anywhere`]. + #[inline] + pub fn drag_area(mut self, drag_area: WindowDrag) -> Self { + self.drag_area = drag_area; + self + } + /// `order(Order::Foreground)` for a Window that should always be on top #[inline] pub fn order(mut self, order: Order) -> Self { @@ -489,8 +556,64 @@ impl Window<'_> { with_title_bar, fade_out, auto_sized, + drag_area: drag_area_setting, } = self; + // `Window::movable(false)` (and `Area::movable(false)`) and + // `WindowDrag::Off` both mean "this window cannot be moved by + // dragging". Without a title bar, `TitleBar` mode would leave the + // window unmovable, so silently fall back to drag-anywhere instead. + let effective_drag = if !area.is_movable() || drag_area_setting == WindowDrag::Off { + WindowDrag::Off + } else if !with_title_bar { + WindowDrag::Anywhere + } else { + drag_area_setting.resolve(ctx) + }; + + // Make the area itself agree: keep its movable flag in sync with + // the resolved drag mode so resize behavior and `Area::begin`'s + // drag-from-anywhere handling don't disagree with the title-bar + // path. (Builder order shouldn't matter β€” `.drag_area(Off)` after + // `.movable(true)` and vice versa both end up here.) + let area = if effective_drag == WindowDrag::Off { + area.movable(false) + } else { + area + }; + + // Apply the previous frame's title-bar drag _before_ `Area::begin` + // loads the state. We can't apply it inside the content closure because + // `Area::end` writes the locally-captured `AreaState` back, overwriting + // any in-frame mutation. + // + // We deliberately leave `Area` with its normal `Sense::DRAG`: that way + // the area's widget still absorbs drag hit-tests over the body, so the + // resize-edge widgets aren't picked as the "closest drag" target when + // hovering anywhere in the window. The drag-from-anywhere move that + // `Area::begin` would then apply is undone right after `begin` for + // `WindowDrag::TitleBar`. + let title_drag_mode = effective_drag == WindowDrag::TitleBar; + let pivot_pos_before_begin = if title_drag_mode { + if let Some(resp) = ctx.read_response(area.id.with("__title_click")) + && resp.dragged() + { + let delta = ctx.input(|i| i.pointer.delta()); + if delta != Vec2::ZERO { + ctx.memory_mut(|mem| { + if let Some(state) = mem.areas_mut().get_mut(area.id) + && let Some(pivot_pos) = state.pivot_pos.as_mut() + { + *pivot_pos += delta; + } + }); + } + } + area::AreaState::load(ctx, area.id).and_then(|s| s.pivot_pos) + } else { + None + }; + let style = ctx.global_style(); let window_frame = frame.unwrap_or_else(|| Frame::window(&style)); @@ -525,6 +648,24 @@ impl Window<'_> { let on_top = Some(area_layer_id) == ctx.top_layer_id(); let mut area = area.begin(ctx); + // Title-bar-drag mode: throw away any drag-from-anywhere movement + // `Area::begin` may have applied. The title-bar pre-begin step above + // already accounted for the title drag. We then re-run the same + // constrain+round step `Area::begin` does so the title-bar drag + // can't escape `constrain_rect` or reintroduce sub-pixel jitter. + if let Some(pre_begin_pivot) = pivot_pos_before_begin { + let constrain = area.constrain(); + let constrain_rect = area.constrain_rect(); + let state = area.state_mut(); + state.pivot_pos = Some(pre_begin_pivot); + if constrain { + state.set_left_top_pos( + Context::constrain_window_rect_to_area(state.rect(), constrain_rect).min, + ); + } + state.set_left_top_pos(area::round_area_position(ctx, state.left_top_pos())); + } + area.with_widget_info(|| { WidgetInfo::labeled( WidgetType::Window, @@ -576,6 +717,8 @@ impl Window<'_> { on_top, open.as_deref_mut(), auto_sized, + effective_drag == WindowDrag::TitleBar, + area_id, ); } collapsing @@ -1142,6 +1285,8 @@ fn title_ui( active: bool, open: Option<&mut bool>, auto_sized: bool, + drag_to_move: bool, + area_id: Id, ) -> Response { let shape_idx = ui.painter().add(Shape::Noop); @@ -1247,16 +1392,21 @@ fn title_ui( } } - if collapsible - && child_ui - .interact( - title_click_rect, - child_ui.auto_id_with("window_title_click"), - Sense::click(), - ) - .double_clicked() - { - collapsing.toggle(&child_ui); + if collapsible || drag_to_move { + // Single widget covers double-click-to-toggle (when collapsible) and + // drag-to-move (in title-bar-drag mode). The move itself is applied in + // `Window::show_dyn` _before_ `Area::begin` next frame, since + // `Area::end` overwrites any in-frame mutation of `AreaState`. + let sense = if drag_to_move { + Sense::click_and_drag() + } else { + Sense::click() + }; + let response = child_ui.interact(title_click_rect, area_id.with("__title_click"), sense); + + if collapsible && response.double_clicked() { + collapsing.toggle(&child_ui); + } } { diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index d348517ef563..072f253be4f8 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -641,11 +641,7 @@ impl ContextImpl { } fn all_viewport_ids(&self) -> ViewportIdSet { - self.viewports - .keys() - .copied() - .chain([ViewportId::ROOT]) - .collect() + std::iter::chain(self.viewports.keys().copied(), [ViewportId::ROOT]).collect() } /// The current active viewport @@ -698,7 +694,7 @@ impl ContextImpl { /// loop { /// let raw_input = egui::RawInput::default(); /// let full_output = ctx.run_ui(raw_input, |ui| { -/// egui::CentralPanel::default().show_inside(ui, |ui| { +/// egui::CentralPanel::default().show(ui, |ui| { /// ui.label("Hello world!"); /// if ui.button("Click me").clicked() { /// // take some action here @@ -1537,6 +1533,19 @@ impl Context { self.output_mut(|o| o.cursor_icon = cursor_icon); } + /// Request that the integration display this RGBA bitmap as the OS + /// cursor for the next frame, instead of the standard `cursor_icon`. + /// Backends that don't support custom cursors (web, eframe with + /// non-winit integrations) silently fall back to the icon. + /// + /// Pass `None` to clear and revert to `cursor_icon` selection. + /// + /// The integration is expected to dedupe by `Arc` pointer identity, + /// so reusing the same `Arc<[u8]>` across frames is cheap. + pub fn set_cursor_image(&self, image: Option) { + self.output_mut(|o| o.cursor_image = image); + } + /// Add a command to [`PlatformOutput::commands`], /// for the integration to execute at the end of the frame. pub fn send_cmd(&self, cmd: crate::OutputCommand) { diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index c3a4cf3828a3..4793fa8a0421 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -116,6 +116,16 @@ pub struct PlatformOutput { /// Set the cursor to this icon. pub cursor_icon: CursorIcon, + /// If set, the integration should display this RGBA image as the OS + /// cursor (via e.g. `winit::window::CustomCursor`) instead of the + /// standard `cursor_icon`. Set per frame; integrations that don't + /// support custom cursors fall back to `cursor_icon`. + /// + /// Skipped from serde because the bitmap is ephemeral and shouldn't + /// roundtrip through persisted state. + #[cfg_attr(feature = "serde", serde(skip))] + pub cursor_image: Option, + /// Events that may be useful to e.g. a screen reader. pub events: Vec, @@ -177,6 +187,7 @@ impl PlatformOutput { let Self { mut commands, cursor_icon, + cursor_image, mut events, mutable_text_under_cursor, ime, @@ -187,6 +198,7 @@ impl PlatformOutput { self.commands.append(&mut commands); self.cursor_icon = cursor_icon; + self.cursor_image = cursor_image; self.events.append(&mut events); self.mutable_text_under_cursor = mutable_text_under_cursor; self.ime = ime.or(self.ime); @@ -198,10 +210,12 @@ impl PlatformOutput { self.accesskit_update = accesskit_update; } - /// Take everything ephemeral (everything except `cursor_icon` currently) + /// Take everything ephemeral (everything except `cursor_icon` and + /// `cursor_image` currently) pub fn take(&mut self) -> Self { let taken = std::mem::take(self); - self.cursor_icon = taken.cursor_icon; // everything else is ephemeral + self.cursor_icon = taken.cursor_icon; // sticky between frames + self.cursor_image = taken.cursor_image.clone(); // sticky between frames taken } @@ -261,6 +275,39 @@ pub enum UserAttentionType { Reset, } +/// A bitmap cursor pushed to the integration via [`PlatformOutput::cursor_image`]. +/// +/// The integration is expected to upload this to the OS as a real cursor +/// (so the image is not clipped by the egui window β€” what `egui::Painter` +/// drawn cursors suffer from). Backends that don't support it should fall +/// back to [`PlatformOutput::cursor_icon`]. +/// +/// `rgba` is straight (non-premultiplied) RGBA β€” same encoding as +/// `winit::window::CustomCursor::from_rgba`. The buffer length must be +/// exactly `size[0] * size[1] * 4` bytes. `size` and `hotspot` use +/// `u16` to match winit's native types and avoid a lossy cast in the +/// integration layer. +/// +/// `Arc<[u8]>` is used so integrations can dedupe / cache by pointer +/// identity (`Arc::ptr_eq`) and avoid re-uploading the same bitmap to +/// the OS every frame. +#[derive(Clone, PartialEq, Eq)] +pub struct CustomCursorImage { + pub rgba: std::sync::Arc<[u8]>, + pub size: [u16; 2], + pub hotspot: [u16; 2], +} + +impl std::fmt::Debug for CustomCursorImage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CustomCursorImage") + .field("size", &self.size) + .field("hotspot", &self.hotspot) + .field("rgba_len", &self.rgba.len()) + .finish() + } +} + /// A mouse cursor icon. /// /// egui emits a [`CursorIcon`] in [`PlatformOutput`] each frame as a request to the integration. diff --git a/crates/egui/src/grid.rs b/crates/egui/src/grid.rs index 0cf5c95dfd62..e5c20f05f9ac 100644 --- a/crates/egui/src/grid.rs +++ b/crates/egui/src/grid.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use emath::GuiRounding as _; use crate::{ - Align2, Color32, Context, Id, InnerResponse, NumExt as _, Painter, Rect, Region, Style, Ui, - UiBuilder, Vec2, vec2, + Align2, AsIdSalt, Color32, Context, Id, IdSalt, InnerResponse, NumExt as _, Painter, Rect, + Region, Style, Ui, UiBuilder, Vec2, vec2, }; #[cfg(debug_assertions)] @@ -312,7 +312,7 @@ impl GridLayout { /// ``` #[must_use = "You should call .show()"] pub struct Grid { - id_salt: Id, + id_salt: IdSalt, num_columns: Option, min_col_width: Option, min_row_height: Option, @@ -324,9 +324,9 @@ pub struct Grid { impl Grid { /// Create a new [`Grid`] with a locally unique identifier. - pub fn new(id_salt: impl std::hash::Hash) -> Self { + pub fn new(id_salt: impl AsIdSalt) -> Self { Self { - id_salt: Id::new(id_salt), + id_salt: IdSalt::new(id_salt), num_columns: None, min_col_width: None, min_row_height: None, diff --git a/crates/egui/src/id.rs b/crates/egui/src/id.rs index 661bdf2bfa88..c0e21fbe2ef8 100644 --- a/crates/egui/src/id.rs +++ b/crates/egui/src/id.rs @@ -2,6 +2,16 @@ use std::num::NonZeroU64; +use crate::{AsIdSalt, IdSalt}; + +/// Types that can be converted to an [`Id`]. +/// +/// This is all types implementing `Hash` and `Debug`, +/// which includes things like string, integers, tuples of those, etc. +pub trait AsId: std::hash::Hash + std::fmt::Debug {} + +impl AsId for T {} + /// egui tracks widgets frame-to-frame using [`Id`]s. /// /// For instance, if you start dragging a slider one frame, egui stores @@ -43,6 +53,7 @@ impl Id { /// though obviously it will lead to a lot of collisions if you do use it! pub const NULL: Self = Self(NonZeroU64::MAX); + /// Create a new root [`Id`] from a high-entropy hash. #[inline] const fn from_hash(hash: u64) -> Self { if let Some(nonzero) = NonZeroU64::new(hash) { @@ -52,17 +63,17 @@ impl Id { } } - /// Generate a new [`Id`] by hashing some source (e.g. a string or integer). - pub fn new(source: impl std::hash::Hash) -> Self { + /// Generate a new root [`Id`] by hashing some source (e.g. a string or integer). + pub fn new(source: impl AsId) -> Self { Self::from_hash(ahash::RandomState::with_seeds(1, 2, 3, 4).hash_one(source)) } - /// Generate a new [`Id`] by hashing the parent [`Id`] and the given argument. - pub fn with(self, child: impl std::hash::Hash) -> Self { + /// Generate a child [`Id`] by salting the parent [`Id`] with the given argument. + pub fn with(self, salt: impl AsIdSalt) -> Self { use std::hash::{BuildHasher as _, Hasher as _}; let mut hasher = ahash::RandomState::with_seeds(1, 2, 3, 4).build_hasher(); - hasher.write_u64(self.0.get()); - child.hash(&mut hasher); + hasher.write_u64(self.value()); + hasher.write_u64(IdSalt::new(salt).value()); Self::from_hash(hasher.finish()) } diff --git a/crates/egui/src/id_salt.rs b/crates/egui/src/id_salt.rs new file mode 100644 index 000000000000..0912dcbd33b4 --- /dev/null +++ b/crates/egui/src/id_salt.rs @@ -0,0 +1,59 @@ +use std::num::NonZeroU64; + +/// Types that can be converted to an [`IdSalt`]. +/// +/// This is all types implementing `Hash` and `Debug`, +/// which includes things like string, integers, tuples of those, etc. +pub trait AsIdSalt: std::hash::Hash + std::fmt::Debug {} + +impl AsIdSalt for T {} + +/// Uniquely identifies a child widget within a parent widget. +/// +/// An [`IdSalt`] is only unique within a parent [`crate::Id`]. +/// An [`IdSalt`] is NOT globally unique. +/// +/// You combine a parent [`crate::Id`] with an [`IdSalt`] to get a child [`crate::Id`], +/// using [`crate::Id::with`]. +/// +/// An [`IdSalt`] is usually a string, an integer, or similar. +/// +/// An [`IdSalt`] should NOT be produced from an [`crate::Id`]. +/// +/// This is niche-optimized to that `Option` is the same size as `IdSalt`. +#[derive(Clone, Copy, Hash, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct IdSalt(NonZeroU64); + +impl nohash_hasher::IsEnabled for IdSalt {} + +impl IdSalt { + /// Create a new [`IdSalt`] by hashing some source (e.g. a string or integer). + pub fn new(source: impl AsIdSalt) -> Self { + Self::from_hash(ahash::RandomState::with_seeds(1, 2, 3, 4).hash_one(source)) + } + + /// Create a new root [`IdSalt`] from a high-entropy hash. + #[inline] + const fn from_hash(hash: u64) -> Self { + if let Some(nonzero) = NonZeroU64::new(hash) { + Self(nonzero) + } else { + Self(NonZeroU64::MIN) // The hash was exactly zero + } + } + + /// The inner value of the [`IdSalt`]. + /// + /// This is a high-entropy hash. + #[inline(always)] + pub fn value(&self) -> u64 { + self.0.get() + } +} + +impl std::fmt::Debug for IdSalt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "salt_{:04X}", self.value() as u16) + } +} diff --git a/crates/egui/src/interaction.rs b/crates/egui/src/interaction.rs index bfac38da79ba..6e01ec8fb24d 100644 --- a/crates/egui/src/interaction.rs +++ b/crates/egui/src/interaction.rs @@ -232,20 +232,14 @@ pub(crate) fn interact( // ); // } - let contains_pointer: IdSet = hits - .contains_pointer - .iter() - .chain(&hits.click) - .chain(&hits.drag) - .map(|w| w.id) - .collect(); + let contains_pointer: IdSet = + itertools::chain!(&hits.contains_pointer, &hits.click, &hits.drag) + .map(|w| w.id) + .collect(); let hovered = if clicked.is_some() || dragged.is_some() || long_touched.is_some() { // If currently clicking or dragging, only that and nothing else is hovered. - clicked - .iter() - .chain(&dragged) - .chain(&long_touched) + itertools::chain!(&clicked, &dragged, &long_touched) .copied() .collect() } else { @@ -268,7 +262,9 @@ pub(crate) fn interact( let drag_order = hits.drag.and_then(|w| order(w.id)).unwrap_or(0); let top_interactive_order = click_order.max(drag_order); - let mut hovered: IdSet = hits.click.iter().chain(&hits.drag).map(|w| w.id).collect(); + let mut hovered: IdSet = std::iter::chain(&hits.click, &hits.drag) + .map(|w| w.id) + .collect(); for w in &hits.contains_pointer { let is_interactive = w.sense.senses_click() || w.sense.senses_drag(); diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index f88372efd588..0cc58c152b26 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -113,7 +113,7 @@ //! let raw_input: egui::RawInput = gather_input(); //! //! let full_output = ctx.run_ui(raw_input, |ui| { -//! egui::CentralPanel::default().show_inside(ui, |ui| { +//! egui::CentralPanel::default().show(ui, |ui| { //! ui.label("Hello world!"); //! if ui.button("Click me").clicked() { //! // take some action here @@ -400,6 +400,7 @@ pub(crate) mod grid; pub mod gui_zoom; mod hit_test; mod id; +mod id_salt; mod input_state; mod interaction; pub mod introspection; @@ -466,14 +467,15 @@ pub use self::{ Key, UserData, input::*, output::{ - self, CursorIcon, FullOutput, OpenUrl, OutputCommand, PlatformOutput, - UserAttentionType, WidgetInfo, + self, CursorIcon, CustomCursorImage, FullOutput, OpenUrl, OutputCommand, + PlatformOutput, UserAttentionType, WidgetInfo, }, }, drag_and_drop::DragAndDrop, epaint::text::TextWrapMode, grid::Grid, - id::{Id, IdMap, IdSet}, + id::{AsId, Id, IdMap, IdSet}, + id_salt::{AsIdSalt, IdSalt}, input_state::{InputOptions, InputState, MultiTouchInfo, PointerState, SurrenderFocusOn}, layers::{LayerId, Order}, layout::*, @@ -486,7 +488,7 @@ pub use self::{ style::{FontSelection, Spacing, Style, TextStyle, Visuals}, text::{Galley, TextFormat}, ui::Ui, - ui_builder::UiBuilder, + ui_builder::{IdSource, UiBuilder}, ui_stack::*, viewport::*, widget_rect::{InteractOptions, WidgetRect, WidgetRects}, diff --git a/crates/egui/src/load.rs b/crates/egui/src/load.rs index 99909eca1409..c02f37c5cfef 100644 --- a/crates/egui/src/load.rs +++ b/crates/egui/src/load.rs @@ -72,7 +72,7 @@ use crate::Context; pub use self::{bytes_loader::DefaultBytesLoader, texture_loader::DefaultTextureLoader}; /// Represents a failed attempt at loading an image. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum LoadError { /// Programmer error: There are no image loaders installed. NoImageLoaders, diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 674f18f5643e..b65dfdffabea 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -1171,6 +1171,10 @@ impl Areas { self.areas.get(&id) } + pub(crate) fn get_mut(&mut self, id: Id) -> Option<&mut area::AreaState> { + self.areas.get_mut(&id) + } + /// All layers back-to-front, top is last. pub(crate) fn order(&self) -> &[LayerId] { &self.order @@ -1232,11 +1236,12 @@ impl Areas { } pub fn visible_layer_ids(&self) -> ahash::HashSet { - self.visible_areas_last_frame - .iter() - .copied() - .chain(self.visible_areas_current_frame.iter().copied()) - .collect() + std::iter::chain( + &self.visible_areas_last_frame, + &self.visible_areas_current_frame, + ) + .copied() + .collect() } pub(crate) fn visible_windows(&self) -> impl Iterator { diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 1114334703bc..a054d96d5f9e 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -2,7 +2,7 @@ use emath::Align; use epaint::{ - AlphaFromCoverage, CornerRadius, Shadow, Stroke, TextOptions, + CornerRadius, FontColorTransferFunction, Shadow, Stroke, TextOptions, mutex::Mutex, text::{FontTweak, Tag}, }; @@ -1461,7 +1461,7 @@ impl Visuals { Self { dark_mode: true, text_options: TextOptions { - alpha_from_coverage: AlphaFromCoverage::DARK_MODE_DEFAULT, + color_transfer_function: FontColorTransferFunction::DARK_MODE_DEFAULT, ..Default::default() }, override_text_color: None, @@ -1527,7 +1527,7 @@ impl Visuals { Self { dark_mode: false, text_options: TextOptions { - alpha_from_coverage: AlphaFromCoverage::LIGHT_MODE_DEFAULT, + color_transfer_function: FontColorTransferFunction::LIGHT_MODE_DEFAULT, ..Default::default() }, widgets: Widgets::light(), @@ -2318,12 +2318,12 @@ impl Visuals { let TextOptions { max_texture_side: _, - alpha_from_coverage, + color_transfer_function, font_hinting, subpixel_binning, } = text_options; - text_alpha_from_coverage_ui(ui, alpha_from_coverage); + color_transfer_function_ui(ui, color_transfer_function); ui.checkbox(font_hinting, "Font hinting (sharper text)"); ui.checkbox(subpixel_binning, "Sub-pixel binning (more even kerning)"); @@ -2437,23 +2437,29 @@ impl Visuals { } } -fn text_alpha_from_coverage_ui(ui: &mut Ui, alpha_from_coverage: &mut AlphaFromCoverage) { - let mut dark_mode_special = - *alpha_from_coverage == AlphaFromCoverage::TwoCoverageMinusCoverageSq; - +fn color_transfer_function_ui( + ui: &mut Ui, + color_transfer_function: &mut FontColorTransferFunction, +) { ui.horizontal(|ui| { - ui.label("Text rendering:"); + ui.label("Opacity tweaking:"); - ui.checkbox(&mut dark_mode_special, "Dark-mode special"); + ui.radio_value( + color_transfer_function, + FontColorTransferFunction::Off, + "Off", + ); + ui.radio_value( + color_transfer_function, + FontColorTransferFunction::DARK_MODE_DEFAULT, + "Dark-mode special", + ); - if dark_mode_special { - *alpha_from_coverage = AlphaFromCoverage::DARK_MODE_DEFAULT; - } else { - let mut gamma = match alpha_from_coverage { - AlphaFromCoverage::Linear => 1.0, - AlphaFromCoverage::Gamma(gamma) => *gamma, - AlphaFromCoverage::TwoCoverageMinusCoverageSq => 0.5, // approximately the same - }; + let mut use_gamma = matches!(color_transfer_function, FontColorTransferFunction::Gamma(_)); + ui.radio_value(&mut use_gamma, true, "Gamma function"); + + if use_gamma { + let mut gamma = color_transfer_function.to_gamma(); ui.add( DragValue::new(&mut gamma) @@ -2462,11 +2468,7 @@ fn text_alpha_from_coverage_ui(ui: &mut Ui, alpha_from_coverage: &mut AlphaFromC .prefix("Gamma: "), ); - if gamma == 1.0 { - *alpha_from_coverage = AlphaFromCoverage::Linear; - } else { - *alpha_from_coverage = AlphaFromCoverage::Gamma(gamma); - } + *color_transfer_function = FontColorTransferFunction::Gamma(gamma); } }); } diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 81508e940735..8406829791f9 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -1,12 +1,13 @@ #![warn(missing_docs)] // Let's keep `Ui` well-documented. #![expect(clippy::use_self)] -use std::{any::Any, hash::Hash, ops::Deref, sync::Arc}; +use std::{any::Any, ops::Deref, sync::Arc}; use crate::containers::menu; use crate::widget_style::{HasClasses as _, ROOT_CLASS}; -use crate::{containers::*, ecolor::*, layout::*, placer::Placer, widgets::*, *}; +use crate::{IdSource, containers::*, ecolor::*, layout::*, placer::Placer, widgets::*, *}; use emath::GuiRounding as _; + // ---------------------------------------------------------------------------- /// This is what you use to place widgets. @@ -106,8 +107,7 @@ impl Ui { /// [`crate::Panel`], [`crate::CentralPanel`], [`crate::Window`] or [`crate::Area`]. pub fn new(ctx: Context, id: Id, ui_builder: UiBuilder) -> Self { let UiBuilder { - id_salt, - global_scope: _, + id_source, ui_stack_info, layer_id, max_rect, @@ -124,8 +124,8 @@ impl Ui { let layer_id = layer_id.unwrap_or_else(LayerId::background); debug_assert!( - id_salt.is_none(), - "Top-level Ui:s should not have an id_salt" + id_source.is_none(), + "Top-level Ui:s should not have an UiBuilder::id_source" ); let max_rect = max_rect.unwrap_or_else(|| ctx.content_rect()); @@ -207,8 +207,7 @@ impl Ui { /// [`Ui::advance_cursor_after_rect`]. pub fn new_child(&mut self, ui_builder: UiBuilder) -> Self { let UiBuilder { - id_salt, - global_scope, + id_source, ui_stack_info, layer_id, max_rect, @@ -224,7 +223,6 @@ impl Ui { let mut painter = self.painter.clone(); - let id_salt = id_salt.unwrap_or_else(|| Id::from("child")); let max_rect = max_rect.unwrap_or_else(|| self.available_rect_before_wrap()); let mut layout = layout.unwrap_or_else(|| *self.layout()); let enabled = self.enabled && !disabled && !invisible; @@ -248,13 +246,15 @@ impl Ui { } debug_assert!(!max_rect.any_nan(), "max_rect is NaN: {max_rect:?}"); - let (stable_id, unique_id) = if global_scope { - (id_salt, id_salt) - } else { - let stable_id = self.id.with(id_salt); - let unique_id = stable_id.with(self.next_auto_id_salt); - (stable_id, unique_id) + let id_source = id_source.unwrap_or_else(|| IdSource::Child(IdSalt::new("child"))); + let (stable_id, unique_id) = match id_source { + IdSource::Explicit(id) => (id, id), + IdSource::Child(id_salt) => { + let stable_id = self.id.with(id_salt); + let unique_id = stable_id.with(self.next_auto_id_salt); + (stable_id, unique_id) + } }; let next_auto_id_salt = unique_id.value().wrapping_add(1); @@ -880,11 +880,8 @@ impl Ui { /// # [`Id`] creation impl Ui { /// Use this to generate widget ids for widgets that have persistent state in [`Memory`]. - pub fn make_persistent_id(&self, id_salt: IdSource) -> Id - where - IdSource: Hash, - { - self.id.with(&id_salt) + pub fn make_persistent_id(&self, id_salt: impl AsIdSalt) -> Id { + self.id.with(id_salt) } /// This is the `Id` that will be assigned to the next widget added to this `Ui`. @@ -893,10 +890,7 @@ impl Ui { } /// Same as `ui.next_auto_id().with(id_salt)` - pub fn auto_id_with(&self, id_salt: IdSource) -> Id - where - IdSource: Hash, - { + pub fn auto_id_with(&self, id_salt: impl AsIdSalt) -> Id { Id::new(self.next_auto_id_salt).with(id_salt) } @@ -2168,7 +2162,7 @@ impl Ui { /// ``` pub fn push_id( &mut self, - id_salt: impl Hash, + id_salt: impl AsIdSalt, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse { self.scope_dyn(UiBuilder::new().id_salt(id_salt), Box::new(add_contents)) @@ -2233,7 +2227,7 @@ impl Ui { #[inline] pub fn indent( &mut self, - id_salt: impl Hash, + id_salt: impl AsIdSalt, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse { self.indent_dyn(id_salt, Box::new(add_contents)) @@ -2241,7 +2235,7 @@ impl Ui { fn indent_dyn<'c, R>( &mut self, - id_salt: impl Hash, + id_salt: impl AsIdSalt, add_contents: Box R + 'c>, ) -> InnerResponse { assert!( diff --git a/crates/egui/src/ui_builder.rs b/crates/egui/src/ui_builder.rs index a8121e235548..893b34468f92 100644 --- a/crates/egui/src/ui_builder.rs +++ b/crates/egui/src/ui_builder.rs @@ -1,10 +1,11 @@ -use std::{hash::Hash, sync::Arc}; +use std::sync::Arc; #[expect(unused_imports)] // Used for doclinks use crate::Ui; -use crate::widget_style::HasClasses; -use crate::{ClosableTag, widget_style::Classes}; -use crate::{Id, LayerId, Layout, Rect, Sense, Style, UiStackInfo}; +use crate::{ + AsIdSalt, ClosableTag, Id, IdSalt, LayerId, Layout, Rect, Sense, Style, UiStackInfo, + widget_style::{Classes, HasClasses}, +}; /// Build a [`Ui`] as the child of another [`Ui`]. /// @@ -14,8 +15,7 @@ use crate::{Id, LayerId, Layout, Rect, Sense, Style, UiStackInfo}; #[must_use] #[derive(Clone, Default)] pub struct UiBuilder { - pub id_salt: Option, - pub global_scope: bool, + pub id_source: Option, pub ui_stack_info: UiStackInfo, pub layer_id: Option, pub max_rect: Option, @@ -29,6 +29,16 @@ pub struct UiBuilder { pub classes: Classes, } +/// Is this [`Ui`] a root or a child of another [`Ui`]? +#[derive(Clone)] +pub enum IdSource { + /// Explicitly use this [`Id`] + Explicit(Id), + + /// Salt the parent [`Id`] with this. + Child(IdSalt), +} + impl UiBuilder { #[inline] pub fn new() -> Self { @@ -41,8 +51,8 @@ impl UiBuilder { /// You should give each [`Ui`] an `id_salt` that is unique /// within the parent, or give it none at all. #[inline] - pub fn id_salt(mut self, id_salt: impl Hash) -> Self { - self.id_salt = Some(Id::new(id_salt)); + pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self { + self.id_source = Some(IdSource::Child(IdSalt::new(id_salt))); self } @@ -57,20 +67,7 @@ impl UiBuilder { /// This is a shortcut for `.id_salt(my_id).global_scope(true)`. #[inline] pub fn id(mut self, id: Id) -> Self { - self.id_salt = Some(id); - self.global_scope = true; - self - } - - /// Make the new `Ui` child ids independent of the parent `Ui`. - /// This way child widgets can be moved in the ui tree without losing state. - /// You have to ensure that in a frame the child widgets do not get rendered in multiple places. - /// - /// You should set the same globally unique `id_salt` at every place in the ui tree where you want the - /// child widgets to share state. - #[inline] - pub fn global_scope(mut self, global_scope: bool) -> Self { - self.global_scope = global_scope; + self.id_source = Some(IdSource::Explicit(id)); self } diff --git a/crates/egui/src/viewport.rs b/crates/egui/src/viewport.rs index ac8704961971..a94f1f637cc5 100644 --- a/crates/egui/src/viewport.rs +++ b/crates/egui/src/viewport.rs @@ -73,7 +73,7 @@ use std::sync::Arc; use epaint::{Pos2, Vec2}; -use crate::{Context, Id, Ui}; +use crate::{AsId, Context, Id, Ui}; // ---------------------------------------------------------------------------- @@ -150,7 +150,7 @@ impl ViewportId { pub const ROOT: Self = Self(Id::NULL); #[inline] - pub fn from_hash_of(source: impl std::hash::Hash) -> Self { + pub fn from_hash_of(source: impl AsId) -> Self { Self(Id::new(source)) } } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 7cb28d8f8709..1489fc67c397 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -4,10 +4,10 @@ use emath::{Rect, TSTransform}; use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor}; use crate::{ - Align, Align2, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context, CursorIcon, Event, - EventFilter, FontSelection, Frame, Id, ImeEvent, IntoAtoms, IntoSizedResult, Key, - KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, SizedAtomKind, TextBuffer, - TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint, + Align, Align2, AsIdSalt, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context, + CursorIcon, Event, EventFilter, FontSelection, Frame, Id, IdSalt, ImeEvent, IntoAtoms, + IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, + SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint, os::OperatingSystem, output::OutputEvent, response, @@ -72,7 +72,7 @@ pub struct TextEdit<'t> { suffix: Atoms<'static>, hint_text: Atoms<'static>, id: Option, - id_salt: Option, + id_salt: Option, font_selection: FontSelection, text_color: Option, layouter: Option>, @@ -171,14 +171,14 @@ impl<'t> TextEdit<'t> { /// A source for the unique [`Id`], e.g. `.id_source("second_text_edit_field")` or `.id_source(loop_index)`. #[inline] - pub fn id_source(self, id_salt: impl std::hash::Hash) -> Self { + pub fn id_source(self, id_salt: impl AsIdSalt) -> Self { self.id_salt(id_salt) } /// A source for the unique [`Id`], e.g. `.id_salt("second_text_edit_field")` or `.id_salt(loop_index)`. #[inline] - pub fn id_salt(mut self, id_salt: impl std::hash::Hash) -> Self { - self.id_salt = Some(Id::new(id_salt)); + pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self { + self.id_salt = Some(IdSalt::new(id_salt)); self } diff --git a/crates/egui_demo_app/src/accessibility_inspector.rs b/crates/egui_demo_app/src/accessibility_inspector.rs index db34c85ac1ea..95c72cc80696 100644 --- a/crates/egui_demo_app/src/accessibility_inspector.rs +++ b/crates/egui_demo_app/src/accessibility_inspector.rs @@ -87,13 +87,13 @@ impl egui::Plugin for AccessibilityInspectorPlugin { ui.enable_accesskit(); - Panel::right(Self::id()).show_inside(ui, |ui| { + Panel::right(Self::id()).show(ui, |ui| { ui.heading("πŸ”Ž AccessKit Inspector"); if let Some(selected_node) = self.selected_node { Panel::bottom(Self::id().with("details_panel")) .frame(Frame::new()) .show_separator_line(false) - .show_inside(ui, |ui| { + .show(ui, |ui| { self.selection_ui(ui, selected_node); }); } diff --git a/crates/egui_demo_app/src/apps/custom3d_glow.rs b/crates/egui_demo_app/src/apps/custom3d_glow.rs index e07da85d393a..8f2485973376 100644 --- a/crates/egui_demo_app/src/apps/custom3d_glow.rs +++ b/crates/egui_demo_app/src/apps/custom3d_glow.rs @@ -25,7 +25,7 @@ impl Custom3d { impl crate::DemoApp for Custom3d { fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // TODO(emilk): Use `ScrollArea::content_margin` - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; diff --git a/crates/egui_demo_app/src/apps/custom3d_wgpu.rs b/crates/egui_demo_app/src/apps/custom3d_wgpu.rs index 1cc105e28ffb..b6ba60df902e 100644 --- a/crates/egui_demo_app/src/apps/custom3d_wgpu.rs +++ b/crates/egui_demo_app/src/apps/custom3d_wgpu.rs @@ -103,7 +103,7 @@ impl Custom3d { impl crate::DemoApp for Custom3d { fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // TODO(emilk): Use `ScrollArea::content_margin` - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; diff --git a/crates/egui_demo_app/src/apps/http_app.rs b/crates/egui_demo_app/src/apps/http_app.rs index f263cef81096..dff7bf1a2a9b 100644 --- a/crates/egui_demo_app/src/apps/http_app.rs +++ b/crates/egui_demo_app/src/apps/http_app.rs @@ -61,14 +61,14 @@ impl Default for HttpApp { impl crate::DemoApp for HttpApp { fn demo_ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - egui::Panel::bottom("http_bottom").show_inside(ui, |ui| { + egui::Panel::bottom("http_bottom").show(ui, |ui| { let layout = egui::Layout::top_down(egui::Align::Center).with_main_justify(true); ui.allocate_ui_with_layout(ui.available_size(), layout, |ui| { ui.add(egui_demo_lib::egui_github_link_file!()) }) }); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { let prev_url = self.url.clone(); let trigger_fetch = ui_url(ui, frame, &mut self.url); diff --git a/crates/egui_demo_app/src/apps/image_viewer.rs b/crates/egui_demo_app/src/apps/image_viewer.rs index 11cc68b6b4bd..1fdb69c2943f 100644 --- a/crates/egui_demo_app/src/apps/image_viewer.rs +++ b/crates/egui_demo_app/src/apps/image_viewer.rs @@ -50,7 +50,7 @@ impl Default for ImageViewer { impl crate::DemoApp for ImageViewer { fn demo_ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) { - egui::Panel::top("url bar").show_inside(ui, |ui| { + egui::Panel::top("url bar").show(ui, |ui| { ui.horizontal_centered(|ui| { let label = ui.label("URI:"); ui.text_edit_singleline(&mut self.uri_edit_text) @@ -71,7 +71,7 @@ impl crate::DemoApp for ImageViewer { }); }); - egui::Panel::left("controls").show_inside(ui, |ui| { + egui::Panel::left("controls").show(ui, |ui| { // uv ui.label("UV"); ui.add(Slider::new(&mut self.image_options.uv.min.x, 0.0..=1.0).text("min x")); @@ -197,7 +197,7 @@ impl crate::DemoApp for ImageViewer { } }); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { egui::ScrollArea::both().show(ui, |ui| { let mut image = egui::Image::from_uri(&self.current_uri); image = image.uv(self.image_options.uv); diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 64ecbce56529..73fcc5e843f2 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -64,7 +64,7 @@ pub struct ColorTestApp { impl DemoApp for ColorTestApp { fn demo_ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { if frame.is_web() { ui.label( "NOTE: Some old browsers stuck on WebGL1 without sRGB support will not pass the color test.", @@ -302,7 +302,7 @@ impl eframe::App for WrapApp { let mut cmd = Command::Nothing; egui::Panel::top("wrap_app_top_bar") .frame(egui::Frame::new().inner_margin(4)) - .show_inside(ui, |ui| { + .show(ui, |ui| { ui.horizontal_wrapped(|ui| { ui.visuals_mut().button_frame = false; self.bar_contents(ui, frame, &mut cmd); @@ -311,7 +311,7 @@ impl eframe::App for WrapApp { self.state.backend_panel.update(ui.ctx(), frame); - egui::CentralPanel::no_frame().show_inside(ui, |ui| { + egui::CentralPanel::no_frame().show(ui, |ui| { if !is_mobile(ui.ctx()) { cmd = self.backend_panel(ui, frame); } @@ -343,13 +343,14 @@ impl WrapApp { fn backend_panel(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) -> Command { // The backend-panel can be toggled on/off. // We show a little animation when the user switches it. - let is_open = self.state.backend_panel.open || ui.memory(|mem| mem.everything_is_visible()); + let mut is_open = + self.state.backend_panel.open || ui.memory(|mem| mem.everything_is_visible()); let mut cmd = Command::Nothing; egui::Panel::left("backend_panel") .resizable(false) - .show_animated_inside(ui, is_open, |ui| { + .show_collapsible(ui, &mut is_open, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { ui.heading("πŸ’» Backend"); @@ -359,6 +360,9 @@ impl WrapApp { self.backend_panel_contents(ui, frame, &mut cmd); }); + // Allow drag-to-close to close the backend panel: + self.state.backend_panel.open = is_open; + cmd } diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 1c7831016fe9..8e59e09337c3 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -245,7 +245,7 @@ impl DemoWindows { } fn mobile_top_bar(&mut self, ui: &mut egui::Ui) { - egui::Panel::top("menu_bar").show_inside(ui, |ui| { + egui::Panel::top("menu_bar").show(ui, |ui| { menu::MenuBar::new() .config(menu::MenuConfig::new().style(StyleModifier::default())) .ui(ui, |ui| { @@ -275,7 +275,7 @@ impl DemoWindows { .resizable(false) .default_size(160.0) .min_size(160.0) - .show_inside(ui, |ui| { + .show(ui, |ui| { ui.vertical_centered_justified(|ui| { ui.add_space(4.0); ui.add( @@ -296,7 +296,7 @@ impl DemoWindows { self.demo_list_ui(ui); }); - egui::Panel::top("menu_bar").show_inside(ui, |ui| { + egui::Panel::top("menu_bar").show(ui, |ui| { menu::MenuBar::new().ui(ui, |ui| { file_menu_button(ui); }); @@ -418,7 +418,7 @@ mod tests { if name == "BΓ©zier Curve" { // The BΓ©zier Curve demo needs a threshold of 2.1 to pass on linux: - options = options.threshold(OsThreshold::new(0.0).linux(2.1)); + options = options.threshold(OsThreshold::new(0.0_f32).linux(2.1)); } results.add(harness.try_snapshot_options(format!("demos/{name}"), &options)); diff --git a/crates/egui_demo_lib/src/demo/drag_and_drop.rs b/crates/egui_demo_lib/src/demo/drag_and_drop.rs index a891acaaed35..f1e44b823713 100644 --- a/crates/egui_demo_lib/src/demo/drag_and_drop.rs +++ b/crates/egui_demo_lib/src/demo/drag_and_drop.rs @@ -16,7 +16,7 @@ impl Default for DragAndDropDemo { vec!["Item H", "Item I", "Item J", "Item K"], ] .into_iter() - .map(|v| v.into_iter().map(ToString::to_string).collect()) + .map(|v| v.into_iter().map(str::to_owned).collect()) .collect(), } } diff --git a/crates/egui_demo_lib/src/demo/extra_viewport.rs b/crates/egui_demo_lib/src/demo/extra_viewport.rs index 89ea735cfb85..ab3eac674420 100644 --- a/crates/egui_demo_lib/src/demo/extra_viewport.rs +++ b/crates/egui_demo_lib/src/demo/extra_viewport.rs @@ -27,7 +27,7 @@ impl crate::Demo for ExtraViewport { // Not a real viewport ui.label("This egui integration does not support multiple viewports"); } else { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { viewport_content(ui, open); }); } diff --git a/crates/egui_demo_lib/src/demo/panels.rs b/crates/egui_demo_lib/src/demo/panels.rs index 84aefcd3e444..8f2e811faa1e 100644 --- a/crates/egui_demo_lib/src/demo/panels.rs +++ b/crates/egui_demo_lib/src/demo/panels.rs @@ -49,7 +49,7 @@ impl crate::View for Panels { egui::Panel::top("top_panel") .resizable(true) .min_size(32.0) - .show_animated_inside(ui, *top, |ui| { + .show_collapsible(ui, top, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { ui.vertical_centered(|ui| { ui.heading("Expandable Upper Panel"); @@ -62,7 +62,7 @@ impl crate::View for Panels { .resizable(true) .default_size(150.0) .size_range(80.0..=200.0) - .show_animated_inside(ui, *left, |ui| { + .show_collapsible(ui, left, |ui| { ui.vertical_centered(|ui| { ui.heading("Left Panel"); }); @@ -75,7 +75,7 @@ impl crate::View for Panels { .resizable(true) .default_size(150.0) .size_range(80.0..=200.0) - .show_animated_inside(ui, *right, |ui| { + .show_collapsible(ui, right, |ui| { ui.vertical_centered(|ui| { ui.heading("Right Panel"); }); @@ -84,31 +84,38 @@ impl crate::View for Panels { }); }); - egui::Panel::show_animated_between_inside( + // Bottom panel: drag the top edge down past the expanded panel's min size + // to collapse; drag it back up past the collapsed panel's max size to + // re-expand. Both panels are `.resizable(true)` so each one's edge accepts + // the gesture; the collapsed panel uses `exact_size` so even a tiny + // outward drag is enough to trigger the swap. + egui::Panel::show_switched( ui, - *bottom, - egui::Panel::bottom("bottom_panel_collapsed"), - egui::Panel::bottom("bottom_panel_expanded"), + bottom, + egui::Panel::bottom("bottom_panel_collapsed") + .resizable(true) + .default_size(20.0), + egui::Panel::bottom("bottom_panel_expanded") + .resizable(true) + .max_size(128.0), |ui, expanded| { if expanded { ui.vertical_centered(|ui| { - if ui.button("Collapse bottom panel").clicked() { - *bottom = false; - } + ui.heading("Bottom panel"); + }); + egui::ScrollArea::vertical().show(ui, |ui| { + lorem_ipsum(ui); }); - ui.label(egui::RichText::new(crate::LOREM_IPSUM_LONG).small().weak()); } else { ui.vertical_centered(|ui| { - if ui.button("Expand bottom panel").clicked() { - *bottom = true; - } + ui.label("Bottom panel (collapsed)"); }); } }, ); // TODO(emilk): This extra panel is superfluous - just use what's left of `ui` instead - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.vertical_centered(|ui| { ui.heading("Central Panel"); }); diff --git a/crates/egui_demo_lib/src/demo/scene.rs b/crates/egui_demo_lib/src/demo/scene.rs index a9791eeb9454..0cafba0d2f44 100644 --- a/crates/egui_demo_lib/src/demo/scene.rs +++ b/crates/egui_demo_lib/src/demo/scene.rs @@ -45,7 +45,7 @@ impl crate::View for SceneDemo { }); ui.separator(); - ui.label(format!("Scene rect: {:#?}", &mut self.scene_rect)); + ui.label(format!("Scene rect: {:#?}", self.scene_rect)); ui.separator(); diff --git a/crates/egui_demo_lib/src/demo/text_edit.rs b/crates/egui_demo_lib/src/demo/text_edit.rs index 8fd42da11974..fa06a9eaa48f 100644 --- a/crates/egui_demo_lib/src/demo/text_edit.rs +++ b/crates/egui_demo_lib/src/demo/text_edit.rs @@ -164,7 +164,7 @@ mod tests { let text = "Hello, world!".to_owned(); let mut harness = Harness::new_ui_state( move |ui, text| { - CentralPanel::default().show_inside(ui, |ui| { + CentralPanel::default().show(ui, |ui| { ui.text_edit_singleline(text); }); }, diff --git a/crates/egui_demo_lib/src/demo/tooltips.rs b/crates/egui_demo_lib/src/demo/tooltips.rs index d158c4f95aa1..008a30a62204 100644 --- a/crates/egui_demo_lib/src/demo/tooltips.rs +++ b/crates/egui_demo_lib/src/demo/tooltips.rs @@ -36,7 +36,7 @@ impl crate::View for Tooltips { ui.add(crate::egui_github_link_file_line!()); }); - egui::Panel::right("scroll_test").show_inside(ui, |ui| { + egui::Panel::right("scroll_test").show(ui, |ui| { ui.label( "The scroll area below has many labels with interactive tooltips. \ The purpose is to test that the tooltips close when you scroll.", @@ -56,7 +56,7 @@ impl crate::View for Tooltips { }); }); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { self.misc_tests(ui); }); } diff --git a/crates/egui_demo_lib/src/demo/window_options.rs b/crates/egui_demo_lib/src/demo/window_options.rs index a043517143a1..d95faa546c3a 100644 --- a/crates/egui_demo_lib/src/demo/window_options.rs +++ b/crates/egui_demo_lib/src/demo/window_options.rs @@ -1,4 +1,4 @@ -use egui::{UiKind, Vec2b}; +use egui::{UiKind, Vec2b, WindowDrag}; #[derive(Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -7,6 +7,7 @@ pub struct WindowOptions { title_bar: bool, closable: bool, collapsible: bool, + movable: bool, resizable: bool, constrain: bool, scroll2: Vec2b, @@ -15,6 +16,8 @@ pub struct WindowOptions { anchored: bool, anchor: egui::Align2, anchor_offset: egui::Vec2, + + drag_area: WindowDrag, } impl Default for WindowOptions { @@ -24,6 +27,7 @@ impl Default for WindowOptions { title_bar: true, closable: true, collapsible: true, + movable: true, resizable: true, constrain: true, scroll2: Vec2b::TRUE, @@ -31,6 +35,7 @@ impl Default for WindowOptions { anchored: false, anchor: egui::Align2::RIGHT_TOP, anchor_offset: egui::Vec2::ZERO, + drag_area: WindowDrag::default(), } } } @@ -46,6 +51,7 @@ impl crate::Demo for WindowOptions { title_bar, closable, collapsible, + movable, resizable, constrain, scroll2, @@ -53,6 +59,7 @@ impl crate::Demo for WindowOptions { anchored, anchor, anchor_offset, + drag_area, } = self.clone(); let enabled = ui.input(|i| i.time) - disabled_time > 2.0; @@ -66,7 +73,9 @@ impl crate::Demo for WindowOptions { .resizable(resizable) .constrain(constrain) .collapsible(collapsible) + .movable(movable) .title_bar(title_bar) + .drag_area(drag_area) .scroll(scroll2) .constrain_to(ui.available_rect_before_wrap()) .enabled(enabled); @@ -87,6 +96,7 @@ impl crate::View for WindowOptions { title_bar, closable, collapsible, + movable, resizable, constrain, scroll2, @@ -94,6 +104,7 @@ impl crate::View for WindowOptions { anchored, anchor, anchor_offset, + drag_area, } = self; ui.horizontal(|ui| { ui.label("title:"); @@ -106,6 +117,8 @@ impl crate::View for WindowOptions { ui.checkbox(title_bar, "title_bar"); ui.checkbox(closable, "closable"); ui.checkbox(collapsible, "collapsible"); + ui.checkbox(movable, "movable") + .on_hover_text("Can the window be moved by dragging?"); ui.checkbox(resizable, "resizable"); ui.checkbox(constrain, "constrain") .on_hover_text("Constrain window to the screen"); @@ -140,6 +153,19 @@ impl crate::View for WindowOptions { }); }); + ui.horizontal(|ui| { + ui.label("Drag to move:") + .on_hover_text("Where the user can grab the window to move it"); + ui.selectable_value(drag_area, WindowDrag::Off, "Off") + .on_hover_text("The window cannot be dragged to move it (same as movable = false)"); + ui.selectable_value(drag_area, WindowDrag::OnTouch, "OnTouch") + .on_hover_text("Anywhere on touch screens, title-bar only otherwise (default)"); + ui.selectable_value(drag_area, WindowDrag::TitleBar, "TitleBar") + .on_hover_text("Only the title bar moves the window"); + ui.selectable_value(drag_area, WindowDrag::Anywhere, "Anywhere") + .on_hover_text("Drag anywhere on the window to move it"); + }); + ui.separator(); let on_top = Some(ui.layer_id()) == ui.ctx().top_layer_id(); ui.label(format!("This window is on top: {on_top}.")); diff --git a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs index 2969c6d3d4fd..ddcf942e0610 100644 --- a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs +++ b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs @@ -33,14 +33,14 @@ impl Default for EasyMarkEditor { impl EasyMarkEditor { pub fn panels(&mut self, ui: &mut egui::Ui) { - egui::Panel::bottom("easy_mark_bottom").show_inside(ui, |ui| { + egui::Panel::bottom("easy_mark_bottom").show(ui, |ui| { let layout = egui::Layout::top_down(egui::Align::Center).with_main_justify(true); ui.allocate_ui_with_layout(ui.available_size(), layout, |ui| { ui.add(crate::egui_github_link_file!()) }) }); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { self.ui(ui); }); } diff --git a/crates/egui_demo_lib/tests/snapshots/demos/ID Test.png b/crates/egui_demo_lib/tests/snapshots/demos/ID Test.png index cbbec3f61237..f26458ea5061 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/ID Test.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/ID Test.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d14571fdd7602ea4924d04725008433e1bb53596d8a279f310ddc6c3231b6d32 -size 114106 +oid sha256:c9b497aa9b2b92843937c84a6ff8901f248501d5d4a89c2fb1237008a44fcad1 +size 114038 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png index 6a474260b32c..14fa4884e188 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b339d04f331a5d5f7ea17a1c68f61626e08ad5199aec9181080c711a60f07d53 -size 344102 +oid sha256:8d48079f85e9529f4b463bbaf2c948a64126388ef32df0584b586dc0ae48a35b +size 344919 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png index ecc93a7d45b5..83750aba9278 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab0a8201038b2b066c5aff1fd1a35bc7aed95e74cf50c293eee4a76d66623822 -size 35171 +oid sha256:d27392f625445e82285c8eaeb532e11dcb02030f24c7a769356cb4c23d7c2067 +size 41516 diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index 944576f08c9c..315ddad0f165 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -74,6 +74,7 @@ egui = { workspace = true, default-features = false } ahash.workspace = true enum-map.workspace = true +itertools.workspace = true log.workspace = true profiling.workspace = true diff --git a/crates/egui_extras/src/loaders/file_loader.rs b/crates/egui_extras/src/loaders/file_loader.rs index 3377476dd33a..bdafb6553e51 100644 --- a/crates/egui_extras/src/loaders/file_loader.rs +++ b/crates/egui_extras/src/loaders/file_loader.rs @@ -3,7 +3,7 @@ use egui::{ load::{Bytes, BytesLoadResult, BytesLoader, BytesPoll, LoadError}, mutex::Mutex, }; -use std::{sync::Arc, task::Poll, thread}; +use std::{path::PathBuf, sync::Arc, task::Poll, thread}; #[derive(Clone)] struct File { @@ -25,17 +25,39 @@ impl FileLoader { const PROTOCOL: &str = "file://"; -/// Remove the leading slash from the path if the target OS is Windows. +/// Converts a hopefully uri encoded string into a `PathBuf` /// -/// This is because Windows paths are not supposed to start with a slash. -/// For example, `file:///C:/path/to/file` is a valid URI, but `/C:/path/to/file` is not a valid path. -#[inline] -fn trim_extra_slash(s: &str) -> &str { +/// Note that there is only minimal translation of the uri string into a path to support windows +/// file and unc paths. Other translations like percent un-encoding are not handled. +fn convert_uri_to_path(s: &str) -> Result { + // File loader only supports the `file` protocol. + let s = s + .strip_prefix(PROTOCOL) + .ok_or(egui::load::LoadError::NotSupported)?; + if cfg!(target_os = "windows") { - s.trim_start_matches('/') - } else { - s + // Standard windows file uris should have the form + // + // file:///c:/path/to/the%20file.txt + // + // in which the hostname field is left out. Check for this by looking at the next character + // after the schema, if it's a slash then we likely have a standard file path. + if let Some(stripped) = s.strip_prefix("/") { + let path = PathBuf::from(stripped); + return Ok(path); + } + + // If it's not a standard file uri, it might be a UNC network path of the form + // + // file://hostname/path/to/the%20file.txt + // + // These file uris need to be converted into UNC correct and so need to have the leading + // two backslashes prepended. + let path = PathBuf::from(format!("\\\\{s}")); + return Ok(path); } + + Ok(PathBuf::from(s)) } impl BytesLoader for FileLoader { @@ -44,10 +66,7 @@ impl BytesLoader for FileLoader { } fn load(&self, ctx: &egui::Context, uri: &str) -> BytesLoadResult { - // File loader only supports the `file` protocol. - let Some(path) = uri.strip_prefix(PROTOCOL).map(trim_extra_slash) else { - return Err(LoadError::NotSupported); - }; + let path = convert_uri_to_path(uri)?; let mut cache = self.cache.lock(); if let Some(entry) = cache.get(uri).cloned() { @@ -66,7 +85,6 @@ impl BytesLoader for FileLoader { // We need to load the file at `path`. // Set the file to `pending` until we finish loading it. - let path = path.to_owned(); cache.insert(uri.to_owned(), Poll::Pending); drop(cache); @@ -146,3 +164,59 @@ impl BytesLoader for FileLoader { self.cache.lock().values().any(|entry| entry.is_pending()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_convert_uri_to_path() { + let mut checks: Vec<(&str, Result, &str)> = vec![ + ( + "http://host/path/to/image.jpg", + Err(egui::load::LoadError::NotSupported), + "Schemas other than file are rejected.", + ), + ( + "https://host/path/to/image.jpg", + Err(egui::load::LoadError::NotSupported), + "Schemas other than file are rejected.", + ), + ( + "ftp://host/path/to/image.jpg", + Err(egui::load::LoadError::NotSupported), + "Schemas other than file are rejected.", + ), + ]; + if cfg!(target_os = "windows") { + let mut windows_checks = vec![ + ( + "file:///path/to/image.jpg", + Ok(PathBuf::from("path\\to\\image.jpg")), + "file uris with no hosts and no drive letter are turned into bare paths on windows.", + ), + ( + "file:///c:/path/to/image.jpg", + Ok(PathBuf::from("c:\\path\\to\\image.jpg")), + "file uris with no hosts and drive letters are turned into absolute paths on windows.", + ), + ( + "file://host/share/path/to/image.jpg", + Ok(PathBuf::from("\\\\host\\share\\path\\to\\image.jpg")), + "file uris with a host are turned into UNC paths with leading backslashes on windows.", + ), + ]; + checks.append(&mut windows_checks); + } else { + let mut more_checks = vec![( + "file://path/to/image.jpg", + Ok(PathBuf::from("path/to/image.jpg")), + "file uris are turned into bare paths.", + )]; + checks.append(&mut more_checks); + } + for (uri_s, path, reason) in checks { + assert_eq!(convert_uri_to_path(uri_s), path, "{reason}"); + } + } +} diff --git a/crates/egui_extras/src/table.rs b/crates/egui_extras/src/table.rs index aef17ce51db5..9147b3c0d4b3 100644 --- a/crates/egui_extras/src/table.rs +++ b/crates/egui_extras/src/table.rs @@ -4,7 +4,7 @@ //! Takes all available height, so if you want something below the table, put it in a strip. use egui::{ - Align, Id, NumExt as _, Rangef, Rect, Response, ScrollArea, Ui, Vec2, Vec2b, + Align, AsIdSalt, IdSalt, NumExt as _, Rangef, Rect, Response, ScrollArea, Ui, Vec2, Vec2b, scroll_area::{DragScroll, ScrollAreaOutput, ScrollBarVisibility, ScrollSource}, }; @@ -246,7 +246,7 @@ impl Default for TableScrollOptions { /// ``` pub struct TableBuilder<'a> { ui: &'a mut Ui, - id_salt: Id, + id_salt: IdSalt, columns: Vec, striped: Option, resizable: bool, @@ -260,7 +260,7 @@ impl<'a> TableBuilder<'a> { let cell_layout = *ui.layout(); Self { ui, - id_salt: Id::new("__table_state"), + id_salt: IdSalt::new("__table_state"), columns: Default::default(), striped: None, resizable: false, @@ -270,12 +270,12 @@ impl<'a> TableBuilder<'a> { } } - /// Give this table a unique id within the parent [`Ui`]. + /// Give this table a unique salt within the parent [`Ui`]. /// /// This is required if you have multiple tables in the same [`Ui`]. #[inline] - pub fn id_salt(mut self, id_salt: impl std::hash::Hash) -> Self { - self.id_salt = Id::new(id_salt); + pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self { + self.id_salt = IdSalt::new(id_salt); self } @@ -619,11 +619,8 @@ impl TableState { // to take up the remainder of the current available width. // Also handles changing item spacing. let mut sizing = crate::sizing::Sizing::default(); - for ((prev_width, max_used), column) in state - .column_widths - .iter() - .zip(&state.max_used_widths) - .zip(columns) + for (prev_width, max_used, column) in + itertools::izip!(&state.column_widths, &state.max_used_widths, columns) { use crate::Size; diff --git a/crates/egui_glow/examples/pure_glow.rs b/crates/egui_glow/examples/pure_glow.rs index 9746c4a1ac9c..c8ce705c844d 100644 --- a/crates/egui_glow/examples/pure_glow.rs +++ b/crates/egui_glow/examples/pure_glow.rs @@ -59,7 +59,7 @@ impl GlutinWindowContext { ) .expect("failed to create gl_config"); let gl_display = gl_config.display(); - log::debug!("found gl_config: {:?}", &gl_config); + log::debug!("found gl_config: {gl_config:?}"); let raw_window_handle = window.as_ref().map(|w| { w.window_handle() @@ -77,9 +77,7 @@ impl GlutinWindowContext { gl_display .create_context(&gl_config, &context_attributes) .unwrap_or_else(|_| { - log::debug!("failed to create gl_context with attributes: {:?}. retrying with fallback context attributes: {:?}", - &context_attributes, - &fallback_context_attributes); + log::debug!("failed to create gl_context with attributes: {context_attributes:?}. retrying with fallback context attributes: {fallback_context_attributes:?}"); gl_config .display() .create_context(&gl_config, &fallback_context_attributes) @@ -106,10 +104,7 @@ impl GlutinWindowContext { width, height, ); - log::debug!( - "creating surface with attributes: {:?}", - &surface_attributes - ); + log::debug!("creating surface with attributes: {surface_attributes:?}"); let gl_surface = unsafe { gl_display .create_window_surface(&gl_config, &surface_attributes) @@ -219,7 +214,7 @@ impl winit::application::ApplicationHandler for GlowApp { .as_mut() .unwrap() .run(self.gl_window.as_mut().unwrap().window(), |ui| { - egui::Panel::left("my_side_panel").show_inside(ui, |ui| { + egui::Panel::left("my_side_panel").show(ui, |ui| { ui.heading("Hello World!"); if ui.button("Quit").clicked() { quit = true; diff --git a/crates/egui_inspection/Cargo.toml b/crates/egui_inspection/Cargo.toml new file mode 100644 index 000000000000..5ebf17bac798 --- /dev/null +++ b/crates/egui_inspection/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "egui_inspection" +version.workspace = true +authors = [ + "Lucas Meurer ", + "Emil Ernerfeldt ", +] +description = "Wire protocol and egui::Plugin for live inspection of running egui apps and kittest harnesses" +edition.workspace = true +rust-version.workspace = true +homepage = "https://github.com/emilk/egui" +license.workspace = true +readme = "./README.md" +repository = "https://github.com/emilk/egui" +categories = ["gui", "development-tools::testing", "accessibility"] +keywords = ["gui", "egui", "inspector", "accesskit", "testing"] +include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--generate-link-to-definition"] + +[features] +default = [] + +## Cross-platform local-socket name helpers ([`transport::socket_name`], +## [`transport::generate_socket_target`]) built on `interprocess`: unix domain sockets on +## unix, named pipes on Windows. Shared by both ends of the connection. +transport = ["dep:interprocess", "dep:tempfile"] + +## [`encode_png`] β€” shared screenshot PNG encoder so every peer (live plugin + kittest +## harness) produces identically-encoded frames. +png = ["dep:image", "image/png"] + +## `InspectionPlugin` β€” an `egui::Plugin` impl that streams frames + accesskit tree to +## an inspector over a local socket and applies received commands. Auto-attaches when +## the [`INSPECTION_SOCKET_ENV_VAR`] env var is set. +plugin = ["transport", "png"] + +[dependencies] +egui = { workspace = true, features = ["serde"] } +serde.workspace = true +serde_bytes = "0.11.17" +rmp-serde.workspace = true +image = { workspace = true, optional = true } +interprocess = { version = "2.4", optional = true } +tempfile = { workspace = true, optional = true } + +document-features = { workspace = true, optional = true } + +[lints] +workspace = true diff --git a/crates/egui_inspection/README.md b/crates/egui_inspection/README.md new file mode 100644 index 000000000000..c5ce75b846e1 --- /dev/null +++ b/crates/egui_inspection/README.md @@ -0,0 +1,15 @@ +# egui_inspection + +Wire protocol and `egui::Plugin` for live inspection of running egui apps and +kittest harnesses. + +Two layers: + +- **`protocol`** (default feature): length-prefixed MessagePack messages used by + `egui_kittest`'s inspector, the external `kittest_inspector` UI, and the + `egui_kittest_mcp` server. + +- **`plugin`** (opt-in): an `egui::Plugin` implementation that streams frames + + AccessKit tree updates to an inspector over a unix domain socket and applies + received `InspectorCommand`s back into the running app. Auto-attaches when + `EGUI_INSPECTION_SOCKET` is set. diff --git a/crates/egui_inspection/src/lib.rs b/crates/egui_inspection/src/lib.rs new file mode 100644 index 000000000000..6dc62e268e28 --- /dev/null +++ b/crates/egui_inspection/src/lib.rs @@ -0,0 +1,35 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +//! +//! ## Feature flags +#![cfg_attr(feature = "document-features", doc = document_features::document_features!())] + +pub mod protocol; + +pub use protocol::{ + Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, MAX_MESSAGE_BYTES, + PROTOCOL_VERSION, PeerHello, PeerKind, SourceView, read_message, write_message, +}; + +/// Environment variable: when set to a local-socket name, [`InspectionPlugin::from_env`] +/// (and similar inspector-side code) connects to it. Parse it with +/// [`transport::socket_name`]. +/// +/// Exposed unconditionally so both ends of the connection β€” the plugin (on `plugin`) and +/// the inspector / MCP server β€” can reference the same name without pulling in the full +/// plugin impl. +pub const INSPECTION_SOCKET_ENV_VAR: &str = "EGUI_INSPECTION_SOCKET"; + +#[cfg(feature = "transport")] +pub mod transport; + +#[cfg(feature = "png")] +mod png; + +#[cfg(feature = "png")] +pub use png::encode_png; + +#[cfg(feature = "plugin")] +mod plugin; + +#[cfg(feature = "plugin")] +pub use plugin::{InspectionError, InspectionPlugin}; diff --git a/crates/egui_inspection/src/plugin.rs b/crates/egui_inspection/src/plugin.rs new file mode 100644 index 000000000000..3e620894e145 --- /dev/null +++ b/crates/egui_inspection/src/plugin.rs @@ -0,0 +1,378 @@ +//! [`InspectionPlugin`] β€” an [`egui::Plugin`] that streams frames + AccessKit tree updates +//! to an inspector over a local socket and applies received commands back into the +//! running app. +//! +//! Connection model: +//! - The inspector binds a local socket. The egui peer dials it. +//! - The plugin spawns one reader thread and one writer thread, each owning one half of the +//! stream. UI-thread hooks (`input_hook` / `output_hook`) only touch in-process channels +//! and the reader-side command queue. +//! - If the writer channel is saturated, the plugin drops the oldest frame in favor of the +//! newest so the UI thread never blocks on a slow inspector. +//! +//! Live apps don't own a deterministic run loop, so `Step` / `Run` / `Play` / `Pause` +//! commands are no-ops. `Handle { events }` is honored by appending the events to the next +//! `RawInput`. After every received command the reader thread calls +//! `Context::request_repaint` so the integration wakes up even when the UI is otherwise +//! idle β€” without this, queued events would sit in the channel until the next mouse move. +//! +//! # Reference cycle +//! +//! The plugin holds a clone of `egui::Context` so the reader thread can wake the UI loop. +//! `egui::Context` is `Arc>` and the context owns its plugins, so this creates an +//! intentional cycle: the context will not drop until the process exits. Acceptable for a +//! live-debugging inspector β€” the typical workflow is "attach for the lifetime of the +//! process, then exit." For deterministic shutdown, kill the process. + +use std::io::{BufReader, BufWriter}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread; + +use egui::{Context, FullOutput, RawInput}; + +use crate::INSPECTION_SOCKET_ENV_VAR; +use crate::transport::{self, RecvHalf, SendHalf}; +use crate::protocol::{ + Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, PROTOCOL_VERSION, + PeerHello, PeerKind, read_message, write_message, +}; + +/// Errors that can occur attaching to an inspector. +#[derive(Debug)] +pub enum InspectionError { + /// Failed to dial the inspector socket. + Connect(std::io::Error), + /// Failed to set up reader / writer threads. + Pipe(String), +} + +impl std::fmt::Display for InspectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Connect(err) => write!( + f, + "failed to connect to egui_inspection socket (set {INSPECTION_SOCKET_ENV_VAR}): {err}" + ), + Self::Pipe(msg) => write!(f, "egui_inspection pipe setup failed: {msg}"), + } + } +} + +impl std::error::Error for InspectionError {} + +/// Bounded outbound queue depth. If the inspector falls behind we drop oldest frames +/// rather than block the UI thread. +const OUTBOUND_QUEUE_DEPTH: usize = 8; + +/// Shared between [`InspectionPlugin::setup`] and the reader thread so the reader can wake +/// the UI loop after each received command. Written exactly once in `setup`. +type SharedCtx = Arc>; + +/// `egui::Plugin` that streams the running app's state to an inspector. +pub struct InspectionPlugin { + /// Incoming commands from the inspector. + command_rx: Arc>>, + /// Outbound messages β†’ writer thread β†’ socket. Bounded; oldest is dropped on overflow. + outbound_tx: mpsc::SyncSender, + /// Filled in `Plugin::setup`; read by the reader thread to call `request_repaint` after + /// every received command. + shared_ctx: SharedCtx, + /// Monotonic frame counter. + step: u64, + /// Frame data (accesskit + meta) captured in `output_hook`, held until the matching + /// `Event::Screenshot` arrives in the next `input_hook`. Emitting only on pair-up keeps + /// the inspector's screenshot and accesskit tree in lockstep β€” the alternative (emit + /// accesskit now, screenshot later) shows widget boxes that don't match the rendered + /// frame they overlay. + pending_frame: Option, + /// `true` between dispatching `ViewportCommand::Screenshot` and observing the reply + /// `Event::Screenshot`. While set, the plugin keeps requesting repaints so the + /// integration eventually paints a visible frame and the screenshot fulfills (the eframe + /// wgpu path skips capture when the viewport reports `visible=false`). + awaiting_screenshot: bool, + /// Set by [`InspectorCommand::Screenshot`]; consumed by the next `output_hook` which + /// dispatches a `ViewportCommand::Screenshot` and stashes the frame. + one_shot_screenshot: bool, + /// When `true`, every `output_hook` requests a `ViewportCommand::Screenshot` and holds + /// the frame until the screenshot returns. Toggled by + /// [`InspectorCommand::SetContinuousScreenshots`]. + continuous_screenshots: bool, + /// Background threads β€” held so they live as long as the plugin. + _reader_thread: thread::JoinHandle<()>, + _writer_thread: thread::JoinHandle<()>, +} + +impl InspectionPlugin { + /// If [`INSPECTION_SOCKET_ENV_VAR`] is set, return a plugin connected to it. + /// Returns `Ok(None)` when the env var is unset. + /// + /// # Errors + /// When the env var is set but the socket can't be dialed. + pub fn from_env(label: Option) -> Result, InspectionError> { + let Ok(name) = std::env::var(INSPECTION_SOCKET_ENV_VAR) else { + return Ok(None); + }; + Self::attach(&name, label).map(Some) + } + + /// Dial the given local socket (see [`crate::transport::socket_name`]) and attach. + /// + /// # Errors + /// When the socket can't be dialed or a thread can't be spawned. + pub fn attach(socket: &str, label: Option) -> Result { + let (reader_stream, writer_stream) = + transport::connect(socket).map_err(InspectionError::Connect)?; + + let shared_ctx: SharedCtx = Arc::new(OnceLock::new()); + + let (command_tx, command_rx) = mpsc::channel::(); + let reader_ctx = shared_ctx.clone(); + let reader_thread = thread::Builder::new() + .name("egui_inspection_reader".into()) + .spawn(move || run_reader(BufReader::new(reader_stream), &command_tx, &reader_ctx)) + .map_err(|err| InspectionError::Pipe(format!("spawn reader thread: {err}")))?; + + let (outbound_tx, outbound_rx) = mpsc::sync_channel::(OUTBOUND_QUEUE_DEPTH); + let writer_thread = thread::Builder::new() + .name("egui_inspection_writer".into()) + .spawn(move || run_writer(BufWriter::new(writer_stream), outbound_rx)) + .map_err(|err| InspectionError::Pipe(format!("spawn writer thread: {err}")))?; + + // Hello must be the first message on the wire. Send via the writer-thread queue + // (rather than directly on the stream) so ordering against later frames is + // preserved even under contention. + let hello = HarnessMessage::Hello(PeerHello { + protocol_version: PROTOCOL_VERSION, + peer_kind: PeerKind::Live, + capabilities: Capabilities::LIVE, + // Live apps start accesskit-only; inspector flips on via + // `SetContinuousScreenshots(true)` when it wants images. + continuous_screenshots: false, + label, + }); + outbound_tx + .send(hello) + .map_err(|err| InspectionError::Pipe(format!("send Hello: {err}")))?; + + Ok(Self { + command_rx: Arc::new(Mutex::new(command_rx)), + outbound_tx, + shared_ctx, + step: 0, + pending_frame: None, + awaiting_screenshot: false, + one_shot_screenshot: false, + continuous_screenshots: false, + _reader_thread: reader_thread, + _writer_thread: writer_thread, + }) + } + + /// Best-effort send. Drops oldest frame on overflow so the UI thread never blocks. + fn send(&self, msg: HarnessMessage) { + match self.outbound_tx.try_send(msg) { + Ok(()) => {} + Err(mpsc::TrySendError::Full(msg)) => { + // Queue saturated β€” try once more in case the writer just drained a slot. + // If still full we drop the message. UI thread never blocks. + let _ = self.outbound_tx.try_send(msg); + } + Err(mpsc::TrySendError::Disconnected(_)) => { /* writer is gone */ } + } + } +} + +impl egui::Plugin for InspectionPlugin { + fn debug_name(&self) -> &'static str { + "egui_inspection" + } + + fn setup(&mut self, ctx: &Context) { + // We rely on the AccessKit tree to describe the UI structure to the inspector. + ctx.enable_accesskit(); + // Hand the context to the reader thread so it can wake the UI loop when commands + // arrive on an otherwise-idle app. `set` only succeeds the first time, which is + // what we want β€” `setup` is documented to run once per plugin registration. + let _ = self.shared_ctx.set(ctx.clone()); + } + + fn input_hook(&mut self, input: &mut RawInput) { + // Capture any screenshot reply the integration produced in response to our previous + // `ViewportCommand::Screenshot`. If we're holding a frame waiting for this + // screenshot, attach the pixels and emit the pair now. Without a pending frame the + // screenshot is stray (we never dispatched) and we drop it. We observe (don't + // consume) β€” apps using the same event keep getting it. + for ev in &input.events { + if let egui::Event::Screenshot { image, .. } = ev { + self.awaiting_screenshot = false; + if let Some(mut frame) = self.pending_frame.take() { + let [w, h] = [image.size[0] as u32, image.size[1] as u32]; + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + match crate::encode_png(w, h, &rgba) { + Ok(png) => { + frame.screenshot = Some(FrameScreenshot { + width: w, + height: h, + png, + }); + } + Err(err) => { + eprintln!("[INSP] PNG encode failed: {err}"); + } + } + // Re-stamp the frame with the *current* step. The stashed `step` was + // captured when we dispatched the screenshot command; in the meantime + // intervening frames (without screenshot) may have been emitted with + // higher step numbers. Inspectors that wait for `step > prev_step` would + // otherwise reject the screenshot-bearing frame because its step has + // regressed. + self.step = self.step.saturating_add(1); + frame.step = self.step; + self.send(HarnessMessage::Frame(Box::new(frame))); + } + break; + } + } + + // Drain any commands the inspector sent since the previous frame. + let mut got_command = false; + let rx = self.command_rx.lock().expect("poisoned"); + while let Ok(cmd) = rx.try_recv() { + got_command = true; + match cmd { + InspectorCommand::Handle { events } => { + input.events.extend(events); + } + InspectorCommand::Screenshot => { + self.one_shot_screenshot = true; + } + InspectorCommand::SetContinuousScreenshots(on) => { + self.continuous_screenshots = on; + } + InspectorCommand::Resize { width, height } => { + if let Some(ctx) = self.shared_ctx.get() { + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2( + width as f32, + height as f32, + ))); + } + } + // The live-app path doesn't own a deterministic run loop, so the + // step/run/play/pause commands are no-ops here. The deterministic side + // lives in `egui_kittest::InspectorPlugin`. + InspectorCommand::Step + | InspectorCommand::Run + | InspectorCommand::Play + | InspectorCommand::Pause => {} + } + } + + // Reactive-mode apps only paint on input. The reader thread's `request_repaint` + // woke us for the current frame, but viewport-command replies (`Event::Screenshot`) + // and synthetic `Handle` events both need at least one *more* frame to be observed + // by the host app and round-trip back into a `Frame` we can emit. Without an extra + // repaint scheduled now, the app goes idle until an unrelated wake-up (mouse move, + // timer) and the inspector sees a multi-second stall. + // + // While a screenshot is outstanding (or continuous mode is on), keep requesting + // repaints every frame β€” eframe's wgpu path skips screenshot capture when the + // viewport reports `visible=false`, so a backgrounded window won't fulfill the + // request until it next becomes visible. We can't force visibility from here without + // disturbing focus, but pumping repaints keeps the app alive so the moment the OS + // reports visibility (cursor enters, app brought forward, system unhide) the queued + // action fires. + if got_command || self.awaiting_screenshot || self.continuous_screenshots { + if let Some(ctx) = self.shared_ctx.get() { + ctx.request_repaint(); + } + } + } + + fn output_hook(&mut self, output: &mut FullOutput) { + self.step = self.step.saturating_add(1); + let want_screenshot = self.continuous_screenshots || self.one_shot_screenshot; + self.one_shot_screenshot = false; + + // Pull the AccessKit tree update out of the PlatformOutput. We *clone* rather than + // take so the host integration still receives it for the real accessibility stack. + let tree = output.platform_output.accesskit_update.clone(); + + let frame = Frame { + step: self.step, + pixels_per_point: output.pixels_per_point, + screenshot: None, + accesskit: tree, + source: None, + }; + + if !want_screenshot { + // No screenshot needed β€” emit immediately. + self.send(HarnessMessage::Frame(Box::new(frame))); + // If we're still waiting on a screenshot from a previous dispatch, keep + // pumping repaints from the end of the frame too. `input_hook` already + // does this at frame start, but on reactive apps the GPU readback can + // take several frames to fulfill β€” without a tail-side repaint the + // integration may go idle between `input_hook` ticks once the captured + // frame finishes presenting. + if self.awaiting_screenshot { + if let Some(ctx) = self.shared_ctx.get() { + ctx.request_repaint(); + } + } + return; + } + + // Want a screenshot. If the previous frame's request is still outstanding, drop + // this output entirely (the screenshot reply would otherwise pair with a stale + // accesskit tree). Slow inspector β†’ matched-pair frames > throughput; the user + // explicitly opted into this delay by enabling continuous screenshots. + if self.awaiting_screenshot { + return; + } + + // Hold the frame; dispatch a screenshot request for what was just rendered. The + // matching `Event::Screenshot` arrives in the next `input_hook`, where we attach + // pixels and emit. + self.pending_frame = Some(frame); + if let Some(ctx) = self.shared_ctx.get() { + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + self.awaiting_screenshot = true; + } + } +} + +/// Reader-thread entry point: forward every decoded [`InspectorCommand`] into the channel +/// until EOF or the receiver is dropped. After each enqueue, wake the UI thread so an +/// otherwise-idle app actually processes the command on its next frame. +fn run_reader( + mut reader: BufReader, + tx: &mpsc::Sender, + ctx: &SharedCtx, +) { + loop { + match read_message::<_, InspectorCommand>(&mut reader) { + Ok(cmd) => { + if tx.send(cmd).is_err() { + return; + } + if let Some(ctx) = ctx.get() { + ctx.request_repaint(); + } + } + Err(_) => return, + } + } +} + +/// Writer-thread entry point: drain the outbound queue, framing each message to the socket. +fn run_writer( + mut writer: BufWriter, + rx: mpsc::Receiver, +) { + while let Ok(msg) = rx.recv() { + if write_message(&mut writer, &msg).is_err() { + return; + } + } +} diff --git a/crates/egui_inspection/src/png.rs b/crates/egui_inspection/src/png.rs new file mode 100644 index 000000000000..c046fca23e6d --- /dev/null +++ b/crates/egui_inspection/src/png.rs @@ -0,0 +1,21 @@ +//! Shared screenshot PNG encoder. +//! +//! Both peers β€” the live [`crate::InspectionPlugin`] and `egui_kittest`'s harness inspector +//! β€” encode their frames here so they produce identically-encoded +//! [`crate::protocol::FrameScreenshot`]s. + +/// Encode tightly-packed RGBA8 pixels (`width * height * 4` bytes) as PNG using `image`'s +/// default settings (`CompressionType::Default` + `FilterType::Adaptive`). +/// +/// PNG keeps high-resolution captures off the hot path of socket throughput β€” a 1550Γ—2114 +/// RGBA8 buffer is ~13 MiB raw but typically <1 MiB encoded. +/// +/// # Errors +/// When the encoder fails (e.g. the buffer length doesn't match `width * height * 4`). +pub fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Result, image::ImageError> { + use image::ImageEncoder as _; + let mut out = std::io::Cursor::new(Vec::new()); + image::codecs::png::PngEncoder::new(&mut out) + .write_image(rgba, width, height, image::ExtendedColorType::Rgba8)?; + Ok(out.into_inner()) +} diff --git a/crates/egui_inspection/src/protocol.rs b/crates/egui_inspection/src/protocol.rs new file mode 100644 index 000000000000..c6629a761361 --- /dev/null +++ b/crates/egui_inspection/src/protocol.rs @@ -0,0 +1,282 @@ +//! Wire protocol shared between an egui peer (an `egui_kittest::Harness` or a live +//! `eframe` app running [`crate::InspectionPlugin`]) and an external inspector +//! (the standalone `kittest_inspector` UI binary, or the `egui_kittest_mcp` server). +//! +//! The egui peer writes [`HarnessMessage`]s (frames plus blocking-state updates) into the +//! transport. The inspector writes [`InspectorCommand`]s back to drive the peer. Shutdown +//! is detected on either side via EOF β€” no explicit goodbye message. +//! +//! Messages are framed as a 4-byte big-endian length followed by a MessagePack-encoded body +//! (`rmp-serde`). Transport-neutral: the same framing works on stdio, unix sockets, and TCP. +//! +//! Living in its own crate (rather than `egui_kittest`) lets eframe pull the protocol in +//! without picking up the test harness, and lets external tools depend on it directly. + +use std::io::{self, Read, Write}; + +use egui::accesskit; + +/// Wire-protocol version sent in [`PeerHello::protocol_version`]. Bump whenever a +/// non-additive change is made to [`HarnessMessage`] / [`InspectorCommand`] / their +/// payload structs. The inspector should refuse peers with a higher major version than +/// it understands. +pub const PROTOCOL_VERSION: u32 = 2; + +/// What kind of egui peer the inspector is talking to. Determines which controls the +/// inspector UI should render (Step / Pause buttons make no sense against a live app). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum PeerKind { + /// A deterministic `egui_kittest::Harness` β€” supports stepping, pause/play, panic + /// capture, and source highlighting. + Kittest, + /// A live `eframe` app running [`crate::InspectionPlugin`] β€” no deterministic run + /// loop, no panic capture, no source view. + Live, +} + +/// Which optional [`InspectorCommand`] variants the peer honors. The inspector should +/// hide / disable UI for commands whose capability is `false`. +/// +/// `Handle` is always supported (no flag) β€” every peer accepts event injection. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Capabilities { + /// Peer honors [`InspectorCommand::Step`]. + pub step: bool, + /// Peer honors [`InspectorCommand::Run`]. + pub run: bool, + /// Peer honors [`InspectorCommand::Play`] / [`InspectorCommand::Pause`]. + pub play_pause: bool, + /// Peer honors [`InspectorCommand::Screenshot`]. + pub screenshot: bool, + /// Peer honors [`InspectorCommand::SetContinuousScreenshots`] β€” i.e. it can be asked to + /// attach a fresh [`FrameScreenshot`] to every outgoing [`Frame`] until told to stop. + pub continuous_screenshots: bool, + /// Peer honors [`InspectorCommand::Resize`]. + pub resize: bool, +} + +impl Capabilities { + /// Capabilities of a deterministic kittest harness: all execution-control commands plus + /// both one-shot and continuous screenshot modes. The harness ships with continuous on + /// by default (matching the pre-flag behavior of always-fresh frames); the inspector + /// can flip it off via [`InspectorCommand::SetContinuousScreenshots`]`(false)` to skip + /// the per-step render cost when it only needs the accesskit tree. + pub const KITTEST: Self = Self { + step: true, + run: true, + play_pause: true, + screenshot: true, + continuous_screenshots: true, + resize: true, + }; + + /// Capabilities of a live `eframe` app: no execution-control (no own run loop), but + /// the integration honors viewport-level screenshot and resize requests, and the + /// plugin can be flipped into per-frame screenshot mode. + pub const LIVE: Self = Self { + step: false, + run: false, + play_pause: false, + screenshot: true, + continuous_screenshots: true, + resize: true, + }; +} + +/// First [`HarnessMessage`] sent on every connection. Identifies the peer and declares +/// which optional commands it will honor. The inspector should treat the absence of a +/// `Hello` (i.e. a `Frame` arriving first) as a protocol error. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PeerHello { + /// [`PROTOCOL_VERSION`] of the peer. + pub protocol_version: u32, + pub peer_kind: PeerKind, + pub capabilities: Capabilities, + /// Whether the peer starts in continuous-screenshot mode (i.e. attaches a + /// [`FrameScreenshot`] to every `Frame` until told otherwise). Inspectors should treat + /// this as the authoritative initial state rather than relying on per-peer defaults. + /// Only meaningful when [`Capabilities::continuous_screenshots`] is `true`. + pub continuous_screenshots: bool, + /// Human-readable identifier (test name, app name). Replaces the per-`Frame` label. + pub label: Option, +} + +/// One source file plus the test-source lines the inspector should highlight inside it. +/// +/// The harness captures `#[track_caller]` locations for the `.run()`/`.step()` call that +/// produced the frame and for each event consumed by it. The inspector highlights +/// [`Self::call_site_line`] for the runner call and [`Self::event_lines`] for each event. +/// +/// Only populated by `egui_kittest`. Live apps (via [`crate::InspectionPlugin`]) leave this +/// `None` on every [`Frame`] β€” they have no test source to point at. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SourceView { + /// Absolute or crate-relative path as reported by `std::panic::Location::file`. + pub path: String, + /// Entire file contents, lines separated by `\n`. `None` if the file couldn't be read. + pub contents: Option, + /// Line number of the `.run()` / `.step()` call that produced this frame. + pub call_site_line: Option, + /// Line numbers of events consumed by this frame's step, in queue order. + pub event_lines: Vec, + /// Line number of a panic captured in this file. The inspector highlights this line in + /// red. Set on the [`HarnessMessage::Finished`] source view when a panic was captured. + pub panic_line: Option, +} + +/// Rendered framebuffer attached to a [`Frame`]. Absent on accesskit-only frames (live +/// apps default to "tree-only" until the inspector asks for screenshots). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct FrameScreenshot { + /// Image width in physical pixels. + pub width: u32, + /// Image height in physical pixels. + pub height: u32, + /// PNG-encoded image bytes. PNG compression keeps high-resolution captures off the + /// hot path of unix-socket throughput β€” a 1550Γ—2114 RGBA8 buffer is ~13 MiB raw but + /// typically <1 MiB as PNG. `serde_bytes` encodes this as a msgpack `bin` blob (one + /// type tag + raw bytes) instead of the default `Vec` path of one type tag *per + /// byte*, which would roughly double on-wire size. + #[serde(with = "serde_bytes")] + pub png: Vec, +} + +/// A single update from the egui peer: accesskit tree + optional screenshot. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Frame { + /// Monotonically increasing step counter. + pub step: u64, + /// `physical_pixel = logical_point * pixels_per_point`. AccessKit bounds are in logical + /// coords, the screenshot is in physical pixels β€” multiply by this to align them. + pub pixels_per_point: f32, + /// Rendered framebuffer for this step, when available. `None` for live-app frames + /// outside continuous-screenshot mode that didn't receive an `Event::Screenshot` reply + /// (i.e. accesskit-only updates). Kittest harnesses populate this on every frame. + pub screenshot: Option, + /// Latest accesskit tree update, if any. + pub accesskit: Option, + /// The test source file associated with this frame + the lines to highlight inside it. + /// `None` for live apps. + pub source: Option, +} + +/// Sent egui-peer β†’ inspector. Always begins with a single [`Self::Hello`]. After that, +/// frames carry rendered images; `Blocked` signals when the harness's blocking state +/// changes without a visual update (e.g. at `after_run`, where nothing has re-rendered +/// since the last `after_step`). Live apps never send `Blocked`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum HarnessMessage { + /// Identifies the peer and declares its capabilities. Sent exactly once, as the very + /// first message on the connection, before any [`Self::Frame`]. + Hello(PeerHello), + /// A new frame (image + tree + source) is available. + Frame(Box), + /// The peer is now either blocked (`true`) waiting for an [`InspectorCommand`], or + /// running freely (`false`). + Blocked(bool), + /// The test has ended. Implies [`Self::Blocked`]`(true)`: the harness blocks after + /// sending this, and any subsequent `Step` / `Run` / `Play` command dismisses the result + /// and lets the harness drop. + /// + /// Live apps never send this. + Finished { + /// `true` on pass; `false` if a panic was in progress when the harness dropped. + ok: bool, + /// Panic message, if captured (requires `egui_kittest::install_panic_hook()`). + message: Option, + /// Final-frame source context: the test entry point's file, with the panic line (if + /// any and if it matches that file) recorded in [`SourceView::panic_line`]. + source: Option, + }, +} + +/// Sent inspector β†’ egui peer at any time to drive execution. +/// +/// `egui_kittest` blocks at `after_step` / `after_run` hooks (and at those hooks only). +/// Which command it waits for, and whether it returns to blocking after executing one, +/// depends on the command that last arrived β€” see each variant's docs. +/// +/// Live apps (via [`crate::InspectionPlugin`]) treat `Step` / `Run` / `Play` / `Pause` as +/// no-ops β€” they don't own a deterministic run loop. `Handle` is honored on the next frame. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum InspectorCommand { + /// Advance one frame, then block at the next `after_step`. + Step, + /// Run until the next `after_run` hook fires, then block. + Run, + /// Run freely until a [`Self::Pause`], [`Self::Step`], or [`Self::Run`] command arrives. + /// Frames keep streaming while playing β€” the inspector may send [`Self::Handle`] at any + /// point without interrupting play. + Play, + /// Cancel [`Self::Play`] (no-op when already blocked). + Pause, + /// Queue these events on the peer and run a single step. Does not change the peer's + /// Pause / Play / Run state. + Handle { events: Vec }, + /// Request a full-framebuffer screenshot for the next frame. + /// + /// Live apps (via [`crate::InspectionPlugin`]) issue a + /// [`egui::ViewportCommand::Screenshot`], intercept the resulting + /// [`egui::Event::Screenshot`], and emit a [`HarnessMessage::Frame`] with + /// [`Frame::screenshot`] populated. The deterministic kittest path already attaches a + /// screenshot to every frame, so it treats this as a no-op. + Screenshot, + /// Toggle continuous screenshot mode. While `true`, the peer attaches a fresh + /// [`FrameScreenshot`] to every outgoing [`Frame`] until told otherwise. Useful for + /// inspectors that always want a current image (mirror the app's window) without + /// having to issue per-step [`Self::Screenshot`] requests. + /// + /// Kittest harnesses ignore this (they already screenshot every frame). + SetContinuousScreenshots(bool), + /// Resize the peer's viewport / harness to the given logical-point dimensions. + /// + /// Live apps issue a [`egui::ViewportCommand::InnerSize`]. The deterministic kittest + /// path calls `Harness::set_size`. + Resize { width: u32, height: u32 }, +} + +/// Hard cap on a single framed message. Matches the sanity limit enforced by both ends. +pub const MAX_MESSAGE_BYTES: usize = 256 * 1024 * 1024; // 256 MiB + +/// Read a length-prefixed MessagePack message. +/// +/// # Errors +/// I/O or decode failures. +pub fn read_message(mut reader: R) -> io::Result +where + R: Read, + T: for<'de> serde::Deserialize<'de>, +{ + let mut len_buf = [0u8; 4]; + reader.read_exact(&mut len_buf)?; + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_MESSAGE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("message too large: {len} bytes"), + )); + } + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; + rmp_serde::from_slice(&buf) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string())) +} + +/// Write a length-prefixed MessagePack message. +/// +/// # Errors +/// I/O or encode failures. +pub fn write_message(mut writer: W, value: &T) -> io::Result<()> +where + W: Write, + T: serde::Serialize, +{ + let bytes = rmp_serde::to_vec(value) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; + let len = u32::try_from(bytes.len()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + writer.write_all(&len.to_be_bytes())?; + writer.write_all(&bytes)?; + writer.flush()?; + Ok(()) +} diff --git a/crates/egui_inspection/src/transport.rs b/crates/egui_inspection/src/transport.rs new file mode 100644 index 000000000000..92cc2103f704 --- /dev/null +++ b/crates/egui_inspection/src/transport.rs @@ -0,0 +1,113 @@ +//! Cross-platform local-socket addressing for the inspection connection. +//! +//! [`interprocess`] maps a name to a unix domain socket (unix) or a named pipe (Windows), +//! so the transport works on every desktop platform without `cfg(unix)` gates. Both ends +//! must build the name the same way β€” hence the shared [`socket_name`] helper β€” and the +//! listener side allocates a fresh target via [`generate_socket_target`]. + +use std::io; + +use interprocess::local_socket::{ListenerOptions, Name, prelude::*}; +#[cfg(windows)] +use interprocess::local_socket::GenericNamespaced; +#[cfg(not(windows))] +use interprocess::local_socket::GenericFilePath; + +/// The two halves of a connected local-socket stream, re-exported so consumers build +/// reader/writer threads without depending on `interprocess` directly. +pub use interprocess::local_socket::{RecvHalf, SendHalf}; + +/// Build a platform-appropriate local-socket [`Name`] from the env-var string produced by +/// [`generate_socket_target`]. +/// +/// On unix the string is a filesystem path (unix domain socket); on Windows it is a +/// namespaced identifier (named pipe). Both ends call this so they agree on the mapping. +/// +/// # Errors +/// When the string is not a valid name for the platform's local-socket namespace. +pub fn socket_name(raw: &str) -> io::Result> { + #[cfg(not(windows))] + { + raw.to_owned().to_fs_name::() + } + #[cfg(windows)] + { + raw.to_owned().to_ns_name::() + } +} + +/// A freshly-allocated local-socket target for the listener side. +pub struct SocketTarget { + /// String to hand the peer (e.g. via an env var); parse it back with [`socket_name`]. + pub name: String, + + /// On unix, the tempdir owning the socket file β€” keep it alive for the socket's + /// lifetime, then dropping it removes the file. Absent on Windows (named pipes have no + /// filesystem object to clean up). + #[cfg(not(windows))] + #[expect(dead_code, reason = "RAII guard: kept alive to own the socket file")] + dir: tempfile::TempDir, +} + +/// Allocate a unique local-socket target for a listener to bind. +/// +/// # Errors +/// On unix, when the backing tempdir can't be created. +pub fn generate_socket_target() -> io::Result { + #[cfg(not(windows))] + { + let dir = tempfile::Builder::new() + .prefix("egui-inspection-") + .tempdir()?; + let name = dir.path().join("inspection.sock").to_string_lossy().into_owned(); + Ok(SocketTarget { name, dir }) + } + #[cfg(windows)] + { + use std::time::{SystemTime, UNIX_EPOCH}; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let name = format!("egui-inspection-{}-{nonce}.sock", std::process::id()); + Ok(SocketTarget { name }) + } +} + +/// Dial an already-listening inspection socket and split the stream into read / write halves. +/// +/// The connector side of the connection: the live plugin, or the kittest harness when +/// [`crate::INSPECTION_SOCKET_ENV_VAR`] is set. +/// +/// # Errors +/// When `raw` isn't a valid local-socket name, or the socket can't be dialed. +pub fn connect(raw: &str) -> io::Result<(RecvHalf, SendHalf)> { + use interprocess::local_socket::Stream; + let stream = Stream::connect(socket_name(raw)?)?; + Ok(stream.split()) +} + +/// A bound synchronous local-socket listener β€” the listener side of the connection (kittest +/// harness in spawn mode, where it binds and then spawns an inspector pointed at the socket). +/// +/// The MCP server uses the tokio listener directly; this sync wrapper exists for the +/// thread-based kittest harness. +pub struct Listener(interprocess::local_socket::Listener); + +impl Listener { + /// Bind a listener at the given target name (from [`generate_socket_target`]). + /// + /// # Errors + /// When `raw` isn't a valid local-socket name, or the socket can't be bound. + pub fn bind(raw: &str) -> io::Result { + let listener = ListenerOptions::new().name(socket_name(raw)?).create_sync()?; + Ok(Self(listener)) + } + + /// Block until a peer connects, then split the accepted stream into read / write halves. + /// + /// # Errors + /// When accepting the inbound connection fails. + pub fn accept(&self) -> io::Result<(RecvHalf, SendHalf)> { + Ok(self.0.accept()?.split()) + } +} diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index 2d26e6bd8eba..66fd440ae6c3 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -26,6 +26,15 @@ wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image", "dep:wgpu", "eframe?/wgpu" ## Adds a dify-based image snapshot utility. snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"] +## Expose the [`inspector_api`] wire protocol used to talk to the external +## `kittest_inspector` binary. Pull this in if you're building a tool that consumes the +## same stream β€” the binary itself enables this transitively. +inspector_api = ["dep:egui_inspection", "egui_inspection/transport", "egui/serde"] + +## Stream frames + accesskit tree to a `kittest_inspector` window for live debugging. +## Auto-launches when the `KITTEST_INSPECTOR` env var is truthy. +inspector = ["inspector_api", "egui_inspection/png", "dep:image", "image/png"] + ## Allows testing eframe::App eframe = ["dep:eframe", "eframe/accesskit"] @@ -50,6 +59,9 @@ wgpu = { workspace = true, features = ["metal", "dx12", "vulkan", "gles"], optio # snapshot dependencies dify = { workspace = true, optional = true } +# inspector dependencies +egui_inspection = { workspace = true, optional = true } + # Enable this when generating docs. document-features = { workspace = true, optional = true } diff --git a/crates/egui_kittest/examples/require_button_labels_plugin.rs b/crates/egui_kittest/examples/require_button_labels_plugin.rs new file mode 100644 index 000000000000..54d853e28d03 --- /dev/null +++ b/crates/egui_kittest/examples/require_button_labels_plugin.rs @@ -0,0 +1,55 @@ +//! Example that shows how to create a `egui_kittest` Plugin that ensures each button has a label. + +use egui::accesskit; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use egui_kittest::{Harness, Plugin}; + +/// Plugin that panics if any visible button in the current UI lacks a non-empty label. +pub struct RequireButtonLabels; + +impl Plugin for RequireButtonLabels { + fn after_step( + &mut self, + harness: &mut Harness<'_, S>, + _accesskit_update: &egui::accesskit::TreeUpdate, + ) { + for button in harness.query_all_by_role(egui::accesskit::Role::Button) { + let node = button.accesskit_node(); + + match node.label().as_deref() { + Some(label) if !label.is_empty() => {} + _ => panic!( + "Button at {:?} has no accessible label. \ + Every button must be labelled so screen readers and kittest queries \ + can address it.", + node.bounding_box(), + ), + } + } + } +} + +fn main() { + // Check tests below for usages +} + +#[test] +fn test_has_label() { + let mut harness = Harness::builder() + .with_plugin(RequireButtonLabels) + .build_ui(|ui| { + let _ = ui.button("Test"); + }); + harness.run(); // this is fine +} + +#[test] +#[should_panic] +fn test_no_label() { + let mut harness = Harness::builder() + .with_plugin(RequireButtonLabels) + .build_ui(|ui| { + let _ = ui.button(()); + }); + harness.run(); // BOOM +} diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs index dc4757ee59bc..e11fab706ad8 100644 --- a/crates/egui_kittest/src/builder.rs +++ b/crates/egui_kittest/src/builder.rs @@ -1,7 +1,7 @@ use crate::app_kind::AppKind; #[cfg(feature = "eframe")] use crate::app_kind::AppKindEframe; -use crate::{Harness, LazyRenderer, TestRenderer}; +use crate::{Harness, LazyRenderer, Plugin, TestRenderer}; use egui::{Pos2, Rect, Vec2}; use std::marker::PhantomData; @@ -17,6 +17,7 @@ pub struct HarnessBuilder { pub(crate) state: PhantomData, pub(crate) renderer: Box, pub(crate) wait_for_pending_images: bool, + pub(crate) plugins: Vec>>, #[cfg(feature = "snapshot")] pub(crate) default_snapshot_options: crate::SnapshotOptions, @@ -37,6 +38,7 @@ impl Default for HarnessBuilder { step_dt: 1.0 / 4.0, wait_for_pending_images: true, os: egui::os::OperatingSystem::Nix, + plugins: Vec::new(), #[cfg(feature = "snapshot")] default_snapshot_options: crate::SnapshotOptions::default(), @@ -47,7 +49,7 @@ impl Default for HarnessBuilder { } } -impl HarnessBuilder { +impl HarnessBuilder { /// Set the size of the window. #[inline] pub fn with_size(mut self, size: impl Into) -> Self { @@ -161,6 +163,16 @@ impl HarnessBuilder { self.renderer(crate::wgpu::WgpuTestRenderer::from_setup(setup)) } + /// Register a [`Plugin`] on this harness. + /// + /// Plugins observe the harness lifecycle (`before_step`, `after_step`, `on_snapshot`, + /// `on_test_result`, etc.) and are dispatched in registration order. + #[inline] + pub fn with_plugin(mut self, plugin: impl Plugin) -> Self { + self.plugins.push(Box::new(plugin)); + self + } + /// Create a new Harness with the given ui closure and a state. /// /// The ui closure will immediately be called once to create the initial ui. diff --git a/crates/egui_kittest/src/config.rs b/crates/egui_kittest/src/config.rs index a1859f232068..a940759733cb 100644 --- a/crates/egui_kittest/src/config.rs +++ b/crates/egui_kittest/src/config.rs @@ -77,7 +77,7 @@ fn load_config() -> Config { match std::fs::read_to_string(&config_path) { Ok(config_str) => match toml::from_str(&config_str) { Ok(config) => config, - Err(e) => panic!("Failed to parse {}: {e}", &config_path.display()), + Err(e) => panic!("Failed to parse {}: {e}", config_path.display()), }, Err(err) => { panic!("Failed to read {}: {}", config_path.display(), err); diff --git a/crates/egui_kittest/src/inspector.rs b/crates/egui_kittest/src/inspector.rs new file mode 100644 index 000000000000..216af7f5046d --- /dev/null +++ b/crates/egui_kittest/src/inspector.rs @@ -0,0 +1,577 @@ +//! [`InspectorPlugin`] β€” connect a [`crate::Harness`] to an inspector for live debugging. +//! +//! The plugin speaks the [`crate::inspector_api`] wire protocol over a local socket β€” the +//! same transport the live [`egui_inspection::InspectionPlugin`] uses. Two topologies: +//! +//! - **connect** ([`egui_inspection::INSPECTION_SOCKET_ENV_VAR`] set): the harness dials an +//! already-listening socket (e.g. the kittest MCP bridge). +//! - **spawn** ([`INSPECTOR_ENV_VAR`] truthy, no socket var): the harness binds a socket, +//! spawns the `kittest_inspector` binary pointed at it, and accepts β€” standalone "pop up +//! an inspector" debugging. +//! +//! A background reader thread receives [`InspectorCommand`]s from the inspector and pushes +//! them into an mpsc channel, so the plugin can check for commands non-blockingly during +//! `Play` mode and block for them in `Paused` mode. +//! +//! Auto-registered on harness creation when either env var requests it (see [`env_enabled`]). + +use std::collections::HashMap; +use std::io::{BufReader, BufWriter}; +use std::panic::Location; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::sync::{LazyLock, OnceLock}; +use std::thread; + +use egui::accesskit; +use egui::mutex::Mutex; + +use egui_inspection::protocol::{ + Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, PROTOCOL_VERSION, + PeerHello, PeerKind, SourceView, read_message, write_message, +}; +use egui_inspection::transport::{self, RecvHalf, SendHalf, SocketTarget}; +use crate::{Harness, Plugin, TestResult}; + +/// Environment variable: when set to a truthy value, every harness auto-launches an inspector. +pub const INSPECTOR_ENV_VAR: &str = "KITTEST_INSPECTOR"; + +/// Environment variable: explicit path to the `kittest_inspector` binary. +pub const INSPECTOR_PATH_ENV_VAR: &str = "KITTEST_INSPECTOR_PATH"; + +/// Errors that can occur attaching or talking to the inspector. +#[derive(Debug)] +pub enum InspectorError { + /// Failed to set up the connection: dial the socket (connect mode), or bind + spawn the + /// `kittest_inspector` binary (spawn mode). + Connect(std::io::Error), + /// Failed to set up the reader/writer or send the initial handshake. + Pipe(String), +} + +impl std::fmt::Display for InspectorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Connect(err) => write!( + f, + "failed to connect to inspector \ + (set {} to dial a socket, or {INSPECTOR_PATH_ENV_VAR} / PATH to spawn one): {err}", + egui_inspection::INSPECTION_SOCKET_ENV_VAR, + ), + Self::Pipe(msg) => write!(f, "inspector pipe setup failed: {msg}"), + } + } +} + +impl std::error::Error for InspectorError {} + +/// Harness execution state as driven by the inspector. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Block at `after_step` / `after_run` waiting for a command. + Paused, + /// Run until the next `after_step` fires, then pause. + StepOnce, + /// Run until the next `after_run` fires, then pause. + RunOnce, + /// Run freely; drain commands non-blockingly at each hook. Transitions to `Paused` on + /// `Pause`, to `StepOnce` on `Step`, to `RunOnce` on `Run`. + Playing, +} + +/// Plugin that streams frames to an external `kittest_inspector` binary. +/// +/// Typical use is to let [`Harness::from_builder`] auto-register this plugin based on the +/// [`INSPECTOR_ENV_VAR`] environment variable. For manual wiring, construct one with +/// [`Self::launch`] and pass to [`crate::HarnessBuilder::with_plugin`]. +pub struct InspectorPlugin { + conn: Connection, + mode: Mode, + /// When `true`, every emitted frame includes a freshly-rendered [`FrameScreenshot`]. + /// When `false`, frames are accesskit-only unless a one-shot [`InspectorCommand::Screenshot`] + /// has fired since the last emission. Toggled by + /// [`InspectorCommand::SetContinuousScreenshots`]. Defaults to `true` to match the + /// pre-flag always-screenshot behavior. + continuous_screenshots: bool, + /// Set by a one-shot [`InspectorCommand::Screenshot`]; consumed by the next + /// `send_frame` so the agent gets a rendered image even when continuous mode is off. + one_shot_screenshot: bool, +} + +impl InspectorPlugin { + /// Connect this plugin to an inspector: dial the socket in + /// [`egui_inspection::INSPECTION_SOCKET_ENV_VAR`] (connect mode), or bind a socket and + /// spawn a `kittest_inspector` child (spawn mode). + /// + /// # Errors + /// If the socket can't be dialed/bound, the inspector binary can't be spawned, or the + /// handshake fails. + pub fn launch(label: Option) -> Result { + Ok(Self { + conn: Connection::launch(label)?, + mode: Mode::Paused, + continuous_screenshots: true, + one_shot_screenshot: false, + }) + } +} + +impl Plugin for InspectorPlugin { + fn after_step( + &mut self, + harness: &mut Harness<'_, S>, + accesskit_update: &accesskit::TreeUpdate, + ) { + self.handle_after_step(harness, accesskit_update); + } + + /// When in `RunOnce`, `after_run` is the blocking point the user asked for. Nothing has + /// re-rendered since the last `after_step`, so we only signal the state change via a + /// `Blocked(true)` event (no duplicate frame) and then block. + fn after_run( + &mut self, + harness: &mut Harness<'_, S>, + _result: Result, + ) { + if self.mode == Mode::RunOnce { + self.mode = Mode::Paused; + self.conn.send_blocked(true); + self.block_until_resume(harness); + } + } + + /// Test ended β€” send `Finished` (carrying the panic location in its `SourceView` when + /// the panic's file matches the test entry), then block until the user dismisses with a + /// Step / Run / Play command. The dismiss unblocks us; the harness finishes dropping on + /// the way out. + fn on_test_result(&mut self, harness: &mut Harness<'_, S>, result: TestResult<'_>) { + if self.conn.broken { + return; + } + + let (ok, message, panic_loc) = match result { + TestResult::Pass => (true, None, None), + TestResult::Fail { message, location } => ( + false, + message.map(str::to_owned), + location.map(|loc| (loc.file.clone(), loc.line)), + ), + }; + + let source = build_source_view( + harness.entry_location(), + harness.consumed_event_locations(), + panic_loc.as_ref(), + ); + self.conn.write(&HarnessMessage::Finished { + ok, + message, + source, + }); + // Park here until the user dismisses with Step/Run/Play. `block_until_resume` exits + // on any of those (they all transition out of `Paused`); `Pause` is a no-op; `Handle` + // still works so the user can poke at the final UI on failure. The mode mutation it + // leaves behind is harmless β€” the plugin is about to drop. + self.mode = Mode::Paused; + self.block_until_resume(harness); + } +} + +impl InspectorPlugin { + /// Send a frame for this step and apply the current mode's blocking / draining policy. + /// `after_run` is handled separately β€” it only transitions `RunOnce β†’ Paused`. + fn handle_after_step(&mut self, harness: &mut Harness<'_, S>, tree: &accesskit::TreeUpdate) { + if self.conn.broken { + return; + } + + // Blocking points at after_step are: Paused (always) and StepOnce (one-shot). + // RunOnce keeps running past every after_step until after_run completes; Playing + // runs freely. + let will_block_here = matches!(self.mode, Mode::Paused | Mode::StepOnce); + + self.send_frame(harness, Some(tree.clone())); + self.conn.send_blocked(will_block_here); + + if self.mode == Mode::StepOnce { + self.mode = Mode::Paused; + } + + match self.mode { + Mode::Paused => self.block_until_resume(harness), + Mode::StepOnce | Mode::RunOnce => { + // Non-blocking: keep running. + } + Mode::Playing => { + self.drain_playing(harness); + if self.mode == Mode::Paused { + // A `Pause` came in while playing β€” block now. + self.conn.send_blocked(true); + self.block_until_resume(harness); + } + } + } + } + + /// Block on the command channel until a command transitions us out of [`Mode::Paused`]. + /// `Handle` commands execute a `step_no_side_effects` and send a fresh frame, then we + /// keep blocking. + fn block_until_resume(&mut self, harness: &mut Harness<'_, S>) { + while self.mode == Mode::Paused && !self.conn.broken { + match self.conn.command_rx.recv() { + Ok(InspectorCommand::Step) => self.mode = Mode::StepOnce, + Ok(InspectorCommand::Run) => self.mode = Mode::RunOnce, + Ok(InspectorCommand::Play) => self.mode = Mode::Playing, + Ok(InspectorCommand::Pause) => { /* already paused */ } + Ok(InspectorCommand::Handle { events }) => { + self.apply_handle(harness, events); + } + Ok(InspectorCommand::Screenshot) => { + self.one_shot_screenshot = true; + } + Ok(InspectorCommand::SetContinuousScreenshots(on)) => { + self.continuous_screenshots = on; + } + Ok(InspectorCommand::Resize { width, height }) => { + self.apply_resize(harness, width, height); + } + Err(_) => { + // Reader thread is gone β€” no more commands will arrive. Stop blocking + // so the test can continue to drop cleanly. + self.conn.broken = true; + return; + } + } + } + } + + /// Drain any commands that are already queued without blocking. Called at every hook + /// while in [`Mode::Playing`]. + fn drain_playing(&mut self, harness: &mut Harness<'_, S>) { + loop { + match self.conn.command_rx.try_recv() { + Ok(InspectorCommand::Pause) => self.mode = Mode::Paused, + Ok(InspectorCommand::Step) => self.mode = Mode::StepOnce, + Ok(InspectorCommand::Run) => self.mode = Mode::RunOnce, + Ok(InspectorCommand::Play) => { /* already playing */ } + Ok(InspectorCommand::Handle { events }) => { + self.apply_handle(harness, events); + } + Ok(InspectorCommand::Screenshot) => { + self.one_shot_screenshot = true; + } + Ok(InspectorCommand::SetContinuousScreenshots(on)) => { + self.continuous_screenshots = on; + } + Ok(InspectorCommand::Resize { width, height }) => { + self.apply_resize(harness, width, height); + } + Err(mpsc::TryRecvError::Empty) => return, + Err(mpsc::TryRecvError::Disconnected) => { + self.conn.broken = true; + return; + } + } + } + } + + /// Queue inspector-driven events and advance one frame without firing plugin hooks, then + /// send a fresh frame so the inspector sees the effect. `Handle` never changes the + /// harness's Paused/Play/Run mode, so we don't emit a `Blocked` event here. + fn apply_handle(&mut self, harness: &mut Harness<'_, S>, events: Vec) { + for event in events { + harness.input_mut().events.push(event); + } + // `step_no_side_effects` returns the tree directly β€” we can't receive it via + // `after_step` because nested plugin dispatches are suppressed. + let tree = harness.step_no_side_effects(); + self.send_frame(harness, Some(tree)); + } + + /// Apply a resize request, then advance one frame so the inspector sees the new layout. + fn apply_resize(&mut self, harness: &mut Harness<'_, S>, width: u32, height: u32) { + harness.set_size(egui::Vec2::new(width as f32, height as f32)); + let tree = harness.step_no_side_effects(); + self.send_frame(harness, Some(tree)); + } + + /// Render the current harness state and push it to the inspector. + fn send_frame(&mut self, harness: &mut Harness<'_, S>, tree: Option) { + if self.conn.broken { + return; + } + let want_screenshot = self.continuous_screenshots || self.one_shot_screenshot; + self.one_shot_screenshot = false; + + let image = if want_screenshot { + match harness.render() { + Ok(img) => Some(img), + Err(err) => { + #[expect(clippy::print_stderr)] + { + eprintln!("egui_kittest inspector: render failed: {err}"); + } + self.conn.broken = true; + return; + } + } + } else { + None + }; + let ppp = harness.ctx.pixels_per_point(); + let source = build_source_view( + harness.entry_location(), + harness.consumed_event_locations(), + None, + ); + self.conn.send_frame(image.as_ref(), ppp, tree, source); + } +} + +/// The inspector connection (local socket) + step counter. Private β€” [`InspectorPlugin`] is +/// the public wrapper. +struct Connection { + writer: BufWriter, + command_rx: mpsc::Receiver, + _reader_thread: thread::JoinHandle<()>, + /// Spawn mode only: the `kittest_inspector` child we started. Kept alive so the inspector + /// window outlives the connection. `None` in connect mode (we don't own the peer). + _child: Option, + /// Spawn mode only: owns the socket file (on unix). Kept alive for the socket's lifetime. + /// `None` in connect mode. + _socket_target: Option, + step: u64, + broken: bool, +} + +impl Connection { + fn launch(label: Option) -> Result { + // Two topologies, both ending in a split local-socket stream. Connect mode wins when + // the socket var is set (the inspector/bridge already bound it). + let (reader, writer, child, socket_target) = + if let Ok(socket) = std::env::var(egui_inspection::INSPECTION_SOCKET_ENV_VAR) { + // Connect mode: dial the already-listening socket. + let (r, w) = transport::connect(&socket).map_err(InspectorError::Connect)?; + (r, w, None, None) + } else { + // Spawn mode: bind a socket, spawn the inspector pointed at it, accept. + let target = + transport::generate_socket_target().map_err(InspectorError::Connect)?; + let listener = + transport::Listener::bind(&target.name).map_err(InspectorError::Connect)?; + + let bin = std::env::var(INSPECTOR_PATH_ENV_VAR) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("kittest_inspector")); + + // Important: do NOT inherit stderr. The cargo-test / nextest stderr capture + // pipe can close between tests while the inspector is still alive; a later + // `eprintln!` in the inspector would then panic ("failed printing to stderr: + // Broken pipe") and take the window down. The inspector keeps its own log + // file for diagnostics. + let child = Command::new(&bin) + .env(egui_inspection::INSPECTION_SOCKET_ENV_VAR, &target.name) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(InspectorError::Connect)?; + + let (r, w) = listener.accept().map_err(InspectorError::Connect)?; + (r, w, Some(child), Some(target)) + }; + + let (command_tx, command_rx) = mpsc::channel::(); + let reader_thread = thread::Builder::new() + .name("kittest_inspector_reader".into()) + .spawn(move || run_reader(BufReader::new(reader), &command_tx)) + .map_err(|err| InspectorError::Pipe(format!("spawn reader thread: {err}")))?; + + let mut writer = BufWriter::new(writer); + + // Hello must be the first message on the wire β€” the inspector reads it before any + // Frame to decide which controls to render. + let hello = HarnessMessage::Hello(PeerHello { + protocol_version: PROTOCOL_VERSION, + peer_kind: PeerKind::Kittest, + capabilities: Capabilities::KITTEST, + // Kittest defaults to continuous so legacy inspectors that ignore the flag still + // get a screenshot on every frame. + continuous_screenshots: true, + label, + }); + write_message(&mut writer, &hello) + .map_err(|err| InspectorError::Pipe(format!("send Hello: {err}")))?; + + Ok(Self { + writer, + command_rx, + _reader_thread: reader_thread, + _child: child, + _socket_target: socket_target, + step: 0, + broken: false, + }) + } + + fn send_frame( + &mut self, + image: Option<&image::RgbaImage>, + pixels_per_point: f32, + accesskit: Option, + source: Option, + ) { + if self.broken { + return; + } + self.step = self.step.saturating_add(1); + let screenshot = image.and_then(|img| match egui_inspection::encode_png( + img.width(), + img.height(), + img.as_raw(), + ) { + Ok(png) => Some(FrameScreenshot { + width: img.width(), + height: img.height(), + png, + }), + Err(err) => { + #[expect(clippy::print_stderr)] + { + eprintln!("[kittest] PNG encode failed: {err}"); + } + None + } + }); + let frame = Frame { + step: self.step, + pixels_per_point, + screenshot, + accesskit, + source, + }; + self.write(&HarnessMessage::Frame(Box::new(frame))); + } + + /// Tell the inspector the harness's blocking state changed. + fn send_blocked(&mut self, blocking: bool) { + self.write(&HarnessMessage::Blocked(blocking)); + } + + fn write(&mut self, msg: &HarnessMessage) { + if self.broken { + return; + } + if let Err(err) = write_message(&mut self.writer, msg) { + #[expect(clippy::print_stderr)] + { + eprintln!("egui_kittest inspector: send failed: {err}"); + } + self.broken = true; + } + } +} + +/// Reader-thread entry point: forward every decoded [`InspectorCommand`] into the mpsc +/// channel until EOF or the receiver is dropped. +fn run_reader(mut reader: BufReader, tx: &mpsc::Sender) { + loop { + match read_message::<_, InspectorCommand>(&mut reader) { + Ok(cmd) => { + if tx.send(cmd).is_err() { + return; + } + } + Err(_) => return, + } + } +} + +/// Build the [`SourceView`] payload for a frame: pick the `.run()`/`.step()` caller's file +/// as the anchor, and record each event's line within that same file. `panic_loc` is set +/// only on the final frame after a failed test β€” and only included in the output if the +/// panic's file matches the anchor (otherwise there's no highlight to attach). +/// +/// `#[track_caller]` chains through the entire event-queuing API, so each `Location` points +/// directly at the user's test source β€” no backtrace walking needed. +fn build_source_view( + call_site: Option<&'static Location<'static>>, + event_sites: &[&'static Location<'static>], + panic_loc: Option<&(String, u32)>, +) -> Option { + let call = call_site?; + let path = call.file().to_owned(); + let event_lines = event_sites + .iter() + .filter(|loc| loc.file() == path) + .map(|loc| loc.line()) + .collect(); + let panic_line = panic_loc + .filter(|(file, _)| file == &path) + .map(|(_, line)| *line); + Some(SourceView { + path: path.clone(), + contents: read_source_file(&path), + call_site_line: Some(call.line()), + event_lines, + panic_line, + }) +} + +/// Read the full contents of a source file, cached per path (including negative results). +/// +/// `path` comes from `std::panic::Location::file()`, which the compiler reports relative to +/// the *workspace* root. Cargo runs tests with CWD set to the *crate* root, so for a +/// workspace crate at `/crates/foo/` the compiler-reported path is +/// `crates/foo/src/…` but CWD is `/crates/foo/`. We try as-is first (handles +/// absolute paths and single-crate layouts), then walk up from CWD looking for an ancestor +/// where `ancestor.join(path)` resolves. +fn read_source_file(path: &str) -> Option { + static CACHE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + let mut cache = CACHE.lock(); + cache + .entry(path.to_owned()) + .or_insert_with(|| resolve_and_read(path)) + .clone() +} + +fn resolve_and_read(path: &str) -> Option { + if let Ok(contents) = std::fs::read_to_string(path) { + return Some(contents); + } + if std::path::Path::new(path).is_absolute() { + return None; + } + let mut cursor = std::env::current_dir().ok()?; + // `pop()` returns false once we've hit the root, which terminates the search. + while cursor.pop() { + if let Ok(contents) = std::fs::read_to_string(cursor.join(path)) { + return Some(contents); + } + } + None +} + +/// Whether to auto-register an [`InspectorPlugin`], read once and cached. Exposed to +/// [`crate::Harness::from_builder`]. +/// +/// Enabled when either: [`egui_inspection::INSPECTION_SOCKET_ENV_VAR`] is set (connect mode β€” +/// an inspector/bridge already bound a socket for us), or [`INSPECTOR_ENV_VAR`] is truthy +/// (spawn mode β€” pop up an inspector ourselves). +pub(crate) fn env_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + if std::env::var_os(egui_inspection::INSPECTION_SOCKET_ENV_VAR).is_some() { + return true; + } + match std::env::var(INSPECTOR_ENV_VAR) { + Ok(value) => matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + Err(_) => false, + } + }) +} diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 2b7c4f8f3791..a7fb10fd11b8 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -13,13 +13,28 @@ pub use crate::snapshot::*; mod app_kind; mod config; +#[cfg(feature = "inspector")] +mod inspector; +/// Re-export of [`egui_inspection`] β€” the wire protocol used to talk to the external +/// `kittest_inspector` UI. Lives in its own crate so non-test consumers (e.g. a live +/// `eframe` app) can pull the protocol in without the test harness. +#[cfg(feature = "inspector_api")] +pub use egui_inspection as inspector_api; mod node; +mod plugin; mod renderer; #[cfg(feature = "wgpu")] mod texture_to_image; #[cfg(feature = "wgpu")] pub mod wgpu; +pub use crate::plugin::{PanicLocation, Plugin, TestResult, install_panic_hook}; + +#[cfg(feature = "inspector")] +pub use crate::inspector::{ + INSPECTOR_ENV_VAR, INSPECTOR_PATH_ENV_VAR, InspectorError, InspectorPlugin, +}; + // re-exports: pub use { self::{builder::*, node::*, renderer::*}, @@ -69,20 +84,24 @@ impl Display for ExceededMaxStepsError { /// Some egui style options are changed from the defaults: /// - The cursor blinking is disabled /// - The scroll animation is disabled -pub struct Harness<'a, State = ()> { +pub struct Harness<'a, State: 'static = ()> { pub ctx: egui::Context, input: egui::RawInput, kittest: kittest::State, output: egui::FullOutput, app: AppKind<'a, State>, response: Option, - state: State, + state: Option, renderer: Box, max_steps: u64, step_dt: f32, wait_for_pending_images: bool, queued_events: EventQueue, + plugins: Vec>>, + entry_location: Option<&'static std::panic::Location<'static>>, + consumed_event_locations: Vec<&'static std::panic::Location<'static>>, + #[cfg(feature = "snapshot")] default_snapshot_options: SnapshotOptions, #[cfg(feature = "snapshot")] @@ -95,7 +114,7 @@ impl Debug for Harness<'_, State> { } } -impl<'a, State> Harness<'a, State> { +impl<'a, State: 'static> Harness<'a, State> { #[track_caller] pub(crate) fn from_builder( builder: HarnessBuilder, @@ -113,6 +132,7 @@ impl<'a, State> Harness<'a, State> { state: _, mut renderer, wait_for_pending_images, + plugins, #[cfg(feature = "snapshot")] default_snapshot_options, @@ -149,32 +169,54 @@ impl<'a, State> Harness<'a, State> { renderer.handle_delta(&output.textures_delta); + let initial_accesskit = output + .platform_output + .accesskit_update + .take() + .expect("AccessKit was disabled"); + let mut harness = Self { app, ctx, input, - kittest: kittest::State::new( - output - .platform_output - .accesskit_update - .take() - .expect("AccessKit was disabled"), - ), + kittest: kittest::State::new(initial_accesskit), output, response, - state, + state: Some(state), renderer, max_steps, step_dt, wait_for_pending_images, queued_events: Default::default(), + plugins, + entry_location: None, + consumed_event_locations: Vec::new(), + #[cfg(feature = "snapshot")] default_snapshot_options, #[cfg(feature = "snapshot")] snapshot_results: SnapshotResults::default(), }; + + // Auto-register the Inspector plugin when the env var is set. Done before `run_ok` + // so the inspector sees the initial stabilization frames. + #[cfg(feature = "inspector")] + if inspector::env_enabled() { + match inspector::InspectorPlugin::launch( + std::thread::current().name().map(String::from), + ) { + Ok(plugin) => harness.add_plugin(plugin), + Err(err) => { + #[expect(clippy::print_stderr)] + { + eprintln!("egui_kittest: failed to launch inspector: {err}"); + } + } + } + } + // Run the harness until it is stable, ensuring that all Areas are shown and animations are done harness.run_ok(); harness @@ -185,6 +227,51 @@ impl<'a, State> Harness<'a, State> { HarnessBuilder::default() } + /// Register a [`Plugin`] after construction. + /// + /// See [`HarnessBuilder::with_plugin`] to register before the first frame runs. + /// + /// Calling this from inside a plugin hook is allowed β€” the new plugin is appended to + /// the list but does not receive the currently-dispatching hook; it starts firing on + /// the next dispatch. + pub fn add_plugin(&mut self, plugin: impl Plugin) { + self.plugins.push(Box::new(plugin)); + } + + /// Advance the harness by one frame without firing plugin hooks. + /// + /// Returns the AccessKit tree update produced by the frame. Useful for plugins driving + /// the harness from inside a hook: `after_step` normally delivers the tree, but nested + /// hook dispatches are suppressed, so plugins that call this from within their own + /// `after_step` need the return value to see the fresh tree. + #[track_caller] + pub fn step_no_side_effects(&mut self) -> egui::accesskit::TreeUpdate { + self._step_no_side_effects(false) + } + + /// [`std::panic::Location`] of the most recent public `#[track_caller]` entry point + /// (e.g. the caller of `step()` / `run()`), or `None` if no such call has been made yet. + pub fn entry_location(&self) -> Option<&'static std::panic::Location<'static>> { + self.entry_location + } + + /// Locations of the events consumed during the most recent step, in order. + pub fn consumed_event_locations(&self) -> &[&'static std::panic::Location<'static>] { + &self.consumed_event_locations + } + + fn dispatch(&mut self, mut f: impl FnMut(&mut dyn Plugin, &mut Self)) { + let mut plugins = std::mem::take(&mut self.plugins); + for p in &mut plugins { + f(p.as_mut(), self); + } + // A plugin's hook is allowed to call `add_plugin`; those land in the now-empty + // `self.plugins`. Append them after the swap so they fire on the next dispatch. + let added = std::mem::take(&mut self.plugins); + self.plugins = plugins; + self.plugins.extend(added); + } + /// Create a new Harness with the given ui closure and a state. /// /// The ui closure will immediately be called once to create the initial ui. @@ -240,17 +327,24 @@ impl<'a, State> Harness<'a, State> { /// Run a frame for each queued event (or a single frame if there are no events). /// This will call the app closure with each queued event and /// update the Harness. + #[track_caller] pub fn step(&mut self) { + self.entry_location = Some(std::panic::Location::caller()); let events = std::mem::take(&mut *self.queued_events.lock()); if events.is_empty() { + self.consumed_event_locations.clear(); self._step(false); } for event in events { + self.consumed_event_locations.clear(); match event { - EventType::Event(event) => { - self.input.events.push(event); + EventType::Event(event, loc) => { + self.consumed_event_locations.push(loc); + self.input.events.push(event.clone()); + self.dispatch(|p, h| p.on_event(h, &event)); } - EventType::Modifiers(modifiers) => { + EventType::Modifiers(modifiers, loc) => { + self.consumed_event_locations.push(loc); self.input.modifiers = modifiers; } } @@ -258,32 +352,38 @@ impl<'a, State> Harness<'a, State> { } } - /// Run a single step. This will not process any events. + /// Run a single step, firing `before_step` / `after_step` plugin hooks. + #[track_caller] fn _step(&mut self, sizing_pass: bool) { + self.dispatch(|p, h| p.before_step(h)); + let accesskit_update = self._step_no_side_effects(sizing_pass); + self.dispatch(|p, h| p.after_step(h, &accesskit_update)); + } + + /// Core frame advance. Does NOT fire plugin hooks β€” callable from within + /// hooks via [`Self::step_no_side_effects`] without recursing. + #[track_caller] + fn _step_no_side_effects(&mut self, sizing_pass: bool) -> egui::accesskit::TreeUpdate { self.input.predicted_dt = self.step_dt; let mut output = self.ctx.run_ui(self.input.take(), |ui| { - self.response = self.app.run(ui, &mut self.state, sizing_pass); + self.response = self.app.run(ui, self.state.as_mut().unwrap(), sizing_pass); }); - self.kittest.update( - output - .platform_output - .accesskit_update - .take() - .expect("AccessKit was disabled"), - ); + let accesskit_update = output + .platform_output + .accesskit_update + .take() + .expect("AccessKit was disabled"); + self.kittest.update(accesskit_update.clone()); self.renderer.handle_delta(&output.textures_delta); self.output = output; + accesskit_update } /// Calculate the rect that includes all popups and tooltips. fn compute_total_rect_with_popups(&self) -> Option { // Start with the standard response rect - let mut used = if let Some(response) = self.response.as_ref() { - response.rect - } else { - return None; - }; + let mut used = self.response.as_ref()?.rect; // Add all visible areas from other orders (popups, tooltips, etc.) self.ctx.memory(|mem| { @@ -301,7 +401,9 @@ impl<'a, State> Harness<'a, State> { /// Resize the test harness to fit the contents. This only works when creating the Harness via /// [`Harness::new_ui`] / [`Harness::new_ui_state`] or /// [`HarnessBuilder::build_ui`] / [`HarnessBuilder::build_ui_state`]. + #[track_caller] pub fn fit_contents(&mut self) { + self.entry_location = Some(std::panic::Location::caller()); self._step(true); // Calculate size including all content (main UI + popups + tooltips) @@ -330,7 +432,8 @@ impl<'a, State> Harness<'a, State> { /// - [`Harness::run_steps`]. #[track_caller] pub fn run(&mut self) -> u64 { - match self.try_run() { + self.entry_location = Some(std::panic::Location::caller()); + match self._try_run(false) { Ok(steps) => steps, Err(err) => { panic!("{err}"); @@ -338,9 +441,12 @@ impl<'a, State> Harness<'a, State> { } } + #[track_caller] fn _try_run(&mut self, sleep: bool) -> Result { + self.dispatch(|p, h| p.before_run(h)); + let mut steps = 0; - loop { + let result = loop { steps += 1; self.step(); @@ -348,18 +454,19 @@ impl<'a, State> Harness<'a, State> { // We only care about immediate repaints if self.root_viewport_output().repaint_delay != Duration::ZERO && !wait_for_images { - break; + break Ok(steps); } else if sleep || wait_for_images { std::thread::sleep(Duration::from_secs_f32(self.step_dt)); } if steps > self.max_steps { - return Err(ExceededMaxStepsError { + break Err(ExceededMaxStepsError { max_steps: self.max_steps, repaint_causes: self.ctx.repaint_causes(), }); } - } - Ok(steps) + }; + self.dispatch(|p, h| p.after_run(h, result.as_ref().map(|s| *s))); + result } /// Run until @@ -378,7 +485,9 @@ impl<'a, State> Harness<'a, State> { /// - [`Harness::step`]. /// - [`Harness::run_steps`]. /// - [`Harness::try_run_realtime`]. + #[track_caller] pub fn try_run(&mut self) -> Result { + self.entry_location = Some(std::panic::Location::caller()); self._try_run(false) } @@ -395,8 +504,10 @@ impl<'a, State> Harness<'a, State> { /// - [`Harness::step`]. /// - [`Harness::run_steps`]. /// - [`Harness::try_run_realtime`]. + #[track_caller] pub fn run_ok(&mut self) -> Option { - self.try_run().ok() + self.entry_location = Some(std::panic::Location::caller()); + self._try_run(false).ok() } /// Run multiple frames, sleeping for [`HarnessBuilder::with_step_dt`] between frames. @@ -418,13 +529,17 @@ impl<'a, State> Harness<'a, State> { /// - [`Harness::step`]. /// - [`Harness::run_steps`]. /// - [`Harness::try_run`]. + #[track_caller] pub fn try_run_realtime(&mut self) -> Result { + self.entry_location = Some(std::panic::Location::caller()); self._try_run(true) } /// Run a number of steps. /// Equivalent to calling [`Harness::step`] x times. + #[track_caller] pub fn run_steps(&mut self, steps: usize) { + self.entry_location = Some(std::panic::Location::caller()); for _ in 0..steps { self.step(); } @@ -452,40 +567,48 @@ impl<'a, State> Harness<'a, State> { /// Access the state. pub fn state(&self) -> &State { - &self.state + self.state.as_ref().expect("state already taken via into_state") } /// Access the state mutably. pub fn state_mut(&mut self) -> &mut State { - &mut self.state + self.state.as_mut().expect("state already taken via into_state") } /// Consume the harness and return the state. - pub fn into_state(self) -> State { - self.state + pub fn into_state(mut self) -> State { + self.state.take().expect("state already taken via into_state") } /// Queue an event to be processed in the next frame. + #[track_caller] pub fn event(&self, event: egui::Event) { - self.queued_events.lock().push(EventType::Event(event)); + self.queued_events + .lock() + .push(EventType::Event(event, std::panic::Location::caller())); } /// Queue an event with modifiers. /// /// Queues the modifiers to be pressed, then the event, then the modifiers to be released. + #[track_caller] pub fn event_modifiers(&self, event: egui::Event, modifiers: Modifiers) { + let caller = std::panic::Location::caller(); let mut queue = self.queued_events.lock(); - queue.push(EventType::Modifiers(modifiers)); - queue.push(EventType::Event(event)); - queue.push(EventType::Modifiers(Modifiers::default())); + queue.push(EventType::Modifiers(modifiers, caller)); + queue.push(EventType::Event(event, caller)); + queue.push(EventType::Modifiers(Modifiers::default(), caller)); } + #[track_caller] fn modifiers(&self, modifiers: Modifiers) { - self.queued_events - .lock() - .push(EventType::Modifiers(modifiers)); + self.queued_events.lock().push(EventType::Modifiers( + modifiers, + std::panic::Location::caller(), + )); } + #[track_caller] pub fn key_down(&self, key: egui::Key) { self.event(egui::Event::Key { key, @@ -496,6 +619,7 @@ impl<'a, State> Harness<'a, State> { }); } + #[track_caller] pub fn key_down_modifiers(&self, modifiers: Modifiers, key: egui::Key) { self.event_modifiers( egui::Event::Key { @@ -509,6 +633,7 @@ impl<'a, State> Harness<'a, State> { ); } + #[track_caller] pub fn key_up(&self, key: egui::Key) { self.event(egui::Event::Key { key, @@ -519,6 +644,7 @@ impl<'a, State> Harness<'a, State> { }); } + #[track_caller] pub fn key_up_modifiers(&self, modifiers: Modifiers, key: egui::Key) { self.event_modifiers( egui::Event::Key { @@ -539,6 +665,7 @@ impl<'a, State> Harness<'a, State> { /// - Press [`Key::B`] /// - Release [`Key::B`] /// - Release [`Key::A`] + #[track_caller] pub fn key_combination(&self, keys: &[Key]) { for key in keys { self.key_down(*key); @@ -557,6 +684,7 @@ impl<'a, State> Harness<'a, State> { /// - Release [`Key::B`] /// - Release [`Key::A`] /// - Release [`Modifiers::COMMAND`] + #[track_caller] pub fn key_combination_modifiers(&self, modifiers: Modifiers, keys: &[Key]) { self.modifiers(modifiers); @@ -578,6 +706,7 @@ impl<'a, State> Harness<'a, State> { /// Press a key. /// /// This will create a key down event and a key up event. + #[track_caller] pub fn key_press(&self, key: egui::Key) { self.key_combination(&[key]); } @@ -589,16 +718,19 @@ impl<'a, State> Harness<'a, State> { /// - create a key down event /// - create a key up event /// - reset the modifiers + #[track_caller] pub fn key_press_modifiers(&self, modifiers: Modifiers, key: egui::Key) { self.key_combination_modifiers(modifiers, &[key]); } /// Move mouse cursor to this position. + #[track_caller] pub fn hover_at(&self, pos: egui::Pos2) { self.event(egui::Event::PointerMoved(pos)); } /// Start dragging from a position. + #[track_caller] pub fn drag_at(&self, pos: egui::Pos2) { self.event(egui::Event::PointerButton { pos, @@ -609,6 +741,7 @@ impl<'a, State> Harness<'a, State> { } /// Stop dragging and remove cursor. + #[track_caller] pub fn drop_at(&self, pos: egui::Pos2) { self.event(egui::Event::PointerButton { pos, @@ -625,6 +758,7 @@ impl<'a, State> Harness<'a, State> { /// /// If you click a button and then take a snapshot, the button will be shown as hovered. /// If you don't want that, you can call this method after clicking. + #[track_caller] pub fn remove_cursor(&self) { self.event(egui::Event::PointerGone); } @@ -645,7 +779,7 @@ impl<'a, State> Harness<'a, State> { /// /// # Errors /// Returns an error if the rendering fails. - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "inspector"))] pub fn render(&mut self) -> Result { let mut output = self.output.clone(); @@ -668,7 +802,9 @@ impl<'a, State> Harness<'a, State> { }); } - self.renderer.render(&self.ctx, &output) + let image = self.renderer.render(&self.ctx, &output)?; + self.dispatch(|p, h| p.on_render(h, &image)); + Ok(image) } /// Get the root viewport output @@ -748,39 +884,36 @@ impl<'a, State> Harness<'a, State> { ); } - struct UiApp { - f: Box, + // Wrap the whole `Harness` in an `eframe::App` adapter so we don't need to + // destructure `self` (which we can't, since `Harness` implements `Drop`). + // The adapter delegates `ui`/`logic` through the stored `AppKind`. + struct HarnessAsApp { + harness: Harness<'static, State>, } - impl eframe::App for UiApp { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - (self.f)(ui); + impl eframe::App for HarnessAsApp { + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + if let AppKind::Eframe(crate::app_kind::AppKindEframe { get_app, .. }) = + &mut self.harness.app + { + get_app(self.harness.state.as_mut().unwrap()).logic(ctx, frame); + } } - } - struct UiStateApp { - f: Box, - state: State, - } - - impl eframe::App for UiStateApp { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - let Self { f, state } = self; - f(ui, state); + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + let harness = &mut self.harness; + match &mut harness.app { + AppKind::Ui(f) => f(ui), + AppKind::UiState(f) => f(ui, harness.state.as_mut().unwrap()), + AppKind::Eframe(crate::app_kind::AppKindEframe { get_app, .. }) => { + get_app(harness.state.as_mut().unwrap()).ui(ui, frame); + } + } } } - use crate::app_kind::AppKindEframe; - - let Self { - ctx, state, app, .. - } = self; - - let eframe_app: Box = match app { - AppKind::Ui(f) => Box::new(UiApp { f }), - AppKind::UiState(f) => Box::new(UiStateApp { f, state }), - AppKind::Eframe(AppKindEframe { take_app, .. }) => take_app(state), - }; + let ctx = self.ctx.clone(); + let eframe_app: Box = Box::new(HarnessAsApp { harness: self }); eframe::run_native_ext( "egui_kittest", @@ -814,7 +947,7 @@ impl<'a> Harness<'a> { } } -impl<'tree, 'node, State> Queryable<'tree, 'node, Node<'tree>> for Harness<'_, State> +impl<'tree, 'node, State: 'static> Queryable<'tree, 'node, Node<'tree>> for Harness<'_, State> where 'node: 'tree, { @@ -822,3 +955,26 @@ where self.root() } } + +impl Drop for Harness<'_, State> { + fn drop(&mut self) { + // Consume SnapshotResults first so its own panic-check runs under our control, + // and so `std::thread::panicking()` reflects snapshot failures when plugins observe + // the final outcome. Drop may panic; if so, the panic propagates and plugins still + // see Fail. + #[cfg(feature = "snapshot")] + drop(std::mem::take(&mut self.snapshot_results)); + + if self.plugins.is_empty() { + return; + } + + if std::thread::panicking() { + plugin::with_fail_test_result(|result| { + self.dispatch(|p, h| p.on_test_result(h, result)); + }); + } else { + self.dispatch(|p, h| p.on_test_result(h, TestResult::Pass)); + } + } +} diff --git a/crates/egui_kittest/src/node.rs b/crates/egui_kittest/src/node.rs index 7e3161c09b14..fca21f338efc 100644 --- a/crates/egui_kittest/src/node.rs +++ b/crates/egui_kittest/src/node.rs @@ -5,8 +5,8 @@ use kittest::{AccessKitNode, NodeT, debug_fmt_node}; use std::fmt::{Debug, Formatter}; pub(crate) enum EventType { - Event(egui::Event), - Modifiers(Modifiers), + Event(egui::Event, &'static std::panic::Location<'static>), + Modifiers(Modifiers, &'static std::panic::Location<'static>), } pub(crate) type EventQueue = Mutex>; @@ -37,27 +37,38 @@ impl<'tree> NodeT<'tree> for Node<'tree> { } impl Node<'_> { + #[track_caller] fn event(&self, event: egui::Event) { - self.queue.lock().push(EventType::Event(event)); + self.queue + .lock() + .push(EventType::Event(event, std::panic::Location::caller())); } + #[track_caller] fn modifiers(&self, modifiers: Modifiers) { - self.queue.lock().push(EventType::Modifiers(modifiers)); + self.queue.lock().push(EventType::Modifiers( + modifiers, + std::panic::Location::caller(), + )); } + #[track_caller] pub fn hover(&self) { self.event(egui::Event::PointerMoved(self.rect().center())); } /// Click at the node center with the primary button. + #[track_caller] pub fn click(&self) { self.click_button(PointerButton::Primary); } + #[track_caller] pub fn click_secondary(&self) { self.click_button(PointerButton::Secondary); } + #[track_caller] pub fn click_button(&self, button: PointerButton) { self.hover(); for pressed in [true, false] { @@ -70,10 +81,12 @@ impl Node<'_> { } } + #[track_caller] pub fn click_modifiers(&self, modifiers: Modifiers) { self.click_button_modifiers(PointerButton::Primary, modifiers); } + #[track_caller] pub fn click_button_modifiers(&self, button: PointerButton, modifiers: Modifiers) { self.hover(); self.modifiers(modifiers); @@ -92,6 +105,7 @@ impl Node<'_> { /// /// This will trigger a [`accesskit::Action::Click`] action. /// In contrast to `click()`, this can also click widgets that are not currently visible. + #[track_caller] pub fn click_accesskit(&self) { let (target_node, target_tree) = self.accesskit_node.locate(); self.event(egui::Event::AccessKitActionRequest( @@ -115,6 +129,7 @@ impl Node<'_> { } } + #[track_caller] pub fn focus(&self) { let (target_node, target_tree) = self.accesskit_node.locate(); self.event(egui::Event::AccessKitActionRequest(ActionRequest { @@ -125,6 +140,7 @@ impl Node<'_> { })); } + #[track_caller] pub fn type_text(&self, text: &str) { self.event(egui::Event::Text(text.to_owned())); } @@ -138,6 +154,7 @@ impl Node<'_> { } /// Scroll the node into view. + #[track_caller] pub fn scroll_to_me(&self) { let (target_node, target_tree) = self.accesskit_node.locate(); self.event(egui::Event::AccessKitActionRequest(ActionRequest { @@ -149,6 +166,7 @@ impl Node<'_> { } /// Scroll the [`egui::ScrollArea`] containing this node down (100px). + #[track_caller] pub fn scroll_down(&self) { let (target_node, target_tree) = self.accesskit_node.locate(); self.event(egui::Event::AccessKitActionRequest(ActionRequest { @@ -160,6 +178,7 @@ impl Node<'_> { } /// Scroll the [`egui::ScrollArea`] containing this node up (100px). + #[track_caller] pub fn scroll_up(&self) { let (target_node, target_tree) = self.accesskit_node.locate(); self.event(egui::Event::AccessKitActionRequest(ActionRequest { @@ -171,6 +190,7 @@ impl Node<'_> { } /// Scroll the [`egui::ScrollArea`] containing this node left (100px). + #[track_caller] pub fn scroll_left(&self) { let (target_node, target_tree) = self.accesskit_node.locate(); self.event(egui::Event::AccessKitActionRequest(ActionRequest { @@ -182,6 +202,7 @@ impl Node<'_> { } /// Scroll the [`egui::ScrollArea`] containing this node right (100px). + #[track_caller] pub fn scroll_right(&self) { let (target_node, target_tree) = self.accesskit_node.locate(); self.event(egui::Event::AccessKitActionRequest(ActionRequest { diff --git a/crates/egui_kittest/src/plugin.rs b/crates/egui_kittest/src/plugin.rs new file mode 100644 index 000000000000..6ddea28baf31 --- /dev/null +++ b/crates/egui_kittest/src/plugin.rs @@ -0,0 +1,183 @@ +//! Plugin system for observing and extending the [`crate::Harness`] test lifecycle. +//! +//! Implement [`Plugin`] to hook into harness events: frame steps, run loops, events, +//! renders, snapshots, and final pass/fail. Register plugins via +//! [`crate::HarnessBuilder::with_plugin`] or [`crate::Harness::add_plugin`]. + +use crate::{ExceededMaxStepsError, Harness}; + +/// A plugin observes the test-harness lifecycle and can drive additional frames. +/// +/// All methods default to no-ops; implement only the ones you need. +/// +/// State-agnostic plugins should impl for all `State` so they're reusable across harnesses: +/// ``` +/// use egui_kittest::{Harness, Plugin}; +/// use egui::accesskit::TreeUpdate; +/// +/// struct MyPlugin; +/// +/// impl Plugin for MyPlugin { +/// fn after_step(&mut self, _harness: &mut Harness<'_, S>, _tree: &TreeUpdate) { +/// // ... +/// } +/// } +/// ``` +/// +/// # Re-entrancy +/// +/// Plugin hooks receive `&mut Harness`. Calling [`Harness::step`] / [`Harness::run`] / +/// etc. from inside a hook will recurse infinitely through your own `after_step`. If +/// a plugin needs to advance the harness from inside a hook β€” e.g. an inspector that +/// blocks on user input β€” use [`Harness::step_no_side_effects`] instead. +#[expect(unused_variables, reason = "default no-op impls")] +pub trait Plugin: Send + 'static { + /// Called once at the start of every `run()` / `try_run()` / `try_run_realtime()` / + /// `run_ok()` invocation, before the first step. + fn before_run(&mut self, harness: &mut Harness<'_, State>) {} + + /// Called once after the outer run loop exits (successful completion or + /// [`ExceededMaxStepsError`]). + fn after_run( + &mut self, + harness: &mut Harness<'_, State>, + result: Result, + ) { + } + + /// Called immediately before each single-frame step (per-frame, not per public call). + fn before_step(&mut self, harness: &mut Harness<'_, State>) {} + + /// Called immediately after each single-frame step. + /// + /// `accesskit_update` is the AccessKit tree update egui produced for the frame that + /// just ran. Plugins that need to retain it (e.g. to stream it to an external + /// debugger) should clone it here β€” the harness doesn't hold on to it after this hook + /// returns. + fn after_step( + &mut self, + harness: &mut Harness<'_, State>, + accesskit_update: &egui::accesskit::TreeUpdate, + ) { + } + + /// Called after a queued event has been pushed into the harness input, before the + /// frame runs that consumes it. + fn on_event(&mut self, harness: &mut Harness<'_, State>, event: &egui::Event) {} + + /// Called from inside [`Harness::render`] after the image is produced. Lets a plugin + /// observe every rendered frame without triggering a second render pass. + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "inspector"))] + fn on_render(&mut self, harness: &mut Harness<'_, State>, image: &image::RgbaImage) {} + + /// Called from [`Harness::try_snapshot`] / [`Harness::try_snapshot_options`] after + /// the comparison has run, before the result is handed back to the caller. The + /// `image` is the frame that was compared against the stored snapshot. + #[cfg(feature = "snapshot")] + fn on_snapshot( + &mut self, + harness: &mut Harness<'_, State>, + name: &str, + image: &image::RgbaImage, + result: &crate::SnapshotResult, + ) { + } + + /// Called exactly once, from [`Harness::drop`], after the harness has finalized its + /// snapshot results. `result` is [`TestResult::Pass`] unless a panic is in progress + /// on this thread, in which case it's [`TestResult::Fail`]. + /// + /// The `message` and `location` fields of `Fail` are only populated if the user has + /// called [`install_panic_hook`]. Without the hook, the variant still flips to + /// `Fail` but both fields are `None`. + fn on_test_result(&mut self, harness: &mut Harness<'_, State>, result: TestResult<'_>) {} +} + +/// Location of a panic β€” a `std::panic::Location` stripped of its borrow so it can be +/// stored in a thread-local and handed to plugins. +#[derive(Debug, Clone)] +pub struct PanicLocation { + pub file: String, + pub line: u32, + pub column: u32, +} + +/// Outcome of a test, as seen by [`Plugin::on_test_result`]. +#[derive(Debug, Clone, Copy)] +pub enum TestResult<'a> { + /// No panic in progress on this thread when `on_test_result` fired. + Pass, + + /// A panic is in progress on this thread. + /// + /// `message` and `location` are populated only if [`install_panic_hook`] has been + /// called (once, process-wide) before the panic occurred. + Fail { + message: Option<&'a str>, + location: Option<&'a PanicLocation>, + }, +} + +// ------------------------------------------------------------------------------------------------ +// Opt-in panic hook for capturing the panic message + location so plugins can report them. +// +// Installing a `std::panic::set_hook` from library code is a process-wide side effect, so we +// do NOT install it automatically. Users opt in once (e.g. from a test main or `#[ctor]`). + +use std::cell::RefCell; +use std::sync::OnceLock; + +thread_local! { + static LAST_PANIC: RefCell> = const { RefCell::new(None) }; +} + +struct PanicRecord { + message: Option, + location: Option, +} + +static INSTALLED: OnceLock<()> = OnceLock::new(); + +/// Install a `std::panic::set_hook` that captures each panic's message and location into +/// a thread-local, which [`Plugin::on_test_result`] then reads into its `Fail` variant. +/// +/// Process-wide and idempotent (subsequent calls are no-ops). Chains to whatever hook was +/// previously installed, so existing output is preserved. +pub fn install_panic_hook() { + INSTALLED.get_or_init(|| { + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let message = info + .payload() + .downcast_ref::<&'static str>() + .map(|s| (*s).to_owned()) + .or_else(|| info.payload().downcast_ref::().cloned()); + let location = info.location().map(|loc| PanicLocation { + file: loc.file().to_owned(), + line: loc.line(), + column: loc.column(), + }); + LAST_PANIC.with(|slot| { + *slot.borrow_mut() = Some(PanicRecord { message, location }); + }); + prev(info); + })); + }); +} + +/// Called from [`Harness::drop`] when `std::thread::panicking()` is true. Builds a +/// [`TestResult::Fail`] borrowing from the thread-local panic record, invokes `f` with +/// it, then restores the record. +/// +/// We have to invoke via callback (rather than returning the `Fail`) because the borrows +/// live inside the thread-local's `RefCell`. +pub(crate) fn with_fail_test_result(f: impl FnOnce(TestResult<'_>) -> R) -> R { + LAST_PANIC.with(|slot| { + let borrow = slot.borrow(); + let (message, location) = match borrow.as_ref() { + Some(rec) => (rec.message.as_deref(), rec.location.as_ref()), + None => (None, None), + }; + f(TestResult::Fail { message, location }) + }) +} diff --git a/crates/egui_kittest/src/renderer.rs b/crates/egui_kittest/src/renderer.rs index 0806c4ead53b..4c13fb9dcc88 100644 --- a/crates/egui_kittest/src/renderer.rs +++ b/crates/egui_kittest/src/renderer.rs @@ -12,7 +12,7 @@ pub trait TestRenderer { /// /// # Errors /// Returns an error if the rendering fails. - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "inspector"))] fn render( &mut self, ctx: &egui::Context, @@ -62,7 +62,7 @@ impl TestRenderer for LazyRenderer { } } - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "inspector"))] fn render( &mut self, ctx: &egui::Context, diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index adbe32399c03..54c543d2794c 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -173,8 +173,9 @@ impl SnapshotOptions { /// The default is `0.6` (which is enough for most egui tests to pass across different /// wgpu backends). #[inline] - pub fn threshold(mut self, threshold: impl Into) -> Self { - self.threshold = threshold.into(); + pub fn threshold(mut self, threshold: impl Into>) -> Self { + let threshold = threshold.into().threshold(); + self.threshold = threshold; self } @@ -590,7 +591,7 @@ pub fn image_snapshot(current: &image::RgbaImage, name: impl Into) { } #[cfg(any(feature = "wgpu", feature = "snapshot"))] -impl Harness<'_, State> { +impl Harness<'_, State> { /// The default options used for snapshot tests. /// set by [`crate::HarnessBuilder::with_options`]. pub fn options(&self) -> &SnapshotOptions { @@ -622,10 +623,14 @@ impl Harness<'_, State> { name: impl Into, options: &SnapshotOptions, ) -> SnapshotResult { - let image = self - .render() - .map_err(|err| SnapshotError::RenderError { err })?; - try_image_snapshot_options(&image, name.into(), options) + let name = name.into(); + let image = match self.render() { + Ok(img) => img, + Err(err) => return Err(SnapshotError::RenderError { err }), + }; + let result = try_image_snapshot_options(&image, name.clone(), options); + self.dispatch(|p, h| p.on_snapshot(h, &name, &image, &result)); + result } /// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot. @@ -640,10 +645,8 @@ impl Harness<'_, State> { /// Returns a [`SnapshotError`] if the image does not match the snapshot, if there was an /// error reading or writing the snapshot, if the rendering fails or if no default renderer is available. pub fn try_snapshot(&mut self, name: impl Into) -> SnapshotResult { - let image = self - .render() - .map_err(|err| SnapshotError::RenderError { err })?; - try_image_snapshot_options(&image, name.into(), &self.default_snapshot_options) + let options = self.default_snapshot_options.clone(); + self.try_snapshot_options(name, &options) } /// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot diff --git a/crates/egui_kittest/src/wgpu.rs b/crates/egui_kittest/src/wgpu.rs index 45152e81e2c4..22972eabcff2 100644 --- a/crates/egui_kittest/src/wgpu.rs +++ b/crates/egui_kittest/src/wgpu.rs @@ -222,7 +222,7 @@ impl crate::TestRenderer for WgpuTestRenderer { self.render_state .queue - .submit(user_buffers.into_iter().chain(once(encoder.finish()))); + .submit(std::iter::chain(user_buffers, once(encoder.finish()))); self.render_state .device diff --git a/crates/egui_kittest/tests/accesskit.rs b/crates/egui_kittest/tests/accesskit.rs index 2b14d0fa1c2f..30dcbaf299be 100644 --- a/crates/egui_kittest/tests/accesskit.rs +++ b/crates/egui_kittest/tests/accesskit.rs @@ -43,7 +43,7 @@ fn button_node() { let button_text = "This is a test button!"; let output = accesskit_output_single_egui_frame(|ui| { - CentralPanel::default().show_inside(ui, |ui| ui.button(button_text)); + CentralPanel::default().show(ui, |ui| ui.button(button_text)); }); let (_, button) = output @@ -61,7 +61,7 @@ fn disabled_button_node() { let button_text = "This is a test button!"; let output = accesskit_output_single_egui_frame(|ui| { - CentralPanel::default().show_inside(ui, |ui| { + CentralPanel::default().show(ui, |ui| { ui.add_enabled(false, egui::Button::new(button_text)) }); }); @@ -82,7 +82,7 @@ fn toggle_button_node() { let mut selected = false; let output = accesskit_output_single_egui_frame(|ui| { - CentralPanel::default().show_inside(ui, |ui| ui.toggle_value(&mut selected, button_text)); + CentralPanel::default().show(ui, |ui| ui.toggle_value(&mut selected, button_text)); }); let (_, toggle) = output @@ -98,7 +98,7 @@ fn toggle_button_node() { #[test] fn multiple_disabled_widgets() { let output = accesskit_output_single_egui_frame(|ui| { - CentralPanel::default().show_inside(ui, |ui| { + CentralPanel::default().show(ui, |ui| { ui.add_enabled_ui(false, |ui| { let _ = ui.button("Button 1"); let _ = ui.button("Button 2"); diff --git a/crates/egui_kittest/tests/menu.rs b/crates/egui_kittest/tests/menu.rs index a76001e46712..33f45966f8bc 100644 --- a/crates/egui_kittest/tests/menu.rs +++ b/crates/egui_kittest/tests/menu.rs @@ -1,6 +1,6 @@ use egui::containers::menu::{MenuBar, MenuConfig, SubMenuButton}; use egui::{PopupCloseBehavior, Ui, include_image}; -use egui_kittest::{Harness, SnapshotResults}; +use egui_kittest::Harness; use kittest::Queryable as _; struct TestMenu { @@ -160,11 +160,12 @@ fn clicking_submenu_button_should_never_close_menu() { assert!(harness.query_by_label("Button in Submenu B").is_none()); } +#[cfg(feature = "snapshot")] #[test] fn menu_snapshots() { let mut harness = TestMenu::new(MenuConfig::new()).into_harness(); - let mut results = SnapshotResults::new(); + let mut results = egui_kittest::SnapshotResults::new(); harness.get_by_label("Menu A").hover(); harness.run(); diff --git a/crates/egui_kittest/tests/regression_tests.rs b/crates/egui_kittest/tests/regression_tests.rs index d68920974be9..ba39a909b00d 100644 --- a/crates/egui_kittest/tests/regression_tests.rs +++ b/crates/egui_kittest/tests/regression_tests.rs @@ -271,7 +271,7 @@ fn keyboard_submenu_harness() -> Harness<'static, bool> { .with_size(Vec2::new(400.0, 240.0)) .build_ui_state( |ui, checked| { - egui::Panel::top("menu_bar").show_inside(ui, |ui| { + egui::Panel::top("menu_bar").show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("X", |ui| { ui.menu_button("Y", |ui| { @@ -600,3 +600,116 @@ fn window_fixed_size_is_outer_size() { Found filled-rect sizes: {sizes:?}" ); } + +/// Regression test for : +/// when content overflows a `Panel`, the returned response (and the panel's +/// stored size, resize handle, and separator) must stay clamped to the panel's +/// allowed size β€” they used to inherit the overflowing content rect. +#[test] +fn panel_rect_clamped_when_content_overflows() { + use std::cell::RefCell; + + let side_panel_width = 100.0_f32; + let top_panel_height = 80.0_f32; + + let side_response: RefCell> = RefCell::new(None); + let top_response: RefCell> = RefCell::new(None); + + let mut harness = Harness::builder() + .with_size(Vec2::new(400.0, 300.0)) + .build_ui(|ui| { + let r = egui::Panel::left("left_panel") + .exact_size(side_panel_width) + .show(ui, |ui| { + // Allocate way more than the panel β€” would overflow without the clamp. + ui.allocate_space(Vec2::new(1000.0, 10.0)); + }); + *side_response.borrow_mut() = Some(r.response); + + let r = egui::Panel::top("top_panel") + .exact_size(top_panel_height) + .show(ui, |ui| { + ui.allocate_space(Vec2::new(10.0, 1000.0)); + }); + *top_response.borrow_mut() = Some(r.response); + }); + + harness.run(); + + let sr = side_response.borrow(); + let sr = sr.as_ref().expect("left panel response was captured"); + assert!( + sr.rect.width() <= side_panel_width + 1.0, + "left panel rect.width()={} exceeded the configured panel width {side_panel_width}", + sr.rect.width() + ); + assert!( + sr.interact_rect.width() <= side_panel_width + 1.0, + "left panel interact_rect.width()={} exceeded the configured panel width {side_panel_width}", + sr.interact_rect.width() + ); + + let tr = top_response.borrow(); + let tr = tr.as_ref().expect("top panel response was captured"); + assert!( + tr.rect.height() <= top_panel_height + 1.0, + "top panel rect.height()={} exceeded the configured panel height {top_panel_height}", + tr.rect.height() + ); + assert!( + tr.interact_rect.height() <= top_panel_height + 1.0, + "top panel interact_rect.height()={} exceeded the configured panel height {top_panel_height}", + tr.interact_rect.height() + ); +} + +/// Regression test: when an animated panel slides off-screen (collapsing), the +/// enclosing parent (e.g. a `Window`) must not be grown to include the slid-off +/// portion of the panel. +#[test] +fn collapsing_panel_must_not_grow_enclosing_window() { + use std::cell::RefCell; + + let window_rect: RefCell> = RefCell::new(None); + let is_expanded: RefCell = RefCell::new(true); + + let mut harness = Harness::builder() + .with_size(Vec2::new(800.0, 600.0)) + .build_ui(|ui| { + let resp = egui::Window::new("panels_window") + .vscroll(false) + .show(ui.ctx(), |ui| { + egui::Panel::bottom("bottom_panel") + .resizable(false) + .min_size(60.0) + .show_collapsible(ui, &mut is_expanded.borrow_mut(), |ui| { + ui.label("bottom content"); + }); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("central"); + }); + }); + if let Some(resp) = resp { + *window_rect.borrow_mut() = Some(resp.response.rect); + } + }); + + harness.run(); + let initial = window_rect.borrow().expect("window rect captured"); + + // Trigger the collapse animation. + *is_expanded.borrow_mut() = false; + + // Step through the animation frames; the window must never grow taller than + // its initial height (slid-off panel portion must not push the window out). + for i in 0..30 { + harness.step(); + let r = window_rect.borrow().expect("window rect captured"); + assert!( + r.height() <= initial.height() + 0.5, + "frame {i}: window grew during panel collapse: initial h={}, now h={}", + initial.height(), + r.height(), + ); + } +} diff --git a/crates/egui_kittest/tests/tests.rs b/crates/egui_kittest/tests/tests.rs index 81f861451f6a..de22026a2754 100644 --- a/crates/egui_kittest/tests/tests.rs +++ b/crates/egui_kittest/tests/tests.rs @@ -1,3 +1,6 @@ +#![cfg(feature = "snapshot")] +#![cfg(feature = "wgpu")] + use egui::{Modifiers, ScrollArea, Vec2, include_image}; use egui_kittest::{Harness, SnapshotResults}; use kittest::Queryable as _; @@ -122,6 +125,7 @@ fn test_scroll_harness() -> Harness<'static, bool> { ) } +#[cfg(feature = "snapshot")] #[test] fn test_scroll_to_me() { let mut harness = test_scroll_harness(); diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 059d9d769319..6fbd2b38fb78 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -1,3 +1,4 @@ +use ecolor::linear_f32_from_linear_u8; use emath::Vec2; use crate::{Color32, textures::TextureOptions}; @@ -346,22 +347,37 @@ impl std::fmt::Debug for ColorImage { // ---------------------------------------------------------------------------- /// How to convert font coverage values into alpha and color values. -// -// This whole thing is less than rigorous. -// Ideally we should do this in a shader instead, and use different computations -// for different text colors. -// See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis. +/// +/// epaint stores all glyphs in the font atlas as white (with varying opacity), +/// so that egui can reuse the same glyph for different text colors +/// (with a simple color multiplication in the shader). +/// +/// Because of this simplification, we need to apply a non-linear +/// ramp to the glyph colors before writing them into the font atlas, +/// as a way to compensate. +/// +/// This whole thing is less than rigorous. +/// +/// It would be better to either render all text colors into the font atlas +/// (which would require more atlas space, but would allow for more accurate rendering of colored text and emojis), +/// or do the color compensation in the shader, based on the active text color. +/// +/// When experimenting, use to compare to a ground truth. +/// +/// See for related analysis. #[derive(Clone, Copy, Debug, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub enum AlphaFromCoverage { - /// `alpha = coverage`. +pub enum FontColorTransferFunction { + /// Use the raw RGBA values from the font rasterizer, without any conversion. /// - /// Looks good for black-on-white text, i.e. light mode. + /// This is the required mode for colored emojis etc. /// - /// Same as [`Self::Gamma`]`(1.0)`, but more efficient. - Linear, + /// This mode looks good for black-on-white text, i.e. light mode. + Off, /// `alpha = coverage^gamma`. + /// + /// Gamma=1 looks good for black-on-white text, i.e. light mode. Gamma(f32), /// `alpha = 2 * coverage - coverage^2` @@ -374,29 +390,59 @@ pub enum AlphaFromCoverage { TwoCoverageMinusCoverageSq, } -impl AlphaFromCoverage { +impl FontColorTransferFunction { /// A good-looking default for light mode (black-on-white text). - pub const LIGHT_MODE_DEFAULT: Self = Self::Linear; + pub const LIGHT_MODE_DEFAULT: Self = Self::Off; /// A good-looking default for dark mode (white-on-black text). pub const DARK_MODE_DEFAULT: Self = Self::TwoCoverageMinusCoverageSq; + /// How to convert a white color written by the font rasterizer + /// into a color to be written into the font atlas. + #[inline(always)] + pub fn to_atlas_color(self, input_color: Color32) -> Color32 { + match self { + Self::Off | Self::Gamma(1.0) => input_color, + + Self::Gamma(gamma) => { + let coverage = linear_f32_from_linear_u8(input_color.a()); + let alpha = coverage.powf(gamma); + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } + + Self::TwoCoverageMinusCoverageSq => { + let coverage = linear_f32_from_linear_u8(input_color.a()); + let alpha = 2.0 * coverage - coverage * coverage; + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } + } + } + /// Convert coverage to alpha. #[inline(always)] - pub fn alpha_from_coverage(&self, coverage: f32) -> f32 { + pub fn alpha_from_coverage(self, coverage: f32) -> f32 { let coverage = coverage.clamp(0.0, 1.0); match self { - Self::Linear => coverage, - Self::Gamma(gamma) => coverage.powf(*gamma), + Self::Off | Self::Gamma(1.0) => coverage, + Self::Gamma(gamma) => coverage.powf(gamma), Self::TwoCoverageMinusCoverageSq => 2.0 * coverage - coverage * coverage, } } #[inline(always)] - pub fn color_from_coverage(&self, coverage: f32) -> Color32 { + pub fn color_from_coverage(self, coverage: f32) -> Color32 { let alpha = self.alpha_from_coverage(coverage); Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) } + + /// Convert this into the closest gamma exponent + pub fn to_gamma(self) -> f32 { + match self { + Self::Off => 1.0, + Self::Gamma(gamma) => gamma, + Self::TwoCoverageMinusCoverageSq => 0.5, // approximately the same + } + } } // ---------------------------------------------------------------------------- diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 427dad181e5a..bff5c79a307a 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -52,7 +52,7 @@ pub use self::{ corner_radius::CornerRadius, corner_radius_f32::CornerRadiusF32, direction::Direction, - image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta}, + image::{ColorImage, FontColorTransferFunction, ImageData, ImageDelta}, margin::Margin, margin_f32::*, mesh::{Mesh, Mesh16, Vertex}, diff --git a/crates/epaint/src/stroke.rs b/crates/epaint/src/stroke.rs index 4adb1f309876..1b072e4bf5a1 100644 --- a/crates/epaint/src/stroke.rs +++ b/crates/epaint/src/stroke.rs @@ -22,9 +22,9 @@ impl Stroke { }; #[inline] - pub fn new(width: impl Into, color: impl Into) -> Self { + pub fn new(width: f32, color: impl Into) -> Self { Self { - width: width.into(), + width, color: color.into(), } } @@ -136,9 +136,9 @@ impl PathStroke { }; #[inline] - pub fn new(width: impl Into, color: impl Into) -> Self { + pub fn new(width: f32, color: impl Into) -> Self { Self { - width: width.into(), + width, color: ColorMode::Solid(color.into()), kind: StrokeKind::Middle, } @@ -149,11 +149,11 @@ impl PathStroke { /// The bounding box passed to the callback will have a margin of [`TessellationOptions::feathering_size_in_pixels`](`crate::tessellator::TessellationOptions::feathering_size_in_pixels`) #[inline] pub fn new_uv( - width: impl Into, + width: f32, callback: impl Fn(Rect, Pos2) -> Color32 + Send + Sync + 'static, ) -> Self { Self { - width: width.into(), + width, color: ColorMode::UV(Arc::new(callback)), kind: StrokeKind::Middle, } diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index 2d5edf1cfae7..987f271e037c 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -1,5 +1,6 @@ #![expect(clippy::mem_forget)] +use ecolor::Color32; use emath::{GuiRounding as _, OrderedFloat, Vec2, vec2}; use self_cell::self_cell; use skrifa::{GlyphId, MetadataProvider as _}; @@ -58,6 +59,41 @@ impl GlyphInfo { }; } +/// Result of resolving a `char` to a [`GlyphId`] within a single [`FontFace`]. +/// +/// Location-independent: only depends on the font's charmap and `FontTweak`, +/// not on variable-font variation coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(super) enum GlyphIdResolution { + /// A real, visible glyph. + Glyph(GlyphId), + + /// A valid char, but rendered as zero-width (control chars, joiners, …). + Invisible, +} + +/// A precomputed hash of a [`skrifa::instance::Location`]. +/// +/// Used as a cache key so that we don't have to re-hash the coordinate list +/// for every glyph lookup. Compute once per text run and reuse for every glyph +/// in the run. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub(crate) struct LocationHash(u64); + +impl nohash_hasher::IsEnabled for LocationHash {} + +impl LocationHash { + #[inline] + pub fn new(location: &skrifa::instance::Location) -> Self { + if location.coords().is_empty() { + // Fast path for the (common) default-coords case. + Self(0) + } else { + Self(crate::util::hash(location)) + } + } +} + // Subpixel binning, taken from cosmic-text: // https://github.com/pop-os/cosmic-text/blob/974ddaed96b334f560b606ebe5d2ca2d2f9f23ef/src/glyph_cache.rs @@ -131,10 +167,12 @@ struct GlyphCacheKey(u64); impl nohash_hasher::IsEnabled for GlyphCacheKey {} impl GlyphCacheKey { + #[inline] fn new(glyph_id: GlyphId, metrics: &StyledMetrics, bin: SubpixelBin) -> Self { let StyledMetrics { pixels_per_point, px_scale_factor, + location_hash, .. } = *metrics; debug_assert!( @@ -150,6 +188,7 @@ impl GlyphCacheKey { pixels_per_point.to_bits(), px_scale_factor.to_bits(), bin, + location_hash, ))) } } @@ -161,7 +200,6 @@ struct DependentFontData<'a> { charmap: skrifa::charmap::Charmap<'a>, outline_glyphs: skrifa::outline::OutlineGlyphCollection<'a>, metrics: skrifa::metrics::Metrics, - glyph_metrics: skrifa::metrics::GlyphMetrics<'a>, hinting_instance: Option, } @@ -204,7 +242,9 @@ impl FontCell { if let Some(hinting_instance) = &mut font_data.hinting_instance { let size = skrifa::instance::Size::new(metrics.scale); - if hinting_instance.size() != size { + if hinting_instance.size() != size + || hinting_instance.location().coords() != location.coords() + { hinting_instance .reconfigure( &font_data.outline_glyphs, @@ -235,25 +275,31 @@ impl FontCell { let width = bounds.width() as u16; let height = bounds.height() as u16; - let mut ctx = vello_cpu::RenderContext::new(width, height); - ctx.set_transform(kurbo::Affine::translate((-bounds.x0, -bounds.y0))); - ctx.set_paint(color::OpaqueColor::::WHITE); - ctx.fill_path(&path); - let mut dest = vello_cpu::Pixmap::new(width, height); - ctx.render_to_pixmap(&mut dest); let uv_rect = if width == 0 || height == 0 { UvRect::default() } else { + let mut ctx = vello_cpu::RenderContext::new(width, height); + ctx.set_transform(kurbo::Affine::translate((-bounds.x0, -bounds.y0))); + ctx.set_paint(color::OpaqueColor::::WHITE); + ctx.fill_path(&path); + let mut dest = vello_cpu::Pixmap::new(width, height); + let mut resources = vello_cpu::Resources::new(); + ctx.render_to_pixmap(&mut resources, &mut dest); + let glyph_pos = { - let alpha_from_coverage = atlas.options().alpha_from_coverage; + let color_transfer_function = atlas.options().color_transfer_function; let (glyph_pos, image) = atlas.allocate((width as usize, height as usize)); let pixels = dest.data_as_u8_slice(); for y in 0..height as usize { for x in 0..width as usize { - image[(x + glyph_pos.0, y + glyph_pos.1)] = alpha_from_coverage - .color_from_coverage( - pixels[((y * width as usize) + x) * 4 + 3] as f32 / 255.0, - ); + let pixel_offset = 4 * ((y * width as usize) + x); + image[(x + glyph_pos.0, y + glyph_pos.1)] = color_transfer_function + .to_atlas_color(Color32::from_rgba_premultiplied( + pixels[pixel_offset], + pixels[pixel_offset + 1], + pixels[pixel_offset + 2], + pixels[pixel_offset + 3], + )); } } glyph_pos @@ -322,7 +368,18 @@ pub struct FontFace { /// `ShaperData` is `Copy` β€” lives outside the `self_cell`. shaper_data: harfrust::ShaperData, - glyph_info_cache: ahash::HashMap, + /// Location-independent: `char β†’ GlyphId | Invisible`. + /// + /// Only depends on the font's charmap + `FontTweak`. A miss means the char + /// is not in this face's repertoire and the fallback chain should be tried. + glyph_id_cache: ahash::HashMap, + + /// Location-dependent: `(char, LocationHash) β†’ unscaled advance width`. + /// + /// Variable fonts can vary advance widths per axis (HVAR table), so this + /// must be re-keyed per resolved [`skrifa::instance::Location`]. + advance_width_cache: ahash::HashMap<(char, LocationHash), OrderedFloat>, + glyph_alloc_cache: ahash::HashMap, } @@ -344,14 +401,11 @@ impl FontFace { // Note: We use default location here during initialization because // the actual weight will be applied via the stored location during rendering. // The metrics won't be significantly different at this unscaled size. + // TODO(emilk): heed location for vertical metrics too (HVAR/MVAR). let metrics = skrifa_font.metrics( skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default(), ); - let glyph_metrics = skrifa_font.glyph_metrics( - skrifa::instance::Size::unscaled(), - skrifa::instance::LocationRef::default(), - ); let hinting_enabled = tweak.hinting.unwrap_or(options.font_hinting); let hinting_instance = hinting_enabled @@ -373,7 +427,6 @@ impl FontFace { charmap, outline_glyphs: glyphs, metrics, - glyph_metrics, hinting_instance, }) })?; @@ -388,7 +441,8 @@ impl FontFace { tweak, subpixel_binning, shaper_data, - glyph_info_cache: Default::default(), + glyph_id_cache: Default::default(), + advance_width_cache: Default::default(), glyph_alloc_cache: Default::default(), }) } @@ -422,65 +476,86 @@ impl FontFace { .filter_map(|(chr, _)| char::from_u32(chr).filter(|c| !self.ignore_character(*c))) } - /// `\n` will result in `None` - pub(super) fn glyph_info(&mut self, c: char) -> Option { - if let Some(glyph_info) = self.glyph_info_cache.get(&c) { - return Some(*glyph_info); + /// Resolve a `char` to a [`GlyphId`] within this face. + /// + /// Location-independent. Returns `None` when this face cannot represent + /// the char (the caller should try the fallback chain). + /// + /// `\t` and thin spaces share `' '`s glyph id (they just have a custom advance). + pub(super) fn glyph_id_resolution(&mut self, c: char) -> Option { + if let Some(resolution) = self.glyph_id_cache.get(&c) { + return Some(*resolution); } if self.ignore_character(c) { return None; // these will result in the replacement character when rendering } - if c == '\t' - && let Some(space) = self.glyph_info(' ') - { - let glyph_info = GlyphInfo { - advance_width_unscaled: (self.tweak.tab_size * space.advance_width_unscaled.0) - .into(), - ..space - }; - self.glyph_info_cache.insert(c, glyph_info); - return Some(glyph_info); - } + let resolution = if c == '\t' || c == '\u{2009}' || c == '\u{202F}' { + // `\t` and thin spaces are rendered as a space glyph with a custom advance. + self.glyph_id_resolution(' ')? + } else if invisible_char(c) { + GlyphIdResolution::Invisible + } else { + let glyph_id = self + .font + .borrow_dependent() + .charmap + .map(c) + .filter(|id| *id != GlyphId::NOTDEF)?; + GlyphIdResolution::Glyph(glyph_id) + }; - if (c == '\u{2009}' || c == '\u{202F}') - && let Some(space) = self.glyph_info(' ') - { - // Thin space (U+2009) and narrow no-break space (U+202F), - // often used as thousands separator: 1 234 567 890 - let advance_width = self.tweak.thin_space_width * space.advance_width_unscaled.0; - let glyph_info = GlyphInfo { - advance_width_unscaled: advance_width.into(), - ..space - }; - self.glyph_info_cache.insert(c, glyph_info); - return Some(glyph_info); - } + self.glyph_id_cache.insert(c, resolution); + Some(resolution) + } - if invisible_char(c) { - let glyph_info = GlyphInfo::INVISIBLE; - self.glyph_info_cache.insert(c, glyph_info); - return Some(glyph_info); + /// Unscaled advance width for `c` at the given variation location. + /// + /// Location-dependent (variable fonts can vary advances via HVAR). + /// Cached per `(char, LocationHash)`. + fn advance_width_unscaled(&mut self, c: char, metrics: &StyledMetrics) -> f32 { + let cache_key = (c, metrics.location_hash); + if let Some(advance) = self.advance_width_cache.get(&cache_key) { + return advance.0; } - let font_data = self.font.borrow_dependent(); + let advance = match c { + '\t' => self.tweak.tab_size * self.advance_width_unscaled(' ', metrics), + '\u{2009}' | '\u{202F}' => { + // Thin space (U+2009) and narrow no-break space (U+202F), + // often used as thousands separator. + self.tweak.thin_space_width * self.advance_width_unscaled(' ', metrics) + } + _ => { + let Some(GlyphIdResolution::Glyph(glyph_id)) = self.glyph_id_resolution(c) else { + return 0.0; + }; + let font_data = self.font.borrow_dependent(); + let glyph_metrics = font_data + .skrifa + .glyph_metrics(skrifa::instance::Size::unscaled(), &metrics.location); + glyph_metrics.advance_width(glyph_id).unwrap_or_default() + } + }; - // Add new character: - let glyph_id = font_data - .charmap - .map(c) - .filter(|id| *id != GlyphId::NOTDEF)?; - - let glyph_info = GlyphInfo { - id: Some(glyph_id), - advance_width_unscaled: font_data - .glyph_metrics - .advance_width(glyph_id) - .unwrap_or_default() - .into(), + self.advance_width_cache.insert(cache_key, advance.into()); + advance + } + + /// `\n` will result in `None`. + /// + /// Caller must pass [`StyledMetrics`] resolved against *this* face so that + /// variable-font advance widths are looked up at the correct location. + pub(super) fn glyph_info(&mut self, c: char, metrics: &StyledMetrics) -> Option { + let resolution = self.glyph_id_resolution(c)?; + let glyph_info = match resolution { + GlyphIdResolution::Invisible => GlyphInfo::INVISIBLE, + GlyphIdResolution::Glyph(glyph_id) => GlyphInfo { + id: Some(glyph_id), + advance_width_unscaled: self.advance_width_unscaled(c, metrics).into(), + }, }; - self.glyph_info_cache.insert(c, glyph_info); Some(glyph_info) } @@ -507,13 +582,9 @@ impl FontFace { let axes = font_data.skrifa.axes(); // Override the default coordinates with ones specified via FontTweak, then the ones specified directly via the // argument (probably from TextFormat). - let settings = self - .tweak - .coords - .as_ref() - .iter() - .chain(coords.as_ref().iter()); + let settings = std::iter::chain(self.tweak.coords.as_ref(), coords.as_ref()); let location = axes.location(settings); + let location_hash = LocationHash::new(&location); StyledMetrics { pixels_per_point, @@ -523,6 +594,7 @@ impl FontFace { ascent, row_height: ascent - descent + line_gap, location, + location_hash, } } @@ -598,7 +670,7 @@ pub struct Font<'a> { impl Font<'_> { pub fn preload_characters(&mut self, s: &str) { for c in s.chars() { - self.glyph_info(c); + self.resolve_face(c); } } @@ -630,19 +702,23 @@ impl Font<'_> { .unwrap_or_default() } - /// Width of this character in points. + /// Width of this character in points, at the font's default variation location. pub fn glyph_width(&mut self, c: char, font_size: f32) -> f32 { - let (key, glyph_info) = self.glyph_info(c); - if let Some(font) = &self.fonts_by_id.get(&key) { - glyph_info.advance_width_unscaled.0 * font.font.px_scale_factor(font_size) - } else { - 0.0 - } + let face_key = self.resolve_face(c); + let Some(font_face) = self.fonts_by_id.get_mut(&face_key) else { + return 0.0; + }; + let metrics = font_face.styled_metrics(1.0, font_size, &VariationCoords::default()); + let Some(glyph_info) = font_face.glyph_info(c, &metrics) else { + return 0.0; + }; + glyph_info.advance_width_unscaled.0 * font_face.font.px_scale_factor(font_size) } /// Can we display this glyph? pub fn has_glyph(&mut self, c: char) -> bool { - self.glyph_info(c) != self.cached_family.replacement_glyph // TODO(emilk): this is a false negative if the user asks about the replacement character itself πŸ€¦β€β™‚οΈ + // TODO(emilk): this is a false negative if the user asks about the replacement character itself πŸ€¦β€β™‚οΈ + self.resolve_face(c) != self.cached_family.replacement_face_key } /// Can we display all the glyphs in this text? @@ -650,21 +726,52 @@ impl Font<'_> { s.chars().all(|c| self.has_glyph(c)) } - /// `\n` will (intentionally) show up as the replacement character. - pub(crate) fn glyph_info(&mut self, c: char) -> (FontFaceKey, GlyphInfo) { - if let Some(font_index_glyph_info) = self.cached_family.glyph_info_cache.get(&c) { - return *font_index_glyph_info; + /// Find which face in the fallback chain owns `c`. + /// + /// Location-independent β€” fallback choice depends only on charmap support. + /// Falls back to the replacement-glyph face when no fallback face has `c`. + #[inline] + pub(crate) fn resolve_face(&mut self, c: char) -> FontFaceKey { + if let Some(font_key) = self.cached_family.face_cache.get(&c) { + return *font_key; } + self.resolve_face_slow(c) + } - let font_index_glyph_info = self + #[cold] + fn resolve_face_slow(&mut self, c: char) -> FontFaceKey { + let font_key = self .cached_family - .glyph_info_no_cache_or_fallback(c, self.fonts_by_id); - let font_index_glyph_info = - font_index_glyph_info.unwrap_or(self.cached_family.replacement_glyph); - self.cached_family - .glyph_info_cache - .insert(c, font_index_glyph_info); - font_index_glyph_info + .find_face_for_char(c, self.fonts_by_id) + .unwrap_or(self.cached_family.replacement_face_key); + self.cached_family.face_cache.insert(c, font_key); + font_key + } + + /// Resolve `c` to its (face, [`GlyphInfo`]) at the given face's location. + /// + /// `\n` will (intentionally) show up as the replacement character. + /// + /// `metrics` must be the resolved [`StyledMetrics`] for the face that ends + /// up owning `c`. Most callers pass the metrics of their text run's primary + /// face β€” that is correct as long as `c` is in that face. For correct + /// fallback-face advances, resolve the face first with [`Self::resolve_face`] + /// and build metrics for that face. + pub(crate) fn glyph_info( + &mut self, + c: char, + metrics: &StyledMetrics, + ) -> (FontFaceKey, GlyphInfo) { + let face_key = self.resolve_face(c); + let Some(face) = self.fonts_by_id.get_mut(&face_key) else { + return (face_key, GlyphInfo::INVISIBLE); + }; + let glyph_info = face.glyph_info(c, metrics).unwrap_or_else(|| { + // `c` is in no face β€” render the replacement character instead. + face.glyph_info(self.cached_family.replacement_char, metrics) + .unwrap_or(GlyphInfo::INVISIBLE) + }); + (face_key, glyph_info) } } @@ -697,6 +804,12 @@ pub struct StyledMetrics { /// Resolved variation coordinates. pub location: skrifa::instance::Location, + + /// Precomputed hash of [`Self::location`]. + /// + /// Hashed once per run of text so per-glyph cache lookups don't have to + /// re-hash the full coordinate list. + pub(crate) location_hash: LocationHash, } /// Code points that will always be invisible (zero width). diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 980e56aee252..23fe6238727a 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -11,7 +11,7 @@ use crate::{ TextureAtlas, text::{ Galley, LayoutJob, LayoutSection, TextOptions, VariationCoords, - font::{Font, FontFace, GlyphInfo}, + font::{Font, FontFace}, }, }; use emath::{NumExt as _, OrderedFloat}; @@ -457,9 +457,20 @@ pub(super) struct CachedFamily { /// Lazily calculated. pub characters: Option>>, - pub replacement_glyph: (FontFaceKey, GlyphInfo), + /// The face used when no face in [`Self::fonts`] supports a char. + pub replacement_face_key: FontFaceKey, - pub glyph_info_cache: ahash::HashMap, + /// The char that [`Self::replacement_face_key`] actually contains. + /// + /// When the user asks about a char that no fallback face supports we + /// render this char in its place. + pub replacement_char: char, + + /// Cache: `char β†’ which face in the fallback chain owns this char`. + /// + /// Location-independent (fallback choice depends only on charmap support, + /// not on variation coordinates). + pub face_cache: ahash::HashMap, } impl CachedFamily { @@ -467,49 +478,59 @@ impl CachedFamily { fonts: Vec, fonts_by_id: &mut nohash_hasher::IntMap, ) -> Self { + const PRIMARY_REPLACEMENT_CHAR: char = 'β—»'; // white medium square + const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback + if fonts.is_empty() { return Self { fonts, characters: None, - replacement_glyph: (FontFaceKey::INVALID, GlyphInfo::INVISIBLE), - glyph_info_cache: Default::default(), + replacement_face_key: FontFaceKey::INVALID, + replacement_char: PRIMARY_REPLACEMENT_CHAR, + face_cache: Default::default(), }; } let mut slf = Self { fonts, characters: None, - replacement_glyph: (FontFaceKey::INVALID, GlyphInfo::INVISIBLE), - glyph_info_cache: Default::default(), + replacement_face_key: FontFaceKey::INVALID, + replacement_char: PRIMARY_REPLACEMENT_CHAR, + face_cache: Default::default(), }; - const PRIMARY_REPLACEMENT_CHAR: char = 'β—»'; // white medium square - const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback - - let replacement_glyph = slf - .glyph_info_no_cache_or_fallback(PRIMARY_REPLACEMENT_CHAR, fonts_by_id) - .or_else(|| slf.glyph_info_no_cache_or_fallback(FALLBACK_REPLACEMENT_CHAR, fonts_by_id)) + let (replacement_face_key, replacement_char) = slf + .find_face_for_char(PRIMARY_REPLACEMENT_CHAR, fonts_by_id) + .map(|key| (key, PRIMARY_REPLACEMENT_CHAR)) + .or_else(|| { + slf.find_face_for_char(FALLBACK_REPLACEMENT_CHAR, fonts_by_id) + .map(|key| (key, FALLBACK_REPLACEMENT_CHAR)) + }) .unwrap_or_else(|| { log::warn!( "Failed to find replacement characters {PRIMARY_REPLACEMENT_CHAR:?} or {FALLBACK_REPLACEMENT_CHAR:?}. Will use empty glyph." ); - (FontFaceKey::INVALID, GlyphInfo::INVISIBLE) + (FontFaceKey::INVALID, PRIMARY_REPLACEMENT_CHAR) }); - slf.replacement_glyph = replacement_glyph; + slf.replacement_face_key = replacement_face_key; + slf.replacement_char = replacement_char; slf } - pub(crate) fn glyph_info_no_cache_or_fallback( - &mut self, + /// Walk the fallback chain and return the first face whose charmap supports `c`. + /// + /// Pure β€” does not touch any cache. Callers that want memoisation should + /// insert into [`Self::face_cache`] themselves. + pub(crate) fn find_face_for_char( + &self, c: char, fonts_by_id: &mut nohash_hasher::IntMap, - ) -> Option<(FontFaceKey, GlyphInfo)> { + ) -> Option { for font_key in &self.fonts { let font_face = fonts_by_id.get_mut(font_key).expect("Nonexistent font ID"); - if let Some(glyph_info) = font_face.glyph_info(c) { - self.glyph_info_cache.insert(c, (*font_key, glyph_info)); - return Some((*font_key, glyph_info)); + if font_face.glyph_id_resolution(c).is_some() { + return Some(*font_key); } } None diff --git a/crates/epaint/src/text/mod.rs b/crates/epaint/src/text/mod.rs index 6d2d783c23bf..d62092d12cdb 100644 --- a/crates/epaint/src/text/mod.rs +++ b/crates/epaint/src/text/mod.rs @@ -25,8 +25,8 @@ pub struct TextOptions { /// Maximum size of the font texture. pub max_texture_side: usize, - /// Controls how to convert glyph coverage to alpha. - pub alpha_from_coverage: crate::AlphaFromCoverage, + /// Controls how to convert glyph colors when writing to the font atlas. + pub color_transfer_function: crate::FontColorTransferFunction, /// Whether to enable font hinting /// @@ -54,7 +54,7 @@ impl Default for TextOptions { fn default() -> Self { Self { max_texture_side: 2048, // Small but portable - alpha_from_coverage: crate::AlphaFromCoverage::default(), + color_transfer_function: crate::FontColorTransferFunction::default(), font_hinting: true, subpixel_binning: true, } diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 35859b095702..77ca74feaab4 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -1,7 +1,7 @@ #![expect(clippy::unwrap_used)] // TODO(emilk): remove unwraps -use std::ops::Range; use std::sync::Arc; +use std::{iter, ops::Range}; use emath::{Align, GuiRounding as _, NumExt as _, Pos2, Rect, Vec2, pos2, vec2}; @@ -244,11 +244,7 @@ fn layout_shaped_run( let mut cluster_start_byte: usize = 0; let mut cluster_glyph_count: usize = 0; - for (info, pos) in glyph_buffer - .glyph_infos() - .iter() - .zip(glyph_buffer.glyph_positions()) - { + for (info, pos) in iter::zip(glyph_buffer.glyph_infos(), glyph_buffer.glyph_positions()) { let glyph_id = skrifa::GlyphId::new(info.glyph_id); let cluster = info.cluster; let mut advance_width_px = pos.x_advance as f32 * px_scale; @@ -265,7 +261,7 @@ fn layout_shaped_run( if chr == '\t' { let tweak = font.fonts_by_id.get(&run.font_key).map(|ff| ff.tweak()); let tab_size = tweak.map_or(4.0, |t| t.tab_size); - let (_, space_info) = font.glyph_info(' '); + let (_, space_info) = font.glyph_info(' ', face_metrics); let space_width_px = space_info.advance_width_unscaled.0 * px_scale; advance_width_px = tab_size * space_width_px; } @@ -275,7 +271,7 @@ fn layout_shaped_run( if chr == '\u{2009}' || chr == '\u{202F}' { let tweak = font.fonts_by_id.get(&run.font_key).map(|ff| ff.tweak()); let thin_space_width = tweak.map_or(0.5, |t| t.thin_space_width); - let (_, space_info) = font.glyph_info(' '); + let (_, space_info) = font.glyph_info(' ', face_metrics); let space_width_px = space_info.advance_width_unscaled.0 * px_scale; advance_width_px = thin_space_width * space_width_px; } @@ -312,7 +308,7 @@ fn layout_shaped_run( } // Use the fallback font face (not run.font_key which returned NOTDEF). - let (fallback_key, glyph_info) = font.glyph_info(chr); + let fallback_key = font.resolve_face(chr); let fallback_metrics = font .fonts_by_id .get(&fallback_key) @@ -320,6 +316,7 @@ fn layout_shaped_run( ff.styled_metrics(ctx.pixels_per_point, ctx.font_size, &Default::default()) }) .unwrap_or_default(); + let (_, glyph_info) = font.glyph_info(chr, &fallback_metrics); let advance_width_px = glyph_info.advance_width_unscaled.0 * fallback_metrics.px_scale_factor; let (glyph_alloc, physical_x) = @@ -525,7 +522,7 @@ fn layout_section( /// Avoids `Box` and `Vec<&str>` allocation. enum SplitOrWhole<'a> { Split(std::str::Split<'a, char>), - Whole(std::iter::Once<&'a str>), + Whole(iter::Once<&'a str>), } impl<'a> SplitOrWhole<'a> { @@ -533,7 +530,7 @@ impl<'a> SplitOrWhole<'a> { if split { Self::Split(text.split('\n')) } else { - Self::Whole(std::iter::once(text)) + Self::Whole(iter::once(text)) } } } @@ -778,12 +775,14 @@ fn replace_last_glyph_with_overflow_character( let mut font = fonts.font(§ion.format.font_id.family); let font_size = section.format.font_id.size; - let (font_id, glyph_info) = font.glyph_info(overflow_character); - let mut font_face = font.fonts_by_id.get_mut(&font_id); - let font_face_metrics = font_face - .as_mut() + let font_id = font.resolve_face(overflow_character); + let font_face_metrics = font + .fonts_by_id + .get(&font_id) .map(|f| f.styled_metrics(pixels_per_point, font_size, §ion.format.coords)) .unwrap_or_default(); + let (_, glyph_info) = font.glyph_info(overflow_character, &font_face_metrics); + let mut font_face = font.fonts_by_id.get_mut(&font_id); let overflow_glyph_x = if let Some(prev_glyph) = row.glyphs.last() { prev_glyph.max_x() + extra_letter_spacing @@ -1375,7 +1374,7 @@ fn segment_into_runs(font: &mut Font<'_>, text: &str, out: &mut Vec) { let byte_end = byte_offset + grapheme_str.len(); let base_char = grapheme_str.chars().next().unwrap_or(' '); - let (font_key, _) = font.glyph_info(base_char); + let font_key = font.resolve_face(base_char); if let Some(last_run) = out.last_mut() && last_run.font_key == font_key @@ -1406,11 +1405,7 @@ fn shape_text( let tweak = font_face.tweak(); // Build shaper with variable font instance if variation coordinates are set. - let variations: Vec = tweak - .coords - .as_ref() - .iter() - .chain(coords.as_ref().iter()) + let variations: Vec = iter::chain(tweak.coords.as_ref(), coords.as_ref()) .map(|&(tag, value)| harfrust::Variation { tag, value }) .collect(); @@ -1439,6 +1434,7 @@ fn shape_text( #[cfg(test)] mod tests { + use std::iter; use super::{super::*, *}; use crate::text::cursor::CCursor; @@ -1575,10 +1571,11 @@ mod tests { &mut fonts, pixels_per_point, Arc::new(LayoutJob::single_section( - (0..elided_galley.rows[0].char_count_excluding_newline()) - .map(|_| ch) - .chain(std::iter::once('…')) - .collect::(), + iter::chain( + (0..elided_galley.rows[0].char_count_excluding_newline()).map(|_| ch), + iter::once('…'), + ) + .collect::(), TextFormat::default(), )), ); diff --git a/crates/epaint/src/texture_atlas.rs b/crates/epaint/src/texture_atlas.rs index 9a77c142aa54..4f85488176cb 100644 --- a/crates/epaint/src/texture_atlas.rs +++ b/crates/epaint/src/texture_atlas.rs @@ -120,8 +120,9 @@ impl TextureAtlas { let distance_to_center = ((dx * dx + dy * dy) as f32).sqrt(); let coverage = remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0); - image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = - options.alpha_from_coverage.color_from_coverage(coverage); + image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = options + .color_transfer_function + .color_from_coverage(coverage); } } atlas.discs.push(PrerasterizedDisc { diff --git a/examples/confirm_exit/src/main.rs b/examples/confirm_exit/src/main.rs index 6c26bed96b69..ed14dfdf9d80 100644 --- a/examples/confirm_exit/src/main.rs +++ b/examples/confirm_exit/src/main.rs @@ -24,7 +24,7 @@ struct MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("Try to close the window"); }); diff --git a/examples/custom_3d_glow/src/main.rs b/examples/custom_3d_glow/src/main.rs index e0a3e3dd2b3f..2e51b0400082 100644 --- a/examples/custom_3d_glow/src/main.rs +++ b/examples/custom_3d_glow/src/main.rs @@ -44,7 +44,7 @@ impl MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label("The triangle is being painted using "); diff --git a/examples/custom_font/src/main.rs b/examples/custom_font/src/main.rs index 851a4fb921bb..d5612b2f8c72 100644 --- a/examples/custom_font/src/main.rs +++ b/examples/custom_font/src/main.rs @@ -87,7 +87,7 @@ impl MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("egui using custom fonts"); ui.text_edit_multiline(&mut self.text); }); diff --git a/examples/custom_font_style/src/main.rs b/examples/custom_font_style/src/main.rs index 742bf4036d68..d2b5855705a9 100644 --- a/examples/custom_font_style/src/main.rs +++ b/examples/custom_font_style/src/main.rs @@ -66,7 +66,7 @@ impl MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, content); + egui::CentralPanel::default().show(ui, content); } } diff --git a/examples/custom_style/src/main.rs b/examples/custom_style/src/main.rs index b8e1599de63f..796292193264 100644 --- a/examples/custom_style/src/main.rs +++ b/examples/custom_style/src/main.rs @@ -58,7 +58,7 @@ impl MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("egui using a customized style"); ui.label("Switch between dark and light mode to see the different styles in action."); global_theme_preference_buttons(ui); diff --git a/examples/external_eventloop/src/main.rs b/examples/external_eventloop/src/main.rs index a940fd7d0a82..227fd3f33334 100644 --- a/examples/external_eventloop/src/main.rs +++ b/examples/external_eventloop/src/main.rs @@ -45,7 +45,7 @@ impl Default for MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("My External Eventloop Application"); ui.horizontal(|ui| { diff --git a/examples/external_eventloop_async/src/app.rs b/examples/external_eventloop_async/src/app.rs index 07f4d4a98c53..d0e62e3cc7dd 100644 --- a/examples/external_eventloop_async/src/app.rs +++ b/examples/external_eventloop_async/src/app.rs @@ -81,7 +81,7 @@ impl Default for MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("My External Eventloop Application"); ui.horizontal(|ui| { diff --git a/examples/file_dialog/src/main.rs b/examples/file_dialog/src/main.rs index a2cef84e7b85..914bd3423851 100644 --- a/examples/file_dialog/src/main.rs +++ b/examples/file_dialog/src/main.rs @@ -26,7 +26,7 @@ struct MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.label("Drag-and-drop files onto the window!"); if ui.button("Open file…").clicked() diff --git a/examples/font_variations/Cargo.toml b/examples/font_variations/Cargo.toml new file mode 100644 index 000000000000..789625f06d6f --- /dev/null +++ b/examples/font_variations/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "font_variations" +version = "0.1.0" +authors = ["Emil Ernerfeldt "] +license = "MIT OR Apache-2.0" +edition = "2024" +rust-version = "1.92" +publish = false + +[lints] +workspace = true + + +[dependencies] +eframe = { workspace = true, features = [ + "default", + "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO +] } +env_logger = { workspace = true, features = ["auto-color", "humantime"] } diff --git a/examples/font_variations/data/Recursive-VariableFont.ttf b/examples/font_variations/data/Recursive-VariableFont.ttf new file mode 100644 index 000000000000..367e2df5ad56 Binary files /dev/null and b/examples/font_variations/data/Recursive-VariableFont.ttf differ diff --git a/examples/font_variations/src/main.rs b/examples/font_variations/src/main.rs new file mode 100644 index 000000000000..1fb341999ef1 --- /dev/null +++ b/examples/font_variations/src/main.rs @@ -0,0 +1,129 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +#![expect(rustdoc::missing_crate_level_docs)] // it's an example + +use eframe::egui; +use eframe::epaint::text::{FontInsert, InsertFontFamily}; + +fn main() -> eframe::Result { + env_logger::init(); + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([600.0, 500.0]), + ..Default::default() + }; + eframe::run_native( + "egui example: font variations", + options, + Box::new(|cc| Ok(Box::new(MyApp::new(cc)))), + ) +} + +struct MyApp { + /// Weight axis (wght): 300..1000 + weight: f32, + + /// Casual axis (CASL): 0..1 + casual: f32, + + /// Monospace axis (MONO): 0..1 + mono: f32, + + /// Slant axis (slnt): -15..0 + slant: f32, + + /// Cursive axis (CRSV): 0..1 + cursive: f32, + + preview_text: String, + font_size: f32, +} + +impl MyApp { + fn new(cc: &eframe::CreationContext<'_>) -> Self { + cc.egui_ctx.add_font(FontInsert::new( + "Recursive", + egui::FontData::from_static({ + #[expect(clippy::large_include_file, reason = "intentional for the example")] + { + include_bytes!("../data/Recursive-VariableFont.ttf") + } + }), + vec![ + InsertFontFamily { + family: egui::FontFamily::Proportional, + priority: egui::epaint::text::FontPriority::Highest, + }, + InsertFontFamily { + family: egui::FontFamily::Monospace, + priority: egui::epaint::text::FontPriority::Highest, + }, + ], + )); + + Self { + weight: 400.0, + casual: 0.0, + mono: 0.0, + slant: 0.0, + cursive: 0.5, + preview_text: "The quick brown fox jumps over the lazy dog.\n\ + ABCDEFGHIJKLMNOPQRSTUVWXYZ\n\ + abcdefghijklmnopqrstuvwxyz\n\ + 0123456789 !@#$%^&*()" + .to_owned(), + font_size: 24.0, + } + } +} + +impl eframe::App for MyApp { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ui, |ui| { + ui.heading("Font Variations (Recursive)"); + ui.add_space(4.0); + + egui::Grid::new("variation_sliders") + .num_columns(2) + .spacing([16.0, 8.0]) + .show(ui, |ui| { + ui.label("Weight (wght):"); + ui.add(egui::Slider::new(&mut self.weight, 300.0..=1000.0)); + ui.end_row(); + + ui.label("Casual (CASL):"); + ui.add(egui::Slider::new(&mut self.casual, 0.0..=1.0)); + ui.end_row(); + + ui.label("Monospace (MONO):"); + ui.add(egui::Slider::new(&mut self.mono, 0.0..=1.0)); + ui.end_row(); + + ui.label("Slant (slnt):"); + ui.add(egui::Slider::new(&mut self.slant, -15.0..=0.0)); + ui.end_row(); + + ui.label("Cursive (CRSV):"); + ui.add(egui::Slider::new(&mut self.cursive, 0.0..=1.0)); + ui.end_row(); + + ui.label("Font size:"); + ui.add(egui::Slider::new(&mut self.font_size, 8.0..=80.0)); + ui.end_row(); + }); + + ui.separator(); + + let rich = egui::RichText::new(&self.preview_text) + .size(self.font_size) + .variation("wght", self.weight) + .variation("CASL", self.casual) + .variation("MONO", self.mono) + .variation("slnt", self.slant) + .variation("CRSV", self.cursive); + + ui.label(rich); + + ui.add_space(8.0); + ui.text_edit_multiline(&mut self.preview_text); + }); + } +} diff --git a/examples/hello_android/src/lib.rs b/examples/hello_android/src/lib.rs index 042e6eda6516..f8f4260094b0 100644 --- a/examples/hello_android/src/lib.rs +++ b/examples/hello_android/src/lib.rs @@ -41,11 +41,11 @@ impl eframe::App for MyApp { // TODO(lucasmerlin): This is a pretty big hack, should be fixed once safe_area implemented // for android: // https://github.com/rust-windowing/winit/issues/3910 - egui::Panel::top("status_bar_space").show_inside(ui, |ui| { + egui::Panel::top("status_bar_space").show(ui, |ui| { ui.set_height(32.0); }); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { self.demo.ui(ui); }); } diff --git a/examples/hello_world/src/main.rs b/examples/hello_world/src/main.rs index 55f4d78216a9..6ffb8968e39c 100644 --- a/examples/hello_world/src/main.rs +++ b/examples/hello_world/src/main.rs @@ -37,7 +37,7 @@ impl Default for MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("My egui Application"); ui.horizontal(|ui| { let name_label = ui.label("Your name: "); diff --git a/examples/hello_world_simple/src/main.rs b/examples/hello_world_simple/src/main.rs index cbdfcf1f1e37..e3660d87bd01 100644 --- a/examples/hello_world_simple/src/main.rs +++ b/examples/hello_world_simple/src/main.rs @@ -16,7 +16,7 @@ fn main() -> eframe::Result { let mut age = 42; eframe::run_ui_native("My egui App", options, move |ui, _frame| { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("My egui Application"); ui.horizontal(|ui| { let name_label = ui.label("Your name: "); diff --git a/examples/images/src/main.rs b/examples/images/src/main.rs index 8259ccdc70bc..2e3df560466b 100644 --- a/examples/images/src/main.rs +++ b/examples/images/src/main.rs @@ -25,7 +25,7 @@ struct MyApp {} impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { egui::ScrollArea::both().show(ui, |ui| { ui.image(egui::include_image!("cat.webp")) .on_hover_text_at_pointer("WebP"); diff --git a/examples/keyboard_events/src/main.rs b/examples/keyboard_events/src/main.rs index a008ac20d635..42c06c390272 100644 --- a/examples/keyboard_events/src/main.rs +++ b/examples/keyboard_events/src/main.rs @@ -21,7 +21,7 @@ struct Content { impl eframe::App for Content { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("Press/Hold/Release example. Press A to test."); if ui.button("Clear").clicked() { self.text.clear(); diff --git a/examples/multiple_viewports/src/main.rs b/examples/multiple_viewports/src/main.rs index 253e81b0e417..b75d3c0167a6 100644 --- a/examples/multiple_viewports/src/main.rs +++ b/examples/multiple_viewports/src/main.rs @@ -36,7 +36,7 @@ struct MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.label("Hello from the root viewport"); ui.checkbox( @@ -72,7 +72,7 @@ impl eframe::App for MyApp { "This viewport is embedded in the parent window, and cannot be moved outside of it.", ); } else { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.label("Hello from immediate viewport"); if ui.input(|i| i.viewport().close_requested()) { @@ -98,7 +98,7 @@ impl eframe::App for MyApp { "This viewport is embedded in the parent window, and cannot be moved outside of it.", ); } else { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.label("Hello from deferred viewport"); if ui.input(|i| i.viewport().close_requested()) { diff --git a/examples/popups/src/main.rs b/examples/popups/src/main.rs index 16e0cf049acd..57516503e90f 100644 --- a/examples/popups/src/main.rs +++ b/examples/popups/src/main.rs @@ -19,7 +19,7 @@ struct MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut eframe::egui::Ui, _frame: &mut eframe::Frame) { - CentralPanel::default().show_inside(ui, |ui| { + CentralPanel::default().show(ui, |ui| { ui.label("PopupCloseBehavior::CloseOnClick popup"); ComboBox::from_label("ComboBox") .selected_text(format!("{}", self.number)) diff --git a/examples/puffin_profiler/src/main.rs b/examples/puffin_profiler/src/main.rs index 3b7ebddb19ad..89118e8c7d29 100644 --- a/examples/puffin_profiler/src/main.rs +++ b/examples/puffin_profiler/src/main.rs @@ -55,7 +55,7 @@ impl Default for MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("Example of how to use the puffin profiler with egui"); ui.separator(); @@ -116,7 +116,7 @@ impl eframe::App for MyApp { "This egui backend doesn't support multiple viewports" ); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.label("Hello from immediate viewport"); }); @@ -143,7 +143,7 @@ impl eframe::App for MyApp { "This egui backend doesn't support multiple viewports" ); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.label("Hello from deferred viewport"); }); if ui.input(|i| i.viewport().close_requested()) { diff --git a/examples/screenshot/src/main.rs b/examples/screenshot/src/main.rs index 6cb285157780..d807e5dde66c 100644 --- a/examples/screenshot/src/main.rs +++ b/examples/screenshot/src/main.rs @@ -28,7 +28,7 @@ struct MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { if let Some(screenshot) = self.screenshot.take() { self.texture = Some(ui.ctx().load_texture( "screenshot", diff --git a/examples/serial_windows/src/main.rs b/examples/serial_windows/src/main.rs index 9959f9b0130c..8ef07dc9cb9e 100644 --- a/examples/serial_windows/src/main.rs +++ b/examples/serial_windows/src/main.rs @@ -44,7 +44,7 @@ struct MyApp { impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { let label_text = if self.has_next { "When this window is closed the next will be opened after a short delay" } else { diff --git a/examples/user_attention/src/main.rs b/examples/user_attention/src/main.rs index c26dd903ce65..46d8fbb991f0 100644 --- a/examples/user_attention/src/main.rs +++ b/examples/user_attention/src/main.rs @@ -76,7 +76,7 @@ impl eframe::App for Application { )); } - CentralPanel::default().show_inside(ui, |ui| { + CentralPanel::default().show(ui, |ui| { ui.vertical(|ui| { ui.horizontal(|ui| { ui.label("Attention type:"); diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py index e54b18431b4d..d2cf72bc1d09 100755 --- a/scripts/generate_changelog.py +++ b/scripts/generate_changelog.py @@ -6,21 +6,22 @@ The result can be copy-pasted into CHANGELOG.md, though it often needs some manual editing too. -Setup: pip install GitPython requests tqdm +Setup: pip install GitPython tqdm +Also requires the `gh` CLI (https://cli.github.com/) authenticated via `gh auth login`. """ import argparse +import json import multiprocessing import os import re -import sys +import subprocess from collections import defaultdict from datetime import date from dataclasses import dataclass from typing import Any, List, Optional -import requests from git import Repo # pip install GitPython from tqdm import tqdm @@ -44,29 +45,6 @@ class CommitInfo: pr_number: Optional[int] -def get_github_token() -> str: - import os - - token = os.environ.get("GH_ACCESS_TOKEN", "") - if token != "": - return token - - home_dir = os.path.expanduser("~") - token_file = os.path.join(home_dir, ".githubtoken") - - try: - with open(token_file, "r") as f: - token = f.read().strip() - return token - except Exception: - pass - - print( - "ERROR: expected a GitHub token in the environment variable GH_ACCESS_TOKEN or in ~/.githubtoken" - ) - sys.exit(1) - - # Slow def fetch_pr_info_from_commit_info(commit_info: CommitInfo) -> Optional[PrInfo]: if commit_info.pr_number is None: @@ -77,26 +55,35 @@ def fetch_pr_info_from_commit_info(commit_info: CommitInfo) -> Optional[PrInfo]: # Slow def fetch_pr_info(pr_number: int) -> Optional[PrInfo]: - url = f"https://api.github.com/repos/{OWNER}/{REPO}/pulls/{pr_number}" - gh_access_token = get_github_token() - headers = {"Authorization": f"Token {gh_access_token}"} - response = requests.get(url, headers=headers) - json = response.json() - - # Check if the request was successful (status code 200) - if response.status_code == 200: - labels = [label["name"] for label in json["labels"]] - gh_user_name = json["user"]["login"] - return PrInfo( - pr_number=pr_number, - gh_user_name=gh_user_name, - title=json["title"], - labels=labels, - ) - else: - print(f"ERROR {url}: {response.status_code} - {json['message']}") + result = subprocess.run( + [ + "gh", + "pr", + "view", + str(pr_number), + "--repo", + f"{OWNER}/{REPO}", + "--json", + "number,title,labels,author", + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"ERROR fetching PR #{pr_number}: {result.stderr.strip()}") return None + data = json.loads(result.stdout) + labels = [label["name"] for label in data["labels"]] + gh_user_name = data["author"]["login"] + return PrInfo( + pr_number=pr_number, + gh_user_name=gh_user_name, + title=data["title"], + labels=labels, + ) + def get_commit_info(commit: Any) -> CommitInfo: match = re.match(r"(.*) \(#(\d+)\)", commit.summary) diff --git a/scripts/lint.py b/scripts/lint.py index 4939d735f7ef..0c1319e8a08b 100755 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -72,6 +72,16 @@ def lint_lines(filepath, lines_in): f"{filepath}:{line_nr}: write 'TODO(username):' instead" ) + if re.search(r"\.zip\(", line): + errors.append( + f"{filepath}:{line_nr}: use `std::iter::zip` or `itertools::izip!` instead of `.zip(`" + ) + + if re.search(r"\.chain\(", line): + errors.append( + f"{filepath}:{line_nr}: use `std::iter::chain` or `itertools::chain!` instead of `.chain(`" + ) + if ( "(target_os" in line and filepath.startswith("./crates/egui/") @@ -105,6 +115,10 @@ def test_lint(): self } """, + "for (a, b) in std::iter::zip(xs, ys) {}", + "for (a, b, c) in itertools::izip!(xs, ys, zs) {}", + "for x in std::iter::chain(xs, ys) {}", + "for x in itertools::chain!(xs, ys, zs) {}", ] should_fail = [ @@ -121,6 +135,8 @@ def test_lint(): self } """, + "for (a, b) in xs.iter().zip(ys) {}", + "for x in xs.iter().chain(ys) {}", ] for code in should_pass: diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png b/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png new file mode 100644 index 000000000000..cb3b393e660e --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eba6e690937fbbd22c8edfce14078f50998e968324d8073d5db5829493d957e0 +size 5292 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png b/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png new file mode 100644 index 000000000000..06679d16d15b --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525 +size 65797 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png b/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png new file mode 100644 index 000000000000..06679d16d15b --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525 +size 65797 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/inside_closed.png b/tests/egui_tests/tests/snapshots/panel_drag/inside_closed.png new file mode 100644 index 000000000000..0e2c2cfbddac --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/inside_closed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcf9871c19199c94cfbc83818b4f70fe9fab5c396a3fb32cb79b713e9145bcae +size 3001 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png b/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png new file mode 100644 index 000000000000..8a9963284263 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf09470a6628e62421bfa754ce238dd13820ba0bf8146ae835e539874d39a150 +size 4882 diff --git a/tests/egui_tests/tests/test_panel_drag.rs b/tests/egui_tests/tests/test_panel_drag.rs new file mode 100644 index 000000000000..7068ba49e2dc --- /dev/null +++ b/tests/egui_tests/tests/test_panel_drag.rs @@ -0,0 +1,157 @@ +//! Snapshot tests for `Panel`'s drag-to-close and drag-to-expand gestures. +//! +//! Covers: +//! * [`Panel::show_collapsible`] β€” drag-to-close on a `Left` panel. +//! * [`Panel::show_switched`] β€” drag-to-close on the expanded panel +//! followed by drag-to-expand on the collapsed panel, both via the shared +//! resize handle. + +use egui::{Panel, Pos2, Vec2}; +use egui_kittest::{Harness, SnapshotResults}; + +/// Pure-data state for the kittest UI closure. +#[derive(Default)] +struct State { + is_expanded: bool, +} + +#[test] +fn drag_to_close_animated_inside() { + let mut results = SnapshotResults::new(); + + let mut harness = Harness::builder() + .with_size(Vec2::new(400.0, 200.0)) + .build_ui_state( + |ui, state: &mut State| { + Panel::left("test_left_panel") + .resizable(true) + .default_size(120.0) + .min_size(60.0) + .show_collapsible(ui, &mut state.is_expanded, |ui| { + ui.label("Left panel content"); + }); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Central"); + }); + }, + State { is_expanded: true }, + ); + + harness.run(); + assert!(harness.state().is_expanded, "should start expanded"); + results.add(harness.try_snapshot("panel_drag/inside_initial")); + + // Query the actual resize edge from PanelState (avoids assumptions about + // Frame margins and the harness's ui padding). + let panel_state = egui::PanelState::load(&harness.ctx, egui::Id::new("test_left_panel")) + .expect("PanelState should be persisted after the first frame"); + let resize_x = panel_state.outer_rect.right(); + let resize_y = panel_state.outer_rect.center().y; + + let drag_start = Pos2::new(resize_x, resize_y); + let drag_end = Pos2::new(resize_x - 200.0, resize_y); + + harness.drag_at(drag_start); + harness.run(); + harness.hover_at(drag_end); + harness.run(); + harness.drop_at(drag_end); + harness.run(); + + assert!( + !harness.state().is_expanded, + "drag past min_size should have closed the panel" + ); + results.add(harness.try_snapshot("panel_drag/inside_closed")); +} + +#[test] +fn drag_to_close_and_reopen_animated_between() { + let mut results = SnapshotResults::new(); + + let panel_size = 400.0_f32; + let expanded_size = 120.0_f32; + let collapsed_size = 28.0_f32; + + let mut harness = Harness::builder() + .with_size(Vec2::new(panel_size, 300.0)) + .build_ui_state( + |ui, state: &mut State| { + let collapsed = Panel::bottom("between_collapsed") + .resizable(true) + .exact_size(collapsed_size); + let expanded = Panel::bottom("between_expanded") + .resizable(true) + .default_size(expanded_size); + Panel::show_switched( + ui, + &mut state.is_expanded, + collapsed, + expanded, + |ui, expanded| { + if expanded { + ui.heading("Expanded panel"); + ui.separator(); + for i in 0..6 { + ui.label(format!( + "Row {i}: filler content so the \ + expanded panel is clearly taller than the \ + collapsed one in the snapshot." + )); + } + } else { + ui.label("Collapsed"); + } + }, + ); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Central"); + }); + }, + State { is_expanded: true }, + ); + + harness.run(); + assert!(harness.state().is_expanded, "should start expanded"); + results.add(harness.try_snapshot("panel_drag/between_initial_expanded")); + + // Drag-to-close: grab the top edge of the expanded bottom panel and drag + // it down past the panel's minimum height to collapse. + let expanded_state = egui::PanelState::load(&harness.ctx, egui::Id::new("between_expanded")) + .expect("expanded PanelState should be persisted"); + let expanded_resize_y = expanded_state.outer_rect.top(); + let drag_x = expanded_state.outer_rect.center().x; + let bottom_y = expanded_state.outer_rect.bottom(); + + harness.drag_at(Pos2::new(drag_x, expanded_resize_y)); + harness.run(); + harness.hover_at(Pos2::new(drag_x, bottom_y - 1.0)); + harness.run(); + harness.drop_at(Pos2::new(drag_x, bottom_y - 1.0)); + harness.run(); + + assert!( + !harness.state().is_expanded, + "drag past min should have closed the expanded panel" + ); + results.add(harness.try_snapshot("panel_drag/between_collapsed")); + + // Drag-to-expand: grab the top edge of the (now visible) collapsed panel + // and drag it upward past the collapsed panel's exact_size cap. + let collapsed_state = egui::PanelState::load(&harness.ctx, egui::Id::new("between_collapsed")) + .expect("collapsed PanelState should be persisted"); + let collapsed_resize_y = collapsed_state.outer_rect.top(); + + harness.drag_at(Pos2::new(drag_x, collapsed_resize_y)); + harness.run(); + harness.hover_at(Pos2::new(drag_x, collapsed_resize_y - 200.0)); + harness.run(); + harness.drop_at(Pos2::new(drag_x, collapsed_resize_y - 200.0)); + harness.run(); + + assert!( + harness.state().is_expanded, + "drag past collapsed exact_size should have reopened the panel" + ); + results.add(harness.try_snapshot("panel_drag/between_reopened")); +} diff --git a/tests/egui_tests/tests/test_window_drag.rs b/tests/egui_tests/tests/test_window_drag.rs new file mode 100644 index 000000000000..979f992c2f59 --- /dev/null +++ b/tests/egui_tests/tests/test_window_drag.rs @@ -0,0 +1,195 @@ +//! Tests for [`Window::drag_area`] and [`Window::movable`]. +//! +//! Each test sets up a window with a particular drag configuration, drags +//! either inside or outside the title bar, and asserts on the area rect's +//! delta. `WindowDrag::OnTouch` is not exercised here since it just resolves +//! to `TitleBar` (no touch screen in headless tests). + +use egui::{Id, Pos2, Sense, Vec2, Window, WindowDrag}; +use egui_kittest::Harness; + +struct State { + drag_area: WindowDrag, + movable: bool, +} + +fn build(state: State) -> Harness<'static, State> { + let mut harness = Harness::builder() + .with_size(Vec2::new(500.0, 400.0)) + .with_max_steps(40) // Area requests a repaint every frame while pressed. + .build_ui_state( + move |ui, state: &mut State| { + Window::new("test_win") + .id(Id::new("test_win")) + .drag_area(state.drag_area) + .movable(state.movable) + .default_pos([100.0, 80.0]) + .default_size([180.0, 140.0]) + .show(ui.ctx(), |ui| { + // A passive widget fills the body; it has no drag sense + // of its own, so the Area / title-bar widget is what + // decides whether a drag moves the window. + ui.allocate_response(ui.available_size(), Sense::hover()); + }); + }, + state, + ); + // Let the window settle (auto-position / size, then idle). + harness.run_steps(4); + harness +} + +fn window_rect(harness: &Harness<'_, State>) -> egui::Rect { + egui::AreaState::load(&harness.ctx, Id::new("test_win")) + .expect("window area should be persisted after the first frame") + .rect() +} + +/// Drag the pointer from `from` to `to` over multiple frames; release at the end. +fn drag(harness: &mut Harness<'_, State>, from: Pos2, to: Pos2) { + harness.drag_at(from); + harness.run_steps(4); + harness.hover_at(to); + harness.run_steps(4); + harness.drop_at(to); + harness.run_steps(4); +} + +fn titlebar_pos(rect: egui::Rect) -> Pos2 { + // Just inside the title bar: + Pos2::new(rect.center().x, rect.top() + 8.0) +} + +fn body_pos(rect: egui::Rect) -> Pos2 { + // Well below the title bar: + Pos2::new(rect.center().x, rect.bottom() - 30.0) +} + +#[test] +fn title_bar_drag_on_titlebar_moves_window() { + let mut harness = build(State { + drag_area: WindowDrag::TitleBar, + movable: true, + }); + + let before = window_rect(&harness); + let from = titlebar_pos(before); + let to = from + Vec2::new(60.0, 40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + 20.0 < moved.x && 20.0 < moved.y, + "TitleBar + drag on titlebar should move the window (delta = {moved:?})" + ); +} + +#[test] +fn title_bar_drag_outside_titlebar_keeps_window_put() { + let mut harness = build(State { + drag_area: WindowDrag::TitleBar, + movable: true, + }); + + let before = window_rect(&harness); + let from = body_pos(before); + let to = from + Vec2::new(60.0, -40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + moved.length() < 1.0, + "TitleBar + drag in the body should NOT move the window (delta = {moved:?})" + ); +} + +#[test] +fn anywhere_drag_in_body_moves_window() { + let mut harness = build(State { + drag_area: WindowDrag::Anywhere, + movable: true, + }); + + let before = window_rect(&harness); + let from = body_pos(before); + let to = from + Vec2::new(60.0, -40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + 20.0 < moved.x && moved.y < -20.0, + "Anywhere + drag anywhere should move the window (delta = {moved:?})" + ); +} + +#[test] +fn movable_false_keeps_window_put_even_on_titlebar() { + // Regression: a `movable(false)` window used to still move when the user + // dragged the title bar in `TitleBar` mode. + let mut harness = build(State { + drag_area: WindowDrag::TitleBar, + movable: false, + }); + + let before = window_rect(&harness); + let from = titlebar_pos(before); + let to = from + Vec2::new(60.0, 40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + moved.length() < 1.0, + "TitleBar + movable(false) should NOT move the window (delta = {moved:?})" + ); +} + +#[test] +fn off_keeps_window_put_on_body_drag() { + // `WindowDrag::Off` should freeze the window regardless of `movable`. + let mut harness = build(State { + drag_area: WindowDrag::Off, + movable: true, + }); + + let before = window_rect(&harness); + let from = body_pos(before); + let to = from + Vec2::new(60.0, -40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + moved.length() < 1.0, + "Off + drag in the body should NOT move the window (delta = {moved:?})" + ); +} + +#[test] +fn off_keeps_window_put_on_titlebar_drag() { + let mut harness = build(State { + drag_area: WindowDrag::Off, + movable: true, + }); + + let before = window_rect(&harness); + let from = titlebar_pos(before); + let to = from + Vec2::new(60.0, 40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + moved.length() < 1.0, + "Off + drag on titlebar should NOT move the window (delta = {moved:?})" + ); +} diff --git a/tests/test_inline_glow_paint/src/main.rs b/tests/test_inline_glow_paint/src/main.rs index fd4abc35fcbc..d23288706fdd 100644 --- a/tests/test_inline_glow_paint/src/main.rs +++ b/tests/test_inline_glow_paint/src/main.rs @@ -30,7 +30,7 @@ struct MyTestApp {} impl eframe::App for MyTestApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - egui::Panel::top("top").show_inside(ui, |ui| { + egui::Panel::top("top").show(ui, |ui| { ui.label("This is a test of painting directly with glow."); }); diff --git a/tests/test_size_pass/src/main.rs b/tests/test_size_pass/src/main.rs index 814def92b291..9003aed5e8bf 100644 --- a/tests/test_size_pass/src/main.rs +++ b/tests/test_size_pass/src/main.rs @@ -9,7 +9,7 @@ fn main() -> eframe::Result { let options = eframe::NativeOptions::default(); eframe::run_ui_native("My egui App", options, move |ui, _frame| { // A bottom panel to force the tooltips to consider if the fit below or under the widget: - egui::Panel::bottom("bottom").show_inside(ui, |ui| { + egui::Panel::bottom("bottom").show(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label("Single tooltips:"); @@ -33,7 +33,7 @@ fn main() -> eframe::Result { }); }); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.horizontal(|ui| { if ui.button("Reset egui memory").clicked() { ui.memory_mut(|mem| *mem = Default::default()); diff --git a/tests/test_ui_stack/src/main.rs b/tests/test_ui_stack/src/main.rs index a2ce967b832f..93b1c0c5af1d 100644 --- a/tests/test_ui_stack/src/main.rs +++ b/tests/test_ui_stack/src/main.rs @@ -36,7 +36,7 @@ impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { ui.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0); - egui::Panel::left("side_panel_left").show_inside(ui, |ui| { + egui::Panel::left("side_panel_left").show(ui, |ui| { ui.heading("Information"); ui.label( "This is a demo/test environment of the `UiStack` feature. The tables display \ @@ -84,7 +84,7 @@ impl eframe::App for MyApp { }); }); - egui::Panel::right("side_panel_right").show_inside(ui, |ui| { + egui::Panel::right("side_panel_right").show(ui, |ui| { egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| { stack_ui(ui); @@ -94,7 +94,7 @@ impl eframe::App for MyApp { }); }); - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| { ui.label("stack here:"); stack_ui(ui); @@ -174,7 +174,7 @@ impl eframe::App for MyApp { egui::Panel::bottom("bottom_panel") .resizable(true) - .show_inside(ui, |ui| { + .show(ui, |ui| { egui::ScrollArea::vertical() .auto_shrink(false) .show(ui, |ui| { diff --git a/tests/test_viewports/src/main.rs b/tests/test_viewports/src/main.rs index 25787c912323..62d357fa8342 100644 --- a/tests/test_viewports/src/main.rs +++ b/tests/test_viewports/src/main.rs @@ -154,7 +154,7 @@ impl Default for App { impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show_inside(ui, |ui| { + egui::CentralPanel::default().show(ui, |ui| { ui.heading("Root viewport"); { let mut embed_viewports = ui.embed_viewports(); @@ -182,7 +182,7 @@ fn show_as_popup( // Not a real viewport - already has a frame content(ui); } else { - egui::CentralPanel::default().show_inside(ui, content); + egui::CentralPanel::default().show(ui, content); } } @@ -334,7 +334,7 @@ fn drag_and_drop_test(ui: &mut egui::Ui) { assert!(col < COLS, "The coll should be less than: {COLS}"); let value: String = value.into(); - let id = Id::new(format!("%{}% {}", self.counter, &value)); + let id = Id::new(format!("%{}% {}", self.counter, value)); self.data.insert(id, value); let viewport_data = self.containers_data.entry(container).or_insert_with(|| { let mut res = Vec::new();