diff --git a/.github/workflows/cargo_machete.yml b/.github/workflows/cargo_machete.yml deleted file mode 100644 index 1dc162e56..000000000 --- a/.github/workflows/cargo_machete.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Cargo Machete - -on: [push, pull_request] - -jobs: - cargo-machete: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: 1.92 - - name: Machete install - ## The official cargo-machete action - uses: bnjbvr/cargo-machete@v0.9.1 - - name: Checkout - uses: actions/checkout@v4 - - name: Machete Check - run: cargo machete diff --git a/.github/workflows/cargo_shear.yml b/.github/workflows/cargo_shear.yml new file mode 100644 index 000000000..734abacb5 --- /dev/null +++ b/.github/workflows/cargo_shear.yml @@ -0,0 +1,25 @@ +# Looks for unused crates. +name: Cargo Shear + +on: + push: + branches: + - "main" + pull_request: + types: [opened, synchronize] + +jobs: + cargo-shear: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Cargo Shear + uses: taiki-e/install-action@v2.48.7 + with: + tool: cargo-shear@1.11.2 + + - name: Run Cargo Shear + run: | + cargo shear diff --git a/CHANGELOG.md b/CHANGELOG.md index e531dddc9..4bae7a734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.34.2 - 2026-05-04 +### ⭐ Added +* Add regression test for O(nΒ²) word boundary scan [#8077](https://github.com/emilk/egui/pull/8077) by [@hallyhaa](https://github.com/hallyhaa) + +### πŸ› Fixed +* Fix wrong color of last glyph of selected text [#8075](https://github.com/emilk/egui/pull/8075) by [@emilk](https://github.com/emilk) +* Fix text selection of centered and right-aligned text [#8076](https://github.com/emilk/egui/pull/8076) by [@emilk](https://github.com/emilk) +* Fix `Context::is_pointer_over_egui` and `Context::egui_wants_pointer_input` [#8081](https://github.com/emilk/egui/pull/8081) by [@emilk](https://github.com/emilk) +* Fix centered & right aligned `TextEdit` [#8082](https://github.com/emilk/egui/pull/8082) by [@lucasmerlin](https://github.com/lucasmerlin) + +### πŸš€ Performance +* Optimize text selection performance for large documents [#7917](https://github.com/emilk/egui/pull/7917) by [@rustbasic](https://github.com/rustbasic) + + ## 0.34.1 - 2026-03-27 Nothing new diff --git a/Cargo.lock b/Cargo.lock index 6efed68d0..94bfe892a 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", @@ -1186,7 +1197,7 @@ checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" [[package]] name = "ecolor" -version = "0.34.1" +version = "0.34.2" dependencies = [ "bytemuck", "cint", @@ -1198,7 +1209,7 @@ dependencies = [ [[package]] name = "eframe" -version = "0.34.1" +version = "0.34.2" dependencies = [ "ahash", "bytemuck", @@ -1237,7 +1248,7 @@ dependencies = [ [[package]] name = "egui" -version = "0.34.1" +version = "0.34.2" dependencies = [ "accesskit", "ahash", @@ -1246,6 +1257,7 @@ dependencies = [ "document-features", "emath", "epaint", + "itertools 0.14.0", "log", "nohash-hasher", "profiling", @@ -1257,7 +1269,7 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.34.1" +version = "0.34.2" dependencies = [ "ahash", "bytemuck", @@ -1275,7 +1287,7 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.34.1" +version = "0.34.2" dependencies = [ "accesskit_winit", "arboard", @@ -1298,7 +1310,7 @@ dependencies = [ [[package]] name = "egui_demo_app" -version = "0.34.1" +version = "0.34.2" dependencies = [ "accesskit", "accesskit_consumer", @@ -1327,7 +1339,7 @@ dependencies = [ [[package]] name = "egui_demo_lib" -version = "0.34.1" +version = "0.34.2" dependencies = [ "criterion", "document-features", @@ -1344,7 +1356,7 @@ dependencies = [ [[package]] name = "egui_extras" -version = "0.34.1" +version = "0.34.2" dependencies = [ "ahash", "document-features", @@ -1352,6 +1364,7 @@ dependencies = [ "ehttp", "enum-map", "image", + "itertools 0.14.0", "jiff", "log", "mime_guess2", @@ -1363,7 +1376,7 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.34.1" +version = "0.34.2" dependencies = [ "bytemuck", "document-features", @@ -1375,14 +1388,12 @@ dependencies = [ "log", "memoffset", "profiling", - "wasm-bindgen", - "web-sys", "winit", ] [[package]] name = "egui_kittest" -version = "0.34.1" +version = "0.34.2" dependencies = [ "dify", "document-features", @@ -1402,7 +1413,7 @@ dependencies = [ [[package]] name = "egui_tests" -version = "0.34.1" +version = "0.34.2" dependencies = [ "egui", "egui_extras", @@ -1432,7 +1443,7 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "emath" -version = "0.34.1" +version = "0.34.2" dependencies = [ "bytemuck", "document-features", @@ -1530,7 +1541,7 @@ dependencies = [ [[package]] name = "epaint" -version = "0.34.1" +version = "0.34.2" dependencies = [ "ahash", "bytemuck", @@ -1559,7 +1570,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.34.1" +version = "0.34.2" [[package]] name = "equivalent" @@ -1716,14 +1727,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" @@ -1906,6 +1925,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" @@ -2040,13 +2075,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", ] @@ -2071,6 +2105,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" @@ -2340,18 +2383,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", ] @@ -2519,12 +2562,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", ] @@ -2630,9 +2674,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" @@ -3223,6 +3267,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" @@ -3260,13 +3314,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", ] @@ -3480,9 +3534,18 @@ 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.1" +version = "0.34.2" dependencies = [ "eframe", "env_logger", @@ -3553,9 +3616,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", @@ -3563,9 +3626,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", @@ -3573,26 +3636,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", @@ -3749,12 +3811,11 @@ 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", ] @@ -3961,9 +4022,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "log", "once_cell", @@ -3985,9 +4046,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -4219,9 +4280,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", @@ -4927,42 +4988,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", ] @@ -5393,6 +5443,22 @@ dependencies = [ "web-sys", ] +[[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" @@ -5402,6 +5468,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" @@ -5821,7 +5893,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.34.1" +version = "0.34.2" [[package]] name = "yaml-rust" diff --git a/Cargo.toml b/Cargo.toml index 4559152a6..3710f8cb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ members = [ edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.92" -version = "0.34.1" +version = "0.34.2" [profile.release] @@ -55,18 +55,18 @@ opt-level = 2 [workspace.dependencies] -emath = { version = "0.34.1", path = "crates/emath", default-features = false } -ecolor = { version = "0.34.1", path = "crates/ecolor", default-features = false } -epaint = { version = "0.34.1", path = "crates/epaint", default-features = false } -epaint_default_fonts = { version = "0.34.1", path = "crates/epaint_default_fonts" } -egui = { version = "0.34.1", path = "crates/egui", default-features = false } -egui-winit = { version = "0.34.1", path = "crates/egui-winit", default-features = false } -egui_extras = { version = "0.34.1", path = "crates/egui_extras", default-features = false } -egui-wgpu = { version = "0.34.1", path = "crates/egui-wgpu", default-features = false } -egui_demo_lib = { version = "0.34.1", path = "crates/egui_demo_lib", default-features = false } -egui_glow = { version = "0.34.1", path = "crates/egui_glow", default-features = false } -egui_kittest = { version = "0.34.1", path = "crates/egui_kittest", default-features = false } -eframe = { version = "0.34.1", path = "crates/eframe", default-features = false } +emath = { version = "0.34.2", path = "crates/emath", default-features = false } +ecolor = { version = "0.34.2", path = "crates/ecolor", default-features = false } +epaint = { version = "0.34.2", path = "crates/epaint", default-features = false } +epaint_default_fonts = { version = "0.34.2", path = "crates/epaint_default_fonts" } +egui = { version = "0.34.2", path = "crates/egui", default-features = false } +egui-winit = { version = "0.34.2", path = "crates/egui-winit", default-features = false } +egui_extras = { version = "0.34.2", path = "crates/egui_extras", default-features = false } +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_kittest = { version = "0.34.2", path = "crates/egui_kittest", default-features = false } +eframe = { version = "0.34.2", path = "crates/eframe", default-features = false } accesskit = "0.24.0" accesskit_consumer = "0.35.0" @@ -82,7 +82,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 +93,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,9 +115,9 @@ 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" @@ -126,7 +127,7 @@ 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 +140,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/CHANGELOG.md b/crates/ecolor/CHANGELOG.md index fe0333748..1e10de83b 100644 --- a/crates/ecolor/CHANGELOG.md +++ b/crates/ecolor/CHANGELOG.md @@ -6,6 +6,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.34.2 - 2026-05-04 +Nothing new + + ## 0.34.1 - 2026-03-27 Nothing new diff --git a/crates/ecolor/src/color32.rs b/crates/ecolor/src/color32.rs index b9de323e0..f444b860d 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 3e40af2b0..98c3ce408 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/CHANGELOG.md b/crates/eframe/CHANGELOG.md index d9ba794b1..9071754f2 100644 --- a/crates/eframe/CHANGELOG.md +++ b/crates/eframe/CHANGELOG.md @@ -7,6 +7,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.34.2 - 2026-05-04 +* Document glow-only fields in `NativeOptions` [#8104](https://github.com/emilk/egui/pull/8104) by [@emilk](https://github.com/emilk) + + ## 0.34.1 - 2026-03-27 * `wgpu` backend: Enable WebGL fallback [#8038](https://github.com/emilk/egui/pull/8038) by [@emilk](https://github.com/emilk) * Only apply cursor style to the `` [#8036](https://github.com/emilk/egui/pull/8036) by [@mkeeter](https://github.com/mkeeter) diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index b9a178a1d..d6bdbd62b 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -2,7 +2,7 @@ //! //! `epi` provides interfaces for window management and serialization. //! -//! Start by looking at the [`App`] trait, and implement [`App::update`]. +//! Start by looking at the [`App`] trait, and implement [`App::ui`]. #![warn(missing_docs)] // Let's keep `epi` well-documented. @@ -83,6 +83,10 @@ pub struct CreationContext<'s> { #[cfg(feature = "wgpu_no_default_features")] pub wgpu_render_state: Option, + /// The root [`winit::window::Window`]. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) window: Option>, + /// Raw platform window handle #[cfg(not(target_arch = "wasm32"))] pub(crate) raw_window_handle: Result, @@ -125,11 +129,21 @@ impl CreationContext<'_> { #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state: None, #[cfg(not(target_arch = "wasm32"))] + window: None, + #[cfg(not(target_arch = "wasm32"))] raw_window_handle: Err(HandleError::NotSupported), #[cfg(not(target_arch = "wasm32"))] raw_display_handle: Err(HandleError::NotSupported), } } + + /// Access to the root [`winit::window::Window`]. + /// + /// `None` for headless (tests etc). + #[cfg(not(target_arch = "wasm32"))] + pub fn winit_window(&self) -> Option<&std::sync::Arc> { + self.window.as_ref() + } } // ---------------------------------------------------------------------------- @@ -161,22 +175,6 @@ pub trait App { /// (A "viewport" in egui means an native OS window). fn ui(&mut self, ui: &mut egui::Ui, frame: &mut Frame); - /// Called each time the UI needs repainting, which may be many times per second. - /// - /// Put your widgets into a [`egui::Panel`], [`egui::CentralPanel`], [`egui::Window`] or [`egui::Area`]. - /// - /// The [`egui::Context`] can be cloned and saved if you like. - /// - /// To force a repaint, call [`egui::Context::request_repaint`] at any time (e.g. from another thread). - /// - /// This is called for the root viewport ([`egui::ViewportId::ROOT`]). - /// Use [`egui::Context::show_viewport_deferred`] to spawn additional viewports (windows). - /// (A "viewport" in egui means an native OS window). - #[deprecated = "Use Self::ui instead"] - fn update(&mut self, ctx: &egui::Context, frame: &mut Frame) { - _ = (ctx, frame); - } - /// Get a handle to the app. /// /// Can be used from web to interact or other external context. @@ -256,7 +254,7 @@ pub trait App { true } - /// A hook for manipulating or filtering raw input before it is processed by [`Self::update`]. + /// A hook for manipulating or filtering raw input before it is processed by [`Self::ui`]. /// /// This function provides a way to modify or filter input events before they are processed by egui. /// @@ -275,22 +273,6 @@ pub trait App { fn raw_input_hook(&mut self, _ctx: &egui::Context, _raw_input: &mut egui::RawInput) {} } -/// Selects the level of hardware graphics acceleration. -#[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum HardwareAcceleration { - /// Require graphics acceleration. - Required, - - /// Prefer graphics acceleration, but fall back to software. - Preferred, - - /// Do NOT use graphics acceleration. - /// - /// On some platforms (macOS) this is ignored and treated the same as [`Self::Preferred`]. - Off, -} - /// Options controlling the behavior of a native window. /// /// Additional windows can be opened using (egui viewports)[`egui::viewport`]. @@ -314,11 +296,6 @@ pub struct NativeOptions { /// To avoid this, set the icon to [`egui::IconData::default`]. pub viewport: egui::ViewportBuilder, - /// Turn on vertical syncing, limiting the FPS to the display refresh rate. - /// - /// The default is `true`. - pub vsync: bool, - /// Set the level of the multisampling anti-aliasing (MSAA). /// /// Must be a power-of-two. Higher = more smooth 3D. @@ -340,11 +317,6 @@ pub struct NativeOptions { /// `egui` doesn't need the stencil buffer, so the default value is 0. pub stencil_buffer: u8, - /// Specify whether or not hardware acceleration is preferred, required, or not. - /// - /// Default: [`HardwareAcceleration::Preferred`]. - pub hardware_acceleration: HardwareAcceleration, - /// What rendering backend to use. #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub renderer: Renderer, @@ -381,13 +353,6 @@ pub struct NativeOptions { #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub window_builder: Option, - #[cfg(feature = "glow")] - /// Needed for cross compiling for VirtualBox VMSVGA driver with OpenGL ES 2.0 and OpenGL 2.1 which doesn't support SRGB texture. - /// See . - /// - /// For OpenGL ES 2.0: set this to [`egui_glow::ShaderVersion::Es100`] to solve blank texture problem (by using the "fallback shader"). - pub shader_version: Option, - /// On desktop: make the window position to be centered at initialization. /// /// Platform specific: @@ -395,6 +360,10 @@ pub struct NativeOptions { /// Wayland desktop currently not supported. pub centered: bool, + /// Configures glow instance. + #[cfg(feature = "glow")] + pub glow_options: egui_glow::GlowConfiguration, + /// Configures wgpu instance/device/adapter/surface creation and renderloop. #[cfg(feature = "wgpu_no_default_features")] pub wgpu_options: egui_wgpu::WgpuConfiguration, @@ -439,6 +408,9 @@ impl Clone for NativeOptions { #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] window_builder: None, // Skip any builder callbacks if cloning + #[cfg(feature = "glow")] + glow_options: self.glow_options.clone(), + #[cfg(feature = "wgpu_no_default_features")] wgpu_options: self.wgpu_options.clone(), @@ -458,11 +430,9 @@ impl Default for NativeOptions { Self { viewport: Default::default(), - vsync: true, multisampling: 0, depth_buffer: 0, stencil_buffer: 0, - hardware_acceleration: HardwareAcceleration::Preferred, #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] renderer: Renderer::default(), @@ -475,13 +445,14 @@ impl Default for NativeOptions { #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] window_builder: None, - #[cfg(feature = "glow")] - shader_version: None, - centered: false, + #[cfg(feature = "glow")] + glow_options: egui_glow::GlowConfiguration::default(), + #[cfg(feature = "wgpu_no_default_features")] - wgpu_options: egui_wgpu::WgpuConfiguration::default(), + wgpu_options: egui_wgpu::WgpuConfiguration::default() + .with_surface_config(egui_wgpu::SurfaceConfig::LOW_LATENCY), persist_window: true, @@ -516,6 +487,10 @@ pub struct WebOptions { #[cfg(feature = "glow")] pub webgl_context_option: WebGlContextOption, + /// Configures glow instance. + #[cfg(feature = "glow")] + pub glow_options: egui_glow::GlowConfiguration, + /// Configures wgpu instance/device/adapter/surface creation and renderloop. #[cfg(feature = "wgpu_no_default_features")] pub wgpu_options: egui_wgpu::WgpuConfiguration, @@ -560,6 +535,9 @@ impl Default for WebOptions { #[cfg(feature = "glow")] webgl_context_option: WebGlContextOption::BestFirst, + #[cfg(feature = "glow")] + glow_options: egui_glow::GlowConfiguration::default(), + #[cfg(feature = "wgpu_no_default_features")] wgpu_options: egui_wgpu::WgpuConfiguration::default(), @@ -695,6 +673,10 @@ pub struct Frame { #[doc(hidden)] pub wgpu_render_state: Option, + /// The current [`winit::window::Window`] (i.e. the one the active viewport is rendered to). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) window: Option>, + /// Raw platform window handle #[cfg(not(target_arch = "wasm32"))] pub(crate) raw_window_handle: Result, @@ -740,6 +722,8 @@ impl Frame { raw_display_handle: Err(HandleError::NotSupported), #[cfg(not(target_arch = "wasm32"))] raw_window_handle: Err(HandleError::NotSupported), + #[cfg(not(target_arch = "wasm32"))] + window: None, storage: None, #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state: None, @@ -769,6 +753,14 @@ impl Frame { self.storage.as_deref_mut() } + /// Access to the current [`winit::window::Window`] (i.e. the one the active viewport is rendered to). + /// + /// `None` for headless (tests etc). + #[cfg(not(target_arch = "wasm32"))] + pub fn winit_window(&self) -> Option<&std::sync::Arc> { + self.window.as_ref() + } + /// A reference to the underlying [`glow`] (OpenGL) context. /// /// This can be used, for instance, to: @@ -776,7 +768,7 @@ impl Frame { /// * Read the pixel buffer from the previous frame (`glow::Context::read_pixels`). /// * Render things behind the egui windows. /// - /// Note that all egui painting is deferred to after the call to [`App::update`] + /// Note that all egui painting is deferred to after the call to [`App::ui`] /// ([`egui`] only collects [`egui::Shape`]s and then eframe paints them all in one go later on). /// /// To get a [`glow`] context you need to compile with the `glow` feature flag, @@ -805,6 +797,28 @@ impl Frame { pub fn wgpu_render_state(&self) -> Option<&egui_wgpu::RenderState> { self.wgpu_render_state.as_ref() } + + /// The currently-applied runtime surface config (present mode, frame latency) + /// used by the `wgpu` renderer, if any. + /// + /// Returns `None` when not using the `wgpu` backend. + #[cfg(feature = "wgpu_no_default_features")] + pub fn wgpu_surface_config(&self) -> Option { + self.wgpu_render_state + .as_ref() + .map(|state| state.surface_config) + } + + /// Set the runtime surface config (present mode, frame latency) for the `wgpu` + /// renderer. The surface is reconfigured on the next paint. + /// + /// No-op when not using the `wgpu` backend. + #[cfg(feature = "wgpu_no_default_features")] + pub fn set_wgpu_surface_config(&mut self, config: egui_wgpu::SurfaceConfig) { + if let Some(state) = &mut self.wgpu_render_state { + state.surface_config = config; + } + } } /// Information about the web environment (if applicable). @@ -882,7 +896,7 @@ pub struct IntegrationInfo { /// Seconds of cpu usage (in seconds) on the previous frame. /// - /// This includes [`App::update`] as well as rendering (except for vsync waiting). + /// This includes [`App::ui`] as well as rendering (except for vsync waiting). /// /// For a more detailed view of cpu usage, connect your preferred profiler by enabling it's feature in [`profiling`](https://crates.io/crates/profiling). /// diff --git a/crates/eframe/src/lib.rs b/crates/eframe/src/lib.rs index c644289e0..e5247a037 100644 --- a/crates/eframe/src/lib.rs +++ b/crates/eframe/src/lib.rs @@ -6,7 +6,7 @@ //! To get started, see the [examples](https://github.com/emilk/egui/tree/main/examples). //! To learn how to set up `eframe` for web and native, go to and follow the instructions there! //! -//! In short, you implement [`App`] (especially [`App::update`]) and then +//! In short, you implement [`App`] (especially [`App::ui`]) and then //! call [`crate::run_native`] from your `main.rs`, and/or use `eframe::WebRunner` from your `lib.rs`. //! //! ## Compiling for web @@ -19,7 +19,7 @@ //! //! ## Simplified usage //! If your app is only for native, and you don't need advanced features like state persistence, -//! then you can use the simpler function [`run_simple_native`]. +//! then you can use the simpler function [`run_ui_native`]. //! //! ## Usage, native: //! ``` no_run @@ -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!"); //! }); //! } @@ -159,7 +159,7 @@ pub use {egui, egui::emath, egui::epaint}; pub use {egui_glow, glow}; #[cfg(feature = "wgpu_no_default_features")] -pub use {egui_wgpu, egui_wgpu::wgpu}; +pub use {egui_wgpu, egui_wgpu::SurfaceConfig, egui_wgpu::WgpuConfiguration, egui_wgpu::wgpu}; mod epi; @@ -244,7 +244,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!"); /// }); /// } @@ -257,8 +257,27 @@ pub mod icon_data; #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] #[allow(clippy::allow_attributes, clippy::needless_pass_by_value)] pub fn run_native( + app_name: &str, + native_options: NativeOptions, + app_creator: AppCreator<'_>, +) -> Result { + run_native_ext(app_name, native_options, None, app_creator) +} + +/// Like [`run_native`], but lets you supply a pre-existing [`egui::Context`]. +/// +/// If `egui_ctx` is `Some`, that context will be used by eframe instead of creating a fresh one. +/// If it is `None`, eframe creates a new context (same behavior as [`run_native`]). +/// +/// # Errors +/// This function can fail if we fail to set up a graphics context. +#[cfg(not(target_arch = "wasm32"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] +#[allow(clippy::allow_attributes, clippy::needless_pass_by_value)] +pub fn run_native_ext( app_name: &str, mut native_options: NativeOptions, + egui_ctx: Option, app_creator: AppCreator<'_>, ) -> Result { let renderer = init_native(app_name, &mut native_options); @@ -267,13 +286,13 @@ pub fn run_native( #[cfg(feature = "glow")] Renderer::Glow => { log::debug!("Using the glow renderer"); - native::run::run_glow(app_name, native_options, app_creator) + native::run::run_glow(app_name, native_options, egui_ctx, app_creator) } #[cfg(feature = "wgpu_no_default_features")] Renderer::Wgpu => { log::debug!("Using the wgpu renderer"); - native::run::run_wgpu(app_name, native_options, app_creator) + native::run::run_wgpu(app_name, native_options, egui_ctx, app_creator) } } } @@ -315,7 +334,7 @@ pub fn run_native( /// /// 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!"); /// }); /// } @@ -370,6 +389,9 @@ fn init_native(app_name: &str, native_options: &mut NativeOptions) -> Renderer { if native_options.viewport.title.is_none() { native_options.viewport.title = Some(app_name.to_owned()); } + if native_options.viewport.app_id.is_none() { + native_options.viewport.app_id = Some(app_name.to_owned()); + } let renderer = native_options.renderer; @@ -403,7 +425,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: "); @@ -446,67 +468,6 @@ pub fn run_ui_native( ) } -/// The simplest way to get started when writing a native app. -/// -/// This does NOT support persistence of custom user data. For that you need to use [`run_native`]. -/// However, it DOES support persistence of egui data (window positions and sizes, how far the user has scrolled in a -/// [`ScrollArea`](egui::ScrollArea), etc.) if the persistence feature is enabled. -/// -/// # Example -/// ``` no_run -/// fn main() -> eframe::Result { -/// // Our application state: -/// let mut name = "Arthur".to_owned(); -/// let mut age = 42; -/// -/// let options = eframe::NativeOptions::default(); -/// eframe::run_simple_native("My egui App", options, move |ctx, _frame| { -/// egui::CentralPanel::default().show(ctx, |ui| { -/// ui.heading("My egui Application"); -/// ui.horizontal(|ui| { -/// let name_label = ui.label("Your name: "); -/// ui.text_edit_singleline(&mut name) -/// .labelled_by(name_label.id); -/// }); -/// ui.add(egui::Slider::new(&mut age, 0..=120).text("age")); -/// if ui.button("Increment").clicked() { -/// age += 1; -/// } -/// ui.label(format!("Hello '{name}', age {age}")); -/// }); -/// }) -/// } -/// ``` -/// -/// # Errors -/// This function can fail if we fail to set up a graphics context. -#[deprecated = "Use run_ui_native instead"] -#[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] -pub fn run_simple_native( - app_name: &str, - native_options: NativeOptions, - update_fun: impl FnMut(&egui::Context, &mut Frame) + 'static, -) -> Result { - struct SimpleApp { - update_fun: U, - } - - impl App for SimpleApp { - fn ui(&mut self, _ui: &mut egui::Ui, _frame: &mut Frame) {} - - fn update(&mut self, ctx: &egui::Context, frame: &mut Frame) { - (self.update_fun)(ctx, frame); - } - } - - run_native( - app_name, - native_options, - Box::new(|_cc| Ok(Box::new(SimpleApp { update_fun }))), - ) -} - // ---------------------------------------------------------------------------- /// The different problems that can occur when trying to run `eframe`. diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 5b22eb08c..270c38388 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -2,7 +2,7 @@ use web_time::Instant; -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; use winit::event_loop::ActiveEventLoop; use raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _}; @@ -171,7 +171,7 @@ impl EpiIntegration { #[allow(clippy::allow_attributes, clippy::too_many_arguments)] pub fn new( egui_ctx: egui::Context, - window: &winit::window::Window, + window: &Arc, app_name: &str, native_options: &crate::NativeOptions, storage: Option>, @@ -192,6 +192,7 @@ impl EpiIntegration { glow_register_native_texture, #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state, + window: Some(Arc::clone(window)), raw_display_handle: window.display_handle().map(|h| h.as_raw()), raw_window_handle: window.window_handle().map(|h| h.as_raw()), }; @@ -214,15 +215,17 @@ impl EpiIntegration { Self { frame, last_auto_save: Instant::now(), - egui_ctx, pending_full_output: Default::default(), close: false, can_drag_window: false, #[cfg(feature = "persistence")] persist_window: native_options.persist_window, app_icon_setter, - beginning: Instant::now(), + beginning: Instant::now() + .checked_sub(web_time::Duration::from_secs_f64(egui_ctx.time())) + .unwrap_or_else(Instant::now), is_first_frame: true, + egui_ctx, } } @@ -259,7 +262,7 @@ impl EpiIntegration { /// Run user code - this can create immediate viewports, so hold no locks over this! /// - /// If `viewport_ui_cb` is None, we are in the root viewport and will call [`crate::App::update`]. + /// If `viewport_ui_cb` is None, we are in the root viewport and will call [`crate::App::ui`]. pub fn update( &mut self, app: &mut dyn epi::App, @@ -287,12 +290,6 @@ impl EpiIntegration { } if is_visible { - { - profiling::scope!("App::update"); - #[expect(deprecated)] - app.update(ui.ctx(), &mut self.frame); - } - { profiling::scope!("App::ui"); app.ui(ui, &mut self.frame); diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index 37e3faa69..2f10433c7 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -55,6 +55,10 @@ pub struct GlowWinitApp<'app> { // re-initializing the `GlowWinitRunning` state on Android if the application // suspends and resumes. app_creator: Option>, + + /// An optional pre-existing egui context. If `Some`, it is used instead of + /// creating a new one via [`create_egui_context`]. Taken during initialization. + egui_ctx: Option, } /// State that is initialized when the application is first starts running via @@ -128,6 +132,7 @@ impl<'app> GlowWinitApp<'app> { event_loop: &EventLoop, app_name: &str, native_options: NativeOptions, + egui_ctx: Option, app_creator: AppCreator<'app>, ) -> Self { profiling::function_scope!(); @@ -137,6 +142,7 @@ impl<'app> GlowWinitApp<'app> { native_options, running: None, app_creator: Some(app_creator), + egui_ctx, } } @@ -184,7 +190,7 @@ impl<'app> GlowWinitApp<'app> { let painter = egui_glow::Painter::new( gl, "", - native_options.shader_version, + native_options.glow_options.shader_version, native_options.dithering, )?; @@ -209,7 +215,10 @@ impl<'app> GlowWinitApp<'app> { ) }; - let egui_ctx = create_egui_context(storage.as_deref()); + let egui_ctx = self + .egui_ctx + .take() + .unwrap_or_else(|| create_egui_context(storage.as_deref())); let (mut glutin, painter) = Self::create_glutin_windowed_context( &egui_ctx, @@ -305,6 +314,7 @@ impl<'app> GlowWinitApp<'app> { get_proc_address: Some(Arc::new(get_proc_address)), #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state: None, + window: Some(Arc::clone(&window)), raw_display_handle: window.display_handle().map(|h| h.as_raw()), raw_window_handle: window.window_handle().map(|h| h.as_raw()), }; @@ -670,7 +680,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); @@ -952,12 +962,12 @@ impl GlutinWindowContext { use glutin::prelude::*; // convert native options to glutin options - let hardware_acceleration = match native_options.hardware_acceleration { - crate::HardwareAcceleration::Required => Some(true), - crate::HardwareAcceleration::Preferred => None, - crate::HardwareAcceleration::Off => Some(false), + let hardware_acceleration = match native_options.glow_options.hardware_acceleration { + egui_glow::HardwareAcceleration::Required => Some(true), + egui_glow::HardwareAcceleration::Preferred => None, + egui_glow::HardwareAcceleration::Off => Some(false), }; - let swap_interval = if native_options.vsync { + let swap_interval = if native_options.glow_options.vsync { glutin::surface::SwapInterval::Wait(NonZeroU32::MIN) } else { glutin::surface::SwapInterval::DontWait diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index 73b58ae61..b89212aa2 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -399,6 +399,7 @@ fn run_and_exit(event_loop: EventLoop, winit_app: impl WinitApp) -> R pub fn run_glow( app_name: &str, mut native_options: epi::NativeOptions, + egui_ctx: Option, app_creator: epi::AppCreator<'_>, ) -> Result { use super::glow_integration::GlowWinitApp; @@ -406,13 +407,15 @@ pub fn run_glow( #[cfg(not(target_os = "ios"))] if native_options.run_and_return { return with_event_loop(native_options, |event_loop, native_options| { - let glow_eframe = GlowWinitApp::new(event_loop, app_name, native_options, app_creator); + let glow_eframe = + GlowWinitApp::new(event_loop, app_name, native_options, egui_ctx, app_creator); run_and_return(event_loop, glow_eframe) })?; } let event_loop = create_event_loop(&mut native_options)?; - let glow_eframe = GlowWinitApp::new(&event_loop, app_name, native_options, app_creator); + let glow_eframe = + GlowWinitApp::new(&event_loop, app_name, native_options, egui_ctx, app_creator); run_and_exit(event_loop, glow_eframe) } @@ -425,7 +428,7 @@ pub fn create_glow<'a>( ) -> impl ApplicationHandler + 'a { use super::glow_integration::GlowWinitApp; - let glow_eframe = GlowWinitApp::new(event_loop, app_name, native_options, app_creator); + let glow_eframe = GlowWinitApp::new(event_loop, app_name, native_options, None, app_creator); WinitAppWrapper::new(glow_eframe, true) } @@ -435,6 +438,7 @@ pub fn create_glow<'a>( pub fn run_wgpu( app_name: &str, mut native_options: epi::NativeOptions, + egui_ctx: Option, app_creator: epi::AppCreator<'_>, ) -> Result { use super::wgpu_integration::WgpuWinitApp; @@ -442,13 +446,15 @@ pub fn run_wgpu( #[cfg(not(target_os = "ios"))] if native_options.run_and_return { return with_event_loop(native_options, |event_loop, native_options| { - let wgpu_eframe = WgpuWinitApp::new(event_loop, app_name, native_options, app_creator); + let wgpu_eframe = + WgpuWinitApp::new(event_loop, app_name, native_options, egui_ctx, app_creator); run_and_return(event_loop, wgpu_eframe) })?; } let event_loop = create_event_loop(&mut native_options)?; - let wgpu_eframe = WgpuWinitApp::new(&event_loop, app_name, native_options, app_creator); + let wgpu_eframe = + WgpuWinitApp::new(&event_loop, app_name, native_options, egui_ctx, app_creator); run_and_exit(event_loop, wgpu_eframe) } @@ -461,7 +467,7 @@ pub fn create_wgpu<'a>( ) -> impl ApplicationHandler + 'a { use super::wgpu_integration::WgpuWinitApp; - let wgpu_eframe = WgpuWinitApp::new(event_loop, app_name, native_options, app_creator); + let wgpu_eframe = WgpuWinitApp::new(event_loop, app_name, native_options, None, app_creator); WinitAppWrapper::new(wgpu_eframe, true) } diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index de691153f..a4da74d97 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -48,6 +48,10 @@ pub struct WgpuWinitApp<'app> { /// Set when we are actually up and running. running: Option>, + + /// An optional pre-existing egui context. If `Some`, it is used instead of + /// creating a new one via [`winit_integration::create_egui_context`]. Taken during initialization. + egui_ctx: Option, } /// State that is initialized when the application is first starts running via @@ -105,6 +109,7 @@ impl<'app> WgpuWinitApp<'app> { event_loop: &EventLoop, app_name: &str, native_options: NativeOptions, + egui_ctx: Option, app_creator: AppCreator<'app>, ) -> Self { profiling::function_scope!(); @@ -121,6 +126,7 @@ impl<'app> WgpuWinitApp<'app> { native_options, running: None, app_creator: Some(app_creator), + egui_ctx, } } @@ -294,6 +300,7 @@ impl<'app> WgpuWinitApp<'app> { #[cfg(feature = "glow")] get_proc_address: None, wgpu_render_state, + window: Some(Arc::clone(&window)), raw_display_handle: window.display_handle().map(|h| h.as_raw()), raw_window_handle: window.window_handle().map(|h| h.as_raw()), }; @@ -403,7 +410,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) } @@ -428,7 +435,10 @@ impl WinitApp for WgpuWinitApp<'_> { .unwrap_or(&self.app_name), ) }; - let egui_ctx = winit_integration::create_egui_context(storage.as_deref()); + let egui_ctx = self + .egui_ctx + .take() + .unwrap_or_else(|| winit_integration::create_egui_context(storage.as_deref())); let (window, builder) = create_window( &egui_ctx, event_loop, @@ -560,7 +570,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 @@ -701,7 +715,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 7161a665e..5388bf023 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -284,9 +284,6 @@ impl AppRunner { self.app.logic(ui.ctx(), &mut self.frame); if is_visible { - #[expect(deprecated)] - self.app.update(ui.ctx(), &mut self.frame); - self.app.ui(ui, &mut self.frame); } }); @@ -371,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 c27897090..62723e8fa 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_glow.rs b/crates/eframe/src/web/web_painter_glow.rs index 470fa40d3..c9b846d50 100644 --- a/crates/eframe/src/web/web_painter_glow.rs +++ b/crates/eframe/src/web/web_painter_glow.rs @@ -31,8 +31,13 @@ impl WebPainterGlow { #[allow(clippy::allow_attributes, clippy::arc_with_non_send_sync)] // For wasm let gl = std::sync::Arc::new(gl); - let painter = egui_glow::Painter::new(gl, shader_prefix, None, options.dithering) - .map_err(|err| format!("Error starting glow painter: {err}"))?; + let painter = egui_glow::Painter::new( + gl, + shader_prefix, + options.glow_options.shader_version, + options.dithering, + ) + .map_err(|err| format!("Error starting glow painter: {err}"))?; Ok(Self { canvas, diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs index 63702592d..b16f98a9b 100644 --- a/crates/eframe/src/web/web_painter_wgpu.rs +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -12,6 +12,7 @@ use super::web_painter::WebPainter; pub(crate) struct WebPainterWgpu { canvas: HtmlCanvasElement, + instance: wgpu::Instance, surface: wgpu::Surface<'static>, surface_configuration: wgpu::SurfaceConfiguration, render_state: Option, @@ -23,6 +24,7 @@ pub(crate) struct WebPainterWgpu { capture_rx: CaptureReceiver, ctx: egui::Context, needs_reconfigure: bool, + needs_recreate: bool, } /// Owned web display handle that is `Send + Sync`. @@ -118,7 +120,7 @@ impl WebPainterWgpu { let surface_configuration = wgpu::SurfaceConfiguration { format: render_state.target_format, - present_mode: wgpu_options.present_mode, + present_mode: wgpu_options.surface.present_mode, view_formats: vec![render_state.target_format], ..default_configuration }; @@ -129,6 +131,7 @@ impl WebPainterWgpu { Ok(Self { canvas, + instance, render_state: Some(render_state), surface, surface_configuration, @@ -140,6 +143,7 @@ impl WebPainterWgpu { capture_rx, ctx, needs_reconfigure: false, + needs_recreate: false, }) } } @@ -173,6 +177,24 @@ impl WebPainter for WebPainterWgpu { )); }; + // If the previous frame produced `CurrentSurfaceTexture::Lost`, drop and recreate the + // surface from the canvas before re-borrowing `self.render_state` for the rest of paint. + if self.needs_recreate { + self.needs_recreate = false; + match self + .instance + .create_surface(wgpu::SurfaceTarget::Canvas(self.canvas.clone())) + { + Ok(new_surface) => { + new_surface.configure(&render_state.device, &self.surface_configuration); + self.surface = new_surface; + } + Err(err) => { + log::error!("Failed to recreate wgpu surface for canvas: {err}"); + } + } + } + let mut encoder = render_state .device @@ -239,10 +261,18 @@ impl WebPainter for WebPainterWgpu { } other => { match (*self.on_surface_status)(&other) { - SurfaceErrorAction::RecreateSurface => { + SurfaceErrorAction::Reconfigure => { self.surface .configure(&render_state.device, &self.surface_configuration); } + SurfaceErrorAction::RecreateSurface => { + // Full recovery needs `&mut self`, which conflicts with the live + // `render_state` / `self.surface` borrows here. Defer to the top + // of the next paint via the `needs_recreate` flag, and request a + // repaint so the next frame actually invokes `paint` to consume it. + self.needs_recreate = true; + self.ctx.request_repaint(); + } SurfaceErrorAction::SkipFrame => {} } return Ok(()); @@ -335,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/CHANGELOG.md b/crates/egui-wgpu/CHANGELOG.md index 4cc344bf2..2eda803fc 100644 --- a/crates/egui-wgpu/CHANGELOG.md +++ b/crates/egui-wgpu/CHANGELOG.md @@ -6,6 +6,11 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.34.2 - 2026-05-04 +* Update to wgpu 29.0.1 [#8073](https://github.com/emilk/egui/pull/8073) by [@emilk](https://github.com/emilk) +* Warn if using a software rasterizer [#8101](https://github.com/emilk/egui/pull/8101) by [@emilk](https://github.com/emilk) + + ## 0.34.1 - 2026-03-27 * `wgpu` backend: Enable WebGL fallback [#8038](https://github.com/emilk/egui/pull/8038) by [@emilk](https://github.com/emilk) diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs index 180b305be..10cd7cf04 100644 --- a/crates/egui-wgpu/src/lib.rs +++ b/crates/egui-wgpu/src/lib.rs @@ -64,6 +64,44 @@ pub enum WgpuError { HandleError(#[from] ::winit::raw_window_handle::HandleError), } +/// Runtime-mutable subset of [`WgpuConfiguration`]. +/// +/// Edit any field to have the surface reconfigured on the next paint. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SurfaceConfig { + /// Present mode used for the primary surface. + pub present_mode: wgpu::PresentMode, + + /// Desired maximum number of frames that the presentation engine should queue in advance. + /// + /// Use `1` for low-latency, and `2` for high-throughput. + /// + /// See [`wgpu::SurfaceConfiguration::desired_maximum_frame_latency`] for details. + /// + /// `None` => Let `wgpu` pick a default (currently `2`). + pub desired_maximum_frame_latency: Option, +} + +impl SurfaceConfig { + /// Good default for GUIs with very little (or no) extra GPU work. + pub const LOW_LATENCY: Self = Self { + present_mode: wgpu::PresentMode::AutoVsync, + + desired_maximum_frame_latency: if cfg!(target_os = "ios") { + None // The default is good on iOS, while `Some(1)` cuts FPS in half + } else { + Some(1) + }, + }; + + /// Good default for GUIs with a lot of extra GPU work, + /// or that want to prioritize smoothness over latency. + pub const HIGH_THROUGHPUT: Self = Self { + present_mode: wgpu::PresentMode::AutoVsync, + desired_maximum_frame_latency: Some(2), // High-throughput. + }; +} + /// Access to the render state for egui. #[derive(Clone)] pub struct RenderState { @@ -88,6 +126,11 @@ pub struct RenderState { /// Egui renderer responsible for drawing the UI. pub renderer: Arc>, + + /// Runtime-mutable subset of the wgpu configuration. + /// + /// Update this to have the surface reconfigured on the next paint. + pub surface_config: SurfaceConfig, } async fn request_adapter( @@ -138,29 +181,12 @@ async fn request_adapter( } })?; - if cfg!(target_arch = "wasm32") { - log::debug!( - "Picked wgpu adapter: {}", - adapter_info_summary(&adapter.get_info()) + if 1 < available_adapters.len() { + log::info!( + "There are {} available wgpu adapters: {}", + available_adapters.len(), + describe_adapters(available_adapters) ); - } else { - // native: - if available_adapters.len() == 1 { - log::debug!( - "Picked the only available wgpu adapter: {}", - adapter_info_summary(&adapter.get_info()) - ); - } else { - log::info!( - "There were {} available wgpu adapters: {}", - available_adapters.len(), - describe_adapters(available_adapters) - ); - log::debug!( - "Picked wgpu adapter: {}", - adapter_info_summary(&adapter.get_info()) - ); - } } Ok(adapter) @@ -236,6 +262,8 @@ impl RenderState { }) => (adapter, device, queue), }; + log_adapter_info(&adapter.get_info()); + let surface_formats = { profiling::scope!("get_capabilities"); compatible_surface.map_or_else( @@ -258,6 +286,7 @@ impl RenderState { queue, target_format, renderer: Arc::new(RwLock::new(renderer)), + surface_config: config.surface, }) } } @@ -281,24 +310,28 @@ pub enum SurfaceErrorAction { /// Do nothing and skip the current frame. SkipFrame, - /// Instructs egui to recreate the surface, then skip the current frame. + /// Reconfigure the existing surface, then skip the current frame. + /// + /// Calls [`wgpu::Surface::configure`] on the current surface object. + /// Use for [`wgpu::CurrentSurfaceTexture::Outdated`]. + Reconfigure, + + /// Drop the surface, create a new one via [`wgpu::Instance::create_surface`], configure it, + /// then skip the current frame. + /// + /// Use for [`wgpu::CurrentSurfaceTexture::Lost`], where reconfiguring the same surface + /// object cannot recover. RecreateSurface, } /// Configuration for using wgpu with eframe or the egui-wgpu winit feature. #[derive(Clone)] pub struct WgpuConfiguration { - /// Present mode used for the primary surface. - pub present_mode: wgpu::PresentMode, - - /// Desired maximum number of frames that the presentation engine should queue in advance. + /// Runtime-mutable configuration for the surface (present mode, frame latency). /// - /// Use `1` for low-latency, and `2` for high-throughput. - /// - /// See [`wgpu::SurfaceConfiguration::desired_maximum_frame_latency`] for details. - /// - /// `None` = `wgpu` default. - pub desired_maximum_frame_latency: Option, + /// These are the fields exposed via [`RenderState::surface_config`] for live + /// reconfiguration at runtime. + pub surface: SurfaceConfig, /// How to create the wgpu adapter & device pub wgpu_setup: WgpuSetup, @@ -323,47 +356,55 @@ fn wgpu_config_impl_send_sync() { impl std::fmt::Debug for WgpuConfiguration { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { - present_mode, - desired_maximum_frame_latency, + surface, wgpu_setup, on_surface_status: _, } = self; f.debug_struct("WgpuConfiguration") - .field("present_mode", &present_mode) - .field( - "desired_maximum_frame_latency", - &desired_maximum_frame_latency, - ) + .field("surface", &surface) .field("wgpu_setup", &wgpu_setup) .finish_non_exhaustive() } } +impl WgpuConfiguration { + #[inline] + pub fn with_surface_config(mut self, surface_config: SurfaceConfig) -> Self { + self.surface = surface_config; + self + } +} + impl Default for WgpuConfiguration { fn default() -> Self { Self { - present_mode: wgpu::PresentMode::AutoVsync, - desired_maximum_frame_latency: None, + surface: SurfaceConfig::HIGH_THROUGHPUT, + // No display handle available at this point β€” callers should replace this with // `WgpuSetup::from_display_handle(...)` before creating the instance if one is available. wgpu_setup: WgpuSetup::without_display_handle(), - on_surface_status: Arc::new(|status| { - match status { - wgpu::CurrentSurfaceTexture::Outdated => { - // This error occurs when the app is minimized on Windows. - // Silently return here to prevent spamming the console with: - // "The underlying surface has changed, and therefore the swap chain must be updated" - } - wgpu::CurrentSurfaceTexture::Occluded => { - // This error occurs when the application is occluded (e.g. minimized or behind another window). - log::debug!("Dropped frame with error: {status:?}"); - } - _ => { - log::warn!("Dropped frame with error: {status:?}"); - } + on_surface_status: Arc::new(|status| match status { + wgpu::CurrentSurfaceTexture::Outdated => { + // The compositor changed the surface (resize, scale, output, …). wgpu + // requires us to reconfigure before the next acquire. Skipping would mean + // we are stuck in `Outdated` forever. + log::trace!("Dropped frame with error: {status:?}"); + SurfaceErrorAction::Reconfigure + } + wgpu::CurrentSurfaceTexture::Lost => { + // The underlying surface is gone and we need a fresh one from the `wgpu::Instance`. + log::debug!("Dropped frame with error: {status:?}"); + SurfaceErrorAction::RecreateSurface + } + wgpu::CurrentSurfaceTexture::Occluded => { + // App is hidden (minimized / behind another window). Skip silently. + log::trace!("Skipping frame due to occlusion."); + SurfaceErrorAction::SkipFrame + } + _ => { + log::warn!("Dropped frame with error: {status:?}"); + SurfaceErrorAction::SkipFrame } - - SurfaceErrorAction::SkipFrame }), } } @@ -406,6 +447,18 @@ pub fn depth_format_from_bits(depth_buffer: u8, stencil_buffer: u8) -> Option String { let wgpu::AdapterInfo { diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index d64644330..78817e879 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -3,7 +3,7 @@ #![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps #![expect(unsafe_code)] -use crate::{RenderState, SurfaceErrorAction, WgpuConfiguration, renderer}; +use crate::{RenderState, SurfaceConfig, SurfaceErrorAction, WgpuConfiguration, renderer}; use crate::{ RendererOptions, capture::{CaptureReceiver, CaptureSender, CaptureState, capture_channel}, @@ -18,6 +18,7 @@ struct SurfaceState { height: u32, resizing: bool, needs_reconfigure: bool, + needs_recreate: bool, } /// Everything you need to paint egui with [`wgpu`] on [`winit`]. @@ -27,7 +28,7 @@ struct SurfaceState { /// NOTE: all egui viewports share the same painter. pub struct Painter { context: Context, - configuration: WgpuConfiguration, + config: WgpuConfiguration, options: RendererOptions, support_transparent_backbuffer: bool, screen_capture_state: Option, @@ -58,16 +59,16 @@ impl Painter { /// associated. pub async fn new( context: Context, - configuration: WgpuConfiguration, + config: WgpuConfiguration, support_transparent_backbuffer: bool, options: RendererOptions, ) -> Self { let (capture_tx, capture_rx) = capture_channel(); - let instance = configuration.wgpu_setup.new_instance().await; + let instance = config.wgpu_setup.new_instance().await; Self { context, - configuration, + config, options, support_transparent_backbuffer, screen_capture_state: None, @@ -94,17 +95,22 @@ impl Painter { fn configure_surface( surface_state: &SurfaceState, render_state: &RenderState, - config: &WgpuConfiguration, + config: &SurfaceConfig, ) { profiling::function_scope!(); + let SurfaceConfig { + present_mode, + desired_maximum_frame_latency, + } = *config; + let width = surface_state.width; let height = surface_state.height; let mut surf_config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: render_state.target_format, - present_mode: config.present_mode, + present_mode, alpha_mode: surface_state.alpha_mode, view_formats: vec![render_state.target_format], ..surface_state @@ -113,7 +119,7 @@ impl Painter { .expect("The surface isn't supported by this adapter") }; - if let Some(desired_maximum_frame_latency) = config.desired_maximum_frame_latency { + if let Some(desired_maximum_frame_latency) = desired_maximum_frame_latency { surf_config.desired_maximum_frame_latency = desired_maximum_frame_latency; } @@ -122,6 +128,33 @@ impl Painter { .configure(&render_state.device, &surf_config); } + /// Drop the existing [`wgpu::Surface`] for `viewport_id` and create a fresh one for the + /// given window via [`wgpu::Instance::create_surface`], then configure it. + /// + /// Used to recover from [`wgpu::CurrentSurfaceTexture::Lost`], where reconfiguring the + /// existing surface object cannot recover. + fn recreate_surface( + &mut self, + viewport_id: ViewportId, + window: &Arc, + ) -> Result<(), crate::WgpuError> { + profiling::function_scope!(); + + let Some(old_state) = self.surfaces.remove(&viewport_id) else { + return Ok(()); + }; + + let surface = self.instance.create_surface(Arc::clone(window))?; + self.install_surface( + surface, + viewport_id, + old_state.width, + old_state.height, + old_state.resizing, + ); + Ok(()) + } + /// Updates (or clears) the [`winit::window::Window`] associated with the [`Painter`] /// /// This creates a [`wgpu::Surface`] for the given Window (as well as initializing render @@ -198,56 +231,74 @@ impl Painter { viewport_id: ViewportId, size: winit::dpi::PhysicalSize, ) -> Result<(), crate::WgpuError> { - let render_state = if let Some(render_state) = &self.render_state { - render_state - } else { - let render_state = RenderState::create( - &self.configuration, - &self.instance, - Some(&surface), - self.options, - ) - .await?; - self.render_state.get_or_insert(render_state) - }; - let alpha_mode = if self.support_transparent_backbuffer { - let supported_alpha_modes = surface.get_capabilities(&render_state.adapter).alpha_modes; + if self.render_state.is_none() { + let render_state = + RenderState::create(&self.config, &self.instance, Some(&surface), self.options) + .await?; + self.render_state = Some(render_state); + } + self.install_surface(surface, viewport_id, size.width, size.height, false); + Ok(()) + } - // Prefer pre multiplied over post multiplied! - if supported_alpha_modes.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { - wgpu::CompositeAlphaMode::PreMultiplied - } else if supported_alpha_modes.contains(&wgpu::CompositeAlphaMode::PostMultiplied) { - wgpu::CompositeAlphaMode::PostMultiplied + /// Inserts a freshly created surface into [`Self::surfaces`] and configures it. + /// + /// Render state must already be initialised before calling this. + // NOTE: The same assumption is already required by `resize_and_generate_depth_texture_view_and_msaa_view`. + fn install_surface( + &mut self, + surface: wgpu::Surface<'static>, + viewport_id: ViewportId, + width: u32, + height: u32, + resizing: bool, + ) { + let alpha_mode = { + // Panic: We use the same failure mode as `resize_and_generate_depth_texture_view_and_msaa_view` + let render_state = self + .render_state + .as_ref() + .expect("install_surface called before render_state initialization"); + if self.support_transparent_backbuffer { + let supported_alpha_modes = + surface.get_capabilities(&render_state.adapter).alpha_modes; + // Prefer pre multiplied over post multiplied! + if supported_alpha_modes.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { + wgpu::CompositeAlphaMode::PreMultiplied + } else if supported_alpha_modes.contains(&wgpu::CompositeAlphaMode::PostMultiplied) + { + wgpu::CompositeAlphaMode::PostMultiplied + } else { + log::warn!( + "Transparent window was requested, but the active wgpu surface does not support a `CompositeAlphaMode` with transparency." + ); + wgpu::CompositeAlphaMode::Auto + } } else { - log::warn!( - "Transparent window was requested, but the active wgpu surface does not support a `CompositeAlphaMode` with transparency." - ); wgpu::CompositeAlphaMode::Auto } - } else { - wgpu::CompositeAlphaMode::Auto }; self.surfaces.insert( viewport_id, SurfaceState { surface, - width: size.width, - height: size.height, + width, + height, alpha_mode, - resizing: false, + resizing, needs_reconfigure: false, + needs_recreate: false, }, ); - let Some(width) = NonZeroU32::new(size.width) else { + let Some(width) = NonZeroU32::new(width) else { log::debug!("The window width was zero; skipping generate textures"); - return Ok(()); + return; }; - let Some(height) = NonZeroU32::new(size.height) else { + let Some(height) = NonZeroU32::new(height) else { log::debug!("The window height was zero; skipping generate textures"); - return Ok(()); + return; }; self.resize_and_generate_depth_texture_view_and_msaa_view(viewport_id, width, height); - Ok(()) } /// Returns the maximum texture dimension supported if known @@ -278,7 +329,7 @@ impl Painter { surface_state.width = width; surface_state.height = height; - Self::configure_surface(surface_state, render_state, &self.configuration); + Self::configure_surface(surface_state, render_state, &self.config.surface); if let Some(depth_format) = self.options.depth_stencil_format { self.depth_texture_view.insert( @@ -363,20 +414,27 @@ impl Painter { // See https://github.com/emilk/egui/issues/903 #[cfg(all(target_os = "macos", feature = "macos-window-resize-jitter-fix"))] { - // SAFETY: The cast is checked with if condition. If the used backend is not metal - // it gracefully fails. - unsafe { - if let Some(hal_surface) = state.surface.as_hal::() { - hal_surface - .render_layer() - .lock() - .setPresentsWithTransaction(resizing); + // setPresentsWithTransaction causes hangs when desired_maximum_frame_latency == 1 + let is_low_latency = self + .render_state + .as_ref() + .is_some_and(|rs| rs.surface_config.desired_maximum_frame_latency == Some(1)); + if !is_low_latency { + // SAFETY: The cast is checked with if condition. If the used backend is not metal + // it gracefully fails. + unsafe { + if let Some(hal_surface) = state.surface.as_hal::() { + hal_surface + .render_layer() + .lock() + .setPresentsWithTransaction(resizing); - Self::configure_surface( - state, - self.render_state.as_ref().unwrap(), - &self.configuration, - ); + Self::configure_surface( + state, + self.render_state.as_ref().unwrap(), + &self.config.surface, + ); + } } } } @@ -420,7 +478,7 @@ impl Painter { clipped_primitives: &[epaint::ClippedPrimitive], textures_delta: &epaint::textures::TexturesDelta, capture_data: Vec, - window: &winit::window::Window, + window: &Arc, ) -> f32 { profiling::function_scope!(); @@ -449,6 +507,33 @@ impl Painter { let capture = !capture_data.is_empty(); let mut vsync_sec = 0.0; + // If the previous frame produced `CurrentSurfaceTexture::Lost`, the action match + // below set `needs_recreate`. Recreate the surface now, before re-borrowing + // `self.render_state` / `self.surfaces` for the rest of the paint. + if self + .surfaces + .get(&viewport_id) + .is_some_and(|s| s.needs_recreate) + && let Err(err) = self.recreate_surface(viewport_id, window) + { + log::error!("Failed to recreate surface for {viewport_id:?}: {err}"); + return vsync_sec; + } + + // Apply any runtime changes requested via `RenderState::surface_config`. + // We diff against the already-applied values in `self.config.surface` + // and, if anything differs, mark every surface as needing reconfiguration so + // the existing `needs_reconfigure` pathway below picks them up. + if let Some(render_state) = self.render_state.as_ref() + && render_state.surface_config != self.config.surface + { + self.config.surface = render_state.surface_config; + #[expect(clippy::iter_over_hash_type)] + for surface in self.surfaces.values_mut() { + surface.needs_reconfigure = true; + } + } + let Some(render_state) = self.render_state.as_mut() else { return vsync_sec; }; @@ -496,7 +581,7 @@ impl Painter { }; if surface_state.needs_reconfigure { - Self::configure_surface(surface_state, render_state, &self.configuration); + Self::configure_surface(surface_state, render_state, &self.config.surface); surface_state.needs_reconfigure = false; } @@ -516,9 +601,19 @@ impl Painter { frame } other => { - match (*self.configuration.on_surface_status)(&other) { + match (*self.config.on_surface_status)(&other) { + SurfaceErrorAction::Reconfigure => { + Self::configure_surface(surface_state, render_state, &self.config.surface); + self.context.request_repaint_of(viewport_id); + } SurfaceErrorAction::RecreateSurface => { - Self::configure_surface(surface_state, render_state, &self.configuration); + // Because of ownership, I could not find an easy way to do a full recovery here, + // as that would involve dropping the old surface and creating a new one. + // For now, we defer the recreation to the beginning of the next frame (which + // we ensure to arrive via `request_repaint_of`). A cleaner solution would be + // to untangle the ownership of `RenderState`. + surface_state.needs_recreate = true; + self.context.request_repaint_of(viewport_id); } SurfaceErrorAction::SkipFrame => {} } @@ -627,7 +722,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/CHANGELOG.md b/crates/egui-winit/CHANGELOG.md index 68fe5b2cd..15c7c2d52 100644 --- a/crates/egui-winit/CHANGELOG.md +++ b/crates/egui-winit/CHANGELOG.md @@ -5,6 +5,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.34.2 - 2026-05-04 +Nothing new + + ## 0.34.1 - 2026-03-27 Nothing new diff --git a/crates/egui-winit/Cargo.toml b/crates/egui-winit/Cargo.toml index dd4aa8f9d..7fea2341b 100644 --- a/crates/egui-winit/Cargo.toml +++ b/crates/egui-winit/Cargo.toml @@ -20,6 +20,9 @@ workspace = true all-features = true rustdoc-args = ["--generate-link-to-definition"] +[package.metadata.cargo-shear] +ignored = ["wayland-cursor"] # TODO(emilk): remove when we update winit + [features] default = ["clipboard", "links", "wayland", "winit/default", "x11"] diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index b0851270f..009dc8486 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. @@ -132,13 +140,16 @@ impl State { }; let mut slf = Self { - egui_ctx, viewport_id, - start_time: web_time::Instant::now(), + start_time: web_time::Instant::now() + .checked_sub(web_time::Duration::from_secs_f64(egui_ctx.time())) + .unwrap_or_else(web_time::Instant::now), + egui_ctx, egui_input, 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()), @@ -1036,12 +1047,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, @@ -1064,7 +1101,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; @@ -1127,26 +1164,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); } } } @@ -1696,7 +1799,24 @@ fn process_viewport_command( ViewportCommand::Fullscreen(v) => { window.set_fullscreen(v.then_some(winit::window::Fullscreen::Borderless(None))); } - ViewportCommand::Decorations(v) => window.set_decorations(v), + ViewportCommand::SetMonitor(idx) => { + if let Some(monitor) = window.available_monitors().nth(idx) { + window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(Some(monitor)))); + } else { + log::warn!( + "ViewportCommand::SetMonitor({idx}): index out of range ({} monitors available)", + window.available_monitors().count() + ); + } + } + ViewportCommand::Decorations(v) => { + window.set_decorations(v); + #[cfg(target_os = "windows")] + { + use winit::platform::windows::WindowExtWindows as _; + window.set_undecorated_shadow(!v); + } + } ViewportCommand::WindowLevel(l) => window.set_window_level(match l { egui::viewport::WindowLevel::AlwaysOnBottom => WindowLevel::AlwaysOnBottom, egui::viewport::WindowLevel::AlwaysOnTop => WindowLevel::AlwaysOnTop, @@ -1794,7 +1914,24 @@ pub fn create_window( ) -> Result { profiling::function_scope!(); - let window_attributes = create_winit_window_attributes(egui_ctx, viewport_builder.clone()); + let mut window_attributes = create_winit_window_attributes(egui_ctx, viewport_builder.clone()); + + // Resolve target monitor index β†’ MonitorHandle, so the window is created + // directly in borderless fullscreen on the requested output. This is the + // only reliable way to target a specific monitor under Wayland, and also + // avoids the Mutter race where OuterPosition is ignored pre-mapping. + if let Some(idx) = viewport_builder.monitor { + if let Some(monitor) = event_loop.available_monitors().nth(idx) { + window_attributes = window_attributes + .with_fullscreen(Some(winit::window::Fullscreen::Borderless(Some(monitor)))); + } else { + log::warn!( + "ViewportBuilder::with_monitor({idx}): index out of range ({} monitors available)", + event_loop.available_monitors().count() + ); + } + } + let window = event_loop.create_window(window_attributes)?; apply_viewport_builder_to_window(egui_ctx, &window, viewport_builder); Ok(window) @@ -1846,6 +1983,7 @@ pub fn create_winit_window_attributes( mouse_passthrough: _, // handled in `apply_viewport_builder_to_window` clamp_size_to_monitor_size: _, // Handled in `viewport_builder` in `epi_integration.rs` + monitor: _, // Handled in `create_window` (needs ActiveEventLoop for monitor handle) } = viewport_builder; let mut window_attributes = winit::window::WindowAttributes::default() @@ -1976,6 +2114,7 @@ pub fn create_winit_window_attributes( if let Some(show) = _taskbar { window_attributes = window_attributes.with_skip_taskbar(!show); } + window_attributes = window_attributes.with_undecorated_shadow(!decorations.unwrap_or(true)); } #[cfg(target_os = "macos")] diff --git a/crates/egui-winit/src/window_settings.rs b/crates/egui-winit/src/window_settings.rs index d15712d4c..5e77061f0 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); + let window_rect = egui::Rect::from_min_size(*position_px, window_size_px); + let overlap = window_rect.intersect(monitor_rect_px(&monitor)).area(); - if monitor_x_range.contains(&(position_px.x as i32)) - && monitor_y_range.contains(&(position_px.y as i32)) - { + 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 3a22bf529..fadc27c6c 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/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index 7894273f3..1b44c986b 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -226,7 +226,7 @@ impl<'a> AtomLayout<'a> { max_size.x = f32::INFINITY; } - let available_size = ui.available_size().at_most(max_size); + let available_size = ui.available_size().at_most(max_size).at_least(min_size); // The size available for the content let available_inner_size = available_size - frame.total_margin().sum(); diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index d44c0ae41..4b3a4f722 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -280,7 +280,7 @@ impl Area { self } - /// Constrains this area to [`Context::screen_rect`]? + /// Constrains this area to [`Context::content_rect`]? /// /// Default: `true`. #[inline] @@ -291,7 +291,7 @@ impl Area { /// Constrain the movement of the window to the given rectangle. /// - /// For instance: `.constrain_to(ctx.screen_rect())`. + /// For instance: `.constrain_to(ctx.content_rect())`. #[inline] pub fn constrain_to(mut self, constrain_rect: Rect) -> Self { self.constrain = true; @@ -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. @@ -594,10 +594,6 @@ fn round_area_position(ctx: &Context, pos: Pos2) -> Pos2 { } impl Prepared { - pub(crate) fn state(&self) -> &AreaState { - &self.state - } - pub(crate) fn state_mut(&mut self) -> &mut AreaState { &mut self.state } diff --git a/crates/egui/src/containers/collapsing_header.rs b/crates/egui/src/containers/collapsing_header.rs index aca8ab138..3e49a3bb0 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, Vec2, 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}; @@ -81,30 +79,6 @@ impl CollapsingState { } } - /// Will toggle when clicked, etc. - pub(crate) fn show_default_button_with_size( - &mut self, - ui: &mut Ui, - button_size: Vec2, - ) -> Response { - let (_id, rect) = ui.allocate_space(button_size); - let response = ui.interact(rect, self.id, Sense::click()); - response.widget_info(|| { - WidgetInfo::labeled( - WidgetType::Button, - ui.is_enabled(), - if self.is_open() { "Hide" } else { "Show" }, - ) - }); - - if response.clicked() { - self.toggle(ui); - } - let openness = self.openness(ui.ctx()); - paint_default_icon(ui, openness, &response); - response - } - /// Will toggle when clicked, etc. fn show_default_button_indented(&mut self, ui: &mut Ui) -> Response { self.show_button_indented(ui, paint_default_icon) @@ -213,20 +187,31 @@ impl CollapsingState { self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring None } else if openness < 1.0 { - 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() - }; + // The spacing between the header and the body. We animate this too. + let item_spacing = ui.spacing().item_spacing.y; - 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); + 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(); + + 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); @@ -237,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 })) @@ -393,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, @@ -410,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, @@ -446,17 +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); - self - } - - /// 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. - #[deprecated = "Renamed id_salt"] - #[inline] - pub fn id_source(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 c4097f803..a5d925827 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, @@ -94,12 +96,6 @@ impl ComboBox { } } - /// Without label. - #[deprecated = "Renamed from_id_salt"] - pub fn from_id_source(id_salt: impl std::hash::Hash) -> Self { - Self::from_id_salt(id_salt) - } - /// Set the outer width of the button and menu. /// /// Default is [`Spacing::combo_width`]. diff --git a/crates/egui/src/containers/frame.rs b/crates/egui/src/containers/frame.rs index d556f827e..d6f751bc2 100644 --- a/crates/egui/src/containers/frame.rs +++ b/crates/egui/src/containers/frame.rs @@ -174,11 +174,6 @@ impl Frame { Self::NONE } - #[deprecated = "Use `Frame::NONE` or `Frame::new()` instead."] - pub const fn none() -> Self { - Self::NONE - } - /// For when you want to group a few widgets together within a frame. pub fn group(style: &Style) -> Self { Self::new() @@ -197,6 +192,7 @@ impl Frame { Self::new().inner_margin(8).fill(style.visuals.panel_fill) } + /// The default frame for an [`crate::Window`]. pub fn window(style: &Style) -> Self { Self::new() .inner_margin(style.spacing.window_margin) @@ -283,16 +279,6 @@ impl Frame { self } - /// The rounding of the _outer_ corner of the [`Self::stroke`] - /// (or, if there is no stroke, the outer corner of [`Self::fill`]). - /// - /// In other words, this is the corner radius of the _widget rect_. - #[inline] - #[deprecated = "Renamed to `corner_radius`"] - pub fn rounding(self, corner_radius: impl Into) -> Self { - self.corner_radius(corner_radius) - } - /// Margin outside the painted frame. /// /// Similar to what is called `margin` in CSS. @@ -413,11 +399,15 @@ impl Frame { } /// Show the given ui surrounded by this frame. + /// + /// The returned [`InnerResponse::response`] will have the rect of the entire frame, including margins. pub fn show(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse { self.show_dyn(ui, Box::new(add_contents)) } /// Show using dynamic dispatch. + /// + /// The returned [`InnerResponse::response`] will have the rect of the entire frame, including margins. pub fn show_dyn<'c, R>( self, ui: &mut Ui, diff --git a/crates/egui/src/containers/menu.rs b/crates/egui/src/containers/menu.rs index cfdaac827..d4c95b298 100644 --- a/crates/egui/src/containers/menu.rs +++ b/crates/egui/src/containers/menu.rs @@ -197,7 +197,7 @@ impl MenuState { /// Horizontal menu bar where you can add [`MenuButton`]s. /// -/// The menu bar goes well in a [`crate::TopBottomPanel::top`], +/// The menu bar goes well in a [`crate::Panel::top`], /// but can also be placed in a [`crate::Window`]. /// In the latter case you may want to wrap it in [`Frame`]. /// @@ -219,9 +219,6 @@ pub struct MenuBar { style: StyleModifier, } -#[deprecated = "Renamed to `egui::MenuBar`"] -pub type Bar = MenuBar; - impl Default for MenuBar { fn default() -> Self { Self { diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index a8f3306e9..cb66eb1f2 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -9,7 +9,6 @@ mod combo_box; pub mod frame; pub mod menu; pub mod modal; -pub mod old_popup; pub mod panel; mod popup; pub(crate) mod resize; @@ -26,7 +25,6 @@ pub use { combo_box::*, frame::Frame, modal::{Modal, ModalResponse}, - old_popup::*, panel::*, popup::*, resize::Resize, @@ -34,5 +32,5 @@ pub use { scroll_area::ScrollArea, sides::Sides, tooltip::*, - window::Window, + window::{Window, WindowDrag}, }; diff --git a/crates/egui/src/containers/old_popup.rs b/crates/egui/src/containers/old_popup.rs deleted file mode 100644 index f8e7cc900..000000000 --- a/crates/egui/src/containers/old_popup.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Old and deprecated API for popups. Use [`Popup`] instead. -#![expect(deprecated)] - -use crate::containers::tooltip::Tooltip; -use crate::{ - Align, Context, Id, LayerId, Layout, Popup, PopupAnchor, PopupCloseBehavior, Pos2, Rect, - Response, Ui, Widget as _, WidgetText, -}; -use emath::RectAlign; -// ---------------------------------------------------------------------------- - -/// Show a tooltip at the current pointer position (if any). -/// -/// Most of the time it is easier to use [`Response::on_hover_ui`]. -/// -/// See also [`show_tooltip_text`]. -/// -/// Returns `None` if the tooltip could not be placed. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// # #[expect(deprecated)] -/// if ui.ui_contains_pointer() { -/// egui::show_tooltip(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| { -/// ui.label("Helpful text"); -/// }); -/// } -/// # }); -/// ``` -#[deprecated = "Use `egui::Tooltip` instead"] -pub fn show_tooltip( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - show_tooltip_at_pointer(ctx, parent_layer, widget_id, add_contents) -} - -/// Show a tooltip at the current pointer position (if any). -/// -/// Most of the time it is easier to use [`Response::on_hover_ui`]. -/// -/// See also [`show_tooltip_text`]. -/// -/// Returns `None` if the tooltip could not be placed. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// if ui.ui_contains_pointer() { -/// egui::show_tooltip_at_pointer(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| { -/// ui.label("Helpful text"); -/// }); -/// } -/// # }); -/// ``` -#[deprecated = "Use `egui::Tooltip` instead"] -pub fn show_tooltip_at_pointer( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - Tooltip::always_open(ctx.clone(), parent_layer, widget_id, PopupAnchor::Pointer) - .gap(12.0) - .show(add_contents) - .map(|response| response.inner) -} - -/// Show a tooltip under the given area. -/// -/// If the tooltip does not fit under the area, it tries to place it above it instead. -#[deprecated = "Use `egui::Tooltip` instead"] -pub fn show_tooltip_for( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - widget_rect: &Rect, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - Tooltip::always_open(ctx.clone(), parent_layer, widget_id, *widget_rect) - .show(add_contents) - .map(|response| response.inner) -} - -/// Show a tooltip at the given position. -/// -/// Returns `None` if the tooltip could not be placed. -#[deprecated = "Use `egui::Tooltip` instead"] -pub fn show_tooltip_at( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - suggested_position: Pos2, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - Tooltip::always_open(ctx.clone(), parent_layer, widget_id, suggested_position) - .show(add_contents) - .map(|response| response.inner) -} - -/// Show some text at the current pointer position (if any). -/// -/// Most of the time it is easier to use [`Response::on_hover_text`]. -/// -/// See also [`show_tooltip`]. -/// -/// Returns `None` if the tooltip could not be placed. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// if ui.ui_contains_pointer() { -/// egui::show_tooltip_text(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), "Helpful text"); -/// } -/// # }); -/// ``` -#[deprecated = "Use `egui::Tooltip` instead"] -pub fn show_tooltip_text( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - text: impl Into, -) -> Option<()> { - show_tooltip(ctx, parent_layer, widget_id, |ui| { - crate::widgets::Label::new(text).ui(ui); - }) -} - -/// Was this tooltip visible last frame? -#[deprecated = "Use `Tooltip::was_tooltip_open_last_frame` instead"] -pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool { - Tooltip::was_tooltip_open_last_frame(ctx, widget_id) -} - -/// Indicate whether a popup will be shown above or below the box. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum AboveOrBelow { - Above, - Below, -} - -/// Helper for [`popup_above_or_below_widget`]. -#[deprecated = "Use `egui::Popup` instead"] -pub fn popup_below_widget( - ui: &Ui, - popup_id: Id, - widget_response: &Response, - close_behavior: PopupCloseBehavior, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - popup_above_or_below_widget( - ui, - popup_id, - widget_response, - AboveOrBelow::Below, - close_behavior, - add_contents, - ) -} - -/// Shows a popup above or below another widget. -/// -/// Useful for drop-down menus (combo boxes) or suggestion menus under text fields. -/// -/// The opened popup will have a minimum width matching its parent. -/// -/// You must open the popup with [`crate::Memory::open_popup`] or [`crate::Memory::toggle_popup`]. -/// -/// Returns `None` if the popup is not open. -/// -/// ``` -/// # egui::__run_test_ui(|ui| { -/// let response = ui.button("Open popup"); -/// let popup_id = ui.make_persistent_id("my_unique_id"); -/// if response.clicked() { -/// ui.memory_mut(|mem| mem.toggle_popup(popup_id)); -/// } -/// let below = egui::AboveOrBelow::Below; -/// let close_on_click_outside = egui::PopupCloseBehavior::CloseOnClickOutside; -/// # #[expect(deprecated)] -/// egui::popup_above_or_below_widget(ui, popup_id, &response, below, close_on_click_outside, |ui| { -/// ui.set_min_width(200.0); // if you want to control the size -/// ui.label("Some more info, or things you can select:"); -/// ui.label("…"); -/// }); -/// # }); -/// ``` -#[deprecated = "Use `egui::Popup` instead"] -pub fn popup_above_or_below_widget( - _parent_ui: &Ui, - popup_id: Id, - widget_response: &Response, - above_or_below: AboveOrBelow, - close_behavior: PopupCloseBehavior, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - let response = Popup::from_response(widget_response) - .layout(Layout::top_down_justified(Align::LEFT)) - .open_memory(None) - .close_behavior(close_behavior) - .id(popup_id) - .align(match above_or_below { - AboveOrBelow::Above => RectAlign::TOP_START, - AboveOrBelow::Below => RectAlign::BOTTOM_START, - }) - .width(widget_response.rect.width()) - .show(|ui| { - ui.set_min_width(ui.available_width()); - add_contents(ui) - })?; - Some(response.inner) -} diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index 4a83ce8d1..1b8e9f320 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -15,12 +15,11 @@ //! //! Add your [`crate::Window`]:s after any top-level panels. -use emath::{GuiRounding as _, Pos2}; +use emath::GuiRounding as _; use crate::{ - Align, Context, CursorIcon, Frame, Id, InnerResponse, LayerId, Layout, NumExt as _, Rangef, - Rect, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetInfo, WidgetType, lerp, - vec2, + 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,7 +30,12 @@ fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 { #[derive(Clone, Copy, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct PanelState { - pub rect: Rect, + /// 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, } impl PanelState { @@ -39,9 +43,10 @@ impl PanelState { ctx.data_mut(|d| d.get_persisted(bar_id)) } - /// The size of the panel (from previous frame). + /// The _outer_ size of the panel (from previous frame), + /// i.e. including the [`Frame`] margin & border. pub fn size(&self) -> Vec2 { - self.rect.size() + self.outer_rect.size() } fn store(self, ctx: &Context, bar_id: Id) { @@ -51,225 +56,92 @@ impl PanelState { // ---------------------------------------------------------------------------- -/// [`Left`](VerticalSide::Left) or [`Right`](VerticalSide::Right) +/// Which side of a [`Ui`] or screen the panel is attached to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum VerticalSide { +enum PanelSide { Left, Right, -} - -impl VerticalSide { - pub fn opposite(self) -> Self { - match self { - Self::Left => Self::Right, - Self::Right => Self::Left, - } - } - - /// `self` is the _fixed_ side. - /// - /// * Left panels are resized on their right side - /// * Right panels are resized on their left side - fn set_rect_width(self, rect: &mut Rect, width: f32) { - match self { - Self::Left => rect.max.x = rect.min.x + width, - Self::Right => rect.min.x = rect.max.x - width, - } - } - - fn sign(self) -> f32 { - match self { - Self::Left => -1.0, - Self::Right => 1.0, - } - } - - fn side_x(self, rect: Rect) -> f32 { - match self { - Self::Left => rect.left(), - Self::Right => rect.right(), - } - } -} - -/// [`Top`](HorizontalSide::Top) or [`Bottom`](HorizontalSide::Bottom) -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum HorizontalSide { Top, Bottom, } -impl HorizontalSide { - pub fn opposite(self) -> Self { - match self { - Self::Top => Self::Bottom, - Self::Bottom => Self::Top, - } - } - - /// `self` is the _fixed_ side. +impl PanelSide { + /// The axis the panel grows along: `0` (x) for left/right panels, + /// `1` (y) for top/bottom panels. /// - /// * Top panels are resized on their bottom side - /// * Bottom panels are resized upwards - fn set_rect_height(self, rect: &mut Rect, height: f32) { + /// Useful as an index into `Vec2`/`Pos2`. + fn axis(self) -> usize { match self { - Self::Top => rect.max.y = rect.min.y + height, - Self::Bottom => rect.min.y = rect.max.y - height, + Self::Left | Self::Right => 0, + Self::Top | Self::Bottom => 1, } } + /// The axis perpendicular to [`Self::axis`]. + fn cross_axis(self) -> usize { + 1 - self.axis() + } + + /// Unit vector along [`Self::axis`]: `(1, 0)` for left/right, `(0, 1)` for top/bottom. + fn axis_unit(self) -> Vec2 { + match self { + Self::Left | Self::Right => Vec2::X, + Self::Top | Self::Bottom => Vec2::Y, + } + } + + /// Outward unit vector from the fixed edge: + /// `(-1, 0)` for [`Left`](Self::Left), `(+1, 0)` for [`Right`](Self::Right), + /// `(0, -1)` for [`Top`](Self::Top), `(0, +1)` for [`Bottom`](Self::Bottom). + fn dir_vec2(self) -> Vec2 { + self.sign() * self.axis_unit() + } + + /// `-1` for sides at the near edge ([`Left`](Self::Left), [`Top`](Self::Top)), + /// `+1` for sides at the far edge ([`Right`](Self::Right), [`Bottom`](Self::Bottom)). fn sign(self) -> f32 { match self { - Self::Top => -1.0, - Self::Bottom => 1.0, + Self::Left | Self::Top => -1.0, + Self::Right | Self::Bottom => 1.0, } } - fn side_y(self, rect: Rect) -> f32 { + /// Coordinate of the _fixed_ side along the panel's [`axis`](Self::axis). + fn fixed_pos(self, rect: Rect) -> f32 { match self { + Self::Left => rect.left(), + Self::Right => rect.right(), Self::Top => rect.top(), Self::Bottom => rect.bottom(), } } -} -// Intentionally private because I'm not sure of the naming. -// TODO(emilk): decide on good names and make public. -// "VerticalSide" and "HorizontalSide" feels inverted to me. -/// [`Horizontal`](PanelSide::Horizontal) or [`Vertical`](PanelSide::Vertical) -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PanelSide { - /// Left or right. - Vertical(VerticalSide), - - /// Top or bottom - Horizontal(HorizontalSide), -} - -impl From for PanelSide { - fn from(side: HorizontalSide) -> Self { - Self::Horizontal(side) + /// Coordinate of the _opposite_ (resizable) side along the panel's [`axis`](Self::axis). + fn resize_pos(self, rect: Rect) -> f32 { + match self { + Self::Left => rect.right(), + Self::Right => rect.left(), + Self::Top => rect.bottom(), + Self::Bottom => rect.top(), + } } -} -impl From for PanelSide { - fn from(side: VerticalSide) -> Self { - Self::Vertical(side) - } -} - -impl PanelSide { - pub const LEFT: Self = Self::Vertical(VerticalSide::Left); - pub const RIGHT: Self = Self::Vertical(VerticalSide::Right); - pub const TOP: Self = Self::Horizontal(HorizontalSide::Top); - pub const BOTTOM: Self = Self::Horizontal(HorizontalSide::Bottom); - - /// Resize by keeping the [`self`] side fixed, and moving the opposite side. + /// Resize by keeping `self` side fixed, and moving the opposite side. fn set_rect_size(self, rect: &mut Rect, size: f32) { match self { - Self::Vertical(side) => side.set_rect_width(rect, size), - Self::Horizontal(side) => side.set_rect_height(rect, size), + Self::Left => rect.max.x = rect.min.x + size, + Self::Right => rect.min.x = rect.max.x - size, + Self::Top => rect.max.y = rect.min.y + size, + Self::Bottom => rect.min.y = rect.max.y - size, } } fn ui_kind(self) -> UiKind { match self { - Self::Vertical(side) => match side { - VerticalSide::Left => UiKind::LeftPanel, - VerticalSide::Right => UiKind::RightPanel, - }, - Self::Horizontal(side) => match side { - HorizontalSide::Top => UiKind::TopPanel, - HorizontalSide::Bottom => UiKind::BottomPanel, - }, - } - } -} - -// ---------------------------------------------------------------------------- - -/// Intermediate structure to abstract some portion of [`Panel::show_inside`](Panel::show_inside). -struct PanelSizer<'a> { - panel: &'a Panel, - frame: Frame, - available_rect: Rect, - size: f32, - panel_rect: Rect, -} - -impl<'a> PanelSizer<'a> { - fn new(panel: &'a Panel, ui: &Ui) -> Self { - let frame = panel - .frame - .unwrap_or_else(|| Frame::side_top_panel(ui.style())); - let available_rect = ui.available_rect_before_wrap(); - let size = PanelSizer::get_size_from_state_or_default(panel, ui, frame); - let panel_rect = PanelSizer::panel_rect(panel, available_rect, size); - - Self { - panel, - frame, - available_rect, - size, - panel_rect, - } - } - - fn get_size_from_state_or_default(panel: &Panel, ui: &Ui, frame: Frame) -> f32 { - if let Some(state) = PanelState::load(ui.ctx(), panel.id) { - match panel.side { - PanelSide::Vertical(_) => state.rect.width(), - PanelSide::Horizontal(_) => state.rect.height(), - } - } else { - match panel.side { - PanelSide::Vertical(_) => panel.default_size.unwrap_or_else(|| { - ui.style().spacing.interact_size.x + frame.inner_margin.sum().x - }), - PanelSide::Horizontal(_) => panel.default_size.unwrap_or_else(|| { - ui.style().spacing.interact_size.y + frame.inner_margin.sum().y - }), - } - } - } - - fn panel_rect(panel: &Panel, available_rect: Rect, mut size: f32) -> Rect { - let side = panel.side; - let size_range = panel.size_range; - - let mut panel_rect = available_rect; - - match side { - PanelSide::Vertical(_) => { - size = clamp_to_range(size, size_range).at_most(available_rect.width()); - } - PanelSide::Horizontal(_) => { - size = clamp_to_range(size, size_range).at_most(available_rect.height()); - } - } - side.set_rect_size(&mut panel_rect, size); - panel_rect - } - - fn prepare_resizing_response(&mut self, is_resizing: bool, pointer: Option) { - let side = self.panel.side; - let size_range = self.panel.size_range; - - if is_resizing && let Some(pointer) = pointer { - match side { - PanelSide::Vertical(side) => { - self.size = (pointer.x - side.side_x(self.panel_rect)).abs(); - self.size = - clamp_to_range(self.size, size_range).at_most(self.available_rect.width()); - } - PanelSide::Horizontal(side) => { - self.size = (pointer.y - side.side_y(self.panel_rect)).abs(); - self.size = - clamp_to_range(self.size, size_range).at_most(self.available_rect.height()); - } - } - - side.set_rect_size(&mut self.panel_rect, self.size); + Self::Left => UiKind::LeftPanel, + Self::Right => UiKind::RightPanel, + Self::Top => UiKind::TopPanel, + Self::Bottom => UiKind::BottomPanel, } } } @@ -288,14 +160,23 @@ impl<'a> PanelSizer<'a> { /// /// 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, @@ -303,13 +184,35 @@ pub struct Panel { resizable: bool, show_separator_line: bool, - /// The size is defined as being either the width for a Vertical Panel - /// or the height for a Horizontal Panel. - default_size: Option, + /// _Outer_ size (including [`Frame`] margin & border): + /// the width for a vertical panel, or the height for a horizontal panel. + default_outer_size: Option, - /// The size is defined as being either the width for a Vertical Panel - /// or the height for a Horizontal Panel. - size_range: Rangef, + /// _Outer_ size range (including [`Frame`] margin & border): + /// the width for a vertical panel, or the height for a horizontal panel. + outer_size_range: Rangef, + + /// `1.0` = panel fully visible (the normal case), + /// `0.0` = panel fully slid off-screen toward its fixed edge. + /// + /// 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 { @@ -317,14 +220,14 @@ impl Panel { /// /// The id should be globally unique, e.g. `Id::new("my_left_panel")`. pub fn left(id: impl Into) -> Self { - Self::new(PanelSide::LEFT, id) + Self::new(PanelSide::Left, id) } /// Create a right panel. /// /// The id should be globally unique, e.g. `Id::new("my_right_panel")`. pub fn right(id: impl Into) -> Self { - Self::new(PanelSide::RIGHT, id) + Self::new(PanelSide::Right, id) } /// Create a top panel. @@ -333,7 +236,7 @@ impl Panel { /// /// By default this is NOT resizable. pub fn top(id: impl Into) -> Self { - Self::new(PanelSide::TOP, id).resizable(false) + Self::new(PanelSide::Top, id).resizable(false) } /// Create a bottom panel. @@ -342,21 +245,21 @@ impl Panel { /// /// By default this is NOT resizable. pub fn bottom(id: impl Into) -> Self { - Self::new(PanelSide::BOTTOM, id).resizable(false) + Self::new(PanelSide::Bottom, id).resizable(false) } /// Create a panel. /// /// The id should be globally unique, e.g. `Id::new("my_panel")`. fn new(side: PanelSide, id: impl Into) -> Self { - let default_size: Option = match side { - PanelSide::Vertical(_) => Some(200.0), - PanelSide::Horizontal(_) => None, + let default_outer_size: Option = match side { + PanelSide::Left | PanelSide::Right => Some(200.0), + PanelSide::Top | PanelSide::Bottom => None, }; - let size_range: Rangef = match side { - PanelSide::Vertical(_) => Rangef::new(96.0, f32::INFINITY), - PanelSide::Horizontal(_) => Rangef::new(20.0, f32::INFINITY), + let outer_size_range: Rangef = match side { + PanelSide::Left | PanelSide::Right => Rangef::new(96.0, f32::INFINITY), + PanelSide::Top | PanelSide::Bottom => Rangef::new(20.0, f32::INFINITY), }; Self { @@ -365,8 +268,11 @@ impl Panel { frame: None, resizable: true, show_separator_line: true, - default_size, - size_range, + default_outer_size, + outer_size_range, + slide_fraction: 1.0, + resize_id_source: None, + collapse_threshold: None, } } @@ -402,10 +308,10 @@ impl Panel { /// The initial wrapping width of the [`Panel`], including margins. #[inline] pub fn default_size(mut self, default_size: f32) -> Self { - self.default_size = Some(default_size); - self.size_range = Rangef::new( - self.size_range.min.at_most(default_size), - self.size_range.max.at_least(default_size), + self.default_outer_size = Some(default_size); + self.outer_size_range = Rangef::new( + self.outer_size_range.min.at_most(default_size), + self.outer_size_range.max.at_least(default_size), ); self } @@ -413,14 +319,14 @@ impl Panel { /// Minimum size of the panel, including margins. #[inline] pub fn min_size(mut self, min_size: f32) -> Self { - self.size_range = Rangef::new(min_size, self.size_range.max.at_least(min_size)); + self.outer_size_range = Rangef::new(min_size, self.outer_size_range.max.at_least(min_size)); self } /// Maximum size of the panel, including margins. #[inline] pub fn max_size(mut self, max_size: f32) -> Self { - self.size_range = Rangef::new(self.size_range.min.at_most(max_size), max_size); + self.outer_size_range = Rangef::new(self.outer_size_range.min.at_most(max_size), max_size); self } @@ -428,18 +334,18 @@ impl Panel { #[inline] pub fn size_range(mut self, size_range: impl Into) -> Self { let size_range = size_range.into(); - self.default_size = self - .default_size + self.default_outer_size = self + .default_outer_size .map(|default_size| clamp_to_range(default_size, size_range)); - self.size_range = size_range; + self.outer_size_range = size_range; self } /// Enforce this exact size, including margins. #[inline] pub fn exact_size(mut self, size: f32) -> Self { - self.default_size = Some(size); - self.size_range = Rangef::point(size); + self.default_outer_size = Some(size); + self.outer_size_range = Rangef::point(size); self } @@ -451,161 +357,240 @@ impl Panel { } } -// Deprecated -impl Panel { - #[deprecated = "Renamed default_size"] - pub fn default_width(self, default_size: f32) -> Self { - self.default_size(default_size) - } - - #[deprecated = "Renamed min_size"] - pub fn min_width(self, min_size: f32) -> Self { - self.min_size(min_size) - } - - #[deprecated = "Renamed max_size"] - pub fn max_width(self, max_size: f32) -> Self { - self.max_size(max_size) - } - - #[deprecated = "Renamed size_range"] - pub fn width_range(self, size_range: impl Into) -> Self { - self.size_range(size_range) - } - - #[deprecated = "Renamed exact_size"] - pub fn exact_width(self, size: f32) -> Self { - self.exact_size(size) - } - - #[deprecated = "Renamed default_size"] - pub fn default_height(self, default_size: f32) -> Self { - self.default_size(default_size) - } - - #[deprecated = "Renamed min_size"] - pub fn min_height(self, min_size: f32) -> Self { - self.min_size(min_size) - } - - #[deprecated = "Renamed max_size"] - pub fn max_height(self, max_size: f32) -> Self { - self.max_size(max_size) - } - - #[deprecated = "Renamed size_range"] - pub fn height_range(self, size_range: impl Into) -> Self { - self.size_range(size_range) - } - - #[deprecated = "Renamed exact_size"] - pub fn exact_height(self, size: f32) -> Self { - self.exact_size(size) - } -} - // 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 at the top level. - #[deprecated = "Use show_inside() instead"] - pub fn show( - self, - ctx: &Context, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> InnerResponse { - self.show_dyn(ctx, Box::new(add_contents)) - } - - /// Show the panel if `is_expanded` is `true`, - /// otherwise don't show it, but with a nice animation between collapsed and expanded. - #[deprecated = "Use show_animated_inside() instead"] - pub fn show_animated( - self, - ctx: &Context, - is_expanded: bool, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> Option> { - #![expect(deprecated)] - - let how_expanded = animate_expansion(ctx, self.id.with("animation"), is_expanded); - - let animated_panel = self.get_animated_panel(ctx, is_expanded)?; - - if how_expanded < 1.0 { - // Show a fake panel in this in-between animation state: - animated_panel.show(ctx, |_ui| {}); - None - } else { - // Show the real panel: - Some(animated_panel.show(ctx, add_contents)) - } - } - - /// Show the panel if `is_expanded` is `true`, - /// otherwise don't show it, but with a nice animation between collapsed and expanded. - pub fn show_animated_inside( + /// 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. + /// + /// `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.ctx(), 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); - // Get either the fake or the real panel to animate - let Some(animated_panel) = self.get_animated_panel(ui.ctx(), is_expanded) else { // 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 { + 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 }; - if how_expanded < 1.0 { - // Show a fake panel in this in-between animation state: - animated_panel.show_inside(ui, |_ui| {}); - None - } else { - // Show the real panel: - Some(animated_panel.show_inside(ui, add_contents)) - } + Some(panel.show_inside_dyn(ui, Some(is_expanded), Box::new(add_contents))) } - /// Show either a collapsed or a expanded panel, with a nice animation between. - #[deprecated = "Use show_animated_between_inside() instead"] - pub fn show_animated_between( - ctx: &Context, - is_expanded: bool, + /// 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. + /// + /// The `collapsed_panel` is shown only when fully collapsed; during the + /// animation, the `expanded_panel` slides in/out toward its fixed edge, + /// interpolating its visible size between the two panels' sizes. + /// `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 + /// 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; + /// // `.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_switched( + /// ui, + /// &mut is_expanded, + /// collapsed, + /// expanded, + /// |ui, expanded| { + /// if expanded { + /// ui.heading("Expanded"); + /// ui.label("More content here…"); + /// } else { + /// ui.label("Collapsed toolbar"); + /// } + /// }, + /// ); + /// ui.toggle_value(&mut is_expanded, "Expand"); + /// # }); + /// ``` + pub fn show_switched( + ui: &mut Ui, + is_expanded: &mut bool, collapsed_panel: Self, expanded_panel: Self, - add_contents: impl FnOnce(&mut Ui, f32) -> R, - ) -> Option> { - #![expect(deprecated)] + add_contents: impl FnOnce(&mut Ui, bool) -> R, + ) -> InnerResponse { + 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); - let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); + // 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()); - // Get either the fake or the real panel to animate - let animated_between_panel = - Self::get_animated_between_panel(ctx, is_expanded, collapsed_panel, expanded_panel); - - if 0.0 == how_expanded { - Some(animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded))) - } else if how_expanded < 1.0 { - // Show animation: - animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded)); - None + 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 { - Some(animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded))) + 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 = *is_expanded || 0.5 < how_expanded; + + if how_expanded == 0.0 { + // 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 expanded_size = expanded_panel.outer_size(ui); + 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 + }; + 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 + }; + // 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)), + ) } } - /// Show either a collapsed or a expanded panel, with a nice animation between. + /// 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, @@ -613,344 +598,412 @@ impl Panel { expanded_panel: Self, add_contents: impl FnOnce(&mut Ui, f32) -> R, ) -> InnerResponse { - let how_expanded = - animate_expansion(ui.ctx(), expanded_panel.id.with("animation"), is_expanded); - - let animated_between_panel = Self::get_animated_between_panel( - ui.ctx(), - is_expanded, + let mut is_expanded = is_expanded; + Self::show_switched( + ui, + &mut is_expanded, collapsed_panel, expanded_panel, - ); - - if 0.0 == how_expanded { - animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) - } else if how_expanded < 1.0 { - // Show animation: - animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) - } else { - animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) - } + |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, - ui: &mut Ui, + 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 size_range = self.size_range; - // Define the sizing of the panel. - let mut panel_sizer = PanelSizer::new(&self, ui); + let available_rect = parent_ui.available_rect_before_wrap(); + + { + // 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 - ui.ctx() - .check_for_id_clash(id, panel_sizer.panel_rect, "Panel"); + parent_ui.check_for_id_clash(id, outer_rect, "Panel"); - if self.resizable { - // Prepare the resizable panel to avoid frame latency in the resize - self.prepare_resizable_panel(&mut panel_sizer, ui); + // 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. + // 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(); + 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; + } + } + } } // NOTE(shark98): This must be **after** the resizable preparation, as the size // may change and round_ui() uses the size. - panel_sizer.panel_rect = panel_sizer.panel_rect.round_ui(); + outer_rect = outer_rect.round_ui(); - let mut panel_ui = ui.new_child( + // Slide animation: translate the panel off-screen toward its fixed edge. + // When `slide_fraction == 1.0` this is a no-op. + let slide_distance = (1.0 - self.slide_fraction) * outer_size; + let shifted_outer_rect = if slide_distance == 0.0 { + outer_rect + } else { + outer_rect + .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(max_rect); + + let mut panel_ui = parent_ui.new_child( UiBuilder::new() .id_salt(id) .ui_stack_info(UiStackInfo::new(side.ui_kind())) - .max_rect(panel_sizer.panel_rect) + .max_rect(shifted_outer_rect) .layout(Layout::top_down(Align::Min)), ); - panel_ui.expand_to_include_rect(panel_sizer.panel_rect); - panel_ui.set_clip_rect(panel_sizer.panel_rect); // If we overflow, don't do so visibly (#4475) + panel_ui.expand_to_include_rect(shifted_outer_rect); + panel_ui.set_clip_rect(visible_outer_rect); // Hides the off-screen part during a slide; also prevents overflow (#4475). - let inner_response = panel_sizer.frame.show(&mut panel_ui, |ui| { - match side { - PanelSide::Vertical(_) => { - ui.set_min_height(ui.max_rect().height()); // Make sure the frame fills the full height - ui.set_min_width( - (size_range.min - panel_sizer.frame.inner_margin.sum().x).at_least(0.0), - ); - } - PanelSide::Horizontal(_) => { - ui.set_min_width(ui.max_rect().width()); // Make the frame fill full width - ui.set_min_height( - (size_range.min - panel_sizer.frame.inner_margin.sum().y).at_least(0.0), - ); - } + let axis = side.axis(); + let panel_axis_min = + (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 { + content_ui.set_min_height(cross_axis_size); + content_ui.set_min_width(panel_axis_min); + } else { + content_ui.set_min_width(cross_axis_size); + content_ui.set_min_height(panel_axis_min); } - add_contents(ui) + add_contents(content_ui) }); - let rect = inner_response.response.rect; - - { - let mut cursor = ui.cursor(); - match side { - PanelSide::Vertical(side) => match side { - VerticalSide::Left => cursor.min.x = rect.max.x, - VerticalSide::Right => cursor.max.x = rect.min.x, - }, - PanelSide::Horizontal(side) => match side { - HorizontalSide::Top => cursor.min.y = rect.max.y, - HorizontalSide::Bottom => cursor.max.y = rect.min.y, - }, - } - ui.set_cursor(cursor); + 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); } - ui.expand_to_include_rect(rect); + // `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(max_rect); - let mut resize_hover = false; - let mut is_resizing = false; - if resizable { + { + let mut cursor = parent_ui.cursor(); + match side { + PanelSide::Left | PanelSide::Top => { + cursor.min[axis] = visible_outer_rect.max[axis]; + } + PanelSide::Right | PanelSide::Bottom => { + cursor.max[axis] = visible_outer_rect.min[axis]; + } + } + parent_ui.set_cursor(cursor); + } + + parent_ui.expand_to_include_rect(visible_outer_rect); + + let (resize_hover, is_resizing) = if resizable { // 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. - (resize_hover, is_resizing) = self.resize_panel(&panel_sizer, ui); - } + let resize_response = self.resize_panel(shifted_outer_rect, parent_ui); + (resize_response.hovered(), resize_response.dragged()) + } else { + (false, false) + }; if resize_hover || is_resizing { - ui.set_cursor_icon(self.cursor_icon(&panel_sizer)); + parent_ui.set_cursor_icon(self.cursor_icon(outer_size)); } - PanelState { rect }.store(ui.ctx(), id); + 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, + } + .store(parent_ui, id); + } + + // Hide the separator once the panel is mostly slid off β€” at that point + // the line would just be a stray dash hovering near the parent edge. + if 0.01 < self.slide_fraction { let stroke = if is_resizing { - ui.style().visuals.widgets.active.fg_stroke // highly visible + parent_ui.style().visuals.widgets.active.fg_stroke // highly visible } else if resize_hover { - ui.style().visuals.widgets.hovered.fg_stroke // highly visible + parent_ui.style().visuals.widgets.hovered.fg_stroke // highly visible } else if show_separator_line { // TODO(emilk): distinguish resizable from non-resizable - ui.style().visuals.widgets.noninteractive.bg_stroke // dim + parent_ui.style().visuals.widgets.noninteractive.bg_stroke // dim } else { Stroke::NONE }; // TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done - match side { - PanelSide::Vertical(side) => { - let x = side.opposite().side_x(rect) + 0.5 * side.sign() * stroke.width; - ui.painter() - .vline(x, panel_sizer.panel_rect.y_range(), stroke); - } - PanelSide::Horizontal(side) => { - let y = side.opposite().side_y(rect) + 0.5 * side.sign() * stroke.width; - ui.painter() - .hline(panel_sizer.panel_rect.x_range(), y, stroke); - } + let line_pos = side.resize_pos(shifted_outer_rect) + 0.5 * side.sign() * stroke.width; + let cross_range = shifted_outer_rect.range_along(side.cross_axis()); + if axis == 0 { + parent_ui.painter().vline(line_pos, cross_range, stroke); + } else { + parent_ui.painter().hline(cross_range, line_pos, stroke); } } inner_response } - /// Show the panel at the top level. - fn show_dyn<'c, R>( - self, - ctx: &Context, - add_contents: Box R + 'c>, - ) -> InnerResponse { - #![expect(deprecated)] - - let side = self.side; - let available_rect = ctx.available_rect(); - let mut panel_ui = Ui::new( - ctx.clone(), - self.id, - UiBuilder::new() - .layer_id(LayerId::background()) - .max_rect(available_rect), - ); - panel_ui.set_clip_rect(ctx.content_rect()); - panel_ui - .response() - .widget_info(|| WidgetInfo::new(WidgetType::Panel)); - - let inner_response = self.show_inside_dyn(&mut panel_ui, add_contents); - let rect = inner_response.response.rect; - - match side { - PanelSide::Vertical(side) => match side { - VerticalSide::Left => ctx.pass_state_mut(|state| { - state.allocate_left_panel(Rect::from_min_max(available_rect.min, rect.max)); - }), - VerticalSide::Right => ctx.pass_state_mut(|state| { - state.allocate_right_panel(Rect::from_min_max(rect.min, available_rect.max)); - }), - }, - PanelSide::Horizontal(side) => match side { - HorizontalSide::Top => { - ctx.pass_state_mut(|state| { - state.allocate_top_panel(Rect::from_min_max(available_rect.min, rect.max)); - }); - } - HorizontalSide::Bottom => { - ctx.pass_state_mut(|state| { - state.allocate_bottom_panel(Rect::from_min_max( - rect.min, - available_rect.max, - )); - }); - } - }, - } - inner_response + /// The configured [`Frame`], or the default side/top panel frame for this [`Ui`]. + fn resolve_frame(&self, ui: &Ui) -> Frame { + self.frame + .unwrap_or_else(|| Frame::side_top_panel(ui.style())) } - fn prepare_resizable_panel(&self, panel_sizer: &mut PanelSizer<'_>, ui: &Ui) { + /// 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 resize_response = ui.ctx().read_response(resize_id); - - if let Some(resize_response) = resize_response { - // NOTE(sharky98): The original code was initializing to - // false first, but it doesn't seem necessary. - let is_resizing = resize_response.dragged(); - let pointer = resize_response.interact_pointer_pos(); - panel_sizer.prepare_resizing_response(is_resizing, pointer); + let Some(resize_response) = ui.read_response(resize_id) else { + return; + }; + if !resize_response.dragged() { + return; } - } - - fn resize_panel(&self, panel_sizer: &PanelSizer<'_>, ui: &Ui) -> (bool, bool) { - let (resize_x, resize_y, amount): (Rangef, Rangef, Vec2) = match self.side { - PanelSide::Vertical(side) => { - let resize_x = side.opposite().side_x(panel_sizer.panel_rect); - let resize_y = panel_sizer.panel_rect.y_range(); - ( - Rangef::from(resize_x..=resize_x), - resize_y, - vec2(ui.style().interaction.resize_grab_radius_side, 0.0), - ) - } - PanelSide::Horizontal(side) => { - let resize_x = panel_sizer.panel_rect.x_range(); - let resize_y = side.opposite().side_y(panel_sizer.panel_rect); - ( - resize_x, - Rangef::from(resize_y..=resize_y), - vec2(0.0, ui.style().interaction.resize_grab_radius_side), - ) - } + let Some(pointer) = resize_response.interact_pointer_pos() else { + return; }; - let resize_id = self.id.with("__resize"); + // 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(); + 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_to_range(raw, self.outer_size_range) + } + + 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()); + let (resize_x, resize_y) = if self.side.axis() == 0 { + (panel_axis_range, cross_range) + } else { + (cross_range, panel_axis_range) + }; + let amount = ui.style().interaction.resize_grab_radius_side * self.side.axis_unit(); + + // 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, panel_sizer: &PanelSizer<'_>) -> CursorIcon { - if panel_sizer.size <= self.size_range.min { - match self.side { - PanelSide::Vertical(side) => match side { - VerticalSide::Left => CursorIcon::ResizeEast, - VerticalSide::Right => CursorIcon::ResizeWest, - }, - PanelSide::Horizontal(side) => match side { - HorizontalSide::Top => CursorIcon::ResizeSouth, - HorizontalSide::Bottom => CursorIcon::ResizeNorth, - }, - } - } else if panel_sizer.size < self.size_range.max { - match self.side { - PanelSide::Vertical(_) => CursorIcon::ResizeHorizontal, - PanelSide::Horizontal(_) => CursorIcon::ResizeVertical, - } + 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 { - match self.side { - PanelSide::Vertical(side) => match side { - VerticalSide::Left => CursorIcon::ResizeWest, - VerticalSide::Right => CursorIcon::ResizeEast, - }, - PanelSide::Horizontal(side) => match side { - HorizontalSide::Top => CursorIcon::ResizeNorth, - HorizontalSide::Bottom => CursorIcon::ResizeSouth, - }, - } - } - } - - /// Get the real or fake panel to animate if `is_expanded` is `true`. - fn get_animated_panel(self, ctx: &Context, is_expanded: bool) -> Option { - let how_expanded = animate_expansion(ctx, self.id.with("animation"), is_expanded); - - if 0.0 == how_expanded { - None - } else if how_expanded < 1.0 { - // Show a fake panel in this in-between animation state: - // TODO(emilk): move the panel out-of-screen instead of changing its width. - // Then we can actually paint it as it animates. - let expanded_size = Self::animated_size(ctx, &self); - let fake_size = how_expanded * expanded_size; - Some( - Self { - id: self.id.with("animating_panel"), - ..self - } - .resizable(false) - .exact_size(fake_size), - ) - } else { - // Show the real panel: - Some(self) - } - } - - /// Get either the collapsed or expended panel to animate. - fn get_animated_between_panel( - ctx: &Context, - is_expanded: bool, - collapsed_panel: Self, - expanded_panel: Self, - ) -> Self { - let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); - - if 0.0 == how_expanded { - collapsed_panel - } else if how_expanded < 1.0 { - let collapsed_size = Self::animated_size(ctx, &collapsed_panel); - let expanded_size = Self::animated_size(ctx, &expanded_panel); - - let fake_size = lerp(collapsed_size..=expanded_size, how_expanded); - - Self { - id: expanded_panel.id.with("animating_panel"), - ..expanded_panel - } - .resizable(false) - .exact_size(fake_size) - } else { - expanded_panel - } - } - - fn animated_size(ctx: &Context, panel: &Self) -> f32 { - let get_rect_state_size = |state: PanelState| match panel.side { - PanelSide::Vertical(_) => state.rect.width(), - PanelSide::Horizontal(_) => state.rect.height(), + self.outer_size_range.max }; - let get_spacing_size = || match panel.side { - PanelSide::Vertical(_) => ctx.global_style().spacing.interact_size.x, - PanelSide::Horizontal(_) => ctx.global_style().spacing.interact_size.y, - }; + if outer_size <= self.outer_size_range.min { + // Can only grow (toward the resizable side): + match self.side { + PanelSide::Left => CursorIcon::ResizeEast, + PanelSide::Right => CursorIcon::ResizeWest, + PanelSide::Top => CursorIcon::ResizeSouth, + PanelSide::Bottom => CursorIcon::ResizeNorth, + } + } else if outer_size < max_for_cursor { + if self.side.axis() == 0 { + CursorIcon::ResizeHorizontal + } else { + CursorIcon::ResizeVertical + } + } else { + // Can only shrink (toward the fixed side): + match self.side { + PanelSide::Left => CursorIcon::ResizeWest, + PanelSide::Right => CursorIcon::ResizeEast, + PanelSide::Top => CursorIcon::ResizeNorth, + PanelSide::Bottom => CursorIcon::ResizeSouth, + } + } + } - PanelState::load(ctx, panel.id) - .map(get_rect_state_size) - .or(panel.default_size) - .unwrap_or_else(get_spacing_size) + /// Slide the panel toward its fixed edge. `1.0` = fully visible, `0.0` = fully off-screen. + #[inline] + fn with_slide_fraction(mut self, slide_fraction: f32) -> Self { + 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 } } @@ -973,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, @@ -1008,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`]. @@ -1024,14 +1083,14 @@ impl CentralPanel { ) -> InnerResponse { let Self { frame } = self; - let panel_rect = ui.available_rect_before_wrap(); + let outer_rect = ui.available_rect_before_wrap(); let mut panel_ui = ui.new_child( UiBuilder::new() .ui_stack_info(UiStackInfo::new(UiKind::CentralPanel)) - .max_rect(panel_rect) + .max_rect(outer_rect) .layout(Layout::top_down(Align::Min)), ); - panel_ui.set_clip_rect(panel_rect); // If we overflow, don't do so visibly (#4475) + panel_ui.set_clip_rect(outer_rect); // If we overflow, don't do so visibly (#4475) let frame = frame.unwrap_or_else(|| Frame::central_panel(ui.style())); let response = frame.show(&mut panel_ui, |ui| { @@ -1044,61 +1103,9 @@ impl CentralPanel { response } - - /// Show the panel at the top level. - #[deprecated = "Use show_inside() instead"] - pub fn show( - self, - ctx: &Context, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> InnerResponse { - self.show_dyn(ctx, Box::new(add_contents)) - } - - /// Show the panel at the top level. - fn show_dyn<'c, R>( - self, - ctx: &Context, - add_contents: Box R + 'c>, - ) -> InnerResponse { - #![expect(deprecated)] - - let id = Id::new((ctx.viewport_id(), "central_panel")); - - let mut panel_ui = Ui::new( - ctx.clone(), - id, - UiBuilder::new() - .layer_id(LayerId::background()) - .max_rect(ctx.available_rect()), - ); - panel_ui.set_clip_rect(ctx.content_rect()); - - if false { - // TODO(emilk): @lucasmerlin shouldn't we enable this? - panel_ui - .response() - .widget_info(|| WidgetInfo::new(WidgetType::Panel)); - } - - let inner_response = self.show_inside_dyn(&mut panel_ui, add_contents); - - // Only inform ctx about what we actually used, so we can shrink the native window to fit. - ctx.pass_state_mut(|state| state.allocate_central_panel(inner_response.response.rect)); - - inner_response - } } fn clamp_to_range(x: f32, range: Rangef) -> f32 { let range = range.as_positive(); x.clamp(range.min, range.max) } - -// ---------------------------------------------------------------------------- - -#[deprecated = "Use Panel::left or Panel::right instead"] -pub type SidePanel = super::Panel; - -#[deprecated = "Use Panel::top or Panel::bottom instead"] -pub type TopBottomPanel = super::Panel; diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 0fb2a9f2a..2a9335ede 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -1,5 +1,3 @@ -#![expect(deprecated)] // This is a new, safe wrapper around the old `Memory::popup` API. - use std::iter::once; use emath::{Align, Pos2, Rect, RectAlign, Vec2, vec2}; @@ -87,7 +85,7 @@ pub enum PopupCloseBehavior { /// but in the popup's body CloseOnClickOutside, - /// Clicks will be ignored. Popup might be closed manually by calling [`crate::Memory::close_all_popups`] + /// Clicks will be ignored. Popup might be closed manually by calling [`Popup::close_all`] /// or by pressing the escape button IgnoreClicks, } @@ -474,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, @@ -666,10 +662,6 @@ impl Popup<'_> { } /// Open the given popup and close all others. - /// - /// If you are NOT using [`Popup::show`], you must - /// also call [`crate::Memory::keep_popup_open`] as long as - /// you're showing the popup. pub fn open_id(ctx: &Context, popup_id: Id) { ctx.memory_mut(|mem| mem.open_popup(popup_id)); } diff --git a/crates/egui/src/containers/resize.rs b/crates/egui/src/containers/resize.rs index 7ff943b3f..b6c086aca 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)] @@ -17,6 +17,13 @@ pub(crate) struct State { /// Externally requested size (e.g. by Window) for the next frame pub(crate) requested_size: Option, + + /// Minimum content width measured by a sizing pass at the start of the current + /// interactive resize. We clamp `desired_size.x` against this for the rest of + /// the drag so the user can't shrink the window past what the content actually + /// needs. Reset to `None` whenever a drag is not in progress. + #[cfg_attr(feature = "serde", serde(default))] + min_content_width: Option, } impl State { @@ -34,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, @@ -42,7 +49,7 @@ pub struct Resize { pub(crate) min_size: Vec2, pub(crate) max_size: Vec2, - default_size: Vec2, + pub(crate) default_size: Vec2, with_stroke: bool, } @@ -69,17 +76,10 @@ impl Resize { self } - /// A source for the unique [`Id`], e.g. `.id_source("second_resize_area")` or `.id_source(loop_index)`. - #[inline] - #[deprecated = "Renamed id_salt"] - pub fn id_source(self, id_salt: impl std::hash::Hash) -> Self { - self.id_salt(id_salt) - } - /// 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 } @@ -202,13 +202,14 @@ struct Prepared { corner_id: Option, state: State, content_ui: Ui, + sizing_pass: bool, } 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) }); @@ -228,6 +229,7 @@ impl Resize { desired_size: default_size, last_content_size: vec2(0.0, 0.0), requested_size: None, + min_content_width: None, } }); @@ -249,13 +251,25 @@ impl Resize { user_requested_size = Some(pointer_pos - position + 0.5 * corner_response.rect.size()); } - if let Some(user_requested_size) = user_requested_size { + let is_actively_resizing = user_requested_size.is_some(); + + // Drag just started: we don't yet know what the content's minimum width is. + // Run a one-frame sizing pass below to discover it. + let needs_sizing_pass = is_actively_resizing && state.min_content_width.is_none(); + + if let Some(mut user_requested_size) = user_requested_size { + if let Some(min_width) = state.min_content_width { + user_requested_size.x = user_requested_size.x.at_least(min_width); + } state.desired_size = user_requested_size; } else { // We are not being actively resized, so auto-expand to include size of last frame. // This prevents auto-shrinking if the contents contain width-filling widgets (separators etc) // but it makes a lot of interactions with [`Window`]s nicer. state.desired_size = state.desired_size.max(state.last_content_size); + // Drag ended (if any). Forget the cached min so the next drag re-measures it, + // in case content changed. + state.min_content_width = None; } state.desired_size = state @@ -265,7 +279,15 @@ impl Resize { // ------------------------------ - let inner_rect = Rect::from_min_size(position, state.desired_size); + // For the sizing pass, offer the tightest possible rect so widgets shrink to + // their natural minimum. We render the frame invisibly and discard it so the + // user never sees the squished layout; the measured min then clamps drags. + let inner_rect = if needs_sizing_pass { + ui.ctx().request_discard("Resize sizing pass"); + Rect::from_min_size(position, Vec2::new(self.min_size.x, state.desired_size.y)) + } else { + Rect::from_min_size(position, state.desired_size) + }; let mut content_clip_rect = inner_rect.expand(ui.visuals().clip_rect_margin); @@ -280,11 +302,13 @@ impl Resize { content_clip_rect = content_clip_rect.intersect(ui.clip_rect()); // Respect parent region - let mut content_ui = ui.new_child( - UiBuilder::new() - .ui_stack_info(UiStackInfo::new(UiKind::Resize)) - .max_rect(inner_rect), - ); + let mut ui_builder = UiBuilder::new() + .ui_stack_info(UiStackInfo::new(UiKind::Resize)) + .max_rect(inner_rect); + if needs_sizing_pass { + ui_builder = ui_builder.sizing_pass(); + } + let mut content_ui = ui.new_child(ui_builder); content_ui.set_clip_rect(content_clip_rect); Prepared { @@ -292,6 +316,7 @@ impl Resize { corner_id, state, content_ui, + sizing_pass: needs_sizing_pass, } } @@ -308,9 +333,17 @@ impl Resize { corner_id, mut state, content_ui, + sizing_pass, } = prepared; - state.last_content_size = content_ui.min_size(); + if sizing_pass { + // Remember the measured minimum so we can clamp the user's drag on subsequent frames. + // Don't touch `last_content_size`, it should keep reflecting the previously + // rendered content. + state.min_content_width = Some(content_ui.min_size().x); + } else { + state.last_content_size = content_ui.min_size(); + } // ------------------------------ diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index b99bcf5da..f95a5c2bd 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)] @@ -141,6 +141,51 @@ impl ScrollBarVisibility { ]; } +/// When [`ScrollArea`] should let the user scroll by dragging the content. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum DragScroll { + /// Never scroll on pointer drag. + Never, + + /// Only allow drag-to-scroll when a touch screen is detected + /// (see [`crate::InputState::has_touch_screen`]). The recommended default. + #[default] + OnTouch, + + /// Always allow drag-to-scroll, even with a mouse. + Always, +} + +impl DragScroll { + /// Whether drag-to-scroll is currently active. + /// + /// Checks if we have a touch screen (via [`crate::InputState::has_touch_screen`]) + /// when `self` is [`Self::OnTouch`]. + pub fn enabled(self, ctx: &Context) -> bool { + match self { + Self::Never => false, + Self::OnTouch => ctx.input(|i| i.has_touch_screen()), + Self::Always => true, + } + } +} + +impl BitOr for DragScroll { + type Output = Self; + + /// Combine two settings, picking the more permissive one. + /// `Always > OnTouch > Never`. + #[inline] + fn bitor(self, rhs: Self) -> Self::Output { + match (self, rhs) { + (Self::Always, _) | (_, Self::Always) => Self::Always, + (Self::OnTouch, _) | (_, Self::OnTouch) => Self::OnTouch, + (Self::Never, Self::Never) => Self::Never, + } + } +} + /// What is the source of scrolling for a [`ScrollArea`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -152,7 +197,11 @@ pub struct ScrollSource { pub scroll_bar: bool, /// Scroll the area by dragging the contents. - pub drag: bool, + /// + /// Defaults to [`DragScroll::OnTouch`]: only active when a touch screen is + /// detected. Set to [`DragScroll::Always`] to force it on, or + /// [`DragScroll::Never`] to disable. + pub drag: DragScroll, /// Scroll the area by scrolling (or shift scrolling) the mouse wheel with /// the mouse cursor over the [`ScrollArea`]. @@ -160,35 +209,40 @@ pub struct ScrollSource { } impl Default for ScrollSource { + /// `scroll_bar` and `mouse_wheel` enabled; `drag` set to [`DragScroll::OnTouch`]. fn default() -> Self { - Self::ALL + Self { + scroll_bar: true, + drag: DragScroll::OnTouch, + mouse_wheel: true, + } } } impl ScrollSource { pub const NONE: Self = Self { scroll_bar: false, - drag: false, + drag: DragScroll::Never, mouse_wheel: false, }; pub const ALL: Self = Self { scroll_bar: true, - drag: true, + drag: DragScroll::Always, mouse_wheel: true, }; pub const SCROLL_BAR: Self = Self { scroll_bar: true, - drag: false, + drag: DragScroll::Never, mouse_wheel: false, }; pub const DRAG: Self = Self { scroll_bar: false, - drag: true, + drag: DragScroll::Always, mouse_wheel: false, }; pub const MOUSE_WHEEL: Self = Self { scroll_bar: false, - drag: false, + drag: DragScroll::Never, mouse_wheel: true, }; @@ -201,13 +255,13 @@ impl ScrollSource { /// Is anything enabled? #[inline] pub fn any(&self) -> bool { - self.scroll_bar | self.drag | self.mouse_wheel + self.scroll_bar || self.drag != DragScroll::Never || self.mouse_wheel } /// Is everything enabled? #[inline] pub fn is_all(&self) -> bool { - self.scroll_bar & self.drag & self.mouse_wheel + self.scroll_bar && self.drag == DragScroll::Always && self.mouse_wheel } } @@ -290,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, @@ -423,17 +477,10 @@ impl ScrollArea { self } - /// A source for the unique [`Id`], e.g. `.id_source("second_scroll_area")` or `.id_source(loop_index)`. - #[inline] - #[deprecated = "Renamed id_salt"] - pub fn id_source(self, id_salt: impl std::hash::Hash) -> Self { - self.id_salt(id_salt) - } - /// 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 } @@ -530,32 +577,6 @@ impl ScrollArea { /// This can be used, for example, to optionally freeze scrolling while the user /// is typing text in a [`crate::TextEdit`] widget contained within the scroll area. /// - /// This controls both scrolling directions. - #[deprecated = "Use `ScrollArea::scroll_source()"] - #[inline] - pub fn enable_scrolling(mut self, enable: bool) -> Self { - self.scroll_source = if enable { - ScrollSource::ALL - } else { - ScrollSource::NONE - }; - self - } - - /// Can the user drag the scroll area to scroll? - /// - /// This is useful for touch screens. - /// - /// If `true`, the [`ScrollArea`] will sense drags. - /// - /// Default: `true`. - #[deprecated = "Use `ScrollArea::scroll_source()"] - #[inline] - pub fn drag_to_scroll(mut self, drag_to_scroll: bool) -> Self { - self.scroll_source.drag = drag_to_scroll; - self - } - /// What sources does the [`ScrollArea`] use for scrolling the contents. #[inline] pub fn scroll_source(mut self, scroll_source: ScrollSource) -> Self { @@ -709,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, @@ -803,72 +824,74 @@ impl ScrollArea { let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size); let dt = ui.input(|i| i.stable_dt).at_most(0.1); - let background_drag_response = - if scroll_source.drag && ui.is_enabled() && state.content_is_too_large.any() { - // Drag contents to scroll (for touch screens mostly). - // We must do this BEFORE adding content to the `ScrollArea`, - // or we will steal input from the widgets we contain. - let content_response_option = state - .interact_rect - .map(|rect| ui.interact(rect, id.with("area"), Sense::DRAG)); + let background_drag_response = if scroll_source.drag.enabled(ui.ctx()) + && ui.is_enabled() + && state.content_is_too_large.any() + { + // Drag contents to scroll (for touch screens mostly). + // We must do this BEFORE adding content to the `ScrollArea`, + // or we will steal input from the widgets we contain. + let content_response_option = state + .interact_rect + .map(|rect| ui.interact(rect, id.with("area"), Sense::DRAG)); + if content_response_option + .as_ref() + .is_some_and(|response| response.dragged()) + { + for d in 0..2 { + if direction_enabled[d] { + ui.input(|input| { + state.offset[d] -= input.pointer.delta()[d]; + }); + state.scroll_stuck_to_end[d] = false; + state.offset_target[d] = None; + } + } + } else { + // Apply the cursor velocity to the scroll area when the user releases the drag. if content_response_option .as_ref() - .is_some_and(|response| response.dragged()) + .is_some_and(|response| response.drag_stopped()) { - for d in 0..2 { - if direction_enabled[d] { - ui.input(|input| { - state.offset[d] -= input.pointer.delta()[d]; - }); - state.scroll_stuck_to_end[d] = false; - state.offset_target[d] = None; - } - } - } else { - // Apply the cursor velocity to the scroll area when the user releases the drag. - if content_response_option - .as_ref() - .is_some_and(|response| response.drag_stopped()) - { - state.vel = direction_enabled.to_vec2() - * ui.input(|input| input.pointer.velocity()); - } - for d in 0..2 { - // Kinetic scrolling - let stop_speed = 20.0; // Pixels per second. - let friction_coeff = 1000.0; // Pixels per second squared. + state.vel = + direction_enabled.to_vec2() * ui.input(|input| input.pointer.velocity()); + } + for d in 0..2 { + // Kinetic scrolling + let stop_speed = 20.0; // Pixels per second. + let friction_coeff = 1000.0; // Pixels per second squared. - let friction = friction_coeff * dt; - if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed { - state.vel[d] = 0.0; - } else { - state.vel[d] -= friction * state.vel[d].signum(); - // Offset has an inverted coordinate system compared to - // the velocity, so we subtract it instead of adding it - state.offset[d] -= state.vel[d] * dt; - ctx.request_repaint(); - } + let friction = friction_coeff * dt; + if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed { + state.vel[d] = 0.0; + } else { + state.vel[d] -= friction * state.vel[d].signum(); + // Offset has an inverted coordinate system compared to + // the velocity, so we subtract it instead of adding it + state.offset[d] -= state.vel[d] * dt; + ctx.request_repaint(); } } + } - // Set the desired mouse cursors. - if let Some(response) = &content_response_option { - if response.dragged() - && let Some(cursor) = on_drag_cursor - { - ui.set_cursor_icon(cursor); - } else if response.hovered() - && let Some(cursor) = on_hover_cursor - { - ui.set_cursor_icon(cursor); - } + // Set the desired mouse cursors. + if let Some(response) = &content_response_option { + if response.dragged() + && let Some(cursor) = on_drag_cursor + { + ui.set_cursor_icon(cursor); + } else if response.hovered() + && let Some(cursor) = on_hover_cursor + { + ui.set_cursor_icon(cursor); } + } - content_response_option - } else { - None - }; + content_response_option + } else { + None + }; // Scroll with an animation if we have a target offset (that hasn't been cleared by the code // above). @@ -1062,6 +1085,8 @@ impl Prepared { .ctx() .pass_state_mut(|state| std::mem::take(&mut state.scroll_delta)); + let mut had_explicit_scroll_adjustment = Vec2b::FALSE; + for d in 0..2 { // PassState::scroll_delta is inverted from the way we apply the delta, so we need to negate it. let mut delta = -scroll_delta.0[d]; @@ -1133,6 +1158,10 @@ impl Prepared { ui.request_repaint(); } } + + if delta != 0.0 { + had_explicit_scroll_adjustment[d] = true; + } } // Restore scroll target meant for ScrollAreas up the stack (if any) @@ -1234,8 +1263,10 @@ impl Prepared { // Paint the bars: let scroll_bar_rect = scroll_bar_rect.unwrap_or(inner_rect); for d in 0..2 { - // maybe force increase in offset to keep scroll stuck to end position - if stick_to_end[d] && state.scroll_stuck_to_end[d] { + // maybe force increase in offset to keep scroll stuck to end position, + // unless this axis had an explicit scroll adjustment. + if stick_to_end[d] && state.scroll_stuck_to_end[d] && !had_explicit_scroll_adjustment[d] + { state.offset[d] = content_size[d] - inner_rect.size()[d]; } @@ -1487,16 +1518,25 @@ impl Prepared { state.offset = state.offset.min(available_offset); state.offset = state.offset.max(Vec2::ZERO); + let suppress_stuck_recompute = Vec2b::new( + had_explicit_scroll_adjustment[0] && state.offset_target[0].is_some(), + had_explicit_scroll_adjustment[1] && state.offset_target[1].is_some(), + ); + // Is scroll handle at end of content, or is there no scrollbar // yet (not enough content), but sticking is requested? If so, enter sticky mode. // Only has an effect if stick_to_end is enabled but we save in // state anyway so that entering sticky mode at an arbitrary time // has appropriate effect. + // Keep explicit target requests from being reclassified as "still stuck" in the same + // frame, otherwise animated scroll-to requests never get a chance to pull away from the end. state.scroll_stuck_to_end = Vec2b::new( - (state.offset[0] == available_offset[0]) - || (self.stick_to_end[0] && available_offset[0] < 0.0), - (state.offset[1] == available_offset[1]) - || (self.stick_to_end[1] && available_offset[1] < 0.0), + !suppress_stuck_recompute[0] + && ((state.offset[0] == available_offset[0]) + || (stick_to_end[0] && available_offset[0] < 0.0)), + !suppress_stuck_recompute[1] + && ((state.offset[1] == available_offset[1]) + || (stick_to_end[1] && available_offset[1] < 0.0)), ); state.show_scroll = show_scroll_this_frame; diff --git a/crates/egui/src/containers/tooltip.rs b/crates/egui/src/containers/tooltip.rs index 78c5a726b..22c319569 100644 --- a/crates/egui/src/containers/tooltip.rs +++ b/crates/egui/src/containers/tooltip.rs @@ -16,24 +16,6 @@ pub struct Tooltip<'a> { } impl Tooltip<'_> { - /// Show a tooltip that is always open. - #[deprecated = "Use `Tooltip::always_open` instead."] - pub fn new( - parent_widget: Id, - ctx: Context, - anchor: impl Into, - parent_layer: LayerId, - ) -> Self { - Self { - popup: Popup::new(parent_widget, ctx, anchor.into(), parent_layer) - .kind(PopupKind::Tooltip) - .gap(4.0) - .sense(Sense::hover()), - parent_layer, - parent_widget, - } - } - /// Show a tooltip that is always open. pub fn always_open( ctx: Context, diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index 5ade37014..cf6c58f88 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -1,16 +1,62 @@ // WARNING: the code in here is horrible. It is a behemoth that needs breaking up into simpler parts. -use std::sync::Arc; - use emath::GuiRounding as _; -use epaint::{CornerRadiusF32, RectShape}; +use epaint::CornerRadiusF32; use crate::collapsing_header::CollapsingState; use crate::*; -use super::scroll_area::{ScrollBarVisibility, ScrollSource}; +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: @@ -33,9 +79,9 @@ use super::{Area, Frame, Resize, ScrollArea, area, resize}; /// Note that this is NOT a native OS window. /// To create a new native OS window, use [`crate::Context::show_viewport_deferred`]. #[must_use = "You should call .show()"] -pub struct Window<'open> { - title: WidgetText, - open: Option<&'open mut bool>, +pub struct Window<'a> { + title: Atoms<'a>, + open: Option<&'a mut bool>, area: Area, frame: Option, resize: Resize, @@ -44,14 +90,16 @@ pub struct Window<'open> { default_open: bool, with_title_bar: bool, fade_out: bool, + auto_sized: bool, + drag_area: WindowDrag, } -impl<'open> Window<'open> { +impl<'a> Window<'a> { /// The window title is used as a unique [`Id`] and must be unique, and should not change. /// This is true even if you disable the title bar with `.title_bar(false)`. /// If you need a changing title, you must call `window.id(…)` with a fixed id. - pub fn new(title: impl Into) -> Self { - let title = title.into().fallback_text_style(TextStyle::Heading); + pub fn new(title: impl IntoAtoms<'a>) -> Self { + let title: Atoms<'_> = title.into_atoms(); let area = Area::new(Id::new(title.text())).kind(UiKind::Window); Self { title, @@ -61,12 +109,14 @@ impl<'open> Window<'open> { resize: Resize::default() .with_stroke(false) .min_size([96.0, 32.0]) - .default_size([340.0, 420.0]), // Default inner size of a window - scroll: ScrollArea::neither().auto_shrink(false), + .default_size([340.0, 420.0]), // Default outer size of a window (includes frame margins, stroke, and title bar) + scroll: ScrollArea::neither().auto_shrink(false).content_margin(0.0), collapsible: true, default_open: true, with_title_bar: true, fade_out: true, + auto_sized: false, + drag_area: WindowDrag::default(), } } @@ -118,7 +168,7 @@ impl<'open> Window<'open> { /// * If `*open == true`, the window will have a close button. /// * If the close button is pressed, `*open` will be set to `false`. #[inline] - pub fn open(mut self, open: &'open mut bool) -> Self { + pub fn open(mut self, open: &'a mut bool) -> Self { self.open = Some(open); self } @@ -142,12 +192,29 @@ impl<'open> Window<'open> { } /// 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 { @@ -213,6 +280,9 @@ impl<'open> Window<'open> { } /// Set minimum size of the window, equivalent to calling both `min_width` and `min_height`. + /// + /// The size refers to the *outer* window size, including the frame's `inner_margin`, + /// `outer_margin`, `stroke`, and the title bar. #[inline] pub fn min_size(mut self, min_size: impl Into) -> Self { self.resize = self.resize.min_size(min_size); @@ -234,6 +304,9 @@ impl<'open> Window<'open> { } /// Set maximum size of the window, equivalent to calling both `max_width` and `max_height`. + /// + /// The size refers to the *outer* window size, including the frame's `inner_margin`, + /// `outer_margin`, `stroke`, and the title bar. #[inline] pub fn max_size(mut self, max_size: impl Into) -> Self { self.resize = self.resize.max_size(max_size); @@ -262,7 +335,7 @@ impl<'open> Window<'open> { self } - /// Constrains this window to [`Context::screen_rect`]. + /// Constrains this window to [`Context::content_rect`]. /// /// To change the area to constrain to, use [`Self::constrain_to`]. /// @@ -275,7 +348,7 @@ impl<'open> Window<'open> { /// Constrain the movement of the window to the given rectangle. /// - /// For instance: `.constrain_to(ctx.screen_rect())`. + /// For instance: `.constrain_to(ctx.content_rect())`. #[inline] pub fn constrain_to(mut self, constrain_rect: Rect) -> Self { self.area = self.area.constrain_to(constrain_rect); @@ -320,6 +393,9 @@ impl<'open> Window<'open> { } /// Set initial size of the window. + /// + /// The size refers to the *outer* window size, including frame margins, stroke, + /// and the title bar. #[inline] pub fn default_size(mut self, default_size: impl Into) -> Self { let default_size: Vec2 = default_size.into(); @@ -345,6 +421,9 @@ impl<'open> Window<'open> { } /// Sets the window size and prevents it from being resized by dragging its edges. + /// + /// The size refers to the *outer* window size, including the frame's `inner_margin`, + /// `outer_margin`, `stroke`, and the title bar. #[inline] pub fn fixed_size(mut self, size: impl Into) -> Self { self.resize = self.resize.fixed_size(size); @@ -399,6 +478,7 @@ impl<'open> Window<'open> { pub fn auto_sized(mut self) -> Self { self.resize = self.resize.auto_sized(); self.scroll = ScrollArea::neither(); + self.auto_sized = true; self } @@ -425,11 +505,13 @@ impl<'open> Window<'open> { self } - /// Enable/disable scrolling on the window by dragging with the pointer. `true` by default. + /// Controls scrolling the window by dragging the contents with the pointer. /// - /// See [`ScrollArea::drag_to_scroll`] for more. + /// Defaults to [`DragScroll::OnTouch`] β€” only active when a touch screen is detected. + /// + /// See [`ScrollArea::scroll_source`] and [`DragScroll`] for more. #[inline] - pub fn drag_to_scroll(mut self, drag_to_scroll: bool) -> Self { + pub fn drag_to_scroll(mut self, drag_to_scroll: DragScroll) -> Self { self.scroll = self.scroll.scroll_source(ScrollSource { drag: drag_to_scroll, ..Default::default() @@ -473,13 +555,72 @@ impl Window<'_> { default_open, 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 header_color = - frame.map_or_else(|| style.visuals.widgets.open.weak_bg_fill, |f| f.fill); - let mut window_frame = frame.unwrap_or_else(|| Frame::window(&style)); + let window_frame = frame.unwrap_or_else(|| Frame::window(&style)); + + // We apply the window margin by using the `ScrollArea::content_margin`. + let window_margin = window_frame.inner_margin; + let window_frame = window_frame.inner_margin(0.0); let is_explicitly_closed = matches!(open, Some(false)); let is_open = !is_explicitly_closed || ctx.memory(|mem| mem.everything_is_visible()); @@ -507,64 +648,55 @@ impl Window<'_> { let on_top = Some(area_layer_id) == ctx.top_layer_id(); let mut area = area.begin(ctx); - area.with_widget_info(|| WidgetInfo::labeled(WidgetType::Window, true, title.text())); + // 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())); + } - // Calculate roughly how much larger the full window inner size is compared to the content rect - let (title_bar_height_with_margin, title_content_spacing) = if with_title_bar { - let title_bar_inner_height = ctx - .fonts_mut(|fonts| title.font_height(fonts, &style)) - .at_least(style.spacing.interact_size.y); - let title_bar_inner_height = title_bar_inner_height + window_frame.inner_margin.sum().y; - let half_height = (title_bar_inner_height / 2.0).round() as _; - window_frame.corner_radius.ne = window_frame.corner_radius.ne.clamp(0, half_height); - window_frame.corner_radius.nw = window_frame.corner_radius.nw.clamp(0, half_height); - - let title_content_spacing = if is_collapsed { - 0.0 - } else { - window_frame.stroke.width - }; - (title_bar_inner_height, title_content_spacing) - } else { - (0.0, 0.0) - }; + area.with_widget_info(|| { + WidgetInfo::labeled( + WidgetType::Window, + true, + title.text().as_deref().unwrap_or(""), + ) + }); { // Prevent window from becoming larger than the constrain rect. + // `resize.max_size` is still in outer-window coordinates here, matching `constrain_rect`. let constrain_rect = area.constrain_rect(); let max_width = constrain_rect.width(); - let max_height = - constrain_rect.height() - title_bar_height_with_margin - title_content_spacing; + let max_height = constrain_rect.height(); resize.max_size.x = resize.max_size.x.min(max_width); resize.max_size.y = resize.max_size.y.min(max_height); } - // First check for resize to avoid frame delay: - let last_frame_outer_rect = area.state().rect(); - let resize_interaction = do_resize_interaction( - ctx, - possible, - area.id(), - area_layer_id, - last_frame_outer_rect, - window_frame, - ); - + // The user-supplied min/max/default sizes on `Window` refer to the *outer* window size + // (the total footprint, including frame margins, stroke, and title bar). `Resize` sizes + // the title bar + inner content area, so we subtract the extra frame margin (the part + // outside of `Resize`). { - let margins = window_frame.total_margin().sum() - + vec2(0.0, title_bar_height_with_margin + title_content_spacing); - - resize_response( - resize_interaction, - ctx, - margins, - area_layer_id, - &mut area, - resize_id, - ); + let frame_margin = window_frame.total_margin().sum(); + resize.min_size = (resize.min_size - frame_margin).at_least(Vec2::ZERO); + resize.max_size = (resize.max_size - frame_margin).at_least(Vec2::ZERO); + resize.default_size = (resize.default_size - frame_margin).at_least(Vec2::ZERO); } let mut area_content_ui = area.content_ui(ctx); + if is_open { // `Area` already takes care of fade-in animations, // so we only need to handle fade-out animations here. @@ -573,55 +705,41 @@ impl Window<'_> { } let content_inner = { - // BEGIN FRAME -------------------------------- - let mut frame = window_frame.begin(&mut area_content_ui); - - let show_close_button = open.is_some(); - - let where_to_put_header_background = &area_content_ui.painter().add(Shape::Noop); - - let title_bar = if with_title_bar { - let title_bar = TitleBar::new( - &frame.content_ui, - title, - show_close_button, - collapsible, - window_frame, - title_bar_height_with_margin, - ); - resize.min_size.x = resize.min_size.x.at_least(title_bar.inner_rect.width()); // Prevent making window smaller than title bar width - - frame.content_ui.set_min_size(title_bar.inner_rect.size()); - - // Skip the title bar (and separator): - if is_collapsed { - frame.content_ui.add_space(title_bar.inner_rect.height()); - } else { - frame.content_ui.add_space( - title_bar.inner_rect.height() - + title_content_spacing - + window_frame.inner_margin.sum().y, - ); - } - - Some(title_bar) - } else { - None - }; - - let (content_inner, content_response) = collapsing - .show_body_unindented(&mut frame.content_ui, |ui| { - resize.show(ui, |ui| { - if scroll.is_any_scroll_enabled() { - scroll.show(ui, add_contents).inner - } else { - add_contents(ui) - } - }) + let outer_response = window_frame.show(&mut area_content_ui, |ui| { + resize.show(ui, |ui| { + if with_title_bar { + title_ui( + ui, + title, + window_frame.inner_margin(window_margin), + &mut collapsing, + collapsible, + on_top, + open.as_deref_mut(), + auto_sized, + effective_drag == WindowDrag::TitleBar, + area_id, + ); + } + collapsing + .show_body_unindented(ui, |ui| { + if scroll.is_any_scroll_enabled() { + scroll + .content_margin(window_margin) + .show(ui, add_contents) + .inner + } else { + crate::Frame::NONE + .inner_margin(window_margin) + .show(ui, add_contents) + .inner + } + }) + .map(|inner| inner.inner) }) - .map_or((None, None), |ir| (Some(ir.inner), Some(ir.response))); + }); - let outer_rect = frame.end(&mut area_content_ui).rect; + let outer_rect = outer_response.response.rect; // Do resize interaction _again_, to move their widget rectangles on TOP of the rest of the window. let resize_interaction = do_resize_interaction( @@ -629,7 +747,7 @@ impl Window<'_> { possible, area.id(), area_layer_id, - last_frame_outer_rect, + outer_rect, window_frame, ); @@ -641,50 +759,25 @@ impl Window<'_> { resize_interaction, ); - // END FRAME -------------------------------- + { + let margins = window_frame.total_margin().sum(); - if let Some(mut title_bar) = title_bar { - title_bar.inner_rect = outer_rect.shrink(window_frame.stroke.width); - title_bar.inner_rect.max.y = - title_bar.inner_rect.min.y + title_bar_height_with_margin; - - if on_top && area_content_ui.visuals().window_highlight_topmost { - let mut round = - window_frame.corner_radius - window_frame.stroke.width.round() as u8; - - if !is_collapsed { - round.se = 0; - round.sw = 0; - } - - area_content_ui.painter().set( - *where_to_put_header_background, - RectShape::filled(title_bar.inner_rect, round, header_color), - ); - } - - if false { - ctx.debug_painter().debug_rect( - title_bar.inner_rect, - Color32::LIGHT_BLUE, - "title_bar.rect", - ); - } - - title_bar.ui( - &mut area_content_ui, - content_response.as_ref(), - open.as_deref_mut(), - &mut collapsing, - collapsible, + resize_response( + resize_interaction, + ctx, + margins, + area_layer_id, + &mut area, + resize_id, ); } + // END FRAME -------------------------------- collapsing.store(ctx); paint_frame_interaction(&area_content_ui, outer_rect, resize_interaction); - content_inner + outer_response.inner }; let full_response = area.end(ctx, area_content_ui); @@ -992,7 +1085,7 @@ fn do_resize_interaction( let side_grab_radius = style.interaction.resize_grab_radius_side; let corner_grab_radius = style.interaction.resize_grab_radius_corner; - let vetrtical_rect = |a: Pos2, b: Pos2| { + let vertical_rect = |a: Pos2, b: Pos2| { Rect::from_min_max(a, b).expand2(vec2(side_grab_radius, -corner_grab_radius)) }; let horizontal_rect = |a: Pos2, b: Pos2| { @@ -1009,14 +1102,14 @@ fn do_resize_interaction( if possible.resize_right { let response = side_response( - vetrtical_rect(rect.right_top(), rect.right_bottom()), + vertical_rect(rect.right_top(), rect.right_bottom()), id.with("right"), ); right |= response; } if possible.resize_left { let response = side_response( - vetrtical_rect(rect.left_top(), rect.left_bottom()), + vertical_rect(rect.left_top(), rect.left_bottom()), id.with("left"), ); left |= response; @@ -1177,176 +1270,175 @@ fn paint_frame_interaction(ui: &Ui, rect: Rect, interaction: ResizeInteraction) // ---------------------------------------------------------------------------- -struct TitleBar { - window_frame: Frame, +/// Show the window titlebar. +/// +/// Should be placed inside a `Frame::window`. The [`Frame`] it was placed inside should be passed as +/// an arg and will be used to paint the divider line at the bottom and the highlighted background +/// when `active` is true. +#[expect(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] +fn title_ui( + ui: &mut Ui, + mut title: Atoms<'_>, + frame: Frame, + collapsing: &mut CollapsingState, + collapsible: bool, + 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); - /// Prepared text in the title - title_galley: Arc, + let mut atoms = Atoms::default(); - /// Size of the title bar in an expanded state. This size become known only - /// after expanding window and painting its content. - /// - /// Does not include the stroke, nor the separator line between the title bar and the window contents. - inner_rect: Rect, -} + let button_size = Vec2::splat(ui.spacing().icon_width); -impl TitleBar { - fn new( - ui: &Ui, - title: WidgetText, - show_close_button: bool, - collapsible: bool, - window_frame: Frame, - title_bar_height_with_margin: f32, - ) -> Self { - if false { - ui.debug_painter() - .debug_rect(ui.min_rect(), Color32::GREEN, "outer_min_rect"); - } + // Since the heading height is higher than the button size, we need to allocate the buttons + // with the headers height as size, otherwise they'd look slightly off-center. + // The shrink is then used to render the buttons with the right size. + let heading_font_height = + ui.fonts_mut(|f| f.row_height(&TextStyle::Heading.resolve(ui.style()))); + let button_allocation_size = Vec2::splat(heading_font_height); + let button_shrink = (button_allocation_size - button_size) / 2.0; - let inner_height = title_bar_height_with_margin - window_frame.inner_margin.sum().y; + let collapse_atom_id = Id::new("__window_collapse_button"); + let close_atom_id = Id::new("__window_close_button"); - let item_spacing = ui.spacing().item_spacing; - let button_size = Vec2::splat(ui.spacing().icon_width.at_most(inner_height)); + let expanded = collapsing.openness(ui.ctx()) > 0.0; - let left_pad = ((inner_height - button_size.y) / 2.0).round_ui(); // calculated so that the icon is on the diagonal (if window padding is symmetrical) + if collapsible { + atoms.push_right(Atom::custom(collapse_atom_id, button_allocation_size)); + } - let title_galley = title.into_galley( - ui, - Some(crate::TextWrapMode::Extend), - f32::INFINITY, - TextStyle::Heading, - ); + atoms.push_right(Atom::grow()); - let minimum_width = if collapsible || show_close_button { - // If at least one button is shown we make room for both buttons (since title should be centered): - 2.0 * (left_pad + button_size.x + item_spacing.x) + title_galley.size().x + if !auto_sized + && !title.any_shrink() + && let Some(first_text) = title + .iter_mut() + .find(|a| matches!(a.kind, AtomKind::Text(..))) + { + first_text.shrink = true; + } + atoms.extend_right(title); + + atoms.push_right(Atom::grow()); + + if open.is_some() { + atoms.push_right(Atom::custom(close_atom_id, button_allocation_size)); + } + + let spacing = ui.spacing().item_spacing.x; + + let mut child_ui = ui.new_child(UiBuilder::new()); + + let mut layout = AtomLayout::new(atoms) + .gap(spacing) + .fallback_font(TextStyle::Heading) + .wrap_mode(TextWrapMode::Truncate) + .frame(Frame::NONE.inner_margin(frame.inner_margin)); + + let frame = frame.inner_margin(0); // Only applied to the atoms; done above. + + if expanded { + let min_width = if auto_sized { + // During auto size, the resize is essentially disabled, meaning we don't get an + // available_width we can rely on. Instead, check of large the content grew last frame + // and use that for sizing the title bar. Unfortunately this adds a frame delay. + ui.response().rect.width() } else { - left_pad + title_galley.size().x + left_pad + child_ui.available_width() }; - let min_inner_size = vec2(minimum_width, inner_height); - let min_rect = Rect::from_min_size(ui.min_rect().min, min_inner_size); - if false { - ui.debug_painter() - .debug_rect(min_rect, Color32::LIGHT_BLUE, "min_rect"); - } - - Self { - window_frame, - title_galley, - inner_rect: min_rect, // First estimate - will be refined later - } + layout = layout.min_size(Vec2::new(min_width, 0.0)); } - /// Finishes painting of the title bar when the window content size already known. - /// - /// # Parameters - /// - /// - `ui`: - /// - `outer_rect`: - /// - `content_response`: if `None`, window is collapsed at this frame, otherwise contains - /// a result of rendering the window content - /// - `open`: if `None`, no "Close" button will be rendered, otherwise renders and processes - /// the "Close" button and writes a `false` if window was closed - /// - `collapsing`: holds the current expanding state. Can be changed by double click on the - /// title if `collapsible` is `true` - /// - `collapsible`: if `true`, double click on the title bar will be handled for a change - /// of `collapsing` state - fn ui( - self, - ui: &mut Ui, - content_response: Option<&Response>, - open: Option<&mut bool>, - collapsing: &mut CollapsingState, - collapsible: bool, - ) { - let window_frame = self.window_frame; - let title_inner_rect = self.inner_rect; + let layout_response = layout.show(&mut child_ui); - if false { - ui.debug_painter() - .debug_rect(self.inner_rect, Color32::RED, "TitleBar"); - } + let mut title_click_rect = layout_response.response.rect + frame.total_margin(); - if collapsible { - // Show collapse-button: - let button_center = Align2::LEFT_CENTER - .align_size_within_rect(Vec2::splat(self.inner_rect.height()), self.inner_rect) - .center(); - let button_size = Vec2::splat(ui.spacing().icon_width); - let button_rect = Rect::from_center_size(button_center, button_size); - let button_rect = button_rect.round_ui(); - - ui.scope_builder(UiBuilder::new().max_rect(button_rect), |ui| { - collapsing.show_default_button_with_size(ui, button_size); - }); - } - - if let Some(open) = open { - // Add close button now that we know our full width: - if self.close_button_ui(ui).clicked() { - *open = false; - } - } - - let text_pos = - emath::align::center_size_in_rect(self.title_galley.size(), title_inner_rect) - .left_top(); - let text_pos = text_pos - self.title_galley.rect.min.to_vec2(); - ui.painter().galley( - text_pos, - Arc::clone(&self.title_galley), - ui.visuals().text_color(), + // Collapse triangle icon + if collapsible && let Some(rect) = layout_response.rect(collapse_atom_id) { + let rect = rect.shrink2(button_shrink); + title_click_rect = title_click_rect.with_min_x(rect.max.x); + let icon_response = child_ui.interact( + rect, + child_ui.auto_id_with("collapse_button"), + Sense::click(), ); - - if let Some(content_response) = content_response { - // Paint separator between title and content: - let content_rect = content_response.rect; - if false { - ui.debug_painter() - .debug_rect(content_rect, Color32::RED, "content_rect"); - } - let y = title_inner_rect.bottom() + window_frame.stroke.width / 2.0; - - // To verify the sanity of this, use a very wide window stroke - ui.painter() - .hline(title_inner_rect.x_range(), y, window_frame.stroke); + icon_response.widget_info(|| { + WidgetInfo::labeled( + WidgetType::Button, + child_ui.is_enabled(), + if collapsing.is_open() { "Hide" } else { "Show" }, + ) + }); + if icon_response.clicked() { + collapsing.toggle(&child_ui); } + let openness = collapsing.openness(child_ui.ctx()); + crate::collapsing_header::paint_default_icon(&mut child_ui, openness, &icon_response); + } - // Don't cover the close- and collapse buttons: - let double_click_rect = title_inner_rect.shrink2(vec2(32.0, 0.0)); - - if false { - ui.debug_painter() - .debug_rect(double_click_rect, Color32::GREEN, "double_click_rect"); - } - - let id = ui.unique_id().with("__window_title_bar"); - - if ui - .interact(double_click_rect, id, Sense::CLICK) - .double_clicked() - && collapsible - { - collapsing.toggle(ui); + // Close button + if let Some(open) = open + && let Some(rect) = layout_response.rect(close_atom_id) + { + let rect = rect.shrink2(button_shrink); + title_click_rect = title_click_rect.with_max_x(rect.min.x); + if close_button(&mut child_ui, rect).clicked() { + *open = false; } } - /// Paints the "Close" button at the right side of the title bar - /// and processes clicks on it. - /// - /// The button is square and its size is determined by the - /// [`crate::style::Spacing::icon_width`] setting. - fn close_button_ui(&self, ui: &mut Ui) -> Response { - let button_center = Align2::RIGHT_CENTER - .align_size_within_rect(Vec2::splat(self.inner_rect.height()), self.inner_rect) - .center(); - let button_size = Vec2::splat(ui.spacing().icon_width); - let button_rect = Rect::from_center_size(button_center, button_size); - let button_rect = button_rect.round_to_pixels(ui.pixels_per_point()); - close_button(ui, button_rect) + 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); + } } + + { + let mut header_frame = frame.shadow(Shadow::NONE); + if active { + header_frame = header_frame.fill(ui.visuals().widgets.open.weak_bg_fill); + } + if expanded { + header_frame.corner_radius.sw = 0; + header_frame.corner_radius.se = 0; + } + ui.painter() + .set(shape_idx, header_frame.paint(layout_response.rect)); + } + + let mut advance_rect = child_ui.min_rect(); + + if auto_sized { + // We may not allocate in the horizontal direction as that would break auto sizing. + // Allocate a rect with 0 width: + advance_rect = advance_rect.with_max_x(advance_rect.min.x); + } + if expanded { + // Account for the margin of the title frame + the margin of the window contents + // - the default ui spacing egui would add on this call + advance_rect.max.y += frame.total_margin().bottom + frame.inner_margin.top as f32 + - child_ui.spacing().item_spacing.y; + } + + ui.advance_cursor_after_rect(advance_rect); + + layout_response.response } /// Paints the "Close" button of the window and processes clicks on it. diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 433446648..cd9cc896b 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -300,7 +300,7 @@ impl RepaintCause { struct ViewportRepaintInfo { /// Monotonically increasing counter. /// - /// Incremented at the end of [`Context::run`]. + /// Incremented at the end of [`Context::run_ui`]. /// This can be smaller than [`Self::cumulative_pass_nr`], /// but never larger. cumulative_frame_nr: u64, @@ -463,7 +463,7 @@ impl ContextImpl { let content_rect = viewport.input.content_rect(); - viewport.this_pass.begin_pass(content_rect); + viewport.this_pass.begin_pass(); { let mut layers: Vec = viewport.prev_pass.widgets.layer_ids().collect(); @@ -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 @@ -697,8 +693,8 @@ impl ContextImpl { /// // Game loop: /// loop { /// let raw_input = egui::RawInput::default(); -/// let full_output = ctx.run(raw_input, |ctx| { -/// egui::CentralPanel::default().show(&ctx, |ui| { +/// let full_output = ctx.run_ui(raw_input, |ui| { +/// egui::CentralPanel::default().show(ui, |ui| { /// ui.label("Hello world!"); /// if ui.button("Click me").clicked() { /// // take some action here @@ -780,9 +776,6 @@ impl Context { /// }); /// // handle full_output /// ``` - /// - /// ## See also - /// * [`Self::run`] #[must_use] pub fn run_ui(&self, new_input: RawInput, mut run_ui: impl FnMut(&mut Ui)) -> FullOutput { self.run_ui_dyn(new_input, &mut run_ui) @@ -791,14 +784,13 @@ impl Context { #[must_use] fn run_ui_dyn(&self, new_input: RawInput, run_ui: &mut dyn FnMut(&mut Ui)) -> FullOutput { let plugins = self.read(|ctx| ctx.plugins.ordered_plugins()); - #[expect(deprecated)] - self.run(new_input, |ctx| { + self.run_dyn(new_input, &mut |ctx| { let mut root_ui = Ui::new( ctx.clone(), Id::new((ctx.viewport_id(), "__top_ui")), UiBuilder::new() .layer_id(LayerId::background()) - .max_rect(ctx.available_rect()), + .max_rect(ctx.viewport_rect()), ); { @@ -814,38 +806,6 @@ impl Context { }) } - /// Run the ui code for one frame. - /// - /// At most [`Options::max_passes`] calls will be issued to `run_ui`, - /// and only on the rare occasion that [`Context::request_discard`] is called. - /// Usually, it `run_ui` will only be called once. - /// - /// Put your widgets into a [`crate::Panel`], [`crate::CentralPanel`], [`crate::Window`] or [`crate::Area`]. - /// - /// Instead of calling `run`, you can alternatively use [`Self::begin_pass`] and [`Context::end_pass`]. - /// - /// ``` - /// // One egui context that you keep reusing: - /// let mut ctx = egui::Context::default(); - /// - /// // Each frame: - /// let input = egui::RawInput::default(); - /// let full_output = ctx.run(input, |ctx| { - /// egui::CentralPanel::default().show(&ctx, |ui| { - /// ui.label("Hello egui!"); - /// }); - /// }); - /// // handle full_output - /// ``` - /// - /// ## See also - /// * [`Self::run_ui`] - #[must_use] - #[deprecated = "Call run_ui instead"] - pub fn run(&self, new_input: RawInput, mut run_ui: impl FnMut(&Self)) -> FullOutput { - self.run_dyn(new_input, &mut run_ui) - } - #[must_use] fn run_dyn(&self, mut new_input: RawInput, run_ui: &mut dyn FnMut(&Self)) -> FullOutput { profiling::function_scope!(); @@ -915,10 +875,10 @@ impl Context { output } - /// An alternative to calling [`Self::run`]. + /// An alternative to calling [`Self::run_ui`]. /// - /// It is usually better to use [`Self::run`], because - /// `run` supports multi-pass layout using [`Self::request_discard`]. + /// It is usually better to use [`Self::run_ui`], because + /// `run_ui` supports multi-pass layout using [`Self::request_discard`]. /// /// ``` /// // One egui context that you keep reusing: @@ -928,9 +888,7 @@ impl Context { /// let input = egui::RawInput::default(); /// ctx.begin_pass(input); /// - /// egui::CentralPanel::default().show(&ctx, |ui| { - /// ui.label("Hello egui!"); - /// }); + /// // … add panels and windows here … /// /// let full_output = ctx.end_pass(); /// // handle full_output @@ -943,12 +901,6 @@ impl Context { self.write(|ctx| ctx.begin_pass(new_input)); } - - /// See [`Self::begin_pass`]. - #[deprecated = "Renamed begin_pass"] - pub fn begin_frame(&self, new_input: RawInput) { - self.begin_pass(new_input); - } } /// ## Borrows parts of [`Context`] @@ -1049,7 +1001,7 @@ impl Context { /// Read-only access to [`PassState`]. /// - /// This is only valid during the call to [`Self::run`] (between [`Self::begin_pass`] and [`Self::end_pass`]). + /// This is only valid during the call to [`Self::run_ui`] (between [`Self::begin_pass`] and [`Self::end_pass`]). #[inline] pub(crate) fn pass_state(&self, reader: impl FnOnce(&PassState) -> R) -> R { self.write(move |ctx| reader(&ctx.viewport().this_pass)) @@ -1057,7 +1009,7 @@ impl Context { /// Read-write access to [`PassState`]. /// - /// This is only valid during the call to [`Self::run`] (between [`Self::begin_pass`] and [`Self::end_pass`]). + /// This is only valid during the call to [`Self::run_ui`] (between [`Self::begin_pass`] and [`Self::end_pass`]). #[inline] pub(crate) fn pass_state_mut(&self, writer: impl FnOnce(&mut PassState) -> R) -> R { self.write(move |ctx| writer(&mut ctx.viewport().this_pass)) @@ -1073,7 +1025,7 @@ impl Context { /// Read-only access to [`Fonts`]. /// - /// Not valid until first call to [`Context::run()`]. + /// Not valid until first call to [`Context::run_ui()`]. /// That's because since we don't know the proper `pixels_per_point` until then. #[inline] pub fn fonts(&self, reader: impl FnOnce(&FontsView<'_>) -> R) -> R { @@ -1090,7 +1042,7 @@ impl Context { /// Read-write access to [`Fonts`]. /// - /// Not valid until first call to [`Context::run()`]. + /// Not valid until first call to [`Context::run_ui()`]. /// That's because since we don't know the proper `pixels_per_point` until then. #[inline] pub fn fonts_mut(&self, reader: impl FnOnce(&mut FontsView<'_>) -> R) -> R { @@ -1581,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) { @@ -1666,7 +1631,7 @@ impl Context { /// The total number of completed frames. /// - /// Starts at zero, and is incremented once at the end of each call to [`Self::run`]. + /// Starts at zero, and is incremented once at the end of each call to [`Self::run_ui`]. /// /// This is always smaller or equal to [`Self::cumulative_pass_nr`]. pub fn cumulative_frame_nr(&self) -> u64 { @@ -1675,7 +1640,7 @@ impl Context { /// The total number of completed frames. /// - /// Starts at zero, and is incremented once at the end of each call to [`Self::run`]. + /// Starts at zero, and is incremented once at the end of each call to [`Self::run_ui`]. /// /// This is always smaller or equal to [`Self::cumulative_pass_nr_for`]. pub fn cumulative_frame_nr_for(&self, id: ViewportId) -> u64 { @@ -1695,7 +1660,7 @@ impl Context { /// The total number of completed passes (usually there is one pass per rendered frame). /// - /// Starts at zero, and is incremented for each completed pass inside of [`Self::run`] (usually once). + /// Starts at zero, and is incremented for each completed pass inside of [`Self::run_ui`] (usually once). /// /// If you instead want to know which pass index this is within the current frame, /// use [`Self::current_pass_index`]. @@ -1705,7 +1670,7 @@ impl Context { /// The total number of completed passes (usually there is one pass per rendered frame). /// - /// Starts at zero, and is incremented for each completed pass inside of [`Self::run`] (usually once). + /// Starts at zero, and is incremented for each completed pass inside of [`Self::run_ui`] (usually once). pub fn cumulative_pass_nr_for(&self, id: ViewportId) -> u64 { self.read(|ctx| { ctx.viewports @@ -2080,7 +2045,7 @@ impl Context { self.options(|opt| opt.theme()) } - /// The [`Theme`] used to select between dark and light [`Self::style`] + /// The [`Theme`] used to select between dark and light [`Self::global_style`] /// as the active style used by all subsequent popups, menus, etc. /// /// Example: @@ -2097,12 +2062,6 @@ impl Context { self.options(|opt| Arc::clone(opt.style())) } - /// The currently active [`Style`] used by all subsequent popups, menus, etc. - #[deprecated = "Renamed to `global_style` to avoid confusion with `ui.style()`"] - pub fn style(&self) -> Arc