diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c309e231..397279699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,66 @@ 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.36.1 - 2026-08-07 +* Fix `Sense::drag` detecting drags when clicking widget above it [#8396](https://github.com/emilk/egui/pull/8396) by [@lucasmerlin](https://github.com/lucasmerlin) + + +## 0.36.0 - 2026-08-05 + +### Highlights ✨ + +This release drastically improves the mobile keyboard experience (when using eframe web). It also adds drag-to-open +panels, window chrome theme sync and a lot of small bug fixes and improvements! + +#### Improved mobile keyboard support + +Autocomplete, autocorrect and IMEs now work correctly on iOS and android (on eframe web)! + +https://github.com/user-attachments/assets/b0aa1084-0755-4e47-a0b3-0ce6890aca85 + +- via [#8045](https://github.com/emilk/egui/pull/8045) by [@umajho](https://github.com/umajho) (and [@rustbasic](https://github.com/rustbasic) who helped a lot with testing) + +#### Drag to reopen panels + +You can now reopen closed panels by dragging the handle: + +https://github.com/user-attachments/assets/e02895c4-248e-4e22-9694-bdfcd9839bfd + +#### Window decoration theme is now synced with app theme + +Previously, when switching themes, the window chrome (OS titlebar) stayed in the OS theme. Now, it syncs with the app theme: + +https://github.com/user-attachments/assets/6b393057-fdfc-431b-b997-744bc183a8ac + + +### ⭐ Added +* Add `BoxedWidget`: dynamically dispatched widgets [#8378](https://github.com/emilk/egui/pull/8378) by [@emilk](https://github.com/emilk) +* Add `WidgetText::size` [#8377](https://github.com/emilk/egui/pull/8377) by [@emilk](https://github.com/emilk) +* Add `LayoutJob::clear` [#8376](https://github.com/emilk/egui/pull/8376) by [@emilk](https://github.com/emilk) +* Sync window theme with egui theme [#8299](https://github.com/emilk/egui/pull/8299) by [@lucasmerlin](https://github.com/lucasmerlin) +* Add `egui::Window::title_frame` [#8353](https://github.com/emilk/egui/pull/8353) by [@Its-Just-Nans](https://github.com/Its-Just-Nans) +* Add `extra_text_line_spacing` to control vertical spacing between text lines [#8040](https://github.com/emilk/egui/pull/8040) by [@rustbasic](https://github.com/rustbasic) +* Add drag-to-open for collapsible panels [#8363](https://github.com/emilk/egui/pull/8363) by [@emilk](https://github.com/emilk) + +### 🔧 Changed +* Rerun `sizing_pass` when reopening popup [#8315](https://github.com/emilk/egui/pull/8315) by [@yay](https://github.com/yay) +* Update MSRV from 1.92 to 1.95 [#8348](https://github.com/emilk/egui/pull/8348) by [@emilk](https://github.com/emilk) +* Treat a press that leaves a widget as a drag [#8365](https://github.com/emilk/egui/pull/8365) by [@emilk](https://github.com/emilk) + +### 🔥 Removed +* Remove `Modifiers` from `RawInput` and make it a `egui::Event` [#8336](https://github.com/emilk/egui/pull/8336) by [@lucasmerlin](https://github.com/lucasmerlin) +* Remove `clip_rect_margin` [#8366](https://github.com/emilk/egui/pull/8366) by [@emilk](https://github.com/emilk) + +### 🐛 Fixed +* Improve backtrace trimming for cranelift [#8294](https://github.com/emilk/egui/pull/8294) by [@emilk](https://github.com/emilk) +* Prevent accidentally dropping `TexturesDelta` [#8356](https://github.com/emilk/egui/pull/8356) by [@lucasmerlin](https://github.com/lucasmerlin) +* Make non-interactive tooltips not interactable [#8362](https://github.com/emilk/egui/pull/8362) by [@lucasmerlin](https://github.com/lucasmerlin) +* Panels: Take separator line width into account [#8367](https://github.com/emilk/egui/pull/8367) by [@emilk](https://github.com/emilk) +* Fix TextEdit hint text not following horizontal_align/vertical_align [#8332](https://github.com/emilk/egui/pull/8332) by [@thedavidweng](https://github.com/thedavidweng) +* Fix ScrollArea failure by handling horizontal and vertical scrolling separately in the missing place [#8275](https://github.com/emilk/egui/pull/8275) by [@rustbasic](https://github.com/rustbasic) +* Fix window with a `Grid` being widenable but not shrinkable again [#8386](https://github.com/emilk/egui/pull/8386) by [@emilk](https://github.com/emilk) + + ## 0.35.0 - 2026-06-25 - Inspection, egui_mcp, classes and improved IME ### Highlights diff --git a/Cargo.lock b/Cargo.lock index 0f289e5a5..88207d5a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,7 +173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.13.0", + "bitflags 2.13.1", "cc", "jni", "libc", @@ -205,7 +205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" dependencies = [ "android_log-sys", - "env_filter", + "env_filter 0.1.4", "log", ] @@ -226,9 +226,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -247,9 +247,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -575,9 +575,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -632,18 +632,18 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", @@ -674,7 +674,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -702,9 +702,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.66" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -907,16 +907,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -930,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -943,7 +933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "libc", ] @@ -1194,7 +1184,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1243,7 +1233,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.35.0" +version = "0.36.1" dependencies = [ "bytemuck", "cint", @@ -1255,7 +1245,7 @@ dependencies = [ [[package]] name = "eframe" -version = "0.35.0" +version = "0.36.1" dependencies = [ "ahash", "bytemuck", @@ -1269,7 +1259,6 @@ dependencies = [ "glow", "glutin", "glutin-winit", - "home", "image", "js-sys", "log", @@ -1278,14 +1267,13 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "pollster", + "pollster 1.0.1", "profiling", "raw-window-handle", "ron", "serde", "static_assertions", "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", "web-time", "wgpu", @@ -1295,12 +1283,12 @@ dependencies = [ [[package]] name = "egui" -version = "0.35.0" +version = "0.36.1" dependencies = [ "accesskit", "ahash", "backtrace", - "bitflags 2.13.0", + "bitflags 2.13.1", "document-features", "emath", "epaint", @@ -1312,11 +1300,12 @@ dependencies = [ "serde", "smallvec", "unicode-segmentation", + "web-sys", ] [[package]] name = "egui-wgpu" -version = "0.35.0" +version = "0.36.1" dependencies = [ "ahash", "bytemuck", @@ -1334,7 +1323,7 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.35.0" +version = "0.36.1" dependencies = [ "accesskit_winit", "arboard", @@ -1357,7 +1346,7 @@ dependencies = [ [[package]] name = "egui_demo_app" -version = "0.35.0" +version = "0.36.1" dependencies = [ "accesskit", "accesskit_consumer", @@ -1386,7 +1375,7 @@ dependencies = [ [[package]] name = "egui_demo_lib" -version = "0.35.0" +version = "0.36.1" dependencies = [ "criterion", "document-features", @@ -1404,7 +1393,7 @@ dependencies = [ [[package]] name = "egui_extras" -version = "0.35.0" +version = "0.36.1" dependencies = [ "ahash", "document-features", @@ -1424,7 +1413,7 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.35.0" +version = "0.36.1" dependencies = [ "bytemuck", "document-features", @@ -1434,14 +1423,13 @@ dependencies = [ "glutin", "glutin-winit", "log", - "memoffset", "profiling", "winit", ] [[package]] name = "egui_inspection" -version = "0.35.0" +version = "0.36.1" dependencies = [ "document-features", "egui", @@ -1454,7 +1442,7 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.35.0" +version = "0.36.1" dependencies = [ "dify", "document-features", @@ -1466,7 +1454,7 @@ dependencies = [ "kittest", "log", "open", - "pollster", + "pollster 1.0.1", "serde", "tempfile", "toml", @@ -1475,7 +1463,7 @@ dependencies = [ [[package]] name = "egui_tests" -version = "0.35.0" +version = "0.36.1" dependencies = [ "egui", "egui_extras", @@ -1505,7 +1493,7 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "emath" -version = "0.35.0" +version = "0.36.1" dependencies = [ "bytemuck", "document-features", @@ -1589,21 +1577,31 @@ dependencies = [ ] [[package]] -name = "env_logger" -version = "0.11.8" +name = "env_filter" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", - "env_filter", + "env_filter 2.0.0", "jiff", "log", ] [[package]] name = "epaint" -version = "0.35.0" +version = "0.36.1" dependencies = [ "ahash", "bytemuck", @@ -1632,7 +1630,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.35.0" +version = "0.36.1" [[package]] name = "equivalent" @@ -1783,9 +1781,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.11.3" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" dependencies = [ "bytemuck", "serde", @@ -1859,9 +1857,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-io" @@ -1884,9 +1882,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -1895,15 +1893,15 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", @@ -1995,9 +1993,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.1.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash", @@ -2027,7 +2025,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg_aliases", "cgl", "dispatch2", @@ -2124,11 +2122,11 @@ dependencies = [ [[package]] name = "harfrust" -version = "0.7.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytemuck", "read-fonts", "smallvec", @@ -2204,15 +2202,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.4.2" @@ -2446,11 +2435,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.31" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "js-sys", "log", @@ -2462,11 +2452,21 @@ dependencies = [ ] [[package]] -name = "jiff-static" -version = "0.2.31" +name = "jiff-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", "proc-macro2", "quote", "syn", @@ -2623,9 +2623,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -2658,7 +2658,7 @@ version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "plain", "redox_syscall 0.9.0", @@ -2723,9 +2723,9 @@ checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -2815,7 +2815,7 @@ checksum = "23bf0a141a9ab6f07dbb492db53245e464bc9db42f407772d9ae03d83a2c1033" dependencies = [ "arrayvec", "bit-set 0.10.0", - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "codespan-reporting", @@ -2851,7 +2851,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2950,7 +2950,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2966,7 +2966,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -2980,7 +2980,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3004,7 +3004,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3016,7 +3016,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -3027,7 +3027,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -3070,7 +3070,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "dispatch", "libc", @@ -3083,7 +3083,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -3095,7 +3095,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3118,7 +3118,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3130,7 +3130,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-foundation 0.3.2", @@ -3142,7 +3142,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3155,7 +3155,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -3179,7 +3179,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -3200,7 +3200,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3223,7 +3223,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3259,9 +3259,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.3.6" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ "is-wsl", "libc", @@ -3529,7 +3529,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64", "indexmap", - "quick-xml 0.41.0", + "quick-xml", "serde", "time", ] @@ -3577,6 +3577,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + [[package]] name = "polycool" version = "0.4.0" @@ -3588,7 +3594,7 @@ dependencies = [ [[package]] name = "popups" -version = "0.35.0" +version = "0.36.1" dependencies = [ "eframe", "env_logger", @@ -3650,9 +3656,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3724,16 +3730,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -3745,9 +3741,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3857,12 +3853,13 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.39.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", "font-types", + "once_cell", ] [[package]] @@ -3880,7 +3877,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3889,7 +3886,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3968,7 +3965,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "percent-encoding", - "pollster", + "pollster 0.4.0", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", @@ -4027,7 +4024,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "once_cell", "serde", "serde_derive", @@ -4074,7 +4071,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4087,7 +4084,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -4141,7 +4138,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytemuck", "core_maths", "log", @@ -4198,9 +4195,9 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[package]] name = "semver" @@ -4308,9 +4305,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" @@ -4365,9 +4362,9 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skrifa" -version = "0.42.1" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", "read-fonts", @@ -4403,7 +4400,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "calloop", "calloop-wayland-source", "cursor-icon", @@ -4458,7 +4455,7 @@ version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -4508,9 +4505,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -4749,9 +4746,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -4764,9 +4761,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "libc", "mio", @@ -4777,9 +4774,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "serde_core", "serde_spanned", @@ -4811,9 +4808,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] @@ -5083,14 +5080,13 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.0.9" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", "guillotiere", - "hashbrown 0.17.1", "log", "peniko", "smallvec", @@ -5099,9 +5095,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.0.9" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -5215,7 +5211,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -5227,7 +5223,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] @@ -5249,7 +5245,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5261,7 +5257,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5274,7 +5270,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5283,12 +5279,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml", "quote", ] @@ -5326,15 +5322,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "core-foundation 0.10.1", "jni", "log", "ndk-context", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", "web-sys", @@ -5362,7 +5358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d8f4bd44d92da5270f03409dba9f952dab24f128e05d6a554926101d1bf9114" dependencies = [ "arrayvec", - "bitflags 2.13.0", + "bitflags 2.13.1", "bytemuck", "cfg-if", "cfg_aliases", @@ -5394,7 +5390,7 @@ dependencies = [ "arrayvec", "bit-set 0.10.0", "bit-vec 0.9.1", - "bitflags 2.13.0", + "bitflags 2.13.1", "bytemuck", "cfg_aliases", "document-features", @@ -5466,7 +5462,7 @@ dependencies = [ "arrayvec", "ash", "bit-set 0.10.0", - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "bytemuck", "cfg-if", @@ -5528,7 +5524,7 @@ version = "30.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a9c93c2b35edde326df60ffdee4c0f5864eac3011d6768b70d43f028ad93565" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", @@ -5770,13 +5766,13 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "bytemuck", "calloop", "cfg_aliases", "concurrent-queue", - "core-foundation 0.9.4", + "core-foundation", "core-graphics", "cursor-icon", "dpi", @@ -5878,7 +5874,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -5905,7 +5901,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.35.0" +version = "0.36.1" [[package]] name = "yaml-rust" @@ -5941,9 +5937,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.16.0" +version = "5.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" dependencies = [ "async-broadcast", "async-executor", @@ -6000,9 +5996,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.16.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -6015,9 +6011,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow", @@ -6026,30 +6022,30 @@ dependencies = [ [[package]] name = "zbus_xml" -version = "5.1.1" +version = "5.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ - "quick-xml 0.39.4", "serde", + "winnow", "zbus_names", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", @@ -6139,9 +6135,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.12.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", @@ -6153,9 +6149,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -6166,9 +6162,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 6c8566cf0..3aae7fa74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" -version = "0.35.0" +version = "0.36.1" [profile.release] @@ -56,23 +56,23 @@ opt-level = 2 [workspace.dependencies] -emath = { version = "0.35.0", path = "crates/emath", default-features = false } -ecolor = { version = "0.35.0", path = "crates/ecolor", default-features = false } -epaint = { version = "0.35.0", path = "crates/epaint", default-features = false } -epaint_default_fonts = { version = "0.35.0", path = "crates/epaint_default_fonts" } -egui = { version = "0.35.0", path = "crates/egui", default-features = false } -egui-winit = { version = "0.35.0", path = "crates/egui-winit", default-features = false } -egui_extras = { version = "0.35.0", path = "crates/egui_extras", default-features = false } -egui-wgpu = { version = "0.35.0", path = "crates/egui-wgpu", default-features = false } -egui_demo_lib = { version = "0.35.0", path = "crates/egui_demo_lib", default-features = false } -egui_glow = { version = "0.35.0", path = "crates/egui_glow", default-features = false } -egui_inspection = { version = "0.35.0", path = "crates/egui_inspection", default-features = false } -egui_kittest = { version = "0.35.0", path = "crates/egui_kittest", default-features = false } -eframe = { version = "0.35.0", path = "crates/eframe", default-features = false } +emath = { version = "0.36.1", path = "crates/emath", default-features = false } +ecolor = { version = "0.36.1", path = "crates/ecolor", default-features = false } +epaint = { version = "0.36.1", path = "crates/epaint", default-features = false } +epaint_default_fonts = { version = "0.36.1", path = "crates/epaint_default_fonts" } +egui = { version = "0.36.1", path = "crates/egui", default-features = false } +egui-winit = { version = "0.36.1", path = "crates/egui-winit", default-features = false } +egui_extras = { version = "0.36.1", path = "crates/egui_extras", default-features = false } +egui-wgpu = { version = "0.36.1", path = "crates/egui-wgpu", default-features = false } +egui_demo_lib = { version = "0.36.1", path = "crates/egui_demo_lib", default-features = false } +egui_glow = { version = "0.36.1", path = "crates/egui_glow", default-features = false } +egui_inspection = { version = "0.36.1", path = "crates/egui_inspection", default-features = false } +egui_kittest = { version = "0.36.1", path = "crates/egui_kittest", default-features = false } +eframe = { version = "0.36.1", path = "crates/eframe", default-features = false } accesskit = "0.24.1" accesskit_consumer = "0.35.0" # Can't update to 0.36+: kittest 0.4 pins accesskit_consumer 0.35, so bumping splits it into two versions -accesskit_winit = "0.32.0" # Can't update to 0.33: it needs accesskit_macos 0.26.2, which pulls accesskit_consumer 0.37, duplicating the 0.35 that kittest 0.4 needs +accesskit_winit = "0.32.0" # Can't update to 0.33: it needs accesskit_macos 0.26.2, which pulls accesskit_consumer 0.37, duplicating the 0.35 that kittest 0.4 needs. For the same reason, `accesskit_macos` is held at 0.26.0 in `Cargo.lock`. ahash = { version = "0.8.12", default-features = false, features = [ "no-rng", # we don't need DOS-protection, so we let users opt-in to it instead "std", @@ -89,23 +89,19 @@ dify = { version = "0.8.0", default-features = false } directories = "6.0" document-features = "0.2.12" ehttp = { version = "0.7.1", default-features = false } -enum-map = "2.7" -env_logger = { version = "0.11.8", default-features = false } # 0.11.9+ pulls env_filter 1.x, duplicating the 0.1.x that android_logger needs -font-types = { version = "0.11.3", default-features = false, features = [ - "std", -] } # Can't update to 0.12: vello_cpu's glifo 0.1.1 pins font-types 0.11 (via skrifa/read-fonts), so bumping splits it into two versions -glow = "0.17.0" +enum-map = "2.7" # Can't update to 3.1: its `enum-map-derive` moved to syn 3, duplicating the syn 2 that most other proc-macro crates still use +env_logger = { version = "0.11.8", default-features = false } # 0.11.9+ pulls env_filter 1.x/2.x, duplicating the 0.1.x that android_logger needs +font-types = { version = "0.12.2", default-features = false, features = ["std"] } +glow = "0.17.0" # Can't update to 0.18: wgpu 30's wgpu-hal pins glow 0.17, so bumping splits it into two versions glutin = { version = "0.32.3", default-features = false } glutin-winit = { version = "0.5.0", default-features = false } -harfrust = "0.7.0" # Can't update to 0.8+: newer versions need read-fonts 0.40+/font-types 0.12, but vello_cpu's glifo 0.1.1 pins read-fonts 0.39/font-types 0.11, so bumping duplicates them -home = "0.5.12" +harfrust = "0.12.0" image = { version = "0.25.6", default-features = false } # Can't update to 0.25.7+: it needs png 0.18, which only matches resvg once resvg moves to tiny-skia 0.12 — blocked, see resvg below itertools = "0.15.0" -jiff = { version = "0.2.29", default-features = false } +jiff = { version = "0.2.35", default-features = false } js-sys = "0.3.103" kittest = { version = "0.4.0" } log = { version = "0.4.33", features = ["std"] } -memoffset = "0.9.1" mimalloc = "0.1.52" mime_guess2 = { version = "2.3", default-features = false } mint = "0.5.9" @@ -114,42 +110,43 @@ objc2 = "0.6.4" objc2-app-kit = { version = "0.3.2", default-features = false } objc2-foundation = { version = "0.3.2", default-features = false } objc2-ui-kit = { version = "0.3.2", default-features = false } -open = "5.3" +open = "5.4" parking_lot = "0.12.5" percent-encoding = "2.3" poll-promise = { version = "0.3.0", default-features = false } -pollster = "0.4.0" +pollster = "1.0" profiling = { version = "1.0", default-features = false } puffin = "0.20.0" puffin_http = "0.17.0" -rand = "0.10.1" +rand = "0.10.2" raw-window-handle = "0.6.2" rayon = "1.12" -resvg = { version = "0.45.1", default-features = false } # Can't update to 0.47: it needs tiny-skia 0.12, but winit 0.30's sctk-adwaita is stuck on tiny-skia 0.11, so bumping duplicates tiny-skia. (0.46 keeps tiny-skia 0.11 but its fontconfig-parser duplicates roxmltree.) +resvg = { version = "0.45.1", default-features = false } # Can't update to 0.47+: it needs tiny-skia 0.12, but winit 0.30's sctk-adwaita 0.10 is stuck on tiny-skia 0.11, so bumping duplicates tiny-skia. (0.46 keeps tiny-skia 0.11 but its fontconfig-parser duplicates roxmltree.) rfd = "0.17.2" rmp-serde = "1.3" ron = "0.12.2" -self_cell = "1.2" +self_cell = "1.3" +# `serde_derive` is held at 1.0.228 in `Cargo.lock`: 1.0.229 moved to syn 3, duplicating the syn 2 that most other proc-macro crates still use serde = { version = "1.0", features = ["derive"] } serde_bytes = "0.11.19" similar-asserts = "2.0" -skrifa = { version = "0.42.1", default-features = false, features = [ +skrifa = { version = "0.44.0", default-features = false, features = [ "std", "autohint_shaping", -] } # Can't update to 0.43: vello_cpu's glifo 0.1.1 pins skrifa 0.42, so bumping splits it into two versions +] } # Can't update to 0.45: it needs read-fonts 0.42, but harfrust 0.12 pins read-fonts 0.41, so bumping splits read-fonts into two versions smallvec = "1.15" smithay-clipboard = "0.7.2" # 0.7.3 pulls smithay-client-toolkit 0.20 (calloop 0.14), duplicating the 0.19/0.13 that winit 0.30 needs static_assertions = "1.1" syntect = { version = "5.3", default-features = false } tempfile = "3.27" -thiserror = "2.0" -tokio = "1.52" -toml = { version = "1.0", default-features = false } +thiserror = "2.0" # Held at 2.0.18 in `Cargo.lock`: `thiserror-impl` 2.0.19 moved to syn 3, duplicating the syn 2 that most other proc-macro crates still use +tokio = "1.53" +toml = { version = "1.1", default-features = false } type-map = "0.5.1" unicode_names2 = { version = "3.1", default-features = false } unicode-general-category = "1.1" unicode-segmentation = "1.13" -vello_cpu = { version = "0.0.9", default-features = false, features = [ +vello_cpu = { version = "0.2.0", default-features = false, features = [ "std", "u8_pipeline", "f32_pipeline", @@ -159,7 +156,7 @@ wasm-bindgen-futures = "0.4.76" wayland-cursor = { version = "0.31.14", default-features = false } web-sys = "0.3.103" web-time = "1.1" # Timekeeping for native and web -webbrowser = "1.2" +webbrowser = "1.2.2" # 1.2.2 fixes RUSTSEC-2026-0257 (`BROWSER` argument injection) wgpu = { version = "30.0", default-features = false, features = ["std"] } windows-sys = "0.61.2" winit = { version = "0.30.13", default-features = false } @@ -167,16 +164,10 @@ winit = { version = "0.30.13", default-features = false } [workspace.lints.rust] unsafe_code = "deny" -elided_lifetimes_in_paths = "warn" future_incompatible = { level = "warn", priority = -1 } nonstandard_style = { level = "warn", priority = -1 } rust_2018_idioms = { level = "warn", priority = -1 } -rust_2021_prelude_collisions = "warn" -semicolon_in_expressions_from_macros = "warn" trivial_numeric_casts = "warn" -unexpected_cfgs = "warn" -unsafe_op_in_unsafe_fn = "warn" # `unsafe_op_in_unsafe_fn` may become the default in future Rust versions: https://github.com/rust-lang/rust/issues/71668 -unused_extern_crates = "warn" unused_import_braces = "warn" unused_lifetimes = "warn" @@ -185,216 +176,128 @@ unused_qualifications = "allow" [workspace.lints.rustdoc] all = "warn" -missing_crate_level_docs = "warn" -broken_intra_doc_links = "warn" # See also clippy.toml [workspace.lints.clippy] +# `all` = `correctness` + `suspicious` + `style` + `complexity` + `perf`. +# The remaining groups are `nursery` and `restriction`, +# which are not meant to be enabled wholesale - we cherry-pick from them below. all = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } allow_attributes = "warn" as_ptr_cast_mut = "warn" -await_holding_lock = "warn" -bool_to_int_with_if = "warn" branches_sharing_code = "warn" -char_lit_as_u8 = "warn" -checked_conversions = "warn" clear_with_drain = "warn" clone_on_ref_ptr = "warn" -cloned_instead_of_copied = "warn" coerce_container_to_any = "warn" dbg_macro = "warn" debug_assert_with_mut_call = "warn" -decimal_bitwise_operands = "warn" default_union_representation = "warn" derive_partial_eq_without_eq = "warn" -disallowed_macros = "warn" # See clippy.toml -disallowed_methods = "warn" # See clippy.toml -disallowed_names = "warn" # See clippy.toml disallowed_script_idents = "warn" # See clippy.toml -disallowed_types = "warn" # See clippy.toml -doc_broken_link = "warn" -doc_comment_double_space_linebreaks = "warn" doc_include_without_cfg = "warn" -doc_link_with_quotes = "warn" -doc_markdown = "warn" -duration_suboptimal_units = "warn" -elidable_lifetime_names = "warn" empty_enum_variants_with_brackets = "warn" -empty_enums = "warn" -empty_line_after_outer_attr = "warn" -enum_glob_use = "warn" equatable_if_let = "warn" exit = "warn" -expl_impl_clone_on_copy = "warn" -explicit_deref_methods = "warn" -explicit_into_iter_loop = "warn" -explicit_iter_loop = "warn" fallible_impl_from = "warn" -filter_map_next = "warn" -flat_map_option = "warn" float_cmp_const = "warn" -fn_params_excessive_bools = "warn" fn_to_numeric_cast_any = "warn" -format_push_string = "warn" -from_iter_instead_of_collect = "warn" get_unwrap = "warn" -if_let_mutex = "warn" -ignore_without_reason = "warn" -ignored_unit_patterns = "warn" -implicit_clone = "warn" -implied_bounds_in_impls = "warn" imprecise_flops = "warn" -inconsistent_struct_constructor = "warn" -index_refutable_slice = "warn" -inefficient_to_string = "warn" infinite_loop = "warn" -into_iter_without_iter = "warn" -invalid_upcast_comparisons = "warn" -ip_constant = "warn" -iter_filter_is_ok = "warn" -iter_filter_is_some = "warn" -iter_not_returning_iterator = "warn" iter_on_empty_collections = "warn" iter_on_single_items = "warn" iter_over_hash_type = "warn" -iter_without_into_iter = "warn" -large_digit_groups = "warn" -large_futures = "warn" large_include_file = "warn" -large_stack_arrays = "warn" large_stack_frames = "warn" -large_types_passed_by_value = "warn" -let_unit_value = "warn" -linkedlist = "warn" literal_string_with_formatting_args = "warn" lossy_float_literal = "warn" -macro_use_imports = "warn" -manual_assert = "warn" -manual_clamp = "warn" -manual_ilog2 = "warn" -manual_instant_elapsed = "warn" -manual_is_power_of_two = "warn" -manual_is_variant_and = "warn" -manual_let_else = "warn" -manual_midpoint = "warn" # NOTE `midpoint` is often a lot slower for floats, so we have our own `emath::fast_midpoint` function. -manual_ok_or = "warn" -manual_string_new = "warn" map_err_ignore = "warn" -map_flatten = "warn" -match_bool = "warn" -match_same_arms = "warn" -match_wild_err_arm = "warn" -match_wildcard_for_single_variants = "warn" mem_forget = "warn" -mismatching_type_param_order = "warn" missing_assert_message = "warn" -missing_enforced_import_renames = "warn" -missing_errors_doc = "warn" -missing_fields_in_debug = "warn" -missing_safety_doc = "warn" -mixed_attributes_style = "warn" -mut_mut = "warn" mutex_integer = "warn" -needless_borrow = "warn" -needless_continue = "warn" -needless_for_each = "warn" needless_pass_by_ref_mut = "warn" -needless_pass_by_value = "warn" -needless_raw_string_hashes = "warn" needless_type_cast = "warn" -negative_feature_names = "warn" -non_std_lazy_statics = "warn" non_zero_suggestions = "warn" nonstandard_macro_braces = "warn" -only_used_in_recursion = "warn" -option_as_ref_cloned = "warn" -option_option = "warn" or_fun_call = "warn" path_buf_push_overwrite = "warn" pathbuf_init_then_push = "warn" precedence_bits = "warn" print_stderr = "warn" print_stdout = "warn" -ptr_as_ptr = "warn" -ptr_cast_constness = "warn" -pub_underscore_fields = "warn" pub_without_shorthand = "warn" rc_mutex = "warn" -readonly_write_lock = "warn" redundant_type_annotations = "warn" -ref_as_ptr = "warn" -ref_option = "warn" -ref_option_ref = "warn" ref_patterns = "warn" rest_pat_in_fully_bound_structs = "warn" return_and_then = "warn" -same_functions_in_if_condition = "warn" -same_length_and_capacity = "warn" -self_only_used_in_recursion = "warn" -semicolon_if_nothing_returned = "warn" set_contains_or_insert = "warn" -single_char_pattern = "warn" -single_match_else = "warn" single_option_map = "warn" -str_split_at_newline = "warn" +std_instead_of_core = "warn" str_to_string = "warn" string_add = "warn" -string_add_assign = "warn" string_lit_as_bytes = "warn" string_lit_chars_any = "warn" -suspicious_command_arg_space = "warn" suspicious_xor_used_as_pow = "warn" todo = "warn" too_long_first_doc_paragraph = "warn" -too_many_arguments = "warn" trailing_empty_array = "warn" trait_duplication_in_bounds = "warn" -transmute_ptr_to_ptr = "warn" tuple_array_conversions = "warn" -unchecked_time_subtraction = "warn" undocumented_unsafe_blocks = "warn" unimplemented = "warn" uninhabited_references = "warn" -uninlined_format_args = "warn" -unnecessary_box_returns = "warn" -unnecessary_debug_formatting = "warn" -unnecessary_literal_bound = "warn" unnecessary_safety_comment = "warn" unnecessary_safety_doc = "warn" unnecessary_self_imports = "warn" -unnecessary_semicolon = "warn" unnecessary_struct_initialization = "warn" -unnecessary_trailing_comma = "warn" -unnecessary_wraps = "warn" -unnested_or_patterns = "warn" -unused_async = "warn" unused_peekable = "warn" unused_rounding = "warn" -unused_self = "warn" unused_trait_names = "warn" unwrap_used = "warn" use_self = "warn" useless_let_if_seq = "warn" -useless_transmute = "warn" verbose_file_reads = "warn" -wildcard_dependencies = "warn" -zero_sized_map_values = "warn" -# TODO(emilk): maybe enable more of these lints? -cast_possible_wrap = "allow" +# Pedantic lints we opt out of, with the number of hits at the time we enabled `pedantic`: +cast_lossless = "allow" # 204 hits +cast_possible_truncation = "allow" # 287 hits +cast_possible_wrap = "allow" # 43 hits +cast_precision_loss = "allow" # 200 hits +cast_sign_loss = "allow" # 98 hits comparison_chain = "allow" +default_trait_access = "allow" # 278 hits +float_cmp = "allow" # exact float comparisons are usually intentional (`float_cmp_const` is still on) +inline_always = "allow" # 271 hits; we know what we are doing +items_after_statements = "allow" # 82 hits +many_single_char_names = "allow" # `r, g, b, a` and `h, s, v` are fine +missing_panics_doc = "allow" # 68 hits +must_use_candidate = "allow" # 1169 hits +redundant_closure_for_method_calls = "allow" # 89 hits +return_self_not_must_use = "allow" # 246 hits should_panic_without_expect = "allow" +similar_names = "allow" # too many false positives, e.g. `encoder`/`encoded` +struct_excessive_bools = "allow" # 32 hits +struct_field_names = "allow" # 23 hits too_many_lines = "allow" +trivially_copy_pass_by_ref = "allow" # 119 hits +unreadable_literal = "allow" # 513 hits +used_underscore_binding = "allow" # 25 hits -# These are meh: +# Other: assigning_clones = "allow" # No please -cast_possible_truncation = "allow" # too many hits -let_underscore_must_use = "allow" -let_underscore_untyped = "allow" manual_range_contains = "allow" # this one is just worse imho map_unwrap_or = "allow" # so is this one +multiple_crate_versions = "allow" # we handle this with `cargo deny` +wildcard_imports = "allow" # `use crate::*` is useful to avoid merge conflicts when adding/removing imports + +# NOTE: these are in `restriction`/`nursery`, so the `allow` is a no-op today. +# We keep them to record our intent in case we ever enable those groups. +let_underscore_must_use = "allow" +let_underscore_untyped = "allow" self_named_module_files = "allow" # Disabled waiting on https://github.com/rust-lang/rust-clippy/issues/9602 significant_drop_tightening = "allow" # Too many false positives -wildcard_imports = "allow" # `use crate::*` is useful to avoid merge conflicts when adding/removing imports diff --git a/README.md b/README.md index 45bd23f6d..4f105a0ef 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ ui.image(egui::include_image!("ferris.png")); ## Quick start -There are simple examples in [the `examples/` folder](https://github.com/emilk/egui/blob/main/examples/). If you want to write a web app, then go to and follow the instructions. The official docs are at . For inspiration and more examples, check out the [the egui web demo](https://www.egui.rs/#demo) and follow the links in it to its source code. +There are simple examples in [the `examples/` folder](https://github.com/emilk/egui/blob/main/examples/). If you want to write a web app, then go to and follow the instructions. The official docs are at . For inspiration and more examples, check out [the egui web demo](https://www.egui.rs/#demo) and follow the links in it to its source code. If you want to integrate egui into an existing engine, go to the [Integrations](#integrations) section. diff --git a/crates/ecolor/CHANGELOG.md b/crates/ecolor/CHANGELOG.md index 340a0c4ae..31300e172 100644 --- a/crates/ecolor/CHANGELOG.md +++ b/crates/ecolor/CHANGELOG.md @@ -6,6 +6,13 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +Nothing new + ## 0.35.0 - 2026-06-25 Nothing new diff --git a/crates/ecolor/src/color32.rs b/crates/ecolor/src/color32.rs index f444b860d..7a9cf72b7 100644 --- a/crates/ecolor/src/color32.rs +++ b/crates/ecolor/src/color32.rs @@ -1,4 +1,4 @@ -use crate::{Rgba, fast_round, linear_f32_from_linear_u8}; +use crate::{Rgba, fast_round, mul_frac_round}; /// This format is used for space-efficient color representation (32 bits). /// @@ -30,15 +30,15 @@ use crate::{Rgba, fast_round, linear_f32_from_linear_u8}; #[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] pub struct Color32(pub(crate) [u8; 4]); -impl std::fmt::Debug for Color32 { +impl core::fmt::Debug for Color32 { /// Prints the contents with premultiplied alpha! - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let [r, g, b, a] = self.0; write!(f, "#{r:02X}_{g:02X}_{b:02X}_{a:02X}") } } -impl std::ops::Index for Color32 { +impl core::ops::Index for Color32 { type Output = u8; #[inline] @@ -47,7 +47,7 @@ impl std::ops::Index for Color32 { } } -impl std::ops::IndexMut for Color32 { +impl core::ops::IndexMut for Color32 { #[inline] fn index_mut(&mut self, index: usize) -> &mut u8 { &mut self.0[index] @@ -131,35 +131,10 @@ impl Color32 { /// but for transparent colors what you get back might be slightly different (rounding errors). #[inline] pub fn from_rgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self { - use std::sync::OnceLock; - match a { - // common-case optimization: - 0 => Self::TRANSPARENT, - - // common-case optimization: - 255 => Self::from_rgb(r, g, b), - - a => { - static LOOKUP_TABLE: OnceLock> = OnceLock::new(); - let lut = LOOKUP_TABLE.get_or_init(|| { - (0..=u16::MAX) - .map(|i| { - let [value, alpha] = i.to_ne_bytes(); - fast_round(value as f32 * linear_f32_from_linear_u8(alpha)) - }) - .collect() - }); - - let [r, g, b] = - [r, g, b].map(|value| lut[usize::from(u16::from_ne_bytes([value, a]))]); - Self::from_rgba_premultiplied(r, g, b, a) - } - } + Self::from_rgba_unmultiplied_const(r, g, b, a) } - /// Same as [`Self::from_rgba_unmultiplied`], but can be used in a const context. - /// - /// It is slightly slower when operating on non-const data. + /// This is the same as [`Self::from_rgba_unmultiplied`], but for const contexts. #[inline] pub const fn from_rgba_unmultiplied_const(r: u8, g: u8, b: u8, a: u8) -> Self { match a { @@ -170,9 +145,9 @@ impl Color32 { 255 => Self::from_rgb(r, g, b), a => { - let r = fast_round(r as f32 * linear_f32_from_linear_u8(a)); - let g = fast_round(g as f32 * linear_f32_from_linear_u8(a)); - let b = fast_round(b as f32 * linear_f32_from_linear_u8(a)); + let r = mul_frac_round(r, a); + let g = mul_frac_round(g, a); + let b = mul_frac_round(b, a); Self::from_rgba_premultiplied(r, g, b, a) } } @@ -378,7 +353,7 @@ impl Color32 { } } -impl std::ops::Mul for Color32 { +impl core::ops::Mul for Color32 { type Output = Self; /// Fast gamma-space multiplication. @@ -393,7 +368,7 @@ impl std::ops::Mul for Color32 { } } -impl std::ops::Add for Color32 { +impl core::ops::Add for Color32 { type Output = Self; #[inline] @@ -489,7 +464,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 std::iter::zip(&in_rgba, &out_rgba) { + for (&a, &b) in core::iter::zip(&in_rgba, &out_rgba) { assert!(a.abs_diff(b) <= 3, "{in_rgba:?} != {out_rgba:?}"); } } @@ -535,4 +510,14 @@ mod test { Color32::from_rgba_unmultiplied(255, 0, 0, 128) ); } + + #[test] + fn mul_frac_round_vs_old() { + for x in (0..=255u8).step_by(4) { + for a in (1..=255u8).step_by(4) { + let old = fast_round(x as f32 * crate::linear_f32_from_linear_u8(a)); + assert_eq!(old, mul_frac_round(x, a)); + } + } + } } diff --git a/crates/ecolor/src/hex_color_runtime.rs b/crates/ecolor/src/hex_color_runtime.rs index 21e07ffc4..5acca2ef7 100644 --- a/crates/ecolor/src/hex_color_runtime.rs +++ b/crates/ecolor/src/hex_color_runtime.rs @@ -3,7 +3,7 @@ //! Supports the 3, 4, 6, and 8-digit formats, according to the specification in //! -use std::{fmt::Display, str::FromStr}; +use core::{fmt::Display, str::FromStr}; use crate::Color32; @@ -31,7 +31,7 @@ pub enum HexColor { pub enum ParseHexColorError { MissingHash, InvalidLength, - InvalidInt(std::num::ParseIntError), + InvalidInt(core::num::ParseIntError), } impl FromStr for HexColor { @@ -45,7 +45,7 @@ impl FromStr for HexColor { } impl Display for HexColor { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Hex3(color) => { let [r, g, b, _] = color.to_srgba_unmultiplied().map(|u| u >> 4); diff --git a/crates/ecolor/src/hsva.rs b/crates/ecolor/src/hsva.rs index 17008f5da..8d92d40b8 100644 --- a/crates/ecolor/src/hsva.rs +++ b/crates/ecolor/src/hsva.rs @@ -41,7 +41,6 @@ impl Hsva { /// From linear RGBA with premultiplied alpha #[inline] pub fn from_rgba_premultiplied(r: f32, g: f32, b: f32, a: f32) -> Self { - #![expect(clippy::many_single_char_names)] if a <= 0.0 { if r == 0.0 && b == 0.0 && a == 0.0 { Self::default() @@ -57,7 +56,6 @@ impl Hsva { /// From linear RGBA without premultiplied alpha #[inline] pub fn from_rgba_unmultiplied(r: f32, g: f32, b: f32, a: f32) -> Self { - #![expect(clippy::many_single_char_names)] let (h, s, v) = hsv_from_rgb([r, g, b]); Self { h, s, v, a } } @@ -189,7 +187,6 @@ impl From for Hsva { /// All ranges in 0-1, rgb is linear. #[inline] pub fn hsv_from_rgb([r, g, b]: [f32; 3]) -> (f32, f32, f32) { - #![expect(clippy::many_single_char_names)] let min = r.min(g.min(b)); let max = r.max(g.max(b)); // value @@ -213,7 +210,6 @@ pub fn hsv_from_rgb([r, g, b]: [f32; 3]) -> (f32, f32, f32) { /// All ranges in 0-1, rgb is linear. #[inline] pub fn rgb_from_hsv((h, s, v): (f32, f32, f32)) -> [f32; 3] { - #![expect(clippy::many_single_char_names)] let h = (h.fract() + 1.0).fract(); // wrap let s = s.clamp(0.0, 1.0); diff --git a/crates/ecolor/src/lib.rs b/crates/ecolor/src/lib.rs index ea7cff6f7..02ea96c51 100644 --- a/crates/ecolor/src/lib.rs +++ b/crates/ecolor/src/lib.rs @@ -134,6 +134,17 @@ const fn fast_round(r: f32) -> u8 { (r + 0.5) as _ // rust does a saturating cast since 1.45 } +/// Compute val * (frac/255) with no floating point or divisions. +#[inline] +const fn mul_frac_round(val: u8, frac: u8) -> u8 { + // Treat this as a simple fixed point calculation + let p = (val as u16) * (frac as u16) + 128; + ((p + (p >> 8)) >> 8) as u8 + // Logic split out a bit more. + //let p = (val as u16) * (frac as u16) + 127; // + 127 to round or remove to truncate. + //return ((p + 1 + (p >> 8)) >> 8) as u8; +} + #[test] pub fn test_srgba_conversion() { for b in 0..=255 { diff --git a/crates/ecolor/src/rgba.rs b/crates/ecolor/src/rgba.rs index 98c3ce408..ecb8cb1d0 100644 --- a/crates/ecolor/src/rgba.rs +++ b/crates/ecolor/src/rgba.rs @@ -9,7 +9,7 @@ use crate::Color32; #[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] pub struct Rgba(pub(crate) [f32; 4]); -impl std::ops::Index for Rgba { +impl core::ops::Index for Rgba { type Output = f32; #[inline] @@ -18,7 +18,7 @@ impl std::ops::Index for Rgba { } } -impl std::ops::IndexMut for Rgba { +impl core::ops::IndexMut for Rgba { #[inline] fn index_mut(&mut self, index: usize) -> &mut f32 { &mut self.0[index] @@ -27,20 +27,20 @@ impl std::ops::IndexMut for Rgba { /// Deterministically hash an `f32`, treating all NANs as equal, and ignoring the sign of zero. #[inline] -pub(crate) fn f32_hash(state: &mut H, f: f32) { +pub(crate) fn f32_hash(state: &mut H, f: f32) { if f == 0.0 { state.write_u8(0); } else if f.is_nan() { state.write_u8(1); } else { - use std::hash::Hash as _; + use core::hash::Hash as _; f.to_bits().hash(state); } } -impl std::hash::Hash for Rgba { +impl core::hash::Hash for Rgba { #[inline] - fn hash(&self, state: &mut H) { + fn hash(&self, state: &mut H) { crate::f32_hash(state, self.0[0]); crate::f32_hash(state, self.0[1]); crate::f32_hash(state, self.0[2]); @@ -219,7 +219,7 @@ impl Rgba { } } -impl std::ops::Add for Rgba { +impl core::ops::Add for Rgba { type Output = Self; #[inline] @@ -233,7 +233,7 @@ impl std::ops::Add for Rgba { } } -impl std::ops::Mul for Rgba { +impl core::ops::Mul for Rgba { type Output = Self; #[inline] @@ -247,7 +247,7 @@ impl std::ops::Mul for Rgba { } } -impl std::ops::Mul for Rgba { +impl core::ops::Mul for Rgba { type Output = Self; #[inline] @@ -261,7 +261,7 @@ impl std::ops::Mul for Rgba { } } -impl std::ops::Mul for f32 { +impl core::ops::Mul for f32 { type Output = Rgba; #[inline] @@ -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 std::iter::zip(&in_rgba, &out_rgba) { + for (&a, &b) in core::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 d65e3699a..eb3dcb350 100644 --- a/crates/eframe/CHANGELOG.md +++ b/crates/eframe/CHANGELOG.md @@ -7,6 +7,24 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +### 🔧 Changed +* Improve robustness of text input handling for `eframe/web` [#8045](https://github.com/emilk/egui/pull/8045) by [@umajho](https://github.com/umajho) +* Eframe: make webbrowser dependency optional [#8372](https://github.com/emilk/egui/pull/8372) by [@wyvernbw](https://github.com/wyvernbw) +* Store `web_sys::File` inside of `DroppedFile` [#8354](https://github.com/emilk/egui/pull/8354) by [@grtlr](https://github.com/grtlr) + +### 🐛 Fixed +* Web: don't scroll host page when text agent or canvas grabs focus [#8296](https://github.com/emilk/egui/pull/8296) by [@emilk](https://github.com/emilk) +* Fix missing modifier events on eframe web, handle physical keys [#8345](https://github.com/emilk/egui/pull/8345) by [@lucasmerlin](https://github.com/lucasmerlin) +* Web: Avoid panic from lost texture updates when loaded on a background tab [#8313](https://github.com/emilk/egui/pull/8313) by [@kevinmehall](https://github.com/kevinmehall) +* Web: anchor the text agent to the canvas [#8297](https://github.com/emilk/egui/pull/8297) by [@emilk](https://github.com/emilk) +* Never run an egui pass when nothing will be shown [#8387](https://github.com/emilk/egui/pull/8387) by [@emilk](https://github.com/emilk) + + ## 0.35.0 - 2026-06-25 ### ⭐ Added * Add Context::set_cursor_image for OS-level custom cursors [#8155](https://github.com/emilk/egui/pull/8155) by [@all3f0r1](https://github.com/all3f0r1) diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index c4ec1d6e2..a9475365e 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -28,6 +28,7 @@ workspace = true default = [ "accesskit", "default_fonts", + "links", "wayland", # Required for Linux support (including CI!) "web_screen_reader", "wgpu", @@ -66,7 +67,7 @@ experimental = ["egui/experimental"] glow = ["dep:egui_glow", "dep:glow", "dep:glutin-winit", "dep:glutin"] ## Enable saving app state to disk. -persistence = ["dep:home", "egui-winit/serde", "egui/persistence", "ron", "serde"] +persistence = ["egui-winit/serde", "egui/persistence", "ron", "serde"] ## Enables wayland support and fixes clipboard issue. ## @@ -128,6 +129,9 @@ __screenshot = [] ## and capture screenshots. Off unless the env var is set; no-op on wasm. inspection = ["dep:egui_inspection", "accesskit"] +## Enables the `links` feature on `egui-winit`, allowing for links to open in browser. +links = ["egui-winit/links"] + [dependencies] egui = { workspace = true, default-features = false, features = ["bytemuck"] } @@ -151,7 +155,7 @@ serde = { workspace = true, optional = true } # ------------------------------------------- # native: [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -egui-winit = { workspace = true, default-features = false, features = ["clipboard", "links"] } +egui-winit = { workspace = true, default-features = false, features = ["clipboard"] } image = { workspace = true, features = ["png"] } # Needed for app icon winit = { workspace = true, default-features = false, features = ["rwh_06"] } @@ -167,7 +171,6 @@ glutin-winit = { workspace = true, optional = true, default-features = false, fe "egl", "wgl", ] } -home = { workspace = true, optional = true } # mac: [target.'cfg(any(target_os = "macos"))'.dependencies] @@ -212,7 +215,6 @@ image = { workspace = true, features = ["png"] } # For copying images js-sys.workspace = true percent-encoding.workspace = true wasm-bindgen.workspace = true -wasm-bindgen-futures.workspace = true web-sys = { workspace = true, features = [ "AddEventListenerOptions", "BinaryType", diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index 7de55736a..a971097a0 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -7,7 +7,7 @@ #![warn(missing_docs)] // Let's keep `epi` well-documented. #[cfg(target_arch = "wasm32")] -use std::any::Any; +use core::any::Any; #[cfg(not(target_arch = "wasm32"))] #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] @@ -41,7 +41,7 @@ pub type EventLoopBuilderHook = Box) #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub type WindowBuilderHook = Box egui::ViewportBuilder>; -type DynError = Box; +type DynError = Box; /// This is how your app is created. /// @@ -73,7 +73,7 @@ pub struct CreationContext<'s> { /// The `get_proc_address` wrapper of underlying GL context #[cfg(feature = "glow")] pub get_proc_address: - Option *const std::ffi::c_void + Send + Sync>>, + Option *const core::ffi::c_void + Send + Sync>>, /// The underlying WGPU render state. /// @@ -155,6 +155,12 @@ pub trait App { /// /// You may NOT show any ui or do any painting during the call to [`Self::logic`]. /// + /// While the window is hidden, `eframe` runs no egui pass at all (so that no ui state is + /// disturbed), and calls this via [`egui::Context::run_logic`] instead. + /// You can then still tell that the window is hidden with + /// [`egui::InputState::viewport`], but the rest of [`egui::Context::input`] + /// (events, time, …) is that of the last shown frame. + /// /// The [`egui::Context`] can be cloned and saved if you like. /// /// To force another call to [`Self::logic`], call [`egui::Context::request_repaint`] at any time (e.g. from another thread). @@ -225,8 +231,8 @@ pub trait App { // Settings: /// Time between automatic calls to [`Self::save`] - fn auto_save_interval(&self) -> std::time::Duration { - std::time::Duration::from_secs(30) + fn auto_save_interval(&self) -> core::time::Duration { + core::time::Duration::from_secs(30) } /// Background color values for the app, e.g. what is sent to `gl.clearColor`. @@ -615,8 +621,8 @@ impl Default for Renderer { } #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] -impl std::fmt::Display for Renderer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for Renderer { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { #[cfg(feature = "glow")] Self::Glow => "glow".fmt(f), @@ -628,7 +634,7 @@ impl std::fmt::Display for Renderer { } #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] -impl std::str::FromStr for Renderer { +impl core::str::FromStr for Renderer { type Err = String; fn from_str(name: &str) -> Result { diff --git a/crates/eframe/src/lib.rs b/crates/eframe/src/lib.rs index 0e7259177..55caffdd6 100644 --- a/crates/eframe/src/lib.rs +++ b/crates/eframe/src/lib.rs @@ -503,7 +503,7 @@ pub fn run_ui_native( #[derive(Debug)] pub enum Error { /// Something went wrong in user code when creating the app. - AppCreation(Box), + AppCreation(Box), /// An error from [`winit`]. #[cfg(not(target_arch = "wasm32"))] @@ -519,7 +519,7 @@ pub enum Error { /// An error from [`glutin`] when using [`glow`]. #[cfg(all(feature = "glow", not(target_arch = "wasm32")))] - NoGlutinConfigs(glutin::config::ConfigTemplate, Box), + NoGlutinConfigs(glutin::config::ConfigTemplate, Box), /// An error from [`glutin`] when using [`glow`]. #[cfg(feature = "glow")] @@ -530,7 +530,7 @@ pub enum Error { Wgpu(egui_wgpu::WgpuError), } -impl std::error::Error for Error {} +impl core::error::Error for Error {} #[cfg(not(target_arch = "wasm32"))] impl From for Error { @@ -572,8 +572,8 @@ impl From for Error { } } -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::AppCreation(err) => write!(f, "app creation error: {err}"), @@ -614,4 +614,4 @@ impl std::fmt::Display for Error { } /// Short for `Result`. -pub type Result = std::result::Result; +pub type Result = core::result::Result; diff --git a/crates/eframe/src/native/app_icon.rs b/crates/eframe/src/native/app_icon.rs index 85be6754b..ef6c96721 100644 --- a/crates/eframe/src/native/app_icon.rs +++ b/crates/eframe/src/native/app_icon.rs @@ -123,7 +123,7 @@ fn set_app_icon_windows(icon_data: &IconData) -> AppIconStatus { ) .is_err() { - return std::ptr::null_mut(); + return core::ptr::null_mut(); } // SAFETY: Creating an HICON which should be readonly on our data. @@ -161,16 +161,16 @@ fn set_app_icon_windows(icon_data: &IconData) -> AppIconStatus { if icon_big.is_null() { log::warn!("Failed to create HICON (for big icon) from embedded png data."); return AppIconStatus::NotSetIgnored; // We could try independently with the small icon but what's the point, it would look bad! - } else { - // SAFETY: Unsafe WinApi function, takes objects previously created with WinAPI, all checked for null prior. - unsafe { - SendMessageW( - window_handle, - WM_SETICON, - ICON_BIG as usize, - icon_big as isize, - ); - } + } + + // SAFETY: Unsafe WinApi function, takes objects previously created with WinAPI, all checked for null prior. + unsafe { + SendMessageW( + window_handle, + WM_SETICON, + ICON_BIG as usize, + icon_big as isize, + ); } } { @@ -180,16 +180,16 @@ fn set_app_icon_windows(icon_data: &IconData) -> AppIconStatus { if icon_small.is_null() { log::warn!("Failed to create HICON (for small icon) from embedded png data."); return AppIconStatus::NotSetIgnored; - } else { - // SAFETY: Unsafe WinApi function, takes objects previously created with WinAPI, all checked for null prior. - unsafe { - SendMessageW( - window_handle, - WM_SETICON, - ICON_SMALL as usize, - icon_small as isize, - ); - } + } + + // SAFETY: Unsafe WinApi function, takes objects previously created with WinAPI, all checked for null prior. + unsafe { + SendMessageW( + window_handle, + WM_SETICON, + ICON_SMALL as usize, + icon_small as isize, + ); } } diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 270c38388..b81225638 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -83,7 +83,7 @@ pub fn viewport_builder( } } - match std::mem::take(&mut native_options.window_builder) { + match core::mem::take(&mut native_options.window_builder) { Some(hook) => hook(viewport_builder), None => viewport_builder, } @@ -156,6 +156,11 @@ pub struct EpiIntegration { pub beginning: Instant, is_first_frame: bool, pub egui_ctx: egui::Context, + + /// Input that we have received, but not yet given to egui, + /// because we haven't run any pass since (see [`Self::update_logic_only`]). + pending_raw_input: egui::RawInput, + pending_full_output: egui::FullOutput, /// When set, it is time to close the native window. @@ -215,6 +220,7 @@ impl EpiIntegration { Self { frame, last_auto_save: Instant::now(), + pending_raw_input: Default::default(), pending_full_output: Default::default(), close: false, can_drag_window: false, @@ -262,57 +268,109 @@ 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::ui`]. + /// If `viewport_ui_cb` is None, we are in the root viewport and will call + /// [`crate::App::logic`] and [`crate::App::ui`]. + /// + /// Only call this when the ui will actually be shown; + /// use [`Self::update_logic_only`] otherwise. pub fn update( &mut self, app: &mut dyn epi::App, viewport_ui_cb: Option<&DeferredViewportUiCallback>, - mut raw_input: egui::RawInput, - is_visible: bool, + raw_input: egui::RawInput, ) -> egui::FullOutput { - raw_input.time = Some(self.beginning.elapsed().as_secs_f64()); + let raw_input = self.prepare_raw_input(app, raw_input); let close_requested = raw_input.viewport().close_requested(); - app.raw_input_hook(&self.egui_ctx, &mut raw_input); + let is_root_viewport = viewport_ui_cb.is_none(); let full_output = self.egui_ctx.run_ui(raw_input, |ui| { if let Some(viewport_ui_cb) = viewport_ui_cb { // Child viewport - if is_visible { - profiling::scope!("viewport_callback"); - viewport_ui_cb(ui); - } + profiling::scope!("viewport_callback"); + viewport_ui_cb(ui); } else { { profiling::scope!("App::logic"); app.logic(ui.ctx(), &mut self.frame); } - - if is_visible { - { - profiling::scope!("App::ui"); - app.ui(ui, &mut self.frame); - } + { + profiling::scope!("App::ui"); + app.ui(ui, &mut self.frame); } } }); - let is_root_viewport = viewport_ui_cb.is_none(); if is_root_viewport && close_requested { let canceled = full_output.viewport_output[&ViewportId::ROOT] .commands .contains(&egui::ViewportCommand::CancelClose); - if canceled { - log::debug!("Closing of root viewport canceled with ViewportCommand::CancelClose"); - } else { - log::debug!("Closing root viewport (ViewportCommand::CancelClose was not sent)"); - self.close = true; - } + self.handle_close_request(canceled); } self.pending_full_output.append(full_output); - std::mem::take(&mut self.pending_full_output) + core::mem::take(&mut self.pending_full_output) + } + + /// Let the app tick its logic without showing any ui, + /// because the window is minimized or occluded. + /// + /// No egui pass is run, so all ui state is left untouched: + /// the app will find everything where it left it once the window is visible again. + /// + /// Only call this for the root viewport: only it has [`crate::App::logic`]. + pub fn update_logic_only( + &mut self, + app: &mut dyn epi::App, + raw_input: egui::RawInput, + ) -> egui::LogicOutput { + let raw_input = self.prepare_raw_input(app, raw_input); + + let close_requested = raw_input.viewport().close_requested(); + + let logic_output = self.egui_ctx.run_logic(&raw_input, |ctx| { + profiling::scope!("App::logic"); + app.logic(ctx, &mut self.frame); + }); + + // No pass consumed the input, so save it for the next one: + self.pending_raw_input = raw_input; + + if close_requested { + let canceled = logic_output + .viewport_commands + .get(&ViewportId::ROOT) + .is_some_and(|commands| commands.contains(&egui::ViewportCommand::CancelClose)); + self.handle_close_request(canceled); + } + + logic_output + } + + /// Prepend any input we couldn't give to egui earlier, set the time, and run the app hook. + fn prepare_raw_input( + &mut self, + app: &mut dyn epi::App, + new_input: egui::RawInput, + ) -> egui::RawInput { + let mut raw_input = core::mem::take(&mut self.pending_raw_input); + raw_input.append(new_input); // The new input wins where they overlap + + raw_input.time = Some(self.beginning.elapsed().as_secs_f64()); + + app.raw_input_hook(&self.egui_ctx, &mut raw_input); + + raw_input + } + + fn handle_close_request(&mut self, canceled: bool) { + if canceled { + log::debug!("Closing of root viewport canceled with ViewportCommand::CancelClose"); + } else { + log::debug!("Closing root viewport (ViewportCommand::CancelClose was not sent)"); + self.close = true; + } } pub fn report_frame_time(&mut self, seconds: f32) { @@ -321,7 +379,7 @@ impl EpiIntegration { pub fn post_rendering(&mut self, window: &winit::window::Window) { profiling::function_scope!(); - if std::mem::take(&mut self.is_first_frame) { + if core::mem::take(&mut self.is_first_frame) { // We keep hidden until we've painted something. See https://github.com/emilk/egui/pull/2279 window.set_visible(true); } diff --git a/crates/eframe/src/native/event_loop_context.rs b/crates/eframe/src/native/event_loop_context.rs index 810db8e1f..d2081543d 100644 --- a/crates/eframe/src/native/event_loop_context.rs +++ b/crates/eframe/src/native/event_loop_context.rs @@ -1,4 +1,4 @@ -use std::cell::Cell; +use core::cell::Cell; use winit::event_loop::ActiveEventLoop; thread_local! { @@ -14,7 +14,7 @@ impl EventLoopGuard { cell.get().is_none(), "Attempted to set a new event loop while one is already set" ); - cell.set(Some(std::ptr::from_ref::(event_loop))); + cell.set(Some(core::ptr::from_ref::(event_loop))); }); Self } diff --git a/crates/eframe/src/native/file_storage.rs b/crates/eframe/src/native/file_storage.rs index 947554a00..70b4a3cae 100644 --- a/crates/eframe/src/native/file_storage.rs +++ b/crates/eframe/src/native/file_storage.rs @@ -21,7 +21,7 @@ pub fn storage_dir(app_id: &str) -> Option { OS::Nix => var_os("XDG_DATA_HOME") .map(PathBuf::from) .filter(|p| p.is_absolute()) - .or_else(|| home::home_dir().map(|p| p.join(".local").join("share"))) + .or_else(|| std::env::home_dir().map(|p| p.join(".local").join("share"))) .map(|p| { p.join( app_id @@ -29,7 +29,7 @@ pub fn storage_dir(app_id: &str) -> Option { .replace(|c: char| c.is_ascii_whitespace(), ""), ) }), - OS::Mac => home::home_dir().map(|p| { + OS::Mac => std::env::home_dir().map(|p| { p.join("Library") .join("Application Support") .join(app_id.replace(|c: char| c.is_ascii_whitespace(), "-")) @@ -44,10 +44,10 @@ pub fn storage_dir(app_id: &str) -> Option { #[cfg(all(windows, not(target_vendor = "uwp")))] #[expect(unsafe_code)] fn roaming_appdata() -> Option { + use core::ptr; + use core::slice; use std::ffi::OsString; use std::os::windows::ffi::OsStringExt as _; - use std::ptr; - use std::slice; use windows_sys::Win32::Foundation::S_OK; use windows_sys::Win32::System::Com::CoTaskMemFree; @@ -66,8 +66,8 @@ fn roaming_appdata() -> Option { SHGetKnownFolderPath( &FOLDERID_RoamingAppData, KF_FLAG_DONT_VERIFY as u32, - std::ptr::null_mut(), - &mut path_raw, + core::ptr::null_mut(), + &raw mut path_raw, ) }; diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index cb53609ee..cca1d22c6 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -8,7 +8,8 @@ #![expect(clippy::undocumented_unsafe_blocks)] #![expect(clippy::unwrap_used)] -use std::{cell::RefCell, num::NonZeroU32, rc::Rc, sync::Arc, time::Instant}; +use core::{cell::RefCell, num::NonZeroU32}; +use std::{rc::Rc, sync::Arc, time::Instant}; use egui_winit::ActionRequested; use glutin::{ @@ -31,16 +32,17 @@ use egui::{ }; #[cfg(feature = "accesskit")] use egui_winit::accesskit_winit; - -use crate::{ - App, AppCreator, CreationContext, NativeOptions, Result, Storage, - native::{epi_integration::EpiIntegration, winit_integration::is_invisible_or_minimized}, -}; +use log::warn; use super::{ epi_integration, event_loop_context, winit_integration::{EventResult, UserEvent, WinitApp, create_egui_context}, }; +use crate::epaint::textures::TexturesDelta; +use crate::{ + App, AppCreator, CreationContext, NativeOptions, Result, Storage, + native::{epi_integration::EpiIntegration, winit_integration::sleep_if_invisible_or_minimized}, +}; // ---------------------------------------------------------------------------- // Types: @@ -73,6 +75,16 @@ struct GlowWinitRunning<'app> { // NOTE: one painter shared by all viewports. painter: Rc>, + + /// Any not yet applied deltas for this app. + pending_deltas: TexturesDelta, +} + +impl Drop for GlowWinitRunning<'_> { + fn drop(&mut self) { + // Avoid debug panic when dropping unapplied deltas on teardown + self.pending_deltas.clear(); + } } /// This struct will contain both persistent and temporary glutin state. @@ -114,6 +126,9 @@ struct Viewport { info: ViewportInfo, actions_requested: Vec, + /// Any not yet applied deltas for this viewport. + pending_delta: TexturesDelta, + /// The user-callback that shows the ui. /// None for immediate viewports. viewport_ui_cb: Option>, @@ -125,6 +140,34 @@ struct Viewport { egui_winit: Option, } +impl Viewport { + /// Apply the commands, or defer them until we have a window. + fn process_commands( + &mut self, + egui_ctx: &egui::Context, + mut commands: Vec, + ) { + self.deferred_commands.append(&mut commands); + + if let Some(window) = &self.window { + egui_winit::process_viewport_commands( + egui_ctx, + &mut self.info, + core::mem::take(&mut self.deferred_commands), + window, + &mut self.actions_requested, + ); + } + } +} + +impl Drop for Viewport { + fn drop(&mut self) { + // Avoid debug panic when dropping unapplied deltas on teardown + self.pending_delta.clear(); + } +} + // ---------------------------------------------------------------------------- impl<'app> GlowWinitApp<'app> { @@ -296,7 +339,7 @@ impl<'app> GlowWinitApp<'app> { log::warn!("set_cursor_hittest(false) failed: {err}"); } - let app_creator = std::mem::take(&mut self.app_creator) + let app_creator = core::mem::take(&mut self.app_creator) .expect("Single-use AppCreator has unexpectedly already been taken"); crate::maybe_attach_inspection_plugin(&integration.egui_ctx, Some(self.app_name.clone())); @@ -353,6 +396,7 @@ impl<'app> GlowWinitApp<'app> { app, glutin, painter, + pending_deltas: Default::default(), })) } } @@ -557,7 +601,7 @@ impl GlowWinitRunning<'_> { } } - let (raw_input, viewport_ui_cb, is_visible, run_ui) = { + let (raw_input, viewport_ui_cb, is_visible, show_ui) = { let mut glutin = self.glutin.borrow_mut(); let egui_ctx = glutin.egui_ctx.clone(); let Some(viewport) = glutin.viewports.get_mut(&viewport_id) else { @@ -576,7 +620,7 @@ impl GlowWinitRunning<'_> { let mut raw_input = egui_winit.take_egui_input(window); let viewport_ui_cb = viewport.viewport_ui_cb.clone(); - let run_ui = + let show_ui = is_visible || is_viewport_or_descendant_visible(&glutin.viewports, viewport_id); self.integration.pre_update(); @@ -588,9 +632,58 @@ impl GlowWinitRunning<'_> { .map(|(id, viewport)| (*id, viewport.info.clone())) .collect(); - (raw_input, viewport_ui_cb, is_visible, run_ui) + (raw_input, viewport_ui_cb, is_visible, show_ui) }; + if !show_ui { + // Nothing will be shown, so we run no egui pass at all. + // That way all ui state is left untouched, and is still there + // when this viewport becomes visible again. + let is_root_viewport = viewport_ui_cb.is_none(); + if is_root_viewport { + // The app logic keeps ticking, so it can e.g. ask to be shown again: + let egui::LogicOutput { + platform_output, + viewport_commands, + } = self + .integration + .update_logic_only(self.app.as_mut(), raw_input); + + let mut glutin = self.glutin.borrow_mut(); + if let Some(viewport) = glutin.viewports.get_mut(&viewport_id) { + viewport.info.events.clear(); // they should have been processed + if let Some(window) = viewport.window.clone() + && let Some(egui_winit) = viewport.egui_winit.as_mut() + { + egui_winit.handle_platform_output_with_event_loop( + &window, + event_loop, + platform_output, + ); + } + } + for (id, commands) in viewport_commands { + if let Some(viewport) = glutin.viewports.get_mut(&id) { + viewport.process_commands(&self.integration.egui_ctx, commands); + } + } + } + + sleep_if_invisible_or_minimized( + self.glutin + .borrow() + .viewports + .get(&viewport_id) + .and_then(|viewport| viewport.window.as_deref()), + ); + + return Ok(if self.integration.should_close() { + EventResult::CloseRequested + } else { + EventResult::Wait + }); + } + // HACK: In order to get the right clear_color, the system theme needs to be set, which // usually only happens in the `update` call. So we call Options::begin_pass early // to set the right theme. Without this there would be a black flash on the first frame. @@ -639,12 +732,9 @@ impl GlowWinitRunning<'_> { // The update function, which could call immediate viewports, // so make sure we don't hold any locks here required by the immediate viewports rendeer. - let full_output = self.integration.update( - self.app.as_mut(), - viewport_ui_cb.as_deref(), - raw_input, - run_ui, - ); + let full_output = + self.integration + .update(self.app.as_mut(), viewport_ui_cb.as_deref(), raw_input); // ------------------------------------------------------------ @@ -653,6 +743,7 @@ impl GlowWinitRunning<'_> { app, glutin, painter, + pending_deltas, .. } = self; @@ -666,6 +757,7 @@ impl GlowWinitRunning<'_> { pixels_per_point, viewport_output, } = full_output; + pending_deltas.append(textures_delta); glutin.remove_viewports_not_in(&viewport_output); @@ -687,30 +779,28 @@ impl GlowWinitRunning<'_> { egui_winit.handle_platform_output_with_event_loop(&window, event_loop, platform_output); - // Upload textures even when not visible: the atlas dirty region is already - // consumed, so dropping the delta would desync the font texture. - let has_texture_updates = !textures_delta.set.is_empty() || !textures_delta.free.is_empty(); - if is_visible || has_texture_updates { - // We may need to switch contexts again, because of immediate viewports: - frame_timer.pause(); - change_gl_context(current_gl_context, not_current_gl_context, gl_surface); - frame_timer.resume(); - } - - for (id, image_delta) in &textures_delta.set { - painter.set_texture(*id, image_delta); - } - if is_visible { let clipped_primitives = integration.egui_ctx.tessellate(shapes, pixels_per_point); + { + // We may need to switch contexts again, because of immediate viewports: + frame_timer.pause(); + change_gl_context(current_gl_context, not_current_gl_context, gl_surface); + frame_timer.resume(); + } + let screen_size_in_pixels: [u32; 2] = window.inner_size().into(); if !clear_before_update { painter.clear(screen_size_in_pixels, clear_color); } - painter.paint_primitives(screen_size_in_pixels, pixels_per_point, &clipped_primitives); + painter.paint_and_update_textures( + screen_size_in_pixels, + pixels_per_point, + &clipped_primitives, + pending_deltas, + ); { for action in viewport.actions_requested.drain(..) { @@ -772,25 +862,13 @@ impl GlowWinitRunning<'_> { } } - // Free textures *after* painting, since they may still be used in the frame we just drew. - for id in &textures_delta.free { - painter.free_texture(*id); - } - glutin.handle_viewport_output(event_loop, &integration.egui_ctx, &viewport_output); integration.report_frame_time(frame_timer.total_time_sec()); // don't count auto-save time as part of regular frame time integration.maybe_autosave(app.as_mut(), Some(&window)); - if is_invisible_or_minimized(&window) { - // On Mac, a minimized Window uses up all CPU: - // https://github.com/emilk/egui/issues/325 - // On Windows, an invisible window also uses up all CPU: - // https://github.com/emilk/egui/issues/7776 - profiling::scope!("minimized_sleep"); - std::thread::sleep(std::time::Duration::from_millis(10)); - } + sleep_if_invisible_or_minimized(Some(&window)); if integration.should_close() { Ok(EventResult::CloseRequested) @@ -1120,6 +1198,7 @@ impl GlutinWindowContext { deferred_commands: vec![], info: viewport_info, actions_requested: Default::default(), + pending_delta: Default::default(), viewport_ui_cb: None, gl_surface: None, window: window.map(Arc::new), @@ -1330,7 +1409,7 @@ impl GlutinWindowContext { } } - fn get_proc_address(&self, addr: &std::ffi::CStr) -> *const std::ffi::c_void { + fn get_proc_address(&self, addr: &core::ffi::CStr) -> *const core::ffi::c_void { self.gl_config.display().get_proc_address(addr) } @@ -1362,7 +1441,7 @@ impl GlutinWindowContext { class, builder, viewport_ui_cb, - mut commands, + commands, repaint_delay: _, // ignored - we listened to the repaint callback instead }, ) in viewport_output.clone() @@ -1377,25 +1456,18 @@ impl GlutinWindowContext { viewport_ui_cb, ); - if let Some(window) = &viewport.window { - let old_inner_size = window.inner_size(); + let old_inner_size = viewport.window.as_ref().map(|window| window.inner_size()); - viewport.deferred_commands.append(&mut commands); + viewport.process_commands(egui_ctx, commands); - egui_winit::process_viewport_commands( - egui_ctx, - &mut viewport.info, - std::mem::take(&mut viewport.deferred_commands), - window, - &mut viewport.actions_requested, - ); - - // For Wayland : https://github.com/emilk/egui/issues/4196 - if cfg!(target_os = "linux") { - let new_inner_size = window.inner_size(); - if new_inner_size != old_inner_size { - self.resize(viewport_id, new_inner_size); - } + // For Wayland : https://github.com/emilk/egui/issues/4196 + if cfg!(target_os = "linux") + && let Some(window) = &viewport.window + && let Some(old_inner_size) = old_inner_size + { + let new_inner_size = window.inner_size(); + if new_inner_size != old_inner_size { + self.resize(viewport_id, new_inner_size); } } } @@ -1436,6 +1508,7 @@ fn initialize_or_update_viewport( deferred_commands: vec![], info: Default::default(), actions_requested: Default::default(), + pending_delta: Default::default(), viewport_ui_cb, window: None, egui_winit: None, @@ -1584,8 +1657,10 @@ fn render_immediate_viewport( } = &mut *glutin; let Some(viewport) = viewports.get_mut(&viewport_id) else { + warn!("Viewport disappeared unexpectedly!"); return; }; + viewport.pending_delta.append(textures_delta); viewport.info.events.clear(); // they should have been processed @@ -1621,7 +1696,7 @@ fn render_immediate_viewport( screen_size_in_pixels, pixels_per_point, &clipped_primitives, - &textures_delta, + &mut viewport.pending_delta, ); { @@ -1645,7 +1720,7 @@ fn save_screenshot_and_exit( screen_size_in_pixels: [u32; 2], ) { assert!( - path.ends_with(".png"), + egui::load::has_extension(path, "png"), "Expected EFRAME_SCREENSHOT_TO to end with '.png', got {path:?}" ); let screenshot = painter.read_screen_rgba(screen_size_in_pixels); diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index 345cc3e2c..7c38aba4b 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -1,4 +1,5 @@ -use std::time::{Duration, Instant}; +use core::time::Duration; +use std::time::Instant; use winit::{ application::ApplicationHandler, @@ -41,7 +42,7 @@ fn create_event_loop(native_options: &mut epi::NativeOptions) -> Result( mut native_options: epi::NativeOptions, f: impl FnOnce(&mut EventLoop, epi::NativeOptions) -> R, ) -> Result { - thread_local!(static EVENT_LOOP: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }); + thread_local!(static EVENT_LOOP: core::cell::RefCell>> = const { core::cell::RefCell::new(None) }); EVENT_LOOP.with(|event_loop| { // Since we want to reference NativeOptions when creating the EventLoop we can't @@ -206,7 +207,12 @@ impl WinitAppWrapper { invisible_window_ids.push(*window_id); } else { log::trace!("request_redraw for {window_id:?}"); - event_loop.set_control_flow(ControlFlow::Poll); + // Don't switch to `ControlFlow::Poll` here. `request_redraw` + // is enough to wake the event loop, and on Wayland the + // `RedrawRequested` event is only delivered once the + // compositor sends a frame callback. Polling in the meantime + // busy-loops a whole CPU core. + // See https://github.com/emilk/egui/issues/8326. window.request_redraw(); } } else { @@ -236,10 +242,16 @@ impl WinitAppWrapper { } } + // Always set an explicit, sleeping control flow. Previously we only set + // `WaitUntil` when a repaint was already scheduled, which meant that a + // `ControlFlow::Poll` set earlier was never undone once the last timed + // repaint had been consumed, leaving the loop spinning. + // See https://github.com/emilk/egui/issues/8326. let next_repaint_time = self.windows_next_repaint_times.values().min().copied(); - if let Some(next_repaint_time) = next_repaint_time { - event_loop.set_control_flow(ControlFlow::WaitUntil(next_repaint_time)); - } + event_loop.set_control_flow(match next_repaint_time { + Some(next_repaint_time) => ControlFlow::WaitUntil(next_repaint_time), + None => ControlFlow::Wait, + }); } } @@ -550,7 +562,7 @@ impl<'a> EframeWinitApplication<'a> { pub fn pump_eframe_app( &mut self, event_loop: &mut EventLoop, - timeout: Option, + timeout: Option, ) -> EframePumpStatus { use winit::platform::pump_events::{EventLoopExtPumpEvents as _, PumpStatus}; diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index 918b8419f..4a94a5d49 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -5,7 +5,8 @@ //! There is a bunch of improvements we could do, //! like removing a bunch of `unwraps`. -use std::{cell::RefCell, num::NonZeroU32, rc::Rc, sync::Arc, time::Instant}; +use core::{cell::RefCell, num::NonZeroU32}; +use std::{rc::Rc, sync::Arc, time::Instant}; use egui_winit::ActionRequested; use parking_lot::Mutex; @@ -17,19 +18,20 @@ use winit::{ use ahash::HashMap; use egui::{ - DeferredViewportUiCallback, FullOutput, ImmediateViewport, OrderedViewportIdMap, + DeferredViewportUiCallback, FullOutput, ImmediateViewport, OrderedViewportIdMap, TexturesDelta, ViewportBuilder, ViewportClass, ViewportId, ViewportIdPair, ViewportIdSet, ViewportInfo, ViewportOutput, }; #[cfg(feature = "accesskit")] use egui_winit::accesskit_winit; +use log::warn; use winit_integration::UserEvent; use crate::{ App, AppCreator, CreationContext, NativeOptions, Result, Storage, native::{ epi_integration::EpiIntegration, - winit_integration::{EventResult, is_invisible_or_minimized}, + winit_integration::{EventResult, sleep_if_invisible_or_minimized}, }, }; @@ -65,6 +67,15 @@ struct WgpuWinitRunning<'app> { /// Wrapped in an `Rc>` so it can be re-entrantly shared via a weak-pointer. shared: Rc>, + + pending_deltas: TexturesDelta, +} + +impl Drop for WgpuWinitRunning<'_> { + fn drop(&mut self) { + // Avoid debug panic when dropping unapplied deltas on teardown + self.pending_deltas.clear(); + } } /// Everything needed by the immediate viewport renderer.\ @@ -91,6 +102,9 @@ pub struct Viewport { info: ViewportInfo, actions_requested: Vec, + /// Any not yet applied deltas for this viewport. + pending_delta: TexturesDelta, + /// `None` for sync viewports. viewport_ui_cb: Option>, @@ -102,6 +116,13 @@ pub struct Viewport { egui_winit: Option, } +impl Drop for Viewport { + fn drop(&mut self) { + // Avoid debug panic when dropping unapplied deltas on teardown + self.pending_delta.clear(); + } +} + // ---------------------------------------------------------------------------- impl<'app> WgpuWinitApp<'app> { @@ -289,7 +310,7 @@ impl<'app> WgpuWinitApp<'app> { egui_winit.init_accesskit(event_loop, &window, event_loop_proxy); } - let app_creator = std::mem::take(&mut self.app_creator) + let app_creator = core::mem::take(&mut self.app_creator) .expect("Single-use AppCreator has unexpectedly already been taken"); crate::maybe_attach_inspection_plugin(&egui_ctx, Some(self.app_name.clone())); @@ -328,6 +349,7 @@ impl<'app> WgpuWinitApp<'app> { viewport_ui_cb: None, window: Some(window), egui_winit: Some(egui_winit), + pending_delta: Default::default(), }, ); @@ -358,6 +380,7 @@ impl<'app> WgpuWinitApp<'app> { integration, app, shared, + pending_deltas: Default::default(), })) } } @@ -596,12 +619,13 @@ impl WgpuWinitRunning<'_> { app, integration, shared, + pending_deltas, } = self; let mut frame_timer = crate::stopwatch::Stopwatch::new(); frame_timer.start(); - let (viewport_ui_cb, raw_input, is_visible, run_ui) = { + let (viewport_ui_cb, raw_input, is_visible, show_ui) = { profiling::scope!("Prepare"); let mut shared_lock = shared.borrow_mut(); @@ -657,7 +681,7 @@ impl WgpuWinitRunning<'_> { }; let mut raw_input = egui_winit.take_egui_input(window); - let run_ui = is_visible || is_viewport_or_descendant_visible(viewports, viewport_id); + let show_ui = is_visible || is_viewport_or_descendant_visible(viewports, viewport_id); integration.pre_update(); @@ -669,15 +693,67 @@ impl WgpuWinitRunning<'_> { painter.handle_screenshots(&mut raw_input.events); - (viewport_ui_cb, raw_input, is_visible, run_ui) + (viewport_ui_cb, raw_input, is_visible, show_ui) }; + if !show_ui { + // Nothing will be shown, so we run no egui pass at all. + // That way all ui state is left untouched, and is still there + // when this viewport becomes visible again. + let is_root_viewport = viewport_ui_cb.is_none(); + if is_root_viewport { + // The app logic keeps ticking, so it can e.g. ask to be shown again: + let egui::LogicOutput { + platform_output, + viewport_commands, + } = integration.update_logic_only(app.as_mut(), raw_input); + + let mut shared_mut = shared.borrow_mut(); + let SharedState { viewports, .. } = &mut *shared_mut; + + if let Some(viewport) = viewports.get_mut(&viewport_id) { + viewport.info.events.clear(); // they should have been processed + if let Viewport { + window: Some(window), + egui_winit: Some(egui_winit), + .. + } = viewport + { + egui_winit.handle_platform_output_with_event_loop( + window, + event_loop, + platform_output, + ); + } + } + + for (id, commands) in viewport_commands { + if let Some(viewport) = viewports.get_mut(&id) { + viewport.process_commands(&integration.egui_ctx, commands); + } + } + } + + sleep_if_invisible_or_minimized( + shared + .borrow() + .viewports + .get(&viewport_id) + .and_then(|viewport| viewport.window.as_deref()), + ); + + return Ok(if integration.should_close() { + EventResult::CloseRequested + } else { + EventResult::Wait + }); + } + // ------------------------------------------------------------ // Runs the update, which could call immediate viewports, // so make sure we hold no locks here! - let full_output = - integration.update(app.as_mut(), viewport_ui_cb.as_deref(), raw_input, run_ui); + let full_output = integration.update(app.as_mut(), viewport_ui_cb.as_deref(), raw_input); // ------------------------------------------------------------ @@ -699,6 +775,8 @@ impl WgpuWinitRunning<'_> { viewport_output, } = full_output; + pending_deltas.append(textures_delta); + remove_viewports_not_in(viewports, painter, viewport_from_window, &viewport_output); let Some(viewport) = viewports.get_mut(&viewport_id) else { @@ -735,7 +813,7 @@ impl WgpuWinitRunning<'_> { pixels_per_point, app.clear_color(&egui_ctx.global_style().visuals), &clipped_primitives, - &textures_delta, + pending_deltas, screenshot_commands, window, ); @@ -796,16 +874,7 @@ impl WgpuWinitRunning<'_> { integration.maybe_autosave(app.as_mut(), window.map(|w| w.as_ref())); - if let Some(window) = window - && is_invisible_or_minimized(window) - { - // On Mac, a minimized Window uses up all CPU: - // https://github.com/emilk/egui/issues/325 - // On Windows, an invisible window also uses up all CPU: - // https://github.com/emilk/egui/issues/7776 - profiling::scope!("minimized_sleep"); - std::thread::sleep(std::time::Duration::from_millis(10)); - } + sleep_if_invisible_or_minimized(window.map(|window| window.as_ref())); if integration.should_close() { Ok(EventResult::CloseRequested) @@ -960,6 +1029,25 @@ impl WgpuWinitRunning<'_> { } impl Viewport { + /// Apply the commands, or defer them until we have a window. + fn process_commands( + &mut self, + egui_ctx: &egui::Context, + mut commands: Vec, + ) { + self.deferred_commands.append(&mut commands); + + if let Some(window) = self.window.as_ref() { + egui_winit::process_viewport_commands( + egui_ctx, + &mut self.info, + core::mem::take(&mut self.deferred_commands), + window, + &mut self.actions_requested, + ); + } + } + /// Create winit window, if needed. fn initialize_window( &mut self, @@ -1125,8 +1213,11 @@ fn render_immediate_viewport( } = &mut *shared_mut; let Some(viewport) = viewports.get_mut(&ids.this) else { + warn!("Viewport disappeared unexpectedly!"); return; }; + viewport.pending_delta.append(textures_delta); + viewport.info.events.clear(); // they should have been processed let (Some(egui_winit), Some(window)) = (&mut viewport.egui_winit, &viewport.window) else { return; @@ -1149,7 +1240,7 @@ fn render_immediate_viewport( pixels_per_point, [0.0, 0.0, 0.0, 0.0], &clipped_primitives, - &textures_delta, + &mut viewport.pending_delta, vec![], window, ); @@ -1194,7 +1285,7 @@ fn handle_viewport_output( class, builder, viewport_ui_cb, - mut commands, + commands, repaint_delay: _, // ignored - we listened to the repaint callback instead }, ) in viewport_output.clone() @@ -1204,30 +1295,23 @@ fn handle_viewport_output( let viewport = initialize_or_update_viewport(viewports, ids, class, builder, viewport_ui_cb, painter); - if let Some(window) = viewport.window.as_ref() { - let old_inner_size = window.inner_size(); + let old_inner_size = viewport.window.as_ref().map(|window| window.inner_size()); - viewport.deferred_commands.append(&mut commands); + viewport.process_commands(egui_ctx, commands); - egui_winit::process_viewport_commands( - egui_ctx, - &mut viewport.info, - std::mem::take(&mut viewport.deferred_commands), - window, - &mut viewport.actions_requested, - ); - - // For Wayland : https://github.com/emilk/egui/issues/4196 - if cfg!(target_os = "linux") { - let new_inner_size = window.inner_size(); - if new_inner_size != old_inner_size - && let (Some(width), Some(height)) = ( - NonZeroU32::new(new_inner_size.width), - NonZeroU32::new(new_inner_size.height), - ) - { - painter.on_window_resized(viewport_id, width, height); - } + // For Wayland : https://github.com/emilk/egui/issues/4196 + if cfg!(target_os = "linux") + && let Some(window) = viewport.window.as_ref() + && let Some(old_inner_size) = old_inner_size + { + let new_inner_size = window.inner_size(); + if new_inner_size != old_inner_size + && let (Some(width), Some(height)) = ( + NonZeroU32::new(new_inner_size.width), + NonZeroU32::new(new_inner_size.height), + ) + { + painter.on_window_resized(viewport_id, width, height); } } } @@ -1268,6 +1352,7 @@ fn initialize_or_update_viewport<'a>( viewport_ui_cb, window: None, egui_winit: None, + pending_delta: Default::default(), }) } diff --git a/crates/eframe/src/native/winit_integration.rs b/crates/eframe/src/native/winit_integration.rs index b4ec62c09..5e767201e 100644 --- a/crates/eframe/src/native/winit_integration.rs +++ b/crates/eframe/src/native/winit_integration.rs @@ -17,6 +17,18 @@ pub fn is_invisible_or_minimized(window: &Window) -> bool { window.is_visible() == Some(false) || window.is_minimized() == Some(true) } +/// On Mac, a minimized window uses up all CPU: +/// +/// +/// On Windows, an invisible window also uses up all CPU: +/// +pub fn sleep_if_invisible_or_minimized(window: Option<&Window>) { + if window.is_some_and(is_invisible_or_minimized) { + profiling::scope!("minimized_sleep"); + std::thread::sleep(core::time::Duration::from_millis(10)); + } +} + /// Create an egui context, restoring it from storage if possible. pub fn create_egui_context(storage: Option<&dyn crate::Storage>) -> egui::Context { profiling::function_scope!(); diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index e259e4019..548bdf4b5 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -280,52 +280,71 @@ impl AppRunner { .and_then(|v| v.visible()) .unwrap_or(true); - let full_output = self.egui_ctx.run_ui(raw_input, |ui| { - self.app.logic(ui.ctx(), &mut self.frame); - - if is_visible { + if is_visible { + let full_output = self.egui_ctx.run_ui(raw_input, |ui| { + self.app.logic(ui.ctx(), &mut self.frame); self.app.ui(ui, &mut self.frame); - } - }); - let egui::FullOutput { - platform_output, - textures_delta, - shapes, - pixels_per_point, - viewport_output, - } = full_output; + }); + let egui::FullOutput { + platform_output, + textures_delta, + shapes, + pixels_per_point, + viewport_output, + } = full_output; - if viewport_output.len() > 1 { - log::warn!("Multiple viewports not yet supported on the web"); - } - for (_viewport_id, viewport_output) in viewport_output { - for command in viewport_output.commands { - match command { - ViewportCommand::Screenshot(user_data) => { - self.screenshot_commands_with_frame_delay - .push((user_data, 1)); - } - _ => { - // TODO(emilk): handle some of the commands - log::warn!( - "Unhandled egui viewport command: {command:?} - not implemented in web backend" - ); - } - } + if viewport_output.len() > 1 { + log::warn!("Multiple viewports not yet supported on the web"); } - } + self.handle_viewport_commands( + viewport_output + .into_values() + .flat_map(|viewport_output| viewport_output.commands), + ); - self.handle_platform_output(platform_output); - if is_visible || !textures_delta.is_empty() { + self.handle_platform_output(platform_output); self.textures_delta.append(textures_delta); self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point)); + } else { + // The tab is hidden, so we run no egui pass at all. + // That way all ui state is left untouched, and is still there + // when the tab is shown again. + + let egui::LogicOutput { + platform_output, + viewport_commands, + } = self.egui_ctx.run_logic(&raw_input, |ctx| { + self.app.logic(ctx, &mut self.frame); + }); + + // No pass consumed the input, so save it for the next one: + self.input.raw.append(raw_input); + + self.handle_viewport_commands(viewport_commands.into_values().flatten()); + self.handle_platform_output(platform_output); + } + } + + fn handle_viewport_commands(&mut self, commands: impl Iterator) { + for command in commands { + match command { + ViewportCommand::Screenshot(user_data) => { + self.screenshot_commands_with_frame_delay + .push((user_data, 1)); + } + _ => { + // TODO(emilk): handle some of the commands + log::warn!( + "Unhandled egui viewport command: {command:?} - not implemented in web backend" + ); + } + } } } /// Paint the results of the last call to [`Self::logic`]. pub fn paint(&mut self) { - let textures_delta = std::mem::take(&mut self.textures_delta); - let clipped_primitives = std::mem::take(&mut self.clipped_primitives); + let clipped_primitives = core::mem::take(&mut self.clipped_primitives); if let Some(clipped_primitives) = clipped_primitives { let mut screenshot_commands = vec![]; @@ -347,7 +366,7 @@ impl AppRunner { self.app.clear_color(&self.egui_ctx.global_style().visuals), &clipped_primitives, self.egui_ctx.pixels_per_point(), - &textures_delta, + &mut self.textures_delta, screenshot_commands, ) { log::error!("Failed to paint: {}", super::string_from_js_value(&err)); @@ -395,7 +414,10 @@ impl AppRunner { if self.has_focus() { // The eframe app has focus. - if ime.is_some() { + if let Some(ime) = ime { + if ime.should_interrupt_composition { + self.text_agent.interrupt_ime_composition(); + } // We are editing text: give the focus to the text agent. self.text_agent.focus(); } else { @@ -407,7 +429,7 @@ impl AppRunner { if let Err(err) = self .text_agent - .move_to(ime, self.canvas(), self.egui_ctx.zoom_factor()) + .update(ime, self.canvas(), self.egui_ctx.zoom_factor()) { log::error!( "failed to update text agent position: {}", diff --git a/crates/eframe/src/web/dropped_file.rs b/crates/eframe/src/web/dropped_file.rs new file mode 100644 index 000000000..2924e6d46 --- /dev/null +++ b/crates/eframe/src/web/dropped_file.rs @@ -0,0 +1,45 @@ +use core::{future::Future, pin::Pin}; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub(crate) struct WebFile { + file: web_sys::File, + // We store a `PathBuf` here so that we can hand out `Path`s + // without allocating each time. + path: PathBuf, +} + +impl From for WebFile { + fn from(file: web_sys::File) -> Self { + let path = file.name().into(); + Self { file, path } + } +} + +impl egui::DroppedFile for WebFile { + fn path(&self) -> &Path { + &self.path + } + + fn bytes_async(&self) -> Pin, String>> + '_>> { + let file = self.file.clone(); + Box::pin(async move { + if file.size() > f64::from(u32::MAX) { + return Err(format!( + "File is too large: browser file reads are limited to {} bytes", + u32::MAX + )); + } + + let array_buffer = file + .array_buffer() + .await + .map_err(|err| crate::web::string_from_js_value(&err))?; + Ok(js_sys::Uint8Array::new(&array_buffer).to_vec()) + }) + } + + fn web_file(&self) -> Option<&web_sys::File> { + Some(&self.file) + } +} diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index 326ba1556..0303f0cdf 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -190,11 +190,6 @@ pub(crate) fn on_keydown(event: web_sys::KeyboardEvent, runner: &mut AppRunner) return; } - if event.is_composing() || event.key_code() == 229 { - // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ - return; - } - let modifiers = modifiers_from_kb_event(&event); runner.input.set_modifiers(modifiers); @@ -978,62 +973,25 @@ fn install_drag_and_drop(runner_ref: &WebRunner, target: &EventTarget) -> Result event.prevent_default(); })?; - runner_ref.add_event_listener(target, "drop", { - let runner_ref = runner_ref.clone(); + runner_ref.add_event_listener(target, "drop", |event: web_sys::DragEvent, runner| { + if let Some(data_transfer) = event.data_transfer() { + // TODO(https://github.com/emilk/egui/issues/3702): support dropping folders + runner.input.raw.hovered_files.clear(); + runner.needs_repaint.repaint_asap(); - move |event: web_sys::DragEvent, runner| { - if let Some(data_transfer) = event.data_transfer() { - // TODO(https://github.com/emilk/egui/issues/3702): support dropping folders - runner.input.raw.hovered_files.clear(); - runner.needs_repaint.repaint_asap(); + if let Some(files) = data_transfer.files() { + for i in 0..files.length() { + if let Some(file) = files.get(i) { + log::debug!("Dropped {:?} ({} bytes)", file.name(), file.size()); - if let Some(files) = data_transfer.files() { - for i in 0..files.length() { - if let Some(file) = files.get(i) { - let name = file.name(); - let mime = file.type_(); - let last_modified = std::time::UNIX_EPOCH - + std::time::Duration::from_millis(file.last_modified() as u64); - - log::debug!("Loading {:?} ({} bytes)…", name, file.size()); - - let future = wasm_bindgen_futures::JsFuture::from(file.array_buffer()); - - let runner_ref = runner_ref.clone(); - let future = async move { - match future.await { - Ok(array_buffer) => { - let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec(); - log::debug!("Loaded {:?} ({} bytes).", name, bytes.len()); - - if let Some(mut runner_lock) = runner_ref.try_lock() { - runner_lock.input.raw.dropped_files.push( - egui::DroppedFile { - name, - mime, - last_modified: Some(last_modified), - bytes: Some(bytes.into()), - ..Default::default() - }, - ); - runner_lock.needs_repaint.repaint_asap(); - } - } - Err(err) => { - log::error!( - "Failed to read file: {}", - string_from_js_value(&err) - ); - } - } - }; - wasm_bindgen_futures::spawn_local(future); - } + runner.input.raw.dropped_files.push(std::sync::Arc::new( + super::dropped_file::WebFile::from(file), + )); } } - event.stop_propagation(); - event.prevent_default(); } + event.stop_propagation(); + event.prevent_default(); } })?; diff --git a/crates/eframe/src/web/input.rs b/crates/eframe/src/web/input.rs index 62723e8fa..bd0195646 100644 --- a/crates/eframe/src/web/input.rs +++ b/crates/eframe/src/web/input.rs @@ -32,7 +32,7 @@ pub fn primary_touch_pos( event: &web_sys::TouchEvent, ) -> Option<(egui::Pos2, web_sys::Touch)> { // 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( + let all_touches: Vec<_> = core::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)), ) diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index 67923987b..bf851d4ad 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -5,6 +5,7 @@ mod app_runner; mod backend; +mod dropped_file; mod events; mod input; mod panic_handler; @@ -207,13 +208,12 @@ fn set_clipboard_text(s: &str) { return; } let promise = window.navigator().clipboard().write_text(s); - let future = wasm_bindgen_futures::JsFuture::from(promise); let future = async move { - if let Err(err) = future.await { + if let Err(err) = promise.await { log::error!("Copy/cut action failed: {}", string_from_js_value(&err)); } }; - wasm_bindgen_futures::spawn_local(future); + js_sys::futures::spawn_local(future); } } @@ -248,16 +248,15 @@ fn set_clipboard_image(image: &egui::ColorImage) { }; let items = js_sys::Array::of1(&item); let promise = window.navigator().clipboard().write(&items); - let future = wasm_bindgen_futures::JsFuture::from(promise); let future = async move { - if let Err(err) = future.await { + if let Err(err) = promise.await { log::error!( "Copy/cut image action failed: {}", string_from_js_value(&err) ); } }; - wasm_bindgen_futures::spawn_local(future); + js_sys::futures::spawn_local(future); } } diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 73461e9e4..d9a12347b 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -1,16 +1,16 @@ //! The text agent is a hidden `` element used to capture //! IME and mobile keyboard input events. -use std::cell::Cell; +use core::cell::RefCell; +use std::rc::Rc; use wasm_bindgen::prelude::*; -use web_sys::Document; use super::{AppRunner, WebRunner}; pub struct TextAgent { input: web_sys::HtmlInputElement, - prev_ime_output: Cell>, + input_state: Rc>, } impl TextAgent { @@ -19,7 +19,8 @@ impl TextAgent { runner_ref: &WebRunner, canvas: &web_sys::HtmlCanvasElement, ) -> Result { - let document = web_sys::window().unwrap().document().unwrap(); + let window = web_sys::window().unwrap(); + let document = window.document().unwrap(); // create an `` element let input = document @@ -27,11 +28,11 @@ impl TextAgent { .dyn_into::()?; input.set_type("text"); input.set_attribute("autocapitalize", "off")?; + let input_state = Rc::new(RefCell::new(InputState::new(input.clone()))); - // Hide the element, and park it over the canvas + // Hide the element, and park it over the top-left corner of the canvas // so that focusing it can never scroll some other part // of the page into view. - let canvas_rect = super::canvas_content_rect(canvas); let style = input.style(); style.set_property("background-color", "transparent")?; style.set_property("border", "none")?; @@ -40,21 +41,22 @@ impl TextAgent { style.set_property("height", "1px")?; style.set_property("caret-color", "transparent")?; style.set_property("position", "absolute")?; - style.set_property("top", &format!("{}px", canvas_rect.min.y))?; - style.set_property("left", &format!("{}px", canvas_rect.min.x))?; + style.set_property("top", &format!("{}px", canvas.offset_top()))?; + style.set_property("left", &format!("{}px", canvas.offset_left()))?; // Prevent auto-zoom on mobile browsers (requires at least 16px). style.set_property("font-size", "16px")?; - let root = canvas.get_root_node(); - if root.has_type::() { - // root object is a document, append to its body - root.dyn_into::()? - .body() - .unwrap() - .append_child(&input)?; - } else { - // append input into root directly - root.append_child(&input)?; + // Insert the input as a sibling of the canvas, so that its + // `position: absolute` resolves against the same containing block + // as the canvas' `offset_top`/`offset_left`. + // This anchors the input to the canvas regardless of how the page + // is scrolled or how the canvas is embedded, and also works when + // the canvas is inside a shadow DOM. + if let Some(parent) = canvas.parent_node() { + parent.insert_before(&input, canvas.next_sibling().as_ref())?; + } else if let Some(body) = document.body() { + log::warn!("Canvas has no parent element - appending text agent to document body"); + body.append_child(&input)?; } // Focus the app on startup, without scrolling the page. @@ -66,152 +68,67 @@ impl TextAgent { // attach event listeners - let on_input = { - let input = input.clone(); - move |event: web_sys::InputEvent, runner: &mut AppRunner| { - let text = input.value(); - // Workaround for an Android Gboard issue: after typing a word, - // the user has to delete invisible characters (whose count - // matches the length of the current suggestion) before actual - // characters are deleted, unless the focus has been reset. - // - // this issue appears to have been fixed in Gboard sometime - // between versions 14.7.09 and 17.0.12. - if !event.is_composing() { - input.blur().ok(); - super::focus_without_scroll(&input).ok(); - } - - if event.is_composing() { - // if `is_composing` is true, then user is using IME, for - // example: emoji, pinyin, kanji, hangul, etc. In that case, - // the browser emits both `input` and `compositionupdate` - // events. - // We handle the composition update here instead of in the - // `compositionupdate` event because the selection range - // has not yet been updated when `compositionupdate` fires. - - let Some(text) = event.data() else { return }; - let selection_start = input - .selection_start() - .unwrap_or(None) - .map(|pos| pos as usize); - let selection_end = input - .selection_end() - .unwrap_or(None) - .map(|pos| pos as usize); - let active_range_chars = if let Some(selection_start) = selection_start - && let Some(selection_end) = selection_end - { - let text_utf16 = text.encode_utf16().collect::>(); - let text_before_selection = - String::from_utf16_lossy(&text_utf16[..selection_start]); - let text_in_selection = - String::from_utf16_lossy(&text_utf16[selection_start..selection_end]); - let count_before_selection = text_before_selection.chars().count(); - let count_in_selection = text_in_selection.chars().count(); - Some(count_before_selection..count_before_selection + count_in_selection) - } else { - None - }; - let event = egui::Event::Ime(egui::ImeEvent::Preedit { - text, - active_range_chars, - }); - runner.input.raw.events.push(event); - } else { - if text.is_empty() { - return; - } - - input.set_value(""); - let event = egui::Event::Text(text); - runner.input.raw.events.push(event); - } - - runner.needs_repaint.repaint_asap(); - } - }; - - let on_composition_start = { + runner_ref.add_event_listener( + &input, + "compositionstart", move |_: web_sys::CompositionEvent, runner: &mut AppRunner| { // Repaint moves the text agent into place, - // see `move_to` in `AppRunner::handle_platform_output`. + // see `AppRunner::handle_platform_output`, which calls + // `TextAgent::update`. runner.needs_repaint.repaint_asap(); + }, + )?; + + runner_ref.add_event_listener(&input, "input", { + let input_state = Rc::clone(&input_state); + move |event: web_sys::InputEvent, runner: &mut AppRunner| { + input_state.borrow_mut().handle_input_event(&event, runner); } - }; - - let on_composition_end = { - let input = input.clone(); - move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { - let Some(text) = event.data() else { return }; - input.set_value(""); - let event = egui::Event::Ime(egui::ImeEvent::Commit(text)); - runner.input.raw.events.push(event); - runner.needs_repaint.repaint_asap(); + })?; + runner_ref.add_event_listener(&input, "compositionend", { + let input_state = Rc::clone(&input_state); + move |_event: web_sys::CompositionEvent, runner: &mut AppRunner| { + input_state + .borrow_mut() + .handle_composition_end_event(runner); } - }; + })?; - runner_ref.add_event_listener(&input, "input", on_input)?; - runner_ref.add_event_listener(&input, "compositionstart", on_composition_start)?; - runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?; + runner_ref.add_event_listener(&input, "keydown", { + let input_state = Rc::clone(&input_state); + move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { + let is_consumed = InputState::handle_keydown_event(&input_state, &event); + if !is_consumed { + // The canvas doesn't get keydown/keyup events when the text agent is focused, + // so we need to forward them to the runner: + super::events::on_keydown(event, runner); + } + } + })?; + runner_ref.add_event_listener(&input, "keyup", { + let input_state = Rc::clone(&input_state); + move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { + let is_consumed = InputState::handle_keyup_event(&input_state, &event); + if !is_consumed { + // The canvas doesn't get keydown/keyup events when the text agent is focused, + // so we need to forward them to the runner: + super::events::on_keyup(event, runner); + } + } + })?; - // The canvas doesn't get keydown/keyup events when the text agent is focused, - // so we need to forward them to the runner: - runner_ref.add_event_listener(&input, "keydown", super::events::on_keydown)?; - runner_ref.add_event_listener(&input, "keyup", super::events::on_keyup)?; - - Ok(Self { - input, - prev_ime_output: Default::default(), - }) + Ok(Self { input, input_state }) } - pub fn move_to( + pub fn update( &self, ime: Option, canvas: &web_sys::HtmlCanvasElement, zoom_factor: f32, ) -> Result<(), JsValue> { - // Don't move the text agent unless the position actually changed: - if self.prev_ime_output.get() == ime { - return Ok(()); - } - self.prev_ime_output.set(ime); - - let Some(ime) = ime else { return Ok(()) }; - - if ime.should_interrupt_composition { - // no-op for now: currently, the text agent is sizeless, so any - // click shifts focus to the canvas, which naturally interrupts the - // composition. - } - - let mut canvas_rect = super::canvas_content_rect(canvas); - // Fix for safari with virtual keyboard flapping position - if is_mobile_safari() { - canvas_rect.min.y = canvas.offset_top() as f32; - } - let cursor_rect = ime.cursor_rect.translate(canvas_rect.min.to_vec2()); - - let style = self.input.style(); - let native_ppp = super::native_pixels_per_point(); - - // Clamp the input position within the canvas width to prevent unwanted horizontal scrolling. - let logical_canvas_width = canvas.width() as f32 / native_ppp; - let visible_x = cursor_rect.center().x * zoom_factor; - let clamped_x = visible_x.clamp(0.0, logical_canvas_width); - - // Clamp the input position within the canvas height to prevent unwanted vertical scrolling. - let logical_canvas_height = canvas.height() as f32 / native_ppp; - let visible_y = cursor_rect.center().y * zoom_factor; - let clamped_y = visible_y.clamp(0.0, logical_canvas_height); - - // This is where the IME input will point to: - style.set_property("left", &format!("{clamped_x}px"))?; - style.set_property("top", &format!("{clamped_y}px"))?; - - Ok(()) + self.input_state + .borrow_mut() + .update(ime, canvas, zoom_factor) } pub fn set_focus(&self, on: bool) { @@ -248,6 +165,11 @@ impl TextAgent { if let Err(err) = self.input.blur() { log::error!("failed to set focus: {}", super::string_from_js_value(&err)); } + self.input_state.borrow_mut().clear(); + } + + pub(crate) fn interrupt_ime_composition(&self) { + self.input_state.borrow_mut().clear(); } } @@ -257,15 +179,273 @@ impl Drop for TextAgent { } } -/// Returns `true` if the app is likely running on a mobile device on navigator Safari. -fn is_mobile_safari() -> bool { - (|| { - let user_agent = web_sys::window()?.navigator().user_agent().ok()?; - let is_ios = user_agent.contains("iPhone") - || user_agent.contains("iPad") - || user_agent.contains("iPod"); - let is_safari = user_agent.contains("Safari"); - Some(is_ios && is_safari) - })() - .unwrap_or(false) +struct InputState { + input: web_sys::HtmlInputElement, + last_text: String, + ime_output: Option, + keydown_special_case: KeydownSpecialCase, +} + +#[derive(Clone, Copy)] +enum KeydownSpecialCase { + None, + + /// On Android Gboard 14.7.09, when suggestions remain visible while typing + /// letters without IME composition (e.g., Latin or Cyrillic), pressing + /// Backspace produces key code 229 instead of the expected Backspace key + /// code. + /// Without the workaround, users have to press Backspace twice before text + /// starts being deleted. + /// + /// This workaround is also required for Android Gboard corrections and + /// completions (e.g., `tex|` -> `Texas`) to work correctly. In these + /// cases, a `deleteContentBackward` input event fires first (e.g., to + /// delete `tex`), followed by an `insertText` input event (e.g., to insert + /// `Texas`). + /// + /// Since it is difficult to distinguish between a Backspace press and a + /// correction or completion (e.g., when the state is `t|`, it is unclear + /// whether the user wants to delete `t` or replace it with `Texas`), we + /// send a `DeleteSurrounding` IME event in all cases instead of + /// synthetically generating Backspace press and release events. + AndroidKeycode229, + + /// iOS (18.6)'s built-in Korean keyboard uses `deleteContentBackward` to + /// compose Hangul characters. In these cases, the key code is 0. + IosKeycode0, +} + +impl InputState { + fn new(input: web_sys::HtmlInputElement) -> Self { + Self { + input, + last_text: String::new(), + ime_output: None, + keydown_special_case: KeydownSpecialCase::None, + } + } + + fn update( + &mut self, + ime: Option, + canvas: &web_sys::HtmlCanvasElement, + zoom_factor: f32, + ) -> Result<(), JsValue> { + // Don't move the text agent unless the position actually changed: + if self.ime_output == ime { + return Ok(()); + } + self.ime_output = ime; + + let Some(ime) = ime else { return Ok(()) }; + + // NOTE: we don't set the input's `type` to `password` based on + // `ime.purpose`, because that would confuse some password managers. + // For example, Chrome's password manager will always think the last + // letter typed in the password field is the password. + + let style = self.input.style(); + let native_ppp = super::native_pixels_per_point(); + + // The input is a sibling of the canvas (see `attach`), so we position + // it relative to the same containing block using the canvas offset. + // Unlike `get_bounding_client_rect`, the offset is unaffected by page + // scrolling, and doesn't flap when the virtual keyboard is shown on + // mobile Safari. + + // Clamp the input position within the canvas width to prevent unwanted horizontal scrolling. + let logical_canvas_width = canvas.width() as f32 / native_ppp; + let visible_x = ime.cursor_rect.center().x * zoom_factor; + let clamped_x = visible_x.clamp(0.0, logical_canvas_width); + + // Clamp the input position within the canvas height to prevent unwanted vertical scrolling. + let logical_canvas_height = canvas.height() as f32 / native_ppp; + let visible_y = ime.cursor_rect.center().y * zoom_factor; + let clamped_y = visible_y.clamp(0.0, logical_canvas_height); + + // This is where the IME input will point to: + style.set_property( + "left", + &format!("{}px", canvas.offset_left() as f32 + clamped_x), + )?; + style.set_property( + "top", + &format!("{}px", canvas.offset_top() as f32 + clamped_y), + )?; + + Ok(()) + } + + fn clear(&mut self) { + self.input.set_value(""); + self.last_text.clear(); + } + + fn handle_input_event(&mut self, event: &web_sys::InputEvent, runner: &mut AppRunner) { + if self + .ime_output + .as_ref() + .is_some_and(|ime| ime.purpose == egui::IMEPurpose::Password) + { + self.handle_input_event_password(event, runner); + return; + } + + let input_type = event.input_type(); + + if !event.is_composing() + && input_type != "insertText" + // iOS uses this for corrections and completions (e.g., `tex|` -> + // `Texas`). + && input_type != "insertReplacementText" + && (matches!(self.keydown_special_case, KeydownSpecialCase::None) + || input_type != "deleteContentBackward") + { + self.clear(); + + return; + } + + let text = self.input.value(); + + let prefix_len = longest_common_prefix_length(&text, &self.last_text); + let last_text_len = self.last_text.chars().count(); + if prefix_len < last_text_len { + let out_event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { + before_chars: last_text_len - prefix_len, + after_chars: 0, + }); + runner.input.raw.events.push(out_event); + } + + let preedit_text: String = text.chars().skip(prefix_len).collect(); + let out_event = if event.is_composing() { + // We handle the composition update here instead of in a + // `compositionupdate` event because the selection range + // has not yet been updated when `compositionupdate` fires. + let active_range_chars = self.active_range_chars(&text, prefix_len); + egui::Event::Ime(egui::ImeEvent::Preedit { + text: preedit_text, + active_range_chars, + }) + } else { + egui::Event::Text(preedit_text) + }; + runner.input.raw.events.push(out_event); + + if event.is_composing() { + self.last_text = text.chars().take(prefix_len).collect(); + } else { + self.last_text = text; + } + + runner.needs_repaint.repaint_asap(); + } + + fn handle_input_event_password(&mut self, event: &web_sys::InputEvent, runner: &mut AppRunner) { + let input_type = event.input_type(); + + if input_type != "insertText" { + return; + } + + let text = self.input.value(); + + runner.input.raw.events.push(egui::Event::Text(text)); + self.clear(); + } + + /// Compute the active range (cursor or conversion segment) within the + /// preedit text, based on the selection in the input element. + /// + /// `text` is the full `input.value()`, and `prefix_len_chars` is the + /// number of chars at the start of `text` that are committed (not part + /// of the preedit). `selectionStart`/`selectionEnd` are UTF-16 offsets + /// within the full `input.value()`, so they are adjusted to be relative + /// to the preedit text. + fn active_range_chars( + &self, + text: &str, + prefix_len_chars: usize, + ) -> Option> { + let selection_start = self.input.selection_start().unwrap_or(None)? as usize; + let selection_end = self.input.selection_end().unwrap_or(None)? as usize; + + let text_utf16 = text.encode_utf16().collect::>(); + if selection_start > text_utf16.len() || selection_end > text_utf16.len() { + // This can occur on Android Chrome. see discussion in: + // . + return None; + } + + let text_before_selection = String::from_utf16_lossy(&text_utf16[..selection_start]); + let text_in_selection = + String::from_utf16_lossy(&text_utf16[selection_start..selection_end]); + let count_before_selection = text_before_selection.chars().count(); + let count_in_selection = text_in_selection.chars().count(); + + // Adjust for the committed prefix to get the range within the preedit text. + let start = count_before_selection.saturating_sub(prefix_len_chars); + let end = start + count_in_selection; + Some(start..end) + } + + fn handle_composition_end_event(&mut self, runner: &mut AppRunner) { + let text = self.input.value(); + + let commit_text = { + let prefix_len = self.last_text.chars().count(); + text.chars().skip(prefix_len).collect::() + }; + let out_event = egui::Event::Ime(egui::ImeEvent::Commit(commit_text)); + runner.input.raw.events.push(out_event); + + self.last_text = text; + + runner.needs_repaint.repaint_asap(); + } + + /// ## Returns + /// Whether the event is consumed. If `true`, the caller should not do + /// further processing for this event. + fn handle_keydown_event(input_state: &RefCell, event: &web_sys::KeyboardEvent) -> bool { + // Platform-sniffing methods are unreliable, so they are not used as + // guards here. + let special_case = match event.key_code() { + 229 => KeydownSpecialCase::AndroidKeycode229, + 0 => KeydownSpecialCase::IosKeycode0, + _ => KeydownSpecialCase::None, + }; + input_state.borrow_mut().keydown_special_case = special_case; + + // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + if event.is_composing() || !matches!(special_case, KeydownSpecialCase::None) { + true + } else { + if event.key().chars().count() > 1 + || event.ctrl_key() + || event.alt_key() + || event.meta_key() + { + input_state.borrow_mut().clear(); + } + false + } + } + + /// ## Returns + /// Whether the event is consumed. If `true`, the caller should not do + /// further processing for this event. + fn handle_keyup_event(input_state: &RefCell, event: &web_sys::KeyboardEvent) -> bool { + input_state.borrow_mut().keydown_special_case = KeydownSpecialCase::None; + + // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + event.is_composing() || event.key_code() == 229 + } +} + +fn longest_common_prefix_length(a: &str, b: &str) -> usize { + core::iter::zip(a.chars(), b.chars()) + .take_while(|(a, b)| a == b) + .count() } diff --git a/crates/eframe/src/web/web_painter.rs b/crates/eframe/src/web/web_painter.rs index fe751bf16..69f60e271 100644 --- a/crates/eframe/src/web/web_painter.rs +++ b/crates/eframe/src/web/web_painter.rs @@ -24,7 +24,7 @@ pub(crate) trait WebPainter { clear_color: [f32; 4], clipped_primitives: &[egui::ClippedPrimitive], pixels_per_point: f32, - textures_delta: &egui::TexturesDelta, + textures_delta: &mut egui::TexturesDelta, capture: Vec, ) -> Result<(), JsValue>; diff --git a/crates/eframe/src/web/web_painter_glow.rs b/crates/eframe/src/web/web_painter_glow.rs index c9b846d50..04d48210e 100644 --- a/crates/eframe/src/web/web_painter_glow.rs +++ b/crates/eframe/src/web/web_painter_glow.rs @@ -61,13 +61,16 @@ impl WebPainter for WebPainterGlow { clear_color: [f32; 4], clipped_primitives: &[egui::ClippedPrimitive], pixels_per_point: f32, - textures_delta: &egui::TexturesDelta, + textures_delta: &mut egui::TexturesDelta, capture: Vec, ) -> Result<(), JsValue> { let canvas_dimension = [self.canvas.width(), self.canvas.height()]; - for (id, image_delta) in &textures_delta.set { - self.painter.set_texture(*id, image_delta); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, image_deltas) in textures_delta.set.drain() { + for image_delta in image_deltas { + self.painter.set_texture(id, &image_delta); + } } egui_glow::painter::clear(self.painter.gl(), canvas_dimension, clear_color); @@ -79,7 +82,8 @@ impl WebPainter for WebPainterGlow { self.screenshots.push((image, capture)); } - for &id in &textures_delta.free { + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for id in textures_delta.free.drain() { self.painter.free_texture(id); } diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs index 0665d99f9..afb4ee2f9 100644 --- a/crates/eframe/src/web/web_painter_wgpu.rs +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -164,7 +164,7 @@ impl WebPainter for WebPainterWgpu { clear_color: [f32; 4], clipped_primitives: &[egui::ClippedPrimitive], pixels_per_point: f32, - textures_delta: &egui::TexturesDelta, + textures_delta: &mut egui::TexturesDelta, capture_data: Vec, ) -> Result<(), JsValue> { let capture = !capture_data.is_empty(); @@ -210,13 +210,16 @@ impl WebPainter for WebPainterWgpu { let user_cmd_bufs = { let mut renderer = render_state.renderer.write(); - for (id, image_delta) in &textures_delta.set { - renderer.update_texture( - &render_state.device, - &render_state.queue, - *id, - image_delta, - ); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, image_deltas) in textures_delta.set.drain() { + for image_delta in image_deltas { + renderer.update_texture( + &render_state.device, + &render_state.queue, + id, + &image_delta, + ); + } } renderer.update_buffers( @@ -365,7 +368,7 @@ impl WebPainter for WebPainterWgpu { // Submit the commands: both the main buffer and user-defined ones. render_state .queue - .submit(std::iter::chain(user_cmd_bufs, [encoder.finish()])); + .submit(core::iter::chain(user_cmd_bufs, [encoder.finish()])); if let Some((frame, capture_buffer)) = frame_and_capture_buffer { if let Some(capture_buffer) = capture_buffer @@ -388,8 +391,9 @@ impl WebPainter for WebPainterWgpu { // However, once we called `wgpu::Queue::submit`, it is up for wgpu to determine how long the underlying gpu resource has to live. { let mut renderer = render_state.renderer.write(); - for id in &textures_delta.free { - renderer.free_texture(id); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for id in textures_delta.free.drain() { + renderer.free_texture(&id); } } diff --git a/crates/eframe/src/web/web_runner.rs b/crates/eframe/src/web/web_runner.rs index 2bf842ab0..528855192 100644 --- a/crates/eframe/src/web/web_runner.rs +++ b/crates/eframe/src/web/web_runner.rs @@ -1,4 +1,5 @@ -use std::{cell::RefCell, rc::Rc}; +use core::cell::RefCell; +use std::rc::Rc; use wasm_bindgen::prelude::*; @@ -107,7 +108,7 @@ impl WebRunner { fn unsubscribe_from_all_events(&self) { let events_to_unsubscribe: Vec<_> = - std::mem::take(&mut *self.events_to_unsubscribe.borrow_mut()); + core::mem::take(&mut *self.events_to_unsubscribe.borrow_mut()); if !events_to_unsubscribe.is_empty() { log::debug!("Unsubscribing from {} events", events_to_unsubscribe.len()); @@ -139,7 +140,7 @@ impl WebRunner { /// Returns `None` if there has been a panic, or if we have been destroyed. /// In that case, just return to JS. - pub(crate) fn try_lock(&self) -> Option> { + pub(crate) fn try_lock(&self) -> Option> { if self.panic_handler.has_panicked() { // Unsubscribe from all events so that we don't get any more callbacks // that will try to access the poisoned runner. @@ -147,7 +148,7 @@ impl WebRunner { None } else { let lock = self.app_runner.try_borrow_mut().ok()?; - std::cell::RefMut::filter_map(lock, |lock| -> Option<&mut AppRunner> { lock.as_mut() }) + core::cell::RefMut::filter_map(lock, |lock| -> Option<&mut AppRunner> { lock.as_mut() }) .ok() } } @@ -158,9 +159,9 @@ impl WebRunner { /// and return `None` if this runner has panicked. pub fn app_mut( &self, - ) -> Option> { + ) -> Option> { self.try_lock() - .map(|lock| std::cell::RefMut::map(lock, |runner| runner.app_mut::())) + .map(|lock| core::cell::RefMut::map(lock, |runner| runner.app_mut::())) } /// Convenience function to reduce boilerplate and ensure that all event handlers diff --git a/crates/egui-wgpu/CHANGELOG.md b/crates/egui-wgpu/CHANGELOG.md index 6d0d1085d..b2d1063b3 100644 --- a/crates/egui-wgpu/CHANGELOG.md +++ b/crates/egui-wgpu/CHANGELOG.md @@ -6,6 +6,16 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +* Upgrade wgpu to v30 [#8289](https://github.com/emilk/egui/pull/8289) by [@akx](https://github.com/akx) +* Fix: ensure mapped range is dropped before unmapping buffer in capture [#8337](https://github.com/emilk/egui/pull/8337) by [@MagicCrazyMan](https://github.com/MagicCrazyMan) +* Make wgpu Instance public [#8321](https://github.com/emilk/egui/pull/8321) by [@oleflb](https://github.com/oleflb) + + ## 0.35.0 - 2026-06-25 * Call `pre_present_notify` before presenting [#8089](https://github.com/emilk/egui/pull/8089) by [@dimtpap](https://github.com/dimtpap) * Wgpu: Allow configuring VSync and frame latency at runtime [#8114](https://github.com/emilk/egui/pull/8114) by [@emilk](https://github.com/emilk) diff --git a/crates/egui-wgpu/src/capture.rs b/crates/egui-wgpu/src/capture.rs index c5519e8a6..54ddeb884 100644 --- a/crates/egui-wgpu/src/capture.rs +++ b/crates/egui-wgpu/src/capture.rs @@ -255,7 +255,7 @@ struct BufferPadding { impl BufferPadding { fn new(width: u32) -> Self { - let bytes_per_pixel = std::mem::size_of::() as u32; + let bytes_per_pixel = core::mem::size_of::() as u32; let unpadded_bytes_per_row = width * bytes_per_pixel; let padded_bytes_per_row = wgpu::util::align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); diff --git a/crates/egui-wgpu/src/egui.wgsl b/crates/egui-wgpu/src/egui.wgsl index 39210841b..5b4b8240f 100644 --- a/crates/egui-wgpu/src/egui.wgsl +++ b/crates/egui-wgpu/src/egui.wgsl @@ -98,20 +98,31 @@ fn vs_main( @group(1) @binding(0) var r_tex_color: texture_2d; @group(1) @binding(1) var r_tex_sampler: sampler; +/// 1 if the texture sampler uses nearest filtering, 0 if linear. +/// Only read when `predictable_texture_filtering` is on. +@group(1) @binding(2) var r_tex_nearest_filtering: u32; + fn sample_texture(in: VertexOutput) -> vec4 { if r_locals.predictable_texture_filtering == 0 { // Hardware filtering: fast, but varies across GPUs and drivers. return textureSample(r_tex_color, r_tex_sampler, in.tex_coord); } else { - // Manual bilinear filtering with four taps at pixel centers using textureLoad let texture_size = vec2(textureDimensions(r_tex_color, 0)); let texture_size_f = vec2(texture_size); + let max_coord = texture_size - vec2(1, 1); + + if r_tex_nearest_filtering == 1 { + // Nearest filtering: load the texel under the sample position. + let texel = clamp(vec2(in.tex_coord * texture_size_f), vec2(0, 0), max_coord); + return textureLoad(r_tex_color, texel, 0); + } + + // Manual bilinear filtering with four taps at pixel centers using textureLoad let pixel_coord = in.tex_coord * texture_size_f - 0.5; let pixel_fract = fract(pixel_coord); let pixel_floor = vec2(floor(pixel_coord)); // Manual texture clamping - let max_coord = texture_size - vec2(1, 1); let p00 = clamp(pixel_floor + vec2(0, 0), vec2(0, 0), max_coord); let p10 = clamp(pixel_floor + vec2(1, 0), vec2(0, 0), max_coord); let p01 = clamp(pixel_floor + vec2(0, 1), vec2(0, 0), max_coord); diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs index d48198ed7..1eb975aee 100644 --- a/crates/egui-wgpu/src/lib.rs +++ b/crates/egui-wgpu/src/lib.rs @@ -115,6 +115,9 @@ pub struct RenderState { #[cfg(not(target_arch = "wasm32"))] pub available_adapters: Vec, + /// Wgpu instance used for creating surfaces and adapters. + pub instance: wgpu::Instance, + /// Wgpu device used for rendering, created from the adapter. pub device: wgpu::Device, @@ -218,7 +221,7 @@ impl RenderState { instance.enumerate_adapters(backends).await }; - let (adapter, device, queue) = match config.wgpu_setup.clone() { + let (instance, adapter, device, queue) = match config.wgpu_setup.clone() { WgpuSetup::CreateNew(WgpuSetupCreateNew { instance_descriptor: _, display_handle: _, @@ -253,14 +256,14 @@ impl RenderState { .await? }; - (adapter, device, queue) + (instance.clone(), adapter, device, queue) } WgpuSetup::Existing(WgpuSetupExisting { - instance: _, + instance, adapter, device, queue, - }) => (adapter, device, queue), + }) => (instance, adapter, device, queue), }; log_adapter_info(&adapter.get_info()); @@ -280,6 +283,7 @@ impl RenderState { // It doesn't make sense to switch to Rc for that special usecase, so simply disable the lint. #[allow(clippy::allow_attributes, clippy::arc_with_non_send_sync)] // For wasm Ok(Self { + instance, adapter, #[cfg(not(target_arch = "wasm32"))] available_adapters, @@ -354,8 +358,8 @@ fn wgpu_config_impl_send_sync() { assert_send_sync::(); } -impl std::fmt::Debug for WgpuConfiguration { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for WgpuConfiguration { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let Self { surface, wgpu_setup, @@ -482,7 +486,7 @@ pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String { // > name: "Apple M1 Pro", device_type: IntegratedGpu, backend: Metal, driver: "", driver_info: "" // > name: "ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)", device_type: IntegratedGpu, backend: Gl, driver: "", driver_info: "" - use std::fmt::Write as _; + use core::fmt::Write as _; let mut summary = format!("backend: {backend:?}, device_type: {device_type:?}"); diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs index 2ff9f7e4a..267363591 100644 --- a/crates/egui-wgpu/src/renderer.rs +++ b/crates/egui-wgpu/src/renderer.rs @@ -1,4 +1,5 @@ -use std::{borrow::Cow, num::NonZeroU64, ops::Range}; +use core::{num::NonZeroU64, ops::Range}; +use std::borrow::Cow; use ahash::HashMap; use bytemuck::Zeroable as _; @@ -244,6 +245,12 @@ pub struct Renderer { uniform_bind_group: wgpu::BindGroup, texture_bind_group_layout: wgpu::BindGroupLayout, + /// Uniform buffers each holding a single `u32`: + /// 1 if the texture sampler uses nearest filtering, 0 otherwise. + /// Indexed by that flag value. + /// Read by the shader when `predictable_texture_filtering` is on. + nearest_filtering_flag_buffers: [wgpu::Buffer; 2], + /// Map of egui texture IDs to textures and their associated bindgroups (texture view + /// sampler). The texture may be None if the `TextureId` is just a handle to a user-provided /// sampler. @@ -299,7 +306,9 @@ impl Renderer { visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { has_dynamic_offset: false, - min_binding_size: NonZeroU64::new(std::mem::size_of::() as _), + min_binding_size: NonZeroU64::new( + core::mem::size_of::() as _ + ), ty: wgpu::BufferBindingType::Uniform, }, count: None, @@ -344,10 +353,28 @@ impl Renderer { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(core::mem::size_of::() as _), + ty: wgpu::BufferBindingType::Uniform, + }, + count: None, + }, ], }) }; + let nearest_filtering_flag_buffers = [0_u32, 1_u32].map(|flag| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(&format!("egui_nearest_filtering_flag_{flag}")), + contents: bytemuck::bytes_of(&flag), + usage: wgpu::BufferUsages::UNIFORM, + }) + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("egui_pipeline_layout"), bind_group_layouts: &[ @@ -434,9 +461,9 @@ impl Renderer { }; const VERTEX_BUFFER_START_CAPACITY: wgpu::BufferAddress = - (std::mem::size_of::() * 1024) as _; + (core::mem::size_of::() * 1024) as _; const INDEX_BUFFER_START_CAPACITY: wgpu::BufferAddress = - (std::mem::size_of::() * 1024 * 3) as _; + (core::mem::size_of::() * 1024 * 3) as _; Self { pipeline, @@ -455,6 +482,7 @@ impl Renderer { previous_uniform_buffer_content: UniformBuffer::zeroed(), uniform_bind_group, texture_bind_group_layout, + nearest_filtering_flag_buffers, textures: HashMap::default(), next_user_texture_id: 0, samplers: HashMap::default(), @@ -706,6 +734,8 @@ impl Renderer { }; let bind_group = bind_group.unwrap_or_else(|| { + let nearest = + image_delta.options.magnification == epaint::textures::TextureFilter::Nearest; let sampler = self .samplers .entry(image_delta.options) @@ -724,6 +754,11 @@ impl Renderer { binding: 1, resource: wgpu::BindingResource::Sampler(sampler), }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.nearest_filtering_flag_buffers[usize::from(nearest)] + .as_entire_binding(), + }, ], }) }); @@ -826,6 +861,7 @@ impl Renderer { ) -> epaint::TextureId { profiling::function_scope!(); + let nearest = sampler_descriptor.mag_filter == wgpu::FilterMode::Nearest; let sampler = device.create_sampler(&wgpu::SamplerDescriptor { compare: None, ..sampler_descriptor @@ -843,6 +879,11 @@ impl Renderer { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler), }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.nearest_filtering_flag_buffers[usize::from(nearest)] + .as_entire_binding(), + }, ], }); @@ -882,6 +923,7 @@ impl Renderer { .get_mut(&id) .expect("Tried to update a texture that has not been allocated yet."); + let nearest = sampler_descriptor.mag_filter == wgpu::FilterMode::Nearest; let sampler = device.create_sampler(&wgpu::SamplerDescriptor { compare: None, ..sampler_descriptor @@ -899,6 +941,11 @@ impl Renderer { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler), }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.nearest_filtering_flag_buffers[usize::from(nearest)] + .as_entire_binding(), + }, ], }); @@ -962,7 +1009,7 @@ impl Renderer { self.index_buffer.slices.clear(); - let required_index_buffer_size = (std::mem::size_of::() * index_count) as u64; + let required_index_buffer_size = (core::mem::size_of::() * index_count) as u64; if self.index_buffer.capacity < required_index_buffer_size { // Resize index buffer if needed. self.index_buffer.capacity = @@ -989,7 +1036,7 @@ impl Renderer { for epaint::ClippedPrimitive { primitive, .. } in paint_jobs { match primitive { Primitive::Mesh(mesh) => { - let size = mesh.indices.len() * std::mem::size_of::(); + let size = mesh.indices.len() * core::mem::size_of::(); let slice = index_offset..(size + index_offset); index_buffer_staging .slice(slice.clone()) @@ -1006,7 +1053,8 @@ impl Renderer { self.vertex_buffer.slices.clear(); - let required_vertex_buffer_size = (std::mem::size_of::() * vertex_count) as u64; + let required_vertex_buffer_size = + (core::mem::size_of::() * vertex_count) as u64; if self.vertex_buffer.capacity < required_vertex_buffer_size { // Resize vertex buffer if needed. self.vertex_buffer.capacity = @@ -1034,7 +1082,7 @@ impl Renderer { for epaint::ClippedPrimitive { primitive, .. } in paint_jobs { match primitive { Primitive::Mesh(mesh) => { - let size = mesh.vertices.len() * std::mem::size_of::(); + let size = mesh.vertices.len() * core::mem::size_of::(); let slice = vertex_offset..(size + vertex_offset); vertex_buffer_staging .slice(slice.clone()) diff --git a/crates/egui-wgpu/src/setup.rs b/crates/egui-wgpu/src/setup.rs index f2733a6e7..3909a9b53 100644 --- a/crates/egui-wgpu/src/setup.rs +++ b/crates/egui-wgpu/src/setup.rs @@ -9,7 +9,7 @@ use std::sync::Arc; /// Automatically implemented for all types that satisfy the bounds /// (including [`winit::event_loop::OwnedDisplayHandle`]). pub trait EguiDisplayHandle: - wgpu::rwh::HasDisplayHandle + std::fmt::Debug + Send + Sync + 'static + wgpu::rwh::HasDisplayHandle + core::fmt::Debug + Send + Sync + 'static { /// Clone into a `Box` for [`wgpu::InstanceDescriptor::display`]. fn clone_for_wgpu(&self) -> Box; @@ -27,7 +27,7 @@ impl Clone for Box { impl EguiDisplayHandle for T where - T: wgpu::rwh::HasDisplayHandle + Clone + std::fmt::Debug + Send + Sync + 'static, + T: wgpu::rwh::HasDisplayHandle + Clone + core::fmt::Debug + Send + Sync + 'static, { fn clone_for_wgpu(&self) -> Box { Box::new(self.clone()) @@ -77,8 +77,8 @@ impl WgpuSetup { } } -impl std::fmt::Debug for WgpuSetup { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for WgpuSetup { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::CreateNew(create_new) => f .debug_tuple("WgpuSetup::CreateNew") @@ -295,8 +295,8 @@ impl Clone for WgpuSetupCreateNew { } } -impl std::fmt::Debug for WgpuSetupCreateNew { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for WgpuSetupCreateNew { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let Self { instance_descriptor, display_handle, diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index 1abf2d2fa..84b32927f 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -8,8 +8,9 @@ use crate::{ RendererOptions, capture::{CaptureReceiver, CaptureSender, CaptureState, capture_channel}, }; +use core::num::NonZeroU32; use egui::{Context, Event, UserData, ViewportId, ViewportIdMap, ViewportIdSet}; -use std::{num::NonZeroU32, sync::Arc}; +use std::sync::Arc; struct SurfaceState { surface: wgpu::Surface<'static>, @@ -478,7 +479,7 @@ impl Painter { pixels_per_point: f32, clear_color: [f32; 4], clipped_primitives: &[epaint::ClippedPrimitive], - textures_delta: &epaint::textures::TexturesDelta, + textures_delta: &mut epaint::textures::TexturesDelta, capture_data: Vec, window: &Arc, ) -> f32 { @@ -545,21 +546,6 @@ impl Painter { commands_submitted: false, }; - { - // Upload textures before the surface-dependent early-returns below: - // uploads only need the device + queue, and the atlas dirty region is - // already consumed, so dropping the delta would desync the font texture. - let mut renderer = render_state.renderer.write(); - for (id, image_delta) in &textures_delta.set { - renderer.update_texture( - &render_state.device, - &render_state.queue, - *id, - image_delta, - ); - } - } - let Some(surface_state) = self.surfaces.get_mut(&viewport_id) else { return vsync_sec; }; @@ -579,6 +565,18 @@ impl Painter { let user_cmd_bufs = { let mut renderer = render_state.renderer.write(); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, image_deltas) in textures_delta.set.drain() { + for image_delta in image_deltas { + renderer.update_texture( + &render_state.device, + &render_state.queue, + id, + &image_delta, + ); + } + } + renderer.update_buffers( &render_state.device, &render_state.queue, @@ -730,7 +728,7 @@ impl Painter { let start = web_time::Instant::now(); render_state .queue - .submit(std::iter::chain(user_cmd_bufs, [encoded])); + .submit(core::iter::chain(user_cmd_bufs, [encoded])); vsync_sec += start.elapsed().as_secs_f32(); }; @@ -742,8 +740,9 @@ impl Painter { // However, once we called `wgpu::Queue::submit`, it is up for wgpu to determine how long the underlying gpu resource has to live. { let mut renderer = render_state.renderer.write(); - for id in &textures_delta.free { - renderer.free_texture(id); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for id in textures_delta.free.drain() { + renderer.free_texture(&id); } } diff --git a/crates/egui-winit/CHANGELOG.md b/crates/egui-winit/CHANGELOG.md index 536294ddc..f7ed2fa07 100644 --- a/crates/egui-winit/CHANGELOG.md +++ b/crates/egui-winit/CHANGELOG.md @@ -5,6 +5,14 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +Nothing new + + ## 0.35.0 - 2026-06-25 * Delegate handling of IME interruptions to integrations to fix virtual keyboard flickering on web [#8078](https://github.com/emilk/egui/pull/8078) by [@umajho](https://github.com/umajho) * Always enable windows undecorated shadows [#8169](https://github.com/emilk/egui/pull/8169) by [@Wumpf](https://github.com/Wumpf) diff --git a/crates/egui-winit/src/dropped_file.rs b/crates/egui-winit/src/dropped_file.rs new file mode 100644 index 000000000..43f8960fc --- /dev/null +++ b/crates/egui-winit/src/dropped_file.rs @@ -0,0 +1,22 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub(crate) struct NativeFile { + path: PathBuf, +} + +impl From for NativeFile { + fn from(path: PathBuf) -> Self { + Self { path } + } +} + +impl egui::DroppedFile for NativeFile { + fn path(&self) -> &Path { + &self.path + } + + fn bytes(&self) -> Result, String> { + std::fs::read(&self.path).map_err(|err| err.to_string()) + } +} diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index d17428adb..0029a47b7 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -21,6 +21,7 @@ use egui::{Pos2, Rect, Theme, Vec2, ViewportBuilder, ViewportCommand, ViewportId pub use winit; pub mod clipboard; +mod dropped_file; mod safe_area; mod window_settings; @@ -28,6 +29,8 @@ pub use window_settings::WindowSettings; use raw_window_handle::HasDisplayHandle; +use dropped_file::NativeFile; + use winit::{ dpi::{PhysicalPosition, PhysicalSize}, event::ElementState, @@ -121,6 +124,7 @@ pub struct State { allow_ime: bool, ime_rect_px: Option, + old_ime_purpose: egui::IMEPurpose, /// Used by [`State::try_on_ime_processed_keyboard_input`] to track key /// release events that should be filtered out. See comments in that method @@ -171,6 +175,7 @@ impl State { allow_ime: false, ime_rect_px: None, + old_ime_purpose: egui::IMEPurpose::Normal, #[cfg(target_os = "windows")] pressed_processed_physical_keys: HashSet::new(), }; @@ -468,10 +473,9 @@ impl State { } WindowEvent::DroppedFile(path) => { self.egui_input.hovered_files.clear(); - self.egui_input.dropped_files.push(egui::DroppedFile { - path: Some(path.clone()), - ..Default::default() - }); + self.egui_input + .dropped_files + .push(std::sync::Arc::new(NativeFile::from(path.clone()))); EventResponse { repaint: true, consumed: false, @@ -1158,6 +1162,11 @@ impl State { window.set_ime_allowed(true); } + if ime.purpose != self.old_ime_purpose { + self.old_ime_purpose = ime.purpose; + window.set_ime_purpose(to_winit_ime_purpose(ime.purpose)); + } + let pixels_per_point = pixels_per_point(&self.egui_ctx, window); let ime_rect_px = pixels_per_point * ime.rect; if self.ime_rect_px != Some(ime_rect_px) @@ -1880,11 +1889,7 @@ fn process_viewport_command( ); } ViewportCommand::IMEAllowed(v) => window.set_ime_allowed(v), - ViewportCommand::IMEPurpose(p) => window.set_ime_purpose(match p { - egui::viewport::IMEPurpose::Password => winit::window::ImePurpose::Password, - egui::viewport::IMEPurpose::Terminal => winit::window::ImePurpose::Terminal, - egui::viewport::IMEPurpose::Normal => winit::window::ImePurpose::Normal, - }), + ViewportCommand::IMEPurpose(p) => window.set_ime_purpose(to_winit_ime_purpose(p)), ViewportCommand::Focus => { if !window.has_focus() { window.focus_window(); @@ -1945,6 +1950,14 @@ fn process_viewport_command( } } +fn to_winit_ime_purpose(purpose: egui::IMEPurpose) -> winit::window::ImePurpose { + match purpose { + egui::IMEPurpose::Password => winit::window::ImePurpose::Password, + egui::IMEPurpose::Terminal => winit::window::ImePurpose::Terminal, + egui::IMEPurpose::Normal => winit::window::ImePurpose::Normal, + } +} + /// Build and intitlaize a window. /// /// Wrapper around `create_winit_window_builder` and `apply_viewport_builder_to_window`. diff --git a/crates/egui/Cargo.toml b/crates/egui/Cargo.toml index e6d01ff3d..0bbd8494e 100644 --- a/crates/egui/Cargo.toml +++ b/crates/egui/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [package.metadata.docs.rs] all-features = true rustdoc-args = ["--generate-link-to-definition"] +targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] [lib] @@ -96,3 +97,9 @@ document-features = { workspace = true, optional = true } ron = { workspace = true, optional = true } serde = { workspace = true, optional = true, features = ["derive", "rc"] } + + +# web: +[target.'cfg(target_arch = "wasm32")'.dependencies] +# For `DroppedFile`, which hands web apps a file handle instead of its contents. +web-sys = { workspace = true, features = ["File"] } diff --git a/crates/egui/src/atomics/atom_kind.rs b/crates/egui/src/atomics/atom_kind.rs index f996c173b..0dfe56069 100644 --- a/crates/egui/src/atomics/atom_kind.rs +++ b/crates/egui/src/atomics/atom_kind.rs @@ -1,7 +1,7 @@ use crate::{AtomLayout, FontSelection, Image, ImageSource, SizedAtomKind, Ui, WidgetText}; +use core::fmt::Debug; use emath::Vec2; use epaint::text::TextWrapMode; -use std::fmt::Debug; /// Args passed when sizing an [`super::Atom`] pub struct IntoSizedArgs { @@ -90,7 +90,7 @@ impl Clone for AtomKind<'_> { } impl Debug for AtomKind<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { AtomKind::Empty => write!(f, "AtomKind::Empty"), AtomKind::Text(text) => write!(f, "AtomKind::Text({text:?})"), diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index ceb43e048..5c7da5cc7 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -2,11 +2,11 @@ use crate::{ AtomKind, Atoms, Direction, FontSelection, Frame, Id, Image, IntoAtoms, Response, Sense, SizedAtom, SizedAtomKind, Stroke, Ui, Widget, text_selection::LabelSelectionState, }; +use core::ops::{Deref, DerefMut}; use emath::{Align2, GuiRounding as _, NumExt as _, Rect, Vec2}; use epaint::text::TextWrapMode; use epaint::{Color32, Galley}; use smallvec::SmallVec; -use std::ops::{Deref, DerefMut}; use std::sync::Arc; /// The `(main, cross)` axis indices for `direction`, for indexing a [`Vec2`] (0 = x, 1 = y). @@ -557,7 +557,7 @@ impl<'atom> SizedAtomLayout<'atom> { F: FnMut(SizedAtomKind<'atom>) -> SizedAtomKind<'atom>, { for kind in self.iter_kinds_mut() { - *kind = f(std::mem::take(kind)); + *kind = f(core::mem::take(kind)); } } diff --git a/crates/egui/src/atomics/atoms.rs b/crates/egui/src/atomics/atoms.rs index 460d4732c..501d879e0 100644 --- a/crates/egui/src/atomics/atoms.rs +++ b/crates/egui/src/atomics/atoms.rs @@ -1,6 +1,6 @@ use crate::{Atom, AtomKind, Image, WidgetText}; +use core::ops::{Deref, DerefMut}; use std::borrow::Cow; -use std::ops::{Deref, DerefMut}; /// A list of [`Atom`]s. /// @@ -41,7 +41,7 @@ impl<'a> Atoms<'a> { /// /// If you have weird lifetime issues with this, use [`Self::push_left`] in a loop instead. pub fn extend_left(&mut self, mut atoms: Self) { - std::mem::swap(&mut atoms.0, &mut self.0); + core::mem::swap(&mut atoms.0, &mut self.0); self.0.extend(atoms.0); } @@ -128,7 +128,7 @@ impl<'a> Atoms<'a> { pub fn map_atoms(&mut self, mut f: impl FnMut(Atom<'a>) -> Atom<'a>) { self.iter_mut() - .for_each(|atom| *atom = f(std::mem::take(atom))); + .for_each(|atom| *atom = f(core::mem::take(atom))); } pub fn map_kind(&mut self, mut f: F) @@ -136,7 +136,7 @@ impl<'a> Atoms<'a> { F: FnMut(AtomKind<'a>) -> AtomKind<'a>, { for kind in self.iter_kinds_mut() { - *kind = f(std::mem::take(kind)); + *kind = f(core::mem::take(kind)); } } diff --git a/crates/egui/src/cache/cache_storage.rs b/crates/egui/src/cache/cache_storage.rs index e0aa65e8c..b3533f4cf 100644 --- a/crates/egui/src/cache/cache_storage.rs +++ b/crates/egui/src/cache/cache_storage.rs @@ -23,18 +23,18 @@ use super::CacheTrait; /// ``` #[derive(Default)] pub struct CacheStorage { - caches: ahash::HashMap>, + caches: ahash::HashMap>, } impl CacheStorage { pub fn cache(&mut self) -> &mut Cache { let cache = self .caches - .entry(std::any::TypeId::of::()) + .entry(core::any::TypeId::of::()) .or_insert_with(|| Box::::default()); #[expect(clippy::unwrap_used)] - (cache.as_mut() as &mut dyn std::any::Any) + (cache.as_mut() as &mut dyn core::any::Any) .downcast_mut::() .unwrap() } @@ -60,8 +60,8 @@ impl Clone for CacheStorage { } } -impl std::fmt::Debug for CacheStorage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for CacheStorage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!( f, "FrameCacheStorage[{} caches with {} elements]", diff --git a/crates/egui/src/cache/cache_trait.rs b/crates/egui/src/cache/cache_trait.rs index 54144c724..f05713d66 100644 --- a/crates/egui/src/cache/cache_trait.rs +++ b/crates/egui/src/cache/cache_trait.rs @@ -1,6 +1,6 @@ /// A cache, storing some value for some length of time. #[expect(clippy::len_without_is_empty)] -pub trait CacheTrait: 'static + Send + Sync + std::any::Any { +pub trait CacheTrait: 'static + Send + Sync + core::any::Any { /// Call once per frame to evict cache. fn update(&mut self); diff --git a/crates/egui/src/cache/frame_cache.rs b/crates/egui/src/cache/frame_cache.rs index ae39712c7..76a58c461 100644 --- a/crates/egui/src/cache/frame_cache.rs +++ b/crates/egui/src/cache/frame_cache.rs @@ -48,7 +48,7 @@ impl FrameCache { /// or recompute and store in the cache. pub fn get(&mut self, key: Key) -> &Value where - Key: Copy + std::hash::Hash, + Key: Copy + core::hash::Hash, Computer: ComputerMut, { let hash = crate::util::hash(key); diff --git a/crates/egui/src/cache/frame_publisher.rs b/crates/egui/src/cache/frame_publisher.rs index 81ba34df6..9ca576f29 100644 --- a/crates/egui/src/cache/frame_publisher.rs +++ b/crates/egui/src/cache/frame_publisher.rs @@ -1,4 +1,4 @@ -use std::hash::Hash; +use core::hash::Hash; use super::CacheTrait; diff --git a/crates/egui/src/callstack.rs b/crates/egui/src/callstack.rs index 6b0c35380..9ec426604 100644 --- a/crates/egui/src/callstack.rs +++ b/crates/egui/src/callstack.rs @@ -1,4 +1,4 @@ -use std::fmt::Write as _; +use core::fmt::Write as _; #[derive(Clone)] struct Frame { @@ -239,7 +239,7 @@ fn test_shorten_path() { ), ("/weird/path/file.rs", "/weird/path/file.rs"), ] { - use std::str::FromStr as _; + use core::str::FromStr as _; let before = std::path::PathBuf::from_str(before).unwrap(); assert_eq!(shorten_source_file_path(&before), after); } diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 4b3a4f722..61375b580 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -454,14 +454,12 @@ impl Area { state.size = None; } state.pivot = pivot; - state.interactable = interactable; if let Some(new_pos) = new_pos { state.pivot_pos = Some(new_pos); } state.pivot_pos.get_or_insert_with(|| { default_pos.unwrap_or_else(|| automatic_area_position(ctx, constrain_rect, layer_id)) }); - state.interactable = interactable; let size = *state.size.get_or_insert_with(|| { sizing_pass = true; @@ -484,6 +482,10 @@ impl Area { size }); + // We should never be interactable during a sizing pass, since then we are shown at a different + // size which might interfere with hover state of the hovered widget causing popup feedback loops. + state.interactable = interactable && !sizing_pass; + // TODO(emilk): if last frame was sizing pass, it should be considered invisible for smoother fade-in let visible_last_frame = ctx.memory(|mem| mem.areas().visible_last_frame(&layer_id)); diff --git a/crates/egui/src/containers/close_tag.rs b/crates/egui/src/containers/close_tag.rs index 3e93dbbd2..273d9e251 100644 --- a/crates/egui/src/containers/close_tag.rs +++ b/crates/egui/src/containers/close_tag.rs @@ -1,6 +1,6 @@ #[expect(unused_imports)] use crate::{Ui, UiBuilder}; -use std::sync::atomic::AtomicBool; +use core::sync::atomic::AtomicBool; /// A tag to mark a container as closable. /// @@ -18,11 +18,12 @@ impl ClosableTag { /// Set close to `true` pub fn set_close(&self) { - self.close.store(true, std::sync::atomic::Ordering::Relaxed); + self.close + .store(true, core::sync::atomic::Ordering::Relaxed); } /// Returns `true` if [`ClosableTag::set_close`] has been called. pub fn should_close(&self) -> bool { - self.close.load(std::sync::atomic::Ordering::Relaxed) + self.close.load(core::sync::atomic::Ordering::Relaxed) } } diff --git a/crates/egui/src/containers/collapsing_header.rs b/crates/egui/src/containers/collapsing_header.rs index 3e49a3bb0..498d64a51 100644 --- a/crates/egui/src/containers/collapsing_header.rs +++ b/crates/egui/src/containers/collapsing_header.rs @@ -342,7 +342,7 @@ pub fn paint_default_icon(ui: &mut Ui, openness: f32, response: &Response) { let rect = Rect::from_center_size(rect.center(), vec2(rect.width(), rect.height()) * 0.75); let rect = rect.expand(visuals.expansion); let mut points = vec![rect.left_top(), rect.right_top(), rect.center_bottom()]; - use std::f32::consts::TAU; + use core::f32::consts::TAU; let rotation = emath::Rot2::from_angle(remap(openness, 0.0..=1.0, -TAU / 4.0..=0.0)); for p in &mut points { *p = rect.center() + rotation * (*p - rect.center()); diff --git a/crates/egui/src/containers/frame.rs b/crates/egui/src/containers/frame.rs index d6f751bc2..ebef299df 100644 --- a/crates/egui/src/containers/frame.rs +++ b/crates/egui/src/containers/frame.rs @@ -143,12 +143,12 @@ pub struct Frame { #[test] fn frame_size() { assert_eq!( - std::mem::size_of::(), + core::mem::size_of::(), 32, "Frame changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." ); assert!( - std::mem::size_of::() <= 64, + core::mem::size_of::() <= 64, "Frame is getting way too big!" ); } diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index 1b8e9f320..cc732007a 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -18,14 +18,24 @@ use emath::GuiRounding as _; use crate::{ - Align, Context, CursorIcon, Frame, Id, InnerResponse, Layout, NumExt as _, Rangef, Rect, - Response, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp, + Align, Context, CursorIcon, Frame, Id, InnerResponse, LayerId, Layout, Margin, NumExt as _, + Order, Rangef, Rect, Response, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp, }; fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 { ctx.animate_bool_responsive(id, is_expanded) } +/// [`Id`] of a panel's resize-handle widget. +/// +/// A panel registers its handle under this same id whether it is open, +/// mid-slide, or fully collapsed — that is what lets one uninterrupted drag +/// collapse the panel and pull it back open. [`Panel::show_switched`] points +/// both of its panels at one shared handle the same way. +fn resize_widget_id(id_source: Id) -> Id { + id_source.with("__resize") +} + /// State regarding panels. #[derive(Clone, Copy, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -126,6 +136,22 @@ impl PanelSide { } } + /// The component of `margin` on the panel's _resizable_ edge, + /// i.e. the edge facing the rest of the ui, where the separator line goes. + fn resize_margin(self, mut margin: Margin) -> i8 { + *self.resize_margin_mut(&mut margin) + } + + /// Mutable version of [`Self::resize_margin`]. + fn resize_margin_mut(self, margin: &mut Margin) -> &mut i8 { + match self { + Self::Left => &mut margin.right, + Self::Right => &mut margin.left, + Self::Top => &mut margin.bottom, + Self::Bottom => &mut margin.top, + } + } + /// Resize by keeping `self` side fixed, and moving the opposite side. fn set_rect_size(self, rect: &mut Rect, size: f32) { match self { @@ -182,6 +208,7 @@ pub struct Panel { id: Id, frame: Option, resizable: bool, + drag_to_open: bool, show_separator_line: bool, /// _Outer_ size (including [`Frame`] margin & border): @@ -267,6 +294,7 @@ impl Panel { id: id.into(), frame: None, resizable: true, + drag_to_open: true, show_separator_line: true, default_outer_size, outer_size_range, @@ -296,8 +324,39 @@ impl Panel { self } + /// Can a fully collapsed panel be dragged back open? + /// + /// Default: `true`. + /// + /// When enabled, a panel that [`Self::show_collapsible`] has collapsed all + /// the way still leaves a thin grab handle at its fixed edge. The handle is + /// invisible until hovered, at which point it lights up like a normal resize + /// handle. Dragging it outward past [`Self::min_size`] — or double-clicking + /// it — reopens the panel. + /// + /// This is the counterpart to drag-to-collapse, and like it requires + /// [`Self::resizable`] to be `true`. + #[inline] + pub fn drag_to_open(mut self, drag_to_open: bool) -> Self { + self.drag_to_open = drag_to_open; + self + } + /// Show a separator line, even when not interacting with it? /// + /// The separator line sits on the panel's inner edge, i.e. the edge facing the rest of the ui. + /// It is painted _outside_ the [`Frame`]'s outline, in room the panel reserves for it in the + /// frame's [`Frame::outer_margin`], so that going from the panel contents outwards you get: + /// + /// contents | [`Frame::inner_margin`] | [`Frame::stroke`] | separator line | [`Frame::outer_margin`] + /// + /// Turning this off removes that reserved room too, so the panel gets no permanent gap along + /// that edge. + /// + /// A `resizable` panel still shows a line while hovered or dragged, regardless of this setting. + /// With this setting off there is no room reserved for it, so that transient line is painted + /// just outside the frame's outline, overlapping the [`Frame::outer_margin`]. + /// /// Default: `true`. #[inline] pub fn show_separator_line(mut self, show_separator_line: bool) -> Self { @@ -386,6 +445,9 @@ impl Panel { /// 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`. + /// + /// A fully collapsed panel keeps a thin grab handle at its fixed edge, so the + /// user can drag it back open. See [`Self::drag_to_open`] to opt out. pub fn show_collapsible( self, ui: &mut Ui, @@ -395,10 +457,11 @@ impl Panel { 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); + // Panel is fully closed, but we still leave a grab handle at its fixed + // edge so the user can drag it back open. + if self.resizable && self.drag_to_open { + self.collapsed_resize_handle(ui, is_expanded); + } // Make sure the ids of the next widgets are the same whether we show the panel or not: ui.skip_ahead_auto_ids(1); @@ -407,7 +470,7 @@ impl Panel { // Don't lose the drag during the slide-back-open animation: let drag_in_progress = ui - .read_response(self.id.with("__resize")) + .read_response(self.resize_id()) .is_some_and(|r| r.dragged()); let panel = if how_expanded < 1.0 { @@ -520,20 +583,11 @@ impl Panel { // Is the resize handle currently being dragged? let drag_in_progress = ui - .read_response(resize_id_source.with("__resize")) + .read_response(resize_widget_id(resize_id_source)) .is_some_and(|r| r.dragged()); let animation_id = expanded_panel.id.with("animation"); - // While the user is dragging, snap the animation to the target so the - // drag (which sets `outer_size` directly from the pointer) doesn't fight - // a simultaneous slide. Without this, drag-to-expand visibly jumps as - // the slide animation tries to grow from 0 while the pointer is already - // at the expanded size. - let how_expanded = if drag_in_progress { - ui.animate_bool_with_time(animation_id, *is_expanded, 0.0) - } else { - animate_expansion(ui, animation_id, *is_expanded) - }; + let 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, @@ -556,7 +610,19 @@ impl Panel { 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 expanded_size = if drag_in_progress { + // During a drag the pointer sets the size, clamped to `min_size` + // — so that, not the (stale) persisted size, is where the slide + // meets the collapsed panel, whether opening or closing. Get it + // wrong and the panel jumps the gap between the two sizes in one + // frame. + expanded_panel + .outer_size_range + .min + .at_least(collapse_threshold) + } else { + 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 @@ -673,7 +739,7 @@ impl Panel { // 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_id = self.resize_id(); let resize_response = parent_ui.read_response(resize_id); // Double-click on the resize edge toggles `*is_expanded` for the @@ -831,21 +897,34 @@ impl Panel { .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 { - parent_ui.style().visuals.widgets.active.fg_stroke // highly visible - } else if resize_hover { - parent_ui.style().visuals.widgets.hovered.fg_stroke // highly visible - } else if show_separator_line { - // TODO(emilk): distinguish resizable from non-resizable - parent_ui.style().visuals.widgets.noninteractive.bg_stroke // dim - } else { - Stroke::NONE - }; + // The highlight follows the pointer all the way down to zero size, where + // `collapsed_resize_handle` picks it straight up again — so the user never + // loses sight of the edge they are dragging. The dim idle separator does + // get hidden once the panel is mostly slid off, since there it would just + // be a stray dash hovering near the parent edge. + let stroke = if is_resizing { + parent_ui.style().visuals.widgets.active.fg_stroke // highly visible + } else if resize_hover { + parent_ui.style().visuals.widgets.hovered.fg_stroke // highly visible + } else if show_separator_line && 0.01 < self.slide_fraction { + // TODO(emilk): distinguish resizable from non-resizable + parent_ui.style().visuals.widgets.noninteractive.bg_stroke // dim + } else { + Stroke::NONE + }; + if 0.0 < stroke.width { + // Nudged inward, to keep the line inside the panel's own (shifted) + // rect: `parent_ui`'s painter sits below the panels that come after + // this one, so anything drawn past the fixed edge is covered by them. // TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done - let line_pos = side.resize_pos(shifted_outer_rect) + 0.5 * side.sign() * stroke.width; + + // The line goes just _outside_ the frame's outline, in the room `resolve_frame` + // reserved for it in the outer margin, i.e.: + // + // contents | `inner_margin` | outline | separator line | `outer_margin` + let outer_margin = f32::from(side.resize_margin(frame.outer_margin)); + let outline_edge = side.resize_pos(shifted_outer_rect) + side.sign() * outer_margin; + let line_pos = outline_edge - 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); @@ -857,54 +936,123 @@ impl Panel { 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())) + /// [`Id`] of this panel's resize-handle widget. + /// + /// See [`resize_widget_id`] for why open and collapsed panels must share it. + fn resize_id(&self) -> Id { + resize_widget_id(self.resize_id_source.unwrap_or(self.id)) } - /// Panel is fully closed. If the user is still dragging the resize handle - /// from the frame the panel closed on, keep its widget id registered so the - /// drag survives, and reopen if they drag back past the minimum size. - fn keep_drag_alive_for_reopen(&self, ui: &Ui, is_expanded: &mut bool) { - let resize_id = self.id.with("__resize"); - let Some(resize_response) = ui.read_response(resize_id) else { - return; - }; - if !resize_response.dragged() { - return; - } - let Some(pointer) = resize_response.interact_pointer_pos() else { - return; - }; + /// The configured [`Frame`], or the default side/top panel frame for this [`Ui`]. + fn resolve_frame(&self, ui: &Ui) -> Frame { + let mut frame = self + .frame + .unwrap_or_else(|| Frame::side_top_panel(ui.style())); + + if self.show_separator_line { + // Reserve room for the separator line in the frame's _outer_ margin, so the line + // lands just outside the frame's outline instead of painting on top of it: + // + // contents | `inner_margin` | outline | separator line | `outer_margin` + // + // We deliberately don't do this for a `resizable` panel that has opted out of the + // separator line: the line it shows while hovered/dragged is a transient affordance, + // and reserving room for it would leave a permanently visible gap. + let widgets = &ui.style().visuals.widgets; + let stroke_width = widgets.noninteractive.bg_stroke.width.round() as i8; + + let margin_side = self.side.resize_margin_mut(&mut frame.outer_margin); + *margin_side = (*margin_side).saturating_add(stroke_width); + } + + frame + } + + /// The grab handle of a fully collapsed panel: a thin strip along the panel's + /// fixed edge, invisible until hovered. + /// + /// Dragging it outward past the minimum size — or double-clicking it — + /// reopens the panel. Registering it under the same id as the expanded + /// panel's resize handle also keeps an in-progress drag-to-collapse gesture + /// alive, so the user can drag the panel straight back out without releasing. + fn collapsed_resize_handle(&self, ui: &Ui, is_expanded: &mut bool) { + let side = self.side; + let axis = side.axis(); - // 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 { + let fixed_edge_pos = side.fixed_pos(available_rect); + let cross_range = available_rect.range_along(side.cross_axis()); + + // The strip lies just _inside_ the fixed edge, so it never reaches + // outside the area the panel is allowed to occupy. + let mut resize_rect = if 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()); + side.set_rect_size( + &mut resize_rect, + ui.style().interaction.resize_grab_radius_side, + ); - // 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)); + let resize_id = self.resize_id(); + let response = ui.interact(resize_rect, resize_id, Sense::click_and_drag()); - // 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 { + if response.double_clicked() { *is_expanded = true; } + + if response.hovered() || response.dragged() { + // Advertise that the panel can be pulled out. Also keeps the resize + // cursor for a drag that started before the panel closed, instead of + // snapping back to the default mid-gesture. + ui.set_cursor_icon(self.cursor_icon(0.0)); + } + + if response.dragged() + && let Some(pointer) = response.interact_pointer_pos() + { + // 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. + // + // We require the full minimum size, so the panel never jumps ahead of + // the pointer: it opens exactly when the drag reaches the size it will + // open at, and follows the pointer from there. + let dragged_size = -side.sign() * (pointer[axis] - fixed_edge_pos); + if self.outer_size_range.min < dragged_size { + *is_expanded = true; + } + } + + // Invisible until hovered, so the handle doesn't read as a stray line at + // the edge of the screen. + let stroke = if response.dragged() { + ui.style().visuals.widgets.active.fg_stroke + } else if response.hovered() { + ui.style().visuals.widgets.hovered.fg_stroke + } else { + Stroke::NONE + }; + if 0.0 < stroke.width { + // The collapsed panel occupies no space of its own, so the line has to + // go _inside_ the area the following panels use — which means painting + // in a layer above them, or they would cover it. + // TODO(emilk): use the panel's own layer once https://github.com/emilk/egui/issues/1516 is done + let painter = ui + .ctx() + .layer_painter(LayerId::new(Order::Middle, resize_id)) + .with_clip_rect(resize_rect); + + // Nudge the line inward so it isn't half-clipped by the edge. + let line_pos = fixed_edge_pos - 0.5 * side.sign() * stroke.width; + if axis == 0 { + painter.vline(line_pos, cross_range, stroke); + } else { + painter.hline(cross_range, line_pos, stroke); + } + } } /// Get the current _outer_ width or height of the panel (from previous frame), @@ -939,7 +1087,7 @@ impl Panel { // 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_id = self.resize_id(); let resize_rect = Rect::from_x_y_ranges(resize_x, resize_y).expand2(amount); ui.interact(resize_rect, resize_id, Sense::click_and_drag()) } diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 080c00bd1..beee9f900 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -1,4 +1,4 @@ -use std::iter::once; +use core::iter::once; use emath::{Align, Pos2, Rect, RectAlign, Vec2, vec2}; @@ -179,7 +179,9 @@ pub struct Popup<'a> { /// Default width passed to the Area width: Option, + sizing_pass: bool, sense: Sense, + interactable: bool, layout: Layout, frame: Option, style: StyleModifier, @@ -201,7 +203,9 @@ impl<'a> Popup<'a> { alternative_aligns: None, gap: 0.0, width: None, + sizing_pass: false, sense: Sense::click(), + interactable: true, layout: Layout::default(), frame: None, style: StyleModifier::default(), @@ -369,6 +373,15 @@ impl<'a> Popup<'a> { self } + /// If `false`, the pointer goes straight through the popup and it's widgets to whatever is behind it. + /// + /// Default: `true`. + #[inline] + pub fn interactable(mut self, interactable: bool) -> Self { + self.interactable = interactable; + self + } + /// Set the sense of the popup. #[inline] pub fn sense(mut self, sense: Sense) -> Self { @@ -390,6 +403,19 @@ impl<'a> Popup<'a> { self } + /// Force the popup's underlying [`Area`] to run an invisible sizing pass. + /// + /// Popups automatically run a sizing pass when they open or reopen. Set this to `true` for + /// one frame when the contents of an already open popup change and its cached size may no + /// longer fit. Do not leave it enabled continuously, because the popup would remain invisible. + /// + /// Default: `false`. + #[inline] + pub fn sizing_pass(mut self, sizing_pass: bool) -> Self { + self.sizing_pass = sizing_pass; + self + } + /// Set the id of the Area. #[inline] pub fn id(mut self, id: Id) -> Self { @@ -472,12 +498,12 @@ impl<'a> Popup<'a> { RectAlign::find_best_align( #[expect(clippy::iter_on_empty_collections)] #[expect(clippy::or_fun_call)] - std::iter::chain( + core::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| std::iter::chain(a.iter().copied(), [].iter().copied())) - .unwrap_or(std::iter::chain( + .map(|a| core::iter::chain(a.iter().copied(), [].iter().copied())) + .unwrap_or(core::iter::chain( self.rect_align.symmetries().iter().copied(), RectAlign::MENU_ALIGNS.iter().copied(), )), @@ -545,7 +571,9 @@ impl<'a> Popup<'a> { alternative_aligns: _, gap, width, + sizing_pass, sense, + interactable, layout, frame, style, @@ -570,8 +598,9 @@ impl<'a> Popup<'a> { .pivot(pivot) .fixed_pos(anchor) .sense(sense) + .interactable(interactable) .layout(layout) - .sizing_pass(!was_open_last_frame) + .sizing_pass(sizing_pass || !was_open_last_frame) .info(info.unwrap_or_else(|| { UiStackInfo::new(kind.into()).with_tag_value( MenuConfig::MENU_CONFIG_TAG, diff --git a/crates/egui/src/containers/resize.rs b/crates/egui/src/containers/resize.rs index b6c086aca..80a40454f 100644 --- a/crates/egui/src/containers/resize.rs +++ b/crates/egui/src/containers/resize.rs @@ -289,16 +289,16 @@ impl Resize { Rect::from_min_size(position, state.desired_size) }; - let mut content_clip_rect = inner_rect.expand(ui.visuals().clip_rect_margin); + let mut content_clip_rect = inner_rect; // If we pull the resize handle to shrink, we want to TRY to shrink it. // After laying out the contents, we might be much bigger. // In those cases we don't want the clip_rect to be smaller, because // then we will clip the contents of the region even thought the result gets larger. This is simply ugly! // So we use the memory of last_content_size to make the clip rect large enough. - content_clip_rect.max = content_clip_rect.max.max( - inner_rect.min + state.last_content_size + Vec2::splat(ui.visuals().clip_rect_margin), - ); + content_clip_rect.max = content_clip_rect + .max + .max(inner_rect.min + state.last_content_size); content_clip_rect = content_clip_rect.intersect(ui.clip_rect()); // Respect parent region diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 0fa4bba0c..54d6550ca 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -2,7 +2,7 @@ #![expect(clippy::needless_range_loop)] -use std::ops::{Add, AddAssign, BitOr, BitOrAssign}; +use core::ops::{Add, AddAssign, BitOr, BitOrAssign}; use emath::GuiRounding as _; use epaint::{Color32, Direction, Margin, Shape}; @@ -810,12 +810,11 @@ impl ScrollArea { { // Clip the content, but only when we really need to: - let clip_rect_margin = ui.visuals().clip_rect_margin; let mut content_clip_rect = ui.clip_rect(); for d in 0..2 { if direction_enabled[d] { - content_clip_rect.min[d] = inner_rect.min[d] - clip_rect_margin; - content_clip_rect.max[d] = inner_rect.max[d] + clip_rect_margin; + content_clip_rect.min[d] = inner_rect.min[d]; + content_clip_rect.max[d] = inner_rect.max[d]; } else { // Nice handling of forced resizing beyond the possible: content_clip_rect.max[d] = ui.clip_rect().max[d] - current_bar_use[d]; @@ -931,7 +930,7 @@ impl ScrollArea { let saved_scroll_target = content_ui .ctx() - .pass_state_mut(|state| std::mem::take(&mut state.scroll_target)); + .pass_state_mut(|state| core::mem::take(&mut state.scroll_target)); Prepared { id, @@ -986,7 +985,7 @@ impl ScrollArea { ui: &mut Ui, row_height_sans_spacing: f32, total_rows: usize, - add_contents: impl FnOnce(&mut Ui, std::ops::Range) -> R, + add_contents: impl FnOnce(&mut Ui, core::ops::Range) -> R, ) -> ScrollAreaOutput { let spacing = ui.spacing().item_spacing; let row_height_with_spacing = row_height_sans_spacing + spacing.y; @@ -1082,17 +1081,9 @@ impl Prepared { let content_size = content_ui.min_size(); - let scroll_delta = content_ui - .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]; - let mut animation = scroll_delta.1; - // We always take both scroll targets regardless of which scroll axes are enabled. This // is to avoid them leaking to other scroll areas. let scroll_target = content_ui @@ -1100,6 +1091,17 @@ impl Prepared { .pass_state_mut(|state| state.scroll_target[d].take()); if direction_enabled[d] { + let (scroll_delta, scroll_animation) = content_ui.ctx().pass_state_mut(|state| { + ( + core::mem::take(&mut state.scroll_delta.0[d]), + state.scroll_delta.1, + ) + }); + + // PassState::scroll_delta is inverted from the way we apply the delta, so we need to negate it. + let mut delta = -scroll_delta; + let mut animation = scroll_animation; + if let Some(target) = scroll_target { let pass_state::ScrollTarget { range, @@ -1133,8 +1135,8 @@ impl Prepared { 0.0 }; - delta += delta_update; animation = animation_update; + delta += delta_update; } if delta != 0.0 { @@ -1158,10 +1160,10 @@ impl Prepared { } ui.request_repaint(); } - } - if delta != 0.0 { - had_explicit_scroll_adjustment[d] = true; + if delta != 0.0 { + had_explicit_scroll_adjustment[d] = true; + } } } @@ -1306,8 +1308,6 @@ impl Prepared { // * When one ScrollArea is nested inside another, and the outer // is scrolled so that the scroll-bars of the inner ScrollArea (us) // is outside the clip rectangle. - // Really this should use the tighter clip_rect that ignores clip_rect_margin, but we don't store that. - // clip_rect_margin is quite a hack. It would be nice to get rid of it. max_cross = ui.clip_rect().max[1 - d] - outer_margin; } @@ -1575,9 +1575,7 @@ fn paint_fade_areas_impl(ui: &Ui, inner_rect: Rect, content_size: Vec2, offset: let overflow = content_size - inner_rect.size(); - let paint_rect = inner_rect - .intersect(ui.min_rect()) - .expand(ui.visuals().clip_rect_margin); + let paint_rect = inner_rect.intersect(ui.min_rect()); // Top fade: animate opacity based on how far we've scrolled down. if 0.0 < offset.y { diff --git a/crates/egui/src/containers/tooltip.rs b/crates/egui/src/containers/tooltip.rs index 22c319569..a372d6cd1 100644 --- a/crates/egui/src/containers/tooltip.rs +++ b/crates/egui/src/containers/tooltip.rs @@ -129,7 +129,15 @@ impl Tooltip<'_> { }); let tooltip_area_id = Self::tooltip_id(parent_widget, state.tooltip_count); - popup = popup.anchor(state.bounding_rect).id(tooltip_area_id); + + // Tooltips without interactive contents should not be interactable (hover should pass + // through to the widget below). + let interactable = Self::had_interactive_widgets(popup.ctx(), tooltip_area_id); + + popup = popup + .anchor(state.bounding_rect) + .id(tooltip_area_id) + .interactable(interactable); let response = popup.show(|ui| { // By default, the text in tooltips aren't selectable. @@ -192,6 +200,20 @@ impl Tooltip<'_> { widget_id.with(tooltip_count) } + /// Did this tooltip contain anything the user can interact with, last pass? + /// + /// Most tooltips are just text. Those should not react to the pointer at all, + /// or they would steal the hover from the widget they belong to. + fn had_interactive_widgets(ctx: &Context, tooltip_id: Id) -> bool { + let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id); + ctx.viewport(|vp| { + vp.prev_pass + .widgets + .get_layer(tooltip_layer_id) + .any(|w| w.enabled && w.sense.interactive()) + }) + } + /// Should we show a tooltip for this response? /// /// Argument `allow_interactive_tooltip` controls whether mouse can interact with tooltip that @@ -247,15 +269,9 @@ impl Tooltip<'_> { // Check if we should automatically stay open: let tooltip_id = Self::next_tooltip_id(&response.ctx, response.id); - let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id); let tooltip_has_interactive_widget = allow_interactive_tooltip - && response.ctx.viewport(|vp| { - vp.prev_pass - .widgets - .get_layer(tooltip_layer_id) - .any(|w| w.enabled && w.sense.interactive()) - }); + && Self::had_interactive_widgets(&response.ctx, tooltip_id); if tooltip_has_interactive_widget { // We keep the tooltip open if hovered, diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index cf6c58f88..c3f4dcb24 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -84,6 +84,7 @@ pub struct Window<'a> { open: Option<&'a mut bool>, area: Area, frame: Option, + title_frame: Option, resize: Resize, scroll: ScrollArea, collapsible: bool, @@ -106,6 +107,7 @@ impl<'a> Window<'a> { open: None, area, frame: None, + title_frame: None, resize: Resize::default() .with_stroke(false) .min_size([96.0, 32.0]) @@ -265,6 +267,13 @@ impl<'a> Window<'a> { self } + /// Change the background color, margins, etc. of the title + #[inline] + pub fn title_frame(mut self, frame: Frame) -> Self { + self.title_frame = Some(frame); + self + } + /// Set minimum width of the window. #[inline] pub fn min_width(mut self, min_width: f32) -> Self { @@ -549,6 +558,7 @@ impl Window<'_> { mut open, area, frame, + title_frame, resize, scroll, collapsible, @@ -616,10 +626,12 @@ impl Window<'_> { let style = ctx.global_style(); + // We get or create the Frame for the title and content let window_frame = frame.unwrap_or_else(|| Frame::window(&style)); + let window_title_frame = title_frame.unwrap_or(window_frame); // We apply the window margin by using the `ScrollArea::content_margin`. - let window_margin = window_frame.inner_margin; + let window_content_margin = window_frame.inner_margin; let window_frame = window_frame.inner_margin(0.0); let is_explicitly_closed = matches!(open, Some(false)); @@ -711,7 +723,7 @@ impl Window<'_> { title_ui( ui, title, - window_frame.inner_margin(window_margin), + window_title_frame, &mut collapsing, collapsible, on_top, @@ -725,12 +737,12 @@ impl Window<'_> { .show_body_unindented(ui, |ui| { if scroll.is_any_scroll_enabled() { scroll - .content_margin(window_margin) + .content_margin(window_content_margin) .show(ui, add_contents) .inner } else { crate::Frame::NONE - .inner_margin(window_margin) + .inner_margin(window_content_margin) .show(ui, add_contents) .inner } @@ -909,7 +921,7 @@ impl SideResponse { } } -impl std::ops::BitAnd for SideResponse { +impl core::ops::BitAnd for SideResponse { type Output = Self; fn bitand(self, rhs: Self) -> Self::Output { @@ -920,7 +932,7 @@ impl std::ops::BitAnd for SideResponse { } } -impl std::ops::BitOrAssign for SideResponse { +impl core::ops::BitOrAssign for SideResponse { fn bitor_assign(&mut self, rhs: Self) { *self = Self { hover: self.hover || rhs.hover, diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index e3e5f7618..48c63f436 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1,6 +1,7 @@ #![warn(missing_docs)] // Let's keep `Context` well-documented. -use std::{borrow::Cow, cell::RefCell, panic::Location, sync::Arc, time::Duration}; +use core::{cell::RefCell, panic::Location, time::Duration}; +use std::{borrow::Cow, sync::Arc}; use emath::GuiRounding as _; use epaint::{ @@ -32,7 +33,7 @@ use crate::{ load::{self, Bytes, Loaders, SizedTexture}, memory::{Options, Theme}, os::OperatingSystem, - output::FullOutput, + output::{FullOutput, LogicOutput}, pass_state::PassState, plugin::{self, TypedPluginHandle}, resize, response, scroll_area, theme, @@ -99,7 +100,7 @@ impl ContextImpl { fn begin_pass_repaint_logic(&mut self, viewport_id: ViewportId) { let viewport = self.viewports.entry(viewport_id).or_default(); - std::mem::swap( + core::mem::swap( &mut viewport.repaint.prev_causes, &mut viewport.repaint.causes, ); @@ -244,6 +245,12 @@ pub struct ViewportState { // ---------------------- // Cross-frame statistics: pub num_multipass_in_row: usize, + + /// The last theme we sent to the native window via [`ViewportCommand::SetTheme`], + /// used to avoid sending redundant commands. + /// + /// See [`crate::Options::sync_window_theme`]. + pub(crate) last_sent_window_theme: Option, } /// What called [`Context::request_repaint`] or [`Context::request_discard`]? @@ -259,14 +266,14 @@ pub struct RepaintCause { pub reason: Cow<'static, str>, } -impl std::fmt::Debug for RepaintCause { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for RepaintCause { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{}:{} {}", self.file, self.line, self.reason) } } -impl std::fmt::Display for RepaintCause { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for RepaintCause { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{}:{} {}", self.file, self.line, self.reason) } } @@ -456,7 +463,7 @@ impl ContextImpl { self.memory.begin_pass(&new_raw_input, &all_viewport_ids); - viewport.input = std::mem::take(&mut viewport.input).begin_pass( + viewport.input = core::mem::take(&mut viewport.input).begin_pass( new_raw_input, viewport.repaint.requested_immediate_repaint_prev_pass(), pixels_per_point, @@ -469,7 +476,13 @@ impl ContextImpl { viewport.this_pass.begin_pass(); { - let mut layers: Vec = viewport.prev_pass.widgets.layer_ids().collect(); + // Areas that are not interactable are click-through: skip them in the hit-test. + let mut layers: Vec = viewport + .prev_pass + .widgets + .layer_ids() + .filter(|layer_id| self.memory.areas().is_interactable(*layer_id)) + .collect(); layers.sort_by(|&a, &b| self.memory.areas().compare_order(a, b)); viewport.hits = if let Some(pos) = viewport.input.pointer.interact_pos() { @@ -644,7 +657,7 @@ impl ContextImpl { } fn all_viewport_ids(&self) -> ViewportIdSet { - std::iter::chain(self.viewports.keys().copied(), [ViewportId::ROOT]).collect() + core::iter::chain(self.viewports.keys().copied(), [ViewportId::ROOT]).collect() } /// The current active viewport @@ -712,13 +725,13 @@ impl ContextImpl { #[derive(Clone)] pub struct Context(Arc>); -impl std::fmt::Debug for Context { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for Context { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("Context").finish_non_exhaustive() } } -impl std::cmp::PartialEq for Context { +impl core::cmp::PartialEq for Context { fn eq(&self, other: &Self) -> bool { Arc::ptr_eq(&self.0, &other.0) } @@ -728,7 +741,7 @@ impl Default for Context { fn default() -> Self { let ctx_impl = ContextImpl { embed_viewports: true, - viewports: std::iter::once((ViewportId::ROOT, ViewportState::default())).collect(), + viewports: core::iter::once((ViewportId::ROOT, ViewportState::default())).collect(), ..Default::default() }; let ctx = Self(Arc::new(RwLock::new(ctx_impl))); @@ -778,6 +791,7 @@ impl Context { /// ui.label("Hello egui!"); /// }); /// // handle full_output + /// # full_output.drop_without_applying_deltas(); /// ``` #[must_use] pub fn run_ui(&self, new_input: RawInput, mut run_ui: impl FnMut(&mut Ui)) -> FullOutput { @@ -837,7 +851,7 @@ impl Context { self.write(|ctx| { let viewport = ctx.viewport_for(viewport_id); viewport.output.num_completed_passes = - std::mem::take(&mut output.platform_output.num_completed_passes); + core::mem::take(&mut output.platform_output.num_completed_passes); output.platform_output.request_discard_reasons.clear(); }); @@ -878,6 +892,57 @@ impl Context { output } + /// Run app logic without showing any ui. + /// + /// Use this instead of [`Self::run_ui`] when nothing will be shown, + /// e.g. because the window is minimized or occluded, + /// but you still want to let the app tick its logic + /// (so that it can e.g. ask to be shown again with [`ViewportCommand::Focus`]). + /// + /// No pass is run, so `f` must not show any ui. + /// This means everything egui knows about the ui is left untouched: + /// no widget state is garbage-collected, no animation advances, + /// and nothing loses focus. + /// + /// Of `new_input`, only the window state ([`RawInput::viewports`] and + /// [`RawInput::focused`]) is used, so that `f` can tell that the window is hidden. + /// The ui input (events, time, …) is _not_ interpreted, and is left for the next + /// call to [`Self::run_ui`]: [`Self::input`] is otherwise still that of the last pass. + /// + /// The returned [`LogicOutput`] is what [`FullOutput`] would have carried: + /// anything `f` asked the integration to do. + /// There is nothing to paint. + #[must_use] + pub fn run_logic(&self, new_input: &RawInput, logic: impl FnOnce(&Self)) -> LogicOutput { + profiling::function_scope!(); + + let viewport_id = new_input.viewport_id; + + self.write(|ctx| { + // Consume any outstanding repaint request, so that a new request from `logic` + // reaches the integration instead of being considered already served: + ctx.begin_pass_repaint_logic(viewport_id); + + // Tell `logic` about the windows, but leave the ui input alone: + let raw = &mut ctx.viewport_for(viewport_id).input.raw; + raw.viewport_id = viewport_id; + raw.viewports = new_input.viewports.clone(); + raw.focused = new_input.focused; + }); + + logic(self); + + self.write(|ctx| LogicOutput { + platform_output: core::mem::take(&mut ctx.viewport_for(viewport_id).output), + viewport_commands: ctx + .viewports + .iter_mut() + .filter(|(_, viewport)| !viewport.commands.is_empty()) + .map(|(&id, viewport)| (id, core::mem::take(&mut viewport.commands))) + .collect(), + }) + } + /// An alternative to calling [`Self::run_ui`]. /// /// It is usually better to use [`Self::run_ui`], because @@ -895,6 +960,7 @@ impl Context { /// /// let full_output = ctx.end_pass(); /// // handle full_output + /// # full_output.drop_without_applying_deltas(); /// ``` pub fn begin_pass(&self, mut new_input: RawInput) { profiling::function_scope!(); @@ -1698,11 +1764,11 @@ impl Context { .get(&id) .map(|v| v.repaint.cumulative_frame_nr) .unwrap_or_else(|| { - if cfg!(debug_assertions) { - panic!("cumulative_frame_nr_for failed to find the viewport {id:?}"); - } else { - 0 - } + debug_assert!( + false, + "cumulative_frame_nr_for failed to find the viewport {id:?}" + ); + 0 }) }) } @@ -1813,7 +1879,7 @@ impl Context { /// See [`Self::request_repaint_after`] for details. #[track_caller] pub fn request_repaint_after_secs(&self, seconds: f32) { - if let Ok(duration) = std::time::Duration::try_from_secs_f32(seconds) { + if let Ok(duration) = core::time::Duration::try_from_secs_f32(seconds) { self.request_repaint_after(duration); } } @@ -1996,7 +2062,7 @@ impl Context { &self, f: impl FnOnce(&mut T) -> R, ) -> Option { - let plugin = self.read(|ctx| ctx.plugins.get(std::any::TypeId::of::())); + let plugin = self.read(|ctx| ctx.plugins.get(core::any::TypeId::of::())); plugin.map(|plugin| f(plugin.lock().typed_plugin_mut())) } @@ -2008,13 +2074,13 @@ impl Context { if let Some(plugin) = self.plugin_opt() { plugin } else { - panic!("Plugin of type {:?} not found", std::any::type_name::()); + panic!("Plugin of type {:?} not found", core::any::type_name::()); } } /// Get a handle to the plugin of type `T`, if it was registered. pub fn plugin_opt(&self) -> Option> { - let plugin = self.read(|ctx| ctx.plugins.get(std::any::TypeId::of::())); + let plugin = self.read(|ctx| ctx.plugins.get(core::any::TypeId::of::())); plugin.map(TypedPluginHandle::new) } @@ -2430,6 +2496,8 @@ impl Context { } } + self.sync_window_theme(); + #[cfg(debug_assertions)] self.debug_painting(); @@ -2441,11 +2509,43 @@ impl Context { output } + /// Keep the native window theme in sync with the egui [`crate::ThemePreference`], + /// if [`crate::Options::sync_window_theme`] is enabled. + /// + /// Sends a [`ViewportCommand::SetTheme`] to the current viewport whenever the + /// derived theme changes, so the native window decorations match the egui theme. + fn sync_window_theme(&self) { + if !self.options(|o| o.sync_window_theme) { + return; + } + + use crate::{SystemTheme, ThemePreference}; + let window_theme = match self.options(|o| o.theme_preference) { + ThemePreference::System => SystemTheme::SystemDefault, + ThemePreference::Dark => SystemTheme::Dark, + ThemePreference::Light => SystemTheme::Light, + }; + + let changed = self.write(|ctx| { + let viewport = ctx.viewport(); + if viewport.last_sent_window_theme == Some(window_theme) { + false + } else { + viewport.last_sent_window_theme = Some(window_theme); + true + } + }); + + if changed { + self.send_viewport_cmd(ViewportCommand::SetTheme(window_theme)); + } + } + /// Called at the end of the pass. #[cfg(debug_assertions)] fn debug_painting(&self) { #![expect(clippy::iter_over_hash_type)] // ok to be sloppy in debug painting - use std::fmt::Write as _; + use core::fmt::Write as _; let paint_widget = |widget: &WidgetRect, text: &str, color: Color32| { let rect = widget.interact_rect; @@ -2626,7 +2726,7 @@ impl ContextImpl { // Inform the backend of all textures that have been updated (including font atlas). let textures_delta = self.tex_manager.0.write().take_delta(); - let mut platform_output: PlatformOutput = std::mem::take(&mut viewport.output); + let mut platform_output: PlatformOutput = core::mem::take(&mut viewport.output); if self.memory.should_interrupt_ime() && let Some(ime) = &mut platform_output.ime @@ -2686,7 +2786,7 @@ impl ContextImpl { shapes }; - std::mem::swap(&mut viewport.prev_pass, &mut viewport.this_pass); + core::mem::swap(&mut viewport.prev_pass, &mut viewport.this_pass); if repaint_needed { self.request_repaint(ended_viewport_id, RepaintCause::new()); @@ -2748,7 +2848,7 @@ impl ContextImpl { // Let the primary immediate viewport handle the commands of its children too. // This can make things easier for the backend, as otherwise we may get commands // that affect a viewport while its egui logic is running. - std::mem::take(&mut viewport.commands) + core::mem::take(&mut viewport.commands) } else { vec![] }; @@ -4233,13 +4333,13 @@ fn warn_if_rect_changes_id( struct OrderedRect(Rect); impl PartialOrd for OrderedRect { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for OrderedRect { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { let lhs = self.0; let rhs = other.0; lhs.min @@ -4341,6 +4441,7 @@ mod test { assert_eq!(num_calls, 1); assert_eq!(output.platform_output.num_completed_passes, 1); assert!(!output.platform_output.requested_discard()); + output.drop_without_applying_deltas(); } // A single call, with a denied request to discard: @@ -4366,6 +4467,7 @@ mod test { .reason, "test" ); + output.drop_without_applying_deltas(); } } @@ -4386,6 +4488,7 @@ mod test { assert_eq!(num_calls, 1); assert_eq!(output.platform_output.num_completed_passes, 1); assert!(!output.platform_output.requested_discard()); + output.drop_without_applying_deltas(); } // Request discard once: @@ -4408,6 +4511,7 @@ mod test { !output.platform_output.requested_discard(), "The request should have been cleared when fulfilled" ); + output.drop_without_applying_deltas(); } // Request discard twice: @@ -4432,6 +4536,7 @@ mod test { output.platform_output.requested_discard(), "The unfulfilled request should be reported" ); + output.drop_without_applying_deltas(); } } @@ -4460,6 +4565,7 @@ mod test { !output.platform_output.requested_discard(), "The request should have been cleared when fulfilled" ); + output.drop_without_applying_deltas(); } } } diff --git a/crates/egui/src/data/input/dropped_file.rs b/crates/egui/src/data/input/dropped_file.rs index 39faceba8..e01955b9f 100644 --- a/crates/egui/src/data/input/dropped_file.rs +++ b/crates/egui/src/data/input/dropped_file.rs @@ -1,19 +1,51 @@ +use std::{path::Path, sync::Arc}; + +#[cfg(target_arch = "wasm32")] +use core::{future::Future, pin::Pin}; + /// A file dropped into egui. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct DroppedFile { - /// Set by the `egui-winit` backend. - pub path: Option, +/// +/// The integration owns the concrete file handle, letting egui remain independent of windowing +/// backends and file APIs. +pub trait DroppedFile: core::fmt::Debug { + /// The path of the dropped file. + /// + /// This is an absolute path on native platforms. On the web, it is a relative path containing + /// only the file name because browsers do not expose the file's local path. + fn path(&self) -> &Path; - /// Name of the file. Set by the `eframe` web backend. - pub name: String, + /// Read the file contents. + /// + /// This is asynchronous because browsers can only read files asynchronously. + /// + /// # Errors + /// + /// Returns an error if the browser cannot read the file. + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> Pin, String>> + '_>>; - /// With the `eframe` web backend, this is set to the mime-type of the file (if available). - pub mime: String, + /// Read the file contents. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read. + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String>; - /// Set by the `eframe` web backend. - pub last_modified: Option, - - /// Set by the `eframe` web backend. - pub bytes: Option>, + /// The browser file handle, if this file was dropped on the web. + #[cfg(target_arch = "wasm32")] + fn web_file(&self) -> Option<&web_sys::File> { + None + } } + +/// A shared reference to a dropped file. +#[cfg(not(all(target_arch = "wasm32", target_feature = "atomics")))] +pub type DroppedFileHandle = Arc; + +/// A shared reference to a dropped file. +/// +/// This is not necessarily `Send + Sync` when wasm threads are enabled, because +/// [`web_sys::File`] is not thread-safe in that configuration. +#[cfg(all(target_arch = "wasm32", target_feature = "atomics"))] +pub type DroppedFileHandle = Arc; diff --git a/crates/egui/src/data/input/ime_event.rs b/crates/egui/src/data/input/ime_event.rs index de12a920f..2075b2fb9 100644 --- a/crates/egui/src/data/input/ime_event.rs +++ b/crates/egui/src/data/input/ime_event.rs @@ -14,7 +14,7 @@ pub enum ImeEvent { /// a non-empty preedit string indicates that the IME is active. Preedit { text: String, - active_range_chars: Option>, + active_range_chars: Option>, }, /// IME composition ended with this final result. @@ -22,6 +22,15 @@ pub enum ImeEvent { /// The IME is considered dismissed after this event. Commit(String), + /// Notifies when the text surrounding the cursor should be deleted. + /// + /// `before_chars` and `after_chars` are the number of characters (not + /// bytes) to delete before and after the cursor, respectively. + DeleteSurrounding { + before_chars: usize, + after_chars: usize, + }, + /// Notifies when the IME was disabled. #[deprecated = "No longer used by egui"] Disabled, diff --git a/crates/egui/src/data/input/mod.rs b/crates/egui/src/data/input/mod.rs index b4e739471..3f2060b06 100644 --- a/crates/egui/src/data/input/mod.rs +++ b/crates/egui/src/data/input/mod.rs @@ -16,7 +16,7 @@ mod touch; mod viewport_info; pub use self::{ - dropped_file::DroppedFile, + dropped_file::{DroppedFile, DroppedFileHandle}, event::Event, event_filter::EventFilter, hovered_file::HoveredFile, diff --git a/crates/egui/src/data/input/modifiers.rs b/crates/egui/src/data/input/modifiers.rs index 2478ea343..35f895950 100644 --- a/crates/egui/src/data/input/modifiers.rs +++ b/crates/egui/src/data/input/modifiers.rs @@ -37,8 +37,8 @@ pub struct Modifiers { pub command: bool, } -impl std::fmt::Debug for Modifiers { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for Modifiers { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { if self.is_none() { return write!(f, "Modifiers::NONE"); } @@ -387,7 +387,7 @@ impl Modifiers { } } -impl std::ops::BitOr for Modifiers { +impl core::ops::BitOr for Modifiers { type Output = Self; #[inline] @@ -396,7 +396,7 @@ impl std::ops::BitOr for Modifiers { } } -impl std::ops::BitOrAssign for Modifiers { +impl core::ops::BitOrAssign for Modifiers { #[inline] fn bitor_assign(&mut self, rhs: Self) { *self = *self | rhs; diff --git a/crates/egui/src/data/input/raw_input.rs b/crates/egui/src/data/input/raw_input.rs index b9fc6e66a..04b20bead 100644 --- a/crates/egui/src/data/input/raw_input.rs +++ b/crates/egui/src/data/input/raw_input.rs @@ -1,6 +1,6 @@ use crate::{OrderedViewportIdMap, Theme, ViewportId, ViewportIdMap, emath::Rect}; -use super::{DroppedFile, Event, HoveredFile, SafeAreaInsets, ViewportInfo}; +use super::{DroppedFileHandle, Event, HoveredFile, SafeAreaInsets, ViewportInfo}; /// What the integrations provides to egui at the start of each frame. /// @@ -13,7 +13,7 @@ use super::{DroppedFile, Event, HoveredFile, SafeAreaInsets, ViewportInfo}; /// /// Ii "points" can be calculated from native physical pixels /// using `pixels_per_point` = [`crate::Context::zoom_factor`] * `native_pixels_per_point`; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct RawInput { /// The id of the active viewport. @@ -65,9 +65,20 @@ pub struct RawInput { /// Dragged files dropped into egui. /// + /// egui never reads the file contents. + #[cfg_attr( + not(target_arch = "wasm32"), + doc = "Call [`crate::DroppedFile::bytes`] to read a dropped file." + )] + #[cfg_attr( + target_arch = "wasm32", + doc = "Call [`crate::DroppedFile::bytes_async`] to read a dropped file." + )] + /// /// Note: when using `eframe` on Windows, this will always be empty if drag-and-drop support has /// been disabled in [`crate::viewport::ViewportBuilder`]. - pub dropped_files: Vec, + #[cfg_attr(feature = "serde", serde(skip))] + pub dropped_files: Vec, /// The native window has the keyboard focus (i.e. is receiving key presses). /// @@ -84,7 +95,7 @@ impl Default for RawInput { fn default() -> Self { Self { viewport_id: ViewportId::ROOT, - viewports: std::iter::once((ViewportId::ROOT, Default::default())).collect(), + viewports: core::iter::once((ViewportId::ROOT, Default::default())).collect(), screen_rect: None, max_texture_side: None, time: None, @@ -123,9 +134,9 @@ impl RawInput { max_texture_side: self.max_texture_side.take(), time: self.time, predicted_dt: self.predicted_dt, - events: std::mem::take(&mut self.events), + events: core::mem::take(&mut self.events), hovered_files: self.hovered_files.clone(), - dropped_files: std::mem::take(&mut self.dropped_files), + dropped_files: core::mem::take(&mut self.dropped_files), focused: self.focused, system_theme: self.system_theme, } diff --git a/crates/egui/src/data/input/safe_area_insets.rs b/crates/egui/src/data/input/safe_area_insets.rs index 914d227c2..a170ec820 100644 --- a/crates/egui/src/data/input/safe_area_insets.rs +++ b/crates/egui/src/data/input/safe_area_insets.rs @@ -10,7 +10,7 @@ use crate::emath::Rect; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct SafeAreaInsets(pub MarginF32); -impl std::ops::Sub for Rect { +impl core::ops::Sub for Rect { type Output = Self; fn sub(self, rhs: SafeAreaInsets) -> Self::Output { diff --git a/crates/egui/src/data/input/viewport_info.rs b/crates/egui/src/data/input/viewport_info.rs index 774ca1e6e..7c5a31a58 100644 --- a/crates/egui/src/data/input/viewport_info.rs +++ b/crates/egui/src/data/input/viewport_info.rs @@ -117,7 +117,7 @@ impl ViewportInfo { Self { parent: self.parent, title: self.title.clone(), - events: std::mem::take(&mut self.events), + events: core::mem::take(&mut self.events), native_pixels_per_point: self.native_pixels_per_point, monitor_size: self.monitor_size, inner_rect: self.inner_rect, @@ -209,7 +209,7 @@ impl ViewportInfo { } #[expect(clippy::ref_option)] - fn opt_as_str(v: &Option) -> String { + fn opt_as_str(v: &Option) -> String { v.as_ref().map_or(String::new(), |v| format!("{v:?}")) } }); diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index 5808ccabc..330b7a53c 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -1,6 +1,6 @@ //! All the data egui returns to the backend at the end of each frame. -use std::ops::Range; +use core::ops::Range; use epaint::text::CharIndex; @@ -16,7 +16,7 @@ pub struct FullOutput { /// Texture changes since last frame (including the font texture). /// - /// The backend needs to apply [`crate::TexturesDelta::set`] _before_ painting, + /// The backend needs to apply [`crate::TexturesDelta::push`] _before_ painting, /// and free any texture in [`crate::TexturesDelta::free`] _after_ painting. /// /// It is assumed that all egui viewports share the same painter and texture namespace. @@ -68,6 +68,28 @@ impl FullOutput { } } } + + /// [`epaint::textures::TexturesDelta`] will panic when dropped with still unapplied deltas, + /// this is a helper to clear the deltas. + pub fn drop_without_applying_deltas(mut self) { + self.textures_delta.clear(); + } +} + +/// What egui emits from [`crate::Context::run_logic`], i.e. from a tick where no ui was shown. +/// +/// There is nothing to paint, but the app may still have asked the integration to do things, +/// e.g. to show a hidden window again with [`crate::ViewportCommand::Focus`]. +#[derive(Clone, Default)] +pub struct LogicOutput { + /// Non-rendering related output. + pub platform_output: PlatformOutput, + + /// The commands sent with [`crate::Context::send_viewport_cmd`] and friends. + /// + /// Note that this contains no information about which viewports exist: + /// the integration should leave its viewports as they are. + pub viewport_commands: OrderedViewportIdMap>, } /// Information about text being edited. @@ -76,6 +98,9 @@ impl FullOutput { #[derive(Copy, Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct IMEOutput { + /// IME's purpose. + pub purpose: crate::IMEPurpose, + /// Where the [`crate::TextEdit`] is located on screen. pub rect: crate::Rect, @@ -217,7 +242,7 @@ impl PlatformOutput { /// Take everything ephemeral (everything except `cursor_icon` and /// `cursor_image` currently) pub fn take(&mut self) -> Self { - let taken = std::mem::take(self); + let taken = core::mem::take(self); self.cursor_icon = taken.cursor_icon; // sticky between frames self.cursor_image = taken.cursor_image.clone(); // sticky between frames taken @@ -302,8 +327,8 @@ pub struct CustomCursorImage { pub hotspot: [u16; 2], } -impl std::fmt::Debug for CustomCursorImage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for CustomCursorImage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("CustomCursorImage") .field("size", &self.size) .field("hotspot", &self.hotspot) @@ -519,8 +544,8 @@ impl OutputEvent { } } -impl std::fmt::Debug for OutputEvent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for OutputEvent { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Clicked(wi) => write!(f, "Clicked({wi:?})"), Self::DoubleClicked(wi) => write!(f, "DoubleClicked({wi:?})"), @@ -566,8 +591,8 @@ pub struct WidgetInfo { pub hint_text: Option, } -impl std::fmt::Debug for WidgetInfo { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for WidgetInfo { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let Self { typ, enabled, diff --git a/crates/egui/src/data/user_data.rs b/crates/egui/src/data/user_data.rs index 12d90adf7..109e47207 100644 --- a/crates/egui/src/data/user_data.rs +++ b/crates/egui/src/data/user_data.rs @@ -1,4 +1,5 @@ -use std::{any::Any, sync::Arc}; +use core::any::Any; +use std::sync::Arc; /// A wrapper around `dyn Any`, used for passing custom user data /// to [`crate::ViewportCommand::Screenshot`]. @@ -30,8 +31,8 @@ impl PartialEq for UserData { impl Eq for UserData {} -impl std::hash::Hash for UserData { - fn hash(&self, state: &mut H) { +impl core::hash::Hash for UserData { + fn hash(&self, state: &mut H) { self.data.as_ref().map(Arc::as_ptr).hash(state); } } @@ -57,7 +58,7 @@ impl<'de> serde::Deserialize<'de> for UserData { impl serde::de::Visitor<'_> for UserDataVisitor { type Value = UserData; - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { formatter.write_str("a None value") } diff --git a/crates/egui/src/debug_text.rs b/crates/egui/src/debug_text.rs index 64d06ddac..17c3d6990 100644 --- a/crates/egui/src/debug_text.rs +++ b/crates/egui/src/debug_text.rs @@ -26,7 +26,7 @@ pub fn print(ctx: &Context, text: impl Into) { return; } - let location = std::panic::Location::caller(); + let location = core::panic::Location::caller(); let location = format!("{}:{}", location.file(), location.line()); let plugin = ctx.plugin::(); @@ -58,7 +58,7 @@ impl Plugin for DebugTextPlugin { } fn on_end_pass(&mut self, ui: &mut Ui) { - let entries = std::mem::take(&mut self.entries); + let entries = core::mem::take(&mut self.entries); Self::paint_entries(ui, entries); } } diff --git a/crates/egui/src/drag_and_drop.rs b/crates/egui/src/drag_and_drop.rs index 305effec9..5629f260a 100644 --- a/crates/egui/src/drag_and_drop.rs +++ b/crates/egui/src/drag_and_drop.rs @@ -1,4 +1,5 @@ -use std::{any::Any, sync::Arc}; +use core::any::Any; +use std::sync::Arc; use crate::{Context, CursorIcon, Plugin, Ui}; diff --git a/crates/egui/src/grid.rs b/crates/egui/src/grid.rs index e5c20f05f..981025713 100644 --- a/crates/egui/src/grid.rs +++ b/crates/egui/src/grid.rs @@ -75,6 +75,11 @@ pub(crate) struct GridLayout { curr_state: State, initial_available: Rect, + /// Are we inside an enclosing sizing pass (e.g. [`crate::Resize`] measuring + /// the minimum content width)? If so we must not remember the (narrow) sizes + /// we measure during it. + sizing_pass: bool, + // Options: num_columns: Option, spacing: Vec2, @@ -90,6 +95,10 @@ pub(crate) struct GridLayout { impl GridLayout { pub(crate) fn new(ui: &Ui, id: Id, prev_state: Option) -> Self { let is_first_frame = prev_state.is_none(); + + // An outer sizing pass, we should render as small as possible. + let sizing_pass = ui.is_sizing_pass(); + let prev_state = prev_state.unwrap_or_default(); // TODO(emilk): respect current layout @@ -110,6 +119,7 @@ impl GridLayout { prev_state, curr_state: State::default(), initial_available, + sizing_pass, num_columns: None, spacing: ui.spacing().item_spacing, @@ -180,7 +190,11 @@ impl GridLayout { } pub(crate) fn next_cell(&self, cursor: Rect, child_size: Vec2) -> Rect { - let width = self.prev_state.col_width(self.col).unwrap_or(0.0); + let width = if self.sizing_pass { + 0.0 + } else { + self.prev_state.col_width(self.col).unwrap_or(0.0) + }; let height = self.prev_row_height(self.row); let size = child_size.max(vec2(width, height)); Rect::from_min_size(cursor.min, size).round_ui() diff --git a/crates/egui/src/id.rs b/crates/egui/src/id.rs index e7bb4b2e2..6b15fd05d 100644 --- a/crates/egui/src/id.rs +++ b/crates/egui/src/id.rs @@ -1,6 +1,6 @@ // TODO(emilk): have separate types `PositionId` and `UniqueId`. ? -use std::num::NonZeroU64; +use core::num::NonZeroU64; use crate::{AsIdSalt, IdSalt}; @@ -8,9 +8,9 @@ use crate::{AsIdSalt, IdSalt}; /// /// This is all types implementing `Hash` and `Debug`, /// which includes things like string, integers, tuples of those, etc. -pub trait AsId: std::hash::Hash + std::fmt::Debug {} +pub trait AsId: core::hash::Hash + core::fmt::Debug {} -impl AsId for T {} +impl AsId for T {} /// egui tracks widgets frame-to-frame using [`Id`]s. /// @@ -41,6 +41,13 @@ impl AsId for T {} /// This is niche-optimized to that `Option` is the same size as `Id`. #[derive(Clone, Copy, Hash, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr( + feature = "serde", + expect( + clippy::unsafe_derive_deserialize, + reason = "`from_high_entropy_bits` is only `unsafe` about entropy, not memory safety" + ) +)] pub struct Id(NonZeroU64); impl nohash_hasher::IsEnabled for Id {} @@ -75,7 +82,7 @@ impl Id { /// Generate a child [`Id`] by salting the parent [`Id`] with the given argument. pub fn with(self, salt: impl AsIdSalt) -> Self { - use std::hash::{BuildHasher as _, Hasher as _}; + use core::hash::{BuildHasher as _, Hasher as _}; let mut hasher = ahash::RandomState::with_seeds(1, 2, 3, 4).build_hasher(); hasher.write_u64(self.value()); hasher.write_u64(IdSalt::new(&salt).value()); @@ -124,8 +131,8 @@ impl Id { } } -impl std::fmt::Debug for Id { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for Id { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { if *self == Self::NULL { return write!(f, "Id::NULL"); } @@ -204,8 +211,8 @@ mod id_source { #[test] fn id_size() { - assert_eq!(std::mem::size_of::(), 8); - assert_eq!(std::mem::size_of::>(), 8); + assert_eq!(core::mem::size_of::(), 8); + assert_eq!(core::mem::size_of::>(), 8); } #[cfg(test)] diff --git a/crates/egui/src/id_salt.rs b/crates/egui/src/id_salt.rs index 486dda239..2540d1e0e 100644 --- a/crates/egui/src/id_salt.rs +++ b/crates/egui/src/id_salt.rs @@ -1,12 +1,12 @@ -use std::num::NonZeroU64; +use core::num::NonZeroU64; /// Types that can be converted to an [`IdSalt`]. /// /// This is all types implementing `Hash` and `Debug`, /// which includes things like string, integers, tuples of those, etc. -pub trait AsIdSalt: std::hash::Hash + std::fmt::Debug {} +pub trait AsIdSalt: core::hash::Hash + core::fmt::Debug {} -impl AsIdSalt for T {} +impl AsIdSalt for T {} /// Uniquely identifies a child widget within a parent widget. /// @@ -57,8 +57,8 @@ impl IdSalt { } } -impl std::fmt::Debug for IdSalt { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for IdSalt { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { #[cfg(debug_assertions)] if let Some(source) = id_salt_source::get(*self) { return write!(f, "IdSalt::new({source})"); diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index 36d6f9bc9..854c1fead 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -13,10 +13,8 @@ use crate::{ }, input_state::wheel_state::WheelState, }; -use std::{ - collections::{BTreeMap, HashSet}, - time::Duration, -}; +use core::time::Duration; +use std::collections::{BTreeMap, HashSet}; pub use crate::Key; pub use touch_state::MultiTouchInfo; diff --git a/crates/egui/src/input_state/touch_state.rs b/crates/egui/src/input_state/touch_state.rs index 578e45e69..833de4238 100644 --- a/crates/egui/src/input_state/touch_state.rs +++ b/crates/egui/src/input_state/touch_state.rs @@ -1,4 +1,5 @@ -use std::{collections::BTreeMap, fmt::Debug}; +use core::fmt::Debug; +use std::collections::BTreeMap; use crate::{ Event, RawInput, TouchId, TouchPhase, @@ -305,7 +306,7 @@ impl TouchState { impl Debug for TouchState { // This outputs less clutter than `#[derive(Debug)]`: - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { for (id, touch) in &self.active_touches { f.write_fmt(format_args!("#{id:?}: {touch:#?}\n"))?; } diff --git a/crates/egui/src/interaction.rs b/crates/egui/src/interaction.rs index 6e01ec8fb..cac08f595 100644 --- a/crates/egui/src/interaction.rs +++ b/crates/egui/src/interaction.rs @@ -197,7 +197,24 @@ pub(crate) fn interact( // This widget is sensitive to both clicks and drags. // When the mouse first is pressed, it could be either, // so we postpone the decision until we know. - input.pointer.is_decidedly_dragging() + // + // …unless the pointer has left the widget: a click has to be + // released on the widget, so once the pointer is outside there is + // nothing left to wait for. + // + // Deciding here means a thin drag handle (narrower than + // `max_click_dist`) doesn't spend the decision window as neither + // hovered nor dragged, which would make its highlight blink out. + // The hit-test picks up widgets within `interact_radius`, so + // `hits.click` can name such a handle even when the pointer is a + // few points outside it. + // + // A widget on top might "steal" the click hit, but then the pointer is still inside + // us, and pressing that button must not start a drag. So we check both. + let pointer_is_inside = hits.contains_pointer.iter().any(|w| w.id == widget.id); + let could_still_be_clicked = + pointer_is_inside || hits.click.is_some_and(|hit| hit.id == widget.id); + input.pointer.is_decidedly_dragging() || !could_still_be_clicked } else { // This widget is just sensitive to drags, so we can mark it as dragged right away: widget.sense.senses_drag() @@ -262,7 +279,7 @@ pub(crate) fn interact( let drag_order = hits.drag.and_then(|w| order(w.id)).unwrap_or(0); let top_interactive_order = click_order.max(drag_order); - let mut hovered: IdSet = std::iter::chain(&hits.click, &hits.drag) + let mut hovered: IdSet = core::iter::chain(&hits.click, &hits.drag) .map(|w| w.id) .collect(); diff --git a/crates/egui/src/layers.rs b/crates/egui/src/layers.rs index 6fa9274ee..462bb0142 100644 --- a/crates/egui/src/layers.rs +++ b/crates/egui/src/layers.rs @@ -96,8 +96,8 @@ impl LayerId { } } -impl std::fmt::Debug for LayerId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for LayerId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let Self { order, id } = self; write!(f, "LayerId {{ {order:?} {id:?} }}") } diff --git a/crates/egui/src/layout.rs b/crates/egui/src/layout.rs index c97652e8d..15152f47b 100644 --- a/crates/egui/src/layout.rs +++ b/crates/egui/src/layout.rs @@ -1,4 +1,4 @@ -use emath::GuiRounding as _; +use emath::{GuiRounding as _, fast_midpoint}; use crate::{ Align, Direction, @@ -477,12 +477,12 @@ impl Layout { // Make sure it isn't negative: if avail.max.x < avail.min.x { - let x = 0.5 * (avail.min.x + avail.max.x); + let x = fast_midpoint(avail.min.x, avail.max.x); avail.min.x = x; avail.max.x = x; } if avail.max.y < avail.min.y { - let y = 0.5 * (avail.min.y + avail.max.y); + let y = fast_midpoint(avail.min.y, avail.max.y); avail.min.y = y; avail.max.y = y; } diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 22f67a095..2d4099d92 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -474,7 +474,7 @@ pub use self::{ Key, UserData, input::*, output::{ - self, CursorIcon, CustomCursorImage, FullOutput, OpenUrl, OutputCommand, + self, CursorIcon, CustomCursorImage, FullOutput, LogicOutput, OpenUrl, OutputCommand, PlatformOutput, UserAttentionType, WidgetInfo, }, }, @@ -680,18 +680,20 @@ pub enum WidgetType { pub fn __run_test_ctx(mut run_ui: impl FnMut(&Context)) { let ctx = Context::default(); ctx.set_fonts(FontDefinitions::empty()); // prevent fonts from being loaded (save CPU time) - let _ = ctx.run_ui(Default::default(), |ui| { + let output = ctx.run_ui(Default::default(), |ui| { run_ui(ui.ctx()); }); + output.drop_without_applying_deltas(); } /// For use in tests; especially doctests. pub fn __run_test_ui(mut add_contents: impl FnMut(&mut Ui)) { let ctx = Context::default(); ctx.set_fonts(FontDefinitions::empty()); // prevent fonts from being loaded (save CPU time) - let _ = ctx.run_ui(Default::default(), |ui| { + let output = ctx.run_ui(Default::default(), |ui| { add_contents(ui); }); + output.drop_without_applying_deltas(); } pub fn accesskit_root_id() -> Id { diff --git a/crates/egui/src/load.rs b/crates/egui/src/load.rs index c02f37c5c..df390dc64 100644 --- a/crates/egui/src/load.rs +++ b/crates/egui/src/load.rs @@ -55,12 +55,11 @@ mod bytes_loader; mod texture_loader; -use std::{ - borrow::Cow, +use core::{ fmt::{Debug, Display}, ops::Deref, - sync::Arc, }; +use std::{borrow::Cow, sync::Arc}; use ahash::HashMap; @@ -108,13 +107,13 @@ impl LoadError { detected_format.as_ref().map_or(0, |s| s.len()) } Self::Loading(message) => message.len(), - _ => std::mem::size_of::(), + _ => core::mem::size_of::(), } } } impl Display for LoadError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::NoImageLoaders => f.write_str( "No image loaders are installed. If you're trying to load some images \ @@ -136,9 +135,9 @@ impl Display for LoadError { } } -impl std::error::Error for LoadError {} +impl core::error::Error for LoadError {} -pub type Result = std::result::Result; +pub type Result = core::result::Result; /// Given as a hint for image loading requests. /// @@ -209,7 +208,7 @@ pub enum Bytes { } impl Debug for Bytes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Static(arg0) => f.debug_tuple("Static").field(&arg0.len()).finish(), Self::Shared(arg0) => f.debug_tuple("Shared").field(&arg0.len()).finish(), @@ -307,6 +306,19 @@ macro_rules! generate_loader_id { } pub use crate::generate_loader_id; +/// Does the given URI end with the given file extension? +/// +/// The comparison ignores ASCII case and any `#fragment` at the end of the URI, +/// so `has_extension("cat.GIF#frame=2", "gif")` is `true`. +/// +/// This is useful when implementing an [`ImageLoader`]. +pub fn has_extension(uri: &str, extension: &str) -> bool { + let path = uri.split('#').next().unwrap_or(uri); + std::path::Path::new(path) + .extension() + .is_some_and(|found| found.eq_ignore_ascii_case(extension)) +} + pub type BytesLoadResult = Result; /// Represents a loader capable of loading raw unstructured bytes from somewhere, @@ -387,7 +399,7 @@ pub type ImageLoadResult = Result; /// An `ImageLoader` decodes raw bytes into a [`ColorImage`]. /// /// Implementations are expected to cache at least each `URI`. -pub trait ImageLoader: std::any::Any { +pub trait ImageLoader: core::any::Any { /// Unique ID of this loader. /// /// To reduce the chance of collisions, include `module_path!()` as part of this ID. @@ -640,3 +652,14 @@ impl Loaders { } } } + +#[test] +fn test_has_extension() { + assert!(has_extension("cat.svg", "svg")); + assert!(has_extension("cat.SVG", "svg")); + assert!(has_extension("http://example.com/cat.gif#frame=2", "gif")); + assert!(!has_extension("cat.svg.png", "svg")); + assert!(!has_extension("svg", "svg")); + assert!(!has_extension("cat.jpeg", "jpg")); + assert!(!has_extension("cat.svg?v=1", "svg")); +} diff --git a/crates/egui/src/load/texture_loader.rs b/crates/egui/src/load/texture_loader.rs index 2ab4d8a57..eb9b4503b 100644 --- a/crates/egui/src/load/texture_loader.rs +++ b/crates/egui/src/load/texture_loader.rs @@ -150,5 +150,5 @@ impl TextureLoader for DefaultTextureLoader { } fn is_svg(uri: &str) -> bool { - uri.ends_with(".svg") + super::has_extension(uri, "svg") } diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 1f22b3dd4..4910d81da 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -1,6 +1,6 @@ #![warn(missing_docs)] // Let's keep this file well-documented.` to memory.rs -use std::num::NonZeroUsize; +use core::num::NonZeroUsize; use ahash::{HashMap, HashSet}; use epaint::emath::TSTransform; @@ -216,6 +216,18 @@ pub struct Options { #[cfg_attr(feature = "serde", serde(skip))] pub(crate) system_theme: Option, + /// If `true`, egui will keep the native window theme in sync with + /// [`Self::theme_preference`] by sending a [`crate::ViewportCommand::SetTheme`] + /// to the root viewport whenever the preference changes. + /// + /// This makes the native window decorations (title bar, borders, …) match the + /// theme selected inside egui. + /// + /// Set this to `false` if you want to manage the native window theme yourself. + /// + /// This is `true` by default. + pub sync_window_theme: bool, + /// Global zoom factor of the UI. /// /// This is used to calculate the `pixels_per_point` @@ -318,6 +330,7 @@ impl Default for Options { theme_preference: Default::default(), fallback_theme: Theme::Dark, system_theme: None, + sync_window_theme: true, zoom_factor: 1.0, zoom_with_keyboard: true, quit_shortcuts: vec![crate::KeyboardShortcut::new( @@ -381,6 +394,7 @@ impl Options { theme_preference, fallback_theme: _, system_theme: _, + sync_window_theme, zoom_factor, zoom_with_keyboard, quit_shortcuts: _, // not shown in ui @@ -429,6 +443,8 @@ impl Options { .show(ui, |ui| { theme_preference.radio_buttons(ui); + ui.checkbox(sync_window_theme, "Sync window theme with egui theme"); + let style = std::sync::Arc::make_mut(match theme { Theme::Dark => dark_style, Theme::Light => light_style, @@ -926,7 +942,7 @@ impl Memory { if let Some(modal_layer) = self.focus().and_then(|f| f.top_modal_layer) { matches!( self.areas().compare_order(layer_id, modal_layer), - std::cmp::Ordering::Equal | std::cmp::Ordering::Greater + core::cmp::Ordering::Equal | core::cmp::Ordering::Greater ) } else { true @@ -966,7 +982,7 @@ impl Memory { if let Some(current) = self.focus().and_then(|f| f.top_modal_layer_current_frame) && matches!( self.areas().compare_order(layer_id, current), - std::cmp::Ordering::Less + core::cmp::Ordering::Less ) { return; @@ -1194,6 +1210,11 @@ impl Areas { self.areas.get_mut(&id) } + /// Can the user interact with this layer or it's widgets, or do clicks go straight through it? + pub(crate) fn is_interactable(&self, layer_id: LayerId) -> bool { + self.get(layer_id.id).is_none_or(|area| area.interactable) + } + /// All layers back-to-front, top is last. pub(crate) fn order(&self) -> &[LayerId] { &self.order @@ -1202,12 +1223,12 @@ impl Areas { /// Compare the order of two layers, based on the order list from last frame. /// /// May return [`std::cmp::Ordering::Equal`] if the layers are not in the order list. - pub(crate) fn compare_order(&self, a: LayerId, b: LayerId) -> std::cmp::Ordering { + pub(crate) fn compare_order(&self, a: LayerId, b: LayerId) -> core::cmp::Ordering { // Sort by layer `order` first and use `order_map` to resolve disputes. // If `order_map` only contains one layer ID, then the other one will be // lower because `None < Some(x)`. match a.order.cmp(&b.order) { - std::cmp::Ordering::Equal => self.order_map.get(&a).cmp(&self.order_map.get(&b)), + core::cmp::Ordering::Equal => self.order_map.get(&a).cmp(&self.order_map.get(&b)), cmp => cmp, } } @@ -1255,7 +1276,7 @@ impl Areas { } pub fn visible_layer_ids(&self) -> ahash::HashSet { - std::iter::chain( + core::iter::chain( &self.visible_areas_last_frame, &self.visible_areas_current_frame, ) @@ -1344,7 +1365,7 @@ impl Areas { .. } = self; - std::mem::swap(visible_areas_last_frame, visible_areas_current_frame); + core::mem::swap(visible_areas_last_frame, visible_areas_current_frame); visible_areas_current_frame.clear(); order.sort_by_key(|layer| (layer.order, wants_to_be_on_top.contains(layer))); @@ -1353,7 +1374,7 @@ impl Areas { // For all layers with sublayers, put the sublayers directly after the parent layer: // (it doesn't matter in which order we replace parents with their children) #[expect(clippy::iter_over_hash_type)] - for (parent, children) in std::mem::take(sublayers) { + for (parent, children) in core::mem::take(sublayers) { let mut moved_layers = vec![parent]; // parent first… order.retain(|l| { @@ -1462,14 +1483,14 @@ fn order_map_total_ordering() { let mut i = 0; for &[a, b] in layers.array_windows() { assert!(a.order <= b.order, "does not follow LayerId.order"); - if areas.compare_order(a, b) != std::cmp::Ordering::Equal { + if areas.compare_order(a, b) != core::cmp::Ordering::Equal { i += 1; } equivalence_classes.push(i); } assert_eq!(layers.len(), equivalence_classes.len()); - for (&l1, c1) in std::iter::zip(&layers, &equivalence_classes) { - for (&l2, c2) in std::iter::zip(&layers, &equivalence_classes) { + for (&l1, c1) in core::iter::zip(&layers, &equivalence_classes) { + for (&l2, c2) in core::iter::zip(&layers, &equivalence_classes) { assert_eq!( c1.cmp(c2), areas.compare_order(l1, l2), diff --git a/crates/egui/src/painter.rs b/crates/egui/src/painter.rs index 4b81e98cb..ab64db864 100644 --- a/crates/egui/src/painter.rs +++ b/crates/egui/src/painter.rs @@ -280,7 +280,7 @@ impl Painter { ); } - pub fn error(&self, pos: Pos2, text: impl std::fmt::Display) -> Rect { + pub fn error(&self, pos: Pos2, text: impl core::fmt::Display) -> Rect { let color = self.ctx.global_style().visuals.error_fg_color; self.debug_text(pos, Align2::LEFT_TOP, color, format!("🔥 {text}")) } @@ -416,7 +416,7 @@ impl Painter { /// Show an arrow starting at `origin` and going in the direction of `vec`, with the length `vec.length()`. pub fn arrow(&self, origin: Pos2, vec: Vec2, stroke: impl Into) { use crate::emath::Rot2; - let rot = Rot2::from_angle(std::f32::consts::TAU / 10.0); + let rot = Rot2::from_angle(core::f32::consts::TAU / 10.0); let tip_length = vec.length() / 4.0; let tip = origin + vec; let dir = vec.normalized(); diff --git a/crates/egui/src/plugin.rs b/crates/egui/src/plugin.rs index f8f1a4468..76df0a641 100644 --- a/crates/egui/src/plugin.rs +++ b/crates/egui/src/plugin.rs @@ -10,7 +10,7 @@ use std::sync::Arc; /// Plugins should not hold a reference to the [`Context`], since this would create a cycle /// (which would prevent the [`Context`] from being dropped). #[expect(unused_variables)] -pub trait Plugin: Send + Sync + std::any::Any + 'static { +pub trait Plugin: Send + Sync + core::any::Any + 'static { /// Plugin name. /// /// Used when profiling. @@ -60,14 +60,14 @@ pub(crate) struct PluginHandle { /// Use [`Self::lock`] to access the plugin. pub struct TypedPluginHandle { handle: Arc>, - _type: std::marker::PhantomData

, + _type: core::marker::PhantomData

, } impl TypedPluginHandle

{ pub(crate) fn new(handle: Arc>) -> Self { Self { handle, - _type: std::marker::PhantomData, + _type: core::marker::PhantomData, } } @@ -77,7 +77,7 @@ impl TypedPluginHandle

{ pub fn lock(&self) -> TypedPluginGuard<'_, P> { TypedPluginGuard { guard: self.handle.lock(), - _type: std::marker::PhantomData, + _type: core::marker::PhantomData, } } } @@ -85,12 +85,12 @@ impl TypedPluginHandle

{ /// A guard that provides access to a [`Plugin`]. pub struct TypedPluginGuard<'a, P: Plugin> { guard: MutexGuard<'a, PluginHandle>, - _type: std::marker::PhantomData

, + _type: core::marker::PhantomData

, } impl TypedPluginGuard<'_, P> {} -impl std::ops::Deref for TypedPluginGuard<'_, P> { +impl core::ops::Deref for TypedPluginGuard<'_, P> { type Target = P; fn deref(&self) -> &Self::Target { @@ -98,7 +98,7 @@ impl std::ops::Deref for TypedPluginGuard<'_, P> { } } -impl std::ops::DerefMut for TypedPluginGuard<'_, P> { +impl core::ops::DerefMut for TypedPluginGuard<'_, P> { fn deref_mut(&mut self) -> &mut Self::Target { self.guard.typed_plugin_mut() } @@ -111,7 +111,7 @@ impl PluginHandle { })) } - fn plugin_type_id(&self) -> std::any::TypeId { + fn plugin_type_id(&self) -> core::any::TypeId { (*self.plugin).type_id() } @@ -120,13 +120,13 @@ impl PluginHandle { } fn typed_plugin(&self) -> &P { - (self.plugin.as_ref() as &dyn std::any::Any) + (self.plugin.as_ref() as &dyn core::any::Any) .downcast_ref::

() .expect("PluginHandle: plugin is not of the expected type") } pub fn typed_plugin_mut(&mut self) -> &mut P { - (self.plugin.as_mut() as &mut dyn std::any::Any) + (self.plugin.as_mut() as &mut dyn core::any::Any) .downcast_mut::

() .expect("PluginHandle: plugin is not of the expected type") } @@ -135,7 +135,7 @@ impl PluginHandle { /// User-registered plugins. #[derive(Clone, Default)] pub(crate) struct Plugins { - plugins: HashMap>>, + plugins: HashMap>>, plugins_ordered: PluginsOrdered, } @@ -215,7 +215,7 @@ impl Plugins { true } - pub fn get(&self, type_id: std::any::TypeId) -> Option>> { + pub fn get(&self, type_id: core::any::TypeId) -> Option>> { self.plugins.get(&type_id).cloned() } } diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 81d04f1ef..e206594ad 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -1,4 +1,5 @@ -use std::{any::Any, sync::Arc}; +use core::any::Any; +use std::sync::Arc; use crate::{ Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, Tooltip, Ui, @@ -77,7 +78,7 @@ pub struct Response { #[test] fn test_response_size() { assert_eq!( - std::mem::size_of::(), + core::mem::size_of::(), 88, "Keep Response small, because we create them often, and we want to keep it lean and fast" ); @@ -309,6 +310,12 @@ impl Response { /// /// In contrast to [`Self::contains_pointer`], this will be `false` whenever some other widget is being dragged. /// `hovered` is always `false` for disabled widgets. + /// + /// While a widget is being clicked or dragged it is the only hovered widget, + /// so this stays `true` even after the pointer moves off it. Together with + /// how [`Self::dragged`] resolves a press that leaves the widget, that means + /// `hovered() || dragged()` holds for a whole press-drag-release gesture, + /// which is what you want for highlighting something like a drag handle. #[inline(always)] pub fn hovered(&self) -> bool { self.flags.contains(Flags::HOVERED) @@ -403,11 +410,21 @@ impl Response { /// To find out which button(s), use [`Self::dragged_by`]. /// /// If the widget is only sensitive to drags, this is `true` as soon as the pointer presses down on it. - /// If the widget also senses clicks, this won't be true until the pointer has moved a bit, - /// or the user has pressed down for long enough. + /// + /// If the widget also senses clicks, the press could be either, so the + /// decision is postponed until whichever of these comes first: + /// * the pointer moves further than [`crate::InputOptions::max_click_dist`], + /// * it is held longer than [`crate::InputOptions::max_click_duration`], + /// * or it leaves the widget — a click has to be released on the widget, so + /// once the pointer is outside, the gesture can only be a drag. This is what + /// keeps a handle thinner than `max_click_dist` from spending the decision + /// window as neither hovered nor dragged. + /// /// See [`crate::input_state::PointerState::is_decidedly_dragging`] for details. /// - /// If you want to avoid the delay, use [`Self::is_pointer_button_down_on`] instead. + /// While the decision is pending the pointer is still on the widget, so + /// [`Self::hovered`] is `true` throughout. If you want neither the delay nor + /// the distinction, use [`Self::is_pointer_button_down_on`]. /// /// If the widget is NOT sensitive to drags, this will always be `false`. /// [`crate::DragValue`] senses drags; [`crate::Label`] does not (unless you call [`crate::Label::sense`]). @@ -571,6 +588,9 @@ impl Response { /// even when dragging outside the widget. /// /// This could also be thought of as "is this widget being interacted with?". + /// + /// Unlike [`Self::dragged`], this is `true` from the press frame onwards, with + /// no click-versus-drag decision window. #[inline(always)] pub fn is_pointer_button_down_on(&self) -> bool { self.flags.contains(Flags::IS_POINTER_BUTTON_DOWN_ON) @@ -1093,7 +1113,7 @@ impl Response { /// ``` /// /// Now `draw_vec2(ui, foo).hovered` is true if either [`DragValue`](crate::DragValue) were hovered. -impl std::ops::BitOr for Response { +impl core::ops::BitOr for Response { type Output = Self; fn bitor(self, rhs: Self) -> Self { @@ -1114,7 +1134,7 @@ impl std::ops::BitOr for Response { /// if response.hovered() { ui.label("You hovered at least one of the widgets"); } /// # }); /// ``` -impl std::ops::BitOrAssign for Response { +impl core::ops::BitOrAssign for Response { fn bitor_assign(&mut self, rhs: Self) { *self = self.union(rhs); } diff --git a/crates/egui/src/sense.rs b/crates/egui/src/sense.rs index c3b3af7f2..1283f320e 100644 --- a/crates/egui/src/sense.rs +++ b/crates/egui/src/sense.rs @@ -22,8 +22,8 @@ bitflags::bitflags! { } } -impl std::fmt::Debug for Sense { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for Sense { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "Sense {{")?; if self.senses_click() { write!(f, " click")?; diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 27be5920b..f6df21f09 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1,11 +1,12 @@ //! egui theme (spacing, colors, etc). +use core::ops::RangeInclusive; use emath::Align; use epaint::{ CornerRadius, FontColorTransferFunction, Shadow, Stroke, TextOptions, text::{FontTweak, FontVariationAxis, HintingTarget, SmoothHinting}, }; -use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc}; use crate::{ ComboBox, CursorIcon, FontFamily, FontId, Grid, Margin, Response, RichText, TextWrapMode, @@ -47,8 +48,8 @@ impl NumberFormatter { } } -impl std::fmt::Debug for NumberFormatter { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for NumberFormatter { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str("NumberFormatter") } } @@ -93,8 +94,8 @@ pub enum TextStyle { Name(std::sync::Arc), } -impl std::fmt::Display for TextStyle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for TextStyle { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Small => "Small".fmt(f), Self::Body => "Body".fmt(f), @@ -192,8 +193,8 @@ impl From for FontSelection { #[derive(Clone, Default)] pub struct StyleModifier(Option>); -impl std::fmt::Debug for StyleModifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for StyleModifier { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str("StyleModifier") } } @@ -419,6 +420,9 @@ pub struct Spacing { /// Default width of a [`crate::TextEdit`]. pub text_edit_width: f32, + /// Additional vertical spacing between lines of text. + pub extra_text_line_spacing: f32, + /// Checkboxes, radio button and collapsing headers have an icon at the start. /// This is the width/height of the outer part of this icon (e.g. the BOX of the checkbox). pub icon_width: f32, @@ -1074,10 +1078,12 @@ pub struct Visuals { /// How the text cursor acts. pub text_cursor: TextCursorStyle, - /// Allow widgets to paint this much outside the scroll area rect. + /// Unused. Kept only for backwards compatibility. /// - /// Legacy. Should not be used anymore. + /// Used to allow widgets to paint this much outside the scroll area rect. + /// Setting it now has no effect. /// Use [`crate::ScrollArea::content_margin`] instead. + #[deprecated(note = "This is now unused and has no effect")] pub clip_rect_margin: f32, /// Show a background behind buttons. @@ -1456,6 +1462,7 @@ impl Default for Spacing { slider_rail_height: 8.0, combo_width: 100.0, text_edit_width: 280.0, + extra_text_line_spacing: 0.0, icon_width: 14.0, icon_width_inner: 8.0, icon_spacing: 4.0, @@ -1487,6 +1494,7 @@ impl Default for Interaction { impl Visuals { /// Default dark theme. + #[expect(deprecated)] pub fn dark() -> Self { Self { dark_mode: true, @@ -1945,6 +1953,7 @@ impl Spacing { slider_rail_height, combo_width, text_edit_width, + extra_text_line_spacing, icon_width, icon_width_inner, icon_spacing, @@ -2011,6 +2020,10 @@ impl Spacing { ui.add(DragValue::new(text_edit_width).range(0.0..=1000.0)); ui.end_row(); + ui.label("Extra text line spacing"); + ui.add(DragValue::new(extra_text_line_spacing).range(0.0..=20.0)); + ui.end_row(); + ui.label("Tooltip wrap width"); ui.add(DragValue::new(tooltip_width).range(0.0..=1000.0)); ui.end_row(); @@ -2263,6 +2276,7 @@ impl WidgetVisuals { } impl Visuals { + #[expect(deprecated)] pub fn ui(&mut self, ui: &mut crate::Ui) { let Self { dark_mode, @@ -2297,7 +2311,7 @@ impl Visuals { text_cursor, - clip_rect_margin, + clip_rect_margin: _, button_frame, collapsing_header_frame, indent_has_left_vline, @@ -2484,8 +2498,6 @@ impl Visuals { ui.collapsing("Misc", |ui| { ui.add(Slider::new(resize_corner_size, 0.0..=20.0).text("resize_corner_size")); - ui.add(Slider::new(clip_rect_margin, 0.0..=20.0).text("clip_rect_margin")); - ui.checkbox(button_frame, "Button has a frame"); ui.checkbox(collapsing_header_frame, "Collapsing header has a frame"); ui.checkbox( @@ -2684,7 +2696,7 @@ impl DebugOptions { } // TODO(emilk): improve and standardize -fn two_drag_values(value: &mut Vec2, range: std::ops::RangeInclusive) -> impl Widget + '_ { +fn two_drag_values(value: &mut Vec2, range: core::ops::RangeInclusive) -> impl Widget + '_ { move |ui: &mut crate::Ui| { ui.horizontal(|ui| { ui.add( @@ -2753,8 +2765,8 @@ impl NumericColorSpace { } } -impl std::fmt::Display for NumericColorSpace { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for NumericColorSpace { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::GammaByte => write!(f, "U8"), Self::Linear => write!(f, "F"), diff --git a/crates/egui/src/text_selection/cursor_range.rs b/crates/egui/src/text_selection/cursor_range.rs index 4229756db..f811b2e5e 100644 --- a/crates/egui/src/text_selection/cursor_range.rs +++ b/crates/egui/src/text_selection/cursor_range.rs @@ -49,9 +49,9 @@ impl CCursorRange { } /// The range of selected character indices. - pub fn as_sorted_char_range(&self) -> std::ops::Range { + pub fn as_sorted_char_range(&self) -> core::ops::Range { let [start, end] = self.sorted_cursors(); - std::ops::Range { + core::ops::Range { start: start.index, end: end.index, } diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index a49579eee..4adcf59ab 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -47,8 +47,8 @@ fn pos_in_galley(galley: &Galley, ccursor: CCursor) -> Pos2 { galley.pos_from_cursor(ccursor).center() } -impl std::fmt::Debug for WidgetTextCursor { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for WidgetTextCursor { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let Self { widget_id, ccursor, @@ -271,7 +271,7 @@ impl ViewportLabelSelectionState { self.is_dragging = false; } - let text_to_copy = std::mem::take(&mut self.text_to_copy); + let text_to_copy = core::mem::take(&mut self.text_to_copy); if !text_to_copy.is_empty() { ui.copy_text(text_to_copy); } @@ -773,7 +773,7 @@ mod tests { .or_default() .selection = Some(test_selection()); - let _ = ctx.run_ui(RawInput::default(), |_| {}); + let output = ctx.run_ui(RawInput::default(), |_| {}); assert!( plugin .lock() @@ -782,11 +782,13 @@ mod tests { .is_some_and(ViewportLabelSelectionState::has_selection), "a pass in another viewport must not clear the child viewport selection" ); + output.drop_without_applying_deltas(); - let _ = ctx.run_ui(child_viewport_input(child_viewport_id), |_| {}); + let output = ctx.run_ui(child_viewport_input(child_viewport_id), |_| {}); assert!( !plugin.lock().has_selection(), "the selection must be cleared when its labels disappear from the same viewport" ); + output.drop_without_applying_deltas(); } } diff --git a/crates/egui/src/text_selection/text_cursor_state.rs b/crates/egui/src/text_selection/text_cursor_state.rs index f88368f22..7ab88ec97 100644 --- a/crates/egui/src/text_selection/text_cursor_state.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -294,7 +294,7 @@ pub fn char_index_from_byte_index(input: &str, byte_index: ByteIndex) -> CharInd CharIndex(input.chars().count()) } -pub fn slice_char_range(s: &str, char_range: std::ops::Range) -> &str { +pub fn slice_char_range(s: &str, char_range: core::ops::Range) -> &str { assert!( char_range.start <= char_range.end, "Invalid range, start must be less than end, but start = {}, end = {}", diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index 5b41fd902..79e38dccb 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -139,8 +139,8 @@ pub(crate) fn paint_ime_preedit_text_visuals( painter: &Painter, galley: &Arc, row_height: f32, - preedit_range: std::ops::Range, - mut relative_active_range: Option>, + preedit_range: core::ops::Range, + mut relative_active_range: Option>, time_since_last_interaction: f64, ) { /// Instead of implementing [`PartialOrd`] and [`Ord`] for [`CCursor`] to @@ -150,7 +150,7 @@ pub(crate) fn paint_ime_preedit_text_visuals( /// These traits are intentionally not implemented because /// [`CCursor::prefer_next_row`] makes it difficult to define a clear /// ordering between two [`CCursor`]s. - fn is_cursor_range_empty(range: &std::ops::Range) -> bool { + fn is_cursor_range_empty(range: &core::ops::Range) -> bool { range.start.index == range.end.index } diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index d6a390377..9021d2d03 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -1,7 +1,8 @@ #![warn(missing_docs)] // Let's keep `Ui` well-documented. #![expect(clippy::use_self)] -use std::{any::Any, ops::Deref, sync::Arc}; +use core::{any::Any, ops::Deref}; +use std::sync::Arc; use crate::containers::menu; use crate::widget_style::{HasClasses as _, ROOT_CLASS}; @@ -1984,7 +1985,7 @@ impl Ui { /// but is shown to the user in fractions of one Tau (i.e. fractions of one turn). /// The angle is NOT wrapped, so the user may select, for instance 2𝞃 (720°) pub fn drag_angle_tau(&mut self, radians: &mut f32) -> Response { - use std::f32::consts::TAU; + use core::f32::consts::TAU; let mut taus = *radians / TAU; let mut response = self.add(DragValue::new(&mut taus).speed(0.01).suffix("τ")); @@ -2599,7 +2600,7 @@ impl Ui { let column_width = (self.available_width() - total_spacing) / (NUM_COL as f32); let top_left = self.cursor().min; - let mut columns = std::array::from_fn(|col_idx| { + let mut columns = core::array::from_fn(|col_idx| { let pos = top_left + vec2((col_idx as f32) * (column_width + spacing), 0.0); let child_rect = Rect::from_min_max( pos, diff --git a/crates/egui/src/ui_stack.rs b/crates/egui/src/ui_stack.rs index f43834dba..f6b4865be 100644 --- a/crates/egui/src/ui_stack.rs +++ b/crates/egui/src/ui_stack.rs @@ -1,5 +1,5 @@ +use core::{any::Any, iter::FusedIterator}; use std::sync::Arc; -use std::{any::Any, iter::FusedIterator}; use crate::widget_style::Classes; use epaint::Color32; diff --git a/crates/egui/src/util/fixed_cache.rs b/crates/egui/src/util/fixed_cache.rs index c0f8662a2..b2d2e45dd 100644 --- a/crates/egui/src/util/fixed_cache.rs +++ b/crates/egui/src/util/fixed_cache.rs @@ -16,15 +16,15 @@ where } } -impl std::fmt::Debug for FixedCache { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for FixedCache { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "Cache") } } impl FixedCache where - K: std::hash::Hash + PartialEq, + K: core::hash::Hash + PartialEq, { pub fn get(&self, key: &K) -> Option<&V> { let bucket = (hash(key) % (FIXED_CACHE_SIZE as u64)) as usize; diff --git a/crates/egui/src/util/id_type_map.rs b/crates/egui/src/util/id_type_map.rs index 76f0da5d7..d384a883b 100644 --- a/crates/egui/src/util/id_type_map.rs +++ b/crates/egui/src/util/id_type_map.rs @@ -3,7 +3,8 @@ // For non-serializable types, these simply return `None`. // This will also allow users to pick their own serialization format per type. -use std::{any::Any, sync::Arc}; +use core::any::Any; +use std::sync::Arc; // ----------------------------------------------------------------------------------------------- /// Like [`std::any::TypeId`], but can be serialized and deserialized. @@ -14,7 +15,7 @@ pub struct TypeId(u64); impl TypeId { #[inline] pub fn of() -> Self { - std::any::TypeId::of::().into() + core::any::TypeId::of::().into() } #[inline(always)] @@ -23,9 +24,9 @@ impl TypeId { } } -impl From for TypeId { +impl From for TypeId { #[inline] - fn from(id: std::any::TypeId) -> Self { + fn from(id: core::any::TypeId) -> Self { Self(epaint::util::hash(id)) } } @@ -113,8 +114,8 @@ impl Clone for Element { } } -impl std::fmt::Debug for Element { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for Element { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match &self { Self::Value { value, .. } => f .debug_struct("Element::Value") @@ -314,7 +315,7 @@ fn from_ron_str(ron: &str) -> Option { Err(_err) => { log::warn!( "egui: Failed to deserialize {} from memory: {}, ron error: {:?}", - std::any::type_name::(), + core::any::type_name::(), _err, ron ); @@ -578,7 +579,7 @@ impl IdTypeMap { pub fn remove_temp(&mut self, id: Id) -> Option { let key = RawKey::new::(id); let mut element = self.map.remove(&key)?; - Some(std::mem::take(element.get_mut_temp()?)) + Some(core::mem::take(element.get_mut_temp()?)) } /// Remove a temporary value given a raw key. diff --git a/crates/egui/src/util/undoer.rs b/crates/egui/src/util/undoer.rs index a2eec6599..cbac10d76 100644 --- a/crates/egui/src/util/undoer.rs +++ b/crates/egui/src/util/undoer.rs @@ -67,8 +67,8 @@ pub struct Undoer { flux: Option>, } -impl std::fmt::Debug for Undoer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for Undoer { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let Self { undos, redos, .. } = self; f.debug_struct("Undoer") .field("undo count", &undos.len()) diff --git a/crates/egui/src/viewport.rs b/crates/egui/src/viewport.rs index 1b1e64fe1..e04f9d505 100644 --- a/crates/egui/src/viewport.rs +++ b/crates/egui/src/viewport.rs @@ -71,9 +71,8 @@ use std::sync::Arc; -use epaint::{Pos2, Vec2}; - use crate::{AsId, Context, Id, Ui}; +use epaint::{Pos2, Vec2}; // ---------------------------------------------------------------------------- @@ -121,13 +120,13 @@ pub struct ViewportId(pub Id); // We implement `PartialOrd` and `Ord` so we can use `ViewportId` in a `BTreeMap`, // which allows predicatable iteration order, frame-to-frame. impl PartialOrd for ViewportId { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for ViewportId { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { self.0.value().cmp(&other.0.value()) } } @@ -139,8 +138,8 @@ impl Default for ViewportId { } } -impl std::fmt::Debug for ViewportId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for ViewportId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { self.0.short_debug_format().fmt(f) } } @@ -199,8 +198,8 @@ impl IconData { } } -impl std::fmt::Debug for IconData { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for IconData { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("IconData") .field("width", &self.width) .field("height", &self.height) @@ -1276,7 +1275,7 @@ pub struct ViewportOutput { /// but if you haven't, you can use this instead. /// /// If the duration is zero, schedule a repaint immediately. - pub repaint_delay: std::time::Duration, + pub repaint_delay: core::time::Duration, } impl ViewportOutput { diff --git a/crates/egui/src/widget_text.rs b/crates/egui/src/widget_text.rs index 8670398fa..9281253ad 100644 --- a/crates/egui/src/widget_text.rs +++ b/crates/egui/src/widget_text.rs @@ -1,5 +1,5 @@ +use core::fmt::Formatter; use epaint::text::{IntoTag, TextFormat, VariationCoords}; -use std::fmt::Formatter; use std::{borrow::Cow, sync::Arc}; use crate::{ @@ -539,8 +539,8 @@ pub enum WidgetText { Galley(Arc), } -impl std::fmt::Debug for WidgetText { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for WidgetText { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { let text = self.text(); match self { Self::Text(_) => write!(f, "Text({text:?})"), @@ -558,6 +558,25 @@ impl Default for WidgetText { } impl WidgetText { + /// Override the font size. + /// + /// For [`Self::Galley`], this does nothing because it has already been laid out. + #[must_use] + pub fn size(self, size: f32) -> Self { + match self { + Self::Text(text) => RichText::new(text).size(size).into(), + Self::RichText(text) => Self::RichText(Arc::new(Arc::unwrap_or_clone(text).size(size))), + Self::LayoutJob(job) => { + let mut job = Arc::unwrap_or_clone(job); + for section in &mut job.sections { + section.format.font_id.size = size; + } + Self::LayoutJob(Arc::new(job)) + } + Self::Galley(galley) => Self::Galley(galley), + } + } + #[inline] pub fn is_empty(&self) -> bool { match self { @@ -750,14 +769,19 @@ impl WidgetText { .visuals .override_text_color .unwrap_or(crate::Color32::PLACEHOLDER); + + // We want the style overrides to take precedence over the fallback font + let font_id = FontSelection::default().resolve_with_fallback(style, fallback_font); + let line_height = ctx + .fonts_mut(|f| f.row_height(&font_id) + style.spacing.extra_text_line_spacing); + let mut layout_job = LayoutJob::simple_format( text, TextFormat { - // We want the style overrides to take precedence over the fallback font - font_id: FontSelection::default() - .resolve_with_fallback(style, fallback_font), + font_id, color, valign: default_valign, + line_height: Some(line_height), ..Default::default() }, ); diff --git a/crates/egui/src/widgets/drag_value.rs b/crates/egui/src/widgets/drag_value.rs index 7f1140bd9..eed264a82 100644 --- a/crates/egui/src/widgets/drag_value.rs +++ b/crates/egui/src/widgets/drag_value.rs @@ -3,8 +3,8 @@ use crate::{ Modifiers, NumExt as _, Response, RichText, Sense, TextEdit, TextWrapMode, Ui, Widget, WidgetInfo, emath, text, }; +use core::{cmp::Ordering, ops::RangeInclusive}; use emath::Vec2; -use std::{cmp::Ordering, ops::RangeInclusive}; // ---------------------------------------------------------------------------- @@ -25,6 +25,24 @@ fn set(get_set_value: &mut GetSetValue<'_>, value: f64) { (get_set_value)(Some(value)); } +// ---------------------------------------------------------------------------- + +/// What the user has typed into a [`DragValue`] that is being edited as text. +/// +/// Stored in [`crate::Memory::data`] between frames, because the text can be +/// something that doesn't (yet) parse to a number, e.g. `"1."` or `"-"`. +#[derive(Clone, Default)] +struct EditState { + /// The text the user is editing. + text: String, + + /// The value of the [`DragValue`] the last time we stored `text`. + /// + /// If the value has changed since then it was changed by something other than + /// this widget, and `text` is stale and must not be written back to the value. + value: f64, +} + /// A numeric value that you can change by dragging the number. More compact than a [`crate::Slider`]. /// /// ``` @@ -466,7 +484,7 @@ impl Widget for DragValue<'_> { }); if ui.memory_mut(|mem| mem.gained_focus(id)) { - ui.data_mut(|data| data.remove::(id)); + ui.data_mut(|data| data.remove::(id)); } let old_value = get(&mut get_set_value); @@ -524,7 +542,7 @@ impl Widget for DragValue<'_> { if old_value != value { set(&mut get_set_value, value); - ui.data_mut(|data| data.remove::(id)); + ui.data_mut(|data| data.remove::(id)); } let value_text = match custom_formatter { @@ -538,8 +556,13 @@ impl Widget for DragValue<'_> { let text_style = ui.style().drag_value_text_style.clone(); if ui.memory(|mem| mem.lost_focus(id)) && !ui.input(|i| i.key_pressed(Key::Escape)) { - let value_text = ui.data_mut(|data| data.remove_temp::(id)); - if let Some(value_text) = value_text { + let edit_state = ui.data_mut(|data| data.remove_temp::(id)); + // Ignore the text if the value was changed by something else while we were editing it, + // or we would revert that change. + if let Some(value_text) = edit_state + .filter(|edit_state| edit_state.value == old_value) + .map(|edit_state| edit_state.text) + { // We were editing the value as text last frame, but lost focus. // Make sure we applied the last text value: let parsed_value = parse(custom_parser.as_ref(), &value_text); @@ -552,9 +575,12 @@ impl Widget for DragValue<'_> { } let mut response = if is_kb_editing { + // Keep editing the text from last frame, unless the value was changed by + // something else in the meantime, in which case the text is stale. let mut value_text = ui - .data_mut(|data| data.remove_temp::(id)) - .unwrap_or_else(|| value_text.clone()); + .data_mut(|data| data.remove_temp::(id)) + .filter(|edit_state| edit_state.value == old_value) + .map_or_else(|| value_text.clone(), |edit_state| edit_state.text); let response = ui.add( TextEdit::singleline(&mut value_text) .clip_text(false) @@ -589,7 +615,13 @@ impl Widget for DragValue<'_> { set(&mut get_set_value, parsed_value); } } - ui.data_mut(|data| data.insert_temp(id, value_text)); + // Remember the value the text belongs to, so that next frame we can tell + // whether the value was changed by us or by something else. + let edit_state = EditState { + text: value_text, + value: get(&mut get_set_value), + }; + ui.data_mut(|data| data.insert_temp(id, edit_state)); response } else { atoms.map_atoms(|atom| { @@ -631,7 +663,7 @@ impl Widget for DragValue<'_> { } if response.clicked() { - ui.data_mut(|data| data.remove::(id)); + ui.data_mut(|data| data.remove::(id)); ui.memory_mut(|mem| mem.request_focus(id)); select_all_text(ui, id, response.id, &value_text); } else if response.dragged() { @@ -780,7 +812,7 @@ mod tests { macro_rules! total_assert_eq { ($a:expr, $b:expr) => { assert!( - matches!($a.total_cmp(&$b), std::cmp::Ordering::Equal), + matches!($a.total_cmp(&$b), core::cmp::Ordering::Equal), "{} != {}", $a, $b diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 30a5997ec..9bce8c4d3 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,4 +1,5 @@ -use std::{borrow::Cow, slice::Iter, sync::Arc, time::Duration}; +use core::{slice::Iter, time::Duration}; +use std::{borrow::Cow, sync::Arc}; use emath::{Align, Float as _, GuiRounding as _, NumExt as _, Rot2}; use epaint::{ @@ -607,8 +608,8 @@ pub enum ImageSource<'a> { }, } -impl std::fmt::Debug for ImageSource<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for ImageSource<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { ImageSource::Bytes { uri, .. } | ImageSource::Uri(uri) => uri.as_ref().fmt(f), ImageSource::Texture(st) => st.id.fmt(f), @@ -933,7 +934,7 @@ fn animated_image_frame_index(ctx: &Context, uri: &str) -> usize { /// Checks if uri is a gif file fn is_gif_uri(uri: &str) -> bool { - uri.ends_with(".gif") || uri.contains(".gif#") + crate::load::has_extension(uri, "gif") } /// Checks if bytes are gifs @@ -943,7 +944,7 @@ pub fn has_gif_magic_header(bytes: &[u8]) -> bool { /// Checks if uri is a webp file fn is_webp_uri(uri: &str) -> bool { - uri.ends_with(".webp") || uri.contains(".webp#") + crate::load::has_extension(uri, "webp") } /// Checks if bytes are webp diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index 21346d64a..ea2e17046 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -6,6 +6,12 @@ use crate::{Response, Ui}; +/// A dynamically dispatched [`Widget`]. +/// +/// [`Widget`] is not dyn compatible because [`Widget::ui`] takes `self` by value. +/// This alias uses a closure, which implements [`Widget`]. +pub type BoxedWidget<'a> = Box Response + 'a>; + mod button; mod checkbox; pub mod color_picker; @@ -63,6 +69,20 @@ pub trait Widget { /// /// Tip: you can `impl Widget for &mut YourObject { }`. fn ui(self, ui: &mut Ui) -> Response; + + /// Box this widget for dynamic dispatch. + #[inline] + fn boxed<'a>(self) -> BoxedWidget<'a> + where + Self: Sized + 'a, + { + Box::new(move |ui: &mut Ui| ui.add(self)) + } +} + +#[test] +fn widgets_can_be_boxed() { + let _: BoxedWidget<'static> = Button::new("boxed").boxed(); } /// This enables functions that return `impl Widget`, so that you can diff --git a/crates/egui/src/widgets/progress_bar.rs b/crates/egui/src/widgets/progress_bar.rs index 444f6088c..ed697cc4d 100644 --- a/crates/egui/src/widgets/progress_bar.rs +++ b/crates/egui/src/widgets/progress_bar.rs @@ -163,7 +163,7 @@ impl Widget for ProgressBar { if animate && !has_custom_cr { let n_points = 20; let time = ui.input(|i| i.time); - let start_angle = time * std::f64::consts::TAU; + let start_angle = time * core::f64::consts::TAU; let end_angle = start_angle + 240f64.to_radians() * time.sin(); let circle_radius = half_height - 2.0; let points: Vec = (0..n_points) diff --git a/crates/egui/src/widgets/slider.rs b/crates/egui/src/widgets/slider.rs index b5ce4fc55..796489421 100644 --- a/crates/egui/src/widgets/slider.rs +++ b/crates/egui/src/widgets/slider.rs @@ -1,6 +1,6 @@ #![expect(clippy::needless_pass_by_value)] // False positives with `impl ToString` -use std::ops::RangeInclusive; +use core::ops::RangeInclusive; use crate::{ Color32, DragValue, EventFilter, Key, Label, MINUS_CHAR_STR, NumExt as _, Pos2, Rangef, Rect, diff --git a/crates/egui/src/widgets/spinner.rs b/crates/egui/src/widgets/spinner.rs index 25820a06e..d378fd3b1 100644 --- a/crates/egui/src/widgets/spinner.rs +++ b/crates/egui/src/widgets/spinner.rs @@ -45,7 +45,7 @@ impl Spinner { let radius = (rect.height().min(rect.width()) / 2.0) - 2.0; let n_points = (radius.round() as u32).clamp(8, 128); let time = ui.input(|i| i.time); - let start_angle = time * std::f64::consts::TAU; + let start_angle = time * core::f64::consts::TAU; let end_angle = start_angle + 240f64.to_radians() * time.sin(); let points: Vec = (0..n_points) .map(|i| { diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index ccc3a56c8..41220ed81 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -5,9 +5,10 @@ use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor}; use crate::{ Align, Align2, AsIdSalt, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context, - CursorIcon, Event, EventFilter, FontSelection, Frame, Id, IdSalt, ImeEvent, IntoAtoms, - IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, - SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint, + CursorIcon, Event, EventFilter, FontSelection, Frame, IMEPurpose, Id, IdSalt, ImeEvent, + IntoAtoms, IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, + Sense, SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, + epaint, os::OperatingSystem, output::OutputEvent, response, @@ -470,6 +471,8 @@ impl TextEdit<'_> { let font_id = font_selection.resolve(ui.style()); let row_height = ui.fonts_mut(|f| f.row_height(&font_id)); + let line_height = row_height + ui.spacing().extra_text_line_spacing; + const MIN_WIDTH: f32 = 24.0; // Never make a [`TextEdit`] more narrow than this. let available_width = ui.available_width().at_least(MIN_WIDTH); let desired_width = desired_width @@ -488,12 +491,17 @@ impl TextEdit<'_> { layout_job.halign = align.x(); // We want to keep the trailing whitespace, since hiding it feels really weird when typing layout_job.keep_trailing_whitespace = true; + + for section in &mut layout_job.sections { + section.format.line_height = Some(line_height); + } + ui.fonts_mut(|f| f.layout_job(layout_job)) }; let layouter = layouter.unwrap_or(&mut default_layouter); - let min_inner_height = (desired_height_rows.at_least(1) as f32) * row_height; + let min_inner_height = (desired_height_rows.at_least(1) as f32) * line_height; let id = id.unwrap_or_else(|| { if let Some(id_salt) = id_salt { @@ -527,6 +535,19 @@ impl TextEdit<'_> { let mut cursor_range = None; let mut prev_cursor_range = None; + let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id)); + if !owns_ime_events { + state.cursor_purpose = TextEditCursorPurpose::Selection; + if !state.cursor.is_empty() { + state.cursor.set_char_range( + state + .cursor + .char_range() + .map(|r| CCursorRange::one(r.primary)), + ); + } + } + let mut text_changed = false; let text_mutable = text.is_mutable(); @@ -547,14 +568,17 @@ impl TextEdit<'_> { text, galley, layouter, - id, - wrap_width, - multiline, - password, - default_cursor_range, - char_limit, - event_filter, - return_key, + &EventsOptions { + id, + wrap_width, + multiline, + password, + default_cursor_range, + owns_ime_events, + char_limit, + event_filter, + return_key, + }, ); if changed { @@ -605,9 +629,10 @@ impl TextEdit<'_> { first = false; } - // The hint text should be shown left top instead of centered (important for - // multi line text edits) - atoms.push_right(atom.atom_align(Align2::LEFT_TOP)); + // Align the hint text the same as the input text so the hint, the + // cursor, and the typed text all share one alignment. The default + // `align` is `LEFT_TOP`, which keeps multi line text edits unchanged. + atoms.push_right(atom.atom_align(align)); } // Calculate the empty galley, so it can be read later. The available width is @@ -772,6 +797,7 @@ impl TextEdit<'_> { if did_interact || response.clicked() { ui.memory_mut(|mem| mem.request_focus(response.id)); + state.cursor_purpose = TextEditCursorPurpose::Selection; state.last_interaction_time = ui.input(|i| i.time); } @@ -907,6 +933,11 @@ impl TextEdit<'_> { .unwrap_or_default(); ui.output_mut(|o| { o.ime = Some(crate::output::IMEOutput { + purpose: if password { + IMEPurpose::Password + } else { + IMEPurpose::Normal + }, rect: to_global * inner_rect, cursor_rect: to_global * primary_cursor_rect, should_interrupt_composition: false, @@ -977,7 +1008,7 @@ impl TextEdit<'_> { fn mask_if_password(is_password: bool, text: &str) -> String { fn mask_password(text: &str) -> String { - std::iter::repeat_n( + core::iter::repeat_n( epaint::text::PASSWORD_REPLACEMENT_CHAR, text.chars().count(), ) @@ -993,23 +1024,41 @@ fn mask_if_password(is_password: bool, text: &str) -> String { // ---------------------------------------------------------------------------- +/// Bundles parameters for [`events`] to avoid `clippy::too_many_arguments` and +/// `clippy::fn_params_excessive_bools`. +struct EventsOptions { + id: Id, + wrap_width: f32, + multiline: bool, + password: bool, + default_cursor_range: CCursorRange, + owns_ime_events: bool, + char_limit: usize, + event_filter: EventFilter, + return_key: Option, +} + /// Check for (keyboard) events to edit the cursor and/or text. -#[expect(clippy::too_many_arguments)] fn events( ui: &crate::Ui, state: &mut TextEditState, text: &mut dyn TextBuffer, galley: &mut Arc, layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc, - id: Id, - wrap_width: f32, - multiline: bool, - password: bool, - default_cursor_range: CCursorRange, - char_limit: usize, - event_filter: EventFilter, - return_key: Option, + opts: &EventsOptions, ) -> (bool, CCursorRange) { + let EventsOptions { + id, + wrap_width, + multiline, + password, + default_cursor_range, + owns_ime_events, + char_limit, + event_filter, + return_key, + } = *opts; + let os = ui.os(); let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range); @@ -1031,9 +1080,13 @@ fn events( let events = ui.input(|i| i.filtered_events(&event_filter)); - let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id)); - if !owns_ime_events { - state.cursor_purpose = TextEditCursorPurpose::Selection; + enum CursorMutation { + Selection(CCursorRange), + ImeComposition { + cursor_range: CCursorRange, + active_range: Option>, + }, + ImeCompositionCursorRange(CCursorRange), } for event in &events { @@ -1052,7 +1105,9 @@ fn events( None } else { copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned()); - Some(CCursorRange::one(text.delete_selected(&cursor_range))) + Some(CursorMutation::Selection(CCursorRange::one( + text.delete_selected(&cursor_range), + ))) } } Event::Paste(text_to_insert) => { @@ -1067,7 +1122,7 @@ fn events( text.insert_text_at(&mut ccursor, &single_line, char_limit); } - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } } Event::Text(text_to_insert) => { @@ -1077,7 +1132,7 @@ fn events( text.insert_text_at(&mut ccursor, text_to_insert, char_limit); - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { None } @@ -1095,7 +1150,7 @@ fn events( } else { text.insert_text_at(&mut ccursor, "\t", char_limit); } - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } Event::Key { key, @@ -1110,7 +1165,7 @@ fn events( let mut ccursor = text.delete_selected(&cursor_range); text.insert_text_at(&mut ccursor, "\n", char_limit); // TODO(emilk): if code editor, auto-indent by same leading tabs, + one if the lines end on an opening bracket - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { ui.memory_mut(|mem| mem.surrender_focus(id)); // End input with enter break; @@ -1132,7 +1187,7 @@ fn events( .redo(&(cursor_range, text.as_str().to_owned())) { text.replace_with(redo_txt); - Some(*redo_ccursor_range) + Some(CursorMutation::Selection(*redo_ccursor_range)) } else { None } @@ -1150,7 +1205,7 @@ fn events( .undo(&(cursor_range, text.as_str().to_owned())) { text.replace_with(undo_txt); - Some(*undo_ccursor_range) + Some(CursorMutation::Selection(*undo_ccursor_range)) } else { None } @@ -1161,8 +1216,8 @@ fn events( key, pressed: true, .. - } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key), - + } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key) + .map(CursorMutation::Selection), Event::Ime(ime_event) if owns_ime_events => { /// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")` /// might be emitted from different integrations to signify that @@ -1235,22 +1290,20 @@ fn events( text: preedit_text, active_range_chars, } => { - state.cursor_purpose = if preedit_text.is_empty() { - TextEditCursorPurpose::Selection + let mut ccursor = clear_preedit_text(text, &cursor_range); + + if preedit_text.is_empty() { + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { - TextEditCursorPurpose::ImeComposition { + let start_cursor = ccursor; + text.insert_text_at(&mut ccursor, preedit_text, char_limit); + Some(CursorMutation::ImeComposition { + cursor_range: CCursorRange::two(start_cursor, ccursor), active_range: active_range_chars.clone().map(|range| { CCursor::new(range.start)..CCursor::new(range.end) }), - } - }; - let mut ccursor = clear_preedit_text(text, &cursor_range); - - let start_cursor = ccursor; - if !preedit_text.is_empty() { - text.insert_text_at(&mut ccursor, preedit_text, char_limit); + }) } - Some(CCursorRange::two(start_cursor, ccursor)) } ImeEvent::Commit(commit_text) => { state.cursor_purpose = TextEditCursorPurpose::Selection; @@ -1260,22 +1313,43 @@ fn events( text.insert_text_at(&mut ccursor, commit_text, char_limit); } - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } + ImeEvent::DeleteSurrounding { + before_chars, + after_chars, + } => Some(CursorMutation::ImeCompositionCursorRange( + text.delete_surrounding_chars(cursor_range, *before_chars, *after_chars), + )), } } _ => None, }; - if let Some(new_ccursor_range) = did_mutate_text { + if let Some(cursor_mutation) = did_mutate_text { any_change = true; // Layout again to avoid frame delay, and to keep `text` and `galley` in sync. *galley = layouter(ui, text, wrap_width); // Set cursor_range using new galley: - cursor_range = new_ccursor_range; + match cursor_mutation { + CursorMutation::Selection(new_cursor_range) => { + cursor_range = new_cursor_range; + state.cursor_purpose = TextEditCursorPurpose::Selection; + } + CursorMutation::ImeComposition { + cursor_range: new_cursor_range, + active_range, + } => { + cursor_range = new_cursor_range; + state.cursor_purpose = TextEditCursorPurpose::ImeComposition { active_range }; + } + CursorMutation::ImeCompositionCursorRange(new_cursor_range) => { + cursor_range = new_cursor_range; + } + } } } diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index 61ffb21ea..d17532753 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -95,7 +95,7 @@ pub(crate) enum TextEditCursorPurpose { /// irrelevant. /// /// When `None`, no active range is displayed. - active_range: Option>, + active_range: Option>, }, } diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs index 848b993d0..f09843e86 100644 --- a/crates/egui/src/widgets/text_edit/text_buffer.rs +++ b/crates/egui/src/widgets/text_edit/text_buffer.rs @@ -1,4 +1,5 @@ -use std::{borrow::Cow, ops::Range}; +use core::ops::Range; +use std::borrow::Cow; use epaint::{ Galley, @@ -154,6 +155,30 @@ pub trait TextBuffer { self.delete_selected_ccursor_range([min_ccursor, max_ccursor]) } + /// Deletes characters surrounding the current cursor range. + /// + /// Removes `before_chars` characters before the selection start and + /// `after_chars` characters after the selection end. + /// The returned [`CCursorRange`] is adjusted to account for the removed + /// characters before the selection. + fn delete_surrounding_chars( + &mut self, + mut cursor_range: CCursorRange, + before_chars: usize, + after_chars: usize, + ) -> CCursorRange { + let [min, max] = cursor_range.sorted_cursors(); + if after_chars > 0 { + self.delete_selected_ccursor_range([max, max + after_chars]); + } + if before_chars > 0 { + self.delete_selected_ccursor_range([min - before_chars, min]); + cursor_range.primary -= before_chars; + cursor_range.secondary -= before_chars; + } + cursor_range + } + fn delete_paragraph_before_cursor( &mut self, galley: &Galley, @@ -213,7 +238,7 @@ pub trait TextBuffer { /// } /// } /// ``` - fn type_id(&self) -> std::any::TypeId; + fn type_id(&self) -> core::any::TypeId; } impl TextBuffer for String { @@ -258,11 +283,11 @@ impl TextBuffer for String { } fn take(&mut self) -> String { - std::mem::take(self) + core::mem::take(self) } - fn type_id(&self) -> std::any::TypeId { - std::any::TypeId::of::() + fn type_id(&self) -> core::any::TypeId { + core::any::TypeId::of::() } } @@ -292,11 +317,11 @@ impl TextBuffer for Cow<'_, str> { } fn take(&mut self) -> String { - std::mem::take(self).into_owned() + core::mem::take(self).into_owned() } - fn type_id(&self) -> std::any::TypeId { - std::any::TypeId::of::>() + fn type_id(&self) -> core::any::TypeId { + core::any::TypeId::of::>() } } @@ -316,7 +341,113 @@ impl TextBuffer for &str { fn delete_char_range(&mut self, _ch_range: Range) {} - fn type_id(&self) -> std::any::TypeId { - std::any::TypeId::of::<&str>() + fn type_id(&self) -> core::any::TypeId { + core::any::TypeId::of::<&str>() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn txt_n_sel(input: &str) -> (String, CCursorRange) { + assert!( + input.matches('[').count() == 1 && input.matches(']').count() == 1, + "`input` must contain exactly one `[` and one `]` to indicate the selection (cursor range)" + ); + let mut primary_index = input.chars().position(|c| c == ']').unwrap(); + let mut secondary_index = input.chars().position(|c| c == '[').unwrap(); + let text = input.replace(['[', ']'], ""); + if primary_index > secondary_index { + primary_index -= 1; + } else { + secondary_index -= 1; + } + let cursor_range = CCursorRange { + primary: CCursor::new(primary_index), + secondary: CCursor::new(secondary_index), + h_pos: None, + }; + (text, cursor_range) + } + + #[test] + fn test_txt_n_sel() { + assert_eq!( + txt_n_sel("<>"), + ("<>".to_owned(), CCursorRange::one(CCursor::new(3))) + ); + assert_eq!( + txt_n_sel("<>"), + ( + "<>".to_owned(), + CCursorRange::two(CCursor::new(3), CCursor::new(4)) + ) + ); + assert_eq!( + txt_n_sel("<<左[_]右>>"), + ( + "<<左_右>>".to_owned(), + CCursorRange::two(CCursor::new(3), CCursor::new(4)) + ) + ); + assert_eq!( + txt_n_sel("<>"), + ( + "<>".to_owned(), + CCursorRange::two(CCursor::new(4), CCursor::new(3)) + ) + ); + } + + #[test] + fn test_delete_surrounding_chars() { + fn test_case( + (mut input_text, input_cursor_range): (String, CCursorRange), + before_chars: usize, + after_chars: usize, + (expected_text, expected_cursor_range): (String, CCursorRange), + ) { + let new_cursor_range = + input_text.delete_surrounding_chars(input_cursor_range, before_chars, after_chars); + assert_eq!(input_text, expected_text); + assert_eq!(new_cursor_range, expected_cursor_range); + } + + // 1 byte per char + test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<[]>>")); + test_case(txt_n_sel("<>"), 1, 0, txt_n_sel("<<[_]R>>")); + test_case(txt_n_sel("<>"), 0, 1, txt_n_sel("<>")); + test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<[__]>>")); + test_case(txt_n_sel("<>"), 2, 2, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<>"), 1, 0, txt_n_sel("<<]_[R>>")); + test_case(txt_n_sel("<>"), 0, 1, txt_n_sel("<>")); + test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<]_[>>")); + + // 2 bytes per char: `˻` = `0xCB 0xBB`, `˼` = `0xCB 0xBC` + test_case(txt_n_sel("<<˻[]˼>>"), 1, 1, txt_n_sel("<<[]>>")); + test_case(txt_n_sel("<<˻[_]˼>>"), 1, 0, txt_n_sel("<<[_]˼>>")); + test_case(txt_n_sel("<<˻[_]˼>>"), 0, 1, txt_n_sel("<<˻[_]>>")); + test_case(txt_n_sel("<<˻[_]˼>>"), 1, 1, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<<˻[__]˼>>"), 1, 1, txt_n_sel("<<[__]>>")); + test_case(txt_n_sel("<<˻˻[_]˼˼>>"), 2, 2, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<<˻]_[˼>>"), 1, 0, txt_n_sel("<<]_[˼>>")); + test_case(txt_n_sel("<<˻]_[˼>>"), 0, 1, txt_n_sel("<<˻]_[>>")); + test_case(txt_n_sel("<<˻]_[˼>>"), 1, 1, txt_n_sel("<<]_[>>")); + + // 3 bytes per char: `左` = `0xE5 0xB7 0xA6`, `右` = `0xE5 0x8F 0xB3` + test_case(txt_n_sel("<<左[]右>>"), 1, 1, txt_n_sel("<<[]>>")); + test_case(txt_n_sel("<<左[_]右>>"), 1, 0, txt_n_sel("<<[_]右>>")); + test_case(txt_n_sel("<<左[_]右>>"), 0, 1, txt_n_sel("<<左[_]>>")); + test_case(txt_n_sel("<<左[_]右>>"), 1, 1, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<<左[__]右>>"), 1, 1, txt_n_sel("<<[__]>>")); + test_case(txt_n_sel("<<左左[_]右右>>"), 2, 2, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<<左]_[右>>"), 1, 0, txt_n_sel("<<]_[右>>")); + test_case(txt_n_sel("<<左]_[右>>"), 0, 1, txt_n_sel("<<左]_[>>")); + test_case(txt_n_sel("<<左]_[右>>"), 1, 1, txt_n_sel("<<]_[>>")); + + // mixed + test_case(txt_n_sel("<>"), 3, 3, txt_n_sel("<<[_]>>")); } } diff --git a/crates/egui_demo_app/src/accessibility_inspector.rs b/crates/egui_demo_app/src/accessibility_inspector.rs index 91da6c653..c28bde2fa 100644 --- a/crates/egui_demo_app/src/accessibility_inspector.rs +++ b/crates/egui_demo_app/src/accessibility_inspector.rs @@ -1,4 +1,4 @@ -use std::mem; +use core::mem; use accesskit::{Action, ActionRequest}; use accesskit_consumer::{FilterResult, Node, NodeId, Tree, TreeChangeHandler}; @@ -168,7 +168,7 @@ impl AccessibilityInspectorPlugin { ui.horizontal_wrapped(|ui| { // Iterate through all possible actions via the `Action::n` helper. let mut current_action = 0; - let all_actions = std::iter::from_fn(|| { + let all_actions = core::iter::from_fn(|| { let action = Action::n(current_action); current_action += 1; action diff --git a/crates/egui_demo_app/src/apps/custom3d_wgpu.rs b/crates/egui_demo_app/src/apps/custom3d_wgpu.rs index b6ba60df9..97ba1d3d2 100644 --- a/crates/egui_demo_app/src/apps/custom3d_wgpu.rs +++ b/crates/egui_demo_app/src/apps/custom3d_wgpu.rs @@ -1,6 +1,6 @@ #![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps -use std::num::NonZeroU64; +use core::num::NonZeroU64; use eframe::{ egui_wgpu::wgpu::util::DeviceExt as _, diff --git a/crates/egui_demo_app/src/apps/fractal_clock.rs b/crates/egui_demo_app/src/apps/fractal_clock.rs index 43ed3fb8b..783e8d2c1 100644 --- a/crates/egui_demo_app/src/apps/fractal_clock.rs +++ b/crates/egui_demo_app/src/apps/fractal_clock.rs @@ -1,10 +1,10 @@ +use core::f32::consts::TAU; use egui::{ Color32, Painter, Pos2, Rect, Shape, Stroke, Ui, Vec2, containers::{CollapsingHeader, Frame}, emath, pos2, widgets::Slider, }; -use std::f32::consts::TAU; #[derive(PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -202,7 +202,7 @@ impl FractalClock { } } - std::mem::swap(&mut nodes, &mut new_nodes); + core::mem::swap(&mut nodes, &mut new_nodes); } self.line_count = shapes.len(); painter.extend(shapes); diff --git a/crates/egui_demo_app/src/backend_panel.rs b/crates/egui_demo_app/src/backend_panel.rs index 4d6039a85..0fb7eee0f 100644 --- a/crates/egui_demo_app/src/backend_panel.rs +++ b/crates/egui_demo_app/src/backend_panel.rs @@ -160,9 +160,9 @@ impl BackendPanel { { log::info!("Waiting 2s before requesting repaint…"); let ctx = ui.ctx().clone(); - call_after_delay(std::time::Duration::from_secs(2), move || { + call_after_delay(core::time::Duration::from_secs(2), move || { log::info!("Request a repaint in 3s…"); - ctx.request_repaint_after(std::time::Duration::from_secs(3)); + ctx.request_repaint_after(core::time::Duration::from_secs(3)); }); } @@ -525,7 +525,7 @@ impl EguiWindows { // ---------------------------------------------------------------------------- #[cfg(not(target_arch = "wasm32"))] -fn call_after_delay(delay: std::time::Duration, f: impl FnOnce() + Send + 'static) { +fn call_after_delay(delay: core::time::Duration, f: impl FnOnce() + Send + 'static) { std::thread::Builder::new() .name("call_after_delay".to_owned()) .spawn(move || { @@ -536,7 +536,7 @@ fn call_after_delay(delay: std::time::Duration, f: impl FnOnce() + Send + 'stati } #[cfg(target_arch = "wasm32")] -fn call_after_delay(delay: std::time::Duration, f: impl FnOnce() + Send + 'static) { +fn call_after_delay(delay: core::time::Duration, f: impl FnOnce() + Send + 'static) { #![expect(clippy::unwrap_used)] use wasm_bindgen::prelude::*; diff --git a/crates/egui_demo_app/src/main.rs b/crates/egui_demo_app/src/main.rs index 38da5751d..b487ab99d 100644 --- a/crates/egui_demo_app/src/main.rs +++ b/crates/egui_demo_app/src/main.rs @@ -38,7 +38,7 @@ fn main() { }); for loud_crate in ["naga", "wgpu_core", "wgpu_hal"] { if !rust_log.contains(&format!("{loud_crate}=")) { - use std::fmt::Write as _; + use core::fmt::Write as _; write!(rust_log, ",{loud_crate}=warn").ok(); } } @@ -103,7 +103,7 @@ fn start_puffin_server() { // We can store the server if we want, but in this case we just want // it to keep running. Dropping it closes the server, so let's not drop it! #[expect(clippy::mem_forget)] - std::mem::forget(puffin_server); + core::mem::forget(puffin_server); } Err(err) => { log::error!("Failed to start puffin server: {err}"); diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 8ed3086d1..8801bba23 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -127,8 +127,8 @@ impl Anchor { } } -impl std::fmt::Display for Anchor { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for Anchor { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let mut name = format!("{self:?}"); name.make_ascii_lowercase(); f.write_str(&name) @@ -183,7 +183,7 @@ pub struct WrapApp { #[cfg(any(feature = "glow", feature = "wgpu"))] custom3d: Option, - dropped_files: Vec, + dropped_files: Vec, } impl WrapApp { @@ -361,7 +361,8 @@ impl WrapApp { let mut cmd = Command::Nothing; egui::Panel::left("backend_panel") - .resizable(false) + .resizable(true) + .size_range(280..=400) .show_collapsible(ui, &mut is_open, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { @@ -472,8 +473,8 @@ impl WrapApp { } fn ui_file_drag_and_drop(&mut self, ctx: &egui::Context) { + use core::fmt::Write as _; use egui::{Align2, Color32, Id, LayerId, Order, TextStyle}; - use std::fmt::Write as _; // Preview hovering files: if !ctx.input(|i| i.raw.hovered_files.is_empty()) { @@ -519,25 +520,23 @@ impl WrapApp { .open(&mut open) .show(ctx, |ui| { for file in &self.dropped_files { - let mut info = if let Some(path) = &file.path { - path.display().to_string() - } else if file.name.is_empty() { - "???".to_owned() - } else { - file.name.clone() - }; + #[cfg(not(target_arch = "wasm32"))] + let info = file.path().display().to_string(); - let mut additional_info = vec![]; - if !file.mime.is_empty() { - additional_info.push(format!("type: {}", file.mime)); - } - if let Some(bytes) = &file.bytes { - additional_info.push(format!("{} bytes", bytes.len())); - } - if !additional_info.is_empty() { - use std::fmt::Write as _; - write!(info, " ({})", additional_info.join(", ")).ok(); - } + // The size and mime-type are free to read; the contents are not, + // so we never touch them here. + #[cfg(target_arch = "wasm32")] + let info = { + let Some(web_file) = file.web_file() else { + continue; + }; + let (name, mime) = (web_file.name(), web_file.type_()); + if mime.is_empty() { + format!("{name} ({} bytes)", web_file.size()) + } else { + format!("{name} ({} bytes, type: {mime})", web_file.size()) + } + }; ui.label(info); } diff --git a/crates/egui_demo_app/tests/snapshots/clock.png b/crates/egui_demo_app/tests/snapshots/clock.png index 347bdd568..87c4b5374 100644 --- a/crates/egui_demo_app/tests/snapshots/clock.png +++ b/crates/egui_demo_app/tests/snapshots/clock.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:288e11a1fa684575155826a760d5aecc5855e1f4b68bc8954441bf3ac015ee84 -size 335175 +oid sha256:dda3ad81551d0001bb9d3614a266010e44c8c18700964d8734c3e825672d9383 +size 334759 diff --git a/crates/egui_demo_app/tests/snapshots/custom3d.png b/crates/egui_demo_app/tests/snapshots/custom3d.png index 6778d8b92..fc393aa9e 100644 --- a/crates/egui_demo_app/tests/snapshots/custom3d.png +++ b/crates/egui_demo_app/tests/snapshots/custom3d.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d674918c635bfc865043f2123c0f5d4a671dd21ba7b878c056e817b19f2e8f00 -size 92770 +oid sha256:9035687ecd3aae0d8639000f091592d69acec0e73e7ba17e04c9794c5321cb1e +size 92771 diff --git a/crates/egui_demo_app/tests/snapshots/easymarkeditor.png b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png index 81485c09a..7f908cbea 100644 --- a/crates/egui_demo_app/tests/snapshots/easymarkeditor.png +++ b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f066e712888a57b5c5ca6ccd6e138c933ab04acc44a3fce5912cfe47852c672 -size 168875 +oid sha256:418f9bc7eb13dd32ce2bba8e4b8003b095dd00aa8c30f1640ea0f94f87a085eb +size 168430 diff --git a/crates/egui_demo_app/tests/snapshots/imageviewer.png b/crates/egui_demo_app/tests/snapshots/imageviewer.png index 80725c619..7381f2737 100644 --- a/crates/egui_demo_app/tests/snapshots/imageviewer.png +++ b/crates/egui_demo_app/tests/snapshots/imageviewer.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1cf4c34af7b69cd8220b11ff7e355ddf8d7b52a43a60a1748abf9cc1d5c7da9b -size 98869 +oid sha256:8d278d0d879e8cb44c4c2e65bdee4441616abcf3507adc37959ccf042991b31c +size 98780 diff --git a/crates/egui_demo_lib/benches/benchmark.rs b/crates/egui_demo_lib/benches/benchmark.rs index d9186b80a..6b0eb5d4c 100644 --- a/crates/egui_demo_lib/benches/benchmark.rs +++ b/crates/egui_demo_lib/benches/benchmark.rs @@ -1,4 +1,4 @@ -use std::fmt::Write as _; +use core::fmt::Write as _; use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; @@ -28,18 +28,21 @@ pub fn criterion_benchmark(c: &mut Criterion) { // The most end-to-end benchmark. c.bench_function("demo_with_tessellate__realistic", |b| { b.iter(|| { - let full_output = ctx.run_ui(RawInput::default(), |ui| { + let mut full_output = ctx.run_ui(RawInput::default(), |ui| { demo_windows.ui(ui); }); - ctx.tessellate(full_output.shapes, full_output.pixels_per_point) + ctx.tessellate(full_output.shapes, full_output.pixels_per_point); + + full_output.textures_delta.clear(); // Don't panic on drop with unapplied deltas }); }); c.bench_function("demo_no_tessellate", |b| { b.iter(|| { - ctx.run_ui(RawInput::default(), |ui| { + let output = ctx.run_ui(RawInput::default(), |ui| { demo_windows.ui(ui); - }) + }); + output.drop_without_applying_deltas(); }); }); @@ -49,6 +52,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { c.bench_function("demo_only_tessellate", |b| { b.iter(|| ctx.tessellate(full_output.shapes.clone(), full_output.pixels_per_point)); }); + full_output.drop_without_applying_deltas(); } if false { @@ -66,7 +70,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { { let ctx = egui::Context::default(); - let _ = ctx.run_ui(RawInput::default(), |ui| { + let output = ctx.run_ui(RawInput::default(), |ui| { c.bench_function("label &str", |b| { b.iter_batched_ref( || create_benchmark_ui(ui), @@ -86,11 +90,12 @@ pub fn criterion_benchmark(c: &mut Criterion) { ); }); }); + output.drop_without_applying_deltas(); } { let ctx = egui::Context::default(); - let _ = ctx.run_ui(RawInput::default(), |ui| { + let output = ctx.run_ui(RawInput::default(), |ui| { let mut group = c.benchmark_group("button"); // To ensure we have a valid image, let's use the font texture. The size @@ -134,6 +139,8 @@ pub fn criterion_benchmark(c: &mut Criterion) { ); }); }); + + output.drop_without_applying_deltas(); } { diff --git a/crates/egui_demo_lib/src/demo/code_editor.rs b/crates/egui_demo_lib/src/demo/code_editor.rs index 869194114..c79cdfd47 100644 --- a/crates/egui_demo_lib/src/demo/code_editor.rs +++ b/crates/egui_demo_lib/src/demo/code_editor.rs @@ -61,7 +61,7 @@ impl crate::View for CodeEditor { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label("Compile the demo with the "); - ui.code("syntax_highlighting"); + ui.code("syntect"); ui.label(" feature to enable more accurate syntax highlighting using "); ui.hyperlink_to("syntect", "https://github.com/trishume/syntect"); ui.label("."); diff --git a/crates/egui_demo_lib/src/demo/dancing_strings.rs b/crates/egui_demo_lib/src/demo/dancing_strings.rs index 137ecc2b9..7846e0ee4 100644 --- a/crates/egui_demo_lib/src/demo/dancing_strings.rs +++ b/crates/egui_demo_lib/src/demo/dancing_strings.rs @@ -61,7 +61,7 @@ impl crate::View for DancingStrings { .map(|i| { let t = i as f64 / (n as f64); let amp = (time * speed * mode).sin() / mode; - let y = amp * (t * std::f64::consts::TAU / 2.0 * mode).sin(); + let y = amp * (t * core::f64::consts::TAU / 2.0 * mode).sin(); to_screen * pos2(t as f32, y as f32) }) .collect(); diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index a874de5b6..fc15aac16 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -13,7 +13,7 @@ struct DemoGroup { demos: Vec>, } -impl std::ops::Add for DemoGroup { +impl core::ops::Add for DemoGroup { type Output = Self; fn add(self, other: Self) -> Self { diff --git a/crates/egui_demo_lib/src/demo/misc_demo_window.rs b/crates/egui_demo_lib/src/demo/misc_demo_window.rs index d035f5186..8b91fbc64 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -41,7 +41,7 @@ impl Default for MiscDemoWindow { dummy_bool: false, dummy_usize: 0, - checklist: std::array::from_fn(|i| i == 0), + checklist: core::array::from_fn(|i| i == 0), } } } @@ -143,9 +143,7 @@ impl View for MiscDemoWindow { ) .changed() { - for check in &mut self.checklist { - *check = all_checked; - } + self.checklist.fill(all_checked); } for (i, checked) in self.checklist.iter_mut().enumerate() { ui.checkbox(checked, format!("Item {}", i + 1)); @@ -186,7 +184,7 @@ impl View for MiscDemoWindow { .show(ui, |ui| { ui.horizontal(|ui| { ui.label("You can pretty easily paint your own small icons:"); - use std::f32::consts::TAU; + use core::f32::consts::TAU; let size = Vec2::splat(16.0); let (response, painter) = ui.allocate_painter(size, Sense::hover()); let rect = response.rect; @@ -266,7 +264,7 @@ pub struct Widgets { impl Default for Widgets { fn default() -> Self { Self { - angle: std::f32::consts::TAU / 3.0, + angle: core::f32::consts::TAU / 3.0, password: "hunter2".to_owned(), } } @@ -284,7 +282,7 @@ impl Widgets { ui.horizontal(|ui| { ui.label("An angle:"); ui.drag_angle(angle); - ui.label(format!("≈ {:.3}τ", *angle / std::f32::consts::TAU)) + ui.label(format!("≈ {:.3}τ", *angle / core::f32::consts::TAU)) .on_hover_text("Each τ represents one turn (τ = 2π)"); }) .response @@ -423,7 +421,7 @@ impl Repaint { ctx.request_repaint(); } if self.repaint_after_delay { - ctx.request_repaint_after(std::time::Duration::from_secs_f64(self.delay)); + ctx.request_repaint_after(core::time::Duration::from_secs_f64(self.delay)); } } } @@ -625,7 +623,7 @@ impl Tree { return Action::Delete; } - self.0 = std::mem::take(self) + self.0 = core::mem::take(self) .0 .into_iter() .enumerate() @@ -899,7 +897,7 @@ impl Default for TextRotation { impl TextRotation { pub fn ui(&mut self, ui: &mut Ui) { - ui.add(Slider::new(&mut self.angle, 0.0..=2.0 * std::f32::consts::PI).text("angle")); + ui.add(Slider::new(&mut self.angle, 0.0..=2.0 * core::f32::consts::PI).text("angle")); let default_color = if ui.visuals().dark_mode { Color32::LIGHT_GRAY diff --git a/crates/egui_demo_lib/src/demo/panels.rs b/crates/egui_demo_lib/src/demo/panels.rs index 8f2e811fa..00d19efdd 100644 --- a/crates/egui_demo_lib/src/demo/panels.rs +++ b/crates/egui_demo_lib/src/demo/panels.rs @@ -94,10 +94,10 @@ impl crate::View for Panels { bottom, egui::Panel::bottom("bottom_panel_collapsed") .resizable(true) - .default_size(20.0), + .exact_size(20.0), egui::Panel::bottom("bottom_panel_expanded") .resizable(true) - .max_size(128.0), + .size_range(64.0..=128.0), |ui, expanded| { if expanded { ui.vertical_centered(|ui| { diff --git a/crates/egui_demo_lib/src/demo/scrolling.rs b/crates/egui_demo_lib/src/demo/scrolling.rs index cee525aaa..d9eb71927 100644 --- a/crates/egui_demo_lib/src/demo/scrolling.rs +++ b/crates/egui_demo_lib/src/demo/scrolling.rs @@ -340,10 +340,8 @@ impl crate::View for ScrollTo { ui.scroll_to_cursor(Some(Align::BOTTOM)); } - let margin = ui.visuals().clip_rect_margin; - - let current_scroll = ui.clip_rect().top() - ui.min_rect().top() + margin; - let max_scroll = ui.min_rect().height() - ui.clip_rect().height() + 2.0 * margin; + let current_scroll = ui.clip_rect().top() - ui.min_rect().top(); + let max_scroll = ui.min_rect().height() - ui.clip_rect().height(); (current_scroll, max_scroll) }) .inner; diff --git a/crates/egui_demo_lib/src/demo/sliders.rs b/crates/egui_demo_lib/src/demo/sliders.rs index 7dab0f26c..d2371cafd 100644 --- a/crates/egui_demo_lib/src/demo/sliders.rs +++ b/crates/egui_demo_lib/src/demo/sliders.rs @@ -125,7 +125,7 @@ impl crate::View for Sliders { ); if ui.button("Assign PI").clicked() { - self.value = std::f64::consts::PI; + self.value = core::f64::consts::PI; } } diff --git a/crates/egui_demo_lib/src/demo/tests/grid_test.rs b/crates/egui_demo_lib/src/demo/tests/grid_test.rs index 1806eb431..7befe09e1 100644 --- a/crates/egui_demo_lib/src/demo/tests/grid_test.rs +++ b/crates/egui_demo_lib/src/demo/tests/grid_test.rs @@ -113,7 +113,7 @@ impl crate::View for GridTest { ui.end_row(); let mut dyn_text = String::from("O"); - dyn_text.extend(std::iter::repeat_n('h', self.text_length)); + dyn_text.extend(core::iter::repeat_n('h', self.text_length)); ui.label(dyn_text); ui.label("Fifth row, second column"); ui.end_row(); diff --git a/crates/egui_demo_lib/src/demo/tests/input_test.rs b/crates/egui_demo_lib/src/demo/tests/input_test.rs index e237b3e4b..ca753d650 100644 --- a/crates/egui_demo_lib/src/demo/tests/input_test.rs +++ b/crates/egui_demo_lib/src/demo/tests/input_test.rs @@ -123,7 +123,7 @@ impl crate::View for InputTest { } fn response_summary(response: &egui::Response, show_hovers: bool) -> String { - use std::fmt::Write as _; + use core::fmt::Write as _; let mut new_info = String::new(); diff --git a/crates/egui_demo_lib/src/easy_mark/easy_mark_parser.rs b/crates/egui_demo_lib/src/easy_mark/easy_mark_parser.rs index 66c0dc04f..0e36844c3 100644 --- a/crates/egui_demo_lib/src/easy_mark/easy_mark_parser.rs +++ b/crates/egui_demo_lib/src/easy_mark/easy_mark_parser.rs @@ -118,15 +118,15 @@ impl<'a> Parser<'a> { { let language = &language_start[..newline]; let code_start = &language_start[newline + 1..]; - if let Some(end) = code_start.find("\n```") { + return if let Some(end) = code_start.find("\n```") { let code = &code_start[..end].trim(); self.s = &code_start[end + 4..]; self.start_of_line = false; - return Some(Item::CodeBlock(language, code)); + Some(Item::CodeBlock(language, code)) } else { self.s = ""; - return Some(Item::CodeBlock(language, code_start)); - } + Some(Item::CodeBlock(language, code_start)) + }; } None } @@ -138,18 +138,18 @@ impl<'a> Parser<'a> { self.start_of_line = false; self.style.code = true; let rest_of_line = &self.s[..self.s.find('\n').unwrap_or(self.s.len())]; - if let Some(end) = rest_of_line.find('`') { + return if let Some(end) = rest_of_line.find('`') { let item = Item::Text(self.style, &self.s[..end]); self.s = &self.s[end + 1..]; self.style.code = false; - return Some(item); + Some(item) } else { let end = rest_of_line.len(); let item = Item::Text(self.style, rest_of_line); self.s = &self.s[end..]; self.style.code = false; - return Some(item); - } + Some(item) + }; } None } diff --git a/crates/egui_demo_lib/src/lib.rs b/crates/egui_demo_lib/src/lib.rs index 3d715f3c0..45f4640bd 100644 --- a/crates/egui_demo_lib/src/lib.rs +++ b/crates/egui_demo_lib/src/lib.rs @@ -72,11 +72,12 @@ fn test_egui_e2e() { const NUM_FRAMES: usize = 5; for _ in 0..NUM_FRAMES { - let full_output = ctx.run_ui(raw_input.clone(), |ui| { + let mut full_output = ctx.run_ui(raw_input.clone(), |ui| { demo_windows.ui(ui); }); let clipped_primitives = ctx.tessellate(full_output.shapes, full_output.pixels_per_point); assert!(!clipped_primitives.is_empty()); + full_output.textures_delta.clear(); // Don't panic on drop with unapplied deltas } } @@ -91,7 +92,7 @@ fn test_egui_zero_window_size() { const NUM_FRAMES: usize = 5; for _ in 0..NUM_FRAMES { - let full_output = ctx.run_ui(raw_input.clone(), |ui| { + let mut full_output = ctx.run_ui(raw_input.clone(), |ui| { demo_windows.ui(ui); }); let clipped_primitives = ctx.tessellate(full_output.shapes, full_output.pixels_per_point); @@ -100,6 +101,7 @@ fn test_egui_zero_window_size() { "There should be nothing to show, has at least one primitive with clip_rect: {:?}", clipped_primitives[0].clip_rect ); + full_output.textures_delta.clear(); // Don't panic on drop with unapplied deltas } } diff --git a/crates/egui_demo_lib/tests/misc.rs b/crates/egui_demo_lib/tests/misc.rs index 427710629..07b61215a 100644 --- a/crates/egui_demo_lib/tests/misc.rs +++ b/crates/egui_demo_lib/tests/misc.rs @@ -1,6 +1,41 @@ use egui::{Color32, accesskit::Role}; use egui_kittest::{Harness, kittest::Queryable as _}; +/// Textures with [`egui::TextureOptions::NEAREST`] should render crisp, +/// also with kittest's predictable texture filtering. +#[test] +fn test_nearest_texture_filtering() { + let mut texture: Option = None; + let mut harness = Harness::builder() + .with_size(egui::Vec2::new(80.0, 48.0)) + .build_ui(move |ui| { + let texture = texture.get_or_insert_with(|| { + let pixels = [ + Color32::BLACK, + Color32::WHITE, + Color32::BLACK, + Color32::WHITE, + Color32::WHITE, + Color32::BLACK, + Color32::WHITE, + Color32::BLACK, + ]; + let image = egui::ColorImage::new([4, 2], pixels.to_vec()); + ui.ctx() + .load_texture("checkerboard", image, egui::TextureOptions::NEAREST) + }); + + let rect = egui::Rect::from_min_size(egui::pos2(8.0, 8.0), egui::vec2(64.0, 32.0)); + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + ui.painter().add( + egui::epaint::RectShape::filled(rect, 0, Color32::WHITE) + .with_texture(texture.id(), uv), + ); + }); + harness.run(); + harness.snapshot("nearest_texture_filtering"); +} + #[test] fn test_kerning() { let mut results = egui_kittest::SnapshotResults::new(); diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png index 14fa4884e..7e6beeac1 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d48079f85e9529f4b463bbaf2c948a64126388ef32df0584b586dc0ae48a35b -size 344919 +oid sha256:d20f4bf882ee16abde6268fe766ceb67dccdfc5d2a509a952cef1d7af72dc3c6 +size 344141 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png index a3fe390e6..0fdb9277d 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e44cef45d27ddd64b0d2f6de0b7005846147bce54a9b8b1223ba9a0a02c2416 +oid sha256:125d6fe0cdc81a0c7973317e62c528c1beff7ceaedb9cb1fb91bc8d6674c8dc6 size 62830 diff --git a/crates/egui_demo_lib/tests/snapshots/nearest_texture_filtering.png b/crates/egui_demo_lib/tests/snapshots/nearest_texture_filtering.png new file mode 100644 index 000000000..fedbc8f13 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/nearest_texture_filtering.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72e133c853ab933d37711665ce1278f8f23ce35b8a74a46559f269d0dfd0bf40 +size 353 diff --git a/crates/egui_extras/CHANGELOG.md b/crates/egui_extras/CHANGELOG.md index a6b2470a4..db7464d9b 100644 --- a/crates/egui_extras/CHANGELOG.md +++ b/crates/egui_extras/CHANGELOG.md @@ -5,6 +5,14 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +Nothing new + + ## 0.35.0 - 2026-06-25 * Improve FileLoader file uri to path handling for windows [#8163](https://github.com/emilk/egui/pull/8163) by [@aconbere](https://github.com/aconbere) * Add arbitrary request headers to `EhttpLoader` [#8121](https://github.com/emilk/egui/pull/8121) by [@frnsys](https://github.com/frnsys) diff --git a/crates/egui_extras/README.md b/crates/egui_extras/README.md index 4e5e96dce..41c9ece8a 100644 --- a/crates/egui_extras/README.md +++ b/crates/egui_extras/README.md @@ -6,7 +6,7 @@ ![MIT](https://img.shields.io/badge/license-MIT-blue.svg) ![Apache](https://img.shields.io/badge/license-Apache-blue.svg) -This is a crate that adds some features on top top of [`egui`](https://github.com/emilk/egui). This crate is for experimental features, and features that require big dependencies that do not belong in `egui`. +This is a crate that adds some features on top of [`egui`](https://github.com/emilk/egui). This crate is for experimental features, and features that require big dependencies that do not belong in `egui`. ## Images One thing `egui_extras` is commonly used for is to install image loaders for `egui`: diff --git a/crates/egui_extras/src/datepicker/button.rs b/crates/egui_extras/src/datepicker/button.rs index 42e4f900f..94770ca71 100644 --- a/crates/egui_extras/src/datepicker/button.rs +++ b/crates/egui_extras/src/datepicker/button.rs @@ -1,7 +1,7 @@ use super::popup::DatePickerPopup; +use core::ops::RangeInclusive; use egui::{Area, Button, Frame, InnerResponse, Key, Order, RichText, Ui, Widget}; use jiff::civil::Date; -use std::ops::RangeInclusive; #[derive(Default, Clone)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] diff --git a/crates/egui_extras/src/datepicker/mod.rs b/crates/egui_extras/src/datepicker/mod.rs index f1f6e58fa..1eaf37118 100644 --- a/crates/egui_extras/src/datepicker/mod.rs +++ b/crates/egui_extras/src/datepicker/mod.rs @@ -26,7 +26,7 @@ fn month_data(year: i16, month: i8) -> Vec { if start.weekday() == Weekday::Sunday { weeks.push(Week { number: ISOWeekDate::from(start).week() as u8, - days: std::mem::take(&mut week), + days: core::mem::take(&mut week), }); } start = start.tomorrow().unwrap(); diff --git a/crates/egui_extras/src/datepicker/popup.rs b/crates/egui_extras/src/datepicker/popup.rs index 5c0726e5a..52bd71be4 100644 --- a/crates/egui_extras/src/datepicker/popup.rs +++ b/crates/egui_extras/src/datepicker/popup.rs @@ -32,7 +32,7 @@ pub(crate) struct DatePickerPopup<'a> { pub calendar: bool, pub calendar_week: bool, pub highlight_weekends: bool, - pub start_end_years: Option>, + pub start_end_years: Option>, pub reverse_years: bool, pub year_scroll_to: Option, } diff --git a/crates/egui_extras/src/layout.rs b/crates/egui_extras/src/layout.rs index 594763daf..66d401a69 100644 --- a/crates/egui_extras/src/layout.rs +++ b/crates/egui_extras/src/layout.rs @@ -217,10 +217,7 @@ impl<'l> StripLayout<'l> { let mut child_ui = self.ui.new_child(ui_builder); if flags.clip { - let margin = egui::Vec2::splat(self.ui.visuals().clip_rect_margin); - let margin = margin.min(0.5 * self.ui.spacing().item_spacing); - let clip_rect = max_rect.expand2(margin); - child_ui.shrink_clip_rect(clip_rect); + child_ui.shrink_clip_rect(max_rect); if !child_ui.is_sizing_pass() { // Better to truncate (if we can), rather than hard clipping: diff --git a/crates/egui_extras/src/loaders/file_loader.rs b/crates/egui_extras/src/loaders/file_loader.rs index bdafb6553..d566628b1 100644 --- a/crates/egui_extras/src/loaders/file_loader.rs +++ b/crates/egui_extras/src/loaders/file_loader.rs @@ -1,9 +1,10 @@ use ahash::HashMap; +use core::task::Poll; use egui::{ load::{Bytes, BytesLoadResult, BytesLoader, BytesPoll, LoadError}, mutex::Mutex, }; -use std::{path::PathBuf, sync::Arc, task::Poll, thread}; +use std::{path::PathBuf, sync::Arc, thread}; #[derive(Clone)] struct File { diff --git a/crates/egui_extras/src/loaders/gif_loader.rs b/crates/egui_extras/src/loaders/gif_loader.rs index ebaf9a6b3..c1242a1f9 100644 --- a/crates/egui_extras/src/loaders/gif_loader.rs +++ b/crates/egui_extras/src/loaders/gif_loader.rs @@ -1,11 +1,12 @@ use ahash::HashMap; +use core::{mem::size_of, time::Duration}; use egui::{ ColorImage, FrameDurations, Id, decode_animated_image_uri, has_gif_magic_header, load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, mutex::Mutex, }; use image::AnimationDecoder as _; -use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; +use std::{io::Cursor, sync::Arc}; /// Array of Frames and the duration for how long each frame should be shown #[derive(Debug, Clone)] diff --git a/crates/egui_extras/src/loaders/http_loader.rs b/crates/egui_extras/src/loaders/http_loader.rs index e3b4d350e..6eb1bc5ba 100644 --- a/crates/egui_extras/src/loaders/http_loader.rs +++ b/crates/egui_extras/src/loaders/http_loader.rs @@ -1,9 +1,10 @@ use ahash::HashMap; +use core::task::Poll; use egui::{ load::{Bytes, BytesLoadResult, BytesLoader, BytesPoll, LoadError}, mutex::Mutex, }; -use std::{sync::Arc, task::Poll}; +use std::sync::Arc; #[derive(Clone)] struct File { diff --git a/crates/egui_extras/src/loaders/image_loader.rs b/crates/egui_extras/src/loaders/image_loader.rs index 969ed5538..b14a57286 100644 --- a/crates/egui_extras/src/loaders/image_loader.rs +++ b/crates/egui_extras/src/loaders/image_loader.rs @@ -1,11 +1,12 @@ use ahash::HashMap; +use core::{mem::size_of, task::Poll}; use egui::{ ColorImage, decode_animated_image_uri, load::{Bytes, BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, mutex::Mutex, }; use image::ImageFormat; -use std::{mem::size_of, path::Path, sync::Arc, task::Poll}; +use std::{path::Path, sync::Arc}; #[cfg(not(target_arch = "wasm32"))] use std::thread; @@ -146,7 +147,7 @@ impl ImageLoader for ImageCrateLoader { .map(Arc::new) .map_err(|err| err.to_string()); log::trace!("finished loading {uri:?}"); - cache_lock.insert(uri.into(), std::task::Poll::Ready(result.clone())); + cache_lock.insert(uri.into(), core::task::Poll::Ready(result.clone())); match result { Ok(image) => Ok(ImagePoll::Ready { image }), Err(err) => Err(LoadError::Loading(err)), diff --git a/crates/egui_extras/src/loaders/svg_loader.rs b/crates/egui_extras/src/loaders/svg_loader.rs index 3bd881fbf..53c879056 100644 --- a/crates/egui_extras/src/loaders/svg_loader.rs +++ b/crates/egui_extras/src/loaders/svg_loader.rs @@ -1,4 +1,5 @@ -use std::{mem::size_of, sync::Arc}; +use core::mem::size_of; +use std::sync::Arc; use ahash::HashMap; @@ -28,7 +29,7 @@ impl SvgLoader { } fn is_supported(uri: &str) -> bool { - uri.ends_with(".svg") + egui::load::has_extension(uri, "svg") } impl Default for SvgLoader { diff --git a/crates/egui_extras/src/loaders/webp_loader.rs b/crates/egui_extras/src/loaders/webp_loader.rs index 23d778358..3ecd7068a 100644 --- a/crates/egui_extras/src/loaders/webp_loader.rs +++ b/crates/egui_extras/src/loaders/webp_loader.rs @@ -1,11 +1,12 @@ use ahash::HashMap; +use core::{mem::size_of, time::Duration}; use egui::{ ColorImage, FrameDurations, Id, decode_animated_image_uri, has_webp_header, load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, mutex::Mutex, }; use image::{AnimationDecoder as _, ColorType, ImageDecoder as _, Rgba, codecs::webp::WebPDecoder}; -use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; +use std::{io::Cursor, sync::Arc}; #[derive(Clone)] enum WebP { diff --git a/crates/egui_extras/src/syntax_highlighting.rs b/crates/egui_extras/src/syntax_highlighting.rs index c3981b0e2..f45b68f2c 100644 --- a/crates/egui_extras/src/syntax_highlighting.rs +++ b/crates/egui_extras/src/syntax_highlighting.rs @@ -210,6 +210,13 @@ impl SyntectTheme { derive(serde::Deserialize, serde::Serialize), serde(default) )] +#[cfg_attr( + all(feature = "serde", not(feature = "syntect")), + expect( + clippy::unsafe_derive_deserialize, + reason = "the `enum_map!` macro expands to `unsafe` code" + ) +)] pub struct CodeTheme { dark_mode: bool, @@ -514,9 +521,9 @@ struct HighlightSettings<'a>(&'a SyntectSettings); #[derive(Copy, Clone)] struct HighlightSettings<'a>(&'a ()); -impl std::hash::Hash for HighlightSettings<'_> { - fn hash(&self, state: &mut H) { - std::ptr::hash(self.0, state); +impl core::hash::Hash for HighlightSettings<'_> { + fn hash(&self, state: &mut H) { + core::ptr::hash(self.0, state); } } diff --git a/crates/egui_glow/CHANGELOG.md b/crates/egui_glow/CHANGELOG.md index 988189427..959f16b57 100644 --- a/crates/egui_glow/CHANGELOG.md +++ b/crates/egui_glow/CHANGELOG.md @@ -6,6 +6,14 @@ Changes since the last release can be found at *const std::ffi::c_void { + fn get_proc_address(&self, addr: &core::ffi::CStr) -> *const core::ffi::c_void { use glutin::display::GlDisplay as _; self.gl_display.get_proc_address(addr) } @@ -154,7 +154,7 @@ impl GlutinWindowContext { #[derive(Debug)] pub enum UserEvent { - Redraw(std::time::Duration), + Redraw(core::time::Duration), } struct GlowApp { @@ -162,7 +162,7 @@ struct GlowApp { gl_window: Option, gl: Option>, egui_glow: Option, - repaint_delay: std::time::Duration, + repaint_delay: core::time::Duration, clear_color: [f32; 3], } @@ -173,7 +173,7 @@ impl GlowApp { gl_window: None, gl: None, egui_glow: None, - repaint_delay: std::time::Duration::MAX, + repaint_delay: core::time::Duration::MAX, clear_color: [0.1, 0.1, 0.1], } } diff --git a/crates/egui_glow/src/painter.rs b/crates/egui_glow/src/painter.rs index 97588ede8..8a68de1f8 100644 --- a/crates/egui_glow/src/painter.rs +++ b/crates/egui_glow/src/painter.rs @@ -1,6 +1,7 @@ #![expect(clippy::unwrap_used)] #![expect(unsafe_code)] +use core::mem::offset_of; use std::{collections::HashMap, sync::Arc}; use egui::{ @@ -8,7 +9,6 @@ use egui::{ epaint::{Mesh, PaintCallbackInfo, Primitive, Vertex}, }; use glow::HasContext as _; -use memoffset::offset_of; use crate::check_for_gl_error; use crate::misc_util::{compile_shader, link_program}; @@ -55,10 +55,10 @@ impl TextureWrapModeExt for egui::TextureWrapMode { #[derive(Debug)] pub struct PainterError(String); -impl std::error::Error for PainterError {} +impl core::error::Error for PainterError {} -impl std::fmt::Display for PainterError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for PainterError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "OpenGL: {}", self.0) } } @@ -219,7 +219,7 @@ impl Painter { let a_tc_loc = gl.get_attrib_location(program, "a_tc").unwrap(); let a_srgba_loc = gl.get_attrib_location(program, "a_srgba").unwrap(); - let stride = std::mem::size_of::() as i32; + let stride = core::mem::size_of::() as i32; let buffer_infos = vec![ vao::BufferInfo { location: a_pos_loc, @@ -358,17 +358,21 @@ impl Painter { screen_size_px: [u32; 2], pixels_per_point: f32, clipped_primitives: &[egui::ClippedPrimitive], - textures_delta: &egui::TexturesDelta, + textures_delta: &mut egui::TexturesDelta, ) { profiling::function_scope!(); - for (id, image_delta) in &textures_delta.set { - self.set_texture(*id, image_delta); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, image_deltas) in textures_delta.set.drain() { + for image_delta in image_deltas { + self.set_texture(id, &image_delta); + } } self.paint_primitives(screen_size_px, pixels_per_point, clipped_primitives); - for &id in &textures_delta.free { + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for id in textures_delta.free.drain() { self.free_texture(id); } } diff --git a/crates/egui_glow/src/shader_version.rs b/crates/egui_glow/src/shader_version.rs index 7d0caf7fa..e34e44c68 100644 --- a/crates/egui_glow/src/shader_version.rs +++ b/crates/egui_glow/src/shader_version.rs @@ -2,7 +2,7 @@ #![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps #![expect(unsafe_code)] -use std::convert::TryInto as _; +use core::convert::TryInto as _; /// Helper for parsing and interpreting the OpenGL shader version. #[derive(Copy, Clone, Debug, PartialEq, Eq)] diff --git a/crates/egui_glow/src/winit.rs b/crates/egui_glow/src/winit.rs index 6ed67ae8e..6ec9b8215 100644 --- a/crates/egui_glow/src/winit.rs +++ b/crates/egui_glow/src/winit.rs @@ -104,11 +104,14 @@ impl EguiGlow { /// Paint the results of the last call to [`Self::run`]. pub fn paint(&mut self, window: &winit::window::Window) { - let shapes = std::mem::take(&mut self.shapes); - let mut textures_delta = std::mem::take(&mut self.textures_delta); + let shapes = core::mem::take(&mut self.shapes); + let mut textures_delta = core::mem::take(&mut self.textures_delta); - for (id, image_delta) in textures_delta.set { - self.painter.set_texture(id, &image_delta); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, image_deltas) in textures_delta.set.drain() { + for image_delta in image_deltas { + self.painter.set_texture(id, &image_delta); + } } let pixels_per_point = self.pixels_per_point; @@ -117,7 +120,8 @@ impl EguiGlow { self.painter .paint_primitives(dimensions, pixels_per_point, &clipped_primitives); - for id in textures_delta.free.drain(..) { + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for id in textures_delta.free.drain() { self.painter.free_texture(id); } } diff --git a/crates/egui_inspection/CHANGELOG.md b/crates/egui_inspection/CHANGELOG.md new file mode 100644 index 000000000..5f81139b7 --- /dev/null +++ b/crates/egui_inspection/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog for egui_inspection +All notable changes to the `egui_inspection` crate will be noted in this file. + + +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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +* Add `egui_inspection::Request::Settle` [#8344](https://github.com/emilk/egui/pull/8344) by [@lucasmerlin](https://github.com/lucasmerlin) + + +## 0.35.0 - Initial release diff --git a/crates/egui_inspection/src/plugin.rs b/crates/egui_inspection/src/plugin.rs index 0ae857ab1..76ad8893a 100644 --- a/crates/egui_inspection/src/plugin.rs +++ b/crates/egui_inspection/src/plugin.rs @@ -33,8 +33,8 @@ //! Note that [`serve`]'s threads hold an [`egui::Context`] clone, so the context stays alive //! for as long as the listener runs (the lifetime of the process, for a debug attach). +use core::time::Duration; use std::sync::mpsc; -use std::time::Duration; use egui::{Context, FullOutput, RawInput}; diff --git a/crates/egui_inspection/src/protocol.rs b/crates/egui_inspection/src/protocol.rs index 631e7c7b1..e95ce5cf5 100644 --- a/crates/egui_inspection/src/protocol.rs +++ b/crates/egui_inspection/src/protocol.rs @@ -136,7 +136,7 @@ pub struct EncodedPng { /// Hard cap on a single framed message. Matches the sanity limit enforced by both ends. pub const MAX_MESSAGE_BYTES: usize = 256 * 1024 * 1024; // 256 MiB -fn invalid_data(err: impl std::fmt::Display) -> io::Error { +fn invalid_data(err: impl core::fmt::Display) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, err.to_string()) } diff --git a/crates/egui_kittest/CHANGELOG.md b/crates/egui_kittest/CHANGELOG.md index 2f6483aaf..fe999ffc9 100644 --- a/crates/egui_kittest/CHANGELOG.md +++ b/crates/egui_kittest/CHANGELOG.md @@ -6,6 +6,16 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +* Handle `ViewportCommand::InnerSize` in `egui_kittest` [#8350](https://github.com/emilk/egui/pull/8350) by [@lucasmerlin](https://github.com/lucasmerlin) +* Report failing pixels by threshold when a kittest snapshot fails [#8360](https://github.com/emilk/egui/pull/8360) by [@emilk](https://github.com/emilk) +* Rename `failed_pixel_count_threshold` to `max_failed_pixels` [#8383](https://github.com/emilk/egui/pull/8383) by [@emilk](https://github.com/emilk) + + ## 0.35.0 - 2026-06-25 * Add `HarnessBuilder::with_render_options()` (closes #7630) [#8060](https://github.com/emilk/egui/pull/8060) by [@MichaelGrupp](https://github.com/MichaelGrupp) * Add `Harness::spawn_eframe_app` [#8120](https://github.com/emilk/egui/pull/8120) by [@emilk](https://github.com/emilk) diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md index 7b97f4e1d..8711aeabb 100644 --- a/crates/egui_kittest/README.md +++ b/crates/egui_kittest/README.md @@ -44,25 +44,32 @@ All possible settings and their defaults: # path to the snapshot directory output_path = "tests/snapshots" -# default threshold for image comparison tests +# maximum weighted squared YIQ color distance between two corresponding pixels +# (a per-pixel color tolerance, applied to each pixel pair on its own) threshold = 0.6 -# default failed_pixel_count_threshold -failed_pixel_count_threshold = 0 +# how many pixels may exceed the `threshold` before the test fails +# (an absolute pixel count, not a fraction of the image) +max_failed_pixels = 0 [windows] threshold = 0.6 -failed_pixel_count_threshold = 0 +max_failed_pixels = 0 [macos] threshold = 0.6 -failed_pixel_count_threshold = 0 +max_failed_pixels = 0 [linux] threshold = 0.6 -failed_pixel_count_threshold = 0 +max_failed_pixels = 0 ``` +Raise `max_failed_pixels` only very carefully: a high value (more than ~10) is enough to hide a +real change, such as a moved separator, a shifted one-pixel border, or a small icon rendering +incorrectly. Prefer the smallest value that makes the test pass, and re-check it whenever you +update the snapshot. + ## Snapshot testing There is a snapshot testing feature. To create snapshot tests, enable the `snapshot` and `wgpu` features. Once enabled, you can call `Harness::snapshot` to render the ui and save the image to the `tests/snapshots` directory. @@ -105,7 +112,7 @@ However, especially when you're using custom rendering, you may observe images d First check whether the difference is due to a change in enabled rendering features, potentially due to difference in hardware (/software renderer) capabilities. Generally you should carefully enforcing the same set of features for all test runs, but this may happen nonetheless. -Once you validated that the differences are miniscule and hard to avoid, you can try to _carefully_ adjust the comparison tolerance setting (`SnapshotOptions::threshold`, TODO([#5683](https://github.com/emilk/egui/issues/5683)): as well as number of pixels allowed to differ) for the specific test. +Once you validated that the differences are miniscule and hard to avoid, you can try to _carefully_ adjust the comparison tolerances (`SnapshotOptions::threshold` and, as a last resort, `SnapshotOptions::max_failed_pixels`) for the specific test. See also TODO([#5683](https://github.com/emilk/egui/issues/5683)). ⚠️ **WARNING** ⚠️ Picking too high tolerances may mean that you are missing actual test failures. diff --git a/crates/egui_kittest/src/app_kind.rs b/crates/egui_kittest/src/app_kind.rs index 942ec4b85..9e92192bb 100644 --- a/crates/egui_kittest/src/app_kind.rs +++ b/crates/egui_kittest/src/app_kind.rs @@ -23,6 +23,10 @@ pub(crate) enum AppKind<'a, State> { } impl AppKind<'_, State> { + // The `Option` is needed when the `eframe` feature is enabled, because the + // `Eframe` variant has no `egui::Response` to return. Without `eframe` the + // wrap is unnecessary, so we silence `clippy::unnecessary_wraps` for that case. + #[cfg_attr(not(feature = "eframe"), expect(clippy::unnecessary_wraps))] pub fn run( &mut self, ui: &mut egui::Ui, diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs index dc4757ee5..23109544b 100644 --- a/crates/egui_kittest/src/builder.rs +++ b/crates/egui_kittest/src/builder.rs @@ -2,8 +2,8 @@ use crate::app_kind::AppKind; #[cfg(feature = "eframe")] use crate::app_kind::AppKindEframe; use crate::{Harness, LazyRenderer, TestRenderer}; +use core::marker::PhantomData; use egui::{Pos2, Rect, Vec2}; -use std::marker::PhantomData; /// Builder for [`Harness`]. #[must_use] diff --git a/crates/egui_kittest/src/config.rs b/crates/egui_kittest/src/config.rs index a94075973..cdb74e00f 100644 --- a/crates/egui_kittest/src/config.rs +++ b/crates/egui_kittest/src/config.rs @@ -15,15 +15,20 @@ pub struct Config { /// Default is "tests/snapshots" (relative to the working directory / crate root). output_path: PathBuf, - /// The per-pixel threshold. + /// The maximum weighted squared YIQ color distance between two corresponding pixels. + /// + /// Pixels that differ by more than this are counted as failing. + /// This is an absolute, per-pixel value, and does not depend on the image dimensions. /// /// Default is 0.6. threshold: f32, - /// The number of pixels that can differ before the test is considered failed. + /// The number of pixels that may fail the [`Self::threshold`] before the test is + /// considered failed. /// /// Default is 0. - failed_pixel_count_threshold: usize, + #[serde(alias = "failed_pixel_count_threshold")] + max_failed_pixels: usize, windows: OsConfig, mac: OsConfig, @@ -35,7 +40,7 @@ impl Default for Config { Self { output_path: PathBuf::from("tests/snapshots"), threshold: 0.6, - failed_pixel_count_threshold: 0, + max_failed_pixels: 0, windows: Default::default(), mac: Default::default(), linux: Default::default(), @@ -48,8 +53,9 @@ pub struct OsConfig { /// Override the per-pixel threshold for this OS. threshold: Option, - /// Override the failed pixel count threshold for this OS. - failed_pixel_count_threshold: Option, + /// Override the maximum number of failing pixels for this OS. + #[serde(alias = "failed_pixel_count_threshold")] + max_failed_pixels: Option, } fn find_kittest_toml() -> io::Result { @@ -72,13 +78,44 @@ fn find_kittest_toml() -> io::Result { } } +/// The old name of `max_failed_pixels` is still accepted, but warned about. +fn warn_about_deprecated_keys(config_str: &str) { + let Ok(config) = toml::from_str::(config_str) else { + return; + }; + + let mut sections = vec![("", &config)]; + for name in ["windows", "mac", "linux"] { + if let Some(table) = config.get(name).and_then(toml::Value::as_table) { + sections.push((name, table)); + } + } + + for (section, table) in sections { + if table.contains_key("failed_pixel_count_threshold") { + let prefix = if section.is_empty() { + String::new() + } else { + format!("{section}.") + }; + log::warn!( + "`{prefix}failed_pixel_count_threshold` in kittest.toml is deprecated; \ + use `{prefix}max_failed_pixels` instead." + ); + } + } +} + fn load_config() -> Config { if let Ok(config_path) = find_kittest_toml() { match std::fs::read_to_string(&config_path) { - Ok(config_str) => match toml::from_str(&config_str) { - Ok(config) => config, - Err(e) => panic!("Failed to parse {}: {e}", config_path.display()), - }, + Ok(config_str) => { + warn_about_deprecated_keys(&config_str); + match toml::from_str(&config_str) { + Ok(config) => config, + Err(err) => panic!("Failed to parse {}: {err}", config_path.display()), + } + } Err(err) => { panic!("Failed to read {}: {}", config_path.display(), err); } @@ -127,30 +164,59 @@ impl Config { } } - pub fn os_failed_pixel_count_threshold(&self) -> crate::OsThreshold { - let fallback = self.failed_pixel_count_threshold; + pub fn os_max_failed_pixels(&self) -> crate::OsThreshold { + let fallback = self.max_failed_pixels; crate::OsThreshold { - windows: self - .windows - .failed_pixel_count_threshold - .unwrap_or(fallback), - macos: self.mac.failed_pixel_count_threshold.unwrap_or(fallback), - linux: self.linux.failed_pixel_count_threshold.unwrap_or(fallback), + windows: self.windows.max_failed_pixels.unwrap_or(fallback), + macos: self.mac.max_failed_pixels.unwrap_or(fallback), + linux: self.linux.max_failed_pixels.unwrap_or(fallback), fallback, } } - /// The threshold. + /// The maximum weighted squared YIQ color distance between two corresponding pixels. /// - /// Default is 1.0. + /// This is an absolute, per-pixel value, and does not depend on the image dimensions. + /// + /// Default is 0.6. pub fn threshold(&self) -> f32 { self.os_threshold().threshold() } - /// The number of pixels that can differ before the test is considered failed. + /// The number of pixels that may fail the [`Self::threshold`] before the test is + /// considered failed. /// /// Default is 0. - pub fn failed_pixel_count_threshold(&self) -> usize { - self.os_failed_pixel_count_threshold().threshold() + pub fn max_failed_pixels(&self) -> usize { + self.os_max_failed_pixels().threshold() + } +} + +#[cfg(test)] +mod tests { + use super::Config; + + #[test] + fn deprecated_failed_pixel_count_threshold_key_is_accepted() { + let config: Config = toml::from_str( + r" + failed_pixel_count_threshold = 1 + + [windows] + failed_pixel_count_threshold = 2 + + [mac] + failed_pixel_count_threshold = 3 + + [linux] + failed_pixel_count_threshold = 4 + ", + ) + .unwrap_or_else(|err| panic!("Failed to parse config: {err}")); + + assert_eq!(config.max_failed_pixels, 1); + assert_eq!(config.windows.max_failed_pixels, Some(2)); + assert_eq!(config.mac.max_failed_pixels, Some(3)); + assert_eq!(config.linux.max_failed_pixels, Some(4)); } } diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 6873be579..8b4ff9ad6 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -26,7 +26,7 @@ pub use { kittest, }; -use std::{ +use core::{ fmt::{Debug, Display, Formatter}, time::Duration, }; @@ -47,7 +47,7 @@ pub struct ExceededMaxStepsError { } impl Display for ExceededMaxStepsError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { write!( f, "Harness::run exceeded max_steps ({}). If your expect your ui to keep repainting \ @@ -90,7 +90,7 @@ pub struct Harness<'a, State = ()> { } impl Debug for Harness<'_, State> { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { self.kittest.fmt(f) } } @@ -147,7 +147,7 @@ impl<'a, State> Harness<'a, State> { response = app.run(ui, &mut state, false); }); - renderer.handle_delta(&output.textures_delta); + renderer.handle_delta(&mut output.textures_delta); let mut harness = Self { app, @@ -245,18 +245,18 @@ impl<'a, State> Harness<'a, State> { /// This will call the app closure with each queued event and /// update the Harness. pub fn step(&mut self) { - let events = std::mem::take(&mut *self.queued_events.lock()); + let events = core::mem::take(&mut *self.queued_events.lock()); if events.is_empty() { - self._step(false); + self.step_impl(false); } for event in events { self.input.events.push(event); - self._step(false); + self.step_impl(false); } } /// Run a single step. This will not process any events. - fn _step(&mut self, sizing_pass: bool) { + fn step_impl(&mut self, sizing_pass: bool) { self.input.predicted_dt = self.step_dt; let mut output = self.ctx.run_ui(self.input.take(), |ui| { @@ -269,7 +269,7 @@ impl<'a, State> Harness<'a, State> { .take() .expect("AccessKit was disabled"), ); - self.renderer.handle_delta(&output.textures_delta); + self.renderer.handle_delta(&mut output.textures_delta); self.output = output; self.handle_viewport_commands(); @@ -297,7 +297,7 @@ impl<'a, State> Harness<'a, State> { /// [`Harness::new_ui`] / [`Harness::new_ui_state`] or /// [`HarnessBuilder::build_ui`] / [`HarnessBuilder::build_ui_state`]. pub fn fit_contents(&mut self) { - self._step(true); + self.step_impl(true); // Calculate size including all content (main UI + popups + tooltips) if let Some(rect) = self.compute_total_rect_with_popups() { @@ -333,7 +333,7 @@ impl<'a, State> Harness<'a, State> { } } - fn _try_run(&mut self, sleep: bool) -> Result { + fn try_run_impl(&mut self, sleep: bool) -> Result { let mut steps = 0; loop { steps += 1; @@ -374,7 +374,7 @@ impl<'a, State> Harness<'a, State> { /// - [`Harness::run_steps`]. /// - [`Harness::try_run_realtime`]. pub fn try_run(&mut self) -> Result { - self._try_run(false) + self.try_run_impl(false) } /// Run until @@ -414,7 +414,7 @@ impl<'a, State> Harness<'a, State> { /// - [`Harness::run_steps`]. /// - [`Harness::try_run`]. pub fn try_run_realtime(&mut self) -> Result { - self._try_run(true) + self.try_run_impl(true) } /// Run a number of steps. @@ -802,7 +802,7 @@ impl<'a, State> Harness<'a, State> { // SAFETY: `pthread_main_np` is a thread-safe libc query with no arguments. let is_main_thread = unsafe { unsafe extern "C" { - fn pthread_main_np() -> std::ffi::c_int; + fn pthread_main_np() -> core::ffi::c_int; } pthread_main_np() != 0 }; diff --git a/crates/egui_kittest/src/node.rs b/crates/egui_kittest/src/node.rs index 729fda763..285602a84 100644 --- a/crates/egui_kittest/src/node.rs +++ b/crates/egui_kittest/src/node.rs @@ -1,8 +1,8 @@ +use core::fmt::{Debug, Formatter}; use egui::accesskit::ActionRequest; use egui::mutex::Mutex; use egui::{Modifiers, PointerButton, Pos2, accesskit}; use kittest::{AccessKitNode, NodeT, debug_fmt_node}; -use std::fmt::{Debug, Formatter}; pub type EventQueue = Mutex>; @@ -14,7 +14,7 @@ pub struct Node<'tree> { } impl Debug for Node<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { debug_fmt_node(self, f) } } diff --git a/crates/egui_kittest/src/renderer.rs b/crates/egui_kittest/src/renderer.rs index 0806c4ead..1f18a7fa3 100644 --- a/crates/egui_kittest/src/renderer.rs +++ b/crates/egui_kittest/src/renderer.rs @@ -1,3 +1,4 @@ +use core::mem; use egui::TexturesDelta; pub trait TestRenderer { @@ -6,7 +7,7 @@ pub trait TestRenderer { fn setup_eframe(&self, _cc: &mut eframe::CreationContext<'_>, _frame: &mut eframe::Frame) {} /// Handle a [`TexturesDelta`] by updating the renderer's textures. - fn handle_delta(&mut self, delta: &TexturesDelta); + fn handle_delta(&mut self, delta: &mut TexturesDelta); /// Render the [`crate::Harness`] and return the resulting image. /// @@ -25,7 +26,7 @@ pub trait TestRenderer { /// By default, this will create a wgpu renderer if the wgpu feature is enabled. pub enum LazyRenderer { Uninitialized { - texture_ops: Vec, + textures_delta: TexturesDelta, builder: Option Box>>, }, Initialized { @@ -39,7 +40,7 @@ impl Default for LazyRenderer { return Self::new(crate::wgpu::WgpuTestRenderer::new); #[cfg(not(feature = "wgpu"))] return Self::Uninitialized { - texture_ops: Vec::new(), + textures_delta: Default::default(), builder: None, }; } @@ -48,16 +49,19 @@ impl Default for LazyRenderer { impl LazyRenderer { pub fn new(create_renderer: impl FnOnce() -> T + 'static) -> Self { Self::Uninitialized { - texture_ops: Vec::new(), + textures_delta: Default::default(), builder: Some(Box::new(move || Box::new(create_renderer()))), } } } impl TestRenderer for LazyRenderer { - fn handle_delta(&mut self, delta: &TexturesDelta) { + fn handle_delta(&mut self, delta: &mut TexturesDelta) { match self { - Self::Uninitialized { texture_ops, .. } => texture_ops.push(delta.clone()), + Self::Uninitialized { + textures_delta: texture_ops, + .. + } => texture_ops.append(mem::take(delta)), Self::Initialized { renderer } => renderer.handle_delta(delta), } } @@ -70,15 +74,15 @@ impl TestRenderer for LazyRenderer { ) -> Result { match self { Self::Uninitialized { - texture_ops, + textures_delta, builder: build, } => { let mut renderer = build.take().ok_or({ "No default renderer available. \ Enable the wgpu feature or set one via HarnessBuilder::renderer" })?(); - for delta in texture_ops.drain(..) { - renderer.handle_delta(&delta); + if !textures_delta.is_empty() { + renderer.handle_delta(textures_delta); } let image = renderer.render(ctx, output)?; *self = Self::Initialized { renderer }; @@ -88,3 +92,14 @@ impl TestRenderer for LazyRenderer { } } } + +impl Drop for LazyRenderer { + fn drop(&mut self) { + match self { + Self::Uninitialized { textures_delta, .. } => { + textures_delta.clear(); // Don't panic when dropping unapplied deltas + } + Self::Initialized { .. } => {} + } + } +} diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index 703ebe3f2..2f709cc3c 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -1,4 +1,4 @@ -use std::fmt::Display; +use core::fmt::Display; use std::io::ErrorKind; use std::path::PathBuf; @@ -11,18 +11,35 @@ pub type SnapshotResult = Result<(), SnapshotError>; #[non_exhaustive] #[derive(Clone, Debug)] pub struct SnapshotOptions { - /// The threshold for the image comparison. + /// How much a single pixel may differ before it is counted as failing: + /// the maximum weighted squared YIQ color distance between two corresponding pixels. + /// + /// This is a color tolerance, not an error budget for the image as a whole: + /// it is applied to each pixel pair on its own, and raising it makes every pixel + /// more forgiving. Use [`Self::max_failed_pixels`] to allow a number of pixels + /// to exceed it. /// /// Can be configured via kittest.toml. The fallback is `0.6` (which is enough for most egui /// tests to pass across different wgpu backends). pub threshold: f32, - /// The number of pixels that can differ before the snapshot is considered a failure. + /// The number of pixels that may fail the [`Self::threshold`] before the snapshot is + /// considered a failure. /// - /// Preferably, you should use `threshold` to control the sensitivity of the image comparison. + /// This is an absolute pixel count, not a fraction of the image, so the same value is + /// stricter for a large snapshot than for a small one. + /// + /// Preferably, you should use [`Self::threshold`] to control the sensitivity of the image + /// comparison. /// As a last resort, you can use this to allow a certain number of pixels to differ. + /// + /// Raise this only very carefully: a high value (more than ~10) is enough to hide a real + /// change, such as a moved separator, a shifted one-pixel border, or a small icon rendering + /// incorrectly. Prefer the smallest value that makes the test pass, and re-check it whenever + /// you update the snapshot. + /// /// Can be configured via kittest.toml. The fallback is `0` (meaning no pixels can differ). - pub failed_pixel_count_threshold: usize, + pub max_failed_pixels: usize, /// The path where the snapshots will be saved. /// @@ -33,12 +50,12 @@ pub struct SnapshotOptions { pub output_path: PathBuf, } -/// Helper struct to define the number of pixels that can differ before the snapshot is considered a failure. +/// Helper struct to define a per-OS comparison tolerance. /// -/// This is useful if you want to set different thresholds for different operating systems. +/// This is useful if you want to set different tolerances for different operating systems. /// /// [`OsThreshold::default`] gets the default from the config file (`kittest.toml`). -/// For `usize`, it's the `failed_pixel_count_threshold` value. +/// For `usize`, it's the `max_failed_pixels` value. /// For `f32`, it's the `threshold` value. /// /// Example usage: @@ -51,7 +68,7 @@ pub struct SnapshotOptions { /// "os_threshold_example", /// &SnapshotOptions::new() /// .threshold(OsThreshold::new(0.0).windows(10.0)) -/// .failed_pixel_count_threshold(OsThreshold::new(0).windows(10).macos(53) +/// .max_failed_pixels(OsThreshold::new(0).windows(10).macos(53) /// )) /// ``` #[derive(Debug, Clone, Copy)] @@ -63,11 +80,11 @@ pub struct OsThreshold { } impl Default for OsThreshold { - /// Returns the default `failed_pixel_count_threshold` as configured in `kittest.toml` + /// Returns the default `max_failed_pixels` as configured in `kittest.toml` /// /// The fallback is `0`. fn default() -> Self { - config().os_failed_pixel_count_threshold() + config().os_max_failed_pixels() } } @@ -158,7 +175,7 @@ impl Default for SnapshotOptions { Self { threshold: config().threshold(), output_path: config().output_path(), - failed_pixel_count_threshold: config().failed_pixel_count_threshold(), + max_failed_pixels: config().max_failed_pixels(), } } } @@ -169,7 +186,14 @@ impl SnapshotOptions { Default::default() } - /// Change the threshold for the image comparison. + /// Change how much a single pixel may differ before it is counted as failing: + /// the maximum weighted squared YIQ color distance between two corresponding pixels. + /// + /// This is a color tolerance, not an error budget for the image as a whole: + /// it is applied to each pixel pair on its own, and raising it makes every pixel + /// more forgiving. Use [`Self::max_failed_pixels`] to allow a number of pixels + /// to exceed it. + /// /// The default is `0.6` (which is enough for most egui tests to pass across different /// wgpu backends). #[inline] @@ -187,18 +211,33 @@ impl SnapshotOptions { self } - /// Change the number of pixels that can differ before the snapshot is considered a failure. + /// Change the number of pixels that may fail the [`Self::threshold`] before the snapshot is + /// considered a failure. + /// + /// This is an absolute pixel count, not a fraction of the image, so the same value is + /// stricter for a large snapshot than for a small one. /// /// Preferably, you should use [`Self::threshold`] to control the sensitivity of the image comparison. /// As a last resort, you can use this to allow a certain number of pixels to differ. + /// + /// Raise this only very carefully: a high value (more than ~10) is enough to hide a real + /// change, such as a moved separator, a shifted one-pixel border, or a small icon rendering + /// incorrectly. Prefer the smallest value that makes the test pass, and re-check it whenever + /// you update the snapshot. + #[inline] + pub fn max_failed_pixels(mut self, max_failed_pixels: impl Into>) -> Self { + self.max_failed_pixels = max_failed_pixels.into().threshold(); + self + } + + /// Renamed to [`Self::max_failed_pixels`]. + #[deprecated(since = "0.36.0", note = "Renamed to max_failed_pixels")] #[inline] pub fn failed_pixel_count_threshold( - mut self, - failed_pixel_count_threshold: impl Into>, + self, + max_failed_pixels: impl Into>, ) -> Self { - let failed_pixel_count_threshold = failed_pixel_count_threshold.into().threshold(); - self.failed_pixel_count_threshold = failed_pixel_count_threshold; - self + self.max_failed_pixels(max_failed_pixels) } } @@ -214,6 +253,14 @@ pub enum SnapshotError { /// Path where the diff image was saved diff_path: PathBuf, + + /// How many pixels would have failed at other per-pixel thresholds. + /// + /// Measured at [`THRESHOLD_SWEEP`], lowest threshold first. + /// Use this to pick a [`SnapshotOptions::threshold`] and a + /// [`SnapshotOptions::max_failed_pixels`] from measurements, + /// instead of by trial and error. + failing_pixels_by_threshold: Vec<(f32, i32)>, }, /// Error opening the existing snapshot (it probably doesn't exist, check the @@ -258,20 +305,30 @@ const HOW_TO_UPDATE_SCREENSHOTS: &str = "Run `UPDATE_SNAPSHOTS=1 cargo test --all-features` to update the snapshots."; impl Display for SnapshotError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Diff { name, diff, diff_path, + failing_pixels_by_threshold, } => { let diff_path = std::path::absolute(diff_path).unwrap_or_else(|_| diff_path.clone()); write!( f, - "'{name}' Image did not match snapshot. Diff: {diff}, {}. {HOW_TO_UPDATE_SCREENSHOTS}", + "'{name}' Image did not match snapshot. Diff: {diff}, {}.", diff_path.display() - ) + )?; + if !failing_pixels_by_threshold.is_empty() { + let sweep = failing_pixels_by_threshold + .iter() + .map(|(threshold, count)| format!("{threshold:.1}: {count}")) + .collect::>() + .join(", "); + write!(f, "\n Failing pixels by threshold: {sweep}")?; + } + write!(f, "\n {HOW_TO_UPDATE_SCREENSHOTS}") } Self::OpenSnapshot { path, err } => { let path = std::path::absolute(path).unwrap_or_else(|_| path.clone()); @@ -380,6 +437,37 @@ pub fn try_image_snapshot_options( try_image_snapshot_options_impl(new, name.into(), options) } +/// The per-pixel thresholds that a failing snapshot is measured against, +/// to help you pick a [`SnapshotOptions::threshold`]. +/// +/// Same unit as [`SnapshotOptions::threshold`]. +pub const THRESHOLD_SWEEP: &[f32] = &[0.0, 0.1, 0.2, 0.4, 0.6, 1.0, 1.5, 2.0, 3.0, 5.0, 10.0]; + +/// How many pixels differ by more than each of [`THRESHOLD_SWEEP`]? +/// +/// Only called for failing snapshots, so the extra comparisons don't slow down passing tests. +fn failing_pixels_by_threshold( + previous: &image::RgbaImage, + new: &image::RgbaImage, +) -> Vec<(f32, i32)> { + THRESHOLD_SWEEP + .iter() + .map(|&threshold| { + let num_wrong_pixels = dify::diff::get_results( + previous.clone(), + new.clone(), + threshold, + true, + None, + &None, + &None, + ) + .map_or(0, |(num_wrong_pixels, _diff_image)| num_wrong_pixels); + (threshold, num_wrong_pixels) + }) + .collect() +} + fn try_image_snapshot_options_impl( new: &image::RgbaImage, name: String, @@ -392,7 +480,7 @@ fn try_image_snapshot_options_impl( let SnapshotOptions { threshold, output_path, - failed_pixel_count_threshold, + max_failed_pixels, } = options; let parent_path = if let Some(parent) = PathBuf::from(&name).parent() { @@ -447,31 +535,31 @@ fn try_image_snapshot_options_impl( Ok(image) => image.to_rgba8(), Err(err) => { // No previous snapshot - probably a new test. - if mode.is_update() { - return update_snapshot(); + return if mode.is_update() { + update_snapshot() } else { write_new_png()?; - return Err(SnapshotError::OpenSnapshot { + Err(SnapshotError::OpenSnapshot { path: snapshot_path.clone(), err, - }); - } + }) + }; } }; if previous.dimensions() != new.dimensions() { - if mode.is_update() { - return update_snapshot(); + return if mode.is_update() { + update_snapshot() } else { write_new_png()?; - return Err(SnapshotError::SizeMismatch { + Err(SnapshotError::SizeMismatch { name, expected: previous.dimensions(), actual: new.dimensions(), - }); - } + }) + }; } // Compare existing image to the new one: @@ -481,14 +569,21 @@ fn try_image_snapshot_options_impl( *threshold }; - let result = - dify::diff::get_results(previous, new.clone(), threshold, true, None, &None, &None); + let result = dify::diff::get_results( + previous.clone(), + new.clone(), + threshold, + true, + None, + &None, + &None, + ); let Some((num_wrong_pixels, diff_image)) = result else { return Ok(()); // Difference below threshold }; - let below_threshold = num_wrong_pixels as i64 <= *failed_pixel_count_threshold as i64; + let below_threshold = num_wrong_pixels as i64 <= *max_failed_pixels as i64; if !below_threshold { diff_image @@ -510,6 +605,7 @@ fn try_image_snapshot_options_impl( name, diff: num_wrong_pixels, diff_path, + failing_pixels_by_threshold: failing_pixels_by_threshold(&previous, new), }) } } @@ -744,7 +840,7 @@ impl Harness<'_, State> { /// This removes the snapshot results from the harness. Useful if you e.g. want to merge it /// with the results from another harness (using [`SnapshotResults::add`]). pub fn take_snapshot_results(&mut self) -> SnapshotResults { - std::mem::take(&mut self.snapshot_results) + core::mem::take(&mut self.snapshot_results) } } @@ -776,7 +872,7 @@ impl Harness<'_, State> { pub struct SnapshotResults { errors: Vec, handled: bool, - location: std::panic::Location<'static>, + location: core::panic::Location<'static>, } impl Default for SnapshotResults { @@ -785,13 +881,13 @@ impl Default for SnapshotResults { Self { errors: Vec::new(), handled: true, // If no snapshots were added, we should consider this handled. - location: *std::panic::Location::caller(), + location: *core::panic::Location::caller(), } } } impl Display for SnapshotResults { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { if self.errors.is_empty() { write!(f, "All snapshots passed") } else { @@ -843,7 +939,7 @@ impl SnapshotResults { /// Consume this and return the list of errors. pub fn into_inner(mut self) -> Vec { self.handled = true; - std::mem::take(&mut self.errors) + core::mem::take(&mut self.errors) } /// Panics if there are any errors, displaying each. @@ -872,7 +968,7 @@ impl Drop for SnapshotResults { } thread_local! { - static UNHANDLED_SNAPSHOT_RESULTS_COUNTER: std::cell::RefCell = const { std::cell::RefCell::new(0) }; + static UNHANDLED_SNAPSHOT_RESULTS_COUNTER: core::cell::RefCell = const { core::cell::RefCell::new(0) }; } if !self.handled { diff --git a/crates/egui_kittest/src/texture_to_image.rs b/crates/egui_kittest/src/texture_to_image.rs index 289033fba..5c292c6cd 100644 --- a/crates/egui_kittest/src/texture_to_image.rs +++ b/crates/egui_kittest/src/texture_to_image.rs @@ -1,8 +1,8 @@ +use core::iter; +use core::mem::size_of; use egui_wgpu::wgpu; use egui_wgpu::wgpu::{Device, Extent3d, Queue, Texture}; use image::RgbaImage; -use std::iter; -use std::mem::size_of; use std::sync::mpsc::channel; use crate::wgpu::WAIT_TIMEOUT; diff --git a/crates/egui_kittest/src/wgpu.rs b/crates/egui_kittest/src/wgpu.rs index 22972eabc..af751fe40 100644 --- a/crates/egui_kittest/src/wgpu.rs +++ b/crates/egui_kittest/src/wgpu.rs @@ -1,5 +1,5 @@ +use core::{iter::once, time::Duration}; use std::sync::Arc; -use std::{iter::once, time::Duration}; use egui::TexturesDelta; use egui_wgpu::{RenderState, ScreenDescriptor, WgpuSetup, wgpu}; @@ -137,15 +137,23 @@ impl crate::TestRenderer for WgpuTestRenderer { frame.wgpu_render_state = Some(self.render_state.clone()); } - fn handle_delta(&mut self, delta: &TexturesDelta) { + fn handle_delta(&mut self, delta: &mut TexturesDelta) { let mut renderer = self.render_state.renderer.write(); - for (id, image) in &delta.set { - renderer.update_texture( - &self.render_state.device, - &self.render_state.queue, - *id, - image, - ); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, images) in delta.set.drain() { + for image in images { + renderer.update_texture( + &self.render_state.device, + &self.render_state.queue, + id, + &image, + ); + } + } + + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for id in delta.free.drain() { + renderer.free_texture(&id); } } @@ -222,7 +230,7 @@ impl crate::TestRenderer for WgpuTestRenderer { self.render_state .queue - .submit(std::iter::chain(user_buffers, once(encoder.finish()))); + .submit(core::iter::chain(user_buffers, once(encoder.finish()))); self.render_state .device diff --git a/crates/egui_kittest/tests/accesskit.rs b/crates/egui_kittest/tests/accesskit.rs index 30dcbaf29..fea8f26da 100644 --- a/crates/egui_kittest/tests/accesskit.rs +++ b/crates/egui_kittest/tests/accesskit.rs @@ -144,7 +144,8 @@ fn accesskit_output_single_egui_frame(run_ui: impl FnMut(&mut Ui)) -> TreeUpdate ctx.global_style_mut(|style| style.animation_time = 0.0); ctx.enable_accesskit(); - let output = ctx.run_ui(RawInput::default(), run_ui); + let mut output = ctx.run_ui(RawInput::default(), run_ui); + output.textures_delta.clear(); // Don't panic on drop with unapplied deltas output .platform_output diff --git a/crates/egui_kittest/tests/popup.rs b/crates/egui_kittest/tests/popup.rs index 4dbe33909..9d9dba654 100644 --- a/crates/egui_kittest/tests/popup.rs +++ b/crates/egui_kittest/tests/popup.rs @@ -67,6 +67,85 @@ fn reopened_popup_resizes_for_wider_items() { ); } +#[test] +fn open_popup_resizes_after_explicit_sizing_pass() { + const POPUP_BUTTON: &str = "Growing popup"; + const MAX_HEIGHT: f32 = 100.0; + + struct State { + item_count: usize, + needs_sizing_pass: bool, + popup_height: f32, + viewport_height: f32, + content_height: f32, + } + + let mut harness = Harness::builder() + .with_size(egui::Vec2::new(500.0, 300.0)) + .build_ui_state( + |ui, state| { + let response = ui.button(POPUP_BUTTON); + let needs_sizing_pass = core::mem::take(&mut state.needs_sizing_pass); + let item_count = state.item_count; + + if let Some(popup) = Popup::from_response(&response) + .sizing_pass(needs_sizing_pass) + .show(|ui| { + egui::ScrollArea::vertical() + .max_height(MAX_HEIGHT) + .show(ui, |ui| { + for index in 0..item_count { + ui.label(format!("Item {index}")); + } + }) + }) + { + state.popup_height = popup.response.rect.height(); + state.viewport_height = popup.inner.inner_rect.height(); + state.content_height = popup.inner.content_size.y; + } + }, + State { + item_count: 2, + needs_sizing_pass: false, + popup_height: 0.0, + viewport_height: 0.0, + content_height: 0.0, + }, + ); + + harness.run(); + let initial_popup_height = harness.state().popup_height; + + harness.state_mut().item_count = 20; + harness.run(); + let stale_viewport_height = harness.state().viewport_height; + assert!( + stale_viewport_height < MAX_HEIGHT, + "viewport unexpectedly reached its maximum without a sizing pass" + ); + + harness.state_mut().needs_sizing_pass = true; + harness.run(); + + assert!( + harness.state().popup_height > initial_popup_height, + "popup did not grow after an explicit sizing pass" + ); + assert!( + harness.state().viewport_height > stale_viewport_height, + "scroll viewport did not grow after an explicit sizing pass" + ); + assert!( + (harness.state().viewport_height - MAX_HEIGHT).abs() <= 0.5, + "scroll viewport did not stop at its maximum height" + ); + assert!( + harness.state().content_height > harness.state().viewport_height, + "popup contents did not remain scrollable at the maximum height" + ); +} + #[test] fn test_interactive_tooltip() { struct State { diff --git a/crates/egui_kittest/tests/regression_tests.rs b/crates/egui_kittest/tests/regression_tests.rs index ba39a909b..1f47cd422 100644 --- a/crates/egui_kittest/tests/regression_tests.rs +++ b/crates/egui_kittest/tests/regression_tests.rs @@ -513,6 +513,61 @@ fn window_resize_wraps_to_content_min_width() { ); } +/// A `Grid` gives its last column all the available width, so a width-filling widget in it +/// (here a `Separator`) makes the grid remember a column width that is really just +/// "however wide the window happened to be". +/// +/// When `Resize` then measures the minimum content width in a sizing pass, that remembered +/// width must not be reported as the minimum — otherwise the window can be widened but +/// never shrunk again. +#[test] +fn window_with_grid_can_shrink_after_being_widened() { + let window_title = "grid_shrink_regression"; + let mut harness = Harness::builder() + .with_size(Vec2::new(800.0, 600.0)) + .build_ui(move |ui| { + Window::new(window_title) + .default_pos([20.0, 20.0]) + .default_width(280.0) + .show(ui.ctx(), |ui| { + egui::Grid::new("grid").num_columns(2).show(ui, |ui| { + ui.label("Separator"); + ui.separator(); // Fills the available width + ui.end_row(); + }); + }); + }); + harness.run(); + + let drag_right_edge = |harness: &mut Harness<'_>, dx: f32| { + let rect = harness + .get_by_role_and_label(Role::Window, window_title) + .rect(); + let grab = Pos2::new(rect.right(), rect.center().y); + harness.hover_at(grab); + harness.run(); + harness.drag_at(grab); + harness.run(); + harness.hover_at(grab + Vec2::new(dx, 0.0)); + harness.run(); + harness.drop_at(grab + Vec2::new(dx, 0.0)); + harness.run(); + harness + .get_by_role_and_label(Role::Window, window_title) + .rect() + .width() + }; + + let widened = drag_right_edge(&mut harness, 300.0); + let shrunk = drag_right_edge(&mut harness, -300.0); + + assert!( + shrunk < widened - 200.0, + "window could not be shrunk again after being widened: \ + widened to {widened}, then only shrunk to {shrunk}" + ); +} + /// Ensure that the size passed to window is actually treated as outer size (including /// margins and borders). #[test] @@ -607,7 +662,7 @@ fn window_fixed_size_is_outer_size() { /// allowed size — they used to inherit the overflowing content rect. #[test] fn panel_rect_clamped_when_content_overflows() { - use std::cell::RefCell; + use core::cell::RefCell; let side_panel_width = 100.0_f32; let top_panel_height = 80.0_f32; @@ -668,7 +723,7 @@ fn panel_rect_clamped_when_content_overflows() { /// portion of the panel. #[test] fn collapsing_panel_must_not_grow_enclosing_window() { - use std::cell::RefCell; + use core::cell::RefCell; let window_rect: RefCell> = RefCell::new(None); let is_expanded: RefCell = RefCell::new(true); @@ -713,3 +768,146 @@ fn collapsing_panel_must_not_grow_enclosing_window() { ); } } + +/// The hint text of a `TextEdit` should follow the same alignment as the input +/// text, instead of always being left-top aligned. +/// +/// Regression test for . +#[test] +pub fn textedit_hint_text_should_follow_text_alignment() { + let mut input = String::new(); + + let mut harness = Harness::builder() + .with_size(Vec2::new(200.0, 40.0)) + .build_ui(|ui| { + ui.add( + egui::TextEdit::singleline(&mut input) + .hint_text("Hint") + .desired_width(200.0) + .horizontal_align(egui::Align::Center), + ); + }); + harness.run(); + + let text_edit = harness.get_by_role(accesskit::Role::TextInput); + let edit_rect = text_edit.rect(); + + // Find the hint text shape (the only text shape while the input is empty). + let hint_shape = harness + .output() + .shapes + .iter() + .find_map(|clipped| { + let egui::epaint::Shape::Text(text_shape) = &clipped.shape else { + return None; + }; + (text_shape.galley.text() == "Hint").then_some(text_shape) + }) + .expect("hint text shape should be painted"); + + let hint_center_x = hint_shape.pos.x + hint_shape.galley.size().x / 2.0; + let edit_center_x = edit_rect.center().x; + + assert!( + (hint_center_x - edit_center_x).abs() < 1.0, + "hint text should be centered in the TextEdit: hint_center_x={hint_center_x}, \ + edit_center_x={edit_center_x}, edit_rect={edit_rect:?}", + ); +} + +/// A focused `DragValue` keeps the text the user is editing in memory. +/// +/// If something else changes the value while the `DragValue` has focus, +/// that memorized text is stale, and must not be written back to the value. +/// +/// Regression test for . +#[test] +pub fn drag_value_should_not_revert_external_changes_while_focused() { + let mut harness = Harness::new_ui_state( + |ui, value: &mut i32| { + ui.add(egui::DragValue::new(value)); + }, + 0, + ); + + // Focus the `DragValue`, putting it in text-edit mode. + harness.key_press(egui::Key::Tab); + harness.run(); + + // Something else changes the value while the `DragValue` is focused. + *harness.state_mut() = 42; + harness.run(); + + assert_eq!(harness.state(), &42); + let drag_value = harness.get_by_role(accesskit::Role::SpinButton); + assert_eq!(drag_value.value(), Some("42".to_owned())); + + // Losing focus must not restore the value the `DragValue` had when it gained focus. + harness.key_press(egui::Key::Tab); + harness.run(); + + assert_eq!(harness.state(), &42); +} + +/// While the user is typing into a `DragValue`, the half-finished text must be kept +/// between frames, even though it doesn't always parse back to the same text. +#[test] +pub fn drag_value_should_keep_text_while_typing() { + let mut harness = Harness::new_ui_state( + |ui, value: &mut f64| { + ui.add(egui::DragValue::new(value)); + }, + 0.0, + ); + + // Focus the `DragValue`, putting it in text-edit mode with the old text selected. + harness.key_press(egui::Key::Tab); + harness.run(); + + // Type one character per frame. `"1."` parses to `1`, which is formatted as `"1"`, + // so re-reading the text from the value would eat the decimal point. + for character in "1.25".chars() { + harness + .get_by_role(accesskit::Role::SpinButton) + .type_text(&character.to_string()); + harness.run(); + } + + harness.key_press(egui::Key::Enter); + harness.run(); + + assert_eq!(harness.state(), &1.25); +} + +/// An integer `DragValue` cannot represent everything the user types into it, +/// but the text must still survive until the user is done typing. +#[test] +pub fn drag_value_should_keep_text_the_value_cannot_represent() { + let mut harness = Harness::new_ui_state( + |ui, value: &mut i32| { + ui.add(egui::DragValue::new(value)); + }, + 0, + ); + + // Focus the `DragValue`, putting it in text-edit mode with the old text selected. + harness.key_press(egui::Key::Tab); + harness.run(); + + // `"12.5"` is stored as `12`, which is formatted as `"12"`. + harness + .get_by_role(accesskit::Role::SpinButton) + .type_text("12.5"); + harness.run(); + + // If the text was re-read from the value now, this would append to `"12"`. + harness + .get_by_role(accesskit::Role::SpinButton) + .type_text("9"); + harness.run(); + + harness.key_press(egui::Key::Enter); + harness.run(); + + assert_eq!(harness.state(), &12, "The text should have been \"12.59\""); +} diff --git a/crates/emath/CHANGELOG.md b/crates/emath/CHANGELOG.md index 4aae540c5..2361112d7 100644 --- a/crates/emath/CHANGELOG.md +++ b/crates/emath/CHANGELOG.md @@ -6,6 +6,14 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +Nothing new + + ## 0.35.0 - 2026-06-25 Nothing new diff --git a/crates/emath/src/align.rs b/crates/emath/src/align.rs index 395323d4f..4001af4bf 100644 --- a/crates/emath/src/align.rs +++ b/crates/emath/src/align.rs @@ -274,7 +274,7 @@ impl Align2 { } } -impl std::ops::Index for Align2 { +impl core::ops::Index for Align2 { type Output = Align; #[inline(always)] @@ -283,7 +283,7 @@ impl std::ops::Index for Align2 { } } -impl std::ops::IndexMut for Align2 { +impl core::ops::IndexMut for Align2 { #[inline(always)] fn index_mut(&mut self, index: usize) -> &mut Align { &mut self.0[index] @@ -299,8 +299,8 @@ pub fn center_size_in_rect(size: Vec2, frame: Rect) -> Rect { Align2::CENTER_CENTER.align_size_within_rect(size, frame) } -impl std::fmt::Debug for Align2 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for Align2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "Align2({:?}, {:?})", self.x(), self.y()) } } diff --git a/crates/emath/src/easing.rs b/crates/emath/src/easing.rs index 95fc7250d..57c439f27 100644 --- a/crates/emath/src/easing.rs +++ b/crates/emath/src/easing.rs @@ -5,7 +5,9 @@ //! All functions take a value in `[0, 1]` and return a value in `[0, 1]`. //! //! Derived from . -use std::f32::consts::PI; +use core::f32::consts::PI; + +use crate::fast_midpoint; #[inline] fn powf(base: f32, exp: f32) -> f32 { @@ -116,7 +118,7 @@ pub fn circular_in_out(t: f32) -> f32 { if t < 0.5 { 0.5 * (1. - (1. - 4. * t * t).sqrt()) } else { - 0.5 * ((-(2. * t - 3.) * (2. * t - 1.)).sqrt() + 1.) + fast_midpoint((-(2. * t - 3.) * (2. * t - 1.)).sqrt(), 1.) } } diff --git a/crates/emath/src/history.rs b/crates/emath/src/history.rs index 4a49defa2..3b0d1fb56 100644 --- a/crates/emath/src/history.rs +++ b/crates/emath/src/history.rs @@ -52,7 +52,7 @@ where /// history.add(now(), 44.0_f32); /// assert_eq!(history.average(), Some(42.0)); /// ``` - pub fn new(length_range: std::ops::Range, max_age: f32) -> Self { + pub fn new(length_range: core::ops::Range, max_age: f32) -> Self { Self { min_len: length_range.start, max_len: length_range.end, @@ -175,8 +175,8 @@ where impl History where T: Copy, - T: std::iter::Sum, - T: std::ops::Div, + T: core::iter::Sum, + T: core::ops::Div, { #[inline] pub fn sum(&self) -> T { @@ -196,9 +196,9 @@ where impl History where T: Copy, - T: std::iter::Sum, - T: std::ops::Div, - T: std::ops::Mul, + T: core::iter::Sum, + T: core::ops::Div, + T: core::ops::Mul, { /// Average times rate. /// If you are keeping track of individual sizes of things (e.g. bytes), @@ -211,8 +211,8 @@ where impl History where T: Copy, - T: std::ops::Sub, - Vel: std::ops::Div, + T: core::ops::Sub, + Vel: core::ops::Div, { /// Calculate a smooth velocity (per second) over the entire time span. /// Calculated as the last value minus the first value over the elapsed time between them. diff --git a/crates/emath/src/lib.rs b/crates/emath/src/lib.rs index 92e34620a..1e7b3b807 100644 --- a/crates/emath/src/lib.rs +++ b/crates/emath/src/lib.rs @@ -21,7 +21,7 @@ #![expect(clippy::float_cmp)] -use std::ops::{Add, Div, Mul, RangeInclusive, Sub}; +use core::ops::{Add, Div, Mul, RangeInclusive, Sub}; // ---------------------------------------------------------------------------- @@ -269,7 +269,7 @@ fn test_format() { assert_eq!(format_with_minimum_decimals(3.14, 2), "3.14"); assert_eq!(format_with_minimum_decimals(3.14, 3), "3.140"); assert_eq!( - format_with_minimum_decimals(std::f64::consts::PI, 2), + format_with_minimum_decimals(core::f64::consts::PI, 2), "3.14159" ); } @@ -365,7 +365,7 @@ impl_num_ext!(Pos2); /// Wrap angle to `[-PI, PI]` range. pub fn normalized_angle(mut angle: f32) -> f32 { - use std::f32::consts::{PI, TAU}; + use core::f32::consts::{PI, TAU}; angle %= TAU; if angle > PI { angle -= TAU; @@ -385,7 +385,7 @@ fn test_normalized_angle() { }; } - use std::f32::consts::TAU; + use core::f32::consts::TAU; almost_eq!(normalized_angle(-3.0 * TAU), 0.0); almost_eq!(normalized_angle(-2.3 * TAU), -0.3 * TAU); almost_eq!(normalized_angle(-TAU), 0.0); diff --git a/crates/emath/src/numeric.rs b/crates/emath/src/numeric.rs index b4b6174e2..a94cfbe6d 100644 --- a/crates/emath/src/numeric.rs +++ b/crates/emath/src/numeric.rs @@ -92,9 +92,9 @@ impl_numeric_integer!(i64); impl_numeric_integer!(u64); impl_numeric_integer!(isize); impl_numeric_integer!(usize); -impl_numeric_non_zero_unsigned!(std::num::NonZeroU8); -impl_numeric_non_zero_unsigned!(std::num::NonZeroU16); -impl_numeric_non_zero_unsigned!(std::num::NonZeroU32); -impl_numeric_non_zero_unsigned!(std::num::NonZeroU64); -impl_numeric_non_zero_unsigned!(std::num::NonZeroU128); -impl_numeric_non_zero_unsigned!(std::num::NonZeroUsize); +impl_numeric_non_zero_unsigned!(core::num::NonZeroU8); +impl_numeric_non_zero_unsigned!(core::num::NonZeroU16); +impl_numeric_non_zero_unsigned!(core::num::NonZeroU32); +impl_numeric_non_zero_unsigned!(core::num::NonZeroU64); +impl_numeric_non_zero_unsigned!(core::num::NonZeroU128); +impl_numeric_non_zero_unsigned!(core::num::NonZeroUsize); diff --git a/crates/emath/src/ordered_float.rs b/crates/emath/src/ordered_float.rs index 369541fed..dc86e2472 100644 --- a/crates/emath/src/ordered_float.rs +++ b/crates/emath/src/ordered_float.rs @@ -1,8 +1,8 @@ //! Total order on floating point types. //! Can be used for sorting, min/max computation, and other collection algorithms. -use std::cmp::Ordering; -use std::hash::{Hash, Hasher}; +use core::cmp::Ordering; +use core::hash::{Hash, Hasher}; /// Wraps a floating-point value to add total order and hash. /// Possible types for `T` are `f32` and `f64`. @@ -21,9 +21,9 @@ impl OrderedFloat { } } -impl std::fmt::Debug for OrderedFloat { +impl core::fmt::Debug for OrderedFloat { #[inline] - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { self.0.fmt(f) } } diff --git a/crates/emath/src/pos2.rs b/crates/emath/src/pos2.rs index f67767e6b..d331769dc 100644 --- a/crates/emath/src/pos2.rs +++ b/crates/emath/src/pos2.rs @@ -1,4 +1,4 @@ -use std::{ +use core::{ fmt, ops::{Add, AddAssign, MulAssign, Sub, SubAssign}, }; @@ -206,7 +206,7 @@ impl Pos2 { } } -impl std::ops::Index for Pos2 { +impl core::ops::Index for Pos2 { type Output = f32; #[inline(always)] @@ -219,7 +219,7 @@ impl std::ops::Index for Pos2 { } } -impl std::ops::IndexMut for Pos2 { +impl core::ops::IndexMut for Pos2 { #[inline(always)] fn index_mut(&mut self, index: usize) -> &mut f32 { match index { diff --git a/crates/emath/src/range.rs b/crates/emath/src/range.rs index ffd34dc20..991659f5d 100644 --- a/crates/emath/src/range.rs +++ b/crates/emath/src/range.rs @@ -1,4 +1,6 @@ -use std::ops::{RangeFrom, RangeFull, RangeInclusive, RangeToInclusive}; +use core::ops::{RangeFrom, RangeFull, RangeInclusive, RangeToInclusive}; + +use crate::fast_midpoint; /// Inclusive range of floats, i.e. `min..=max`, but more ergonomic than [`RangeInclusive`]. #[repr(C)] @@ -52,7 +54,7 @@ impl Rangef { /// The center of the range #[inline] pub fn center(self) -> f32 { - 0.5 * (self.min + self.max) + fast_midpoint(self.min, self.max) } #[inline] @@ -170,6 +172,14 @@ impl From<&RangeInclusive> for Rangef { } } +/// Makes specifying size ranges slightly more convenient (no need for the extra `.0` suffixes) +impl From> for Rangef { + #[inline] + fn from(range: RangeInclusive) -> Self { + Self::new(*range.start() as _, *range.end() as _) + } +} + impl From> for Rangef { #[inline] fn from(range: RangeFrom) -> Self { diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index 8fd04b431..f01387c18 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -1,7 +1,7 @@ use std::fmt; use crate::{Div, Mul, NumExt as _, Pos2, Rangef, Rot2, Vec2, fast_midpoint, lerp, pos2, vec2}; -use std::ops::{BitOr, BitOrAssign}; +use core::ops::{BitOr, BitOrAssign}; /// A rectangular region of space. /// @@ -727,7 +727,7 @@ impl Rect { let mut t1 = (self.max[i] - self.center()[i]) * inv_d; if inv_d < 0.0 { - std::mem::swap(&mut t0, &mut t1); + core::mem::swap(&mut t0, &mut t1); } tmin = tmin.max(t0); diff --git a/crates/emath/src/rect_transform.rs b/crates/emath/src/rect_transform.rs index 3539efe75..05355620a 100644 --- a/crates/emath/src/rect_transform.rs +++ b/crates/emath/src/rect_transform.rs @@ -65,7 +65,7 @@ impl RectTransform { } /// Transforms the position. -impl std::ops::Mul for RectTransform { +impl core::ops::Mul for RectTransform { type Output = Pos2; fn mul(self, pos: Pos2) -> Pos2 { @@ -74,7 +74,7 @@ impl std::ops::Mul for RectTransform { } /// Transforms the position. -impl std::ops::Mul for &RectTransform { +impl core::ops::Mul for &RectTransform { type Output = Pos2; fn mul(self, pos: Pos2) -> Pos2 { diff --git a/crates/emath/src/rot2.rs b/crates/emath/src/rot2.rs index 9af0103a0..9aa4b52d7 100644 --- a/crates/emath/src/rot2.rs +++ b/crates/emath/src/rot2.rs @@ -92,8 +92,8 @@ impl Rot2 { } } -impl std::fmt::Debug for Rot2 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for Rot2 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { if let Some(precision) = f.precision() { write!( f, @@ -113,7 +113,7 @@ impl std::fmt::Debug for Rot2 { } } -impl std::ops::Mul for Rot2 { +impl core::ops::Mul for Rot2 { type Output = Self; #[inline] @@ -130,7 +130,7 @@ impl std::ops::Mul for Rot2 { } /// Rotates (and maybe scales) the vector. -impl std::ops::Mul for Rot2 { +impl core::ops::Mul for Rot2 { type Output = Vec2; #[inline] @@ -143,7 +143,7 @@ impl std::ops::Mul for Rot2 { } /// Scales the rotor. -impl std::ops::Mul for f32 { +impl core::ops::Mul for f32 { type Output = Rot2; #[inline] @@ -156,7 +156,7 @@ impl std::ops::Mul for f32 { } /// Scales the rotor. -impl std::ops::Mul for Rot2 { +impl core::ops::Mul for Rot2 { type Output = Self; #[inline] @@ -169,7 +169,7 @@ impl std::ops::Mul for Rot2 { } /// Scales the rotor. -impl std::ops::Div for Rot2 { +impl core::ops::Div for Rot2 { type Output = Self; #[inline] @@ -189,7 +189,7 @@ mod test { #[test] fn test_rotation2() { { - let angle = std::f32::consts::TAU / 6.0; + let angle = core::f32::consts::TAU / 6.0; let rot = Rot2::from_angle(angle); assert!((rot.angle() - angle).abs() < 1e-5); assert!((rot * rot.inverse()).angle().abs() < 1e-5); @@ -197,14 +197,14 @@ mod test { } { - let angle = std::f32::consts::TAU / 4.0; + let angle = core::f32::consts::TAU / 4.0; let rot = Rot2::from_angle(angle); assert!(((rot * vec2(1.0, 0.0)) - vec2(0.0, 1.0)).length() < 1e-5); } { // Test rotation and scaling - let angle = std::f32::consts::TAU / 4.0; + let angle = core::f32::consts::TAU / 4.0; let rot = 3.0 * Rot2::from_angle(angle); let rotated = rot * vec2(1.0, 0.0); let expected = vec2(0.0, 3.0); diff --git a/crates/emath/src/ts_transform.rs b/crates/emath/src/ts_transform.rs index 515f5ecd7..5d36911ed 100644 --- a/crates/emath/src/ts_transform.rs +++ b/crates/emath/src/ts_transform.rs @@ -109,7 +109,7 @@ impl TSTransform { } /// Transforms the position. -impl std::ops::Mul for TSTransform { +impl core::ops::Mul for TSTransform { type Output = Pos2; #[inline] @@ -119,7 +119,7 @@ impl std::ops::Mul for TSTransform { } /// Transforms the rectangle. -impl std::ops::Mul for TSTransform { +impl core::ops::Mul for TSTransform { type Output = Rect; #[inline] @@ -128,7 +128,7 @@ impl std::ops::Mul for TSTransform { } } -impl std::ops::Mul for TSTransform { +impl core::ops::Mul for TSTransform { type Output = Self; #[inline] diff --git a/crates/emath/src/vec2.rs b/crates/emath/src/vec2.rs index f79359df9..5422aa79b 100644 --- a/crates/emath/src/vec2.rs +++ b/crates/emath/src/vec2.rs @@ -1,5 +1,5 @@ +use core::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use std::fmt; -use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use crate::Vec2b; @@ -322,7 +322,7 @@ impl Vec2 { } } -impl std::ops::Index for Vec2 { +impl core::ops::Index for Vec2 { type Output = f32; #[inline(always)] @@ -335,7 +335,7 @@ impl std::ops::Index for Vec2 { } } -impl std::ops::IndexMut for Vec2 { +impl core::ops::IndexMut for Vec2 { #[inline(always)] fn index_mut(&mut self, index: usize) -> &mut f32 { match index { @@ -514,7 +514,7 @@ mod test { #[test] fn test_vec2() { - use std::f32::consts::TAU; + use core::f32::consts::TAU; assert_eq!(Vec2::ZERO.angle(), 0.0); assert_eq!(Vec2::angled(0.0).angle(), 0.0); @@ -547,7 +547,7 @@ mod test { #[test] fn test_vec2_normalized() { fn generate_spiral(n: usize, start: Vec2, end: Vec2) -> impl Iterator { - let angle_step = 2.0 * std::f32::consts::PI / n as f32; + let angle_step = 2.0 * core::f32::consts::PI / n as f32; let radius_step = (end.length() - start.length()) / n as f32; (0..n).map(move |i| { diff --git a/crates/emath/src/vec2b.rs b/crates/emath/src/vec2b.rs index 673f2959e..be5c36f78 100644 --- a/crates/emath/src/vec2b.rs +++ b/crates/emath/src/vec2b.rs @@ -67,7 +67,7 @@ impl From<[bool; 2]> for Vec2b { } } -impl std::ops::Index for Vec2b { +impl core::ops::Index for Vec2b { type Output = bool; #[inline(always)] @@ -80,7 +80,7 @@ impl std::ops::Index for Vec2b { } } -impl std::ops::IndexMut for Vec2b { +impl core::ops::IndexMut for Vec2b { #[inline(always)] fn index_mut(&mut self, index: usize) -> &mut bool { match index { @@ -91,7 +91,7 @@ impl std::ops::IndexMut for Vec2b { } } -impl std::ops::Not for Vec2b { +impl core::ops::Not for Vec2b { type Output = Self; #[inline] diff --git a/crates/epaint/CHANGELOG.md b/crates/epaint/CHANGELOG.md index 4bb868bd3..d0db754f3 100644 --- a/crates/epaint/CHANGELOG.md +++ b/crates/epaint/CHANGELOG.md @@ -5,6 +5,15 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +* Add `LayoutJob::clear` [#8376](https://github.com/emilk/egui/pull/8376) by [@emilk](https://github.com/emilk) +* Add `extra_text_line_spacing` to control vertical spacing between text lines [#8040](https://github.com/emilk/egui/pull/8040) by [@rustbasic](https://github.com/rustbasic) + + ## 0.35.0 - 2026-06-25 ### ⭐ Added * Make the size of tabs and thin space configurable [#8070](https://github.com/emilk/egui/pull/8070) by [@emilk](https://github.com/emilk) diff --git a/crates/epaint/benches/benchmark.rs b/crates/epaint/benches/benchmark.rs index 8fbfc65ea..5cafeaf0f 100644 --- a/crates/epaint/benches/benchmark.rs +++ b/crates/epaint/benches/benchmark.rs @@ -5,7 +5,7 @@ use epaint::{ Tessellator, TextureAtlas, Vec2, pos2, tessellator::Path, }; -use std::hint::black_box; +use core::hint::black_box; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; // Much faster allocator diff --git a/crates/epaint/src/color.rs b/crates/epaint/src/color.rs index 54106c10d..8aa4f56df 100644 --- a/crates/epaint/src/color.rs +++ b/crates/epaint/src/color.rs @@ -1,4 +1,5 @@ -use std::{fmt::Debug, sync::Arc}; +use core::fmt::Debug; +use std::sync::Arc; use ecolor::Color32; use emath::{Pos2, Rect}; @@ -25,7 +26,7 @@ impl Default for ColorMode { } impl Debug for ColorMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Solid(arg0) => f.debug_tuple("Solid").field(arg0).finish(), Self::UV(_arg0) => f.debug_tuple("UV").field(&"").finish(), diff --git a/crates/epaint/src/corner_radius.rs b/crates/epaint/src/corner_radius.rs index 07bd56c9e..dd0b432b9 100644 --- a/crates/epaint/src/corner_radius.rs +++ b/crates/epaint/src/corner_radius.rs @@ -99,7 +99,7 @@ impl CornerRadius { } } -impl std::ops::Add for CornerRadius { +impl core::ops::Add for CornerRadius { type Output = Self; #[inline] fn add(self, rhs: Self) -> Self { @@ -112,7 +112,7 @@ impl std::ops::Add for CornerRadius { } } -impl std::ops::Add for CornerRadius { +impl core::ops::Add for CornerRadius { type Output = Self; #[inline] fn add(self, rhs: u8) -> Self { @@ -125,7 +125,7 @@ impl std::ops::Add for CornerRadius { } } -impl std::ops::AddAssign for CornerRadius { +impl core::ops::AddAssign for CornerRadius { #[inline] fn add_assign(&mut self, rhs: Self) { *self = Self { @@ -137,7 +137,7 @@ impl std::ops::AddAssign for CornerRadius { } } -impl std::ops::AddAssign for CornerRadius { +impl core::ops::AddAssign for CornerRadius { #[inline] fn add_assign(&mut self, rhs: u8) { *self = Self { @@ -149,7 +149,7 @@ impl std::ops::AddAssign for CornerRadius { } } -impl std::ops::Sub for CornerRadius { +impl core::ops::Sub for CornerRadius { type Output = Self; #[inline] fn sub(self, rhs: Self) -> Self { @@ -162,7 +162,7 @@ impl std::ops::Sub for CornerRadius { } } -impl std::ops::Sub for CornerRadius { +impl core::ops::Sub for CornerRadius { type Output = Self; #[inline] fn sub(self, rhs: u8) -> Self { @@ -175,7 +175,7 @@ impl std::ops::Sub for CornerRadius { } } -impl std::ops::SubAssign for CornerRadius { +impl core::ops::SubAssign for CornerRadius { #[inline] fn sub_assign(&mut self, rhs: Self) { *self = Self { @@ -187,7 +187,7 @@ impl std::ops::SubAssign for CornerRadius { } } -impl std::ops::SubAssign for CornerRadius { +impl core::ops::SubAssign for CornerRadius { #[inline] fn sub_assign(&mut self, rhs: u8) { *self = Self { @@ -199,7 +199,7 @@ impl std::ops::SubAssign for CornerRadius { } } -impl std::ops::Div for CornerRadius { +impl core::ops::Div for CornerRadius { type Output = Self; #[inline] fn div(self, rhs: f32) -> Self { @@ -212,7 +212,7 @@ impl std::ops::Div for CornerRadius { } } -impl std::ops::DivAssign for CornerRadius { +impl core::ops::DivAssign for CornerRadius { #[inline] fn div_assign(&mut self, rhs: f32) { *self = Self { @@ -224,7 +224,7 @@ impl std::ops::DivAssign for CornerRadius { } } -impl std::ops::Mul for CornerRadius { +impl core::ops::Mul for CornerRadius { type Output = Self; #[inline] fn mul(self, rhs: f32) -> Self { @@ -237,7 +237,7 @@ impl std::ops::Mul for CornerRadius { } } -impl std::ops::MulAssign for CornerRadius { +impl core::ops::MulAssign for CornerRadius { #[inline] fn mul_assign(&mut self, rhs: f32) { *self = Self { diff --git a/crates/epaint/src/corner_radius_f32.rs b/crates/epaint/src/corner_radius_f32.rs index 0a88aaac7..ef6e597f5 100644 --- a/crates/epaint/src/corner_radius_f32.rs +++ b/crates/epaint/src/corner_radius_f32.rs @@ -111,7 +111,7 @@ impl CornerRadiusF32 { } } -impl std::ops::Add for CornerRadiusF32 { +impl core::ops::Add for CornerRadiusF32 { type Output = Self; #[inline] fn add(self, rhs: Self) -> Self { @@ -124,7 +124,7 @@ impl std::ops::Add for CornerRadiusF32 { } } -impl std::ops::AddAssign for CornerRadiusF32 { +impl core::ops::AddAssign for CornerRadiusF32 { #[inline] fn add_assign(&mut self, rhs: Self) { *self = Self { @@ -136,7 +136,7 @@ impl std::ops::AddAssign for CornerRadiusF32 { } } -impl std::ops::AddAssign for CornerRadiusF32 { +impl core::ops::AddAssign for CornerRadiusF32 { #[inline] fn add_assign(&mut self, rhs: f32) { *self = Self { @@ -148,7 +148,7 @@ impl std::ops::AddAssign for CornerRadiusF32 { } } -impl std::ops::Sub for CornerRadiusF32 { +impl core::ops::Sub for CornerRadiusF32 { type Output = Self; #[inline] fn sub(self, rhs: Self) -> Self { @@ -161,7 +161,7 @@ impl std::ops::Sub for CornerRadiusF32 { } } -impl std::ops::SubAssign for CornerRadiusF32 { +impl core::ops::SubAssign for CornerRadiusF32 { #[inline] fn sub_assign(&mut self, rhs: Self) { *self = Self { @@ -173,7 +173,7 @@ impl std::ops::SubAssign for CornerRadiusF32 { } } -impl std::ops::SubAssign for CornerRadiusF32 { +impl core::ops::SubAssign for CornerRadiusF32 { #[inline] fn sub_assign(&mut self, rhs: f32) { *self = Self { @@ -185,7 +185,7 @@ impl std::ops::SubAssign for CornerRadiusF32 { } } -impl std::ops::Div for CornerRadiusF32 { +impl core::ops::Div for CornerRadiusF32 { type Output = Self; #[inline] fn div(self, rhs: f32) -> Self { @@ -198,7 +198,7 @@ impl std::ops::Div for CornerRadiusF32 { } } -impl std::ops::DivAssign for CornerRadiusF32 { +impl core::ops::DivAssign for CornerRadiusF32 { #[inline] fn div_assign(&mut self, rhs: f32) { *self = Self { @@ -210,7 +210,7 @@ impl std::ops::DivAssign for CornerRadiusF32 { } } -impl std::ops::Mul for CornerRadiusF32 { +impl core::ops::Mul for CornerRadiusF32 { type Output = Self; #[inline] fn mul(self, rhs: f32) -> Self { @@ -223,7 +223,7 @@ impl std::ops::Mul for CornerRadiusF32 { } } -impl std::ops::MulAssign for CornerRadiusF32 { +impl core::ops::MulAssign for CornerRadiusF32 { #[inline] fn mul_assign(&mut self, rhs: f32) { *self = Self { diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 6fbd2b38f..92890e180 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -301,7 +301,7 @@ impl ColorImage { } } -impl std::ops::Index<(usize, usize)> for ColorImage { +impl core::ops::Index<(usize, usize)> for ColorImage { type Output = Color32; #[inline] @@ -312,7 +312,7 @@ impl std::ops::Index<(usize, usize)> for ColorImage { } } -impl std::ops::IndexMut<(usize, usize)> for ColorImage { +impl core::ops::IndexMut<(usize, usize)> for ColorImage { #[inline] fn index_mut(&mut self, (x, y): (usize, usize)) -> &mut Color32 { let [w, h] = self.size; @@ -335,8 +335,8 @@ impl From> for ImageData { } } -impl std::fmt::Debug for ColorImage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for ColorImage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("ColorImage") .field("size", &self.size) .field("pixel-count", &self.pixels.len()) diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index bff5c79a3..c3e7a5b74 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -33,6 +33,7 @@ mod margin; mod margin_f32; mod mesh; pub mod mutex; +mod rounded_rect; mod shadow; pub mod shape_transform; mod shapes; @@ -56,6 +57,7 @@ pub use self::{ margin::Margin, margin_f32::*, mesh::{Mesh, Mesh16, Vertex}, + rounded_rect::RoundedRect, shadow::Shadow, shapes::{ CircleShape, CubicBezierShape, EllipseShape, PaintCallback, PaintCallbackInfo, PathShape, diff --git a/crates/epaint/src/margin.rs b/crates/epaint/src/margin.rs index 0e2063efe..765208ef1 100644 --- a/crates/epaint/src/margin.rs +++ b/crates/epaint/src/margin.rs @@ -120,7 +120,7 @@ impl From for Margin { } /// `Margin + Margin` -impl std::ops::Add for Margin { +impl core::ops::Add for Margin { type Output = Self; #[inline] @@ -135,7 +135,7 @@ impl std::ops::Add for Margin { } /// `Margin + i8` -impl std::ops::Add for Margin { +impl core::ops::Add for Margin { type Output = Self; #[inline] @@ -150,7 +150,7 @@ impl std::ops::Add for Margin { } /// `Margin += i8` -impl std::ops::AddAssign for Margin { +impl core::ops::AddAssign for Margin { #[inline] fn add_assign(&mut self, v: i8) { *self = *self + v; @@ -158,7 +158,7 @@ impl std::ops::AddAssign for Margin { } /// `Margin * f32` -impl std::ops::Mul for Margin { +impl core::ops::Mul for Margin { type Output = Self; #[inline] @@ -173,7 +173,7 @@ impl std::ops::Mul for Margin { } /// `Margin *= f32` -impl std::ops::MulAssign for Margin { +impl core::ops::MulAssign for Margin { #[inline] fn mul_assign(&mut self, v: f32) { *self = *self * v; @@ -181,7 +181,7 @@ impl std::ops::MulAssign for Margin { } /// `Margin / f32` -impl std::ops::Div for Margin { +impl core::ops::Div for Margin { type Output = Self; #[inline] @@ -192,7 +192,7 @@ impl std::ops::Div for Margin { } /// `Margin /= f32` -impl std::ops::DivAssign for Margin { +impl core::ops::DivAssign for Margin { #[inline] fn div_assign(&mut self, v: f32) { *self = *self / v; @@ -200,7 +200,7 @@ impl std::ops::DivAssign for Margin { } /// `Margin - Margin` -impl std::ops::Sub for Margin { +impl core::ops::Sub for Margin { type Output = Self; #[inline] @@ -215,7 +215,7 @@ impl std::ops::Sub for Margin { } /// `Margin - i8` -impl std::ops::Sub for Margin { +impl core::ops::Sub for Margin { type Output = Self; #[inline] @@ -230,7 +230,7 @@ impl std::ops::Sub for Margin { } /// `Margin -= i8` -impl std::ops::SubAssign for Margin { +impl core::ops::SubAssign for Margin { #[inline] fn sub_assign(&mut self, v: i8) { *self = *self - v; @@ -238,7 +238,7 @@ impl std::ops::SubAssign for Margin { } /// `Rect + Margin` -impl std::ops::Add for Rect { +impl core::ops::Add for Rect { type Output = Self; #[inline] @@ -251,7 +251,7 @@ impl std::ops::Add for Rect { } /// `Rect += Margin` -impl std::ops::AddAssign for Rect { +impl core::ops::AddAssign for Rect { #[inline] fn add_assign(&mut self, margin: Margin) { *self = *self + margin; @@ -259,7 +259,7 @@ impl std::ops::AddAssign for Rect { } /// `Rect - Margin` -impl std::ops::Sub for Rect { +impl core::ops::Sub for Rect { type Output = Self; #[inline] @@ -272,7 +272,7 @@ impl std::ops::Sub for Rect { } /// `Rect -= Margin` -impl std::ops::SubAssign for Rect { +impl core::ops::SubAssign for Rect { #[inline] fn sub_assign(&mut self, margin: Margin) { *self = *self - margin; diff --git a/crates/epaint/src/margin_f32.rs b/crates/epaint/src/margin_f32.rs index 2a8c820a1..793e5de47 100644 --- a/crates/epaint/src/margin_f32.rs +++ b/crates/epaint/src/margin_f32.rs @@ -111,7 +111,7 @@ impl From for MarginF32 { } /// `MarginF32 + MarginF32` -impl std::ops::Add for MarginF32 { +impl core::ops::Add for MarginF32 { type Output = Self; #[inline] @@ -126,7 +126,7 @@ impl std::ops::Add for MarginF32 { } /// `MarginF32 + f32` -impl std::ops::Add for MarginF32 { +impl core::ops::Add for MarginF32 { type Output = Self; #[inline] @@ -141,7 +141,7 @@ impl std::ops::Add for MarginF32 { } /// `Margind += f32` -impl std::ops::AddAssign for MarginF32 { +impl core::ops::AddAssign for MarginF32 { #[inline] fn add_assign(&mut self, v: f32) { self.left += v; @@ -152,7 +152,7 @@ impl std::ops::AddAssign for MarginF32 { } /// `MarginF32 * f32` -impl std::ops::Mul for MarginF32 { +impl core::ops::Mul for MarginF32 { type Output = Self; #[inline] @@ -167,7 +167,7 @@ impl std::ops::Mul for MarginF32 { } /// `MarginF32 *= f32` -impl std::ops::MulAssign for MarginF32 { +impl core::ops::MulAssign for MarginF32 { #[inline] fn mul_assign(&mut self, v: f32) { self.left *= v; @@ -178,7 +178,7 @@ impl std::ops::MulAssign for MarginF32 { } /// `MarginF32 / f32` -impl std::ops::Div for MarginF32 { +impl core::ops::Div for MarginF32 { type Output = Self; #[inline] @@ -193,7 +193,7 @@ impl std::ops::Div for MarginF32 { } /// `MarginF32 /= f32` -impl std::ops::DivAssign for MarginF32 { +impl core::ops::DivAssign for MarginF32 { #[inline] fn div_assign(&mut self, v: f32) { self.left /= v; @@ -204,7 +204,7 @@ impl std::ops::DivAssign for MarginF32 { } /// `MarginF32 - MarginF32` -impl std::ops::Sub for MarginF32 { +impl core::ops::Sub for MarginF32 { type Output = Self; #[inline] @@ -219,7 +219,7 @@ impl std::ops::Sub for MarginF32 { } /// `MarginF32 - f32` -impl std::ops::Sub for MarginF32 { +impl core::ops::Sub for MarginF32 { type Output = Self; #[inline] @@ -234,7 +234,7 @@ impl std::ops::Sub for MarginF32 { } /// `MarginF32 -= f32` -impl std::ops::SubAssign for MarginF32 { +impl core::ops::SubAssign for MarginF32 { #[inline] fn sub_assign(&mut self, v: f32) { self.left -= v; @@ -245,7 +245,7 @@ impl std::ops::SubAssign for MarginF32 { } /// `Rect + MarginF32` -impl std::ops::Add for Rect { +impl core::ops::Add for Rect { type Output = Self; #[inline] @@ -258,7 +258,7 @@ impl std::ops::Add for Rect { } /// `Rect += MarginF32` -impl std::ops::AddAssign for Rect { +impl core::ops::AddAssign for Rect { #[inline] fn add_assign(&mut self, margin: MarginF32) { *self = *self + margin; @@ -266,7 +266,7 @@ impl std::ops::AddAssign for Rect { } /// `Rect - MarginF32` -impl std::ops::Sub for Rect { +impl core::ops::Sub for Rect { type Output = Self; #[inline] @@ -279,7 +279,7 @@ impl std::ops::Sub for Rect { } /// `Rect -= MarginF32` -impl std::ops::SubAssign for Rect { +impl core::ops::SubAssign for Rect { #[inline] fn sub_assign(&mut self, margin: MarginF32) { *self = *self - margin; diff --git a/crates/epaint/src/mesh.rs b/crates/epaint/src/mesh.rs index d48c98bc2..5ef978ff3 100644 --- a/crates/epaint/src/mesh.rs +++ b/crates/epaint/src/mesh.rs @@ -90,9 +90,9 @@ impl Mesh { /// Returns the amount of memory used by the vertices and indices. pub fn bytes_used(&self) -> usize { - std::mem::size_of::() - + self.vertices.len() * std::mem::size_of::() - + self.indices.len() * std::mem::size_of::() + core::mem::size_of::() + + self.vertices.len() * core::mem::size_of::() + + self.indices.len() * core::mem::size_of::() } /// Are all indices within the bounds of the contained vertices? diff --git a/crates/epaint/src/mutex.rs b/crates/epaint/src/mutex.rs index 272046823..09e64d30c 100644 --- a/crates/epaint/src/mutex.rs +++ b/crates/epaint/src/mutex.rs @@ -2,7 +2,7 @@ // ---------------------------------------------------------------------------- -const DEADLOCK_DURATION: std::time::Duration = std::time::Duration::from_secs(10); +const DEADLOCK_DURATION: core::time::Duration = core::time::Duration::from_secs(10); /// Provides interior mutability. /// @@ -128,7 +128,7 @@ mod tests { #![expect(clippy::disallowed_methods)] // Ok for tests use crate::mutex::Mutex; - use std::time::Duration; + use core::time::Duration; #[test] fn lock_two_different_mutexes_single_thread() { @@ -161,7 +161,7 @@ mod tests_rwlock { #![expect(clippy::disallowed_methods)] // Ok for tests use crate::mutex::RwLock; - use std::time::Duration; + use core::time::Duration; #[test] fn lock_two_different_rwlocks_single_thread() { diff --git a/crates/epaint/src/rounded_rect.rs b/crates/epaint/src/rounded_rect.rs new file mode 100644 index 000000000..e5023dcd0 --- /dev/null +++ b/crates/epaint/src/rounded_rect.rs @@ -0,0 +1,152 @@ +use emath::{Pos2, Rect, Vec2, vec2}; + +use crate::CornerRadiusF32; + +/// A rectangle geometry with rounded corners. +/// +/// Not a painting primitive. For that, see [`crate::RectShape`]. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct RoundedRect { + rect: Rect, + corner_radius: CornerRadiusF32, +} + +impl RoundedRect { + /// The corner radius is clamped to half the size of the rectangle. + #[inline] + pub fn new(rect: Rect, corner_radius: impl Into) -> Self { + let max_radius = 0.5 * rect.size().min_elem(); + Self { + rect, + corner_radius: corner_radius.into().at_most(max_radius).at_least(0.0), + } + } + + #[inline] + pub fn rect(&self) -> Rect { + self.rect + } + + #[inline] + pub fn corner_radius(&self) -> CornerRadiusF32 { + self.corner_radius + } + + /// Split into the rectangle and the corner radius. + #[inline] + pub fn into_parts(self) -> (Rect, CornerRadiusF32) { + let Self { + rect, + corner_radius, + } = self; + (rect, corner_radius) + } + + /// Expand the rectangle and the corner radii by the given amount. + #[inline] + #[must_use] + pub fn expand(self, amount: f32) -> Self { + Self::new( + self.rect.expand(amount), + self.corner_radius + CornerRadiusF32::same(amount), + ) + } + + /// Clamp the given position to lie within this rounded rectangle. + /// + /// Positions in the corner regions are projected onto the corner arcs. + pub fn clamp_pos(&self, pos: Pos2) -> Pos2 { + let Self { + rect, + corner_radius, + } = *self; + let pos = rect.clamp(pos); + let corners = [ + (corner_radius.nw, vec2(-1.0, -1.0)), + (corner_radius.ne, vec2(1.0, -1.0)), + (corner_radius.sw, vec2(-1.0, 1.0)), + (corner_radius.se, vec2(1.0, 1.0)), + ]; + for (radius, dir) in corners { + let arc_center = rect.center() + dir * (rect.size() / 2.0 - Vec2::splat(radius)); + let offset = pos - arc_center; + if 0.0 < offset.x * dir.x && 0.0 < offset.y * dir.y && radius < offset.length() { + return arc_center + (radius / offset.length()) * offset; + } + } + pos + } +} + +impl From for RoundedRect { + #[inline] + fn from(rect: Rect) -> Self { + Self { + rect, + corner_radius: CornerRadiusF32::ZERO, + } + } +} + +#[cfg(test)] +mod tests { + use emath::pos2; + + use super::*; + + #[test] + fn clamp_pos() { + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let rounded = RoundedRect::new( + rect, + CornerRadiusF32 { + nw: 10.0, + ne: 0.0, + sw: 0.0, + se: 20.0, + }, + ); + + // Interior point is untouched: + assert_eq!(rounded.clamp_pos(pos2(50.0, 50.0)), pos2(50.0, 50.0)); + + // Sharp corner is untouched: + assert_eq!(rounded.clamp_pos(pos2(100.0, 0.0)), pos2(100.0, 0.0)); + + // Outside the rect is clamped to the edge: + assert_eq!(rounded.clamp_pos(pos2(-10.0, 50.0)), pos2(0.0, 50.0)); + + // Rounded corner is projected onto the arc: + let clamped = rounded.clamp_pos(pos2(0.0, 0.0)); + let arc_center = pos2(10.0, 10.0); + assert!((clamped - arc_center).length() - 10.0 < 0.001); + let expected = 10.0 - 10.0 / core::f32::consts::SQRT_2; + assert!((clamped - pos2(expected, expected)).length() < 0.001); + + // Point on the arc stays put: + assert_eq!(rounded.clamp_pos(pos2(10.0, 0.0)), pos2(10.0, 0.0)); + } + + #[test] + fn expand() { + let rect = Rect::from_min_max(pos2(10.0, 10.0), pos2(90.0, 90.0)); + let expanded = RoundedRect::new(rect, 20.0).expand(10.0); + assert_eq!( + expanded.rect(), + Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)) + ); + assert_eq!(expanded.corner_radius(), CornerRadiusF32::same(30.0)); + } + + #[test] + fn oversized_radius_is_clamped() { + // A radius larger than half the rect is clamped, like in the tessellator: + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + assert_eq!(RoundedRect::new(rect, 200.0), RoundedRect::new(rect, 50.0)); + assert_eq!( + RoundedRect::new(rect, 200.0).corner_radius(), + CornerRadiusF32::same(50.0) + ); + } +} diff --git a/crates/epaint/src/shadow.rs b/crates/epaint/src/shadow.rs index ace5ab90a..11dd3f1d1 100644 --- a/crates/epaint/src/shadow.rs +++ b/crates/epaint/src/shadow.rs @@ -1,4 +1,4 @@ -use crate::{Color32, CornerRadius, MarginF32, Rect, RectShape, Vec2}; +use crate::{Color32, CornerRadius, MarginF32, Rect, RectShape, RoundedRect, Vec2}; /// The color and fuzziness of a fuzzy shape. /// @@ -29,7 +29,7 @@ pub struct Shadow { #[test] fn shadow_size() { assert_eq!( - std::mem::size_of::(), + core::mem::size_of::(), 8, "Shadow changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." ); @@ -56,10 +56,12 @@ impl Shadow { } = *self; let [offset_x, offset_y] = offset; - let rect = rect - .translate(Vec2::new(offset_x as _, offset_y as _)) - .expand(spread as _); - let corner_radius = corner_radius.into() + CornerRadius::from(spread); + let (rect, corner_radius) = RoundedRect::new( + rect.translate(Vec2::new(offset_x as _, offset_y as _)), + corner_radius.into(), + ) + .expand(f32::from(spread)) + .into_parts(); RectShape::filled(rect, corner_radius, color).with_blur_width(blur as _) } diff --git a/crates/epaint/src/shapes/bezier_shape.rs b/crates/epaint/src/shapes/bezier_shape.rs index dfeaae5ec..99694f4fb 100644 --- a/crates/epaint/src/shapes/bezier_shape.rs +++ b/crates/epaint/src/shapes/bezier_shape.rs @@ -1,9 +1,7 @@ -#![expect(clippy::many_single_char_names)] - -use std::ops::Range; +use core::ops::Range; use crate::{Color32, PathShape, PathStroke, Shape}; -use emath::{Pos2, Rect, RectTransform}; +use emath::{Pos2, Rect, RectTransform, fast_midpoint}; // ---------------------------------------------------------------------------- @@ -256,8 +254,8 @@ impl CubicBezierShape { let theta = (-q / (2.0 * r)).acos() / 3.0; let t1 = 2.0 * r.cbrt() * theta.cos() + h; - let t2 = 2.0 * r.cbrt() * (theta + 120.0 * std::f32::consts::PI / 180.0).cos() + h; - let t3 = 2.0 * r.cbrt() * (theta + 240.0 * std::f32::consts::PI / 180.0).cos() + h; + let t2 = 2.0 * r.cbrt() * (theta + 120.0 * core::f32::consts::PI / 180.0).cos() + h; + let t3 = 2.0 * r.cbrt() * (theta + 240.0 * core::f32::consts::PI / 180.0).cos() + h; if t1 > epsilon && t1 < 1.0 - epsilon { return Some(t1); @@ -689,8 +687,8 @@ fn single_curve_approximation(curve: &CubicBezierShape) -> QuadraticBezierShape let c2_x = (curve.points[2].x * 3.0 - curve.points[3].x) * 0.5; let c2_y = (curve.points[2].y * 3.0 - curve.points[3].y) * 0.5; let c = Pos2 { - x: (c1_x + c2_x) * 0.5, - y: (c1_y + c2_y) * 0.5, + x: fast_midpoint(c1_x, c2_x), + y: fast_midpoint(c1_y, c2_y), }; QuadraticBezierShape { points: [curve.points[0], c, curve.points[3]], diff --git a/crates/epaint/src/shapes/paint_callback.rs b/crates/epaint/src/shapes/paint_callback.rs index 00882f0f2..a45109280 100644 --- a/crates/epaint/src/shapes/paint_callback.rs +++ b/crates/epaint/src/shapes/paint_callback.rs @@ -1,4 +1,5 @@ -use std::{any::Any, sync::Arc}; +use core::any::Any; +use std::sync::Arc; use crate::*; @@ -32,7 +33,7 @@ fn test_viewport_rounding() { let left = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_max_x(x); let right = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_min_x(x); - for pixels_per_point in [0.618, 1.0, std::f32::consts::PI] { + for pixels_per_point in [0.618, 1.0, core::f32::consts::PI] { let left = ViewportInPixels::from_points(&left, pixels_per_point, [100, 100]); let right = ViewportInPixels::from_points(&right, pixels_per_point, [100, 100]); assert_eq!(left.left_px + left.width_px, right.left_px); @@ -81,15 +82,15 @@ pub struct PaintCallback { pub callback: Arc, } -impl std::fmt::Debug for PaintCallback { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Debug for PaintCallback { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("CustomShape") .field("rect", &self.rect) .finish_non_exhaustive() } } -impl std::cmp::PartialEq for PaintCallback { +impl core::cmp::PartialEq for PaintCallback { fn eq(&self, other: &Self) -> bool { self.rect.eq(&other.rect) && Arc::ptr_eq(&self.callback, &other.callback) } diff --git a/crates/epaint/src/shapes/rect_shape.rs b/crates/epaint/src/shapes/rect_shape.rs index e0c528377..159d56af5 100644 --- a/crates/epaint/src/shapes/rect_shape.rs +++ b/crates/epaint/src/shapes/rect_shape.rs @@ -62,12 +62,12 @@ pub struct RectShape { #[test] fn rect_shape_size() { assert_eq!( - std::mem::size_of::(), + core::mem::size_of::(), 56, "RectShape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." ); assert!( - std::mem::size_of::() <= 64, + core::mem::size_of::() <= 64, "RectShape is getting way too big!" ); } diff --git a/crates/epaint/src/shapes/shape.rs b/crates/epaint/src/shapes/shape.rs index ff15708f1..55be52ba5 100644 --- a/crates/epaint/src/shapes/shape.rs +++ b/crates/epaint/src/shapes/shape.rs @@ -73,12 +73,12 @@ pub enum Shape { #[test] fn shape_size() { assert_eq!( - std::mem::size_of::(), + core::mem::size_of::(), 64, "Shape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." ); assert!( - std::mem::size_of::() <= 64, + core::mem::size_of::() <= 64, "Shape is getting way too big!" ); } diff --git a/crates/epaint/src/shapes/text_shape.rs b/crates/epaint/src/shapes/text_shape.rs index 3e177db07..8083dcf25 100644 --- a/crates/epaint/src/shapes/text_shape.rs +++ b/crates/epaint/src/shapes/text_shape.rs @@ -201,7 +201,7 @@ mod tests { // 90 degree rotation if let Shape::Text(ts) = &mut t { - ts.angle = std::f32::consts::PI / 2.0; + ts.angle = core::f32::consts::PI / 2.0; } let size_rot = t.visual_bounding_rect().size(); diff --git a/crates/epaint/src/stats.rs b/crates/epaint/src/stats.rs index de8f275cf..8c191dcb1 100644 --- a/crates/epaint/src/stats.rs +++ b/crates/epaint/src/stats.rs @@ -26,7 +26,7 @@ impl From<&[T]> for AllocInfo { } } -impl std::ops::Add for AllocInfo { +impl core::ops::Add for AllocInfo { type Output = Self; fn add(self, rhs: Self) -> Self { @@ -47,13 +47,13 @@ impl std::ops::Add for AllocInfo { } } -impl std::ops::AddAssign for AllocInfo { +impl core::ops::AddAssign for AllocInfo { fn add_assign(&mut self, rhs: Self) { *self = *self + rhs; } } -impl std::iter::Sum for AllocInfo { +impl core::iter::Sum for AllocInfo { fn sum(iter: I) -> Self where I: Iterator, @@ -95,13 +95,13 @@ impl AllocInfo { } pub fn from_slice(slice: &[T]) -> Self { - use std::mem::size_of; + use core::mem::size_of; let element_size = size_of::(); Self { element_size: ElementSize::Homogeneous(element_size), num_allocs: 1, num_elements: slice.len(), - num_bytes: std::mem::size_of_val(slice), + num_bytes: core::mem::size_of_val(slice), } } diff --git a/crates/epaint/src/stroke.rs b/crates/epaint/src/stroke.rs index 1b072e4bf..1a5b2f0d1 100644 --- a/crates/epaint/src/stroke.rs +++ b/crates/epaint/src/stroke.rs @@ -1,4 +1,5 @@ -use std::{fmt::Debug, sync::Arc}; +use core::fmt::Debug; +use std::sync::Arc; use emath::GuiRounding as _; @@ -86,9 +87,9 @@ where } } -impl std::hash::Hash for Stroke { +impl core::hash::Hash for Stroke { #[inline(always)] - fn hash(&self, state: &mut H) { + fn hash(&self, state: &mut H) { let Self { width, color } = *self; emath::OrderedFloat(width).hash(state); color.hash(state); diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index 9a90636a4..6f3a3698b 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -5,12 +5,14 @@ #![expect(clippy::identity_op)] -use emath::{GuiRounding as _, NumExt as _, Pos2, Rect, Rot2, Vec2, pos2, remap, vec2}; +use emath::{ + GuiRounding as _, NumExt as _, Pos2, Rect, Rot2, Vec2, fast_midpoint, pos2, remap, vec2, +}; use crate::{ CircleShape, ClippedPrimitive, ClippedShape, Color32, CornerRadiusF32, CubicBezierShape, - EllipseShape, Mesh, PathShape, Primitive, QuadraticBezierShape, RectShape, Shape, Stroke, - StrokeKind, TextShape, TextureId, Vertex, color::ColorMode, emath, stroke::PathStroke, + EllipseShape, Mesh, PathShape, Primitive, QuadraticBezierShape, RectShape, RoundedRect, Shape, + Stroke, StrokeKind, TextShape, TextureId, Vertex, color::ColorMode, emath, stroke::PathStroke, texture_atlas::PreparedDisc, }; @@ -534,18 +536,19 @@ impl Path { pub mod path { //! Helpers for constructing paths - use crate::CornerRadiusF32; - use emath::{Pos2, Rect, pos2}; + use crate::{CornerRadiusF32, RoundedRect}; + use emath::{Pos2, pos2}; /// overwrites existing points - pub fn rounded_rectangle(path: &mut Vec, rect: Rect, cr: CornerRadiusF32) { + pub fn rounded_rectangle(path: &mut Vec, rounded_rect: RoundedRect) { path.clear(); + // The corner radius is already clamped to half the rect size by `RoundedRect`: + let (rect, cr) = rounded_rect.into_parts(); + let min = rect.min; let max = rect.max; - let cr = clamp_corner_radius(cr, rect); - if cr == CornerRadiusF32::ZERO { path.reserve(4); path.push(pos2(min.x, min.y)); // left top @@ -631,14 +634,6 @@ pub mod path { path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); } } - - // Ensures the radius of each corner is within a valid range - fn clamp_corner_radius(cr: CornerRadiusF32, rect: Rect) -> CornerRadiusF32 { - let half_width = rect.width() * 0.5; - let half_height = rect.height() * 0.5; - let max_cr = half_width.min(half_height); - cr.at_most(max_cr).at_least(0.0) - } } // ---------------------------------------------------------------------------- @@ -1074,7 +1069,7 @@ fn stroke_and_fill_path( */ let inner_rad = 0.5 * (stroke.width - feathering); - let outer_rad = 0.5 * (stroke.width + feathering); + let outer_rad = fast_midpoint(stroke.width, feathering); match path_type { PathType::Closed => { @@ -1517,11 +1512,11 @@ impl Tessellator { if stroke.is_empty() { return; // we are done - } else { - // we still need to do the stroke - fill = Color32::TRANSPARENT; // don't fill again below - break; } + + // we still need to do the stroke + fill = Color32::TRANSPARENT; // don't fill again below + break; } } } @@ -1577,7 +1572,7 @@ impl Tessellator { let eased = 2.0 * (percent - percent.powf(2.0)) * ratio + percent.powf(2.0); // Scale the ease to the quarter - let t = eased * std::f32::consts::FRAC_PI_2; + let t = eased * core::f32::consts::FRAC_PI_2; Vec2::new(radius.x * f32::cos(t), radius.y * f32::sin(t)) }) .collect(); @@ -1936,7 +1931,10 @@ impl Tessellator { let path = &mut self.scratchpad_path; path.clear(); - path::rounded_rectangle(&mut self.scratchpad_points, rect, corner_radius); + path::rounded_rectangle( + &mut self.scratchpad_points, + RoundedRect::new(rect, corner_radius), + ); // Apply rotation if angle is non-zero if angle != 0.0 { diff --git a/crates/epaint/src/text/cursor.rs b/crates/epaint/src/text/cursor.rs index ac2e4216d..1143fc59f 100644 --- a/crates/epaint/src/text/cursor.rs +++ b/crates/epaint/src/text/cursor.rs @@ -37,7 +37,7 @@ impl PartialEq for CCursor { } } -impl std::ops::Add for CCursor { +impl core::ops::Add for CCursor { type Output = Self; fn add(self, rhs: usize) -> Self::Output { @@ -48,7 +48,7 @@ impl std::ops::Add for CCursor { } } -impl std::ops::Add for CCursor { +impl core::ops::Add for CCursor { type Output = Self; fn add(self, rhs: CharIndex) -> Self::Output { @@ -59,7 +59,7 @@ impl std::ops::Add for CCursor { } } -impl std::ops::Sub for CCursor { +impl core::ops::Sub for CCursor { type Output = Self; fn sub(self, rhs: usize) -> Self::Output { @@ -70,7 +70,7 @@ impl std::ops::Sub for CCursor { } } -impl std::ops::Sub for CCursor { +impl core::ops::Sub for CCursor { type Output = Self; fn sub(self, rhs: CharIndex) -> Self::Output { @@ -81,13 +81,13 @@ impl std::ops::Sub for CCursor { } } -impl std::ops::AddAssign for CCursor { +impl core::ops::AddAssign for CCursor { fn add_assign(&mut self, rhs: usize) { self.index = self.index.saturating_add(rhs); } } -impl std::ops::SubAssign for CCursor { +impl core::ops::SubAssign for CCursor { fn sub_assign(&mut self, rhs: usize) { self.index = self.index.saturating_sub(rhs); } diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index fad657e6e..287230db3 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -276,7 +276,7 @@ impl FontCell { ctx.fill_path(&path); let mut dest = vello_cpu::Pixmap::new(width, height); let mut resources = vello_cpu::Resources::new(); - ctx.render_to_pixmap(&mut resources, &mut dest); + ctx.render(&mut dest, &mut resources); let glyph_pos = { let color_transfer_function = atlas.options().color_transfer_function; @@ -382,7 +382,7 @@ impl FontFace { font_data: Blob, index: u32, tweak: FontTweak, - ) -> Result> { + ) -> Result> { let font = FontCell::try_new(font_data, |font_data| { let skrifa_font = skrifa::FontRef::from_index(AsRef::<[u8]>::as_ref(font_data.as_ref()), index)?; @@ -414,7 +414,7 @@ impl FontFace { }) .flatten(); - Ok::, Box>(DependentFontData { + Ok::, Box>(DependentFontData { skrifa: skrifa_font, charmap, outline_glyphs: glyphs, @@ -574,7 +574,7 @@ impl FontFace { let axes = font_data.skrifa.axes(); // Override the default coordinates with ones specified via FontTweak, then the ones specified directly via the // argument (probably from TextFormat). - let settings = std::iter::chain(self.tweak.coords.as_ref(), coords.as_ref()); + let settings = core::iter::chain(self.tweak.coords.as_ref(), coords.as_ref()); let location = axes.location(settings); let location_hash = LocationHash::new(&location); diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 62b1b83a0..28ab8bbf5 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -1,11 +1,5 @@ -use std::{ - borrow::Cow, - collections::BTreeMap, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, -}; +use core::sync::atomic::{AtomicUsize, Ordering}; +use std::{borrow::Cow, collections::BTreeMap, sync::Arc}; use crate::{ TextureAtlas, @@ -60,9 +54,9 @@ impl FontId { } } -impl std::hash::Hash for FontId { +impl core::hash::Hash for FontId { #[inline(always)] - fn hash(&self, state: &mut H) { + fn hash(&self, state: &mut H) { let Self { size, family } = self; emath::OrderedFloat(*size).hash(state); family.hash(state); @@ -100,8 +94,8 @@ pub enum FontFamily { Name(Arc), } -impl std::fmt::Display for FontFamily { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for FontFamily { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Monospace => "Monospace".fmt(f), Self::Proportional => "Proportional".fmt(f), diff --git a/crates/epaint/src/text/index.rs b/crates/epaint/src/text/index.rs index 3fcd9a4ce..0f1450740 100644 --- a/crates/epaint/src/text/index.rs +++ b/crates/epaint/src/text/index.rs @@ -4,7 +4,7 @@ //! (Unicode scalar) offset. Mixing the two is a common source of bugs, //! so we use distinct types to keep them apart. -use std::ops::Range; +use core::ops::Range; /// A byte offset into a UTF-8 string. /// @@ -63,7 +63,7 @@ macro_rules! impl_text_index { } } - impl std::ops::Add for $Type { + impl core::ops::Add for $Type { type Output = Self; #[inline] @@ -73,7 +73,7 @@ macro_rules! impl_text_index { } /// Compose offsets, e.g. a base position plus a relative one. - impl std::ops::Add<$Type> for $Type { + impl core::ops::Add<$Type> for $Type { type Output = Self; #[inline] @@ -82,7 +82,7 @@ macro_rules! impl_text_index { } } - impl std::ops::Sub for $Type { + impl core::ops::Sub for $Type { type Output = Self; #[inline] @@ -91,7 +91,7 @@ macro_rules! impl_text_index { } } - impl std::ops::Sub<$Type> for $Type { + impl core::ops::Sub<$Type> for $Type { type Output = Self; #[inline] @@ -100,30 +100,30 @@ macro_rules! impl_text_index { } } - impl std::ops::AddAssign for $Type { + impl core::ops::AddAssign for $Type { #[inline] fn add_assign(&mut self, rhs: usize) { self.0 += rhs; } } - impl std::ops::AddAssign<$Type> for $Type { + impl core::ops::AddAssign<$Type> for $Type { #[inline] fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; } } - impl std::ops::SubAssign for $Type { + impl core::ops::SubAssign for $Type { #[inline] fn sub_assign(&mut self, rhs: usize) { self.0 -= rhs; } } - impl std::fmt::Display for $Type { + impl core::fmt::Display for $Type { #[inline] - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { self.0.fmt(f) } } diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index ffe187e6c..06ddc3b1f 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -1,7 +1,7 @@ #![expect(clippy::unwrap_used)] // TODO(emilk): remove unwraps +use core::{iter, ops::Range}; use std::sync::Arc; -use std::{iter, ops::Range}; use emath::{Align, GuiRounding as _, NumExt as _, Pos2, Rect, Vec2, pos2, vec2}; @@ -523,7 +523,7 @@ fn layout_section( /// Iterator that either splits on `'\n'` or yields the whole string once. /// Avoids `Box` and `Vec<&str>` allocation. enum SplitOrWhole<'a> { - Split(std::str::Split<'a, char>), + Split(core::str::Split<'a, char>), Whole(iter::Once<&'a str>), } @@ -571,7 +571,7 @@ fn calculate_intrinsic_size( .glyphs .iter() .map(|g| g.line_height) - .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal)) .unwrap_or(paragraph.empty_paragraph_height); if idx == 0 { height = f32::max(height, job.first_row_min_height); @@ -1430,14 +1430,14 @@ fn shape_text( buffer.push_str(text); buffer.guess_segment_properties(); - shaper.shape(buffer, &[]) + shaper.shape(buffer, harfrust::ShapeOptions::new()) } // ---------------------------------------------------------------------------- #[cfg(test)] mod tests { - use std::iter; + use core::iter; use super::{super::*, *}; use crate::text::cursor::CCursor; diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 5ae784468..e51c0e1cd 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -1,5 +1,5 @@ +use core::{ops::Range, str::FromStr as _}; use std::sync::Arc; -use std::{ops::Range, str::FromStr as _}; use super::{ cursor::{CCursor, LayoutCursor}, @@ -114,6 +114,13 @@ impl Default for LayoutJob { } impl LayoutJob { + /// Clear the text and sections while preserving the layout settings. + #[inline] + pub fn clear(&mut self) { + self.text.clear(); + self.sections.clear(); + } + /// Break on `\n` and at the given wrap width. #[inline] pub fn simple(text: String, font_id: FontId, color: Color32, wrap_width: f32) -> Self { @@ -264,7 +271,7 @@ impl LayoutJob { let Range { start, end } = section.byte_range; assert!(start <= end, "LayoutSection has a reversed byte_range"); } - for (prev, next) in std::iter::zip(&self.sections, self.sections.iter().skip(1)) { + for (prev, next) in core::iter::zip(&self.sections, self.sections.iter().skip(1)) { assert_eq!( prev.byte_range.end, next.byte_range.start, "LayoutSections must be ordered with no gaps and no overlaps" @@ -297,9 +304,9 @@ impl LayoutJob { } } -impl std::hash::Hash for LayoutJob { +impl core::hash::Hash for LayoutJob { #[inline] - fn hash(&self, state: &mut H) { + fn hash(&self, state: &mut H) { let Self { text, sections, @@ -348,9 +355,9 @@ pub struct LayoutSection { pub format: TextFormat, } -impl std::hash::Hash for LayoutSection { +impl core::hash::Hash for LayoutSection { #[inline] - fn hash(&self, state: &mut H) { + fn hash(&self, state: &mut H) { let Self { leading_space, byte_range, @@ -455,8 +462,8 @@ impl AsMut<[(font_types::Tag, f32)]> for VariationCoords { } } -impl std::hash::Hash for VariationCoords { - fn hash(&self, state: &mut H) { +impl core::hash::Hash for VariationCoords { + fn hash(&self, state: &mut H) { self.0.len().hash(state); for (tag, coord) in &self.0 { tag.hash(state); @@ -534,9 +541,9 @@ impl Default for TextFormat { } } -impl std::hash::Hash for TextFormat { +impl core::hash::Hash for TextFormat { #[inline] - fn hash(&self, state: &mut H) { + fn hash(&self, state: &mut H) { let Self { font_id, extra_letter_spacing, @@ -644,9 +651,9 @@ pub struct TextWrapping { pub overflow_character: Option, } -impl std::hash::Hash for TextWrapping { +impl core::hash::Hash for TextWrapping { #[inline] - fn hash(&self, state: &mut H) { + fn hash(&self, state: &mut H) { let Self { max_width, max_rows, @@ -810,7 +817,7 @@ impl PlacedRow { } } -impl std::ops::Deref for PlacedRow { +impl core::ops::Deref for PlacedRow { type Target = Row; fn deref(&self) -> &Self::Target { @@ -1119,14 +1126,14 @@ impl AsRef for Galley { } } -impl std::borrow::Borrow for Galley { +impl core::borrow::Borrow for Galley { #[inline] fn borrow(&self) -> &str { self.text() } } -impl std::ops::Deref for Galley { +impl core::ops::Deref for Galley { type Target = str; #[inline] fn deref(&self) -> &str { diff --git a/crates/epaint/src/texture_atlas.rs b/crates/epaint/src/texture_atlas.rs index 4f8548817..0bb235b33 100644 --- a/crates/epaint/src/texture_atlas.rs +++ b/crates/epaint/src/texture_atlas.rs @@ -202,7 +202,7 @@ impl TextureAtlas { pub fn take_delta(&mut self) -> Option { let texture_options = Self::texture_options(); - let dirty = std::mem::replace(&mut self.dirty, Rectu::NOTHING); + let dirty = core::mem::replace(&mut self.dirty, Rectu::NOTHING); if dirty == Rectu::NOTHING { None } else if dirty == Rectu::EVERYTHING { diff --git a/crates/epaint/src/texture_handle.rs b/crates/epaint/src/texture_handle.rs index bbbf490b5..b8b4310dc 100644 --- a/crates/epaint/src/texture_handle.rs +++ b/crates/epaint/src/texture_handle.rs @@ -47,9 +47,9 @@ impl PartialEq for TextureHandle { impl Eq for TextureHandle {} -impl std::hash::Hash for TextureHandle { +impl core::hash::Hash for TextureHandle { #[inline] - fn hash(&self, state: &mut H) { + fn hash(&self, state: &mut H) { self.id.hash(state); } } diff --git a/crates/epaint/src/textures.rs b/crates/epaint/src/textures.rs index 0944a9052..594cada75 100644 --- a/crates/epaint/src/textures.rs +++ b/crates/epaint/src/textures.rs @@ -1,4 +1,7 @@ use crate::{ImageData, ImageDelta, TextureId}; +use ahash::{HashMap, HashSet}; +use core::mem; +use smallvec::{SmallVec, smallvec}; // ---------------------------------------------------------------------------- @@ -40,7 +43,7 @@ impl TextureManager { options, }); - self.delta.set.push((id, ImageDelta::full(image, options))); + self.delta.push(id, ImageDelta::full(image, options)); id } @@ -58,10 +61,8 @@ impl TextureManager { // whole update meta.size = delta.image.size(); meta.bytes_per_pixel = delta.image.bytes_per_pixel(); - // since we update the whole image, we can discard all old enqueued deltas - self.delta.set.retain(|(x, _)| x != &id); } - self.delta.set.push((id, delta)); + self.delta.push(id, delta); } else { debug_assert!(false, "Tried setting texture {id:?} which is not allocated"); } @@ -74,7 +75,7 @@ impl TextureManager { meta.retain_count -= 1; if meta.retain_count == 0 { entry.remove(); - self.delta.free.push(id); + self.delta.free(id); } } else { debug_assert!(false, "Tried freeing texture {id:?} which is not allocated"); @@ -99,7 +100,7 @@ impl TextureManager { /// /// These should be applied to the painting subsystem each frame. pub fn take_delta(&mut self) -> TexturesDelta { - std::mem::take(&mut self.delta) + core::mem::take(&mut self.delta) } /// Get meta-data about a specific texture. @@ -118,6 +119,12 @@ impl TextureManager { } } +impl Drop for TextureManager { + fn drop(&mut self) { + self.delta.clear(); // Prevent a debug panic on application shutdown + } +} + /// Meta-data about an allocated texture. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TextureMeta { @@ -276,10 +283,10 @@ pub enum TextureWrapMode { #[must_use = "The painter must take care of this"] pub struct TexturesDelta { /// New or changed textures. Apply before painting. - pub set: Vec<(TextureId, ImageDelta)>, + pub set: HashMap>, /// Textures to free after painting. - pub free: Vec, + pub free: HashSet, } impl TexturesDelta { @@ -287,9 +294,36 @@ impl TexturesDelta { self.set.is_empty() && self.free.is_empty() } + /// Inserts a [`ImageDelta`]. + /// + /// If this [`TexturesDelta`] already contains this [`TextureId`], and this is a `whole` delta, + /// the previous deltas for this id are discarded. + pub fn push(&mut self, id: TextureId, delta: ImageDelta) { + if delta.is_whole() { + // It replaces the whole texture, fine to overwrite any previous deltas + self.set.insert(id, smallvec![delta]); + } else { + self.set.entry(id).or_default().push(delta); + } + } + + pub fn free(&mut self, id: TextureId) { + self.free.insert(id); + } + + #[expect(clippy::iter_over_hash_type)] pub fn append(&mut self, mut newer: Self) { - self.set.extend(newer.set); - self.free.append(&mut newer.free); + // Only clear previous entries on append, not on set, since within a frame a texture might + // be created and immediately removed again. + for id in &newer.free { + self.set.remove(id); + } + for (id, deltas) in newer.set.drain() { + for delta in deltas { + self.push(id, delta); + } + } + self.free.extend(mem::take(&mut newer.free)); } pub fn clear(&mut self) { @@ -298,28 +332,42 @@ impl TexturesDelta { } } -impl std::fmt::Debug for TexturesDelta { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use std::fmt::Write as _; +impl Drop for TexturesDelta { + fn drop(&mut self) { + debug_assert!( + self.is_empty(), + "Dropped TexturesDelta with {} unapplied deltas. Deltas need to be handled. \ + If you want to drop this intentionally call `clear` before dropping.", + self.free.len() + self.set.len() + ); + } +} + +impl core::fmt::Debug for TexturesDelta { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + use core::fmt::Write as _; let mut debug_struct = f.debug_struct("TexturesDelta"); if !self.set.is_empty() { let mut string = String::new(); - for (tex_id, delta) in &self.set { - let size = delta.image.size(); - if let Some(pos) = delta.pos { - write!( - string, - "{:?} partial ([{} {}] - [{} {}]), ", - tex_id, - pos[0], - pos[1], - pos[0] + size[0], - pos[1] + size[1] - ) - .ok(); - } else { - write!(string, "{:?} full {}x{}, ", tex_id, size[0], size[1]).ok(); + #[expect(clippy::iter_over_hash_type)] + for (tex_id, deltas) in &self.set { + for delta in deltas { + let size = delta.image.size(); + if let Some(pos) = delta.pos { + write!( + string, + "{:?} partial ([{} {}] - [{} {}]), ", + tex_id, + pos[0], + pos[1], + pos[0] + size[0], + pos[1] + size[1] + ) + .ok(); + } else { + write!(string, "{:?} full {}x{}, ", tex_id, size[0], size[1]).ok(); + } } } debug_struct.field("set", &string); diff --git a/crates/epaint/src/util/mod.rs b/crates/epaint/src/util/mod.rs index 471576630..ad324adcf 100644 --- a/crates/epaint/src/util/mod.rs +++ b/crates/epaint/src/util/mod.rs @@ -1,12 +1,12 @@ /// Hash the given value with a predictable hasher. #[inline] -pub fn hash(value: impl std::hash::Hash) -> u64 { +pub fn hash(value: impl core::hash::Hash) -> u64 { ahash::RandomState::with_seeds(1, 2, 3, 4).hash_one(value) } /// Hash the given value with the given hasher. #[inline] -pub fn hash_with(value: impl std::hash::Hash, mut hasher: impl std::hash::Hasher) -> u64 { +pub fn hash_with(value: impl core::hash::Hash, mut hasher: impl core::hash::Hasher) -> u64 { value.hash(&mut hasher); hasher.finish() } diff --git a/crates/epaint_default_fonts/CHANGELOG.md b/crates/epaint_default_fonts/CHANGELOG.md index 67a0a9234..0d6af564f 100644 --- a/crates/epaint_default_fonts/CHANGELOG.md +++ b/crates/epaint_default_fonts/CHANGELOG.md @@ -5,6 +5,14 @@ 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.36.1 - 2026-08-07 +Nothing new + + +## 0.36.0 - 2026-08-05 +Nothing new + + ## 0.35.0 - 2026-06-25 Nothing new diff --git a/deny.toml b/deny.toml index ff6c8e2ac..01faf237a 100644 --- a/deny.toml +++ b/deny.toml @@ -34,8 +34,6 @@ ignore = [ "RUSTSEC-2024-0320", # unmaintained yaml-rust pulled in by syntect "RUSTSEC-2025-0141", # https://rustsec.org/advisories/RUSTSEC-2025-0141 - bincode is unmaintained - https://git.sr.ht/~stygianentity/bincode/tree/v3.0/item/README.md "RUSTSEC-2026-0192", # ttf-parser is unmaintained. Only brought in via winit/sctk-adwaita (wayland window frame rendering) - "RUSTSEC-2026-0194", # quick-xml DoS - fix is in >=0.41, but held back transitively by zbus_xml (accesskit) and wayland-scanner (winit) - "RUSTSEC-2026-0195", # quick-xml DoS - same as above "RUSTSEC-2026-0206", # rustybuzz is unmaintained. Brought in via resvg. TODO(linebender/resvg#922): Remove once the PR lands and is released ] @@ -52,7 +50,7 @@ skip = [ { name = "bit-set" }, # wgpu's naga depends on 0.8, syntect's (used by egui_extras) fancy-regex depends on 0.5 { name = "bit-vec" }, # dependency of bit-set in turn, different between 0.6 and 0.5 { name = "bitflags" }, # old 1.0 version via glutin, png, spirv, … - { name = "core-foundation" }, # version conflict between winit and wgpu ecosystems + { name = "env_filter" }, # 0.1.4 only used by android_logger, which is only used by the hello_android example { name = "foldhash" }, # pulled by the duplicated hashbrown versions { name = "getrandom" }, # ring / rustls (and thus ehttp) still depend on getrandom 0.2 { name = "hashbrown" }, # wgpu's naga depends on 0.16, accesskit depends on 0.15 diff --git a/examples/external_eventloop/src/main.rs b/examples/external_eventloop/src/main.rs index 227fd3f33..a84dcd551 100644 --- a/examples/external_eventloop/src/main.rs +++ b/examples/external_eventloop/src/main.rs @@ -1,8 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![expect(rustdoc::missing_crate_level_docs, clippy::unwrap_used)] // it's an example +use core::cell::Cell; use eframe::{UserEvent, egui}; -use std::{cell::Cell, rc::Rc}; +use std::rc::Rc; use winit::event_loop::{ControlFlow, EventLoop}; fn main() -> eframe::Result { diff --git a/examples/external_eventloop_async/src/app.rs b/examples/external_eventloop_async/src/app.rs index d0e62e3cc..ea9c0b413 100644 --- a/examples/external_eventloop_async/src/app.rs +++ b/examples/external_eventloop_async/src/app.rs @@ -1,6 +1,7 @@ #![expect(clippy::unwrap_used)] // It's an example -use std::{cell::Cell, io, os::fd::AsRawFd as _, rc::Rc, time::Duration}; +use core::{cell::Cell, time::Duration}; +use std::{io, os::fd::AsRawFd as _, rc::Rc}; use tokio::task::LocalSet; use winit::event_loop::{ControlFlow, EventLoop}; diff --git a/examples/file_dialog/src/main.rs b/examples/file_dialog/src/main.rs index 914bd3423..3034e2376 100644 --- a/examples/file_dialog/src/main.rs +++ b/examples/file_dialog/src/main.rs @@ -20,7 +20,7 @@ fn main() -> eframe::Result { #[derive(Default)] struct MyApp { - dropped_files: Vec, + dropped_files: Vec, picked_path: Option, } @@ -48,27 +48,23 @@ impl eframe::App for MyApp { ui.label("Dropped files:"); for file in &self.dropped_files { - let mut info = if let Some(path) = &file.path { - path.display().to_string() - } else if file.name.is_empty() { - "???".to_owned() - } else { - file.name.clone() - }; + #[cfg(not(target_arch = "wasm32"))] + ui.label(file.path().display().to_string()); - let mut additional_info = vec![]; - if !file.mime.is_empty() { - additional_info.push(format!("type: {}", file.mime)); + #[cfg(target_arch = "wasm32")] + { + let Some(web_file) = file.web_file() else { + continue; + }; + let name = web_file.name(); + let mime = web_file.type_(); + let size = web_file.size(); + if mime.is_empty() { + ui.label(format!("{name} ({size} bytes)")); + } else { + ui.label(format!("{name} (type: {mime}, {size} bytes)")); + } } - if let Some(bytes) = &file.bytes { - additional_info.push(format!("{} bytes", bytes.len())); - } - if !additional_info.is_empty() { - use std::fmt::Write as _; - write!(info, " ({})", additional_info.join(", ")).ok(); - } - - ui.label(info); } }); } @@ -87,8 +83,8 @@ impl eframe::App for MyApp { /// Preview hovering files: fn preview_files_being_dropped(ctx: &egui::Context) { + use core::fmt::Write as _; use egui::{Align2, Color32, Id, LayerId, Order, TextStyle}; - use std::fmt::Write as _; if !ctx.input(|i| i.raw.hovered_files.is_empty()) { let text = ctx.input(|i| { diff --git a/examples/hello_world_par/src/main.rs b/examples/hello_world_par/src/main.rs index b064c65bd..ee62b1668 100644 --- a/examples/hello_world_par/src/main.rs +++ b/examples/hello_world_par/src/main.rs @@ -106,10 +106,10 @@ impl MyApp { } } -impl std::ops::Drop for MyApp { +impl core::ops::Drop for MyApp { fn drop(&mut self) { for (handle, show_tx) in self.threads.drain(..) { - std::mem::drop(show_tx); + core::mem::drop(show_tx); handle.join().unwrap(); } } diff --git a/examples/multiple_viewports/src/main.rs b/examples/multiple_viewports/src/main.rs index b75d3c016..59383aa93 100644 --- a/examples/multiple_viewports/src/main.rs +++ b/examples/multiple_viewports/src/main.rs @@ -1,10 +1,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![expect(rustdoc::missing_crate_level_docs)] // it's an example -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; +use core::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use eframe::egui; diff --git a/examples/popups/Cargo.toml b/examples/popups/Cargo.toml index d1f199918..c0d4384c7 100644 --- a/examples/popups/Cargo.toml +++ b/examples/popups/Cargo.toml @@ -2,6 +2,7 @@ name = "popups" edition.workspace = true license.workspace = true +publish = false rust-version.workspace = true version.workspace = true diff --git a/examples/puffin_profiler/src/main.rs b/examples/puffin_profiler/src/main.rs index 89118e8c7..03e4eb790 100644 --- a/examples/puffin_profiler/src/main.rs +++ b/examples/puffin_profiler/src/main.rs @@ -1,10 +1,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![expect(rustdoc::missing_crate_level_docs)] // it's an example -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; +use core::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use eframe::egui; @@ -88,7 +86,7 @@ impl eframe::App for MyApp { .clicked() { puffin::profile_scope!("long_sleep"); - std::thread::sleep(std::time::Duration::from_millis(50)); + std::thread::sleep(core::time::Duration::from_millis(50)); } ui.checkbox( @@ -172,7 +170,7 @@ fn start_puffin_server() { // We can store the server if we want, but in this case we just want // it to keep running. Dropping it closes the server, so let's not drop it! #[expect(clippy::mem_forget)] - std::mem::forget(puffin_server); + core::mem::forget(puffin_server); } Err(err) => { log::error!("Failed to start puffin server: {err}"); diff --git a/examples/serial_windows/src/main.rs b/examples/serial_windows/src/main.rs index 8ef07dc9c..0ece04d34 100644 --- a/examples/serial_windows/src/main.rs +++ b/examples/serial_windows/src/main.rs @@ -19,7 +19,7 @@ fn main() -> eframe::Result { Box::new(|_cc| Ok(Box::new(MyApp { has_next: true }))), )?; - std::thread::sleep(std::time::Duration::from_secs(2)); + std::thread::sleep(core::time::Duration::from_secs(2)); log::info!("Starting second window…"); eframe::run_native( @@ -28,7 +28,7 @@ fn main() -> eframe::Result { Box::new(|_cc| Ok(Box::new(MyApp { has_next: true }))), )?; - std::thread::sleep(std::time::Duration::from_secs(2)); + std::thread::sleep(core::time::Duration::from_secs(2)); log::info!("Starting third window…"); eframe::run_native( diff --git a/examples/user_attention/src/main.rs b/examples/user_attention/src/main.rs index 46d8fbb99..6b16ddd07 100644 --- a/examples/user_attention/src/main.rs +++ b/examples/user_attention/src/main.rs @@ -4,7 +4,8 @@ use eframe::{CreationContext, NativeOptions, egui}; use egui::{Button, CentralPanel, UserAttentionType}; -use std::time::{Duration, SystemTime}; +use core::time::Duration; +use std::time::SystemTime; fn main() -> eframe::Result { env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). diff --git a/tests/egui_tests/Cargo.toml b/tests/egui_tests/Cargo.toml index bc719e239..7e894a2c0 100644 --- a/tests/egui_tests/Cargo.toml +++ b/tests/egui_tests/Cargo.toml @@ -2,6 +2,7 @@ name = "egui_tests" edition.workspace = true license.workspace = true +publish = false rust-version.workspace = true version.workspace = true diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index f250b1ab2..b72ac6b61 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -1,15 +1,15 @@ use std::sync::Arc; -use egui::ScrollArea; use egui::accesskit::Role; #[cfg(debug_assertions)] use egui::epaint::Shape; use egui::style::ScrollAnimation; use egui::text::{LayoutJob, TextWrapping}; use egui::{ - Align, Button, Color32, FontFamily, FontId, Image, Label, Layout, RichText, Sense, TextBuffer, - TextFormat, TextWrapMode, Ui, include_image, vec2, + Align, Button, Color32, FontFamily, FontId, Image, Label, Layout, Rect, RichText, Sense, + TextBuffer, TextFormat, TextWrapMode, Ui, Vec2, include_image, vec2, }; +use egui::{Pos2, ScrollArea}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT as _, Queryable as _}; @@ -316,7 +316,7 @@ fn warn_if_rect_changes_id() { #[test] #[cfg(debug_assertions)] fn warn_if_rect_changes_id_false_positive_parent_shift() { - use std::cell::Cell; + use core::cell::Cell; let counter = Cell::new(0); let button_rect = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(100.0, 30.0)); @@ -481,3 +481,197 @@ fn animated_scroll_beats_sticky_bottom() { "animated explicit scroll should leave the sticky bottom" ); } + +/// Tests that tooltips are shown correctly for buttons that are only shown on hover. +/// +/// Basically, this tests that a tooltip overlapping the mouse cursor does not interfere with a +/// buttons hover state. +#[test] +fn tooltip_should_work_for_hover_button() { + let button_rect = Rect::from_min_size(Pos2::new(4.0, 4.0), Vec2::new(80.0, 20.0)); + let mut harness = Harness::builder().with_size((320.0, 80.0)).build_ui(|ui| { + if ui.rect_contains_pointer(button_rect) { + ui.button("A tooltip should be shown") + .on_hover_text("My tooltip"); + } + }); + + harness.hover_at(button_rect.center()); + + harness.run(); + + harness.snapshot("test_tooltip_hover_regression"); +} + +/// Ensure that hovering close to a widget doesn't cause a tooltip feedback loop (due to a +/// difference between `hovered` and `contains_pointer` caused by the interact radius). +#[test] +fn tooltip_covering_button_should_not_cause_feedback_loop() { + let mut harness = Harness::builder().with_size((200.0, 30.0)).build_ui(|ui| { + ui.button("A tooltip should be shown") + .on_hover_text("This tooltip is larger than the button"); + }); + + harness.hover_at( + harness + .get_by_label("A tooltip should be shown") + .rect() + .left_center() + - Vec2::X, + ); + + harness.run(); + + harness.snapshot("tooltip_covering_button_should_not_cause_feedback_loop"); +} + +/// Tests that a tooltip closes when the pointer moves onto a neighboring widget, +/// so that the neighbor can show its own tooltip. +/// +/// The two buttons are only `item_spacing.y` (3 pt) apart, which is less than the +/// hit-test `interact_radius` (5 pt), so the first button is still close enough to +/// interact with when the pointer is on the second one. +#[test] +fn tooltip_should_hand_over_to_neighboring_widget() { + let mut harness = Harness::builder().with_size((300.0, 200.0)).build_ui(|ui| { + ui.button("Button A").on_hover_text("Tooltip A"); + ui.button("Button B").on_hover_text("Tooltip B"); + }); + + let a_rect = harness.get_by_label("Button A").rect(); + let b_rect = harness.get_by_label("Button B").rect(); + + harness.hover_at(a_rect.center_bottom() - Vec2::Y); + harness.run(); + assert!( + harness.query_by_label("Tooltip A").is_some(), + "Tooltip A should be shown when hovering Button A" + ); + + harness.hover_at(b_rect.center_top() + Vec2::Y); + harness.run(); + assert!( + harness.query_by_label("Tooltip B").is_some(), + "Tooltip B should be shown when hovering Button B" + ); + assert!( + harness.query_by_label("Tooltip A").is_none(), + "Tooltip A should be hidden when hovering Button B" + ); +} + +/// When a window is minimized or occluded, the integration runs no pass at all, +/// and instead ticks the app logic with [`egui::Context::run_logic`]. +/// +/// Such a tick must leave all ui state alone. Otherwise areas think they were hidden and +/// replay their fade-in, popups close, focus is lost, and child viewports pop back up. +/// See . +#[test] +fn run_logic_should_not_disturb_ui_state() { + const MENU: &str = "My menu"; + const MENU_ITEM: &str = "Button in my menu"; + const FOCUSED_BUTTON: &str = "Click me"; + + let child_viewport = egui::ViewportId::from_hash_of("My child viewport"); + let area_id = egui::Id::new("My area"); + let area_layer = egui::LayerId::new(egui::Order::Middle, area_id); + + let mut harness = Harness::builder() + .with_size(Vec2::new(400.0, 300.0)) + .build_ui(move |ui| { + // A backend that can open real windows, like eframe: + ui.ctx().set_embed_viewports(false); + + ui.ctx() + .show_viewport_deferred(child_viewport, Default::default(), |_ui, _class| {}); + + ui.menu_button(MENU, |ui| { + _ = ui.button(MENU_ITEM); + }); + + egui::Area::new(area_id) + .fixed_pos((150.0, 120.0)) + .show(ui.ctx(), |ui| { + _ = ui.button(FOCUSED_BUTTON); + }); + }); + + harness.get_by_label(MENU).click(); + harness.run(); + // Nothing asks for focus again, so the test fails if egui ever loses it: + harness.get_by_label(FOCUSED_BUTTON).focus(); + harness.run(); + + let assert_state = |harness: &Harness<'_>| { + assert!( + harness + .get_by_label(FOCUSED_BUTTON) + .accesskit_node() + .is_focused(), + "The button lost focus" + ); + harness.get_by_label(MENU_ITEM); // Panics if the menu closed + assert!( + harness + .ctx + .memory(|m| m.areas().visible_last_frame(&area_layer)), + "Area state was reset" + ); + assert!( + harness + .ctx + .viewport_for(child_viewport, |viewport| viewport.class) + == egui::ViewportClass::Deferred, + "The child viewport was closed" + ); + }; + + assert_state(&harness); + + // The window is now occluded, so the integration runs no pass, + // and only ticks the app logic: + for i in 0..2 { + let time = 100.0 + f64::from(i); + let mut raw_input = egui::RawInput { + time: Some(time), + ..Default::default() + }; + raw_input + .viewports + .entry(egui::ViewportId::ROOT) + .or_default() + .occluded = Some(true); + + let output = harness.ctx.run_logic(&raw_input, |ctx| { + assert_eq!( + ctx.input(|i| i.viewport().occluded), + Some(true), + "App logic should be able to tell that the window is occluded" + ); + assert!( + ctx.input(|i| i.time) != time, + "The ui input should not be interpreted: it is for the next pass" + ); + + // The app asks to be shown again: + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + }); + + assert_eq!( + output + .viewport_commands + .into_values() + .flatten() + .collect::>(), + vec![egui::ViewportCommand::Focus], + "The integration should receive the command, even though there was no pass" + ); + + assert_state(&harness); + } + + // The window is visible again, and everything should be where we left it: + harness.run(); + + assert_state(&harness); +} diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png b/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png index cb3b393e6..6c824bd91 100644 --- a/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_collapsed.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eba6e690937fbbd22c8edfce14078f50998e968324d8073d5db5829493d957e0 +oid sha256:9a21708315ae4514c1ff2c71075ccce2bef4627720808e4eebc9d7922388a17f size 5292 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png b/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png index 06679d16d..6310e8309 100644 --- a/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_initial_expanded.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525 +oid sha256:a0c8d12d6f3741d33b993b5390fba5b845778a1f4dcf6d661513e65243978e18 size 65797 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png b/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png index 06679d16d..6310e8309 100644 --- a/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png +++ b/tests/egui_tests/tests/snapshots/panel_drag/between_reopened.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84e6030760561e308a190d2eb9781f53b896fbea5d11a3548b73d68c49f4d525 +oid sha256:a0c8d12d6f3741d33b993b5390fba5b845778a1f4dcf6d661513e65243978e18 size 65797 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_hovered.png b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_hovered.png new file mode 100644 index 000000000..27e751fa8 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_hovered.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71c6c7299aad708fd16b945bcc9c82f529a276486eb1b2042f2ebaa932935a54 +size 3516 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_idle.png b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_idle.png new file mode 100644 index 000000000..fced7caae --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_idle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc0393c0b85df389314d15161b14803fde138eb66ef2b055335aef275ac03d1c +size 3538 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_reopened.png b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_reopened.png new file mode 100644 index 000000000..ba81d4095 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_drag/collapsed_handle_reopened.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bbf9c826403a00937f9e37cccc76b16dc67db8aef3536bf076922fdd2d564d5 +size 4881 diff --git a/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png b/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png index 8a9963284..b25aa5581 100644 --- a/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png +++ b/tests/egui_tests/tests/snapshots/panel_drag/inside_initial.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf09470a6628e62421bfa754ce238dd13820ba0bf8146ae835e539874d39a150 -size 4882 +oid sha256:054c9005865ff2d6c1ee6c76539453fbad6005d1c62693de5653c4bb00164322 +size 4881 diff --git a/tests/egui_tests/tests/snapshots/panel_separator_line/separator_off_hovered.png b/tests/egui_tests/tests/snapshots/panel_separator_line/separator_off_hovered.png new file mode 100644 index 000000000..bb3745c59 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_separator_line/separator_off_hovered.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbe8eda2e6162220ff8baa6e84156ea18f0bb15ffc46ebf9d60b6d3eeb8861d4 +size 9016 diff --git a/tests/egui_tests/tests/snapshots/panel_separator_line/separator_off_idle.png b/tests/egui_tests/tests/snapshots/panel_separator_line/separator_off_idle.png new file mode 100644 index 000000000..8a690c1a5 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_separator_line/separator_off_idle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:547316973b563bd92841f428838848323d656688cd03e8babcbc24626a389419 +size 7195 diff --git a/tests/egui_tests/tests/snapshots/panel_separator_line/separator_on_hovered.png b/tests/egui_tests/tests/snapshots/panel_separator_line/separator_on_hovered.png new file mode 100644 index 000000000..43e51aa02 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_separator_line/separator_on_hovered.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b18774a40c1c1065645be7b59c90e65bb6fb40d4931ab37ccf40126e2767b709 +size 9019 diff --git a/tests/egui_tests/tests/snapshots/panel_separator_line/separator_on_idle.png b/tests/egui_tests/tests/snapshots/panel_separator_line/separator_on_idle.png new file mode 100644 index 000000000..3c92145ab --- /dev/null +++ b/tests/egui_tests/tests/snapshots/panel_separator_line/separator_on_idle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e9ffcb9f14f86155a4a8279d694ae3e43c6bb8df59fa681673199c01ff68f96 +size 7206 diff --git a/tests/egui_tests/tests/snapshots/test_tooltip_hover_regression.png b/tests/egui_tests/tests/snapshots/test_tooltip_hover_regression.png new file mode 100644 index 000000000..9f489aa81 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/test_tooltip_hover_regression.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59bef0e3593896985988c03171c710d8ed31ed8bb89c7a3f63c060c573e4fb74 +size 6510 diff --git a/tests/egui_tests/tests/snapshots/tooltip_covering_button_should_not_cause_feedback_loop.png b/tests/egui_tests/tests/snapshots/tooltip_covering_button_should_not_cause_feedback_loop.png new file mode 100644 index 000000000..53f8ff24d --- /dev/null +++ b/tests/egui_tests/tests/snapshots/tooltip_covering_button_should_not_cause_feedback_loop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed2f80921ab864b0df3303834a8ab943a3aa5e10456896991c2dca81be0c4968 +size 3968 diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index bf1054ce1..b6cd6cda8 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -225,8 +225,8 @@ fn test_atom_selectable_senses_click_and_drag() { /// See . #[test] fn test_atom_selectable_text_can_be_copied() { + use core::cell::Cell; use egui::{AtomLayout, Event, Modifiers, OutputCommand, PointerButton, Pos2, Rect}; - use std::cell::Cell; fn copied_text(selectable: bool) -> Option { let rect_cell = Cell::new(Rect::NOTHING); diff --git a/tests/egui_tests/tests/test_click_or_drag.rs b/tests/egui_tests/tests/test_click_or_drag.rs new file mode 100644 index 000000000..c3cc010ec --- /dev/null +++ b/tests/egui_tests/tests/test_click_or_drag.rs @@ -0,0 +1,234 @@ +//! Tests for how egui decides whether a press on a click-and-drag widget +//! is a click or a drag. + +use egui::{Id, InputOptions, Pos2, Rect, Sense, Style, Vec2}; +use egui_kittest::Harness; + +/// How far the pointer may move before a press is decidedly a drag. +fn max_click_dist() -> f32 { + InputOptions::default().max_click_dist +} + +/// How far outside its rect a widget can still be hit. +fn interact_radius() -> f32 { + Style::default().interaction.interact_radius +} + +fn widget_id() -> Id { + Id::new("click_and_drag") +} + +/// A harness with one click-and-drag widget of the given size at the top-left. +/// +/// If `with_background`, a second click-and-drag widget covers the whole area +/// _beneath_ it. That one matters: without something under the pointer to take +/// over the hover, the first widget keeps it even after the pointer leaves. +/// +/// Steps at 60Hz. The default `step_dt` of 0.25s would blow past +/// `max_click_duration` within a couple of frames, turning every press into a +/// drag before the distance rules get a chance to matter. +fn harness_with_widget(size: Vec2, with_background: bool) -> Harness<'static, ()> { + Harness::builder() + .with_step_dt(1.0 / 60.0) + .with_size(Vec2::new(300.0, 200.0)) + .build_ui(move |ui| { + if with_background { + // Allocated first, so it ends up _behind_ the widget under test. + ui.interact( + ui.max_rect(), + Id::new("background"), + Sense::click_and_drag(), + ); + } + let rect = Rect::from_min_size(ui.max_rect().min, size); + ui.interact(rect, widget_id(), Sense::click_and_drag()); + }) +} + +/// The widget's `(hovered, dragged)` as of the last completed pass. +fn widget_state(harness: &Harness<'_, ()>) -> (bool, bool) { + harness + .ctx + .read_response(widget_id()) + .map(|r| (r.hovered(), r.dragged())) + .expect("the widget should have been registered") +} + +/// The widget's rect as of the last completed pass. +fn widget_rect(harness: &Harness<'_, ()>) -> Rect { + harness + .ctx + .read_response(widget_id()) + .expect("the widget should have been registered") + .rect +} + +/// Press the primary button at `pos`, without releasing it. +fn press_at(harness: &mut Harness<'_, ()>, pos: Pos2) { + harness.hover_at(pos); + harness.step(); + harness.drag_at(pos); + harness.step(); +} + +/// Once a release can no longer land on the widget, the press can no longer become +/// a click, so it counts as a drag right away — without waiting for `max_click_dist`. +/// +/// This matters for widgets thinner than `max_click_dist` (panel resize handles, +/// say): waiting would leave them neither hovered nor dragged for a few frames, +/// which shows up as a flickering highlight. +#[test] +fn press_that_leaves_a_thin_widget_becomes_a_drag_immediately() { + let width = max_click_dist() / 2.0; // thinner than `max_click_dist` + let mut harness = harness_with_widget(Vec2::new(width, 100.0), true); + harness.step(); + + let grab = widget_rect(&harness).center(); + press_at(&mut harness, grab); + + let (hovered, dragged) = widget_state(&harness); + assert!(hovered && !dragged, "the press starts out undecided"); + + // Creep outward in 1px steps, never reaching `max_click_dist` — + // if we did, `is_decidedly_dragging` would explain the drag on its own + // and the test would prove nothing. + let mut saw_drag = false; + for step in 1..max_click_dist().ceil() as i32 { + let offset = step as f32; + harness.hover_at(Pos2::new(grab.x + offset, grab.y)); + harness.step(); + + let (hovered, dragged) = widget_state(&harness); + saw_drag |= dragged; + assert!( + hovered || dragged, + "at +{offset}px the widget was neither hovered nor dragged, \ + so anything highlighting on `hovered || dragged` would blink out" + ); + } + + assert!( + saw_drag, + "leaving the widget should have started a drag, even within max_click_dist" + ); +} + +/// While the pointer is still on the widget, a press stays undecided: hovered, +/// but not yet dragged, so it can still become a click. +#[test] +fn press_inside_a_wide_widget_stays_undecided() { + let mut harness = harness_with_widget(Vec2::new(100.0, 100.0), true); + harness.step(); + + let grab = widget_rect(&harness).center(); + press_at(&mut harness, grab); + + // A small twitch: inside the widget, and inside `max_click_dist`. + harness.hover_at(Pos2::new(grab.x + max_click_dist() / 2.0, grab.y)); + harness.step(); + + let (hovered, dragged) = widget_state(&harness); + assert!(hovered, "the pointer is still over the widget"); + assert!( + !dragged, + "a small twitch inside the widget should still be able to become a click" + ); +} + +/// A press just _outside_ the widget still hits it, thanks to `interact_radius`. +/// The pointer hasn't moved at all, so this must not count as leaving the widget. +#[test] +fn press_just_outside_a_widget_stays_undecided() { + // No background: we want the widget to win the hit-test from a distance. + let mut harness = harness_with_widget(Vec2::new(100.0, 100.0), false); + harness.step(); + + let rect = widget_rect(&harness); + let offset = interact_radius() - 1.0; + let grab = Pos2::new(rect.right() + offset, rect.center().y); + press_at(&mut harness, grab); + + let (hovered, dragged) = widget_state(&harness); + assert!( + hovered, + "a press within interact_radius still hits the widget" + ); + assert!( + !dragged, + "the pointer never moved, so this press must still be able to become a click" + ); +} + +/// A press and release inside the widget is still a click, not a drag. +#[test] +fn click_inside_a_widget_still_clicks() { + let mut harness = harness_with_widget(Vec2::new(100.0, 100.0), true); + harness.step(); + + let grab = widget_rect(&harness).center(); + press_at(&mut harness, grab); + // Release without `drop_at`, which would also fire `PointerGone` and so + // discard the click. + harness.event(egui::Event::PointerButton { + pos: grab, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + harness.step(); + + assert!( + harness + .ctx + .read_response(widget_id()) + .is_some_and(|r| r.clicked()), + "press and release without moving should be a click" + ); +} + +/// A button inside a draggable row takes the click hit, because it is on top. +/// The pointer is still inside the row though, so the press must stay undecided — +/// otherwise the row starts dragging the moment the user touches the button. +#[test] +fn press_on_a_button_inside_a_draggable_row_stays_undecided() { + let button_id = Id::new("button"); + let button_size = Vec2::new(50.0, 20.0); + + let mut harness = Harness::builder() + .with_step_dt(1.0 / 60.0) + .with_size(Vec2::new(300.0, 200.0)) + .build_ui(move |ui| { + let row_rect = ui.max_rect(); + ui.interact(row_rect, widget_id(), Sense::click_and_drag()); + + // Allocated after the row, so it ends up _on top_ of it. + let button_rect = Rect::from_min_size(row_rect.min, button_size); + ui.interact(button_rect, button_id, Sense::click()); + }); + harness.step(); + + let grab = Rect::from_min_size(widget_rect(&harness).min, button_size).center(); + press_at(&mut harness, grab); + + let (_hovered, dragged) = widget_state(&harness); + assert!( + !dragged, + "pressing a button inside the row must not start dragging the row" + ); + + harness.event(egui::Event::PointerButton { + pos: grab, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + harness.step(); + + assert!( + harness + .ctx + .read_response(button_id) + .is_some_and(|r| r.clicked()), + "the button should have been clicked" + ); +} diff --git a/tests/egui_tests/tests/test_panel_drag.rs b/tests/egui_tests/tests/test_panel_drag.rs index 7068ba49e..96c7daba1 100644 --- a/tests/egui_tests/tests/test_panel_drag.rs +++ b/tests/egui_tests/tests/test_panel_drag.rs @@ -2,6 +2,8 @@ //! //! Covers: //! * [`Panel::show_collapsible`] — drag-to-close on a `Left` panel. +//! * [`Panel::show_collapsible`] — drag-to-open via the grab handle a fully +//! collapsed panel leaves behind, plus [`Panel::drag_to_open`] opting out of it. //! * [`Panel::show_switched`] — drag-to-close on the expanded panel //! followed by drag-to-expand on the collapsed panel, both via the shared //! resize handle. @@ -13,6 +15,15 @@ use egui_kittest::{Harness, SnapshotResults}; #[derive(Default)] struct State { is_expanded: bool, + + /// The panel's live _outer_ width, recorded each pass. + /// + /// `None` while the panel is fully collapsed. + /// + /// We can't read this back from [`egui::PanelState`], because a panel + /// deliberately doesn't persist its size while its resize handle is being + /// dragged — which is exactly when these tests need to observe it. + panel_width: Option, } #[test] @@ -34,7 +45,10 @@ fn drag_to_close_animated_inside() { ui.label("Central"); }); }, - State { is_expanded: true }, + State { + is_expanded: true, + ..Default::default() + }, ); harness.run(); @@ -65,6 +79,156 @@ fn drag_to_close_animated_inside() { results.add(harness.try_snapshot("panel_drag/inside_closed")); } +/// The size range of the collapsible left panel used by the drag-to-open tests. +const MIN_SIZE: f32 = 60.0; +const DEFAULT_SIZE: f32 = 80.0; + +/// A harness with a single collapsible, resizable left panel. +/// +/// `drag_to_open` is passed straight through to [`Panel::drag_to_open`]. +fn collapsible_left_panel_harness(drag_to_open: bool) -> Harness<'static, State> { + Harness::builder() + .with_size(Vec2::new(400.0, 200.0)) + .build_ui_state( + move |ui, state: &mut State| { + let response = Panel::left("test_left_panel") + .resizable(true) + .drag_to_open(drag_to_open) + .default_size(DEFAULT_SIZE) + .min_size(MIN_SIZE) + .show_collapsible(ui, &mut state.is_expanded, |ui| { + ui.label("Left panel content"); + // Without this the frame shrinks to fit the label, and the + // panel's rect would report the content width instead of + // the width the panel was resized to. + ui.take_available_space(); + }); + state.panel_width = response.map(|response| response.response.rect.width()); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Central"); + }); + }, + State { + is_expanded: true, + panel_width: None, + }, + ) +} + +/// The panel's live _outer_ width, as of the last completed pass. +fn panel_width(harness: &Harness<'_, State>) -> f32 { + harness + .state() + .panel_width + .expect("the panel should be showing") +} + +/// Collapse the panel by dragging its resize edge past `min_size`, and return the +/// panel's fixed (left) edge — where the grab handle it leaves behind sits. +fn collapse_by_drag(harness: &mut Harness<'_, State>) -> Pos2 { + harness.run(); + assert!(harness.state().is_expanded, "should start expanded"); + + // Query the actual resize edge from PanelState (avoids assumptions about + // Frame margins and the harness's ui padding). + let panel_state = egui::PanelState::load(&harness.ctx, egui::Id::new("test_left_panel")) + .expect("PanelState should be persisted after the first frame"); + let fixed_edge = Pos2::new( + panel_state.outer_rect.left(), + panel_state.outer_rect.center().y, + ); + + let drag_start = Pos2::new(panel_state.outer_rect.right(), fixed_edge.y); + let drag_end = Pos2::new(drag_start.x - 200.0, fixed_edge.y); + + harness.drag_at(drag_start); + harness.run(); + harness.hover_at(drag_end); + harness.run(); + harness.drop_at(drag_end); + harness.run(); + + assert!( + !harness.state().is_expanded, + "drag past min_size should have closed the panel" + ); + + // Move the pointer away so the handle isn't left hovered. + harness.hover_at(Pos2::new(300.0, fixed_edge.y)); + harness.run(); + + fixed_edge +} + +#[test] +fn drag_to_open_collapsed_panel() { + let mut results = SnapshotResults::new(); + + let mut harness = collapsible_left_panel_harness(true); + let fixed_edge = collapse_by_drag(&mut harness); + // Grab just inside the fixed edge, where the handle is. + let handle_pos = fixed_edge + Vec2::new(1.0, 0.0); + + // The handle is invisible until hovered: + results.add(harness.try_snapshot("panel_drag/collapsed_handle_idle")); + + harness.hover_at(handle_pos); + harness.run(); + results.add(harness.try_snapshot("panel_drag/collapsed_handle_hovered")); + + // Dragging out but not as far as `min_size` must not reopen the panel. + harness.drag_at(handle_pos); + harness.run(); + let short_of_min = Pos2::new(fixed_edge.x + MIN_SIZE - 10.0, fixed_edge.y); + harness.hover_at(short_of_min); + harness.run(); + assert!( + !harness.state().is_expanded, + "dragging out less than min_size should not reopen the panel" + ); + + // …but continuing past `min_size` should, without releasing the drag. The + // panel opens at the size the pointer is already at, so it never jumps ahead. + let past_min = Pos2::new(fixed_edge.x + MIN_SIZE + 20.0, fixed_edge.y); + harness.hover_at(past_min); + harness.run(); + assert!( + harness.state().is_expanded, + "dragging out past min_size should have reopened the panel" + ); + assert_eq!( + panel_width(&harness), + past_min.x - fixed_edge.x, + "the reopened panel's edge should sit under the pointer" + ); + + harness.drop_at(past_min); + harness.run(); + assert!( + harness.state().is_expanded, + "the panel should stay open after the drag is released" + ); + results.add(harness.try_snapshot("panel_drag/collapsed_handle_reopened")); +} + +#[test] +fn drag_to_open_can_be_opted_out_of() { + let mut harness = collapsible_left_panel_harness(false); + let handle_pos = collapse_by_drag(&mut harness) + Vec2::new(1.0, 0.0); + + harness.drag_at(handle_pos); + harness.run(); + harness.hover_at(Pos2::new(handle_pos.x + 150.0, handle_pos.y)); + harness.run(); + harness.drop_at(Pos2::new(handle_pos.x + 150.0, handle_pos.y)); + harness.run(); + + assert!( + !harness.state().is_expanded, + "with `drag_to_open(false)` there should be no grab handle to reopen the panel with" + ); +} + #[test] fn drag_to_close_and_reopen_animated_between() { let mut results = SnapshotResults::new(); @@ -108,7 +272,10 @@ fn drag_to_close_and_reopen_animated_between() { ui.label("Central"); }); }, - State { is_expanded: true }, + State { + is_expanded: true, + ..Default::default() + }, ); harness.run(); @@ -155,3 +322,174 @@ fn drag_to_close_and_reopen_animated_between() { ); results.add(harness.try_snapshot("panel_drag/between_reopened")); } + +/// State for the animated-close test: records the panel's live top edge. +#[derive(Default)] +struct SwitchedState { + is_expanded: bool, + + /// Bottom of whatever space is left after the panel — i.e. the top edge of + /// the panel that is currently showing. + /// + /// Read from the ui rather than [`egui::PanelState`], which a panel doesn't + /// persist while its resize handle is held. + panel_top: f32, +} + +/// The sizes a `show_switched` bottom panel moves between in these tests. +/// +/// The expanded minimum sits well above the collapsed size, so the gap between +/// the two shows up in the panel's edge. +const SWITCHED_COLLAPSED_SIZE: f32 = 20.0; +const SWITCHED_EXPANDED_MIN: f32 = 80.0; + +fn switched_bottom_panel_harness(start_expanded: bool) -> Harness<'static, SwitchedState> { + let mut harness = Harness::builder() + .with_size(Vec2::new(400.0, 300.0)) + .with_step_dt(1.0 / 60.0) + .build_ui_state( + move |ui, state: &mut SwitchedState| { + Panel::show_switched( + ui, + &mut state.is_expanded, + Panel::bottom("switched_collapsed") + .resizable(true) + .exact_size(SWITCHED_COLLAPSED_SIZE), + Panel::bottom("switched_expanded") + .resizable(true) + .default_size(160.0) + .min_size(SWITCHED_EXPANDED_MIN) + .max_size(250.0), + |ui, _expanded| ui.take_available_space(), + ); + state.panel_top = ui.available_rect_before_wrap().bottom(); + egui::CentralPanel::default().show(ui, |_ui| {}); + }, + SwitchedState { + is_expanded: start_expanded, + ..Default::default() + }, + ); + // kittest disables animations by default, and these tests are about one. + harness + .ctx + .all_styles_mut(|style| style.animation_time = 0.25); + for _ in 0..4 { + harness.step(); + } + harness +} + +/// Assert that the panel edge crossed `gap` gradually, rather than in one frame. +fn assert_crossed_gradually(tops: &[f32], gap: core::ops::Range) { + let frames_in_gap = tops.iter().filter(|top| gap.contains(top)).count(); + assert!( + 3 <= frames_in_gap, + "expected the panel to be animated across the gap between the collapsed \ + size and the expanded min_size, but only {frames_in_gap} frame(s) landed \ + inside {gap:?}: {tops:?}" + ); +} + +/// Dragging the expanded panel shut animates it the rest of the way, rather than +/// snapping, even while the drag is still held. +/// +/// The expanded panel can't shrink past its own `min_size`, so a drag that goes +/// below that leaves a gap between where the panel is stuck and the collapsed +/// panel's size. That gap has to be animated, or the panel jumps. +#[test] +fn drag_to_close_switched_animates_while_held() { + let collapsed_size = SWITCHED_COLLAPSED_SIZE; + let expanded_min = SWITCHED_EXPANDED_MIN; + + let mut harness = switched_bottom_panel_harness(true); + + let expanded = egui::PanelState::load(&harness.ctx, egui::Id::new("switched_expanded")) + .expect("PanelState should be persisted after the first frame"); + let (x, bottom) = (expanded.outer_rect.center().x, expanded.outer_rect.bottom()); + let collapsed_top = bottom - collapsed_size; + + // Drag the top edge down well past the collapsed size, and keep holding. + harness.drag_at(Pos2::new(x, expanded.outer_rect.top())); + harness.step(); + harness.hover_at(Pos2::new(x, bottom - 10.0)); + harness.step(); + + assert!( + !harness.state().is_expanded, + "dragging past the collapsed size should have collapsed the panel" + ); + let top_at_collapse = harness.state().panel_top; + assert_eq!( + top_at_collapse, + bottom - expanded_min, + "the expanded panel should be stuck at its min_size when the collapse fires" + ); + + // Follow the close, still holding the drag. + let mut tops = vec![top_at_collapse]; + for _ in 0..40 { + harness.step(); + tops.push(harness.state().panel_top); + } + + assert!( + tops.windows(2).all(|w| w[0] <= w[1]), + "the panel should only ever move towards being shut, never jump back open: {tops:?}" + ); + assert!( + (tops.last().copied().unwrap_or_default() - collapsed_top).abs() < 1.0, + "the close should end at the collapsed panel's size, got {:?}", + tops.last() + ); + + // The gap between min_size and the collapsed size must be crossed over + // several frames, not in one jump. + assert_crossed_gradually(&tops, (top_at_collapse + 1.0)..(collapsed_top - 1.0)); +} + +/// The mirror image: dragging the collapsed panel open animates across the same +/// gap, instead of snapping straight out to the expanded panel's `min_size`. +#[test] +fn drag_to_open_switched_animates_while_held() { + let mut harness = switched_bottom_panel_harness(false); + + let collapsed = egui::PanelState::load(&harness.ctx, egui::Id::new("switched_collapsed")) + .expect("PanelState should be persisted after the first frame"); + let (x, collapsed_top, bottom) = ( + collapsed.outer_rect.center().x, + collapsed.outer_rect.top(), + collapsed.outer_rect.bottom(), + ); + let expanded_min_top = bottom - SWITCHED_EXPANDED_MIN; + + // Nudge the collapsed panel's top edge out past its `exact_size` cap, and keep + // holding. The pointer stays far short of the expanded panel's `min_size`. + harness.drag_at(Pos2::new(x, collapsed_top)); + harness.step(); + harness.hover_at(Pos2::new(x, collapsed_top - 10.0)); + harness.step(); + + assert!( + harness.state().is_expanded, + "a small outward drag past the collapsed panel's cap should expand it" + ); + + let mut tops = vec![harness.state().panel_top]; + for _ in 0..40 { + harness.step(); + tops.push(harness.state().panel_top); + } + + assert!( + tops.windows(2).all(|w| w[1] <= w[0]), + "the panel should only ever grow, never jump back shut: {tops:?}" + ); + assert!( + (tops.last().copied().unwrap_or_default() - expanded_min_top).abs() < 1.0, + "the panel should settle at the expanded min_size (top {expanded_min_top}), \ + since the pointer never got further out than that, got {:?}", + tops.last() + ); + assert_crossed_gradually(&tops, (expanded_min_top + 1.0)..(collapsed_top - 1.0)); +} diff --git a/tests/egui_tests/tests/test_panel_separator_line.rs b/tests/egui_tests/tests/test_panel_separator_line.rs new file mode 100644 index 000000000..96afb509f --- /dev/null +++ b/tests/egui_tests/tests/test_panel_separator_line.rs @@ -0,0 +1,105 @@ +//! Snapshot tests for where a [`Panel`] puts its separator line, and how much room it reserves. +//! +//! Going outwards from the panel contents, the order is: +//! +//! contents | `Frame::inner_margin` | `Frame::stroke` | separator line | `Frame::outer_margin` +//! +//! i.e. the line is painted _outside_ the frame's outline, in room the panel reserves for it in the +//! frame's outer margin. A panel that opted out of the separator line must not reserve that room, +//! or it ends up with a permanently visible gap along that edge — even though it is `resizable` and +//! therefore still shows a line while hovered or dragged. +//! +//! The snapshots span `show_separator_line` on/off × resize handle hovered/not. The panel uses a +//! garish frame outline and separator colors so both are unmistakable, and its only content is a +//! [`egui::SelectableLabel`] vertically centered in the panel: if the panel reserves room it +//! shouldn't, the label drifts off center. + +use egui::{Color32, CornerRadius, Frame, Margin, Panel, Pos2, Stroke, Vec2}; +use egui_kittest::{Harness, SnapshotResults}; + +/// [`Frame::fill`] of the test panel. +const FILL: Color32 = Color32::from_rgb(20, 20, 40); + +/// [`Frame::stroke`] color of the test panel. +const OUTLINE: Color32 = Color32::from_rgb(255, 0, 255); + +/// The dim, always-visible separator line (`noninteractive.bg_stroke`). +const SEPARATOR: Color32 = Color32::from_rgb(0, 255, 0); + +/// The bright separator line shown while the resize handle is hovered (`hovered.fg_stroke`). +const HOVERED_SEPARATOR: Color32 = Color32::from_rgb(255, 255, 0); + +const PANEL_ID: &str = "test_panel"; + +fn build_harness(show_separator_line: bool) -> Harness<'static> { + let mut harness = Harness::builder() + .with_size(Vec2::new(200.0, 120.0)) + // So the thin lines are legible to a human reviewing the snapshots: + .with_pixels_per_point(2.0) + .build_ui(move |ui| { + // Loud, distinguishable colors, so we can tell the separator line, the frame outline + // and the frame fill apart. + let widgets = &mut ui.visuals_mut().widgets; + widgets.noninteractive.bg_stroke = Stroke::new(1.0, SEPARATOR); + widgets.hovered.fg_stroke = Stroke::new(1.0, HOVERED_SEPARATOR); + + let frame = Frame::new() + .fill(FILL) + .stroke(Stroke::new(2.0, OUTLINE)) + .corner_radius(CornerRadius::ZERO) + .inner_margin(Margin::same(4)) + .outer_margin(Margin::same(2)); + + Panel::top(PANEL_ID) + .frame(frame) + .resizable(true) + .default_size(60.0) + .show_separator_line(show_separator_line) + .show(ui, |ui| { + // Vertically centered in whatever room the panel gave us. + ui.horizontal_centered(|ui| { + let _ = ui.selectable_label(true, "Centered"); + }); + }); + + egui::CentralPanel::default() + .frame(Frame::default().fill(Color32::GRAY)) + .show(ui, |ui| { + ui.label("CentralPanel"); + }); + }); + harness.run(); + harness +} + +fn hover_resize_handle(harness: &mut Harness<'_>) { + let outer = egui::PanelState::load(&harness.ctx, egui::Id::new(PANEL_ID)) + .expect("PanelState should be persisted after the first frame") + .outer_rect; + + // Hover just _inside_ the panel's inner edge, but still well within the resize grab radius: + // the `CentralPanel` and its label start exactly at that edge, and would otherwise take the + // hover from the resize handle. + harness.hover_at(Pos2::new(outer.center().x, outer.bottom() - 1.0)); + harness.run(); +} + +#[test] +fn separator_line_matrix() { + let mut results = SnapshotResults::new(); + + for show_separator_line in [false, true] { + let suffix = if show_separator_line { "on" } else { "off" }; + + // Not hovered: the line is dim (`show_separator_line`) or absent. + let mut harness = build_harness(show_separator_line); + results.add(harness.try_snapshot(format!("panel_separator_line/separator_{suffix}_idle"))); + + // Hovered: a `resizable` panel shows a bright line regardless of `show_separator_line`, + // and must not shift its contents to make room for it. + let mut harness = build_harness(show_separator_line); + hover_resize_handle(&mut harness); + results + .add(harness.try_snapshot(format!("panel_separator_line/separator_{suffix}_hovered"))); + } +} diff --git a/tests/test_background_logic/src/main.rs b/tests/test_background_logic/src/main.rs index b1c559916..0709bc2ed 100644 --- a/tests/test_background_logic/src/main.rs +++ b/tests/test_background_logic/src/main.rs @@ -2,7 +2,7 @@ #![expect(rustdoc::missing_crate_level_docs)] #![allow(clippy::print_stderr)] -use std::time::Duration; +use core::time::Duration; use eframe::egui::{self, ViewportInfo}; @@ -56,7 +56,7 @@ fn viewport_info(ctx: &egui::Context) -> String { ]; for (name, value) in flags { if let Some(value) = value { - use std::fmt::Write as _; + use core::fmt::Write as _; write!(s, " {name}={value}").ok(); } } diff --git a/tests/test_inline_glow_paint/src/main.rs b/tests/test_inline_glow_paint/src/main.rs index d23288706..517d1706d 100644 --- a/tests/test_inline_glow_paint/src/main.rs +++ b/tests/test_inline_glow_paint/src/main.rs @@ -11,7 +11,7 @@ use eframe::egui; use eframe::glow; -fn main() -> Result<(), Box> { +fn main() -> Result<(), Box> { env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). let options = eframe::NativeOptions { renderer: eframe::Renderer::Glow, diff --git a/tests/test_viewports/src/main.rs b/tests/test_viewports/src/main.rs index 62d357fa8..8dc00d7d9 100644 --- a/tests/test_viewports/src/main.rs +++ b/tests/test_viewports/src/main.rs @@ -451,17 +451,13 @@ fn drop_target( ) -> egui::InnerResponse { let is_being_dragged = ui.ctx().dragged_id().is_some(); - let margin = egui::Vec2::splat(ui.visuals().clip_rect_margin); // 3.0 - let background_id = ui.painter().add(egui::Shape::Noop); let available_rect = ui.available_rect_before_wrap(); - let inner_rect = available_rect.shrink2(margin); - let mut content_ui = ui.new_child(UiBuilder::new().max_rect(inner_rect)); + let mut content_ui = ui.new_child(UiBuilder::new().max_rect(available_rect)); let ret = body(&mut content_ui); - let outer_rect = - egui::Rect::from_min_max(available_rect.min, content_ui.min_rect().max + margin); + let outer_rect = egui::Rect::from_min_max(available_rect.min, content_ui.min_rect().max); let (rect, response) = ui.allocate_at_least(outer_rect.size(), egui::Sense::hover()); let style = if is_being_dragged && response.hovered() { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 81471d622..fb2bf2570 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -6,7 +6,7 @@ mod deny; pub(crate) mod utils; -type DynError = Box; +type DynError = Box; fn main() { if let Err(e) = try_main() {