diff --git a/.github/workflows/preview_deploy.yml b/.github/workflows/preview_deploy.yml index 8fbe8bae7..d29e7f4b0 100644 --- a/.github/workflows/preview_deploy.yml +++ b/.github/workflows/preview_deploy.yml @@ -62,6 +62,6 @@ jobs: Preview available at https://egui-pr-preview.github.io/pr/${{ env.URL_SLUG }} Note that it might take a couple seconds for the update to show up after the preview_build workflow has completed. - View snapshot changes at [kitdiff](https://rerun-io.github.io/kitdiff/?url=${{ github.event.pull_request.html_url }}) + View snapshot changes at [kitdiff](https://rerun-io.github.io/kitdiff/?url=https://github.com/emilk/egui/pull/${{ env.PR_NUMBER }}) pr_number: ${{ env.PR_NUMBER }} comment_tag: 'egui-preview' diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 85a059160..f71588545 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -46,7 +46,9 @@ jobs: - run: cargo clippy --locked --no-default-features --lib --all-targets - - run: cargo clippy --locked --no-default-features --features x11 --lib -p eframe + - run: cargo clippy --locked --no-default-features --lib -p eframe --features x11 + + - run: cargo clippy --locked --no-default-features --lib -p eframe --features x11,wgpu_no_default_features - run: cargo clippy --locked --no-default-features --lib -p egui_extras @@ -87,7 +89,7 @@ jobs: run: cargo clippy -p egui_demo_app --lib --target wasm32-unknown-unknown --all-features - name: clippy wasm32 eframe - run: cargo clippy -p eframe --lib --no-default-features --features glow,persistence --target wasm32-unknown-unknown + run: cargo clippy -p eframe --lib --no-default-features --features wgpu,persistence --target wasm32-unknown-unknown - name: wasm-bindgen uses: jetli/wasm-bindgen-action@v0.1.0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 51d6d41d1..be98b3308 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,7 +5,7 @@ Also see [`CONTRIBUTING.md`](CONTRIBUTING.md) for what to do before opening a PR ## Crate overview -The crates in this repository are: `egui, emath, epaint, epaint_default_fonts, egui_extras, egui-winit, egui_glow, egui_demo_lib, egui_demo_app`. +The crates in this repository are: `egui, emath, epaint, epaint_default_fonts, egui_extras, egui-winit, egui_glow, egui-wgpu, egui_demo_lib, egui_demo_app`. ### `egui`: The main GUI library. Example code: `if ui.button("Click me").clicked() { … }` @@ -37,6 +37,9 @@ The library translates winit events to egui, handled copy/paste, updates the cur ### `egui_glow` Puts an egui app inside a native window on your laptop. Paints the triangles that egui outputs using [glow](https://github.com/grovesNL/glow). +### `egui-wgpu` +Paints the triangles that egui outputs using [wgpu](https://github.com/grovesNL/wgpu). + ### `eframe` `eframe` is the official `egui` framework, built so you can compile the same app for either web or native. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc31e57b..856fe09da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,22 @@ 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.33.2 - 2025-11-13 +### ⭐ Added +* Add `Plugin::on_widget_under_pointer` to support widget inspector [#7652](https://github.com/emilk/egui/pull/7652) by [@juancampa](https://github.com/juancampa) +* Add `Response::total_drag_delta` and `PointerState::total_drag_delta` [#7708](https://github.com/emilk/egui/pull/7708) by [@emilk](https://github.com/emilk) + +### πŸ”§ Changed +* Improve accessibility and testability of `ComboBox` [#7658](https://github.com/emilk/egui/pull/7658) by [@lucasmerlin](https://github.com/lucasmerlin) + +### πŸ› Fixed +* Fix `profiling::scope` compile error when profiling using `tracing` backend [#7646](https://github.com/emilk/egui/pull/7646) by [@PPakalns](https://github.com/PPakalns) +* Fix edge cases in "smart aiming" in sliders [#7680](https://github.com/emilk/egui/pull/7680) by [@emilk](https://github.com/emilk) +* Hide scroll bars when dragging other things [#7689](https://github.com/emilk/egui/pull/7689) by [@emilk](https://github.com/emilk) +* Prevent widgets sometimes appearing to move relative to each other [#7710](https://github.com/emilk/egui/pull/7710) by [@emilk](https://github.com/emilk) +* Fix `ui.response().interact(Sense::click())` being flakey [#7713](https://github.com/emilk/egui/pull/7713) by [@lucasmerlin](https://github.com/lucasmerlin) + + ## 0.33.0 - 2025-10-09 - `egui::Plugin`, better kerning, kitdiff viewer Highlights from this release: - `egui::Plugin` a improved way to create and access egui plugins diff --git a/Cargo.lock b/Cargo.lock index e6cd1533c..404f3563c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1248,7 +1248,7 @@ checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" [[package]] name = "ecolor" -version = "0.33.0" +version = "0.33.2" dependencies = [ "bytemuck", "cint", @@ -1260,7 +1260,7 @@ dependencies = [ [[package]] name = "eframe" -version = "0.33.0" +version = "0.33.2" dependencies = [ "ahash", "bytemuck", @@ -1299,7 +1299,7 @@ dependencies = [ [[package]] name = "egui" -version = "0.33.0" +version = "0.33.2" dependencies = [ "accesskit", "ahash", @@ -1319,7 +1319,7 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.33.0" +version = "0.33.2" dependencies = [ "ahash", "bytemuck", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.33.0" +version = "0.33.2" dependencies = [ "accesskit_winit", "arboard", @@ -1360,7 +1360,7 @@ dependencies = [ [[package]] name = "egui_demo_app" -version = "0.33.0" +version = "0.33.2" dependencies = [ "accesskit", "accesskit_consumer", @@ -1390,7 +1390,7 @@ dependencies = [ [[package]] name = "egui_demo_lib" -version = "0.33.0" +version = "0.33.2" dependencies = [ "chrono", "criterion", @@ -1407,7 +1407,7 @@ dependencies = [ [[package]] name = "egui_extras" -version = "0.33.0" +version = "0.33.2" dependencies = [ "ahash", "chrono", @@ -1426,7 +1426,7 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.33.0" +version = "0.33.2" dependencies = [ "bytemuck", "document-features", @@ -1445,7 +1445,7 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.33.0" +version = "0.33.2" dependencies = [ "dify", "document-features", @@ -1457,13 +1457,15 @@ dependencies = [ "kittest", "open", "pollster", + "serde", "tempfile", + "toml", "wgpu", ] [[package]] name = "egui_tests" -version = "0.33.0" +version = "0.33.2" dependencies = [ "egui", "egui_extras", @@ -1493,7 +1495,7 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "emath" -version = "0.33.0" +version = "0.33.2" dependencies = [ "bytemuck", "document-features", @@ -1591,7 +1593,7 @@ dependencies = [ [[package]] name = "epaint" -version = "0.33.0" +version = "0.33.2" dependencies = [ "ab_glyph", "ahash", @@ -1613,7 +1615,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.33.0" +version = "0.33.2" [[package]] name = "equivalent" @@ -3425,7 +3427,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "popups" -version = "0.33.0" +version = "0.33.2" dependencies = [ "eframe", "env_logger", @@ -4036,6 +4038,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serial_windows" version = "0.1.0" @@ -4491,11 +4502,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "toml" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] [[package]] name = "toml_edit" @@ -4504,6 +4530,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap", + "serde", + "serde_spanned", "toml_datetime", "winnow", ] @@ -5645,9 +5673,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.3" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -5748,7 +5776,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.33.0" +version = "0.33.2" [[package]] name = "yaml-rust" diff --git a/Cargo.toml b/Cargo.toml index 7aaa81139..0a78d4354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ members = [ edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.88" -version = "0.33.0" +version = "0.33.2" [profile.release] @@ -55,18 +55,18 @@ opt-level = 2 [workspace.dependencies] -emath = { version = "0.33.0", path = "crates/emath", default-features = false } -ecolor = { version = "0.33.0", path = "crates/ecolor", default-features = false } -epaint = { version = "0.33.0", path = "crates/epaint", default-features = false } -epaint_default_fonts = { version = "0.33.0", path = "crates/epaint_default_fonts" } -egui = { version = "0.33.0", path = "crates/egui", default-features = false } -egui-winit = { version = "0.33.0", path = "crates/egui-winit", default-features = false } -egui_extras = { version = "0.33.0", path = "crates/egui_extras", default-features = false } -egui-wgpu = { version = "0.33.0", path = "crates/egui-wgpu", default-features = false } -egui_demo_lib = { version = "0.33.0", path = "crates/egui_demo_lib", default-features = false } -egui_glow = { version = "0.33.0", path = "crates/egui_glow", default-features = false } -egui_kittest = { version = "0.33.0", path = "crates/egui_kittest", default-features = false } -eframe = { version = "0.33.0", path = "crates/eframe", default-features = false } +emath = { version = "0.33.2", path = "crates/emath", default-features = false } +ecolor = { version = "0.33.2", path = "crates/ecolor", default-features = false } +epaint = { version = "0.33.2", path = "crates/epaint", default-features = false } +epaint_default_fonts = { version = "0.33.2", path = "crates/epaint_default_fonts" } +egui = { version = "0.33.2", path = "crates/egui", default-features = false } +egui-winit = { version = "0.33.2", path = "crates/egui-winit", default-features = false } +egui_extras = { version = "0.33.2", path = "crates/egui_extras", default-features = false } +egui-wgpu = { version = "0.33.2", path = "crates/egui-wgpu", default-features = false } +egui_demo_lib = { version = "0.33.2", path = "crates/egui_demo_lib", default-features = false } +egui_glow = { version = "0.33.2", path = "crates/egui_glow", default-features = false } +egui_kittest = { version = "0.33.2", path = "crates/egui_kittest", default-features = false } +eframe = { version = "0.33.2", path = "crates/eframe", default-features = false } accesskit = "0.21.1" accesskit_consumer = "0.30.1" @@ -131,6 +131,7 @@ syntect = { version = "5.3.0", default-features = false } tempfile = "3.23.0" thiserror = "2.0.17" tokio = "1.47.1" +toml = "0.8" type-map = "0.5.1" unicode_names2 = { version = "2.0.0", default-features = false } unicode-segmentation = "1.12.0" @@ -232,6 +233,7 @@ 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" @@ -276,6 +278,7 @@ non_zero_suggestions = "warn" nonstandard_macro_braces = "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" @@ -329,6 +332,7 @@ unnecessary_semicolon = "warn" unnecessary_struct_initialization = "warn" unnecessary_wraps = "warn" unnested_or_patterns = "warn" +unused_async = "warn" unused_peekable = "warn" unused_rounding = "warn" unused_self = "warn" diff --git a/README.md b/README.md index c1d25f913..f4a094465 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ If you have questions, use [GitHub Discussions](https://github.com/emilk/egui/di To test the demo app locally, run `cargo run --release -p egui_demo_app`. -The native backend is [`egui_glow`](https://github.com/emilk/egui/tree/main/crates/egui_glow) (using [`glow`](https://crates.io/crates/glow)) and should work out-of-the-box on Mac and Windows, but on Linux you need to first run: +The native backend is [`egui-wgpu`](https://github.com/emilk/egui/tree/main/crates/egui-wgpu) (using [`wgpu`](https://crates.io/crates/wgpu)) and should work out-of-the-box on Mac and Windows, but on Linux you need to first run: `sudo apt-get install -y libclang-dev libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev` diff --git a/crates/ecolor/CHANGELOG.md b/crates/ecolor/CHANGELOG.md index 6996d838f..4dee81296 100644 --- a/crates/ecolor/CHANGELOG.md +++ b/crates/ecolor/CHANGELOG.md @@ -6,6 +6,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.33.2 - 2025-11-13 +Nothing new + + ## 0.33.0 - 2025-10-09 * Align `Color32` to 4 bytes [#7318](https://github.com/emilk/egui/pull/7318) by [@anti-social](https://github.com/anti-social) * Make the `hex_color` macro `const` [#7444](https://github.com/emilk/egui/pull/7444) by [@YgorSouza](https://github.com/YgorSouza) diff --git a/crates/eframe/CHANGELOG.md b/crates/eframe/CHANGELOG.md index 142634d02..11f1c8fc9 100644 --- a/crates/eframe/CHANGELOG.md +++ b/crates/eframe/CHANGELOG.md @@ -7,6 +7,11 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.33.2 - 2025-11-13 +* Fix jittering during window resize on MacOS for WGPU/Metal [#7641](https://github.com/emilk/egui/pull/7641) by [@aspcartman](https://github.com/aspcartman) +* Make sure `native_pixels_per_point` is set during app creation [#7683](https://github.com/emilk/egui/pull/7683) by [@emilk](https://github.com/emilk) + + ## 0.33.0 - 2025-10-09 ### ⭐ Added * Add an option to limit the repaint rate in the web runner [#7482](https://github.com/emilk/egui/pull/7482) by [@s-nie](https://github.com/s-nie) diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index ce2103cc8..6924633f1 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -28,19 +28,15 @@ workspace = true default = [ "accesskit", "default_fonts", - "glow", "wayland", # Required for Linux support (including CI!) "web_screen_reader", + "wgpu", "winit/default", "x11", - "egui-wgpu?/fragile-send-sync-non-atomic-wasm", - # Let's enable some backends so that users can use `eframe` out-of-the-box - # without having to explicitly opt-in to backends - "egui-wgpu?/default", ] ## Enable platform accessibility API implementations through [AccessKit](https://accesskit.dev/). -accesskit = ["egui/accesskit", "egui-winit/accesskit"] +accesskit = ["egui-winit/accesskit"] # Allow crates to choose an android-activity backend via Winit # - It's important that most applications should not have to depend on android-activity directly, and can @@ -56,7 +52,11 @@ android-native-activity = ["egui-winit/android-native-activity"] ## If you plan on specifying your own fonts you may disable this feature. default_fonts = ["egui/default_fonts"] -## Use [`glow`](https://github.com/grovesNL/glow) for painting, via [`egui_glow`](https://github.com/emilk/egui/tree/main/crates/egui_glow). +## Enable [`glow`](https://github.com/grovesNL/glow) for painting, via [`egui_glow`](https://github.com/emilk/egui/tree/main/crates/egui_glow). +## +## There is generally no need to enable both the `wgpu` and `glow` features, +## but if you do you can pick the renderer to use with [`NativeOptions::renderer`] +## and `WebOptions::renderer`. glow = ["dep:egui_glow", "dep:glow", "dep:glutin-winit", "dep:glutin"] ## Enable saving app state to disk. @@ -78,20 +78,28 @@ wayland = [ ## For other platforms, use the `accesskit` feature instead. web_screen_reader = ["web-sys/SpeechSynthesis", "web-sys/SpeechSynthesisUtterance"] -## Use [`wgpu`](https://docs.rs/wgpu) for painting (via [`egui-wgpu`](https://github.com/emilk/egui/tree/main/crates/egui-wgpu)). +## Enable [`wgpu`](https://docs.rs/wgpu) for painting (via [`egui-wgpu`](https://github.com/emilk/egui/tree/main/crates/egui-wgpu)). ## -## This overrides the `glow` feature. +## There is generally no need to enable both the `wgpu` and `glow` features, +## but if you do you can pick the renderer to use with [`NativeOptions::renderer`] +## and `WebOptions::renderer`. ## -## By default, only WebGPU is enabled on web. -## If you want to enable WebGL, you need to turn on the `webgl` feature of crate `wgpu`: -## -## ```toml -## wgpu = { version = "*", features = ["webgpu", "webgl"] } -## ``` +## Switching from `wgpu (the default)` to `glow` can significantly reduce your binary size +## (including the .wasm of a web app). +## See for more details. ## ## By default, eframe will prefer WebGPU over WebGL, but ## you can configure this at run-time with [`NativeOptions::wgpu_options`]. -wgpu = ["dep:wgpu", "dep:egui-wgpu", "dep:pollster"] +wgpu = ["wgpu_no_default_features", "egui-wgpu/default"] + +## This is exactly like the `wgpu` feature, but does NOT enable the default features of `wgpu` and `egui-wgpu`. +## +## This means that no `wgpu` backends are enabled. You will need to enable them yourself, e.g. like this: +## +## ```toml +## wgpu = { version = "*", features = ["dx12", "metal", "webgl"] } +## ``` +wgpu_no_default_features = ["dep:wgpu", "dep:egui-wgpu", "dep:pollster"] ## Enables compiling for x11. x11 = [ @@ -137,6 +145,7 @@ winit = { workspace = true, default-features = false, features = ["rwh_06"] } # optional native: egui-wgpu = { workspace = true, optional = true, features = [ "winit", + "capture", ] } # if wgpu is used, use it with winit pollster = { workspace = true, optional = true } # needed for wgpu @@ -242,7 +251,7 @@ web-sys = { workspace = true, features = [ ] } # optional web: -egui-wgpu = { workspace = true, optional = true } # if wgpu is used, use it without (!) winit +egui-wgpu = { workspace = true, optional = true, features = ["capture"] } # if wgpu is used, use it without (!) winit wgpu = { workspace = true, optional = true } # Native dev dependencies for testing diff --git a/crates/eframe/README.md b/crates/eframe/README.md index 9dbf42caf..63d5872c6 100644 --- a/crates/eframe/README.md +++ b/crates/eframe/README.md @@ -16,7 +16,7 @@ For how to use `egui`, see [the egui docs](https://docs.rs/egui). --- -`eframe` uses [`egui_glow`](https://github.com/emilk/egui/tree/main/crates/egui_glow) for rendering, and on native it uses [`egui-winit`](https://github.com/emilk/egui/tree/main/crates/egui-winit). +`eframe` defaults to using [wgpu](https://crates.io/crates/wgpu) for rendering (with an option to change to [glow](https://crates.io/crates/glow)), and on native it uses [`egui-winit`](https://github.com/emilk/egui/tree/main/crates/egui-winit). To use on Linux, first run: @@ -26,7 +26,7 @@ sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev lib You need to either use `edition = "2024"`, or set `resolver = "2"` in the `[workspace]` section of your to-level `Cargo.toml`. See [this link](https://doc.rust-lang.org/edition-guide/rust-2021/default-cargo-resolver.html) for more info. -You can opt-in to the using [`egui-wgpu`](https://github.com/emilk/egui/tree/main/crates/egui-wgpu) for rendering by enabling the `wgpu` feature and setting `NativeOptions::renderer` to `Renderer::Wgpu`. +You can opt-in to the using [`egui_glow`](https://github.com/emilk/egui/tree/main/crates/egui_glow) for rendering by enabling the `glow` feature and setting `NativeOptions::renderer` to `Renderer::Glow`. ## Alternatives `eframe` is not the only way to write an app using `egui`! You can also try [`egui-miniquad`](https://github.com/not-fl3/egui-miniquad), [`bevy_egui`](https://github.com/mvlabat/bevy_egui), [`egui_sdl2_gl`](https://github.com/ArjunNair/egui_sdl2_gl), and others. @@ -35,7 +35,7 @@ You can also use `egui_glow` and [`winit`](https://github.com/rust-windowing/win ## Limitations when running egui on the web -`eframe` uses WebGL (via [`glow`](https://crates.io/crates/glow)) and Wasm, and almost nothing else from the web tech stack. This has some benefits, but also produces some challenges and serious downsides. +`eframe` and egui compiles to Wasm using either WebGPU (when available) or WebGL2 for rendering, and almost nothing else from the web tech stack. This has some benefits, but also produces some challenges and serious downsides. * Rendering: Getting pixel-perfect rendering right on the web is very difficult. * Search: you cannot search an egui web page like you would a normal web page. diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index 384b8e918..a7bcfd6ef 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -10,7 +10,7 @@ use std::any::Any; #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub use crate::native::winit_integration::UserEvent; #[cfg(not(target_arch = "wasm32"))] @@ -22,7 +22,7 @@ use raw_window_handle::{ use static_assertions::assert_not_impl_any; #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub use winit::{event_loop::EventLoopBuilder, window::WindowAttributes}; /// Hook into the building of an event loop before it is run @@ -30,7 +30,7 @@ pub use winit::{event_loop::EventLoopBuilder, window::WindowAttributes}; /// You can configure any platform specific details required on top of the default configuration /// done by `EFrame`. #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub type EventLoopBuilderHook = Box)>; /// Hook into the building of a the native window. @@ -38,7 +38,7 @@ pub type EventLoopBuilderHook = Box) /// You can configure any platform specific details required on top of the default configuration /// done by `eframe`. #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub type WindowBuilderHook = Box egui::ViewportBuilder>; type DynError = Box; @@ -79,7 +79,7 @@ pub struct CreationContext<'s> { /// Only available when compiling with the `wgpu` feature and using [`Renderer::Wgpu`]. /// /// Can be used to manage GPU resources for custom rendering with WGPU using [`egui::PaintCallback`]s. - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] pub wgpu_render_state: Option, /// Raw platform window handle @@ -121,7 +121,7 @@ impl CreationContext<'_> { gl: None, #[cfg(feature = "glow")] get_proc_address: None, - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state: None, #[cfg(not(target_arch = "wasm32"))] raw_window_handle: Err(HandleError::NotSupported), @@ -137,7 +137,7 @@ impl CreationContext<'_> { pub trait App { /// Called each time the UI needs repainting, which may be many times per second. /// - /// Put your widgets into a [`egui::SidePanel`], [`egui::TopBottomPanel`], [`egui::CentralPanel`], [`egui::Window`] or [`egui::Area`]. + /// Put your widgets into a [`egui::Panel`], [`egui::CentralPanel`], [`egui::Window`] or [`egui::Area`]. /// /// The [`egui::Context`] can be cloned and saved if you like. /// @@ -317,7 +317,7 @@ pub struct NativeOptions { pub hardware_acceleration: HardwareAcceleration, /// What rendering backend to use. - #[cfg(any(feature = "glow", feature = "wgpu"))] + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub renderer: Renderer, /// This controls what happens when you close the main eframe window. @@ -340,7 +340,7 @@ pub struct NativeOptions { /// event loop before it is run. /// /// Note: A [`NativeOptions`] clone will not include any `event_loop_builder` hook. - #[cfg(any(feature = "glow", feature = "wgpu"))] + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub event_loop_builder: Option, /// Hook into the building of a window. @@ -349,7 +349,7 @@ pub struct NativeOptions { /// window appearance. /// /// Note: A [`NativeOptions`] clone will not include any `window_builder` hook. - #[cfg(any(feature = "glow", feature = "wgpu"))] + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub window_builder: Option, #[cfg(feature = "glow")] @@ -367,7 +367,7 @@ pub struct NativeOptions { pub centered: bool, /// Configures wgpu instance/device/adapter/surface creation and renderloop. - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] pub wgpu_options: egui_wgpu::WgpuConfiguration, /// Controls whether or not the native window position and size will be @@ -404,13 +404,13 @@ impl Clone for NativeOptions { Self { viewport: self.viewport.clone(), - #[cfg(any(feature = "glow", feature = "wgpu"))] + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] event_loop_builder: None, // Skip any builder callbacks if cloning - #[cfg(any(feature = "glow", feature = "wgpu"))] + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] window_builder: None, // Skip any builder callbacks if cloning - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] wgpu_options: self.wgpu_options.clone(), persistence_path: self.persistence_path.clone(), @@ -435,15 +435,15 @@ impl Default for NativeOptions { stencil_buffer: 0, hardware_acceleration: HardwareAcceleration::Preferred, - #[cfg(any(feature = "glow", feature = "wgpu"))] + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] renderer: Renderer::default(), run_and_return: true, - #[cfg(any(feature = "glow", feature = "wgpu"))] + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] event_loop_builder: None, - #[cfg(any(feature = "glow", feature = "wgpu"))] + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] window_builder: None, #[cfg(feature = "glow")] @@ -451,7 +451,7 @@ impl Default for NativeOptions { centered: false, - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] wgpu_options: egui_wgpu::WgpuConfiguration::default(), persist_window: true, @@ -471,6 +471,10 @@ impl Default for NativeOptions { /// Options when using `eframe` in a web page. #[cfg(target_arch = "wasm32")] pub struct WebOptions { + /// What rendering backend to use. + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] + pub renderer: Renderer, + /// Sets the number of bits in the depth buffer. /// /// `egui` doesn't need the depth buffer, so the default value is 0. @@ -484,7 +488,7 @@ pub struct WebOptions { pub webgl_context_option: WebGlContextOption, /// Configures wgpu instance/device/adapter/surface creation and renderloop. - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] pub wgpu_options: egui_wgpu::WgpuConfiguration, /// Controls whether to apply dithering to minimize banding artifacts. @@ -519,12 +523,15 @@ pub struct WebOptions { impl Default for WebOptions { fn default() -> Self { Self { + #[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] + renderer: Renderer::default(), + depth_buffer: 0, #[cfg(feature = "glow")] webgl_context_option: WebGlContextOption::BestFirst, - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] wgpu_options: egui_wgpu::WgpuConfiguration::default(), dithering: true, @@ -561,7 +568,7 @@ pub enum WebGlContextOption { /// What rendering backend to use. /// /// You need to enable the "glow" and "wgpu" features to have a choice. -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] @@ -571,49 +578,49 @@ pub enum Renderer { Glow, /// Use [`egui_wgpu`] renderer for [`wgpu`](https://github.com/gfx-rs/wgpu). - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] Wgpu, } -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] impl Default for Renderer { fn default() -> Self { #[cfg(not(feature = "glow"))] - #[cfg(not(feature = "wgpu"))] + #[cfg(not(feature = "wgpu_no_default_features"))] compile_error!( "eframe: you must enable at least one of the rendering backend features: 'glow' or 'wgpu'" ); #[cfg(feature = "glow")] - #[cfg(not(feature = "wgpu"))] + #[cfg(not(feature = "wgpu_no_default_features"))] return Self::Glow; #[cfg(not(feature = "glow"))] - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] return Self::Wgpu; - // By default, only the `glow` feature is enabled, so if the user added `wgpu` to the feature list - // they probably wanted to use wgpu: + // It's weird that the user has enabled both glow and wgpu, + // but let's pick the better of the two (wgpu): #[cfg(feature = "glow")] - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] return Self::Wgpu; } } -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[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 { match self { #[cfg(feature = "glow")] Self::Glow => "glow".fmt(f), - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] Self::Wgpu => "wgpu".fmt(f), } } } -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] impl std::str::FromStr for Renderer { type Err = String; @@ -622,7 +629,7 @@ impl std::str::FromStr for Renderer { #[cfg(feature = "glow")] "glow" => Ok(Self::Glow), - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] "wgpu" => Ok(Self::Wgpu), _ => Err(format!( @@ -655,7 +662,7 @@ pub struct Frame { Option egui::TextureId>>, /// Can be used to manage GPU resources for custom rendering with WGPU using [`egui::PaintCallback`]s. - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] #[doc(hidden)] pub wgpu_render_state: Option, @@ -705,7 +712,7 @@ impl Frame { #[cfg(not(target_arch = "wasm32"))] raw_window_handle: Err(HandleError::NotSupported), storage: None, - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state: None, } } @@ -764,7 +771,7 @@ impl Frame { /// Only available when compiling with the `wgpu` feature and using [`Renderer::Wgpu`]. /// /// Can be used to manage GPU resources for custom rendering with WGPU using [`egui::PaintCallback`]s. - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] pub fn wgpu_render_state(&self) -> Option<&egui_wgpu::RenderState> { self.wgpu_render_state.as_ref() } diff --git a/crates/eframe/src/lib.rs b/crates/eframe/src/lib.rs index f0f392256..d803a5249 100644 --- a/crates/eframe/src/lib.rs +++ b/crates/eframe/src/lib.rs @@ -159,7 +159,7 @@ pub use {egui, egui::emath, egui::epaint}; #[cfg(feature = "glow")] pub use {egui_glow, glow}; -#[cfg(feature = "wgpu")] +#[cfg(feature = "wgpu_no_default_features")] pub use {egui_wgpu, wgpu}; mod epi; @@ -188,19 +188,19 @@ pub use web::{WebLogger, WebRunner}; // When compiling natively #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] mod native; #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub use native::run::EframeWinitApplication; #[cfg(not(any(target_arch = "wasm32", target_os = "ios")))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub use native::run::EframePumpStatus; #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] #[cfg(feature = "persistence")] pub use native::file_storage::storage_dir; @@ -252,7 +252,7 @@ pub mod icon_data; /// # Errors /// This function can fail if we fail to set up a graphics context. #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] #[allow(clippy::needless_pass_by_value, clippy::allow_attributes)] pub fn run_native( app_name: &str, @@ -268,7 +268,7 @@ pub fn run_native( native::run::run_glow(app_name, native_options, app_creator) } - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] Renderer::Wgpu => { log::debug!("Using the wgpu renderer"); native::run::run_wgpu(app_name, native_options, app_creator) @@ -322,7 +322,7 @@ pub fn run_native( /// /// See the `external_eventloop` example for a more complete example. #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub fn create_native<'a>( app_name: &str, mut native_options: NativeOptions, @@ -343,7 +343,7 @@ pub fn create_native<'a>( )) } - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] Renderer::Wgpu => { log::debug!("Using the wgpu renderer"); EframeWinitApplication::new(native::run::create_wgpu( @@ -357,7 +357,7 @@ pub fn create_native<'a>( } #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] fn init_native(app_name: &str, native_options: &mut NativeOptions) -> Renderer { #[cfg(not(feature = "__screenshot"))] assert!( @@ -371,7 +371,7 @@ fn init_native(app_name: &str, native_options: &mut NativeOptions) -> Renderer { let renderer = native_options.renderer; - #[cfg(all(feature = "glow", feature = "wgpu"))] + #[cfg(all(feature = "glow", feature = "wgpu_no_default_features"))] { match native_options.renderer { Renderer::Glow => "glow", @@ -420,7 +420,7 @@ fn init_native(app_name: &str, native_options: &mut NativeOptions) -> Renderer { /// # Errors /// This function can fail if we fail to set up a graphics context. #[cfg(not(target_arch = "wasm32"))] -#[cfg(any(feature = "glow", feature = "wgpu"))] +#[cfg(any(feature = "glow", feature = "wgpu_no_default_features"))] pub fn run_simple_native( app_name: &str, native_options: NativeOptions, @@ -472,7 +472,7 @@ pub enum Error { OpenGL(egui_glow::PainterError), /// An error from [`wgpu`]. - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] Wgpu(egui_wgpu::WgpuError), } @@ -510,7 +510,7 @@ impl From for Error { } } -#[cfg(feature = "wgpu")] +#[cfg(feature = "wgpu_no_default_features")] impl From for Error { #[inline] fn from(err: egui_wgpu::WgpuError) -> Self { @@ -551,7 +551,7 @@ impl std::fmt::Display for Error { write!(f, "egui_glow: {err}") } - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] Self::Wgpu(err) => { write!(f, "WGPU error: {err}") } diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 1b4c4b664..447b3ea9d 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -179,7 +179,9 @@ impl EpiIntegration { #[cfg(feature = "glow")] glow_register_native_texture: Option< Box egui::TextureId>, >, - #[cfg(feature = "wgpu")] wgpu_render_state: Option, + #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state: Option< + egui_wgpu::RenderState, + >, ) -> Self { let frame = epi::Frame { info: epi::IntegrationInfo { cpu_usage: None }, @@ -188,7 +190,7 @@ impl EpiIntegration { gl, #[cfg(feature = "glow")] glow_register_native_texture, - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state, raw_display_handle: window.display_handle().map(|h| h.as_raw()), raw_window_handle: window.window_handle().map(|h| h.as_raw()), diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index 4a3bee46a..e448c6c19 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -239,7 +239,7 @@ impl<'app> GlowWinitApp<'app> { let painter = painter.clone(); move |native| painter.borrow_mut().register_native_texture(native) })), - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] None, ); @@ -301,7 +301,7 @@ impl<'app> GlowWinitApp<'app> { storage: integration.frame.storage(), gl: Some(gl), get_proc_address: Some(&get_proc_address), - #[cfg(feature = "wgpu")] + #[cfg(feature = "wgpu_no_default_features")] wgpu_render_state: None, raw_display_handle: window.display_handle().map(|h| h.as_raw()), raw_window_handle: window.window_handle().map(|h| h.as_raw()), @@ -719,11 +719,11 @@ impl GlowWinitRunning<'_> { // vsync - don't count as frame-time: frame_timer.pause(); profiling::scope!("swap_buffers"); - let context = current_gl_context - .as_ref() - .ok_or(egui_glow::PainterError::from( + let context = current_gl_context.as_ref().ok_or_else(|| { + egui_glow::PainterError::from( "failed to get current context to swap buffers".to_owned(), - ))?; + ) + })?; gl_surface.swap_buffers(context)?; frame_timer.resume(); @@ -1041,11 +1041,23 @@ impl GlutinWindowContext { let mut viewport_from_window = HashMap::default(); let mut window_from_viewport = OrderedViewportIdMap::default(); - let mut info = ViewportInfo::default(); + let mut viewport_info = ViewportInfo::default(); if let Some(window) = &window { viewport_from_window.insert(window.id(), ViewportId::ROOT); window_from_viewport.insert(ViewportId::ROOT, window.id()); - egui_winit::update_viewport_info(&mut info, egui_ctx, window, true); + egui_winit::update_viewport_info(&mut viewport_info, egui_ctx, window, true); + + // Tell egui right away about native_pixels_per_point etc, + // so that the app knows about it during app creation: + let pixels_per_point = egui_winit::pixels_per_point(egui_ctx, window); + + egui_ctx.input_mut(|i| { + i.raw + .viewports + .insert(ViewportId::ROOT, viewport_info.clone()); + + i.pixels_per_point = pixels_per_point; + }); } let mut viewports = OrderedViewportIdMap::default(); @@ -1056,7 +1068,7 @@ impl GlutinWindowContext { class: ViewportClass::Root, builder: viewport_builder, deferred_commands: vec![], - info, + info: viewport_info, actions_requested: Default::default(), viewport_ui_cb: None, gl_surface: None, diff --git a/crates/eframe/src/native/mod.rs b/crates/eframe/src/native/mod.rs index cc0bfd7fc..eb9413717 100644 --- a/crates/eframe/src/native/mod.rs +++ b/crates/eframe/src/native/mod.rs @@ -12,5 +12,5 @@ pub(crate) mod winit_integration; #[cfg(feature = "glow")] mod glow_integration; -#[cfg(feature = "wgpu")] +#[cfg(feature = "wgpu_no_default_features")] mod wgpu_integration; diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index 6fdae7d3c..7a3a8b4c4 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -381,7 +381,7 @@ pub fn create_glow<'a>( // ---------------------------------------------------------------------------- -#[cfg(feature = "wgpu")] +#[cfg(feature = "wgpu_no_default_features")] pub fn run_wgpu( app_name: &str, mut native_options: epi::NativeOptions, @@ -404,7 +404,7 @@ pub fn run_wgpu( run_and_exit(event_loop, wgpu_eframe) } -#[cfg(feature = "wgpu")] +#[cfg(feature = "wgpu_no_default_features")] pub fn create_wgpu<'a>( app_name: &str, native_options: epi::NativeOptions, diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index 046340bdb..c6c715c8c 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -71,6 +71,7 @@ pub struct SharedState { painter: egui_wgpu::winit::Painter, viewport_from_window: HashMap, focused_viewport: Option, + resized_viewport: Option, } pub type Viewports = egui::OrderedViewportIdMap; @@ -198,6 +199,22 @@ impl<'app> WgpuWinitApp<'app> { }, )); + let mut viewport_info = ViewportInfo::default(); + egui_winit::update_viewport_info(&mut viewport_info, &egui_ctx, &window, true); + + { + // Tell egui right away about native_pixels_per_point etc, + // so that the app knows about it during app creation: + let pixels_per_point = egui_winit::pixels_per_point(&egui_ctx, &window); + + egui_ctx.input_mut(|i| { + i.raw + .viewports + .insert(ViewportId::ROOT, viewport_info.clone()); + i.pixels_per_point = pixels_per_point; + }); + } + let window = Arc::new(window); { @@ -277,9 +294,6 @@ impl<'app> WgpuWinitApp<'app> { let mut viewport_from_window = HashMap::default(); viewport_from_window.insert(window.id(), ViewportId::ROOT); - let mut info = ViewportInfo::default(); - egui_winit::update_viewport_info(&mut info, &egui_ctx, &window, true); - let mut viewports = Viewports::default(); viewports.insert( ViewportId::ROOT, @@ -288,7 +302,7 @@ impl<'app> WgpuWinitApp<'app> { class: ViewportClass::Root, builder, deferred_commands: vec![], - info, + info: viewport_info, actions_requested: Default::default(), viewport_ui_cb: None, window: Some(window), @@ -302,6 +316,7 @@ impl<'app> WgpuWinitApp<'app> { viewports, painter, focused_viewport: Some(ViewportId::ROOT), + resized_viewport: None, })); { @@ -763,20 +778,34 @@ impl WgpuWinitRunning<'_> { let viewport_id = shared.viewport_from_window.get(&window_id).copied(); // On Windows, if a window is resized by the user, it should repaint synchronously, inside the - // event handler. - // - // If this is not done, the compositor will assume that the window does not want to redraw, - // and continue ahead. + // event handler. If this is not done, the compositor will assume that the window does not want + // to redraw and continue ahead. // // In eframe's case, that causes the window to rapidly flicker, as it struggles to deliver - // new frames to the compositor in time. - // - // The flickering is technically glutin or glow's fault, but we should be responding properly + // new frames to the compositor in time. The flickering is technically glutin or glow's fault, but we should be responding properly // to resizes anyway, as doing so avoids dropping frames. // // See: https://github.com/emilk/egui/issues/903 let mut repaint_asap = false; + // On MacOS the asap repaint is not enough. The drawn frames must be synchronized with + // the CoreAnimation transactions driving the window resize process. + // + // Thus, Painter, responsible for wgpu surfaces and their resize, has to be notified of the + // resize lifecycle, yet winit does not provide any events for that. To work around, + // the last resized viewport is tracked until any next non-resize event is received. + // + // Accidental state change during the resize process due to an unexpected event fire + // is ok, state will switch back upon next resize event. + // + // See: https://github.com/emilk/egui/issues/903 + if let Some(id) = viewport_id + && shared.resized_viewport == viewport_id + { + shared.painter.on_window_resize_state_change(id, false); + shared.resized_viewport = None; + } + match event { winit::event::WindowEvent::Focused(focused) => { let focused = if cfg!(target_os = "macos") @@ -799,14 +828,18 @@ impl WgpuWinitRunning<'_> { // Resize with 0 width and height is used by winit to signal a minimize event on Windows. // See: https://github.com/rust-windowing/winit/issues/208 // This solves an issue where the app would panic when minimizing on Windows. - if let Some(viewport_id) = viewport_id + if let Some(id) = viewport_id && let (Some(width), Some(height)) = ( NonZeroU32::new(physical_size.width), NonZeroU32::new(physical_size.height), ) { + if shared.resized_viewport != viewport_id { + shared.resized_viewport = viewport_id; + shared.painter.on_window_resize_state_change(id, true); + } + shared.painter.on_window_resized(id, width, height); repaint_asap = true; - shared.painter.on_window_resized(viewport_id, width, height); } } diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index bd245a1fe..8a10a90ef 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -1,15 +1,15 @@ use egui::{TexturesDelta, UserData, ViewportCommand}; -use crate::{App, epi}; +use crate::{App, epi, web::web_painter::WebPainter}; -use super::{NeedRepaint, now_sec, text_agent::TextAgent, web_painter::WebPainter as _}; +use super::{NeedRepaint, now_sec, text_agent::TextAgent}; pub struct AppRunner { #[allow(dead_code, clippy::allow_attributes)] pub(crate) web_options: crate::WebOptions, pub(crate) frame: epi::Frame, egui_ctx: egui::Context, - painter: super::ActiveWebPainter, + painter: Box, pub(crate) input: super::WebInput, app: Box, pub(crate) needs_repaint: std::sync::Arc, @@ -34,6 +34,10 @@ impl Drop for AppRunner { impl AppRunner { /// # Errors /// Failure to initialize WebGL renderer, or failure to create app. + #[cfg_attr( + not(feature = "wgpu_no_default_features"), + expect(clippy::unused_async) + )] pub async fn new( canvas: web_sys::HtmlCanvasElement, web_options: crate::WebOptions, @@ -41,7 +45,41 @@ impl AppRunner { text_agent: TextAgent, ) -> Result { let egui_ctx = egui::Context::default(); - let painter = super::ActiveWebPainter::new(egui_ctx.clone(), canvas, &web_options).await?; + + #[allow(clippy::allow_attributes, unused_assignments)] + #[cfg(feature = "glow")] + let mut gl = None; + + #[allow(clippy::allow_attributes, unused_assignments)] + #[cfg(feature = "wgpu_no_default_features")] + let mut wgpu_render_state = None; + + let painter = match web_options.renderer { + #[cfg(feature = "glow")] + epi::Renderer::Glow => { + log::debug!("Using the glow renderer"); + let painter = super::web_painter_glow::WebPainterGlow::new( + egui_ctx.clone(), + canvas, + &web_options, + )?; + gl = Some(painter.gl().clone()); + Box::new(painter) as Box + } + + #[cfg(feature = "wgpu_no_default_features")] + epi::Renderer::Wgpu => { + log::debug!("Using the wgpu renderer"); + let painter = super::web_painter_wgpu::WebPainterWgpu::new( + egui_ctx.clone(), + canvas, + &web_options, + ) + .await?; + wgpu_render_state = painter.render_state(); + Box::new(painter) as Box + } + }; let info = epi::IntegrationInfo { web_info: epi::WebInfo { @@ -65,21 +103,27 @@ impl AppRunner { o.zoom_factor = 1.0; }); + // Tell egui right away about native_pixels_per_point + // so that the app knows about it during app creation: + egui_ctx.input_mut(|i| { + let viewport_info = i.raw.viewports.entry(egui::ViewportId::ROOT).or_default(); + viewport_info.native_pixels_per_point = Some(super::native_pixels_per_point()); + i.pixels_per_point = super::native_pixels_per_point(); + }); + let cc = epi::CreationContext { egui_ctx: egui_ctx.clone(), integration_info: info.clone(), storage: Some(&storage), #[cfg(feature = "glow")] - gl: Some(painter.gl().clone()), + gl: gl.clone(), #[cfg(feature = "glow")] get_proc_address: None, - #[cfg(all(feature = "wgpu", not(feature = "glow")))] - wgpu_render_state: painter.render_state(), - #[cfg(all(feature = "wgpu", feature = "glow"))] - wgpu_render_state: None, + #[cfg(feature = "wgpu_no_default_features")] + wgpu_render_state: wgpu_render_state.clone(), }; let app = app_creator(&cc).map_err(|err| err.to_string())?; @@ -88,12 +132,10 @@ impl AppRunner { storage: Some(Box::new(storage)), #[cfg(feature = "glow")] - gl: Some(painter.gl().clone()), + gl, - #[cfg(all(feature = "wgpu", not(feature = "glow")))] - wgpu_render_state: painter.render_state(), - #[cfg(all(feature = "wgpu", feature = "glow"))] - wgpu_render_state: None, + #[cfg(feature = "wgpu_no_default_features")] + wgpu_render_state, }; let needs_repaint: std::sync::Arc = @@ -316,8 +358,7 @@ impl AppRunner { events: _, // already handled mutable_text_under_cursor: _, // TODO(#4569): https://github.com/emilk/egui/issues/4569 ime, - #[cfg(feature = "accesskit")] - accesskit_update: _, // not currently implemented + accesskit_update: _, // not currently implemented num_completed_passes: _, // handled by `Context::run` request_discard_reasons: _, // handled by `Context::run` } = platform_output; diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index eb0d848e0..c61a80012 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -831,6 +831,7 @@ fn install_wheel(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsV unit, delta, modifiers, + phase: egui::TouchPhase::Move, } }; let should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index fdc9d2123..ac4c637db 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -23,20 +23,16 @@ pub use panic_handler::{PanicHandler, PanicSummary}; pub use web_logger::WebLogger; pub use web_runner::WebRunner; -#[cfg(not(any(feature = "glow", feature = "wgpu")))] +#[cfg(not(any(feature = "glow", feature = "wgpu_no_default_features")))] compile_error!("You must enable either the 'glow' or 'wgpu' feature"); mod web_painter; #[cfg(feature = "glow")] mod web_painter_glow; -#[cfg(feature = "glow")] -pub(crate) type ActiveWebPainter = web_painter_glow::WebPainterGlow; -#[cfg(feature = "wgpu")] +#[cfg(feature = "wgpu_no_default_features")] mod web_painter_wgpu; -#[cfg(all(feature = "wgpu", not(feature = "glow")))] -pub(crate) type ActiveWebPainter = web_painter_wgpu::WebPainterWgpu; pub use backend::*; diff --git a/crates/eframe/src/web/web_painter_glow.rs b/crates/eframe/src/web/web_painter_glow.rs index ca6e11bf5..e2fc4a6f2 100644 --- a/crates/eframe/src/web/web_painter_glow.rs +++ b/crates/eframe/src/web/web_painter_glow.rs @@ -20,7 +20,7 @@ impl WebPainterGlow { self.painter.gl() } - pub async fn new( + pub fn new( _ctx: egui::Context, canvas: HtmlCanvasElement, options: &WebOptions, diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs index 9faba9dd7..efecd12ee 100644 --- a/crates/eframe/src/web/web_painter_wgpu.rs +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -1,13 +1,15 @@ use std::sync::Arc; -use super::web_painter::WebPainter; -use crate::WebOptions; use egui::{Event, UserData, ViewportId}; -use egui_wgpu::capture::{CaptureReceiver, CaptureSender, CaptureState, capture_channel}; -use egui_wgpu::{RenderState, SurfaceErrorAction}; +use egui_wgpu::{ + RenderState, SurfaceErrorAction, + capture::{CaptureReceiver, CaptureSender, CaptureState, capture_channel}, +}; use wasm_bindgen::JsValue; use web_sys::HtmlCanvasElement; +use super::web_painter::WebPainter; + pub(crate) struct WebPainterWgpu { canvas: HtmlCanvasElement, surface: wgpu::Surface<'static>, @@ -23,7 +25,6 @@ pub(crate) struct WebPainterWgpu { } impl WebPainterWgpu { - #[expect(unused)] // only used if `wgpu` is the only active feature. pub fn render_state(&self) -> Option { self.render_state.clone() } @@ -55,11 +56,10 @@ impl WebPainterWgpu { }) } - #[expect(unused)] // only used if `wgpu` is the only active feature. pub async fn new( ctx: egui::Context, canvas: web_sys::HtmlCanvasElement, - options: &WebOptions, + options: &crate::WebOptions, ) -> Result { log::debug!("Creating wgpu painter"); diff --git a/crates/egui-wgpu/CHANGELOG.md b/crates/egui-wgpu/CHANGELOG.md index 5f4fd78c1..be00dd049 100644 --- a/crates/egui-wgpu/CHANGELOG.md +++ b/crates/egui-wgpu/CHANGELOG.md @@ -6,6 +6,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.33.2 - 2025-11-13 +* Fix jittering during window resize on MacOS for WGPU/Metal [#7641](https://github.com/emilk/egui/pull/7641) by [@aspcartman](https://github.com/aspcartman) + + ## 0.33.0 - 2025-10-09 ### πŸ”§ Changed * Update wgpu to 26 and wasm-bindgen to 0.2.100 [#7540](https://github.com/emilk/egui/pull/7540) by [@Kumpelinus](https://github.com/Kumpelinus) diff --git a/crates/egui-wgpu/Cargo.toml b/crates/egui-wgpu/Cargo.toml index 88321e652..c514e0a49 100644 --- a/crates/egui-wgpu/Cargo.toml +++ b/crates/egui-wgpu/Cargo.toml @@ -25,10 +25,13 @@ all-features = true rustdoc-args = ["--generate-link-to-definition"] [features] -default = ["fragile-send-sync-non-atomic-wasm", "wgpu/default"] +default = ["fragile-send-sync-non-atomic-wasm", "macos-window-resize-jitter-fix", "wgpu/default"] + +## Enables the `capture` module for capturing screenshots. +capture = ["dep:egui"] ## Enable [`winit`](https://docs.rs/winit) integration. On Linux, requires either `wayland` or `x11` -winit = ["dep:winit", "winit/rwh_06"] +winit = ["dep:winit", "winit/rwh_06", "dep:egui", "capture"] ## Enables Wayland support for winit. wayland = ["winit?/wayland"] @@ -43,8 +46,10 @@ x11 = ["winit?/x11"] ## Thus that usage is guarded against with compiler errors in wgpu. fragile-send-sync-non-atomic-wasm = ["wgpu/fragile-send-sync-non-atomic-wasm"] +## Enables `present_with_transaction` surface flag temporary during window resize on MacOS. +macos-window-resize-jitter-fix = ["wgpu/metal"] + [dependencies] -egui = { workspace = true, default-features = false } epaint = { workspace = true, default-features = false, features = ["bytemuck"] } ahash.workspace = true @@ -59,4 +64,5 @@ wgpu = { workspace = true, features = ["wgsl"] } # Optional dependencies: +egui = { workspace = true, optional = true, default-features = false } winit = { workspace = true, optional = true, default-features = false } diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs index 59e27e7ac..d340526af 100644 --- a/crates/egui-wgpu/src/lib.rs +++ b/crates/egui-wgpu/src/lib.rs @@ -29,6 +29,7 @@ pub use renderer::*; pub use setup::{NativeAdapterSelectorMethod, WgpuSetup, WgpuSetupCreateNew, WgpuSetupExisting}; /// Helpers for capturing screenshots of the UI. +#[cfg(feature = "capture")] pub mod capture; /// Module for painting [`egui`](https://github.com/emilk/egui) with [`wgpu`] on [`winit`]. diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index bbd19edb1..3a286cc9e 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -14,6 +14,7 @@ struct SurfaceState { alpha_mode: wgpu::CompositeAlphaMode, width: u32, height: u32, + resizing: bool, } /// Everything you need to paint egui with [`wgpu`] on [`winit`]. @@ -230,6 +231,7 @@ impl Painter { width: size.width, height: size.height, alpha_mode, + resizing: false, }, ); let Some(width) = NonZeroU32::new(size.width) else { @@ -326,6 +328,59 @@ impl Painter { } } + /// Handles changes of the resizing state. + /// + /// Should be called prior to the first [`Painter::on_window_resized`] call and after the last in + /// the chain. Used to apply platform-specific logic, e.g. OSX Metal window resize jitter fix. + pub fn on_window_resize_state_change(&mut self, viewport_id: ViewportId, resizing: bool) { + profiling::function_scope!(); + + let Some(state) = self.surfaces.get_mut(&viewport_id) else { + return; + }; + if state.resizing == resizing { + if resizing { + log::debug!( + "Painter::on_window_resize_state_change() redundant call while resizing" + ); + } else { + log::debug!( + "Painter::on_window_resize_state_change() redundant call after resizing" + ); + } + return; + } + + // Resizing is a bit tricky on macOS. + // It requires enabling ["present_with_transaction"](https://developer.apple.com/documentation/quartzcore/cametallayer/presentswithtransaction) + // flag to avoid jittering during the resize. Even though resize jittering on macOS + // is common across rendering backends, the solution for wgpu/metal is known. + // + // See https://github.com/emilk/egui/issues/903 + #[cfg(all(target_os = "macos", feature = "macos-window-resize-jitter-fix"))] + { + // SAFETY: The cast is checked with if condition. If the used backend is not metal + // it gracefully fails. The pointer casts are valid as it's 1-to-1 type mapping. + // This is how wgpu currently exposes this backend-specific flag. + unsafe { + if let Some(hal_surface) = state.surface.as_hal::() { + let raw = + std::ptr::from_ref::(&*hal_surface).cast_mut(); + + (*raw).present_with_transaction = resizing; + + Self::configure_surface( + state, + self.render_state.as_ref().unwrap(), + &self.configuration, + ); + } + } + } + + state.resizing = resizing; + } + pub fn on_window_resized( &mut self, viewport_id: ViewportId, diff --git a/crates/egui-winit/CHANGELOG.md b/crates/egui-winit/CHANGELOG.md index e6b094502..8e555a7bc 100644 --- a/crates/egui-winit/CHANGELOG.md +++ b/crates/egui-winit/CHANGELOG.md @@ -5,6 +5,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.33.2 - 2025-11-13 +* Don't enable `arboard` on iOS [#7663](https://github.com/emilk/egui/pull/7663) by [@irh](https://github.com/irh) + + ## 0.33.0 - 2025-10-09 ### ⭐ Added * Add rotation gesture support for trackpad sources [#7453](https://github.com/emilk/egui/pull/7453) by [@thatcomputerguy0101](https://github.com/thatcomputerguy0101) diff --git a/crates/egui-winit/Cargo.toml b/crates/egui-winit/Cargo.toml index a4c84b05f..d1b2ab220 100644 --- a/crates/egui-winit/Cargo.toml +++ b/crates/egui-winit/Cargo.toml @@ -24,7 +24,7 @@ rustdoc-args = ["--generate-link-to-definition"] default = ["clipboard", "links", "wayland", "winit/default", "x11"] ## Enable platform accessibility API implementations through [AccessKit](https://accesskit.dev/). -accesskit = ["dep:accesskit_winit", "egui/accesskit"] +accesskit = ["dep:accesskit_winit"] # Allow crates to choose an android-activity backend via Winit # - It's important that most applications should not have to depend on android-activity directly, and can diff --git a/crates/egui-winit/src/clipboard.rs b/crates/egui-winit/src/clipboard.rs index cec4b43c2..fc7334388 100644 --- a/crates/egui-winit/src/clipboard.rs +++ b/crates/egui-winit/src/clipboard.rs @@ -5,7 +5,10 @@ use raw_window_handle::RawDisplayHandle; /// If the "clipboard" feature is off, or we cannot connect to the OS clipboard, /// then a fallback clipboard that just works within the same app is used instead. pub struct Clipboard { - #[cfg(all(feature = "arboard", not(target_os = "android")))] + #[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", + ))] arboard: Option, #[cfg(all( @@ -28,7 +31,10 @@ impl Clipboard { /// Construct a new instance pub fn new(_raw_display_handle: Option) -> Self { Self { - #[cfg(all(feature = "arboard", not(target_os = "android")))] + #[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", + ))] arboard: init_arboard(), #[cfg(all( @@ -68,7 +74,10 @@ impl Clipboard { }; } - #[cfg(all(feature = "arboard", not(target_os = "android")))] + #[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", + ))] if let Some(clipboard) = &mut self.arboard { return match clipboard.get_text() { Ok(text) => Some(text), @@ -98,7 +107,10 @@ impl Clipboard { return; } - #[cfg(all(feature = "arboard", not(target_os = "android")))] + #[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", + ))] if let Some(clipboard) = &mut self.arboard { if let Err(err) = clipboard.set_text(text) { log::error!("arboard copy/cut error: {err}"); @@ -110,7 +122,10 @@ impl Clipboard { } pub fn set_image(&mut self, image: &egui::ColorImage) { - #[cfg(all(feature = "arboard", not(target_os = "android")))] + #[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", + ))] if let Some(clipboard) = &mut self.arboard { if let Err(err) = clipboard.set_image(arboard::ImageData { width: image.width(), @@ -130,7 +145,10 @@ impl Clipboard { } } -#[cfg(all(feature = "arboard", not(target_os = "android")))] +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", +))] fn init_arboard() -> Option { profiling::function_scope!(); diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index ed293e39a..7660e3cef 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -312,8 +312,8 @@ impl State { consumed: self.egui_ctx.wants_pointer_input(), } } - WindowEvent::MouseWheel { delta, .. } => { - self.on_mouse_wheel(window, *delta); + WindowEvent::MouseWheel { delta, phase, .. } => { + self.on_mouse_wheel(window, *delta, *phase); EventResponse { repaint: true, consumed: self.egui_ctx.wants_pointer_input(), @@ -545,12 +545,13 @@ impl State { } } - WindowEvent::PanGesture { delta, .. } => { + WindowEvent::PanGesture { delta, phase, .. } => { let pixels_per_point = pixels_per_point(&self.egui_ctx, window); self.egui_input.events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: Vec2::new(delta.x, delta.y) / pixels_per_point, + phase: to_egui_touch_phase(*phase), modifiers: self.egui_input.modifiers, }); EventResponse { @@ -680,12 +681,7 @@ impl State { self.egui_input.events.push(egui::Event::Touch { device_id: egui::TouchDeviceId(egui::epaint::util::hash(touch.device_id)), id: egui::TouchId::from(touch.id), - phase: match touch.phase { - winit::event::TouchPhase::Started => egui::TouchPhase::Start, - winit::event::TouchPhase::Moved => egui::TouchPhase::Move, - winit::event::TouchPhase::Ended => egui::TouchPhase::End, - winit::event::TouchPhase::Cancelled => egui::TouchPhase::Cancel, - }, + phase: to_egui_touch_phase(touch.phase), pos: egui::pos2( touch.location.x as f32 / pixels_per_point, touch.location.y as f32 / pixels_per_point, @@ -738,7 +734,12 @@ impl State { } } - fn on_mouse_wheel(&mut self, window: &Window, delta: winit::event::MouseScrollDelta) { + fn on_mouse_wheel( + &mut self, + window: &Window, + delta: winit::event::MouseScrollDelta, + phase: winit::event::TouchPhase, + ) { let pixels_per_point = pixels_per_point(&self.egui_ctx, window); { @@ -754,10 +755,12 @@ impl State { egui::vec2(x as f32, y as f32) / pixels_per_point, ), }; + let phase = to_egui_touch_phase(phase); let modifiers = self.egui_input.modifiers; self.egui_input.events.push(egui::Event::MouseWheel { unit, delta, + phase, modifiers, }); } @@ -885,7 +888,6 @@ impl State { events: _, // handled elsewhere mutable_text_under_cursor: _, // only used in eframe web ime, - #[cfg(feature = "accesskit")] accesskit_update, num_completed_passes: _, // `egui::Context::run` handles this request_discard_reasons: _, // `egui::Context::run` handles this @@ -944,6 +946,9 @@ impl State { profiling::scope!("accesskit"); accesskit.update_if_active(|| update); } + + #[cfg(not(feature = "accesskit"))] + let _ = accesskit_update; } fn set_cursor_icon(&mut self, window: &Window, cursor_icon: egui::CursorIcon) { @@ -970,6 +975,15 @@ impl State { } } +fn to_egui_touch_phase(phase: winit::event::TouchPhase) -> egui::TouchPhase { + match phase { + winit::event::TouchPhase::Started => egui::TouchPhase::Start, + winit::event::TouchPhase::Moved => egui::TouchPhase::Move, + winit::event::TouchPhase::Ended => egui::TouchPhase::End, + winit::event::TouchPhase::Cancelled => egui::TouchPhase::Cancel, + } +} + fn to_egui_theme(theme: winit::window::Theme) -> Theme { match theme { winit::window::Theme::Dark => Theme::Dark, diff --git a/crates/egui/Cargo.toml b/crates/egui/Cargo.toml index c82ae5618..764d2401e 100644 --- a/crates/egui/Cargo.toml +++ b/crates/egui/Cargo.toml @@ -26,10 +26,6 @@ rustdoc-args = ["--generate-link-to-definition"] [features] default = ["default_fonts"] -## Exposes detailed accessibility implementation required by platform -## accessibility APIs. Also requires support in the egui integration. -accesskit = ["dep:accesskit"] - ## [`bytemuck`](https://docs.rs/bytemuck) enables you to cast [`epaint::Vertex`], [`emath::Vec2`] etc to `&[u8]`. bytemuck = ["epaint/bytemuck"] @@ -61,7 +57,7 @@ persistence = ["serde", "epaint/serde", "ron"] rayon = ["epaint/rayon"] ## Allow serialization using [`serde`](https://docs.rs/serde). -serde = ["dep:serde", "epaint/serde", "accesskit?/serde"] +serde = ["dep:serde", "epaint/serde", "accesskit/serde"] ## Change Vertex layout to be compatible with unity unity = ["epaint/unity"] @@ -75,6 +71,7 @@ _override_unity = ["epaint/_override_unity"] emath = { workspace = true, default-features = false } epaint = { workspace = true, default-features = false } +accesskit.workspace = true ahash.workspace = true bitflags.workspace = true log.workspace = true @@ -84,7 +81,6 @@ smallvec.workspace = true unicode-segmentation.workspace = true #! ### Optional dependencies -accesskit = { workspace = true, optional = true } backtrace = { workspace = true, optional = true } diff --git a/crates/egui/src/atomics/atom_kind.rs b/crates/egui/src/atomics/atom_kind.rs index 3c54c496b..10ca3353b 100644 --- a/crates/egui/src/atomics/atom_kind.rs +++ b/crates/egui/src/atomics/atom_kind.rs @@ -82,7 +82,7 @@ impl<'a> AtomKind<'a> { ) -> (Vec2, SizedAtomKind<'a>) { match self { AtomKind::Text(text) => { - let wrap_mode = wrap_mode.unwrap_or(ui.wrap_mode()); + let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode()); let galley = text.into_galley(ui, Some(wrap_mode), available_size.x, fallback_font); (galley.intrinsic_size(), SizedAtomKind::Text(galley)) } diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index 1df890250..8132a7dc9 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -168,7 +168,7 @@ impl<'a> AtomLayout<'a> { let fallback_font = fallback_font.unwrap_or_default(); - let wrap_mode = wrap_mode.unwrap_or(ui.wrap_mode()); + let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode()); // If the TextWrapMode is not Extend, ensure there is some item marked as `shrink`. // If none is found, mark the first text item as `shrink`. @@ -188,7 +188,7 @@ impl<'a> AtomLayout<'a> { let fallback_text_color = fallback_text_color.unwrap_or_else(|| ui.style().visuals.text_color()); - let gap = gap.unwrap_or(ui.spacing().icon_spacing); + let gap = gap.unwrap_or_else(|| ui.spacing().icon_spacing); // The size available for the content let available_inner_size = ui.available_size() - frame.total_margin().sum(); diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 1c3e058b3..3ebac1d65 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -525,11 +525,21 @@ impl Area { true, ); + // Used to prevent drift + let pivot_at_start_of_drag_id = id.with("pivot_at_drag_start"); + if movable && move_response.dragged() && let Some(pivot_pos) = &mut state.pivot_pos { - *pivot_pos += move_response.drag_delta(); + let pivot_at_start_of_drag = ctx.data_mut(|data| { + *data.get_temp_mut_or::(pivot_at_start_of_drag_id, *pivot_pos) + }); + + *pivot_pos = + pivot_at_start_of_drag + move_response.total_drag_delta().unwrap_or_default(); + } else { + ctx.data_mut(|data| data.remove::(pivot_at_start_of_drag_id)); } if (move_response.dragged() || move_response.clicked()) @@ -543,13 +553,14 @@ impl Area { move_response }; - if constrain { - state.set_left_top_pos( - Context::constrain_window_rect_to_area(state.rect(), constrain_rect).min, - ); - } - - state.set_left_top_pos(state.left_top_pos()); + state.set_left_top_pos(round_area_position( + ctx, + if constrain { + Context::constrain_window_rect_to_area(state.rect(), constrain_rect).min + } else { + state.left_top_pos() + }, + )); // Update response with possibly moved/constrained rect: move_response.rect = state.rect(); @@ -570,6 +581,16 @@ impl Area { } } +fn round_area_position(ctx: &Context, pos: Pos2) -> Pos2 { + // We round a lot of rendering to pixels, so we round the whole + // area positions to pixels too, so avoid widgets appearing to float + // around independently of each other when the area is dragged. + // But just in case pixels_per_point is irrational, + // we then also round to ui coordinates: + + pos.round_to_pixels(ctx.pixels_per_point()).round_ui() +} + impl Prepared { pub(crate) fn state(&self) -> &AreaState { &self.state diff --git a/crates/egui/src/containers/combo_box.rs b/crates/egui/src/containers/combo_box.rs index 8195024fb..c4097f803 100644 --- a/crates/egui/src/containers/combo_box.rs +++ b/crates/egui/src/containers/combo_box.rs @@ -239,7 +239,7 @@ impl ComboBox { let mut ir = combo_box_dyn( ui, button_id, - selected_text, + selected_text.clone(), menu_contents, icon, wrap_mode, @@ -247,14 +247,16 @@ impl ComboBox { popup_style, (width, height), ); + ir.response.widget_info(|| { + let mut info = WidgetInfo::new(WidgetType::ComboBox); + info.enabled = ui.is_enabled(); + info.current_text_value = Some(selected_text.text().to_owned()); + info + }); if let Some(label) = label { - ir.response.widget_info(|| { - WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), label.text()) - }); - ir.response |= ui.label(label); - } else { - ir.response - .widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), "")); + let label_response = ui.label(label); + ir.response = ir.response.labelled_by(label_response.id); + ir.response |= label_response; } ir }) diff --git a/crates/egui/src/containers/menu.rs b/crates/egui/src/containers/menu.rs index f2aaee046..756d68dd3 100644 --- a/crates/egui/src/containers/menu.rs +++ b/crates/egui/src/containers/menu.rs @@ -161,14 +161,13 @@ impl MenuState { if state.last_visible_pass + 1 < pass_nr { state.open_item = None; } - if let Some(item) = state.open_item { - if data + if let Some(item) = state.open_item + && data .get_temp(item.with(Self::ID)) .is_none_or(|item: Self| item.last_visible_pass + 1 < pass_nr) - { - // If the open item wasn't shown for at least a frame, reset the open item - state.open_item = None; - } + { + // If the open item wasn't shown for at least a frame, reset the open item + state.open_item = None; } let r = f(&mut state); data.insert_temp(state_id, state); diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index 4312385da..a8f3306e9 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -1,4 +1,4 @@ -//! Containers are pieces of the UI which wraps other pieces of UI. Examples: [`Window`], [`ScrollArea`], [`Resize`], [`SidePanel`], etc. +//! Containers are pieces of the UI which wraps other pieces of UI. Examples: [`Window`], [`ScrollArea`], [`Resize`], [`Panel`], etc. //! //! For instance, a [`Frame`] adds a frame and background to some contained UI. @@ -27,7 +27,7 @@ pub use { frame::Frame, modal::{Modal, ModalResponse}, old_popup::*, - panel::{CentralPanel, SidePanel, TopBottomPanel}, + panel::*, popup::*, resize::Resize, scene::{DragPanButtons, Scene}, diff --git a/crates/egui/src/containers/modal.rs b/crates/egui/src/containers/modal.rs index 6b846ab5e..23190ddf6 100644 --- a/crates/egui/src/containers/modal.rs +++ b/crates/egui/src/containers/modal.rs @@ -11,7 +11,7 @@ use crate::{ /// /// You can show multiple modals on top of each other. The topmost modal will always be /// the most recently shown one. -/// If multiple modals are newly shown in the same frame, the order of the modals not undefined +/// If multiple modals are newly shown in the same frame, the order of the modals is undefined /// (either first or second could be top). pub struct Modal { pub area: Area, diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index 6e582b428..3c52f63a3 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -15,7 +15,7 @@ //! //! Add your [`crate::Window`]:s after any top-level panels. -use emath::GuiRounding as _; +use emath::{GuiRounding as _, Pos2}; use crate::{ Align, Context, CursorIcon, Frame, Id, InnerResponse, LayerId, Layout, NumExt as _, Rangef, @@ -51,21 +51,25 @@ impl PanelState { // ---------------------------------------------------------------------------- -/// [`Left`](Side::Left) or [`Right`](Side::Right) +/// [`Left`](VerticalSide::Left) or [`Right`](VerticalSide::Right) #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Side { +enum VerticalSide { Left, Right, } -impl Side { - fn opposite(self) -> Self { +impl VerticalSide { + pub fn opposite(self) -> Self { match self { Self::Left => Self::Right, Self::Right => Self::Left, } } + /// `self` is the _fixed_ side. + /// + /// * Left panels are resized on their right side + /// * Right panels are resized on their left side fn set_rect_width(self, rect: &mut Rect, width: f32) { match self { Self::Left => rect.max.x = rect.min.x + width, @@ -73,22 +77,211 @@ impl Side { } } - fn side_x(self, rect: Rect) -> f32 { - match self { - Self::Left => rect.left(), - Self::Right => rect.right(), - } - } - fn sign(self) -> f32 { match self { Self::Left => -1.0, Self::Right => 1.0, } } + + fn side_x(self, rect: Rect) -> f32 { + match self { + Self::Left => rect.left(), + Self::Right => rect.right(), + } + } } -/// A panel that covers the entire left or right side of a [`Ui`] or screen. +/// [`Top`](HorizontalSide::Top) or [`Bottom`](HorizontalSide::Bottom) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HorizontalSide { + Top, + Bottom, +} + +impl HorizontalSide { + pub fn opposite(self) -> Self { + match self { + Self::Top => Self::Bottom, + Self::Bottom => Self::Top, + } + } + + /// `self` is the _fixed_ side. + /// + /// * Top panels are resized on their bottom side + /// * Bottom panels are resized upwards + fn set_rect_height(self, rect: &mut Rect, height: f32) { + match self { + Self::Top => rect.max.y = rect.min.y + height, + Self::Bottom => rect.min.y = rect.max.y - height, + } + } + + fn sign(self) -> f32 { + match self { + Self::Top => -1.0, + Self::Bottom => 1.0, + } + } + + fn side_y(self, rect: Rect) -> f32 { + match self { + Self::Top => rect.top(), + Self::Bottom => rect.bottom(), + } + } +} + +// Intentionally private because I'm not sure of the naming. +// TODO(emilk): decide on good names and make public. +// "VerticalSide" and "HorizontalSide" feels inverted to me. +/// [`Horizontal`](PanelSide::Horizontal) or [`Vertical`](PanelSide::Vertical) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PanelSide { + /// Left or right. + Vertical(VerticalSide), + + /// Top or bottom + Horizontal(HorizontalSide), +} + +impl From for PanelSide { + fn from(side: HorizontalSide) -> Self { + Self::Horizontal(side) + } +} + +impl From for PanelSide { + fn from(side: VerticalSide) -> Self { + Self::Vertical(side) + } +} + +impl PanelSide { + pub const LEFT: Self = Self::Vertical(VerticalSide::Left); + pub const RIGHT: Self = Self::Vertical(VerticalSide::Right); + pub const TOP: Self = Self::Horizontal(HorizontalSide::Top); + pub const BOTTOM: Self = Self::Horizontal(HorizontalSide::Bottom); + + /// Resize by keeping the [`self`] side fixed, and moving the opposite side. + fn set_rect_size(self, rect: &mut Rect, size: f32) { + match self { + Self::Vertical(side) => side.set_rect_width(rect, size), + Self::Horizontal(side) => side.set_rect_height(rect, size), + } + } + + fn ui_kind(self) -> UiKind { + match self { + Self::Vertical(side) => match side { + VerticalSide::Left => UiKind::LeftPanel, + VerticalSide::Right => UiKind::RightPanel, + }, + Self::Horizontal(side) => match side { + HorizontalSide::Top => UiKind::TopPanel, + HorizontalSide::Bottom => UiKind::BottomPanel, + }, + } + } +} + +// ---------------------------------------------------------------------------- + +/// Intermediate structure to abstract some portion of [`Panel::show_inside`](Panel::show_inside). +struct PanelSizer<'a> { + panel: &'a Panel, + frame: Frame, + available_rect: Rect, + size: f32, + panel_rect: Rect, +} + +impl<'a> PanelSizer<'a> { + fn new(panel: &'a Panel, ui: &Ui) -> Self { + let frame = panel + .frame + .unwrap_or_else(|| Frame::side_top_panel(ui.style())); + let available_rect = ui.available_rect_before_wrap(); + let size = PanelSizer::get_size_from_state_or_default(panel, ui, frame); + let panel_rect = PanelSizer::panel_rect(panel, available_rect, size); + + Self { + panel, + frame, + available_rect, + size, + panel_rect, + } + } + + fn get_size_from_state_or_default(panel: &Panel, ui: &Ui, frame: Frame) -> f32 { + if let Some(state) = PanelState::load(ui.ctx(), panel.id) { + match panel.side { + PanelSide::Vertical(_) => state.rect.width(), + PanelSide::Horizontal(_) => state.rect.height(), + } + } else { + match panel.side { + PanelSide::Vertical(_) => panel.default_size.unwrap_or_else(|| { + ui.style().spacing.interact_size.x + frame.inner_margin.sum().x + }), + PanelSide::Horizontal(_) => panel.default_size.unwrap_or_else(|| { + ui.style().spacing.interact_size.y + frame.inner_margin.sum().y + }), + } + } + } + + fn panel_rect(panel: &Panel, available_rect: Rect, mut size: f32) -> Rect { + let side = panel.side; + let size_range = panel.size_range; + + let mut panel_rect = available_rect; + + match side { + PanelSide::Vertical(_) => { + size = clamp_to_range(size, size_range).at_most(available_rect.width()); + } + PanelSide::Horizontal(_) => { + size = clamp_to_range(size, size_range).at_most(available_rect.height()); + } + } + side.set_rect_size(&mut panel_rect, size); + panel_rect + } + + fn prepare_resizing_response(&mut self, is_resizing: bool, pointer: Option) { + let side = self.panel.side; + let size_range = self.panel.size_range; + + if is_resizing && pointer.is_some() { + let pointer = pointer.unwrap(); + + match side { + PanelSide::Vertical(side) => { + self.size = (pointer.x - side.side_x(self.panel_rect)).abs(); + self.size = + clamp_to_range(self.size, size_range).at_most(self.available_rect.width()); + } + PanelSide::Horizontal(side) => { + self.size = (pointer.y - side.side_y(self.panel_rect)).abs(); + self.size = + clamp_to_range(self.size, size_range).at_most(self.available_rect.height()); + } + } + + side.set_rect_size(&mut self.panel_rect, self.size); + } + } +} + +// ---------------------------------------------------------------------------- + +/// A panel that covers an entire side +/// ([`left`](Panel::left), [`right`](Panel::right), +/// [`top`](Panel::top) or [`bottom`](Panel::bottom)) +/// of a [`Ui`] or screen. /// /// The order in which you add panels matter! /// The first panel you add will always be the outermost, and the last you add will always be the innermost. @@ -99,45 +292,83 @@ impl Side { /// /// ``` /// # egui::__run_test_ctx(|ctx| { -/// egui::SidePanel::left("my_left_panel").show(ctx, |ui| { +/// egui::Panel::left("my_left_panel").show(ctx, |ui| { /// ui.label("Hello World!"); /// }); /// # }); /// ``` -/// -/// See also [`TopBottomPanel`]. #[must_use = "You should call .show()"] -pub struct SidePanel { - side: Side, +pub struct Panel { + side: PanelSide, id: Id, frame: Option, resizable: bool, show_separator_line: bool, - default_width: f32, - width_range: Rangef, + + /// The size is defined as being either the width for a Vertical Panel + /// or the height for a Horizontal Panel. + default_size: Option, + + /// The size is defined as being either the width for a Vertical Panel + /// or the height for a Horizontal Panel. + size_range: Rangef, } -impl SidePanel { +impl Panel { + /// Create a left panel. + /// /// The id should be globally unique, e.g. `Id::new("my_left_panel")`. pub fn left(id: impl Into) -> Self { - Self::new(Side::Left, id) + Self::new(PanelSide::LEFT, id) } + /// Create a right panel. + /// /// The id should be globally unique, e.g. `Id::new("my_right_panel")`. pub fn right(id: impl Into) -> Self { - Self::new(Side::Right, id) + Self::new(PanelSide::RIGHT, id) } + /// Create a top panel. + /// + /// The id should be globally unique, e.g. `Id::new("my_top_panel")`. + /// + /// By default this is NOT resizable. + pub fn top(id: impl Into) -> Self { + Self::new(PanelSide::TOP, id).resizable(false) + } + + /// Create a bottom panel. + /// + /// The id should be globally unique, e.g. `Id::new("my_bottom_panel")`. + /// + /// By default this is NOT resizable. + pub fn bottom(id: impl Into) -> Self { + Self::new(PanelSide::BOTTOM, id).resizable(false) + } + + /// Create a panel. + /// /// The id should be globally unique, e.g. `Id::new("my_panel")`. - pub fn new(side: Side, id: impl Into) -> Self { + fn new(side: PanelSide, id: impl Into) -> Self { + let default_size: Option = match side { + PanelSide::Vertical(_) => Some(200.0), + PanelSide::Horizontal(_) => None, + }; + + let size_range: Rangef = match side { + PanelSide::Vertical(_) => Rangef::new(96.0, f32::INFINITY), + PanelSide::Horizontal(_) => Rangef::new(20.0, f32::INFINITY), + }; + Self { side, id: id.into(), frame: None, resizable: true, show_separator_line: true, - default_width: 200.0, - width_range: Rangef::new(96.0, f32::INFINITY), + default_size, + size_range, } } @@ -170,45 +401,47 @@ impl SidePanel { self } - /// The initial wrapping width of the [`SidePanel`], including margins. + /// The initial wrapping width of the [`Panel`], including margins. #[inline] - pub fn default_width(mut self, default_width: f32) -> Self { - self.default_width = default_width; - self.width_range = Rangef::new( - self.width_range.min.at_most(default_width), - self.width_range.max.at_least(default_width), + pub fn default_size(mut self, default_size: f32) -> Self { + self.default_size = Some(default_size); + self.size_range = Rangef::new( + self.size_range.min.at_most(default_size), + self.size_range.max.at_least(default_size), ); self } - /// Minimum width of the panel, including margins. + /// Minimum size of the panel, including margins. #[inline] - pub fn min_width(mut self, min_width: f32) -> Self { - self.width_range = Rangef::new(min_width, self.width_range.max.at_least(min_width)); + pub fn min_size(mut self, min_size: f32) -> Self { + self.size_range = Rangef::new(min_size, self.size_range.max.at_least(min_size)); self } - /// Maximum width of the panel, including margins. + /// Maximum size of the panel, including margins. #[inline] - pub fn max_width(mut self, max_width: f32) -> Self { - self.width_range = Rangef::new(self.width_range.min.at_most(max_width), max_width); + pub fn max_size(mut self, max_size: f32) -> Self { + self.size_range = Rangef::new(self.size_range.min.at_most(max_size), max_size); self } - /// The allowable width range for the panel, including margins. + /// The allowable size range for the panel, including margins. #[inline] - pub fn width_range(mut self, width_range: impl Into) -> Self { - let width_range = width_range.into(); - self.default_width = clamp_to_range(self.default_width, width_range); - self.width_range = width_range; + pub fn size_range(mut self, size_range: impl Into) -> Self { + let size_range = size_range.into(); + self.default_size = self + .default_size + .map(|default_size| clamp_to_range(default_size, size_range)); + self.size_range = size_range; self } - /// Enforce this exact width, including margins. + /// Enforce this exact size, including margins. #[inline] - pub fn exact_width(mut self, width: f32) -> Self { - self.default_width = width; - self.width_range = Rangef::point(width); + pub fn exact_size(mut self, size: f32) -> Self { + self.default_size = Some(size); + self.size_range = Rangef::point(size); self } @@ -220,7 +453,61 @@ impl SidePanel { } } -impl SidePanel { +// Deprecated +impl Panel { + #[deprecated = "Renamed default_size"] + pub fn default_width(self, default_size: f32) -> Self { + self.default_size(default_size) + } + + #[deprecated = "Renamed min_size"] + pub fn min_width(self, min_size: f32) -> Self { + self.min_size(min_size) + } + + #[deprecated = "Renamed max_size"] + pub fn max_width(self, max_size: f32) -> Self { + self.max_size(max_size) + } + + #[deprecated = "Renamed size_range"] + pub fn width_range(self, size_range: impl Into) -> Self { + self.size_range(size_range) + } + + #[deprecated = "Renamed exact_size"] + pub fn exact_width(self, size: f32) -> Self { + self.exact_size(size) + } + + #[deprecated = "Renamed default_size"] + pub fn default_height(self, default_size: f32) -> Self { + self.default_size(default_size) + } + + #[deprecated = "Renamed min_size"] + pub fn min_height(self, min_size: f32) -> Self { + self.min_size(min_size) + } + + #[deprecated = "Renamed max_size"] + pub fn max_height(self, max_size: f32) -> Self { + self.max_size(max_size) + } + + #[deprecated = "Renamed size_range"] + pub fn height_range(self, size_range: impl Into) -> Self { + self.size_range(size_range) + } + + #[deprecated = "Renamed exact_size"] + pub fn exact_height(self, size: f32) -> Self { + self.exact_size(size) + } +} + +// Public showing methods +impl Panel { /// Show the panel inside a [`Ui`]. pub fn show_inside( self, @@ -230,70 +517,170 @@ impl SidePanel { self.show_inside_dyn(ui, Box::new(add_contents)) } + /// Show the panel at the top level. + pub fn show( + self, + ctx: &Context, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse { + self.show_dyn(ctx, Box::new(add_contents)) + } + + /// Show the panel if `is_expanded` is `true`, + /// otherwise don't show it, but with a nice animation between collapsed and expanded. + pub fn show_animated( + self, + ctx: &Context, + is_expanded: bool, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + let how_expanded = animate_expansion(ctx, self.id.with("animation"), is_expanded); + + let animated_panel = self.get_animated_panel(ctx, is_expanded)?; + + if how_expanded < 1.0 { + // Show a fake panel in this in-between animation state: + animated_panel.show(ctx, |_ui| {}); + None + } else { + // Show the real panel: + Some(animated_panel.show(ctx, add_contents)) + } + } + + /// Show the panel if `is_expanded` is `true`, + /// otherwise don't show it, but with a nice animation between collapsed and expanded. + pub fn show_animated_inside( + self, + ui: &mut Ui, + is_expanded: bool, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + let how_expanded = animate_expansion(ui.ctx(), self.id.with("animation"), is_expanded); + + // Get either the fake or the real panel to animate + let animated_panel = self.get_animated_panel(ui.ctx(), is_expanded)?; + + if how_expanded < 1.0 { + // Show a fake panel in this in-between animation state: + animated_panel.show_inside(ui, |_ui| {}); + None + } else { + // Show the real panel: + Some(animated_panel.show_inside(ui, add_contents)) + } + } + + /// Show either a collapsed or a expanded panel, with a nice animation between. + pub fn show_animated_between( + ctx: &Context, + is_expanded: bool, + collapsed_panel: Self, + expanded_panel: Self, + add_contents: impl FnOnce(&mut Ui, f32) -> R, + ) -> Option> { + let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); + + // Get either the fake or the real panel to animate + let animated_between_panel = + Self::get_animated_between_panel(ctx, is_expanded, collapsed_panel, expanded_panel); + + if 0.0 == how_expanded { + Some(animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded))) + } else if how_expanded < 1.0 { + // Show animation: + animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded)); + None + } else { + Some(animated_between_panel.show(ctx, |ui| add_contents(ui, how_expanded))) + } + } + + /// Show either a collapsed or a expanded panel, with a nice animation between. + pub fn show_animated_between_inside( + ui: &mut Ui, + is_expanded: bool, + collapsed_panel: Self, + expanded_panel: Self, + add_contents: impl FnOnce(&mut Ui, f32) -> R, + ) -> InnerResponse { + let how_expanded = + animate_expansion(ui.ctx(), expanded_panel.id.with("animation"), is_expanded); + + let animated_between_panel = Self::get_animated_between_panel( + ui.ctx(), + is_expanded, + collapsed_panel, + expanded_panel, + ); + + if 0.0 == how_expanded { + animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) + } else if how_expanded < 1.0 { + // Show animation: + animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) + } else { + animated_between_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) + } + } +} + +// Private methods to support the various show methods +impl Panel { /// Show the panel inside a [`Ui`]. fn show_inside_dyn<'c, R>( self, ui: &mut Ui, add_contents: Box R + 'c>, ) -> InnerResponse { - let Self { - side, - id, - frame, - resizable, - show_separator_line, - default_width, - width_range, - } = self; + let side = self.side; + let id = self.id; + let resizable = self.resizable; + let show_separator_line = self.show_separator_line; + let size_range = self.size_range; - let available_rect = ui.available_rect_before_wrap(); - let mut panel_rect = available_rect; - let mut width = default_width; - { - if let Some(state) = PanelState::load(ui.ctx(), id) { - width = state.rect.width(); - } - width = clamp_to_range(width, width_range).at_most(available_rect.width()); - side.set_rect_width(&mut panel_rect, width); - ui.ctx().check_for_id_clash(id, panel_rect, "SidePanel"); + // Define the sizing of the panel. + let mut panel_sizer = PanelSizer::new(&self, ui); + + // Check for duplicate id + ui.ctx() + .check_for_id_clash(id, panel_sizer.panel_rect, "Panel"); + + if self.resizable { + // Prepare the resizable panel to avoid frame latency in the resize + self.prepare_resizable_panel(&mut panel_sizer, ui); } - let resize_id = id.with("__resize"); - let mut resize_hover = false; - let mut is_resizing = false; - if resizable { - // First we read the resize interaction results, to avoid frame latency in the resize: - if let Some(resize_response) = ui.ctx().read_response(resize_id) { - resize_hover = resize_response.hovered(); - is_resizing = resize_response.dragged(); - - if is_resizing && let Some(pointer) = resize_response.interact_pointer_pos() { - width = (pointer.x - side.side_x(panel_rect)).abs(); - width = clamp_to_range(width, width_range).at_most(available_rect.width()); - side.set_rect_width(&mut panel_rect, width); - } - } - } - - panel_rect = panel_rect.round_ui(); + // NOTE(shark98): This must be **after** the resizable preparation, as the size + // may change and round_ui() uses the size. + panel_sizer.panel_rect = panel_sizer.panel_rect.round_ui(); let mut panel_ui = ui.new_child( UiBuilder::new() .id_salt(id) - .ui_stack_info(UiStackInfo::new(match side { - Side::Left => UiKind::LeftPanel, - Side::Right => UiKind::RightPanel, - })) - .max_rect(panel_rect) + .ui_stack_info(UiStackInfo::new(side.ui_kind())) + .max_rect(panel_sizer.panel_rect) .layout(Layout::top_down(Align::Min)), ); - panel_ui.expand_to_include_rect(panel_rect); - panel_ui.set_clip_rect(panel_rect); // If we overflow, don't do so visibly (#4475) + panel_ui.expand_to_include_rect(panel_sizer.panel_rect); + panel_ui.set_clip_rect(panel_sizer.panel_rect); // If we overflow, don't do so visibly (#4475) + + let inner_response = panel_sizer.frame.show(&mut panel_ui, |ui| { + match side { + PanelSide::Vertical(_) => { + ui.set_min_height(ui.max_rect().height()); // Make sure the frame fills the full height + ui.set_min_width( + (size_range.min - panel_sizer.frame.inner_margin.sum().x).at_least(0.0), + ); + } + PanelSide::Horizontal(_) => { + ui.set_min_width(ui.max_rect().width()); // Make the frame fill full width + ui.set_min_height( + (size_range.min - panel_sizer.frame.inner_margin.sum().y).at_least(0.0), + ); + } + } - let frame = frame.unwrap_or_else(|| Frame::side_top_panel(ui.style())); - let inner_response = frame.show(&mut panel_ui, |ui| { - ui.set_min_height(ui.max_rect().height()); // Make sure the frame fills the full height - ui.set_min_width((width_range.min - frame.inner_margin.sum().x).at_least(0.0)); add_contents(ui) }); @@ -302,44 +689,31 @@ impl SidePanel { { let mut cursor = ui.cursor(); match side { - Side::Left => { - cursor.min.x = rect.max.x; - } - Side::Right => { - cursor.max.x = rect.min.x; - } + PanelSide::Vertical(side) => match side { + VerticalSide::Left => cursor.min.x = rect.max.x, + VerticalSide::Right => cursor.max.x = rect.min.x, + }, + PanelSide::Horizontal(side) => match side { + HorizontalSide::Top => cursor.min.y = rect.max.y, + HorizontalSide::Bottom => cursor.max.y = rect.min.y, + }, } ui.set_cursor(cursor); } + ui.expand_to_include_rect(rect); + let mut resize_hover = false; + let mut is_resizing = false; if resizable { - // Now we do the actual resize interaction, on top of all the contents. - // Otherwise its input could be eaten by the contents, e.g. a + // Now we do the actual resize interaction, on top of all the contents, + // otherwise its input could be eaten by the contents, e.g. a // `ScrollArea` on either side of the panel boundary. - let resize_x = side.opposite().side_x(panel_rect); - let resize_rect = Rect::from_x_y_ranges(resize_x..=resize_x, panel_rect.y_range()) - .expand2(vec2(ui.style().interaction.resize_grab_radius_side, 0.0)); - let resize_response = ui.interact(resize_rect, resize_id, Sense::drag()); - resize_hover = resize_response.hovered(); - is_resizing = resize_response.dragged(); + (resize_hover, is_resizing) = self.resize_panel(&panel_sizer, ui); } if resize_hover || is_resizing { - let cursor_icon = if width <= width_range.min { - match self.side { - Side::Left => CursorIcon::ResizeEast, - Side::Right => CursorIcon::ResizeWest, - } - } else if width < width_range.max { - CursorIcon::ResizeHorizontal - } else { - match self.side { - Side::Left => CursorIcon::ResizeWest, - Side::Right => CursorIcon::ResizeEast, - } - }; - ui.ctx().set_cursor_icon(cursor_icon); + ui.ctx().set_cursor_icon(self.cursor_icon(&panel_sizer)); } PanelState { rect }.store(ui.ctx(), id); @@ -356,25 +730,23 @@ impl SidePanel { Stroke::NONE }; // TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done - let resize_x = side.opposite().side_x(rect); - - // Make sure the line is on the inside of the panel: - let resize_x = resize_x + 0.5 * side.sign() * stroke.width; - ui.painter().vline(resize_x, panel_rect.y_range(), stroke); + match side { + PanelSide::Vertical(side) => { + let x = side.opposite().side_x(rect) + 0.5 * side.sign() * stroke.width; + ui.painter() + .vline(x, panel_sizer.panel_rect.y_range(), stroke); + } + PanelSide::Horizontal(side) => { + let y = side.opposite().side_y(rect) + 0.5 * side.sign() * stroke.width; + ui.painter() + .hline(panel_sizer.panel_rect.x_range(), y, stroke); + } + } } inner_response } - /// Show the panel at the top level. - pub fn show( - self, - ctx: &Context, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> InnerResponse { - self.show_dyn(ctx, Box::new(add_contents)) - } - /// Show the panel at the top level. fn show_dyn<'c, R>( self, @@ -399,663 +771,177 @@ impl SidePanel { let rect = inner_response.response.rect; match side { - Side::Left => ctx.pass_state_mut(|state| { - state.allocate_left_panel(Rect::from_min_max(available_rect.min, rect.max)); - }), - Side::Right => ctx.pass_state_mut(|state| { - state.allocate_right_panel(Rect::from_min_max(rect.min, available_rect.max)); - }), + PanelSide::Vertical(side) => match side { + VerticalSide::Left => ctx.pass_state_mut(|state| { + state.allocate_left_panel(Rect::from_min_max(available_rect.min, rect.max)); + }), + VerticalSide::Right => ctx.pass_state_mut(|state| { + state.allocate_right_panel(Rect::from_min_max(rect.min, available_rect.max)); + }), + }, + PanelSide::Horizontal(side) => match side { + HorizontalSide::Top => { + ctx.pass_state_mut(|state| { + state.allocate_top_panel(Rect::from_min_max(available_rect.min, rect.max)); + }); + } + HorizontalSide::Bottom => { + ctx.pass_state_mut(|state| { + state.allocate_bottom_panel(Rect::from_min_max( + rect.min, + available_rect.max, + )); + }); + } + }, } inner_response } - /// Show the panel if `is_expanded` is `true`, - /// otherwise don't show it, but with a nice animation between collapsed and expanded. - pub fn show_animated( - self, - ctx: &Context, - is_expanded: bool, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> Option> { - let how_expanded = animate_expansion(ctx, self.id.with("animation"), is_expanded); + fn prepare_resizable_panel(&self, panel_sizer: &mut PanelSizer<'_>, ui: &Ui) { + let resize_id = self.id.with("__resize"); + let resize_response = ui.ctx().read_response(resize_id); - if 0.0 == how_expanded { - None - } else if how_expanded < 1.0 { - // Show a fake panel in this in-between animation state: - // TODO(emilk): move the panel out-of-screen instead of changing its width. - // Then we can actually paint it as it animates. - let expanded_width = PanelState::load(ctx, self.id) - .map_or(self.default_width, |state| state.rect.width()); - let fake_width = how_expanded * expanded_width; - Self { - id: self.id.with("animating_panel"), - ..self + if resize_response.is_some() { + let resize_response = resize_response.unwrap(); + + // NOTE(sharky98): The original code was initializing to + // false first, but it doesn't seem necessary. + let is_resizing = resize_response.dragged(); + let pointer = resize_response.interact_pointer_pos(); + panel_sizer.prepare_resizing_response(is_resizing, pointer); + } + } + + fn resize_panel(&self, panel_sizer: &PanelSizer<'_>, ui: &Ui) -> (bool, bool) { + let (resize_x, resize_y, amount): (Rangef, Rangef, Vec2) = match self.side { + PanelSide::Vertical(side) => { + let resize_x = side.opposite().side_x(panel_sizer.panel_rect); + let resize_y = panel_sizer.panel_rect.y_range(); + ( + Rangef::from(resize_x..=resize_x), + resize_y, + vec2(ui.style().interaction.resize_grab_radius_side, 0.0), + ) } - .resizable(false) - .exact_width(fake_width) - .show(ctx, |_ui| {}); - None - } else { - // Show the real panel: - Some(self.show(ctx, add_contents)) - } - } - - /// Show the panel if `is_expanded` is `true`, - /// otherwise don't show it, but with a nice animation between collapsed and expanded. - pub fn show_animated_inside( - self, - ui: &mut Ui, - is_expanded: bool, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> Option> { - let how_expanded = animate_expansion(ui.ctx(), self.id.with("animation"), is_expanded); - - if 0.0 == how_expanded { - None - } else if how_expanded < 1.0 { - // Show a fake panel in this in-between animation state: - // TODO(emilk): move the panel out-of-screen instead of changing its width. - // Then we can actually paint it as it animates. - let expanded_width = PanelState::load(ui.ctx(), self.id) - .map_or(self.default_width, |state| state.rect.width()); - let fake_width = how_expanded * expanded_width; - Self { - id: self.id.with("animating_panel"), - ..self + PanelSide::Horizontal(side) => { + let resize_x = panel_sizer.panel_rect.x_range(); + let resize_y = side.opposite().side_y(panel_sizer.panel_rect); + ( + resize_x, + Rangef::from(resize_y..=resize_y), + vec2(0.0, ui.style().interaction.resize_grab_radius_side), + ) } - .resizable(false) - .exact_width(fake_width) - .show_inside(ui, |_ui| {}); - None - } else { - // Show the real panel: - Some(self.show_inside(ui, add_contents)) - } - } - - /// Show either a collapsed or a expanded panel, with a nice animation between. - pub fn show_animated_between( - ctx: &Context, - is_expanded: bool, - collapsed_panel: Self, - expanded_panel: Self, - add_contents: impl FnOnce(&mut Ui, f32) -> R, - ) -> Option> { - let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); - - if 0.0 == how_expanded { - Some(collapsed_panel.show(ctx, |ui| add_contents(ui, how_expanded))) - } else if how_expanded < 1.0 { - // Show animation: - let collapsed_width = PanelState::load(ctx, collapsed_panel.id) - .map_or(collapsed_panel.default_width, |state| state.rect.width()); - let expanded_width = PanelState::load(ctx, expanded_panel.id) - .map_or(expanded_panel.default_width, |state| state.rect.width()); - let fake_width = lerp(collapsed_width..=expanded_width, how_expanded); - Self { - id: expanded_panel.id.with("animating_panel"), - ..expanded_panel - } - .resizable(false) - .exact_width(fake_width) - .show(ctx, |ui| add_contents(ui, how_expanded)); - None - } else { - Some(expanded_panel.show(ctx, |ui| add_contents(ui, how_expanded))) - } - } - - /// Show either a collapsed or a expanded panel, with a nice animation between. - pub fn show_animated_between_inside( - ui: &mut Ui, - is_expanded: bool, - collapsed_panel: Self, - expanded_panel: Self, - add_contents: impl FnOnce(&mut Ui, f32) -> R, - ) -> InnerResponse { - let how_expanded = - animate_expansion(ui.ctx(), expanded_panel.id.with("animation"), is_expanded); - - if 0.0 == how_expanded { - collapsed_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) - } else if how_expanded < 1.0 { - // Show animation: - let collapsed_width = PanelState::load(ui.ctx(), collapsed_panel.id) - .map_or(collapsed_panel.default_width, |state| state.rect.width()); - let expanded_width = PanelState::load(ui.ctx(), expanded_panel.id) - .map_or(expanded_panel.default_width, |state| state.rect.width()); - let fake_width = lerp(collapsed_width..=expanded_width, how_expanded); - Self { - id: expanded_panel.id.with("animating_panel"), - ..expanded_panel - } - .resizable(false) - .exact_width(fake_width) - .show_inside(ui, |ui| add_contents(ui, how_expanded)) - } else { - expanded_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) - } - } -} - -// ---------------------------------------------------------------------------- - -/// [`Top`](TopBottomSide::Top) or [`Bottom`](TopBottomSide::Bottom) -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TopBottomSide { - Top, - Bottom, -} - -impl TopBottomSide { - fn opposite(self) -> Self { - match self { - Self::Top => Self::Bottom, - Self::Bottom => Self::Top, - } - } - - fn set_rect_height(self, rect: &mut Rect, height: f32) { - match self { - Self::Top => rect.max.y = rect.min.y + height, - Self::Bottom => rect.min.y = rect.max.y - height, - } - } - - fn side_y(self, rect: Rect) -> f32 { - match self { - Self::Top => rect.top(), - Self::Bottom => rect.bottom(), - } - } - - fn sign(self) -> f32 { - match self { - Self::Top => -1.0, - Self::Bottom => 1.0, - } - } -} - -/// A panel that covers the entire top or bottom of a [`Ui`] or screen. -/// -/// The order in which you add panels matter! -/// The first panel you add will always be the outermost, and the last you add will always be the innermost. -/// -/// ⚠ Always add any [`CentralPanel`] last. -/// -/// See the [module level docs](crate::containers::panel) for more details. -/// -/// ``` -/// # egui::__run_test_ctx(|ctx| { -/// egui::TopBottomPanel::top("my_panel").show(ctx, |ui| { -/// ui.label("Hello World!"); -/// }); -/// # }); -/// ``` -/// -/// See also [`SidePanel`]. -#[must_use = "You should call .show()"] -pub struct TopBottomPanel { - side: TopBottomSide, - id: Id, - frame: Option, - resizable: bool, - show_separator_line: bool, - default_height: Option, - height_range: Rangef, -} - -impl TopBottomPanel { - /// The id should be globally unique, e.g. `Id::new("my_top_panel")`. - pub fn top(id: impl Into) -> Self { - Self::new(TopBottomSide::Top, id) - } - - /// The id should be globally unique, e.g. `Id::new("my_bottom_panel")`. - pub fn bottom(id: impl Into) -> Self { - Self::new(TopBottomSide::Bottom, id) - } - - /// The id should be globally unique, e.g. `Id::new("my_panel")`. - pub fn new(side: TopBottomSide, id: impl Into) -> Self { - Self { - side, - id: id.into(), - frame: None, - resizable: false, - show_separator_line: true, - default_height: None, - height_range: Rangef::new(20.0, f32::INFINITY), - } - } - - /// Can panel be resized by dragging the edge of it? - /// - /// Default is `false`. - /// - /// If you want your panel to be resizable you also need to make the ui use - /// the available space. - /// - /// This can be done by using [`Ui::take_available_space`], or using a - /// widget in it that takes up more space as you resize it, such as: - /// * Wrapping text ([`Ui::horizontal_wrapped`]). - /// * A [`crate::ScrollArea`]. - /// * A [`crate::Separator`]. - /// * A [`crate::TextEdit`]. - /// * … - #[inline] - pub fn resizable(mut self, resizable: bool) -> Self { - self.resizable = resizable; - self - } - - /// Show a separator line, even when not interacting with it? - /// - /// Default: `true`. - #[inline] - pub fn show_separator_line(mut self, show_separator_line: bool) -> Self { - self.show_separator_line = show_separator_line; - self - } - - /// The initial height of the [`TopBottomPanel`], including margins. - /// Defaults to [`crate::style::Spacing::interact_size`].y, plus frame margins. - #[inline] - pub fn default_height(mut self, default_height: f32) -> Self { - self.default_height = Some(default_height); - self.height_range = Rangef::new( - self.height_range.min.at_most(default_height), - self.height_range.max.at_least(default_height), - ); - self - } - - /// Minimum height of the panel, including margins. - #[inline] - pub fn min_height(mut self, min_height: f32) -> Self { - self.height_range = Rangef::new(min_height, self.height_range.max.at_least(min_height)); - self - } - - /// Maximum height of the panel, including margins. - #[inline] - pub fn max_height(mut self, max_height: f32) -> Self { - self.height_range = Rangef::new(self.height_range.min.at_most(max_height), max_height); - self - } - - /// The allowable height range for the panel, including margins. - #[inline] - pub fn height_range(mut self, height_range: impl Into) -> Self { - let height_range = height_range.into(); - self.default_height = self - .default_height - .map(|default_height| clamp_to_range(default_height, height_range)); - self.height_range = height_range; - self - } - - /// Enforce this exact height, including margins. - #[inline] - pub fn exact_height(mut self, height: f32) -> Self { - self.default_height = Some(height); - self.height_range = Rangef::point(height); - self - } - - /// Change the background color, margins, etc. - #[inline] - pub fn frame(mut self, frame: Frame) -> Self { - self.frame = Some(frame); - self - } -} - -impl TopBottomPanel { - /// Show the panel inside a [`Ui`]. - pub fn show_inside( - self, - ui: &mut Ui, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> InnerResponse { - self.show_inside_dyn(ui, Box::new(add_contents)) - } - - /// Show the panel inside a [`Ui`]. - fn show_inside_dyn<'c, R>( - self, - ui: &mut Ui, - add_contents: Box R + 'c>, - ) -> InnerResponse { - let Self { - side, - id, - frame, - resizable, - show_separator_line, - default_height, - height_range, - } = self; - - let frame = frame.unwrap_or_else(|| Frame::side_top_panel(ui.style())); - - let available_rect = ui.available_rect_before_wrap(); - let mut panel_rect = available_rect; - - let mut height = if let Some(state) = PanelState::load(ui.ctx(), id) { - state.rect.height() - } else { - default_height - .unwrap_or_else(|| ui.style().spacing.interact_size.y + frame.inner_margin.sum().y) }; - { - height = clamp_to_range(height, height_range).at_most(available_rect.height()); - side.set_rect_height(&mut panel_rect, height); - ui.ctx() - .check_for_id_clash(id, panel_rect, "TopBottomPanel"); - } - let resize_id = id.with("__resize"); - let mut resize_hover = false; - let mut is_resizing = false; - if resizable { - // First we read the resize interaction results, to avoid frame latency in the resize: - if let Some(resize_response) = ui.ctx().read_response(resize_id) { - resize_hover = resize_response.hovered(); - is_resizing = resize_response.dragged(); + let resize_id = self.id.with("__resize"); + let resize_rect = Rect::from_x_y_ranges(resize_x, resize_y).expand2(amount); + let resize_response = ui.interact(resize_rect, resize_id, Sense::drag()); - if is_resizing && let Some(pointer) = resize_response.interact_pointer_pos() { - height = (pointer.y - side.side_y(panel_rect)).abs(); - height = clamp_to_range(height, height_range).at_most(available_rect.height()); - side.set_rect_height(&mut panel_rect, height); - } - } - } - - panel_rect = panel_rect.round_ui(); - - let mut panel_ui = ui.new_child( - UiBuilder::new() - .id_salt(id) - .ui_stack_info(UiStackInfo::new(match side { - TopBottomSide::Top => UiKind::TopPanel, - TopBottomSide::Bottom => UiKind::BottomPanel, - })) - .max_rect(panel_rect) - .layout(Layout::top_down(Align::Min)), - ); - panel_ui.expand_to_include_rect(panel_rect); - panel_ui.set_clip_rect(panel_rect); // If we overflow, don't do so visibly (#4475) - - let inner_response = frame.show(&mut panel_ui, |ui| { - ui.set_min_width(ui.max_rect().width()); // Make the frame fill full width - ui.set_min_height((height_range.min - frame.inner_margin.sum().y).at_least(0.0)); - add_contents(ui) - }); - - let rect = inner_response.response.rect; - - { - let mut cursor = ui.cursor(); - match side { - TopBottomSide::Top => { - cursor.min.y = rect.max.y; - } - TopBottomSide::Bottom => { - cursor.max.y = rect.min.y; - } - } - ui.set_cursor(cursor); - } - ui.expand_to_include_rect(rect); - - if resizable { - // Now we do the actual resize interaction, on top of all the contents. - // Otherwise its input could be eaten by the contents, e.g. a - // `ScrollArea` on either side of the panel boundary. - - let resize_y = side.opposite().side_y(panel_rect); - let resize_rect = Rect::from_x_y_ranges(panel_rect.x_range(), resize_y..=resize_y) - .expand2(vec2(0.0, ui.style().interaction.resize_grab_radius_side)); - let resize_response = ui.interact(resize_rect, resize_id, Sense::drag()); - resize_hover = resize_response.hovered(); - is_resizing = resize_response.dragged(); - } - - if resize_hover || is_resizing { - let cursor_icon = if height <= height_range.min { - match self.side { - TopBottomSide::Top => CursorIcon::ResizeSouth, - TopBottomSide::Bottom => CursorIcon::ResizeNorth, - } - } else if height < height_range.max { - CursorIcon::ResizeVertical - } else { - match self.side { - TopBottomSide::Top => CursorIcon::ResizeNorth, - TopBottomSide::Bottom => CursorIcon::ResizeSouth, - } - }; - ui.ctx().set_cursor_icon(cursor_icon); - } - - PanelState { rect }.store(ui.ctx(), id); - - { - let stroke = if is_resizing { - ui.style().visuals.widgets.active.fg_stroke // highly visible - } else if resize_hover { - ui.style().visuals.widgets.hovered.fg_stroke // highly visible - } else if show_separator_line { - // TODO(emilk): distinguish resizable from non-resizable - ui.style().visuals.widgets.noninteractive.bg_stroke // dim - } else { - Stroke::NONE - }; - // TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done - let resize_y = side.opposite().side_y(rect); - - // Make sure the line is on the inside of the panel: - let resize_y = resize_y + 0.5 * side.sign() * stroke.width; - ui.painter().hline(panel_rect.x_range(), resize_y, stroke); - } - - inner_response + (resize_response.hovered(), resize_response.dragged()) } - /// Show the panel at the top level. - pub fn show( - self, - ctx: &Context, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> InnerResponse { - self.show_dyn(ctx, Box::new(add_contents)) - } - - /// Show the panel at the top level. - fn show_dyn<'c, R>( - self, - ctx: &Context, - add_contents: Box R + 'c>, - ) -> InnerResponse { - let available_rect = ctx.available_rect(); - let side = self.side; - - let mut panel_ui = Ui::new( - ctx.clone(), - self.id, - UiBuilder::new() - .layer_id(LayerId::background()) - .max_rect(available_rect), - ); - panel_ui.set_clip_rect(ctx.content_rect()); - - let inner_response = self.show_inside_dyn(&mut panel_ui, add_contents); - let rect = inner_response.response.rect; - - match side { - TopBottomSide::Top => { - ctx.pass_state_mut(|state| { - state.allocate_top_panel(Rect::from_min_max(available_rect.min, rect.max)); - }); + fn cursor_icon(&self, panel_sizer: &PanelSizer<'_>) -> CursorIcon { + if panel_sizer.size <= self.size_range.min { + match self.side { + PanelSide::Vertical(side) => match side { + VerticalSide::Left => CursorIcon::ResizeEast, + VerticalSide::Right => CursorIcon::ResizeWest, + }, + PanelSide::Horizontal(side) => match side { + HorizontalSide::Top => CursorIcon::ResizeSouth, + HorizontalSide::Bottom => CursorIcon::ResizeNorth, + }, } - TopBottomSide::Bottom => { - ctx.pass_state_mut(|state| { - state.allocate_bottom_panel(Rect::from_min_max(rect.min, available_rect.max)); - }); + } else if panel_sizer.size < self.size_range.max { + match self.side { + PanelSide::Vertical(_) => CursorIcon::ResizeHorizontal, + PanelSide::Horizontal(_) => CursorIcon::ResizeVertical, + } + } else { + match self.side { + PanelSide::Vertical(side) => match side { + VerticalSide::Left => CursorIcon::ResizeWest, + VerticalSide::Right => CursorIcon::ResizeEast, + }, + PanelSide::Horizontal(side) => match side { + HorizontalSide::Top => CursorIcon::ResizeNorth, + HorizontalSide::Bottom => CursorIcon::ResizeSouth, + }, } } - - inner_response } - /// Show the panel if `is_expanded` is `true`, - /// otherwise don't show it, but with a nice animation between collapsed and expanded. - pub fn show_animated( - self, - ctx: &Context, - is_expanded: bool, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> Option> { + /// Get the real or fake panel to animate if `is_expanded` is `true`. + fn get_animated_panel(self, ctx: &Context, is_expanded: bool) -> Option { let how_expanded = animate_expansion(ctx, self.id.with("animation"), is_expanded); if 0.0 == how_expanded { None } else if how_expanded < 1.0 { // Show a fake panel in this in-between animation state: - // TODO(emilk): move the panel out-of-screen instead of changing its height. + // TODO(emilk): move the panel out-of-screen instead of changing its width. // Then we can actually paint it as it animates. - let expanded_height = PanelState::load(ctx, self.id) - .map(|state| state.rect.height()) - .or(self.default_height) - .unwrap_or_else(|| ctx.style().spacing.interact_size.y); - let fake_height = how_expanded * expanded_height; - Self { - id: self.id.with("animating_panel"), - ..self - } - .resizable(false) - .exact_height(fake_height) - .show(ctx, |_ui| {}); - None + let expanded_size = Self::animated_size(ctx, &self); + let fake_size = how_expanded * expanded_size; + Some( + Self { + id: self.id.with("animating_panel"), + ..self + } + .resizable(false) + .exact_size(fake_size), + ) } else { // Show the real panel: - Some(self.show(ctx, add_contents)) + Some(self) } } - /// Show the panel if `is_expanded` is `true`, - /// otherwise don't show it, but with a nice animation between collapsed and expanded. - pub fn show_animated_inside( - self, - ui: &mut Ui, - is_expanded: bool, - add_contents: impl FnOnce(&mut Ui) -> R, - ) -> Option> { - let how_expanded = animate_expansion(ui.ctx(), self.id.with("animation"), is_expanded); - - if 0.0 == how_expanded { - None - } else if how_expanded < 1.0 { - // Show a fake panel in this in-between animation state: - // TODO(emilk): move the panel out-of-screen instead of changing its height. - // Then we can actually paint it as it animates. - let expanded_height = PanelState::load(ui.ctx(), self.id) - .map(|state| state.rect.height()) - .or(self.default_height) - .unwrap_or_else(|| ui.style().spacing.interact_size.y); - let fake_height = how_expanded * expanded_height; - Self { - id: self.id.with("animating_panel"), - ..self - } - .resizable(false) - .exact_height(fake_height) - .show_inside(ui, |_ui| {}); - None - } else { - // Show the real panel: - Some(self.show_inside(ui, add_contents)) - } - } - - /// Show either a collapsed or a expanded panel, with a nice animation between. - pub fn show_animated_between( + /// Get either the collapsed or expended panel to animate. + fn get_animated_between_panel( ctx: &Context, is_expanded: bool, collapsed_panel: Self, expanded_panel: Self, - add_contents: impl FnOnce(&mut Ui, f32) -> R, - ) -> Option> { + ) -> Self { let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); if 0.0 == how_expanded { - Some(collapsed_panel.show(ctx, |ui| add_contents(ui, how_expanded))) + collapsed_panel } else if how_expanded < 1.0 { - // Show animation: - let collapsed_height = PanelState::load(ctx, collapsed_panel.id) - .map(|state| state.rect.height()) - .or(collapsed_panel.default_height) - .unwrap_or_else(|| ctx.style().spacing.interact_size.y); + let collapsed_size = Self::animated_size(ctx, &collapsed_panel); + let expanded_size = Self::animated_size(ctx, &expanded_panel); - let expanded_height = PanelState::load(ctx, expanded_panel.id) - .map(|state| state.rect.height()) - .or(expanded_panel.default_height) - .unwrap_or_else(|| ctx.style().spacing.interact_size.y); + let fake_size = lerp(collapsed_size..=expanded_size, how_expanded); - let fake_height = lerp(collapsed_height..=expanded_height, how_expanded); Self { id: expanded_panel.id.with("animating_panel"), ..expanded_panel } .resizable(false) - .exact_height(fake_height) - .show(ctx, |ui| add_contents(ui, how_expanded)); - None + .exact_size(fake_size) } else { - Some(expanded_panel.show(ctx, |ui| add_contents(ui, how_expanded))) + expanded_panel } } - /// Show either a collapsed or a expanded panel, with a nice animation between. - pub fn show_animated_between_inside( - ui: &mut Ui, - is_expanded: bool, - collapsed_panel: Self, - expanded_panel: Self, - add_contents: impl FnOnce(&mut Ui, f32) -> R, - ) -> InnerResponse { - let how_expanded = - animate_expansion(ui.ctx(), expanded_panel.id.with("animation"), is_expanded); + fn animated_size(ctx: &Context, panel: &Self) -> f32 { + let get_rect_state_size = |state: PanelState| match panel.side { + PanelSide::Vertical(_) => state.rect.width(), + PanelSide::Horizontal(_) => state.rect.height(), + }; - if 0.0 == how_expanded { - collapsed_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) - } else if how_expanded < 1.0 { - // Show animation: - let collapsed_height = PanelState::load(ui.ctx(), collapsed_panel.id) - .map(|state| state.rect.height()) - .or(collapsed_panel.default_height) - .unwrap_or_else(|| ui.style().spacing.interact_size.y); + let get_spacing_size = || match panel.side { + PanelSide::Vertical(_) => ctx.style().spacing.interact_size.x, + PanelSide::Horizontal(_) => ctx.style().spacing.interact_size.y, + }; - let expanded_height = PanelState::load(ui.ctx(), expanded_panel.id) - .map(|state| state.rect.height()) - .or(expanded_panel.default_height) - .unwrap_or_else(|| ui.style().spacing.interact_size.y); - - let fake_height = lerp(collapsed_height..=expanded_height, how_expanded); - Self { - id: expanded_panel.id.with("animating_panel"), - ..expanded_panel - } - .resizable(false) - .exact_height(fake_height) - .show_inside(ui, |ui| add_contents(ui, how_expanded)) - } else { - expanded_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) - } + PanelState::load(ctx, panel.id) + .map(get_rect_state_size) + .or(panel.default_size) + .unwrap_or_else(get_spacing_size) } } @@ -1075,8 +961,8 @@ impl TopBottomPanel { /// /// ``` /// # egui::__run_test_ctx(|ctx| { -/// egui::TopBottomPanel::top("my_panel").show(ctx, |ui| { -/// ui.label("Hello World! From `TopBottomPanel`, that must be before `CentralPanel`!"); +/// egui::Panel::top("my_panel").show(ctx, |ui| { +/// ui.label("Hello World! From `Panel`, that must be before `CentralPanel`!"); /// }); /// egui::CentralPanel::default().show(ctx, |ui| { /// ui.label("Hello World!"); @@ -1090,15 +976,25 @@ pub struct CentralPanel { } impl CentralPanel { + /// A central panel with no margin or background color + pub fn no_frame() -> Self { + Self { + frame: Some(Frame::NONE), + } + } + + /// A central panel with a background color and some inner margins + pub fn default_margins() -> Self { + Self { frame: None } + } + /// Change the background color, margins, etc. #[inline] pub fn frame(mut self, frame: Frame) -> Self { self.frame = Some(frame); self } -} -impl CentralPanel { /// Show the panel inside a [`Ui`]. pub fn show_inside( self, @@ -1158,6 +1054,13 @@ impl CentralPanel { ); panel_ui.set_clip_rect(ctx.content_rect()); + if false { + // TODO(emilk): @lucasmerlin shouldn't we enable this? + panel_ui + .response() + .widget_info(|| WidgetInfo::new(WidgetType::Panel)); + } + let inner_response = self.show_inside_dyn(&mut panel_ui, add_contents); // Only inform ctx about what we actually used, so we can shrink the native window to fit. @@ -1171,3 +1074,11 @@ fn clamp_to_range(x: f32, range: Rangef) -> f32 { let range = range.as_positive(); x.clamp(range.min, range.max) } + +// ---------------------------------------------------------------------------- + +#[deprecated = "Use Panel::left or Panel::right instead"] +pub type SidePanel = super::Panel; + +#[deprecated = "Use Panel::top or Panel::bottom instead"] +pub type TopBottomPanel = super::Panel; diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index a9c00661d..0fb2a9f2a 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -465,7 +465,7 @@ impl<'a> Popup<'a> { pub fn get_best_align(&self) -> RectAlign { let expected_popup_size = self .get_expected_size() - .unwrap_or(vec2(self.width.unwrap_or(0.0), 0.0)); + .unwrap_or_else(|| vec2(self.width.unwrap_or(0.0), 0.0)); let Some(anchor_rect) = self.anchor.rect(self.id, &self.ctx) else { return self.rect_align; @@ -473,6 +473,7 @@ impl<'a> Popup<'a> { RectAlign::find_best_align( #[expect(clippy::iter_on_empty_collections)] + #[expect(clippy::or_fun_call)] once(self.rect_align).chain( self.alternative_aligns // Need the empty slice so the iters have the same type so we can unwrap_or diff --git a/crates/egui/src/containers/scene.rs b/crates/egui/src/containers/scene.rs index 58739ba2a..36222b138 100644 --- a/crates/egui/src/containers/scene.rs +++ b/crates/egui/src/containers/scene.rs @@ -245,7 +245,7 @@ impl Scene { { let pointer_in_scene = to_global.inverse() * mouse_pos; let zoom_delta = ui.ctx().input(|i| i.zoom_delta()); - let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta); + let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta()); // Most of the time we can return early. This is also important to // avoid `ui_from_scene` to change slightly due to floating point errors. diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index ff2542d8d..5ed4c31f3 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -1,10 +1,15 @@ +//! See [`ScrollArea`] for docs. + #![allow(clippy::needless_range_loop)] use std::ops::{Add, AddAssign, BitOr, BitOrAssign}; +use emath::GuiRounding as _; +use epaint::Margin; + use crate::{ - Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Sense, Ui, UiBuilder, UiKind, - UiStackInfo, Vec2, Vec2b, emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, + Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Response, Sense, Ui, UiBuilder, + UiKind, UiStackInfo, Vec2, Vec2b, emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, }; #[derive(Clone, Copy, Debug)] @@ -256,7 +261,7 @@ impl AddAssign for ScrollSource { /// ### Coordinate system /// * content: size of contents (generally large; that's why we want scroll bars) /// * outer: size of scroll area including scroll bar(s) -/// * inner: excluding scroll bar(s). The area we clip the contents to. +/// * inner: excluding scroll bar(s). The area we clip the contents to. Includes `content_margin`. /// /// If the floating scroll bars settings is turned on then `inner == outer`. /// @@ -292,6 +297,8 @@ pub struct ScrollArea { scroll_source: ScrollSource, wheel_scroll_multiplier: Vec2, + content_margin: Option, + /// If true for vertical or horizontal the scroll wheel will stick to the /// end position until user manually changes position. It will become true /// again once scroll handle makes contact with end. @@ -344,6 +351,7 @@ impl ScrollArea { on_drag_cursor: None, scroll_source: ScrollSource::default(), wheel_scroll_multiplier: Vec2::splat(1.0), + content_margin: None, stick_to_end: Vec2b::FALSE, animated: true, } @@ -591,6 +599,18 @@ impl ScrollArea { self.direction_enabled[0] || self.direction_enabled[1] } + /// Extra margin added around the contents. + /// + /// The scroll bars will be either on top of this margin, or outside of it, + /// depending on the value of [`crate::style::ScrollStyle::floating`]. + /// + /// Default: [`crate::style::ScrollStyle::content_margin`]. + #[inline] + pub fn content_margin(mut self, margin: impl Into) -> Self { + self.content_margin = Some(margin.into()); + self + } + /// The scroll handle will stick to the rightmost position even while the content size /// changes dynamically. This can be useful to simulate text scrollers coming in from right /// hand side. The scroll handle remains stuck until user manually changes position. Once "unstuck" @@ -642,7 +662,7 @@ struct Prepared { scroll_bar_visibility: ScrollBarVisibility, scroll_bar_rect: Option, - /// Where on the screen the content is (excludes scroll bars). + /// Where on the screen the content is (excludes scroll bars; includes `content_margin`). inner_rect: Rect, content_ui: Ui, @@ -659,6 +679,9 @@ struct Prepared { /// not for us to handle so we save it and restore it after this [`ScrollArea`] is done. saved_scroll_target: [Option; 2], + /// The response from dragging the background (if enabled) + background_drag_response: Option, + animated: bool, } @@ -678,6 +701,7 @@ impl ScrollArea { on_drag_cursor, scroll_source, wheel_scroll_multiplier, + content_margin: _, // Used elsewhere stick_to_end, animated, } = self; @@ -745,6 +769,12 @@ impl ScrollArea { } let content_max_rect = Rect::from_min_size(inner_rect.min - state.offset, content_max_size); + + // Round to pixels to avoid widgets appearing to "float" when scrolling fractional amounts: + let content_max_rect = content_max_rect + .round_to_pixels(ui.pixels_per_point()) + .round_ui(); + let mut content_ui = ui.new_child( UiBuilder::new() .ui_stack_info(UiStackInfo::new(UiKind::ScrollArea)) @@ -772,70 +802,72 @@ impl ScrollArea { let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size); let dt = ui.input(|i| i.stable_dt).at_most(0.1); - if scroll_source.drag - && ui.is_enabled() - && (state.content_is_too_large[0] || state.content_is_too_large[1]) - { - // Drag contents to scroll (for touch screens mostly). - // We must do this BEFORE adding content to the `ScrollArea`, - // or we will steal input from the widgets we contain. - let content_response_option = state - .interact_rect - .map(|rect| ui.interact(rect, id.with("area"), Sense::drag())); + let background_drag_response = + if scroll_source.drag && ui.is_enabled() && state.content_is_too_large.any() { + // Drag contents to scroll (for touch screens mostly). + // We must do this BEFORE adding content to the `ScrollArea`, + // or we will steal input from the widgets we contain. + let content_response_option = state + .interact_rect + .map(|rect| ui.interact(rect, id.with("area"), Sense::drag())); - if content_response_option - .as_ref() - .is_some_and(|response| response.dragged()) - { - for d in 0..2 { - if direction_enabled[d] { - ui.input(|input| { - state.offset[d] -= input.pointer.delta()[d]; - }); - state.scroll_stuck_to_end[d] = false; - state.offset_target[d] = None; - } - } - } else { - // Apply the cursor velocity to the scroll area when the user releases the drag. if content_response_option .as_ref() - .is_some_and(|response| response.drag_stopped()) + .is_some_and(|response| response.dragged()) { - state.vel = - direction_enabled.to_vec2() * ui.input(|input| input.pointer.velocity()); - } - for d in 0..2 { - // Kinetic scrolling - let stop_speed = 20.0; // Pixels per second. - let friction_coeff = 1000.0; // Pixels per second squared. + for d in 0..2 { + if direction_enabled[d] { + ui.input(|input| { + state.offset[d] -= input.pointer.delta()[d]; + }); + state.scroll_stuck_to_end[d] = false; + state.offset_target[d] = None; + } + } + } else { + // Apply the cursor velocity to the scroll area when the user releases the drag. + if content_response_option + .as_ref() + .is_some_and(|response| response.drag_stopped()) + { + state.vel = direction_enabled.to_vec2() + * ui.input(|input| input.pointer.velocity()); + } + for d in 0..2 { + // Kinetic scrolling + let stop_speed = 20.0; // Pixels per second. + let friction_coeff = 1000.0; // Pixels per second squared. - let friction = friction_coeff * dt; - if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed { - state.vel[d] = 0.0; - } else { - state.vel[d] -= friction * state.vel[d].signum(); - // Offset has an inverted coordinate system compared to - // the velocity, so we subtract it instead of adding it - state.offset[d] -= state.vel[d] * dt; - ctx.request_repaint(); + let friction = friction_coeff * dt; + if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed { + state.vel[d] = 0.0; + } else { + state.vel[d] -= friction * state.vel[d].signum(); + // Offset has an inverted coordinate system compared to + // the velocity, so we subtract it instead of adding it + state.offset[d] -= state.vel[d] * dt; + ctx.request_repaint(); + } } } - } - // Set the desired mouse cursors. - if let Some(response) = content_response_option { - if response.dragged() { - if let Some(cursor) = on_drag_cursor { - response.on_hover_cursor(cursor); + // Set the desired mouse cursors. + if let Some(response) = &content_response_option { + if response.dragged() + && let Some(cursor) = on_drag_cursor + { + ui.ctx().set_cursor_icon(cursor); + } else if response.hovered() + && let Some(cursor) = on_hover_cursor + { + ui.ctx().set_cursor_icon(cursor); } - } else if response.hovered() - && let Some(cursor) = on_hover_cursor - { - response.on_hover_cursor(cursor); } - } - } + + content_response_option + } else { + None + }; // Scroll with an animation if we have a target offset (that hasn't been cleared by the code // above). @@ -888,6 +920,7 @@ impl ScrollArea { wheel_scroll_multiplier, stick_to_end, saved_scroll_target, + background_drag_response, animated, } } @@ -969,10 +1002,21 @@ impl ScrollArea { ui: &mut Ui, add_contents: Box R + 'c>, ) -> ScrollAreaOutput { + let margin = self + .content_margin + .unwrap_or_else(|| ui.spacing().scroll.content_margin); + let mut prepared = self.begin(ui); let id = prepared.id; let inner_rect = prepared.inner_rect; - let inner = add_contents(&mut prepared.content_ui, prepared.viewport); + + let inner = crate::Frame::NONE + .inner_margin(margin) + .show(&mut prepared.content_ui, |ui| { + add_contents(ui, prepared.viewport) + }) + .inner; + let (content_size, state) = prepared.end(ui); ScrollAreaOutput { inner, @@ -1003,6 +1047,7 @@ impl Prepared { wheel_scroll_multiplier, stick_to_end, saved_scroll_target, + background_drag_response, animated, } = self; @@ -1118,7 +1163,16 @@ impl Prepared { ); let max_offset = content_size - inner_rect.size(); - let is_hovering_outer_rect = ui.rect_contains_pointer(outer_rect); + + // Drag-to-scroll? + let is_dragging_background = background_drag_response + .as_ref() + .is_some_and(|r| r.dragged()); + + let is_hovering_outer_rect = ui.rect_contains_pointer(outer_rect) + && ui.ctx().dragged_id().is_none() + || is_dragging_background; + if scroll_source.mouse_wheel && ui.is_enabled() && is_hovering_outer_rect { let always_scroll_enabled_direction = ui.style().always_scroll_the_only_direction && direction_enabled[0] != direction_enabled[1]; @@ -1127,9 +1181,9 @@ impl Prepared { let scroll_delta = ui.ctx().input(|input| { if always_scroll_enabled_direction { // no bidirectional scrolling; allow horizontal scrolling without pressing shift - input.smooth_scroll_delta[0] + input.smooth_scroll_delta[1] + input.smooth_scroll_delta()[0] + input.smooth_scroll_delta()[1] } else { - input.smooth_scroll_delta[d] + input.smooth_scroll_delta()[d] } }); let scroll_delta = scroll_delta * wheel_scroll_multiplier[d]; @@ -1143,10 +1197,10 @@ impl Prepared { // Clear scroll delta so no parent scroll will use it: ui.ctx().input_mut(|input| { if always_scroll_enabled_direction { - input.smooth_scroll_delta[0] = 0.0; - input.smooth_scroll_delta[1] = 0.0; + input.smooth_scroll_delta()[0] = 0.0; + input.smooth_scroll_delta()[1] = 0.0; } else { - input.smooth_scroll_delta[d] = 0.0; + input.smooth_scroll_delta()[d] = 0.0; } }); @@ -1204,6 +1258,7 @@ impl Prepared { let is_hovering_bar_area = is_hovering_outer_rect && ui.rect_contains_pointer(max_bar_rect) + && !is_dragging_background || state.scroll_bar_interaction[d]; let is_hovering_bar_area_t = ui diff --git a/crates/egui/src/containers/tooltip.rs b/crates/egui/src/containers/tooltip.rs index 682b11fd8..c46e21d57 100644 --- a/crates/egui/src/containers/tooltip.rs +++ b/crates/egui/src/containers/tooltip.rs @@ -358,7 +358,7 @@ impl Tooltip<'_> { // We only show the tooltip when the mouse pointer is still. if !response .ctx - .input(|i| i.pointer.is_still() && i.smooth_scroll_delta == Vec2::ZERO) + .input(|i| i.pointer.is_still() && !i.is_scrolling()) { // wait for mouse to stop response.ctx.request_repaint(); diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index da7e65c1b..8d7a95be7 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -825,7 +825,7 @@ fn resize_response( area: &mut area::Prepared, resize_id: Id, ) { - let Some(mut new_rect) = move_and_resize_window(ctx, &resize_interaction) else { + let Some(mut new_rect) = move_and_resize_window(ctx, resize_id, &resize_interaction) else { return; }; @@ -847,27 +847,38 @@ fn resize_response( } /// Acts on outer rect (outside the stroke) -fn move_and_resize_window(ctx: &Context, interaction: &ResizeInteraction) -> Option { +fn move_and_resize_window(ctx: &Context, id: Id, interaction: &ResizeInteraction) -> Option { + // Used to prevent drift + let rect_at_start_of_drag_id = id.with("window_rect_at_drag_start"); + if !interaction.any_dragged() { + ctx.data_mut(|data| { + data.remove::(rect_at_start_of_drag_id); + }); return None; } - let pointer_pos = ctx.input(|i| i.pointer.interact_pos())?; - let mut rect = interaction.outer_rect; // prevent drift + let total_drag_delta = ctx.input(|i| i.pointer.total_drag_delta())?; + + let rect_at_start_of_drag = ctx.data_mut(|data| { + *data.get_temp_mut_or::(rect_at_start_of_drag_id, interaction.outer_rect) + }); + + let mut rect = rect_at_start_of_drag; // prevent drift // Put the rect in the center of the stroke: rect = rect.shrink(interaction.window_frame.stroke.width / 2.0); if interaction.left.drag { - rect.min.x = pointer_pos.x; + rect.min.x += total_drag_delta.x; } else if interaction.right.drag { - rect.max.x = pointer_pos.x; + rect.max.x += total_drag_delta.x; } if interaction.top.drag { - rect.min.y = pointer_pos.y; + rect.min.y += total_drag_delta.y; } else if interaction.bottom.drag { - rect.max.y = pointer_pos.y; + rect.max.y += total_drag_delta.y; } // Return to having the rect outside the stroke: @@ -899,7 +910,6 @@ fn resize_interaction( let rect = outer_rect.shrink(window_frame.stroke.width / 2.0); let side_response = |rect, id| { - #[cfg(feature = "accesskit")] ctx.register_accesskit_parent(id, _accessibility_parent); let response = ctx.create_widget( WidgetRect { diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index aab81ab2c..9d9d4b53f 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -41,7 +41,6 @@ use crate::{ viewport::ViewportClass, }; -#[cfg(feature = "accesskit")] use crate::IdMap; /// Information given to the backend about when it is time to repaint the ui. @@ -404,7 +403,6 @@ struct ContextImpl { embed_viewports: bool, - #[cfg(feature = "accesskit")] is_accesskit_enabled: bool, loaders: Arc, @@ -507,7 +505,6 @@ impl ContextImpl { }, ); - #[cfg(feature = "accesskit")] if self.is_accesskit_enabled { profiling::scope!("accesskit"); use crate::pass_state::AccessKitPassState; @@ -589,10 +586,10 @@ impl ContextImpl { } } - #[cfg(feature = "accesskit")] fn accesskit_node_builder(&mut self, id: Id) -> &mut accesskit::Node { let state = self.viewport().this_pass.accesskit_state.as_mut().unwrap(); let builders = &mut state.nodes; + if let std::collections::hash_map::Entry::Vacant(entry) = builders.entry(id) { entry.insert(Default::default()); @@ -614,11 +611,12 @@ impl ContextImpl { } let parent_id = find_accesskit_parent(&state.parent_map, builders, id) - .unwrap_or(crate::accesskit_root_id()); + .unwrap_or_else(crate::accesskit_root_id); let parent_builder = builders.get_mut(&parent_id).unwrap(); parent_builder.push_child(id.accesskit_id()); } + builders.get_mut(&id).unwrap() } @@ -766,7 +764,48 @@ impl Context { /// and only on the rare occasion that [`Context::request_discard`] is called. /// Usually, it `run_ui` will only be called once. /// - /// Put your widgets into a [`crate::SidePanel`], [`crate::TopBottomPanel`], [`crate::CentralPanel`], [`crate::Window`] or [`crate::Area`]. + /// The [`Ui`] given to the callback will cover the entire [`Self::content_rect`], + /// with no margin or background color. Use [`crate::Frame`] to add that. + /// + /// You can organize your GUI using [`crate::Panel`]. + /// + /// Instead of calling `run_ui`, you can alternatively use [`Self::begin_pass`] and [`Context::end_pass`]. + /// + /// ``` + /// // One egui context that you keep reusing: + /// let mut ctx = egui::Context::default(); + /// + /// // Each frame: + /// let input = egui::RawInput::default(); + /// let full_output = ctx.run_ui(input, |ui| { + /// ui.label("Hello egui!"); + /// }); + /// // handle full_output + /// ``` + /// + /// ## See also + /// * [`Self::run`] + #[must_use] + pub fn run_ui(&self, new_input: RawInput, mut run_ui: impl FnMut(&mut Ui)) -> FullOutput { + self.run_ui_dyn(new_input, &mut run_ui) + } + + #[must_use] + fn run_ui_dyn(&self, new_input: RawInput, run_ui: &mut dyn FnMut(&mut Ui)) -> FullOutput { + self.run(new_input, |ctx| { + crate::CentralPanel::no_frame().show(ctx, |ui| { + run_ui(ui); + }); + }) + } + + /// Run the ui code for one frame. + /// + /// At most [`Options::max_passes`] calls will be issued to `run_ui`, + /// and only on the rare occasion that [`Context::request_discard`] is called. + /// Usually, it `run_ui` will only be called once. + /// + /// Put your widgets into a [`crate::Panel`], [`crate::CentralPanel`], [`crate::Window`] or [`crate::Area`]. /// /// Instead of calling `run`, you can alternatively use [`Self::begin_pass`] and [`Context::end_pass`]. /// @@ -783,8 +822,16 @@ impl Context { /// }); /// // handle full_output /// ``` + /// + /// ## See also + /// * [`Self::run_ui`] #[must_use] - pub fn run(&self, mut new_input: RawInput, mut run_ui: impl FnMut(&Self)) -> FullOutput { + pub fn run(&self, new_input: RawInput, mut run_ui: impl FnMut(&Self)) -> FullOutput { + self.run_dyn(new_input, &mut run_ui) + } + + #[must_use] + fn run_dyn(&self, mut new_input: RawInput, run_ui: &mut dyn FnMut(&Self)) -> FullOutput { profiling::function_scope!(); let viewport_id = new_input.viewport_id; let max_passes = self.write(|ctx| ctx.memory.options.max_passes.get()); @@ -1198,7 +1245,12 @@ impl Context { #[allow(clippy::let_and_return, clippy::allow_attributes)] let res = self.get_response(w); - #[cfg(feature = "accesskit")] + #[cfg(debug_assertions)] + if res.contains_pointer() { + let plugins = self.read(|ctx| ctx.plugins.ordered_plugins()); + plugins.on_widget_under_pointer(self, &w); + } + if allow_focus && w.sense.is_focusable() { // Make sure anything that can receive focus has an AccessKit node. // TODO(mwcampbell): For nodes that are filled from widget info, @@ -1206,7 +1258,6 @@ impl Context { self.accesskit_node_builder(w.id, |builder| res.fill_accesskit_node_common(builder)); } - #[cfg(feature = "accesskit")] self.write(|ctx| { use crate::{Align, pass_state::ScrollTarget, style::ScrollAnimation}; let viewport = ctx.viewport_for(ctx.viewport_id()); @@ -1214,12 +1265,14 @@ impl Context { viewport .input .consume_accesskit_action_requests(res.id, |request| { + use accesskit::Action; + // TODO(lucasmerlin): Correctly handle the scroll unit: // https://github.com/AccessKit/accesskit/blob/e639c0e0d8ccbfd9dff302d972fa06f9766d608e/common/src/lib.rs#L2621 const DISTANCE: f32 = 100.0; match &request.action { - accesskit::Action::ScrollIntoView => { + Action::ScrollIntoView => { viewport.this_pass.scroll_target = [ Some(ScrollTarget::new( res.rect.x_range(), @@ -1233,16 +1286,16 @@ impl Context { )), ]; } - accesskit::Action::ScrollDown => { + Action::ScrollDown => { viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::UP; } - accesskit::Action::ScrollUp => { + Action::ScrollUp => { viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::DOWN; } - accesskit::Action::ScrollLeft => { + Action::ScrollLeft => { viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::LEFT; } - accesskit::Action::ScrollRight => { + Action::ScrollRight => { viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::RIGHT; } _ => return false, @@ -1335,7 +1388,6 @@ impl Context { res.flags.set(Flags::FAKE_PRIMARY_CLICKED, true); } - #[cfg(feature = "accesskit")] if enabled && sense.senses_click() && input.has_accesskit_action_request(id, accesskit::Action::Click) @@ -2492,7 +2544,6 @@ impl ContextImpl { let mut platform_output: PlatformOutput = std::mem::take(&mut viewport.output); - #[cfg(feature = "accesskit")] { profiling::scope!("accesskit"); let state = viewport.this_pass.accesskit_state.take(); @@ -3491,9 +3542,8 @@ impl Context { /// /// The `Context` lock is held while the given closure is called! /// - /// Returns `None` if acesskit is off. + /// Returns `None` if accesskit is off. // TODO(emilk): consider making both read-only and read-write versions - #[cfg(feature = "accesskit")] pub fn accesskit_node_builder( &self, id: Id, @@ -3509,7 +3559,6 @@ impl Context { }) } - #[cfg(feature = "accesskit")] pub(crate) fn register_accesskit_parent(&self, id: Id, parent_id: Id) { self.write(|ctx| { if let Some(state) = ctx.viewport().this_pass.accesskit_state.as_mut() { @@ -3519,13 +3568,11 @@ impl Context { } /// Enable generation of AccessKit tree updates in all future frames. - #[cfg(feature = "accesskit")] pub fn enable_accesskit(&self) { self.write(|ctx| ctx.is_accesskit_enabled = true); } /// Disable generation of AccessKit tree updates in all future frames. - #[cfg(feature = "accesskit")] pub fn disable_accesskit(&self) { self.write(|ctx| ctx.is_accesskit_enabled = false); } @@ -4010,7 +4057,7 @@ impl Context { /// Is this specific widget being dragged? /// /// A widget that sense both clicks and drags is only marked as "dragged" - /// when the mouse has moved a bit + /// when the mouse has moved a bit. /// /// See also: [`crate::Response::dragged`]. pub fn is_being_dragged(&self, id: Id) -> bool { @@ -4024,7 +4071,7 @@ impl Context { self.interaction_snapshot(|i| i.drag_started) } - /// This widget was being dragged, but was released this pass + /// This widget was being dragged, but was released this pass. pub fn drag_stopped_id(&self) -> Option { self.interaction_snapshot(|i| i.drag_stopped) } diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 8eecd979d..61f6ae00c 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -535,6 +535,11 @@ pub enum Event { /// as when swiping down on a touch-screen or track-pad with natural scrolling. delta: Vec2, + /// The phase of the scroll, useful for trackpads. + /// + /// If unknown set this to [`TouchPhase::Move`]. + phase: TouchPhase, + /// The state of the modifier keys at the time of the event. modifiers: Modifiers, }, @@ -543,7 +548,6 @@ pub enum Event { WindowFocused(bool), /// An assistive technology (e.g. screen reader) requested an action. - #[cfg(feature = "accesskit")] AccessKitActionRequest(accesskit::ActionRequest), /// The reply of a screenshot requested with [`crate::ViewportCommand::Screenshot`]. diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index deec5162d..2c6edba84 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -128,7 +128,6 @@ pub struct PlatformOutput { /// The difference in the widget tree since last frame. /// /// NOTE: this needs to be per-viewport. - #[cfg(feature = "accesskit")] pub accesskit_update: Option, /// How many ui passes is this the sum of? @@ -175,7 +174,6 @@ impl PlatformOutput { mut events, mutable_text_under_cursor, ime, - #[cfg(feature = "accesskit")] accesskit_update, num_completed_passes, mut request_discard_reasons, @@ -190,12 +188,8 @@ impl PlatformOutput { self.request_discard_reasons .append(&mut request_discard_reasons); - #[cfg(feature = "accesskit")] - { - // egui produces a complete AccessKit tree for each frame, - // so overwrite rather than appending. - self.accesskit_update = accesskit_update; - } + // egui produces a complete AccessKit tree for each frame, so overwrite rather than append: + self.accesskit_update = accesskit_update; } /// Take everything ephemeral (everything except `cursor_icon` currently) diff --git a/crates/egui/src/id.rs b/crates/egui/src/id.rs index 0565dc567..7484930c8 100644 --- a/crates/egui/src/id.rs +++ b/crates/egui/src/id.rs @@ -79,7 +79,6 @@ impl Id { self.0.get() } - #[cfg(feature = "accesskit")] pub(crate) fn accesskit_id(&self) -> accesskit::NodeId { self.value().into() } diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index b23a47828..0c99b6ac3 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -1,14 +1,18 @@ mod touch_state; +mod wheel_state; -use crate::data::input::{ - Event, EventFilter, KeyboardShortcut, Modifiers, MouseWheelUnit, NUM_POINTER_BUTTONS, - PointerButton, RawInput, TouchDeviceId, ViewportInfo, -}; use crate::{ SafeAreaInsets, emath::{NumExt as _, Pos2, Rect, Vec2, vec2}, util::History, }; +use crate::{ + data::input::{ + Event, EventFilter, KeyboardShortcut, Modifiers, NUM_POINTER_BUTTONS, PointerButton, + RawInput, TouchDeviceId, ViewportInfo, + }, + input_state::wheel_state::WheelState, +}; use std::{ collections::{BTreeMap, HashSet}, time::Duration, @@ -221,31 +225,8 @@ pub struct InputState { // ---------------------------------------------- // Scrolling: - // - /// Time of the last scroll event. - last_scroll_time: f64, - - /// Used for smoothing the scroll delta. - unprocessed_scroll_delta: Vec2, - - /// Used for smoothing the scroll delta when zooming. - unprocessed_scroll_delta_for_zoom: f32, - - /// You probably want to use [`Self::smooth_scroll_delta`] instead. - /// - /// The raw input of how many points the user scrolled. - /// - /// The delta dictates how the _content_ should move. - /// - /// A positive X-value indicates the content is being moved right, - /// as when swiping right on a touch-screen or track-pad with natural scrolling. - /// - /// A positive Y-value indicates the content is being moved down, - /// as when swiping down on a touch-screen or track-pad with natural scrolling. - /// - /// When using a notched scroll-wheel this will spike very large for one frame, - /// then drop to zero. For a smoother experience, use [`Self::smooth_scroll_delta`]. - pub raw_scroll_delta: Vec2, + #[cfg_attr(feature = "serde", serde(skip))] + wheel: WheelState, /// How many points the user scrolled, smoothed over a few frames. /// @@ -357,10 +338,7 @@ impl Default for InputState { pointer: Default::default(), touch_states: Default::default(), - last_scroll_time: f64::NEG_INFINITY, - unprocessed_scroll_delta: Vec2::ZERO, - unprocessed_scroll_delta_for_zoom: 0.0, - raw_scroll_delta: Vec2::ZERO, + wheel: Default::default(), smooth_scroll_delta: Vec2::ZERO, zoom_factor_delta: 1.0, rotation_radians: 0.0, @@ -415,12 +393,8 @@ impl InputState { let mut keys_down = self.keys_down; let mut zoom_factor_delta = 1.0; // TODO(emilk): smoothing for zoom factor let mut rotation_radians = 0.0; - let mut raw_scroll_delta = Vec2::ZERO; - let mut unprocessed_scroll_delta = self.unprocessed_scroll_delta; - let mut unprocessed_scroll_delta_for_zoom = self.unprocessed_scroll_delta_for_zoom; - let mut smooth_scroll_delta = Vec2::ZERO; - let mut smooth_scroll_delta_for_zoom = 0.0; + self.wheel.smooth_wheel_delta = Vec2::ZERO; for event in &mut new.events { match event { @@ -440,55 +414,18 @@ impl InputState { Event::MouseWheel { unit, delta, + phase, modifiers, } => { - let mut delta = match unit { - MouseWheelUnit::Point => *delta, - MouseWheelUnit::Line => options.line_scroll_speed * *delta, - MouseWheelUnit::Page => viewport_rect.height() * *delta, - }; - - let is_horizontal = modifiers.matches_any(options.horizontal_scroll_modifier); - let is_vertical = modifiers.matches_any(options.vertical_scroll_modifier); - - if is_horizontal && !is_vertical { - // Treat all scrolling as horizontal scrolling. - // Note: one Mac we already get horizontal scroll events when shift is down. - delta = vec2(delta.x + delta.y, 0.0); - } - if !is_horizontal && is_vertical { - // Treat all scrolling as vertical scrolling. - delta = vec2(0.0, delta.x + delta.y); - } - - raw_scroll_delta += delta; - - // Mouse wheels often go very large steps. - // A single notch on a logitech mouse wheel connected to a Macbook returns 14.0 raw_scroll_delta. - // So we smooth it out over several frames for a nicer user experience when scrolling in egui. - // BUT: if the user is using a nice smooth mac trackpad, we don't add smoothing, - // because it adds latency. - let is_smooth = match unit { - MouseWheelUnit::Point => delta.length() < 8.0, // a bit arbitrary here - MouseWheelUnit::Line | MouseWheelUnit::Page => false, - }; - - let is_zoom = modifiers.matches_any(options.zoom_modifier); - - #[expect(clippy::collapsible_else_if)] - if is_zoom { - if is_smooth { - smooth_scroll_delta_for_zoom += delta.x + delta.y; - } else { - unprocessed_scroll_delta_for_zoom += delta.x + delta.y; - } - } else { - if is_smooth { - smooth_scroll_delta += delta; - } else { - unprocessed_scroll_delta += delta; - } - } + self.wheel.on_wheel_event( + viewport_rect, + &options, + time, + *unit, + *delta, + *phase, + *modifiers, + ); } Event::Zoom(factor) => { zoom_factor_delta *= *factor; @@ -508,54 +445,28 @@ impl InputState { } } + let mut smooth_scroll_delta = Vec2::ZERO; + { let dt = stable_dt.at_most(0.1); - let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt); // reach _% in _ seconds. TODO(emilk): parameterize + self.wheel.after_events(time, dt); - if unprocessed_scroll_delta != Vec2::ZERO { - for d in 0..2 { - if unprocessed_scroll_delta[d].abs() < 1.0 { - smooth_scroll_delta[d] += unprocessed_scroll_delta[d]; - unprocessed_scroll_delta[d] = 0.0; - } else { - let applied = t * unprocessed_scroll_delta[d]; - smooth_scroll_delta[d] += applied; - unprocessed_scroll_delta[d] -= applied; - } - } - } + let is_zoom = self.wheel.modifiers.matches_any(options.zoom_modifier); - { - // Smooth scroll-to-zoom: - if unprocessed_scroll_delta_for_zoom.abs() < 1.0 { - smooth_scroll_delta_for_zoom += unprocessed_scroll_delta_for_zoom; - unprocessed_scroll_delta_for_zoom = 0.0; - } else { - let applied = t * unprocessed_scroll_delta_for_zoom; - smooth_scroll_delta_for_zoom += applied; - unprocessed_scroll_delta_for_zoom -= applied; - } - - zoom_factor_delta *= - (options.scroll_zoom_speed * smooth_scroll_delta_for_zoom).exp(); + if is_zoom { + zoom_factor_delta *= (options.scroll_zoom_speed + * (self.wheel.smooth_wheel_delta.x + self.wheel.smooth_wheel_delta.y)) + .exp(); + } else { + smooth_scroll_delta = self.wheel.smooth_wheel_delta; } } - let is_scrolling = raw_scroll_delta != Vec2::ZERO || smooth_scroll_delta != Vec2::ZERO; - let last_scroll_time = if is_scrolling { - time - } else { - self.last_scroll_time - }; - Self { pointer, touch_states: self.touch_states, - last_scroll_time, - unprocessed_scroll_delta, - unprocessed_scroll_delta_for_zoom, - raw_scroll_delta, + wheel: self.wheel, smooth_scroll_delta, zoom_factor_delta, rotation_radians, @@ -627,6 +538,22 @@ impl InputState { self.safe_area_insets } + /// How many points the user scrolled, smoothed over a few frames. + /// + /// The delta dictates how the _content_ should move. + /// + /// A positive X-value indicates the content is being moved right, + /// as when swiping right on a touch-screen or track-pad with natural scrolling. + /// + /// A positive Y-value indicates the content is being moved down, + /// as when swiping down on a touch-screen or track-pad with natural scrolling. + /// + /// [`crate::ScrollArea`] will both read and write to this field, so that + /// at the end of the frame this will be zero if a scroll-area consumed the delta. + pub fn smooth_scroll_delta(&self) -> Vec2 { + self.smooth_scroll_delta + } + /// Uniform zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture). /// * `zoom = 1`: no change /// * `zoom < 1`: pinch together @@ -705,13 +632,18 @@ impl InputState { #[inline(always)] pub fn translation_delta(&self) -> Vec2 { self.multi_touch() - .map_or(self.smooth_scroll_delta, |touch| touch.translation_delta) + .map_or(self.smooth_scroll_delta(), |touch| touch.translation_delta) } - /// How long has it been (in seconds) since the use last scrolled? + /// True if there is an active scroll action that might scroll more when using [`Self::smooth_scroll_delta`]. + pub fn is_scrolling(&self) -> bool { + self.wheel.is_scrolling() + } + + /// How long has it been (in seconds) since the last scroll event? #[inline(always)] pub fn time_since_last_scroll(&self) -> f32 { - (self.time - self.last_scroll_time) as f32 + (self.time - self.wheel.last_wheel_event) as f32 } /// The [`crate::Context`] will call this at the beginning of each frame to see if we need a repaint. @@ -723,8 +655,7 @@ impl InputState { /// cause a repaint. pub(crate) fn wants_repaint_after(&self) -> Option { if self.pointer.wants_repaint() - || self.unprocessed_scroll_delta.abs().max_elem() > 0.2 - || self.unprocessed_scroll_delta_for_zoom.abs() > 0.2 + || self.wheel.unprocessed_wheel_delta.abs().max_elem() > 0.2 || !self.events.is_empty() { // Immediate repaint @@ -924,7 +855,6 @@ impl InputState { } } - #[cfg(feature = "accesskit")] pub fn accesskit_action_requests( &self, id: crate::Id, @@ -942,7 +872,6 @@ impl InputState { }) } - #[cfg(feature = "accesskit")] pub fn consume_accesskit_action_requests( &mut self, id: crate::Id, @@ -959,12 +888,10 @@ impl InputState { }); } - #[cfg(feature = "accesskit")] pub fn has_accesskit_action_request(&self, id: crate::Id, action: accesskit::Action) -> bool { self.accesskit_action_requests(id, action).next().is_some() } - #[cfg(feature = "accesskit")] pub fn num_accesskit_action_requests(&self, id: crate::Id, action: accesskit::Action) -> usize { self.accesskit_action_requests(id, action).count() } @@ -1336,6 +1263,11 @@ impl PointerState { self.press_origin } + /// How far has the pointer moved since the start of the drag (if any)? + pub fn total_drag_delta(&self) -> Option { + Some(self.latest_pos? - self.press_origin?) + } + /// When did the current click/drag originate? /// `None` if no mouse button is down. #[inline(always)] @@ -1598,14 +1530,9 @@ impl InputState { raw, pointer, touch_states, - - last_scroll_time, - unprocessed_scroll_delta, - unprocessed_scroll_delta_for_zoom, - raw_scroll_delta, + wheel, smooth_scroll_delta, rotation_radians, - zoom_factor_delta, viewport_rect, safe_area_insets, @@ -1642,22 +1569,13 @@ impl InputState { }); } - ui.label(format!( - "Time since last scroll: {:.1} s", - time - last_scroll_time - )); - if cfg!(debug_assertions) { - ui.label(format!( - "unprocessed_scroll_delta: {unprocessed_scroll_delta:?} points" - )); - ui.label(format!( - "unprocessed_scroll_delta_for_zoom: {unprocessed_scroll_delta_for_zoom:?} points" - )); - } - ui.label(format!("raw_scroll_delta: {raw_scroll_delta:?} points")); - ui.label(format!( - "smooth_scroll_delta: {smooth_scroll_delta:?} points" - )); + crate::containers::CollapsingHeader::new("⬍ Scroll") + .default_open(false) + .show(ui, |ui| { + wheel.ui(ui); + }); + + ui.label(format!("smooth_scroll_delta: {smooth_scroll_delta:4.1}x")); ui.label(format!("zoom_factor_delta: {zoom_factor_delta:4.2}x")); ui.label(format!("rotation_radians: {rotation_radians:.3} radians")); diff --git a/crates/egui/src/input_state/wheel_state.rs b/crates/egui/src/input_state/wheel_state.rs new file mode 100644 index 000000000..2efbbc1ff --- /dev/null +++ b/crates/egui/src/input_state/wheel_state.rs @@ -0,0 +1,233 @@ +use emath::{Rect, Vec2, vec2}; + +use crate::{InputOptions, Modifiers, MouseWheelUnit, TouchPhase}; + +/// The current state of scrolling. +/// +/// There are two important types of scroll input deviced: +/// * Discreen scroll wheels on a mouse +/// * Smooth scroll input from a trackpad +/// +/// Scroll wheels will usually fire one single scroll event, +/// so it is important that egui smooths it out over time. +/// +/// On the contrary, trackpads usually provide smooth scroll input, +/// and with kinetic scrolling (which on Mac is implemented by the OS) +/// scroll events can arrive _after_ the user lets go of the trackpad. +/// +/// In either case, we consider use to be scrolling until there is no more +/// scroll events expected. +/// +/// This means there are a few different states we can be in: +/// * Not scrolling +/// * "Smooth scrolling" (low-pass filter of discreet scroll events) +/// * Trackpad-scrolling (we receive begin/end phases for these) +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Status { + /// Not scrolling, + Static, + + /// We're smoothing out previous scroll events + Smoothing, + + // We're in-between [`TouchPhase::Start`] and [`TouchPhase::End`] of a trackpad scroll. + InTouch, +} + +/// Keeps track of wheel (scroll) input. +#[derive(Clone, Debug)] +pub struct WheelState { + /// Are we currently in a scroll action? + /// + /// This may be true even if no scroll events came in this frame, + /// but we are in a kinetic scroll or in a smoothed scroll. + pub status: Status, + + /// The modifiers at the start of the scroll. + pub modifiers: Modifiers, + + /// Time of the last scroll event. + pub last_wheel_event: f64, + + /// Used for smoothing the scroll delta. + pub unprocessed_wheel_delta: Vec2, + + /// How many points the user scrolled, smoothed over a few frames. + /// + /// The delta dictates how the _content_ should move. + /// + /// A positive X-value indicates the content is being moved right, + /// as when swiping right on a touch-screen or track-pad with natural scrolling. + /// + /// A positive Y-value indicates the content is being moved down, + /// as when swiping down on a touch-screen or track-pad with natural scrolling. + /// + /// [`crate::ScrollArea`] will both read and write to this field, so that + /// at the end of the frame this will be zero if a scroll-area consumed the delta. + pub smooth_wheel_delta: Vec2, +} + +impl Default for WheelState { + fn default() -> Self { + Self { + status: Status::Static, + modifiers: Default::default(), + last_wheel_event: f64::NEG_INFINITY, + unprocessed_wheel_delta: Vec2::ZERO, + smooth_wheel_delta: Vec2::ZERO, + } + } +} + +impl WheelState { + #[expect(clippy::too_many_arguments)] + pub fn on_wheel_event( + &mut self, + viewport_rect: Rect, + options: &InputOptions, + time: f64, + unit: MouseWheelUnit, + delta: Vec2, + phase: TouchPhase, + latest_modifiers: Modifiers, + ) { + self.last_wheel_event = time; + match phase { + crate::TouchPhase::Start => { + self.status = Status::InTouch; + self.modifiers = latest_modifiers; + } + crate::TouchPhase::Move => { + match self.status { + Status::Static | Status::Smoothing => { + self.modifiers = latest_modifiers; + self.status = Status::Smoothing; + } + Status::InTouch => { + // If the user lets go of a modifier - ignore it. + // More kinematic scrolling may arrive. + // But if the users presses down new modifiers - heed it! + self.modifiers |= latest_modifiers; + } + } + + let mut delta = match unit { + MouseWheelUnit::Point => delta, + MouseWheelUnit::Line => options.line_scroll_speed * delta, + MouseWheelUnit::Page => viewport_rect.height() * delta, + }; + + let is_horizontal = self + .modifiers + .matches_any(options.horizontal_scroll_modifier); + let is_vertical = self.modifiers.matches_any(options.vertical_scroll_modifier); + + if is_horizontal && !is_vertical { + // Treat all scrolling as horizontal scrolling. + // Note: one Mac we already get horizontal scroll events when shift is down. + delta = vec2(delta.x + delta.y, 0.0); + } + if !is_horizontal && is_vertical { + // Treat all scrolling as vertical scrolling. + delta = vec2(0.0, delta.x + delta.y); + } + + // Mouse wheels often go very large steps. + // A single notch on a logitech mouse wheel connected to a Macbook returns 14.0 raw scroll delta. + // So we smooth it out over several frames for a nicer user experience when scrolling in egui. + // BUT: if the user is using a nice smooth mac trackpad, we don't add smoothing, + // because it adds latency. + let is_smooth = self.status == Status::InTouch + || match unit { + MouseWheelUnit::Point => delta.length() < 8.0, // a bit arbitrary here + MouseWheelUnit::Line | MouseWheelUnit::Page => false, + }; + + if is_smooth { + self.smooth_wheel_delta += delta; + } else { + self.unprocessed_wheel_delta += delta; + } + } + crate::TouchPhase::End | crate::TouchPhase::Cancel => { + self.status = Status::Static; + self.modifiers = Default::default(); + self.unprocessed_wheel_delta = Default::default(); + self.smooth_wheel_delta = Default::default(); + } + } + } + + pub fn after_events(&mut self, time: f64, dt: f32) { + let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt); // reach _% in _ seconds. TODO(emilk): parameterize + + if self.unprocessed_wheel_delta != Vec2::ZERO { + for d in 0..2 { + if self.unprocessed_wheel_delta[d].abs() < 1.0 { + self.smooth_wheel_delta[d] += self.unprocessed_wheel_delta[d]; + self.unprocessed_wheel_delta[d] = 0.0; + } else { + let applied = t * self.unprocessed_wheel_delta[d]; + self.smooth_wheel_delta[d] += applied; + self.unprocessed_wheel_delta[d] -= applied; + } + } + } + + let time_since_last_scroll = time - self.last_wheel_event; + + if self.status == Status::Smoothing + && self.smooth_wheel_delta == Vec2::ZERO + && 0.150 < time_since_last_scroll + { + // On certain platforms, like web, we don't get the start & stop scrolling events, so + // we rely on a timer there. + // + // Tested on a mac touchpad 2025, where the largest observed gap between scroll events + // was 68 ms. But we add some margin to be safe + self.status = Status::Static; + self.modifiers = Default::default(); + } + } + + /// True if there is an active scroll action that might scroll more when using [`Self::smooth_wheel_delta`]. + pub fn is_scrolling(&self) -> bool { + self.status != Status::Static + } + + pub fn ui(&self, ui: &mut crate::Ui) { + let Self { + status, + modifiers, + last_wheel_event, + unprocessed_wheel_delta, + smooth_wheel_delta, + } = self; + + let time = ui.input(|i| i.time); + + crate::Grid::new("ScrollState") + .num_columns(2) + .show(ui, |ui| { + ui.label("status"); + ui.monospace(format!("{status:?}")); + ui.end_row(); + + ui.label("modifiers"); + ui.monospace(format!("{modifiers:?}")); + ui.end_row(); + + ui.label("last_wheel_event"); + ui.monospace(format!("{:.1}s ago", time - *last_wheel_event)); + ui.end_row(); + + ui.label("unprocessed_wheel_delta"); + ui.monospace(unprocessed_wheel_delta.to_string()); + ui.end_row(); + + ui.label("smooth_wheel_delta"); + ui.monospace(smooth_wheel_delta.to_string()); + ui.end_row(); + }); + } +} diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 960480b23..d756caf75 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -9,7 +9,7 @@ //! which uses [`eframe`](https://docs.rs/eframe). //! //! To create a GUI using egui you first need a [`Context`] (by convention referred to by `ctx`). -//! Then you add a [`Window`] or a [`SidePanel`] to get a [`Ui`], which is what you'll be using to add all the buttons and labels that you need. +//! Then you add a [`Window`] or a [`Panel`] to get a [`Ui`], which is what you'll be using to add all the buttons and labels that you need. //! //! //! ## Feature flags @@ -45,7 +45,7 @@ //! //! ### Getting a [`Ui`] //! -//! Use one of [`SidePanel`], [`TopBottomPanel`], [`CentralPanel`], [`Window`] or [`Area`] to +//! Use one of [`Panel`], [`CentralPanel`], [`Window`] or [`Area`] to //! get access to an [`Ui`] where you can put widgets. For example: //! //! ``` @@ -322,7 +322,7 @@ //! when you release the panel/window shrinks again. //! This is an artifact of immediate mode, and here are some alternatives on how to avoid it: //! -//! 1. Turn off resizing with [`Window::resizable`], [`SidePanel::resizable`], [`TopBottomPanel::resizable`]. +//! 1. Turn off resizing with [`Window::resizable`], [`Panel::resizable`]. //! 2. Wrap your panel contents in a [`ScrollArea`], or use [`Window::vscroll`] and [`Window::hscroll`]. //! 3. Use a justified layout: //! @@ -448,7 +448,6 @@ pub mod widgets; #[cfg(debug_assertions)] mod callstack; -#[cfg(feature = "accesskit")] pub use accesskit; #[deprecated = "Use the ahash crate directly."] @@ -692,8 +691,8 @@ 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(Default::default(), |ctx| { - run_ui(ctx); + let _ = ctx.run_ui(Default::default(), |ui| { + run_ui(ui.ctx()); }); } @@ -701,14 +700,11 @@ pub fn __run_test_ctx(mut run_ui: impl FnMut(&Context)) { pub fn __run_test_ui(add_contents: impl Fn(&mut Ui)) { let ctx = Context::default(); ctx.set_fonts(FontDefinitions::empty()); // prevent fonts from being loaded (save CPU time) - let _ = ctx.run(Default::default(), |ctx| { - crate::CentralPanel::default().show(ctx, |ui| { - add_contents(ui); - }); + let _ = ctx.run_ui(Default::default(), |ui| { + add_contents(ui); }); } -#[cfg(feature = "accesskit")] pub fn accesskit_root_id() -> Id { Id::new("accesskit_root") } diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index ddc5a9ffe..d215a3bec 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -470,7 +470,6 @@ pub(crate) struct Focus { /// The ID of a widget to give the focus to in the next frame. id_next_frame: Option, - #[cfg(feature = "accesskit")] id_requested_by_accesskit: Option, /// If set, the next widget that is interested in focus will automatically get it. @@ -529,10 +528,7 @@ impl Focus { } let event_filter = self.focused_widget.map(|w| w.filter).unwrap_or_default(); - #[cfg(feature = "accesskit")] - { - self.id_requested_by_accesskit = None; - } + self.id_requested_by_accesskit = None; self.focus_direction = FocusDirection::None; @@ -567,16 +563,13 @@ impl Focus { self.focus_direction = cardinality; } - #[cfg(feature = "accesskit")] + if let crate::Event::AccessKitActionRequest(accesskit::ActionRequest { + action: accesskit::Action::Focus, + target, + data: None, + }) = event { - if let crate::Event::AccessKitActionRequest(accesskit::ActionRequest { - action: accesskit::Action::Focus, - target, - data: None, - }) = event - { - self.id_requested_by_accesskit = Some(*target); - } + self.id_requested_by_accesskit = Some(*target); } } } @@ -606,14 +599,11 @@ impl Focus { } fn interested_in_focus(&mut self, id: Id) { - #[cfg(feature = "accesskit")] - { - if self.id_requested_by_accesskit == Some(id.accesskit_id()) { - self.focused_widget = Some(FocusWidget::new(id)); - self.id_requested_by_accesskit = None; - self.give_to_next = false; - self.reset_focus(); - } + if self.id_requested_by_accesskit == Some(id.accesskit_id()) { + self.focused_widget = Some(FocusWidget::new(id)); + self.id_requested_by_accesskit = None; + self.give_to_next = false; + self.reset_focus(); } // The rect is updated at the end of the frame. @@ -1282,8 +1272,7 @@ impl Areas { pub fn top_layer_id(&self, order: Order) -> Option { self.order .iter() - .filter(|layer| layer.order == order && !self.is_sublayer(layer)) - .next_back() + .rfind(|layer| layer.order == order && !self.is_sublayer(layer)) .copied() } diff --git a/crates/egui/src/menu.rs b/crates/egui/src/menu.rs index 348f42c21..e5fb04b0d 100644 --- a/crates/egui/src/menu.rs +++ b/crates/egui/src/menu.rs @@ -84,7 +84,7 @@ fn set_menu_style(style: &mut Style) { } } -/// The menu bar goes well in a [`crate::TopBottomPanel::top`], +/// The menu bar goes well in a [`crate::Panel::top`], /// but can also be placed in a [`crate::Window`]. /// In the latter case you may want to wrap it in [`Frame`]. #[deprecated = "Use `egui::MenuBar::new().ui(` instead"] @@ -634,7 +634,7 @@ impl SubMenu { /// Usually you don't need to use it directly. pub struct MenuState { /// The opened sub-menu and its [`Id`] - sub_menu: Option<(Id, Arc>)>, + sub_menu: Option<(Id, Arc>)>, /// Bounding box of this menu (without the sub-menu), /// including the frame and everything. diff --git a/crates/egui/src/pass_state.rs b/crates/egui/src/pass_state.rs index 2be7e5098..9b323bfa0 100644 --- a/crates/egui/src/pass_state.rs +++ b/crates/egui/src/pass_state.rs @@ -67,7 +67,6 @@ impl ScrollTarget { } } -#[cfg(feature = "accesskit")] #[derive(Clone)] pub struct AccessKitPassState { pub nodes: IdMap, @@ -225,7 +224,6 @@ pub struct PassState { /// as when swiping down on a touch-screen or track-pad with natural scrolling. pub scroll_delta: (Vec2, style::ScrollAnimation), - #[cfg(feature = "accesskit")] pub accesskit_state: Option, /// Highlight these widgets the next pass. @@ -247,7 +245,6 @@ impl Default for PassState { used_by_panels: Rect::NAN, scroll_target: [None, None], scroll_delta: (Vec2::default(), style::ScrollAnimation::none()), - #[cfg(feature = "accesskit")] accesskit_state: None, highlight_next_pass: Default::default(), @@ -270,7 +267,6 @@ impl PassState { used_by_panels, scroll_target, scroll_delta, - #[cfg(feature = "accesskit")] accesskit_state, highlight_next_pass, @@ -293,10 +289,7 @@ impl PassState { *debug_rect = None; } - #[cfg(feature = "accesskit")] - { - *accesskit_state = None; - } + *accesskit_state = None; highlight_next_pass.clear(); } diff --git a/crates/egui/src/plugin.rs b/crates/egui/src/plugin.rs index bebcf892e..80afc4510 100644 --- a/crates/egui/src/plugin.rs +++ b/crates/egui/src/plugin.rs @@ -34,14 +34,21 @@ pub trait Plugin: Send + Sync + std::any::Any + 'static { /// Called just before the input is processed. /// /// Useful to inspect or modify the input. - /// Since this is called outside a pass, don't show ui here. + /// Since this is called outside a pass, don't show ui here. Using `Context::debug_painter` is fine though. fn input_hook(&mut self, input: &mut RawInput) {} /// Called just before the output is passed to the backend. /// /// Useful to inspect or modify the output. - /// Since this is called outside a pass, don't show ui here. + /// Since this is called outside a pass, don't show ui here. Using `Context::debug_painter` is fine though. fn output_hook(&mut self, output: &mut FullOutput) {} + + /// Called when a widget is created and is under the pointer. + /// + /// Useful for capturing a stack trace so that widgets can be mapped back to their source code. + /// Since this is called outside a pass, don't show ui here. Using `Context::debug_painter` is fine though. + #[cfg(debug_assertions)] + fn on_widget_under_pointer(&mut self, ctx: &Context, widget: &crate::WidgetRect) {} } pub(crate) struct PluginHandle { @@ -167,6 +174,14 @@ impl PluginsOrdered { plugin.output_hook(output); }); } + + #[cfg(debug_assertions)] + pub fn on_widget_under_pointer(&self, ctx: &Context, widget: &crate::WidgetRect) { + profiling::scope!("plugins", "on_widget_under_pointer"); + self.for_each_dyn(|plugin| { + plugin.on_widget_under_pointer(ctx, widget); + }); + } } impl Plugins { @@ -216,7 +231,7 @@ impl Plugin for CallbackPlugin { profiling::function_scope!(); for (_debug_name, cb) in &self.on_begin_plugins { - profiling::scope!(*_debug_name); + profiling::scope!("on_begin_pass", *_debug_name); (cb)(ctx); } } @@ -225,7 +240,7 @@ impl Plugin for CallbackPlugin { profiling::function_scope!(); for (_debug_name, cb) in &self.on_end_plugins { - profiling::scope!(*_debug_name); + profiling::scope!("on_end_pass", *_debug_name); (cb)(ctx); } } diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index e17c1aff5..8190f0006 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -396,7 +396,7 @@ impl Response { self.drag_stopped() && self.ctx.input(|i| i.pointer.button_released(button)) } - /// If dragged, how many points were we dragged and in what direction? + /// If dragged, how many points were we dragged in since last frame? #[inline] pub fn drag_delta(&self) -> Vec2 { if self.dragged() { @@ -410,7 +410,22 @@ impl Response { } } - /// If dragged, how far did the mouse move? + /// If dragged, how many points have we been dragged since the start of the drag? + #[inline] + pub fn total_drag_delta(&self) -> Option { + if self.dragged() { + let mut delta = self.ctx.input(|i| i.pointer.total_drag_delta())?; + if let Some(from_global) = self.ctx.layer_transform_from_global(self.layer_id) { + delta *= from_global.scaling; + } + Some(delta) + } else { + None + } + } + + /// If dragged, how far did the mouse move since last frame? + /// /// This will use raw mouse movement if provided by the integration, otherwise will fall back to [`Response::drag_delta`] /// Raw mouse movement is unaccelerated and unclamped by screen boundaries, and does not relate to any position on the screen. /// This may be useful in certain situations such as draggable values and 3D cameras, where screen position does not matter. @@ -418,7 +433,7 @@ impl Response { pub fn drag_motion(&self) -> Vec2 { if self.dragged() { self.ctx - .input(|i| i.pointer.motion().unwrap_or(i.pointer.delta())) + .input(|i| i.pointer.motion().unwrap_or_else(|| i.pointer.delta())) } else { Vec2::ZERO } @@ -457,7 +472,7 @@ impl Response { /// /// Only returns something if [`Self::contains_pointer`] is true, /// the user is drag-dropping something of this type, - /// and they released it this frame + /// and they released it this frame. #[doc(alias = "drag and drop")] pub fn dnd_release_payload(&self) -> Option> { // NOTE: we use `response.contains_pointer` here instead of `hovered`, because @@ -709,10 +724,9 @@ impl Response { /// ``` #[must_use] pub fn interact(&self, sense: Sense) -> Self { - if (self.sense | sense) == self.sense { - // Early-out: we already sense everything we need to sense. - return self.clone(); - } + // We could check here if the new Sense equals the old one to avoid the extra create_widget + // call. But that would break calling `interact` on a response from `Context::read_response` + // or `Ui::response`. (See https://github.com/emilk/egui/pull/7713 for more details.) self.ctx.create_widget( WidgetRect { @@ -793,7 +807,6 @@ impl Response { if let Some(event) = event { self.output_event(event); } else { - #[cfg(feature = "accesskit")] self.ctx.accesskit_node_builder(self.id, |builder| { self.fill_accesskit_node_from_widget_info(builder, make_info()); }); @@ -803,7 +816,6 @@ impl Response { } pub fn output_event(&self, event: crate::output::OutputEvent) { - #[cfg(feature = "accesskit")] self.ctx.accesskit_node_builder(self.id, |builder| { self.fill_accesskit_node_from_widget_info(builder, event.widget_info().clone()); }); @@ -814,7 +826,6 @@ impl Response { self.ctx.output_mut(|o| o.events.push(event)); } - #[cfg(feature = "accesskit")] pub(crate) fn fill_accesskit_node_common(&self, builder: &mut accesskit::Node) { if !self.enabled() { builder.set_disabled(); @@ -833,7 +844,6 @@ impl Response { } } - #[cfg(feature = "accesskit")] fn fill_accesskit_node_from_widget_info( &self, builder: &mut accesskit::Node, @@ -908,14 +918,9 @@ impl Response { /// # }); /// ``` pub fn labelled_by(self, id: Id) -> Self { - #[cfg(feature = "accesskit")] self.ctx.accesskit_node_builder(self.id, |builder| { builder.push_labelled_by(id.accesskit_id()); }); - #[cfg(not(feature = "accesskit"))] - { - let _ = id; - } self } diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 454fc6d89..b77536002 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -508,6 +508,12 @@ pub struct ScrollStyle { /// it more promiment. pub floating: bool, + /// Extra margin added around the contents of a [`crate::ScrollArea`]. + /// + /// The scroll bars will be either on top of this margin, or outside of it, + /// depending on the value of [`Self::floating`]. + pub content_margin: Margin, + /// The width of the scroll bars at it largest. pub bar_width: f32, @@ -591,6 +597,7 @@ impl ScrollStyle { pub fn solid() -> Self { Self { floating: false, + content_margin: Margin::ZERO, bar_width: 6.0, handle_min_length: 12.0, bar_inner_margin: 4.0, @@ -672,6 +679,9 @@ impl ScrollStyle { pub fn details_ui(&mut self, ui: &mut Ui) { let Self { floating, + + content_margin, + bar_width, handle_min_length, bar_inner_margin, @@ -695,6 +705,11 @@ impl ScrollStyle { ui.selectable_value(floating, true, "Floating"); }); + ui.horizontal(|ui| { + ui.label("Content margin:"); + content_margin.ui(ui); + }); + ui.horizontal(|ui| { ui.add(DragValue::new(bar_width).range(0.0..=32.0)); ui.label("Full bar width"); @@ -1129,7 +1144,10 @@ impl Visuals { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct Selection { + /// Background color behind selected text and other selectable buttons. pub bg_fill: Color32, + + /// Color of selected text. pub stroke: Stroke, } @@ -1821,6 +1839,10 @@ impl Spacing { ui.add(window_margin); ui.end_row(); + ui.label("ScrollArea margin"); + scroll.content_margin.ui(ui); + ui.end_row(); + ui.label("Menu margin"); ui.add(menu_margin); ui.end_row(); diff --git a/crates/egui/src/text_selection/accesskit_text.rs b/crates/egui/src/text_selection/accesskit_text.rs index 4d64229c5..974a334d0 100644 --- a/crates/egui/src/text_selection/accesskit_text.rs +++ b/crates/egui/src/text_selection/accesskit_text.rs @@ -42,8 +42,9 @@ pub fn update_accesskit_for_text_widget( for (row_index, row) in galley.rows.iter().enumerate() { let row_id = parent_id.with(row_index); - #[cfg(feature = "accesskit")] + ctx.register_accesskit_parent(row_id, parent_id); + ctx.accesskit_node_builder(row_id, |builder| { builder.set_role(accesskit::Role::TextRun); let rect = global_from_galley * row.rect_without_leading_space(); diff --git a/crates/egui/src/text_selection/cursor_range.rs b/crates/egui/src/text_selection/cursor_range.rs index 10980c581..a816f5f26 100644 --- a/crates/egui/src/text_selection/cursor_range.rs +++ b/crates/egui/src/text_selection/cursor_range.rs @@ -190,7 +190,6 @@ impl CCursorRange { .. } => self.on_key_press(os, galley, modifiers, *key), - #[cfg(feature = "accesskit")] Event::AccessKitActionRequest(accesskit::ActionRequest { action: accesskit::Action::SetTextSelection, target, @@ -220,7 +219,6 @@ impl CCursorRange { // ---------------------------------------------------------------------------- -#[cfg(feature = "accesskit")] fn ccursor_from_accesskit_text_position( id: Id, galley: &Galley, diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index 0405ca5da..bc2884441 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -624,7 +624,6 @@ impl LabelSelectionState { ); } - #[cfg(feature = "accesskit")] super::accesskit_text::update_accesskit_for_text_widget( ui.ctx(), response.id, diff --git a/crates/egui/src/text_selection/mod.rs b/crates/egui/src/text_selection/mod.rs index 8d0943d60..cbd51c31a 100644 --- a/crates/egui/src/text_selection/mod.rs +++ b/crates/egui/src/text_selection/mod.rs @@ -1,6 +1,5 @@ //! Helpers regarding text selection for labels and text edit. -#[cfg(feature = "accesskit")] pub mod accesskit_text; mod cursor_range; diff --git a/crates/egui/src/text_selection/text_cursor_state.rs b/crates/egui/src/text_selection/text_cursor_state.rs index 2a02e4577..9c9b0a263 100644 --- a/crates/egui/src/text_selection/text_cursor_state.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -208,25 +208,33 @@ fn ccursor_previous_line(text: &str, ccursor: CCursor) -> CCursor { } } -fn next_word_boundary_char_index(text: &str, index: usize) -> usize { - for word in text.split_word_bound_indices() { +fn next_word_boundary_char_index(text: &str, cursor_ci: usize) -> usize { + for (word_byte_index, word) in text.split_word_bound_indices() { + let word_ci = char_index_from_byte_index(text, word_byte_index); + + // We consider `.` a word boundary. + // At least that's how Mac works when navigating something like `www.example.com`. + for (dot_ci_offset, chr) in word.chars().enumerate() { + let dot_ci = word_ci + dot_ci_offset; + if chr == '.' && cursor_ci < dot_ci { + return dot_ci; + } + } + // Splitting considers contiguous whitespace as one word, such words must be skipped, // this handles cases for example ' abc' (a space and a word), the cursor is at the beginning // (before space) - this jumps at the end of 'abc' (this is consistent with text editors // or browsers) - let ci = char_index_from_byte_index(text, word.0); - if ci > index && !skip_word(word.1) { - return ci; + if cursor_ci < word_ci && !all_word_chars(word) { + return word_ci; } } char_index_from_byte_index(text, text.len()) } -fn skip_word(text: &str) -> bool { - // skip words that contain anything other than alphanumeric characters and underscore - // (i.e. whitespace, dashes, etc.) - !text.chars().any(|c| !is_word_char(c)) +fn all_word_chars(text: &str) -> bool { + text.chars().all(is_word_char) } fn next_line_boundary_char_index(it: impl Iterator, mut index: usize) -> usize { @@ -337,6 +345,12 @@ mod test { assert_eq!(next_word_boundary_char_index("", 0), 0); assert_eq!(next_word_boundary_char_index("", 1), 0); + // ASCII only + let text = "abc.def.ghi"; + assert_eq!(next_word_boundary_char_index(text, 1), 3); + assert_eq!(next_word_boundary_char_index(text, 3), 7); + assert_eq!(next_word_boundary_char_index(text, 7), 11); + // Unicode graphemes, some of which consist of multiple Unicode characters, // !!! Unicode character is not always what is tranditionally considered a character, // the values below are correct despite not seeming that way on the first look, diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index e3054b19d..50bb1a34d 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -25,13 +25,16 @@ pub fn paint_text_selection( // and so we need to clone it if it is shared: let galley: &mut Galley = Arc::make_mut(galley); - let color = visuals.selection.bg_fill; + let background_color = visuals.selection.bg_fill; + let text_color = visuals.selection.stroke.color; + let [min, max] = cursor_range.sorted_cursors(); let min = galley.layout_from_cursor(min); let max = galley.layout_from_cursor(max); for ri in min.row..=max.row { - let row = Arc::make_mut(&mut galley.rows[ri].row); + let placed_row = &mut galley.rows[ri]; + let row = Arc::make_mut(&mut placed_row.row); let left = if ri == min.row { row.x_offset(min.column) @@ -41,7 +44,7 @@ pub fn paint_text_selection( let right = if ri == max.row { row.x_offset(max.column) } else { - let newline_size = if row.ends_with_newline { + let newline_size = if placed_row.ends_with_newline { row.height() / 2.0 // visualize that we select the newline } else { 0.0 @@ -52,6 +55,31 @@ pub fn paint_text_selection( let rect = Rect::from_min_max(pos2(left, 0.0), pos2(right, row.size.y)); let mesh = &mut row.visuals.mesh; + if !row.glyphs.is_empty() { + // Change color of the selected text: + let first_glyph_index = if ri == min.row { min.column } else { 0 }; + let last_glyph_index = if ri == max.row { + max.column + } else { + row.glyphs.len() - 1 + }; + + let first_vertex_index = row + .glyphs + .get(first_glyph_index) + .map_or(row.visuals.glyph_vertex_range.start, |g| { + g.first_vertex as _ + }); + let last_vertex_index = row + .glyphs + .get(last_glyph_index) + .map_or(row.visuals.glyph_vertex_range.end, |g| g.first_vertex as _); + + for vi in first_vertex_index..last_vertex_index { + mesh.vertices[vi].color = text_color; + } + } + // Time to insert the selection rectangle into the row mesh. // It should be on top (after) of any background in the galley, // but behind (before) any glyphs. The row visuals has this information: @@ -59,7 +87,7 @@ pub fn paint_text_selection( // Start by appending the selection rectangle to end of the mesh, as two triangles (= 6 indices): let num_indices_before = mesh.indices.len(); - mesh.add_colored_rect(rect, color); + mesh.add_colored_rect(rect, background_color); assert_eq!( num_indices_before + 6, mesh.indices.len(), diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index d746b8fec..d230ed736 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -119,7 +119,7 @@ impl Ui { /// Create a new top-level [`Ui`]. /// /// Normally you would not use this directly, but instead use - /// [`crate::SidePanel`], [`crate::TopBottomPanel`], [`crate::CentralPanel`], [`crate::Window`] or [`crate::Area`]. + /// [`crate::Panel`], [`crate::CentralPanel`], [`crate::Window`] or [`crate::Area`]. pub fn new(ctx: Context, id: Id, ui_builder: UiBuilder) -> Self { let UiBuilder { id_salt, @@ -133,11 +133,10 @@ impl Ui { sizing_pass, style, sense, - #[cfg(feature = "accesskit")] accessibility_parent, } = ui_builder; - let layer_id = layer_id.unwrap_or(LayerId::background()); + let layer_id = layer_id.unwrap_or_else(LayerId::background); debug_assert!( id_salt.is_none(), @@ -149,7 +148,7 @@ impl Ui { let layout = layout.unwrap_or_default(); let disabled = disabled || invisible; let style = style.unwrap_or_else(|| ctx.style()); - let sense = sense.unwrap_or(Sense::hover()); + let sense = sense.unwrap_or_else(Sense::hover); let placer = Placer::new(max_rect, layout); let ui_stack = UiStack { @@ -175,7 +174,6 @@ impl Ui { min_rect_already_remembered: false, }; - #[cfg(feature = "accesskit")] if let Some(accessibility_parent) = accessibility_parent { ui.ctx() .register_accesskit_parent(ui.unique_id, accessibility_parent); @@ -202,7 +200,6 @@ impl Ui { ui.set_invisible(); } - #[cfg(feature = "accesskit")] ui.ctx().accesskit_node_builder(ui.unique_id, |node| { node.set_role(accesskit::Role::GenericContainer); }); @@ -273,7 +270,6 @@ impl Ui { sizing_pass, style, sense, - #[cfg(feature = "accesskit")] accessibility_parent, } = ui_builder; @@ -281,7 +277,7 @@ impl Ui { let id_salt = id_salt.unwrap_or_else(|| Id::from("child")); let max_rect = max_rect.unwrap_or_else(|| self.available_rect_before_wrap()); - let mut layout = layout.unwrap_or(*self.layout()); + let mut layout = layout.unwrap_or_else(|| *self.layout()); let enabled = self.enabled && !disabled && !invisible; if let Some(layer_id) = layer_id { painter.set_layer_id(layer_id); @@ -291,7 +287,7 @@ impl Ui { } let sizing_pass = self.sizing_pass || sizing_pass; let style = style.unwrap_or_else(|| self.style.clone()); - let sense = sense.unwrap_or(Sense::hover()); + let sense = sense.unwrap_or_else(Sense::hover); if sizing_pass { // During the sizing pass we want widgets to use up as little space as possible, @@ -343,7 +339,6 @@ impl Ui { child_ui.disable(); } - #[cfg(feature = "accesskit")] child_ui.ctx().register_accesskit_parent( child_ui.unique_id, accessibility_parent.unwrap_or(self.unique_id), @@ -363,7 +358,6 @@ impl Ui { true, ); - #[cfg(feature = "accesskit")] child_ui .ctx() .accesskit_node_builder(child_ui.unique_id, |node| { @@ -1129,7 +1123,6 @@ impl Ui { impl Ui { /// Check for clicks, drags and/or hover on a specific region of this [`Ui`]. pub fn interact(&self, rect: Rect, id: Id, sense: Sense) -> Response { - #[cfg(feature = "accesskit")] self.ctx().register_accesskit_parent(id, self.unique_id); self.ctx().create_widget( @@ -3011,7 +3004,7 @@ impl Ui { /// /// Returns the dropped item, if it was released this frame. /// - /// The given frame is used for its margins, but it color is ignored. + /// The given frame is used for its margins, but the color is ignored. #[doc(alias = "drag and drop")] pub fn dnd_drop_zone( &mut self, diff --git a/crates/egui/src/ui_builder.rs b/crates/egui/src/ui_builder.rs index 51b8ec8a5..686fdcb47 100644 --- a/crates/egui/src/ui_builder.rs +++ b/crates/egui/src/ui_builder.rs @@ -24,7 +24,6 @@ pub struct UiBuilder { pub sizing_pass: bool, pub style: Option>, pub sense: Option, - #[cfg(feature = "accesskit")] pub accessibility_parent: Option, } @@ -187,15 +186,9 @@ impl UiBuilder { /// /// This will override the automatic parent assignment for accessibility purposes. /// If not set, the parent [`Ui`]'s ID will be used as the accessibility parent. - /// - /// This does nothing if the `accesskit` feature is not enabled. - #[cfg_attr(not(feature = "accesskit"), expect(unused_mut, unused_variables))] #[inline] pub fn accessibility_parent(mut self, parent_id: Id) -> Self { - #[cfg(feature = "accesskit")] - { - self.accessibility_parent = Some(parent_id); - } + self.accessibility_parent = Some(parent_id); self } } diff --git a/crates/egui/src/ui_stack.rs b/crates/egui/src/ui_stack.rs index 0122f5681..07026c45b 100644 --- a/crates/egui/src/ui_stack.rs +++ b/crates/egui/src/ui_stack.rs @@ -12,16 +12,16 @@ pub enum UiKind { /// A [`crate::CentralPanel`]. CentralPanel, - /// A left [`crate::SidePanel`]. + /// A left [`crate::Panel`]. LeftPanel, - /// A right [`crate::SidePanel`]. + /// A right [`crate::Panel`]. RightPanel, - /// A top [`crate::TopBottomPanel`]. + /// A top [`crate::Panel`]. TopPanel, - /// A bottom [`crate::TopBottomPanel`]. + /// A bottom [`crate::Panel`]. BottomPanel, /// A modal [`crate::Modal`]. @@ -209,7 +209,7 @@ pub struct UiStack { pub layout_direction: Direction, pub min_rect: Rect, pub max_rect: Rect, - pub parent: Option>, + pub parent: Option>, } // these methods act on this specific node diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index af31b40af..ccb1db69f 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -223,22 +223,29 @@ impl<'a> Button<'a> { /// /// See also [`Self::right_text`]. #[inline] - pub fn shortcut_text(mut self, shortcut_text: impl Into>) -> Self { - let mut atom = shortcut_text.into(); - atom.kind = match atom.kind { - AtomKind::Text(text) => AtomKind::Text(text.weak()), - other => other, - }; + pub fn shortcut_text(mut self, shortcut_text: impl IntoAtoms<'a>) -> Self { self.layout.push_right(Atom::grow()); - self.layout.push_right(atom); + + for mut atom in shortcut_text.into_atoms() { + atom.kind = match atom.kind { + AtomKind::Text(text) => AtomKind::Text(text.weak()), + other => other, + }; + self.layout.push_right(atom); + } + self } /// Show some text on the right side of the button. #[inline] - pub fn right_text(mut self, right_text: impl Into>) -> Self { + pub fn right_text(mut self, right_text: impl IntoAtoms<'a>) -> Self { self.layout.push_right(Atom::grow()); - self.layout.push_right(right_text.into()); + + for atom in right_text.into_atoms() { + self.layout.push_right(atom); + } + self } diff --git a/crates/egui/src/widgets/drag_value.rs b/crates/egui/src/widgets/drag_value.rs index 9515726c2..29d596201 100644 --- a/crates/egui/src/widgets/drag_value.rs +++ b/crates/egui/src/widgets/drag_value.rs @@ -489,27 +489,21 @@ impl Widget for DragValue<'_> { - input.count_and_consume_key(Modifiers::NONE, Key::ArrowDown) as f64; } - #[cfg(feature = "accesskit")] - { - use accesskit::Action; - change += input.num_accesskit_action_requests(id, Action::Increment) as f64 - - input.num_accesskit_action_requests(id, Action::Decrement) as f64; - } + use accesskit::Action; + change += input.num_accesskit_action_requests(id, Action::Increment) as f64 + - input.num_accesskit_action_requests(id, Action::Decrement) as f64; change }); - #[cfg(feature = "accesskit")] - { + ui.input(|input| { use accesskit::{Action, ActionData}; - ui.input(|input| { - for request in input.accesskit_action_requests(id, Action::SetValue) { - if let Some(ActionData::NumericValue(new_value)) = request.data { - value = new_value; - } + for request in input.accesskit_action_requests(id, Action::SetValue) { + if let Some(ActionData::NumericValue(new_value)) = request.data { + value = new_value; } - }); - } + } + }); if clamp_existing_to_range { value = clamp_value_to_range(value, range.clone()); @@ -669,7 +663,6 @@ impl Widget for DragValue<'_> { response.widget_info(|| WidgetInfo::drag_value(ui.is_enabled(), value)); - #[cfg(feature = "accesskit")] ui.ctx().accesskit_node_builder(response.id, |builder| { use accesskit::Action; // If either end of the range is unbounded, it's better diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 167920adc..8a7c49209 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -681,7 +681,7 @@ pub fn paint_texture_load_result( } Ok(TexturePoll::Pending { .. }) => { let show_loading_spinner = - show_loading_spinner.unwrap_or(ui.visuals().image_loading_spinners); + show_loading_spinner.unwrap_or_else(|| ui.visuals().image_loading_spinners); if show_loading_spinner { Spinner::new().paint_at(ui, rect); } diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 86259ab2a..284cfd12c 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -248,7 +248,9 @@ impl Label { layout_job.halign = Align::LEFT; layout_job.justify = false; } else { - layout_job.halign = self.halign.unwrap_or(ui.layout().horizontal_placement()); + layout_job.halign = self + .halign + .unwrap_or_else(|| ui.layout().horizontal_placement()); layout_job.justify = ui.layout().horizontal_justify(); } diff --git a/crates/egui/src/widgets/progress_bar.rs b/crates/egui/src/widgets/progress_bar.rs index bba6be8ef..fb7a79ffe 100644 --- a/crates/egui/src/widgets/progress_bar.rs +++ b/crates/egui/src/widgets/progress_bar.rs @@ -118,7 +118,7 @@ impl Widget for ProgressBar { let desired_width = desired_width.unwrap_or_else(|| ui.available_size_before_wrap().x.at_least(96.0)); - let height = desired_height.unwrap_or(ui.spacing().interact_size.y); + let height = desired_height.unwrap_or_else(|| ui.spacing().interact_size.y); let (outer_rect, response) = ui.allocate_exact_size(vec2(desired_width, height), Sense::hover()); diff --git a/crates/egui/src/widgets/slider.rs b/crates/egui/src/widgets/slider.rs index 7937e5897..129c41c3b 100644 --- a/crates/egui/src/widgets/slider.rs +++ b/crates/egui/src/widgets/slider.rs @@ -716,14 +716,11 @@ impl Slider<'_> { }); } - #[cfg(feature = "accesskit")] - { + ui.input(|input| { use accesskit::Action; - ui.input(|input| { - decrement += input.num_accesskit_action_requests(response.id, Action::Decrement); - increment += input.num_accesskit_action_requests(response.id, Action::Increment); - }); - } + decrement += input.num_accesskit_action_requests(response.id, Action::Decrement); + increment += input.num_accesskit_action_requests(response.id, Action::Increment); + }); let kb_step = increment as f32 - decrement as f32; @@ -759,17 +756,14 @@ impl Slider<'_> { self.set_value(new_value); } - #[cfg(feature = "accesskit")] - { + ui.input(|input| { use accesskit::{Action, ActionData}; - ui.input(|input| { - for request in input.accesskit_action_requests(response.id, Action::SetValue) { - if let Some(ActionData::NumericValue(new_value)) = request.data { - self.set_value(new_value); - } + for request in input.accesskit_action_requests(response.id, Action::SetValue) { + if let Some(ActionData::NumericValue(new_value)) = request.data { + self.set_value(new_value); } - }); - } + } + }); // Paint it: if ui.is_rect_visible(response.rect) { @@ -978,7 +972,6 @@ impl Slider<'_> { } response.widget_info(|| WidgetInfo::slider(ui.is_enabled(), value, self.text.text())); - #[cfg(feature = "accesskit")] ui.ctx().accesskit_node_builder(response.id, |builder| { use accesskit::Action; builder.set_min_numeric_value(*self.range.start()); diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index c0364e7ee..e364b4a00 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -496,7 +496,7 @@ impl TextEdit<'_> { } = self; let text_color = text_color - .or(ui.visuals().override_text_color) + .or_else(|| ui.visuals().override_text_color) // .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()); @@ -691,7 +691,7 @@ impl TextEdit<'_> { if ui.is_rect_visible(rect) { if text.as_str().is_empty() && !hint_text.is_empty() { let hint_text_color = ui.visuals().weak_text_color(); - let hint_text_font_id = hint_text_font.unwrap_or(font_id.into()); + let hint_text_font_id = hint_text_font.unwrap_or_else(|| font_id.into()); let galley = if multiline { hint_text.into_galley( ui, @@ -844,7 +844,6 @@ impl TextEdit<'_> { }); } - #[cfg(feature = "accesskit")] { let role = if password { accesskit::Role::PasswordInput diff --git a/crates/egui_demo_app/Cargo.toml b/crates/egui_demo_app/Cargo.toml index 8eb441ac5..7cde46383 100644 --- a/crates/egui_demo_app/Cargo.toml +++ b/crates/egui_demo_app/Cargo.toml @@ -23,7 +23,7 @@ crate-type = ["cdylib", "rlib"] [features] -default = ["glow", "persistence"] +default = ["wgpu", "persistence"] # image_viewer adds about 0.9 MB of WASM web_app = ["http", "persistence"] diff --git a/crates/egui_demo_app/src/accessibility_inspector.rs b/crates/egui_demo_app/src/accessibility_inspector.rs index f721b710c..ab8b9270d 100644 --- a/crates/egui_demo_app/src/accessibility_inspector.rs +++ b/crates/egui_demo_app/src/accessibility_inspector.rs @@ -1,12 +1,13 @@ +use std::mem; + use accesskit::{Action, ActionRequest, NodeId}; use accesskit_consumer::{FilterResult, Node, Tree, TreeChangeHandler}; + use eframe::epaint::text::TextWrapMode; -use egui::collapsing_header::CollapsingState; use egui::{ Button, Color32, Context, Event, Frame, FullOutput, Id, Key, KeyboardShortcut, Label, - Modifiers, RawInput, RichText, ScrollArea, SidePanel, TopBottomPanel, Ui, + Modifiers, Panel, RawInput, RichText, ScrollArea, Ui, collapsing_header::CollapsingState, }; -use std::mem; /// This [`egui::Plugin`] adds an inspector Panel. /// @@ -86,10 +87,10 @@ impl egui::Plugin for AccessibilityInspectorPlugin { ctx.enable_accesskit(); - SidePanel::right(Self::id()).show(ctx, |ui| { + Panel::right(Self::id()).show(ctx, |ui| { ui.heading("πŸ”Ž AccessKit Inspector"); if let Some(selected_node) = self.selected_node { - TopBottomPanel::bottom(Self::id().with("details_panel")) + Panel::bottom(Self::id().with("details_panel")) .frame(Frame::new()) .show_separator_line(false) .show_inside(ui, |ui| { @@ -198,8 +199,8 @@ impl AccessibilityInspectorPlugin { } let label = node .label() - .or(node.value()) - .unwrap_or(node.id().0.to_string()); + .or_else(|| node.value()) + .unwrap_or_else(|| node.id().0.to_string()); let label = format!("({:?}) {}", node.role(), label); // Safety: This is safe since the `accesskit::NodeId` was created from an `egui::Id`. diff --git a/crates/egui_demo_app/src/apps/custom3d_glow.rs b/crates/egui_demo_app/src/apps/custom3d_glow.rs index 803f6156f..30380e31f 100644 --- a/crates/egui_demo_app/src/apps/custom3d_glow.rs +++ b/crates/egui_demo_app/src/apps/custom3d_glow.rs @@ -22,26 +22,27 @@ impl Custom3d { } } -impl eframe::App for Custom3d { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - egui::ScrollArea::both() - .auto_shrink(false) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - ui.label("The triangle is being painted using "); - ui.hyperlink_to("glow", "https://github.com/grovesNL/glow"); - ui.label(" (OpenGL)."); - }); - ui.label("It's not a very impressive demo, but it shows you can embed 3D inside of egui."); - - egui::Frame::canvas(ui.style()).show(ui, |ui| { - self.custom_painting(ui); - }); - ui.label("Drag to rotate!"); - ui.add(egui_demo_lib::egui_github_link_file!()); +impl crate::DemoApp for Custom3d { + fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + // TODO(emilk): Use `ScrollArea::inner_margin` + egui::CentralPanel::default().show_inside(ui, |ui| { + egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label("The triangle is being painted using "); + ui.hyperlink_to("glow", "https://github.com/grovesNL/glow"); + ui.label(" (OpenGL)."); }); + ui.label( + "It's not a very impressive demo, but it shows you can embed 3D inside of egui.", + ); + + egui::Frame::canvas(ui.style()).show(ui, |ui| { + self.custom_painting(ui); + }); + ui.label("Drag to rotate!"); + ui.add(egui_demo_lib::egui_github_link_file!()); + }); }); } diff --git a/crates/egui_demo_app/src/apps/custom3d_wgpu.rs b/crates/egui_demo_app/src/apps/custom3d_wgpu.rs index d3b10d480..0be1ed19b 100644 --- a/crates/egui_demo_app/src/apps/custom3d_wgpu.rs +++ b/crates/egui_demo_app/src/apps/custom3d_wgpu.rs @@ -98,26 +98,27 @@ impl Custom3d { } } -impl eframe::App for Custom3d { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - egui::ScrollArea::both() - .auto_shrink(false) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - ui.label("The triangle is being painted using "); - ui.hyperlink_to("WGPU", "https://wgpu.rs"); - ui.label(" (Portable Rust graphics API awesomeness)"); - }); - ui.label("It's not a very impressive demo, but it shows you can embed 3D inside of egui."); - - egui::Frame::canvas(ui.style()).show(ui, |ui| { - self.custom_painting(ui); - }); - ui.label("Drag to rotate!"); - ui.add(egui_demo_lib::egui_github_link_file!()); +impl crate::DemoApp for Custom3d { + fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + // TODO(emilk): Use `ScrollArea::inner_margin` + egui::CentralPanel::default().show_inside(ui, |ui| { + egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label("The triangle is being painted using "); + ui.hyperlink_to("WGPU", "https://wgpu.rs"); + ui.label(" (Portable Rust graphics API awesomeness)"); }); + ui.label( + "It's not a very impressive demo, but it shows you can embed 3D inside of egui.", + ); + + egui::Frame::canvas(ui.style()).show(ui, |ui| { + self.custom_painting(ui); + }); + ui.label("Drag to rotate!"); + ui.add(egui_demo_lib::egui_github_link_file!()); + }); }); } } diff --git a/crates/egui_demo_app/src/apps/http_app.rs b/crates/egui_demo_app/src/apps/http_app.rs index f16aa5969..8953f09e7 100644 --- a/crates/egui_demo_app/src/apps/http_app.rs +++ b/crates/egui_demo_app/src/apps/http_app.rs @@ -59,16 +59,16 @@ impl Default for HttpApp { } } -impl eframe::App for HttpApp { - fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - egui::TopBottomPanel::bottom("http_bottom").show(ctx, |ui| { +impl crate::DemoApp for HttpApp { + fn demo_ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + egui::Panel::bottom("http_bottom").show_inside(ui, |ui| { let layout = egui::Layout::top_down(egui::Align::Center).with_main_justify(true); ui.allocate_ui_with_layout(ui.available_size(), layout, |ui| { ui.add(egui_demo_lib::egui_github_link_file!()) }) }); - egui::CentralPanel::default().show(ctx, |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| { let prev_url = self.url.clone(); let trigger_fetch = ui_url(ui, frame, &mut self.url); @@ -80,7 +80,7 @@ impl eframe::App for HttpApp { }); if trigger_fetch { - let ctx = ctx.clone(); + let ctx = ui.ctx().clone(); let (sender, promise) = Promise::new(); let request = ehttp::Request::get(&self.url); ehttp::fetch(request, move |response| { diff --git a/crates/egui_demo_app/src/apps/image_viewer.rs b/crates/egui_demo_app/src/apps/image_viewer.rs index c341d2385..11cc68b6b 100644 --- a/crates/egui_demo_app/src/apps/image_viewer.rs +++ b/crates/egui_demo_app/src/apps/image_viewer.rs @@ -2,8 +2,6 @@ use egui::ImageFit; use egui::Slider; use egui::Vec2; use egui::emath::Rot2; -use egui::panel::Side; -use egui::panel::TopBottomSide; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct ImageViewer { @@ -50,15 +48,15 @@ impl Default for ImageViewer { } } -impl eframe::App for ImageViewer { - fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { - egui::TopBottomPanel::new(TopBottomSide::Top, "url bar").show(ctx, |ui| { +impl crate::DemoApp for ImageViewer { + fn demo_ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) { + egui::Panel::top("url bar").show_inside(ui, |ui| { ui.horizontal_centered(|ui| { let label = ui.label("URI:"); ui.text_edit_singleline(&mut self.uri_edit_text) .labelled_by(label.id); if ui.small_button("βœ”").clicked() { - ctx.forget_image(&self.current_uri); + ui.ctx().forget_image(&self.current_uri); self.uri_edit_text = self.uri_edit_text.trim().to_owned(); self.current_uri = self.uri_edit_text.clone(); } @@ -73,7 +71,7 @@ impl eframe::App for ImageViewer { }); }); - egui::SidePanel::new(Side::Left, "controls").show(ctx, |ui| { + egui::Panel::left("controls").show_inside(ui, |ui| { // uv ui.label("UV"); ui.add(Slider::new(&mut self.image_options.uv.min.x, 0.0..=1.0).text("min x")); @@ -199,7 +197,7 @@ impl eframe::App for ImageViewer { } }); - egui::CentralPanel::default().show(ctx, |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| { egui::ScrollArea::both().show(ui, |ui| { let mut image = egui::Image::from_uri(&self.current_uri); image = image.uv(self.image_options.uv); diff --git a/crates/egui_demo_app/src/lib.rs b/crates/egui_demo_app/src/lib.rs index 05b3c4bd6..40264fd8e 100644 --- a/crates/egui_demo_app/src/lib.rs +++ b/crates/egui_demo_app/src/lib.rs @@ -15,6 +15,14 @@ pub(crate) fn seconds_since_midnight() -> f64 { time.num_seconds_from_midnight() as f64 + 1e-9 * (time.nanosecond() as f64) } +/// Trait that wraps different parts of the demo app. +pub trait DemoApp { + fn demo_ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame); + + #[cfg(feature = "glow")] + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {} +} + // ---------------------------------------------------------------------------- #[cfg(feature = "accessibility_inspector")] pub mod accessibility_inspector; diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index bec0aacf3..87394b503 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -1,4 +1,4 @@ -use egui_demo_lib::is_mobile; +use egui_demo_lib::{DemoWindows, is_mobile}; #[cfg(feature = "glow")] use eframe::glow; @@ -6,29 +6,25 @@ use eframe::glow; #[cfg(target_arch = "wasm32")] use core::any::Any; +use crate::DemoApp; + #[derive(Default)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] struct EasyMarkApp { editor: egui_demo_lib::easy_mark::EasyMarkEditor, } -impl eframe::App for EasyMarkApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.editor.panels(ctx); +impl DemoApp for EasyMarkApp { + fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.editor.panels(ui); } } // ---------------------------------------------------------------------------- -#[derive(Default)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct DemoApp { - demo_windows: egui_demo_lib::DemoWindows, -} - -impl eframe::App for DemoApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.demo_windows.ui(ctx); +impl DemoApp for DemoWindows { + fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.ui(ui); } } @@ -41,17 +37,17 @@ pub struct FractalClockApp { pub mock_time: Option, } -impl eframe::App for FractalClockApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default() - .frame( - egui::Frame::dark_canvas(&ctx.style()) - .stroke(egui::Stroke::NONE) - .corner_radius(0), - ) - .show(ctx, |ui| { - self.fractal_clock - .ui(ui, self.mock_time.or(Some(crate::seconds_since_midnight()))); +impl DemoApp for FractalClockApp { + fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + egui::Frame::dark_canvas(ui.style()) + .stroke(egui::Stroke::NONE) + .corner_radius(0) + .show(ui, |ui| { + self.fractal_clock.ui( + ui, + self.mock_time + .or_else(|| Some(crate::seconds_since_midnight())), + ); }); } } @@ -64,13 +60,13 @@ pub struct ColorTestApp { color_test: egui_demo_lib::ColorTest, } -impl eframe::App for ColorTestApp { - fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { +impl DemoApp for ColorTestApp { + fn demo_ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + egui::CentralPanel::default().show_inside(ui, |ui| { if frame.is_web() { ui.label( - "NOTE: Some old browsers stuck on WebGL1 without sRGB support will not pass the color test.", - ); + "NOTE: Some old browsers stuck on WebGL1 without sRGB support will not pass the color test.", + ); ui.separator(); } egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| { @@ -155,7 +151,7 @@ enum Command { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct State { - demo: DemoApp, + demo: DemoWindows, easy_mark_editor: EasyMarkApp, #[cfg(feature = "http")] http: crate::apps::HttpApp, @@ -209,34 +205,34 @@ impl WrapApp { pub fn apps_iter_mut( &mut self, - ) -> impl Iterator { + ) -> impl Iterator { let mut vec = vec![ ( "✨ Demos", Anchor::Demo, - &mut self.state.demo as &mut dyn eframe::App, + &mut self.state.demo as &mut dyn DemoApp, ), ( "πŸ–Ή EasyMark editor", Anchor::EasyMarkEditor, - &mut self.state.easy_mark_editor as &mut dyn eframe::App, + &mut self.state.easy_mark_editor as &mut dyn DemoApp, ), #[cfg(feature = "http")] ( "⬇ HTTP", Anchor::Http, - &mut self.state.http as &mut dyn eframe::App, + &mut self.state.http as &mut dyn DemoApp, ), ( "πŸ•‘ Fractal Clock", Anchor::Clock, - &mut self.state.clock as &mut dyn eframe::App, + &mut self.state.clock as &mut dyn DemoApp, ), #[cfg(feature = "image_viewer")] ( "πŸ–Ό Image Viewer", Anchor::ImageViewer, - &mut self.state.image_viewer as &mut dyn eframe::App, + &mut self.state.image_viewer as &mut dyn DemoApp, ), ]; @@ -245,14 +241,14 @@ impl WrapApp { vec.push(( "πŸ”Ί 3D painting", Anchor::Custom3d, - custom3d as &mut dyn eframe::App, + custom3d as &mut dyn DemoApp, )); } vec.push(( "🎨 Rendering test", Anchor::Rendering, - &mut self.state.rendering_test as &mut dyn eframe::App, + &mut self.state.rendering_test as &mut dyn DemoApp, )); vec.into_iter() @@ -295,7 +291,7 @@ impl eframe::App for WrapApp { } let mut cmd = Command::Nothing; - egui::TopBottomPanel::top("wrap_app_top_bar") + egui::Panel::top("wrap_app_top_bar") .frame(egui::Frame::new().inner_margin(4)) .show(ctx, |ui| { ui.horizontal_wrapped(|ui| { @@ -306,11 +302,13 @@ impl eframe::App for WrapApp { self.state.backend_panel.update(ctx, frame); - if !is_mobile(ctx) { - cmd = self.backend_panel(ctx, frame); - } + egui::CentralPanel::no_frame().show(ctx, |ui| { + if !is_mobile(ctx) { + cmd = self.backend_panel(ui, frame); + } - self.show_selected_app(ctx, frame); + self.show_selected_app(ui, frame); + }); self.state.backend_panel.end_of_frame(ctx); @@ -333,17 +331,16 @@ impl eframe::App for WrapApp { } impl WrapApp { - fn backend_panel(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) -> Command { + fn backend_panel(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) -> Command { // The backend-panel can be toggled on/off. // We show a little animation when the user switches it. - let is_open = - self.state.backend_panel.open || ctx.memory(|mem| mem.everything_is_visible()); + let is_open = self.state.backend_panel.open || ui.memory(|mem| mem.everything_is_visible()); let mut cmd = Command::Nothing; - egui::SidePanel::left("backend_panel") + egui::Panel::left("backend_panel") .resizable(false) - .show_animated(ctx, is_open, |ui| { + .show_animated_inside(ui, is_open, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { ui.heading("πŸ’» Backend"); @@ -393,11 +390,11 @@ impl WrapApp { }); } - fn show_selected_app(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + fn show_selected_app(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let selected_anchor = self.state.selected_anchor; for (_name, anchor, app) in self.apps_iter_mut() { - if anchor == selected_anchor || ctx.memory(|mem| mem.everything_is_visible()) { - app.update(ctx, frame); + if anchor == selected_anchor || ui.memory(|mem| mem.everything_is_visible()) { + app.demo_ui(ui, frame); } } } diff --git a/crates/egui_demo_app/tests/snapshots/clock.png b/crates/egui_demo_app/tests/snapshots/clock.png index 39d9bb5ce..ec50255fd 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:44a68dc4d3aeebeb2d296c5c8e03aac330e1e4552364084347b710326c88f70c -size 335794 +oid sha256:784cbcdfd8deaf61e7b663f9416d67724e6a6a189a20ba3351908aa5c5f2deff +size 336159 diff --git a/crates/egui_demo_app/tests/snapshots/custom3d.png b/crates/egui_demo_app/tests/snapshots/custom3d.png index deed497b1..3fbf0ab56 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:e9a760fe4a695e6321f00e40bfa76fd0195bee7157a1217572765e3f146ea2cc -size 93640 +oid sha256:4cdde1dda0e64f584c769c72f5910a7035e6a4a86a074b590e88365f12570109 +size 94062 diff --git a/crates/egui_demo_app/tests/snapshots/easymarkeditor.png b/crates/egui_demo_app/tests/snapshots/easymarkeditor.png index a039b8c24..b9d8b2f22 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:a1670bbfc1f0a71e20cbbeb73625c148b680963bc503d9b48e9cc43e704d7c54 -size 181671 +oid sha256:824d941ea538fd44fc374f5df1893eee2309004c0ee5e69a97f1c84a74b2b423 +size 182128 diff --git a/crates/egui_demo_app/tests/snapshots/imageviewer.png b/crates/egui_demo_app/tests/snapshots/imageviewer.png index e1d518a96..fee7ad891 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:dc9c22567b76193a7f6753c4217adb3c92afa921c488ba1cf2e14b403814e7ac -size 99841 +oid sha256:44ea7ac8c8e22eb51fbcb63f00c8510de0e6ae126d19ab44c5d708d979b5362b +size 100345 diff --git a/crates/egui_demo_lib/benches/benchmark.rs b/crates/egui_demo_lib/benches/benchmark.rs index 48e7d5207..1d791cd6d 100644 --- a/crates/egui_demo_lib/benches/benchmark.rs +++ b/crates/egui_demo_lib/benches/benchmark.rs @@ -28,8 +28,8 @@ 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(RawInput::default(), |ctx| { - demo_windows.ui(ctx); + let full_output = ctx.run_ui(RawInput::default(), |ui| { + demo_windows.ui(ui); }); ctx.tessellate(full_output.shapes, full_output.pixels_per_point) }); @@ -37,14 +37,14 @@ pub fn criterion_benchmark(c: &mut Criterion) { c.bench_function("demo_no_tessellate", |b| { b.iter(|| { - ctx.run(RawInput::default(), |ctx| { - demo_windows.ui(ctx); + ctx.run_ui(RawInput::default(), |ui| { + demo_windows.ui(ui); }) }); }); - let full_output = ctx.run(RawInput::default(), |ctx| { - demo_windows.ui(ctx); + let full_output = ctx.run_ui(RawInput::default(), |ui| { + demo_windows.ui(ui); }); c.bench_function("demo_only_tessellate", |b| { b.iter(|| ctx.tessellate(full_output.shapes.clone(), full_output.pixels_per_point)); @@ -57,8 +57,8 @@ pub fn criterion_benchmark(c: &mut Criterion) { let mut demo_windows = egui_demo_lib::DemoWindows::default(); c.bench_function("demo_full_no_tessellate", |b| { b.iter(|| { - ctx.run(RawInput::default(), |ctx| { - demo_windows.ui(ctx); + ctx.run_ui(RawInput::default(), |ui| { + demo_windows.ui(ui); }) }); }); 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 8033539dd..d6f92b284 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -195,11 +195,11 @@ impl Default for DemoWindows { impl DemoWindows { /// Show the app ui (menu bar and windows). - pub fn ui(&mut self, ctx: &Context) { - if is_mobile(ctx) { - self.mobile_ui(ctx); + pub fn ui(&mut self, ui: &mut egui::Ui) { + if is_mobile(ui.ctx()) { + self.mobile_ui(ui); } else { - self.desktop_ui(ctx); + self.desktop_ui(ui); } } @@ -207,36 +207,36 @@ impl DemoWindows { self.open.contains(About::default().name()) } - fn mobile_ui(&mut self, ctx: &Context) { + fn mobile_ui(&mut self, ui: &mut egui::Ui) { if self.about_is_open() { let mut close = false; - egui::CentralPanel::default().show(ctx, |ui| { - egui::ScrollArea::vertical() - .auto_shrink(false) - .show(ui, |ui| { - self.groups.about.ui(ui); - ui.add_space(12.0); - ui.vertical_centered_justified(|ui| { - if ui - .button(egui::RichText::new("Continue to the demo!").size(20.0)) - .clicked() - { - close = true; - } - }); + + egui::ScrollArea::vertical() + .auto_shrink(false) + .show(ui, |ui| { + self.groups.about.ui(ui); + ui.add_space(12.0); + ui.vertical_centered_justified(|ui| { + if ui + .button(egui::RichText::new("Continue to the demo!").size(20.0)) + .clicked() + { + close = true; + } }); - }); + }); + if close { set_open(&mut self.open, About::default().name(), false); } } else { - self.mobile_top_bar(ctx); - self.groups.windows(ctx, &mut self.open); + self.mobile_top_bar(ui); + self.groups.windows(ui.ctx(), &mut self.open); } } - fn mobile_top_bar(&mut self, ctx: &Context) { - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + fn mobile_top_bar(&mut self, ui: &mut egui::Ui) { + egui::Panel::top("menu_bar").show_inside(ui, |ui| { menu::MenuBar::new() .config(menu::MenuConfig::new().style(StyleModifier::default())) .ui(ui, |ui| { @@ -261,12 +261,12 @@ impl DemoWindows { }); } - fn desktop_ui(&mut self, ctx: &Context) { - egui::SidePanel::right("egui_demo_panel") + fn desktop_ui(&mut self, ui: &mut egui::Ui) { + egui::Panel::right("egui_demo_panel") .resizable(false) - .default_width(160.0) - .min_width(160.0) - .show(ctx, |ui| { + .default_size(160.0) + .min_size(160.0) + .show_inside(ui, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { ui.heading("βœ’ egui demos"); @@ -289,13 +289,13 @@ impl DemoWindows { self.demo_list_ui(ui); }); - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + egui::Panel::top("menu_bar").show_inside(ui, |ui| { menu::MenuBar::new().ui(ui, |ui| { file_menu_button(ui); }); }); - self.groups.windows(ctx, &mut self.open); + self.groups.windows(ui.ctx(), &mut self.open); } fn demo_list_ui(&mut self, ui: &mut egui::Ui) { 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 bb62f1822..b502fa767 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -451,7 +451,7 @@ enum Action { #[derive(Clone, Default)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -struct Tree(Vec); +struct Tree(Vec); impl Tree { pub fn demo() -> Self { diff --git a/crates/egui_demo_lib/src/demo/panels.rs b/crates/egui_demo_lib/src/demo/panels.rs index f94513866..957166c4f 100644 --- a/crates/egui_demo_lib/src/demo/panels.rs +++ b/crates/egui_demo_lib/src/demo/panels.rs @@ -22,9 +22,9 @@ impl crate::View for Panels { fn ui(&mut self, ui: &mut egui::Ui) { // Note that the order we add the panels is very important! - egui::TopBottomPanel::top("top_panel") + egui::Panel::top("top_panel") .resizable(true) - .min_height(32.0) + .min_size(32.0) .show_inside(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { ui.vertical_centered(|ui| { @@ -34,10 +34,10 @@ impl crate::View for Panels { }); }); - egui::SidePanel::left("left_panel") + egui::Panel::left("left_panel") .resizable(true) - .default_width(150.0) - .width_range(80.0..=200.0) + .default_size(150.0) + .size_range(80.0..=200.0) .show_inside(ui, |ui| { ui.vertical_centered(|ui| { ui.heading("Left Panel"); @@ -47,10 +47,10 @@ impl crate::View for Panels { }); }); - egui::SidePanel::right("right_panel") + egui::Panel::right("right_panel") .resizable(true) - .default_width(150.0) - .width_range(80.0..=200.0) + .default_size(150.0) + .size_range(80.0..=200.0) .show_inside(ui, |ui| { ui.vertical_centered(|ui| { ui.heading("Right Panel"); @@ -60,9 +60,9 @@ impl crate::View for Panels { }); }); - egui::TopBottomPanel::bottom("bottom_panel") + egui::Panel::bottom("bottom_panel") .resizable(false) - .min_height(0.0) + .min_size(0.0) .show_inside(ui, |ui| { ui.vertical_centered(|ui| { ui.heading("Bottom Panel"); @@ -72,6 +72,7 @@ impl crate::View for Panels { }); }); + // TODO(emilk): This extra panel is superfluous - just use what's left of `ui` instead egui::CentralPanel::default().show_inside(ui, |ui| { ui.vertical_centered(|ui| { ui.heading("Central Panel"); diff --git a/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs b/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs index cb08cf24e..78af853ef 100644 --- a/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs +++ b/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs @@ -357,11 +357,13 @@ fn rect_shape_ui(ui: &mut egui::Ui, shape: &mut RectShape) { #[cfg(test)] mod tests { use crate::View as _; + use egui_kittest::SnapshotResults; use super::*; #[test] fn snapshot_tessellation_test() { + let mut results = SnapshotResults::new(); for (name, shape) in TessellationTest::interesting_shapes() { let mut test = TessellationTest { shape, @@ -375,6 +377,7 @@ mod tests { harness.run(); harness.snapshot(format!("tessellation_test/{name}")); + results.extend_harness(&mut harness); } } } diff --git a/crates/egui_demo_lib/src/demo/tooltips.rs b/crates/egui_demo_lib/src/demo/tooltips.rs index 0e391c553..cc474bd4d 100644 --- a/crates/egui_demo_lib/src/demo/tooltips.rs +++ b/crates/egui_demo_lib/src/demo/tooltips.rs @@ -35,7 +35,7 @@ impl crate::View for Tooltips { ui.add(crate::egui_github_link_file_line!()); }); - egui::SidePanel::right("scroll_test").show_inside(ui, |ui| { + egui::Panel::right("scroll_test").show_inside(ui, |ui| { ui.label( "The scroll area below has many labels with interactive tooltips. \ The purpose is to test that the tooltips close when you scroll.", diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index 214646d49..b277b6d12 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -310,7 +310,7 @@ mod tests { use super::*; use crate::View as _; use egui::Vec2; - use egui_kittest::Harness; + use egui_kittest::{Harness, SnapshotResults}; #[test] pub fn should_match_screenshot() { @@ -320,6 +320,8 @@ mod tests { ..Default::default() }; + let mut results = SnapshotResults::new(); + for pixels_per_point in [1, 2] { for theme in [egui::Theme::Light, egui::Theme::Dark] { let mut harness = Harness::builder() @@ -339,6 +341,7 @@ mod tests { }; let image_name = format!("widget_gallery_{theme_name}_x{pixels_per_point}"); harness.snapshot(&image_name); + results.extend_harness(&mut harness); } } } diff --git a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs index 9a66b8bc5..2969c6d3d 100644 --- a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs +++ b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs @@ -32,15 +32,15 @@ impl Default for EasyMarkEditor { } impl EasyMarkEditor { - pub fn panels(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::bottom("easy_mark_bottom").show(ctx, |ui| { + pub fn panels(&mut self, ui: &mut egui::Ui) { + egui::Panel::bottom("easy_mark_bottom").show_inside(ui, |ui| { let layout = egui::Layout::top_down(egui::Align::Center).with_main_justify(true); ui.allocate_ui_with_layout(ui.available_size(), layout, |ui| { ui.add(crate::egui_github_link_file!()) }) }); - egui::CentralPanel::default().show(ctx, |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| { self.ui(ui); }); } diff --git a/crates/egui_demo_lib/src/lib.rs b/crates/egui_demo_lib/src/lib.rs index 76be28859..e0a257224 100644 --- a/crates/egui_demo_lib/src/lib.rs +++ b/crates/egui_demo_lib/src/lib.rs @@ -73,8 +73,8 @@ fn test_egui_e2e() { const NUM_FRAMES: usize = 5; for _ in 0..NUM_FRAMES { - let full_output = ctx.run(raw_input.clone(), |ctx| { - demo_windows.ui(ctx); + let 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()); @@ -92,8 +92,8 @@ fn test_egui_zero_window_size() { const NUM_FRAMES: usize = 5; for _ in 0..NUM_FRAMES { - let full_output = ctx.run(raw_input.clone(), |ctx| { - demo_windows.ui(ctx); + let 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!( diff --git a/crates/egui_demo_lib/tests/image_blending.rs b/crates/egui_demo_lib/tests/image_blending.rs index c8e5775a8..5cf129efc 100644 --- a/crates/egui_demo_lib/tests/image_blending.rs +++ b/crates/egui_demo_lib/tests/image_blending.rs @@ -3,6 +3,7 @@ use egui_kittest::Harness; #[test] fn test_image_blending() { + let mut results = egui_kittest::SnapshotResults::new(); for pixels_per_point in [1.0, 2.0] { let mut harness = Harness::builder() .with_pixels_per_point(pixels_per_point) @@ -21,5 +22,6 @@ fn test_image_blending() { harness.run(); harness.fit_contents(); harness.snapshot(format!("image_blending/image_x{pixels_per_point}")); + results.extend_harness(&mut harness); } } diff --git a/crates/egui_demo_lib/tests/misc.rs b/crates/egui_demo_lib/tests/misc.rs index 395baceb6..8abc69d19 100644 --- a/crates/egui_demo_lib/tests/misc.rs +++ b/crates/egui_demo_lib/tests/misc.rs @@ -1,7 +1,9 @@ -use egui_kittest::Harness; +use egui::{Color32, accesskit::Role}; +use egui_kittest::{Harness, kittest::Queryable as _}; #[test] fn test_kerning() { + let mut results = egui_kittest::SnapshotResults::new(); for pixels_per_point in [1.0, 2.0] { for theme in [egui::Theme::Dark, egui::Theme::Light] { let mut harness = Harness::builder() @@ -23,12 +25,14 @@ fn test_kerning() { egui::Theme::Light => "light", } )); + results.extend_harness(&mut harness); } } } #[test] fn test_italics() { + let mut results = egui_kittest::SnapshotResults::new(); for pixels_per_point in [1.0, 2.0_f32.sqrt(), 2.0] { for theme in [egui::Theme::Dark, egui::Theme::Light] { let mut harness = Harness::builder() @@ -42,12 +46,34 @@ fn test_italics() { harness.run(); harness.fit_contents(); harness.snapshot(format!( - "image_blending/image_{theme}_x{pixels_per_point:.2}", + "italics/image_{theme}_x{pixels_per_point:.2}", theme = match theme { egui::Theme::Dark => "dark", egui::Theme::Light => "light", } )); + results.extend_harness(&mut harness); } } } + +#[test] +fn test_text_selection() { + let mut harness = Harness::builder().build_ui(|ui| { + let visuals = ui.visuals_mut(); + visuals.selection.bg_fill = Color32::LIGHT_GREEN; + visuals.selection.stroke.color = Color32::DARK_BLUE; + + ui.label("Some varied ☺ text :)\nAnd it has a second line!"); + }); + harness.run(); + harness.fit_contents(); + + // Drag to select text: + let label = harness.get_by_role(Role::Label); + harness.drag_at(label.rect().lerp_inside([0.2, 0.25])); + harness.drop_at(label.rect().lerp_inside([0.6, 0.75])); + harness.run(); + + harness.snapshot("text_selection"); +} diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Input Test.png b/crates/egui_demo_lib/tests/snapshots/demos/Input Test.png index 58cc9f94b..dd2d414a4 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Input Test.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Input Test.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aff927596be5db77349ec0bbdcc852a0b1467e94c2a553a740a383ae318bad18 +oid sha256:bb3f7b5f790830b46d1410c2bbb5e19c6beb403f8fe979eb8d250fba4f89be3e size 51670 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png b/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png index 396d83508..919bdc66d 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d6f055247034fa13ab55c9ec1fca275e6c23999c9a7e01c87af1fcc930faac6 -size 66777 +oid sha256:170cee9d72a4ab59aa2faf1b77aff4a9eee64f3380aa3f1b256340d88b1dabc2 +size 66525 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png index 22daed0ed..ca9bacfca 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:c91f592571ba654d0a96791662ae7530a1db4c1630b57c795d1c006ea6e46f19 -size 256975 +oid sha256:f7a7d0e2618b852b5966073438c95cb62901d5410c1473639920b0b0bf2ec59b +size 256913 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Table.png b/crates/egui_demo_lib/tests/snapshots/demos/Table.png index cf728bf3f..188c548d8 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Table.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Table.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4fbcca2b13c94769a62b44853b19f7e841bbb60c9197b3d0bf6e83ef9f8f76d1 -size 77815 +oid sha256:72f4c6fe4f5ec243506152027e1150f3069caf98511ceef92b8fea4f6a1563d5 +size 77614 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png index 97fa6cebc..e782c983a 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a31f0c12bb70449136443f9086103bd5b46356eedc2bb93ae1b6b10684ab69ca -size 36285 +oid sha256:611a2d6c793a85eebe807b2ddd4446cc0bc21e4284343dd756e64f0232fb6815 +size 35991 diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x1.00.png b/crates/egui_demo_lib/tests/snapshots/italics/image_dark_x1.00.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x1.00.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_dark_x1.00.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x1.41.png b/crates/egui_demo_lib/tests/snapshots/italics/image_dark_x1.41.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x1.41.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_dark_x1.41.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x2.00.png b/crates/egui_demo_lib/tests/snapshots/italics/image_dark_x2.00.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x2.00.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_dark_x2.00.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x1.00.png b/crates/egui_demo_lib/tests/snapshots/italics/image_light_x1.00.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x1.00.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_light_x1.00.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x1.41.png b/crates/egui_demo_lib/tests/snapshots/italics/image_light_x1.41.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x1.41.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_light_x1.41.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x2.00.png b/crates/egui_demo_lib/tests/snapshots/italics/image_light_x2.00.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x2.00.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_light_x2.00.png diff --git a/crates/egui_demo_lib/tests/snapshots/modals_2.png b/crates/egui_demo_lib/tests/snapshots/modals_2.png index c8e7cb55a..0aa16858a 100644 --- a/crates/egui_demo_lib/tests/snapshots/modals_2.png +++ b/crates/egui_demo_lib/tests/snapshots/modals_2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:53c1509f7be264ed2947cd4ec0f10b555e9f710e949ed6fd8a73ca8ade53abd4 -size 48570 +oid sha256:e7bc441559ff2d8723cf344113ce5ff8158e41179e4c93abcacbe7b1b13b3723 +size 48998 diff --git a/crates/egui_demo_lib/tests/snapshots/modals_3.png b/crates/egui_demo_lib/tests/snapshots/modals_3.png index 777b700c2..eaae2a758 100644 --- a/crates/egui_demo_lib/tests/snapshots/modals_3.png +++ b/crates/egui_demo_lib/tests/snapshots/modals_3.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:20eecafb998f69c2384afabc27eec1f97f413d603ece944adae9a99139be0b58 -size 44689 +oid sha256:3e092be54efaeb700a63d9b679894647159f39a0d3062692ac7056e98242cbee +size 45364 diff --git a/crates/egui_demo_lib/tests/snapshots/modals_backdrop_should_prevent_focusing_lower_area.png b/crates/egui_demo_lib/tests/snapshots/modals_backdrop_should_prevent_focusing_lower_area.png index 700eaf46b..8b54bf99f 100644 --- a/crates/egui_demo_lib/tests/snapshots/modals_backdrop_should_prevent_focusing_lower_area.png +++ b/crates/egui_demo_lib/tests/snapshots/modals_backdrop_should_prevent_focusing_lower_area.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a7f8282946761e6ab40193267e47a9421f5642bae67458a9aadb71ac1231c8f -size 44581 +oid sha256:88930779ac199e42fcc9ee25f29bd120478c129807713218370b617905340087 +size 45366 diff --git a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png index 1344edcfb..0fc009f8a 100644 --- a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png +++ b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b7d7e290b97a8042af3af3cd9ceb274950cf607dd7e9cd6c71d5a113d3b57a5 -size 1206155 +oid sha256:3a3a9aa8383abfe4580be2cc9987f8123aeabf36bf8ec06029a9af64b9500ec9 +size 1206157 diff --git a/crates/egui_demo_lib/tests/snapshots/text_selection.png b/crates/egui_demo_lib/tests/snapshots/text_selection.png new file mode 100644 index 000000000..78ebc0dbf --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/text_selection.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14f253fedc94985ff1431f1016d901d747e1f9948531cc6350f6615649f29056 +size 4862 diff --git a/crates/egui_extras/CHANGELOG.md b/crates/egui_extras/CHANGELOG.md index 726a1759c..9dbeb5879 100644 --- a/crates/egui_extras/CHANGELOG.md +++ b/crates/egui_extras/CHANGELOG.md @@ -5,6 +5,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.33.2 - 2025-11-13 +Nothing new + + ## 0.33.0 - 2025-10-09 * Fix: use unique id for resize columns in `Table` [#7414](https://github.com/emilk/egui/pull/7414) by [@zezic](https://github.com/zezic) * Feat: Add serde serialization to SyntectSettings [#7506](https://github.com/emilk/egui/pull/7506) by [@bircni](https://github.com/bircni) diff --git a/crates/egui_extras/src/table.rs b/crates/egui_extras/src/table.rs index a984226ae..f2a42b850 100644 --- a/crates/egui_extras/src/table.rs +++ b/crates/egui_extras/src/table.rs @@ -479,7 +479,7 @@ impl<'a> TableBuilder<'a> { } } - let striped = striped.unwrap_or(ui.visuals().striped); + let striped = striped.unwrap_or_else(|| ui.visuals().striped); let state_id = ui.id().with(id_salt); @@ -548,7 +548,7 @@ impl<'a> TableBuilder<'a> { sense, } = self; - let striped = striped.unwrap_or(ui.visuals().striped); + let striped = striped.unwrap_or_else(|| ui.visuals().striped); let state_id = ui.id().with(id_salt); diff --git a/crates/egui_glow/CHANGELOG.md b/crates/egui_glow/CHANGELOG.md index 34c2133e9..4dd90a359 100644 --- a/crates/egui_glow/CHANGELOG.md +++ b/crates/egui_glow/CHANGELOG.md @@ -6,6 +6,10 @@ Changes since the last release can be found at for GlowApp { self.egui_glow.as_mut().unwrap().run( self.gl_window.as_mut().unwrap().window(), |egui_ctx| { - egui::SidePanel::left("my_side_panel").show(egui_ctx, |ui| { + egui::Panel::left("my_side_panel").show(egui_ctx, |ui| { ui.heading("Hello World!"); if ui.button("Quit").clicked() { quit = true; diff --git a/crates/egui_kittest/CHANGELOG.md b/crates/egui_kittest/CHANGELOG.md index 9716c726d..09fe5c4f1 100644 --- a/crates/egui_kittest/CHANGELOG.md +++ b/crates/egui_kittest/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.33.2 - 2025-11-13 +Nothing new + + +## 0.33.1 - 2025-10-15 +* Add `egui_kittest::HarnessBuilder::with_options` [#7638](https://github.com/emilk/egui/pull/7638) by [@emilk](https://github.com/emilk) + + ## 0.33.0 - 2025-10-09 ### ⭐ Added * Kittest: Add `UPDATE_SNAPSHOTS=force` [#7508](https://github.com/emilk/egui/pull/7508) by [@emilk](https://github.com/emilk) diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index c283d0734..33c895617 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "egui_kittest" version.workspace = true -authors = ["Lucas Meurer ", "Emil Ernerfeldt "] +authors = ["Lucas Meurer ", "Emil Ernerfeldt "] description = "Testing library for egui based on kittest and AccessKit" edition.workspace = true rust-version.workspace = true @@ -34,9 +34,11 @@ x11 = ["eframe?/x11"] [dependencies] -kittest.workspace = true -egui = { workspace = true, features = ["accesskit"] } +egui.workspace = true eframe = { workspace = true, optional = true } +kittest.workspace = true +serde.workspace = true +toml.workspace = true # wgpu dependencies egui-wgpu = { workspace = true, optional = true } diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md index b774572f6..8c3c4bc30 100644 --- a/crates/egui_kittest/README.md +++ b/crates/egui_kittest/README.md @@ -38,6 +38,33 @@ fn main() { } ``` +## Configuration + +You can configure test settings via a `kittest.toml` file in your workspace root. +All possible settings and their defaults: +```toml +# path to the snapshot directory +output_path = "tests/snapshots" + +# default threshold for image comparison tests +threshold = 0.6 + +# default failed_pixel_count_threshold +failed_pixel_count_threshold = 0 + +[windows] +threshold = 0.6 +failed_pixel_count_threshold = 0 + +[macos] +threshold = 0.6 +failed_pixel_count_threshold = 0 + +[linux] +threshold = 0.6 +failed_pixel_count_threshold = 0 +``` + ## 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. diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs index 75ebd54b2..87b199c6d 100644 --- a/crates/egui_kittest/src/builder.rs +++ b/crates/egui_kittest/src/builder.rs @@ -4,6 +4,7 @@ use egui::{Pos2, Rect, Vec2}; use std::marker::PhantomData; /// Builder for [`Harness`]. +#[must_use] pub struct HarnessBuilder { pub(crate) screen_rect: Rect, pub(crate) pixels_per_point: f32, @@ -14,6 +15,9 @@ pub struct HarnessBuilder { pub(crate) state: PhantomData, pub(crate) renderer: Box, pub(crate) wait_for_pending_images: bool, + + #[cfg(feature = "snapshot")] + pub(crate) default_snapshot_options: crate::SnapshotOptions, } impl Default for HarnessBuilder { @@ -28,6 +32,9 @@ impl Default for HarnessBuilder { step_dt: 1.0 / 4.0, wait_for_pending_images: true, os: egui::os::OperatingSystem::Nix, + + #[cfg(feature = "snapshot")] + default_snapshot_options: crate::SnapshotOptions::default(), } } } @@ -56,6 +63,14 @@ impl HarnessBuilder { self } + /// Set the default options used for snapshot tests on this harness. + #[cfg(feature = "snapshot")] + #[inline] + pub fn with_options(mut self, options: crate::SnapshotOptions) -> Self { + self.default_snapshot_options = options; + self + } + /// Override the [`egui::os::OperatingSystem`] reported to egui. /// /// This affects e.g. the way shortcuts are displayed. So for snapshot tests, @@ -151,6 +166,7 @@ impl HarnessBuilder { /// /// assert_eq!(*harness.state(), true); /// ``` + #[track_caller] pub fn build_state<'a>( self, app: impl FnMut(&egui::Context, &mut State) + 'a, @@ -180,6 +196,7 @@ impl HarnessBuilder { /// /// assert_eq!(*harness.state(), true); /// ``` + #[track_caller] pub fn build_ui_state<'a>( self, app: impl FnMut(&mut egui::Ui, &mut State) + 'a, @@ -191,6 +208,7 @@ impl HarnessBuilder { /// Create a new [Harness] from the given eframe creation closure. /// The app can be accessed via the [`Harness::state`] / [`Harness::state_mut`] methods. #[cfg(feature = "eframe")] + #[track_caller] pub fn build_eframe<'a>( self, build: impl FnOnce(&mut eframe::CreationContext<'a>) -> State, @@ -232,6 +250,7 @@ impl HarnessBuilder { /// }); /// ``` #[must_use] + #[track_caller] pub fn build<'a>(self, app: impl FnMut(&egui::Context) + 'a) -> Harness<'a> { Harness::from_builder(self, AppKind::Context(Box::new(app)), (), None) } @@ -252,6 +271,7 @@ impl HarnessBuilder { /// }); /// ``` #[must_use] + #[track_caller] pub fn build_ui<'a>(self, app: impl FnMut(&mut egui::Ui) + 'a) -> Harness<'a> { Harness::from_builder(self, AppKind::Ui(Box::new(app)), (), None) } diff --git a/crates/egui_kittest/src/config.rs b/crates/egui_kittest/src/config.rs new file mode 100644 index 000000000..2565ceabf --- /dev/null +++ b/crates/egui_kittest/src/config.rs @@ -0,0 +1,154 @@ +use std::io; +use std::path::PathBuf; + +/// Configuration for `egui_kittest`. +/// +/// It's loaded once (per process) by searching for a `kittest.toml` file in the project root +/// (the directory containing `Cargo.lock`). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + /// The output path for image snapshots. + /// + /// Default is "tests/snapshots" (relative to the working directory / crate root). + output_path: PathBuf, + + /// The per-pixel threshold. + /// + /// Default is 0.6. + threshold: f32, + + /// The number of pixels that can differ before the test is considered failed. + /// + /// Default is 0. + failed_pixel_count_threshold: usize, + + windows: OsConfig, + mac: OsConfig, + linux: OsConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + output_path: PathBuf::from("tests/snapshots"), + threshold: 0.6, + failed_pixel_count_threshold: 0, + windows: Default::default(), + mac: Default::default(), + linux: Default::default(), + } + } +} +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +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, +} + +fn find_kittest_toml() -> io::Result { + let mut current_dir = std::env::current_dir()?; + + loop { + let current_kittest = current_dir.join("kittest.toml"); + // Check if Cargo.toml exists in this directory + if current_kittest.exists() { + return Ok(current_kittest); + } + + // Move up one directory + if !current_dir.pop() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "kittest.toml not found", + )); + } + } +} + +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()), + }, + Err(err) => { + panic!("Failed to read {}: {}", config_path.display(), err); + } + } + } else { + Config::default() + } +} + +/// Get the global configuration. +/// +/// See [`Config::global`] for details. +pub fn config() -> &'static Config { + Config::global() +} + +impl Config { + /// Get or load the global configuration. + /// + /// This is either + /// - Based on a `kittest.toml`, found by searching from the current working directory + /// (for tests that is the crate root) upwards. + /// - The default [Config], if no `kittest.toml` is found. + pub fn global() -> &'static Self { + static INSTANCE: std::sync::LazyLock = std::sync::LazyLock::new(load_config); + &INSTANCE + } + + /// The output path for image snapshots. + /// + /// Default is "tests/snapshots". + pub fn output_path(&self) -> PathBuf { + self.output_path.clone() + } +} + +#[cfg(feature = "snapshot")] +impl Config { + pub fn os_threshold(&self) -> crate::OsThreshold { + let fallback = self.threshold; + crate::OsThreshold { + windows: self.windows.threshold.unwrap_or(fallback), + macos: self.mac.threshold.unwrap_or(fallback), + linux: self.linux.threshold.unwrap_or(fallback), + fallback, + } + } + + pub fn os_failed_pixel_count_threshold(&self) -> crate::OsThreshold { + let fallback = self.failed_pixel_count_threshold; + 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), + fallback, + } + } + + /// The threshold. + /// + /// Default is 1.0. + pub fn threshold(&self) -> f32 { + self.os_threshold().threshold() + } + + /// The number of pixels that can differ before the test is considered failed. + /// + /// Default is 0. + pub fn failed_pixel_count_threshold(&self) -> usize { + self.os_failed_pixel_count_threshold().threshold() + } +} diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index e331119e3..71312c352 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -8,11 +8,10 @@ mod builder; mod snapshot; #[cfg(feature = "snapshot")] -pub use snapshot::*; -use std::fmt::{Debug, Display, Formatter}; -use std::time::Duration; +pub use crate::snapshot::*; mod app_kind; +mod config; mod node; mod renderer; #[cfg(feature = "wgpu")] @@ -20,19 +19,26 @@ mod texture_to_image; #[cfg(feature = "wgpu")] pub mod wgpu; -pub use kittest; +// re-exports: +pub use { + self::{builder::*, node::*, renderer::*}, + kittest, +}; + +use std::{ + fmt::{Debug, Display, Formatter}, + time::Duration, +}; + +use egui::{ + Color32, Key, Modifiers, PointerButton, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId, + epaint::{ClippedShape, RectShape}, + style::ScrollAnimation, +}; +use kittest::Queryable; use crate::app_kind::AppKind; -pub use builder::*; -pub use node::*; -pub use renderer::*; - -use egui::epaint::{ClippedShape, RectShape}; -use egui::style::ScrollAnimation; -use egui::{Color32, Key, Modifiers, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId}; -use kittest::Queryable; - #[derive(Debug, Clone)] pub struct ExceededMaxStepsError { pub max_steps: u64, @@ -75,6 +81,11 @@ pub struct Harness<'a, State = ()> { step_dt: f32, wait_for_pending_images: bool, queued_events: EventQueue, + + #[cfg(feature = "snapshot")] + default_snapshot_options: SnapshotOptions, + #[cfg(feature = "snapshot")] + snapshot_results: SnapshotResults, } impl Debug for Harness<'_, State> { @@ -84,6 +95,7 @@ impl Debug for Harness<'_, State> { } impl<'a, State> Harness<'a, State> { + #[track_caller] pub(crate) fn from_builder( builder: HarnessBuilder, mut app: AppKind<'a, State>, @@ -100,6 +112,9 @@ impl<'a, State> Harness<'a, State> { state: _, mut renderer, wait_for_pending_images, + + #[cfg(feature = "snapshot")] + default_snapshot_options, } = builder; let ctx = ctx.unwrap_or_default(); ctx.set_theme(theme); @@ -147,6 +162,12 @@ impl<'a, State> Harness<'a, State> { step_dt, wait_for_pending_images, queued_events: Default::default(), + + #[cfg(feature = "snapshot")] + default_snapshot_options, + + #[cfg(feature = "snapshot")] + snapshot_results: SnapshotResults::default(), }; // Run the harness until it is stable, ensuring that all Areas are shown and animations are done harness.run_ok(); @@ -182,6 +203,7 @@ impl<'a, State> Harness<'a, State> { /// /// assert_eq!(*harness.state(), true); /// ``` + #[track_caller] pub fn new_state(app: impl FnMut(&egui::Context, &mut State) + 'a, state: State) -> Self { Self::builder().build_state(app, state) } @@ -207,12 +229,14 @@ impl<'a, State> Harness<'a, State> { /// /// assert_eq!(*harness.state(), true); /// ``` + #[track_caller] pub fn new_ui_state(app: impl FnMut(&mut egui::Ui, &mut State) + 'a, state: State) -> Self { Self::builder().build_ui_state(app, state) } /// Create a new [Harness] from the given eframe creation closure. #[cfg(feature = "eframe")] + #[track_caller] pub fn new_eframe(builder: impl FnOnce(&mut eframe::CreationContext<'a>) -> State) -> Self where State: eframe::App, @@ -589,6 +613,32 @@ impl<'a, State> Harness<'a, State> { self.key_combination_modifiers(modifiers, &[key]); } + /// Move mouse cursor to this position. + pub fn hover_at(&self, pos: egui::Pos2) { + self.event(egui::Event::PointerMoved(pos)); + } + + /// Start dragging from a position. + pub fn drag_at(&self, pos: egui::Pos2) { + self.event(egui::Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: true, + modifiers: Modifiers::NONE, + }); + } + + /// Stop dragging and remove cursor. + pub fn drop_at(&self, pos: egui::Pos2) { + self.event(egui::Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + self.remove_cursor(); + } + /// Remove the cursor from the screen. /// /// Will fire a [`egui::Event::PointerGone`] event. @@ -617,7 +667,28 @@ impl<'a, State> Harness<'a, State> { /// Returns an error if the rendering fails. #[cfg(any(feature = "wgpu", feature = "snapshot"))] pub fn render(&mut self) -> Result { - self.renderer.render(&self.ctx, &self.output) + let mut output = self.output.clone(); + + if let Some(mouse_pos) = self.ctx.input(|i| i.pointer.hover_pos()) { + // Paint a mouse cursor: + let triangle = vec![ + mouse_pos, + mouse_pos + egui::vec2(16.0, 8.0), + mouse_pos + egui::vec2(8.0, 16.0), + ]; + + output.shapes.push(ClippedShape { + clip_rect: self.ctx.content_rect(), + shape: egui::epaint::PathShape::convex_polygon( + triangle, + Color32::WHITE, + egui::Stroke::new(1.0, Color32::BLACK), + ) + .into(), + }); + } + + self.renderer.render(&self.ctx, &output) } /// Get the root viewport output @@ -663,6 +734,7 @@ impl<'a> Harness<'a> { /// }); /// }); /// ``` + #[track_caller] pub fn new(app: impl FnMut(&egui::Context) + 'a) -> Self { Self::builder().build(app) } @@ -683,6 +755,7 @@ impl<'a> Harness<'a> { /// ui.label("Hello, world!"); /// }); /// ``` + #[track_caller] pub fn new_ui(app: impl FnMut(&mut egui::Ui) + 'a) -> Self { Self::builder().build_ui(app) } diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index c11533206..4d139fcbb 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -1,27 +1,35 @@ -use crate::Harness; -use image::ImageError; use std::fmt::Display; use std::io::ErrorKind; use std::path::PathBuf; +use image::ImageError; + +use crate::{Harness, config::config}; + pub type SnapshotResult = Result<(), SnapshotError>; #[non_exhaustive] +#[derive(Clone, Debug)] pub struct SnapshotOptions { /// The threshold for the image comparison. - /// The default is `0.6` (which is enough for most egui tests to pass across different - /// wgpu backends). + /// + /// 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. + /// /// Preferably, you should use `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. - /// If `None`, the default is `0` (meaning no pixels can differ). - /// If `Some`, the value can be set per OS + /// Can be configured via kittest.toml. The fallback is `0` (meaning no pixels can differ). pub failed_pixel_count_threshold: usize, /// The path where the snapshots will be saved. - /// The default is `tests/snapshots`. + /// + /// This is relative to the current working directory (usually the crate root when + /// running tests). + /// + /// Can be configured via kittest.toml. The fallback is `tests/snapshots`. pub output_path: PathBuf, } @@ -29,7 +37,9 @@ pub struct SnapshotOptions { /// /// This is useful if you want to set different thresholds for different operating systems. /// -/// The default values are 0 / 0.0 +/// [`OsThreshold::default`] gets the default from the config file (`kittest.toml`). +/// For `usize`, it's the `failed_pixel_count_threshold` value. +/// For `f32`, it's the `threshold` value. /// /// Example usage: /// ```no_run @@ -52,12 +62,36 @@ pub struct OsThreshold { pub fallback: T, } +impl Default for OsThreshold { + /// Returns the default `failed_pixel_count_threshold` as configured in `kittest.toml` + /// + /// The fallback is `0`. + fn default() -> Self { + config().os_failed_pixel_count_threshold() + } +} + +impl Default for OsThreshold { + /// Returns the default `threshold` as configured in `kittest.toml` + /// + /// The fallback is `0.6`. + fn default() -> Self { + config().os_threshold() + } +} + impl From for OsThreshold { fn from(value: usize) -> Self { Self::new(value) } } +impl From for OsThreshold { + fn from(value: f32) -> Self { + Self::new(value) + } +} + impl OsThreshold where T: Copy, @@ -122,9 +156,9 @@ impl From> for f32 { impl Default for SnapshotOptions { fn default() -> Self { Self { - threshold: 0.6, - output_path: PathBuf::from("tests/snapshots"), - failed_pixel_count_threshold: 0, // Default is 0, meaning no pixels can differ + threshold: config().threshold(), + output_path: config().output_path(), + failed_pixel_count_threshold: config().failed_pixel_count_threshold(), } } } @@ -230,7 +264,8 @@ impl Display for SnapshotError { diff, diff_path, } => { - let diff_path = std::path::absolute(diff_path).unwrap_or(diff_path.clone()); + 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}", @@ -238,7 +273,7 @@ impl Display for SnapshotError { ) } Self::OpenSnapshot { path, err } => { - let path = std::path::absolute(path).unwrap_or(path.clone()); + let path = std::path::absolute(path).unwrap_or_else(|_| path.clone()); match err { ImageError::IoError(io) => match io.kind() { ErrorKind::NotFound => { @@ -276,7 +311,7 @@ impl Display for SnapshotError { ) } Self::WriteSnapshot { path, err } => { - let path = std::path::absolute(path).unwrap_or(path.clone()); + let path = std::path::absolute(path).unwrap_or_else(|_| path.clone()); write!(f, "Error writing snapshot: {err}\nAt: {}", path.display()) } Self::RenderError { err } => { @@ -556,9 +591,17 @@ pub fn image_snapshot(current: &image::RgbaImage, name: impl Into) { #[cfg(any(feature = "wgpu", feature = "snapshot"))] impl Harness<'_, State> { + /// The default options used for snapshot tests. + /// set by [`crate::HarnessBuilder::with_options`]. + pub fn options(&self) -> &SnapshotOptions { + &self.default_snapshot_options + } + /// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot /// with custom options. /// + /// These options will override the ones set by [`crate::HarnessBuilder::with_options`]. + /// /// If you want to change the default options for your whole project, you could create an /// [extension trait](http://xion.io/post/code/rust-extension-traits.html) to create a /// new `my_image_snapshot` function on the Harness that calls this function with the desired options. @@ -586,6 +629,9 @@ impl Harness<'_, State> { } /// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot. + /// + /// This is like [`Self::try_snapshot_options`] but will use the options set by [`crate::HarnessBuilder::with_options`]. + /// /// The snapshot will be saved under `tests/snapshots/{name}.png`. /// The new image from the last test run will be saved under `tests/snapshots/{name}.new.png`. /// If the new image didn't match the snapshot, a diff image will be saved under `tests/snapshots/{name}.diff.png`. @@ -597,12 +643,14 @@ impl Harness<'_, State> { let image = self .render() .map_err(|err| SnapshotError::RenderError { err })?; - try_image_snapshot(&image, name) + try_image_snapshot_options(&image, name.into(), &self.default_snapshot_options) } /// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot /// with custom options. /// + /// These options will override the ones set by [`crate::HarnessBuilder::with_options`]. + /// /// If you want to change the default options for your whole project, you could create an /// [extension trait](http://xion.io/post/code/rust-extension-traits.html) to create a /// new `my_image_snapshot` function on the Harness that calls this function with the desired options. @@ -616,19 +664,22 @@ impl Harness<'_, State> { /// If the new image didn't match the snapshot, a diff image will be saved under `{output_path}/{name}.diff.png`. /// /// # Panics - /// Panics if the image does not match the snapshot, if there was an error reading or writing the + /// The result is added to the [`Harness`]'s internal [`SnapshotResults`]. + /// + /// The harness will panic when dropped if there were any snapshot errors. + /// + /// Errors happen if the image does not match the snapshot, if there was an error reading or writing the /// snapshot, if the rendering fails or if no default renderer is available. #[track_caller] pub fn snapshot_options(&mut self, name: impl Into, options: &SnapshotOptions) { - match self.try_snapshot_options(name, options) { - Ok(_) => {} - Err(err) => { - panic!("{err}"); - } - } + let result = self.try_snapshot_options(name, options); + self.snapshot_results.add(result); } /// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot. + /// + /// This is like [`Self::snapshot_options`] but will use the options set by [`crate::HarnessBuilder::with_options`]. + /// /// The snapshot will be saved under `tests/snapshots/{name}.png`. /// The new image from the last test run will be saved under `tests/snapshots/{name}.new.png`. /// If the new image didn't match the snapshot, a diff image will be saved under `tests/snapshots/{name}.diff.png`. @@ -638,12 +689,8 @@ impl Harness<'_, State> { /// snapshot, if the rendering fails or if no default renderer is available. #[track_caller] pub fn snapshot(&mut self, name: impl Into) { - match self.try_snapshot(name) { - Ok(_) => {} - Err(err) => { - panic!("{err}"); - } - } + let result = self.try_snapshot(name); + self.snapshot_results.add(result); } /// Render a snapshot, save it to a temp file and open it in the default image viewer. @@ -689,6 +736,12 @@ 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) + } } /// Utility to collect snapshot errors and display them at the end of the test. @@ -715,9 +768,22 @@ impl Harness<'_, State> { /// Panics if there are any errors when dropped (this way it is impossible to forget to call `unwrap`). /// If you don't want to panic, you can use [`SnapshotResults::into_result`] or [`SnapshotResults::into_inner`]. /// If you want to panic early, you can use [`SnapshotResults::unwrap`]. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct SnapshotResults { errors: Vec, + handled: bool, + location: std::panic::Location<'static>, +} + +impl Default for SnapshotResults { + #[track_caller] + fn default() -> Self { + Self { + errors: Vec::new(), + handled: true, // If no snapshots were added, we should consider this handled. + location: *std::panic::Location::caller(), + } + } } impl Display for SnapshotResults { @@ -735,17 +801,30 @@ impl Display for SnapshotResults { } impl SnapshotResults { + #[track_caller] pub fn new() -> Self { Default::default() } /// Check if the result is an error and add it to the list of errors. pub fn add(&mut self, result: SnapshotResult) { + self.handled = false; if let Err(err) = result { self.errors.push(err); } } + /// Add all errors from another `SnapshotResults`. + pub fn extend(&mut self, other: Self) { + self.handled = false; + self.errors.extend(other.into_inner()); + } + + /// Add all errors from a [`Harness`]. + pub fn extend_harness(&mut self, harness: &mut Harness<'_, T>) { + self.extend(harness.take_snapshot_results()); + } + /// Check if there are any errors. pub fn has_errors(&self) -> bool { !self.errors.is_empty() @@ -757,13 +836,14 @@ impl SnapshotResults { if self.has_errors() { Err(self) } else { Ok(()) } } + /// Consume this and return the list of errors. pub fn into_inner(mut self) -> Vec { + self.handled = true; std::mem::take(&mut self.errors) } /// Panics if there are any errors, displaying each. #[expect(clippy::unused_self)] - #[track_caller] pub fn unwrap(self) { // Panic is handled in drop } @@ -776,7 +856,6 @@ impl From for Vec { } impl Drop for SnapshotResults { - #[track_caller] fn drop(&mut self) { // Don't panic if we are already panicking (the test probably failed for another reason) if std::thread::panicking() { @@ -786,5 +865,32 @@ impl Drop for SnapshotResults { if self.has_errors() { panic!("{}", self); } + + thread_local! { + static UNHANDLED_SNAPSHOT_RESULTS_COUNTER: std::cell::RefCell = const { std::cell::RefCell::new(0) }; + } + + if !self.handled { + let count = UNHANDLED_SNAPSHOT_RESULTS_COUNTER.with(|counter| { + let mut count = counter.borrow_mut(); + *count += 1; + *count + }); + + #[expect(clippy::manual_assert)] + if count >= 2 { + panic!( + r#" +Multiple SnapshotResults were dropped without being handled. + +In order to allow consistent snapshot updates, all snapshot results within a test should be merged in a single SnapshotResults instance. +Usually this is handled internally in a harness. If you have multiple harnesses, you can merge the results using `Harness::take_snapshot_results` and `SnapshotResults::extend`. + +The SnapshotResult was constructed at {} + "#, + self.location + ); + } + } } } diff --git a/crates/egui_kittest/src/wgpu.rs b/crates/egui_kittest/src/wgpu.rs index ae773095d..3f97e0036 100644 --- a/crates/egui_kittest/src/wgpu.rs +++ b/crates/egui_kittest/src/wgpu.rs @@ -51,7 +51,7 @@ pub fn default_wgpu_setup() -> egui_wgpu::WgpuSetup { adapters .first() .map(|a| (*a).clone()) - .ok_or("No adapter found".to_owned()) + .ok_or_else(|| "No adapter found".to_owned()) })); egui_wgpu::WgpuSetup::CreateNew(setup) diff --git a/crates/egui_kittest/tests/snapshots/combobox_opened.png b/crates/egui_kittest/tests/snapshots/combobox_opened.png index aaa7198ce..e45a4aed3 100644 --- a/crates/egui_kittest/tests/snapshots/combobox_opened.png +++ b/crates/egui_kittest/tests/snapshots/combobox_opened.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f70ef032c241cd63675a246de07886c5c822e6fe21525b3a6d3fee106a589c9 -size 7501 +oid sha256:42911cbb500fa49170aac0da8e4167641c5d7c9724a6accd4d400258fc74e2d7 +size 8061 diff --git a/crates/egui_kittest/tests/snapshots/menu/closed_hovered.png b/crates/egui_kittest/tests/snapshots/menu/closed_hovered.png index d2969adee..c30b3fdd1 100644 --- a/crates/egui_kittest/tests/snapshots/menu/closed_hovered.png +++ b/crates/egui_kittest/tests/snapshots/menu/closed_hovered.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd6e159a462dde10240c4ca51da5ca5badfb7fc170bad97a59106babb72f8ae3 -size 10795 +oid sha256:94ba2e648c981bf4afbd9b9d01eef0708f7067be6e4cefbdfacc13aa219c6289 +size 11253 diff --git a/crates/egui_kittest/tests/snapshots/menu/opened.png b/crates/egui_kittest/tests/snapshots/menu/opened.png index 30f26b446..7a2750454 100644 --- a/crates/egui_kittest/tests/snapshots/menu/opened.png +++ b/crates/egui_kittest/tests/snapshots/menu/opened.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f2a5873350f85457d599c1fd165ac756ed69758e7647e160c64f44d2f35c804 -size 21812 +oid sha256:436999f511dce318f29172f0b7e2007e1f0fedae58f5e0e85e19f1d8e0bee361 +size 22273 diff --git a/crates/egui_kittest/tests/snapshots/menu/submenu.png b/crates/egui_kittest/tests/snapshots/menu/submenu.png index 96ffaf97c..25453c8d9 100644 --- a/crates/egui_kittest/tests/snapshots/menu/submenu.png +++ b/crates/egui_kittest/tests/snapshots/menu/submenu.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:facc05c499745594ac286f15645e40447633a176058337cad9edcb850ad578c7 -size 29379 +oid sha256:28435faf5c8c6d880cd50d52050c9f4cd6b992d0c621f01ca28fb5502eed16a1 +size 29863 diff --git a/crates/egui_kittest/tests/snapshots/menu/subsubmenu.png b/crates/egui_kittest/tests/snapshots/menu/subsubmenu.png index d1d0b4cd3..c22c2b9b6 100644 --- a/crates/egui_kittest/tests/snapshots/menu/subsubmenu.png +++ b/crates/egui_kittest/tests/snapshots/menu/subsubmenu.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f23ff8c6782befdbe7bd5f076dcdda15c38555f8e505282369bf52e43938c1b -size 34194 +oid sha256:f9a364b4b8c4ad3e78a80b0c6825d9de28c0e0d2e18dcfcd0ff18652ca86c859 +size 34750 diff --git a/crates/egui_kittest/tests/snapshots/readme_example.png b/crates/egui_kittest/tests/snapshots/readme_example.png index 2c8565718..f58e6faec 100644 --- a/crates/egui_kittest/tests/snapshots/readme_example.png +++ b/crates/egui_kittest/tests/snapshots/readme_example.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb8d702361987803995c0f557ce94552a87b97dcd25bed5ee39af4c0e6090700 -size 1904 +oid sha256:87c76a9d07174e4e24ad3d08585c1df7bf3628bdc8f183d11beb6f9e14c4b2ec +size 2325 diff --git a/crates/egui_kittest/tests/snapshots/test_tooltip_shown.png b/crates/egui_kittest/tests/snapshots/test_tooltip_shown.png index 8ff6bba67..4d00c924a 100644 --- a/crates/egui_kittest/tests/snapshots/test_tooltip_shown.png +++ b/crates/egui_kittest/tests/snapshots/test_tooltip_shown.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c267530452adb4f1ed1440df476d576ef4c2d96e6c58068bb57fed4615f5e113 -size 4453 +oid sha256:e269ede9c0784d00c153d51a13566d9c8f0d61ce11565997691fa63be06ec889 +size 5075 diff --git a/crates/emath/CHANGELOG.md b/crates/emath/CHANGELOG.md index b7e0fec1f..334af345f 100644 --- a/crates/emath/CHANGELOG.md +++ b/crates/emath/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to the `emath` 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.33.2 - 2025-11-13 +* Fix edge cases in "smart aiming" in sliders [#7680](https://github.com/emilk/egui/pull/7680) by [@emilk](https://github.com/emilk) + + ## 0.33.0 - 2025-10-09 * Add `emath::fast_midpoint` [#7435](https://github.com/emilk/egui/pull/7435) by [@emilk](https://github.com/emilk) * Generate changelogs for emath [#7513](https://github.com/emilk/egui/pull/7513) by [@lucasmerlin](https://github.com/lucasmerlin) diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index b46fc43ca..81729713b 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -449,7 +449,8 @@ impl Rect { /// Linearly interpolate so that `[0, 0]` is [`Self::min`] and /// `[1, 1]` is [`Self::max`]. #[inline] - pub fn lerp_inside(&self, t: Vec2) -> Pos2 { + pub fn lerp_inside(&self, t: impl Into) -> Pos2 { + let t = t.into(); Pos2 { x: lerp(self.min.x..=self.max.x, t.x), y: lerp(self.min.y..=self.max.y, t.y), diff --git a/crates/emath/src/smart_aim.rs b/crates/emath/src/smart_aim.rs index ebcd68321..c1b96ec7b 100644 --- a/crates/emath/src/smart_aim.rs +++ b/crates/emath/src/smart_aim.rs @@ -31,6 +31,8 @@ pub fn best_in_range_f64(min: f64, max: f64) -> f64 { return -best_in_range_f64(-max, -min); } + debug_assert!(0.0 < min && min < max, "Logic bug"); + // Prefer finite numbers: if !max.is_finite() { return min; @@ -44,7 +46,8 @@ pub fn best_in_range_f64(min: f64, max: f64) -> f64 { let max_exponent = max.log10(); if min_exponent.floor() != max_exponent.floor() { - // pick the geometric center of the two: + // Different orders of magnitude. + // Pick the geometric center of the two: let exponent = fast_midpoint(min_exponent, max_exponent); return 10.0_f64.powi(exponent.round() as i32); } @@ -56,65 +59,85 @@ pub fn best_in_range_f64(min: f64, max: f64) -> f64 { return 10.0_f64.powf(max_exponent); } - let exp_factor = 10.0_f64.powi(max_exponent.floor() as i32); + // Find the proper scale, and then convert to integers: - let min_str = to_decimal_string(min / exp_factor); - let max_str = to_decimal_string(max / exp_factor); + let scale = NUM_DECIMALS as i32 - max_exponent.floor() as i32 - 1; + let scale_factor = 10.0_f64.powi(scale); + + let min_str = to_decimal_string((min * scale_factor).round() as u64); + let max_str = to_decimal_string((max * scale_factor).round() as u64); + + // We now have two positive integers of the same length. + // We want to find the first non-matching digit, + // which we will call the "deciding digit". + // Everything before it will be the same, + // everything after will be zero, + // and the deciding digit itself will be picked as a "smart average" + // min: 12345 + // max: 12780 + // output: 12500 let mut ret_str = [0; NUM_DECIMALS]; - // Select the common prefix: - let mut i = 0; - while i < NUM_DECIMALS && max_str[i] == min_str[i] { - ret_str[i] = max_str[i]; - i += 1; + for i in 0..NUM_DECIMALS { + if min_str[i] == max_str[i] { + ret_str[i] = min_str[i]; + } else { + // Found the deciding digit at index `i` + let mut deciding_digit_min = min_str[i]; + let deciding_digit_max = max_str[i]; + + debug_assert!( + deciding_digit_min < deciding_digit_max, + "Bug in smart aim code" + ); + + let rest_of_min_is_zeroes = min_str[i + 1..].iter().all(|&c| c == 0); + + if !rest_of_min_is_zeroes { + // There are more digits coming after `deciding_digit_min`, so we cannot pick it. + // So the true min of what we can pick is one greater: + deciding_digit_min += 1; + } + + let deciding_digit = if deciding_digit_min == 0 { + 0 + } else if deciding_digit_min <= 5 && 5 <= deciding_digit_max { + 5 // 5 is the roundest number in the range + } else { + deciding_digit_min.midpoint(deciding_digit_max) + }; + + ret_str[i] = deciding_digit; + + return from_decimal_string(ret_str) as f64 / scale_factor; + } } - if i < NUM_DECIMALS { - // Pick the deciding digit. - // Note that "to_decimal_string" rounds down, so we that's why we add 1 here - ret_str[i] = simplest_digit_closed_range(min_str[i] + 1, max_str[i]); - } - - from_decimal_string(&ret_str) * exp_factor + min // All digits are the same. Already handled earlier, but better safe than sorry } fn is_integer(f: f64) -> bool { f.round() == f } -fn to_decimal_string(v: f64) -> [i32; NUM_DECIMALS] { - debug_assert!(v < 10.0, "{v:?}"); - let mut digits = [0; NUM_DECIMALS]; - let mut v = v.abs(); - for r in &mut digits { - let digit = v.floor(); - *r = digit as i32; - v -= digit; - v *= 10.0; - } - digits -} - -fn from_decimal_string(s: &[i32]) -> f64 { - let mut ret: f64 = 0.0; - for (i, &digit) in s.iter().enumerate() { - ret += (digit as f64) * 10.0_f64.powi(-(i as i32)); +fn to_decimal_string(v: u64) -> [u8; NUM_DECIMALS] { + let mut ret = [0; NUM_DECIMALS]; + let mut value = v; + for i in (0..NUM_DECIMALS).rev() { + ret[i] = (value % 10) as u8; + value /= 10; } ret } -/// Find the simplest integer in the range [min, max] -fn simplest_digit_closed_range(min: i32, max: i32) -> i32 { - debug_assert!( - 1 <= min && min <= max && max <= 9, - "min should be in [1, 9], but was {min:?} and max should be in [min, 9], but was {max:?}" - ); - if min <= 5 && 5 <= max { - 5 - } else { - min.midpoint(max) +fn from_decimal_string(s: [u8; NUM_DECIMALS]) -> u64 { + let mut value = 0; + for &c in &s { + debug_assert!(c <= 9, "Bad number"); + value = value * 10 + c as u64; } + value } #[expect(clippy::approx_constant)] @@ -161,4 +184,53 @@ fn test_aim() { assert_eq!(best_in_range_f64(NEG_INFINITY, NEG_INFINITY), NEG_INFINITY); assert_eq!(best_in_range_f64(NEG_INFINITY, INFINITY), 0.0); assert_eq!(best_in_range_f64(INFINITY, NEG_INFINITY), 0.0); + + #[track_caller] + fn test_f64((min, max): (f64, f64), expected: f64) { + let aimed = best_in_range_f64(min, max); + assert!( + aimed == expected, + "smart_aim({min} – {max}) => {aimed}, but expected {expected}" + ); + } + #[track_caller] + fn test_i64((min, max): (i64, i64), expected: i64) { + let aimed = best_in_range_f64(min as _, max as _); + assert!( + aimed == expected as f64, + "smart_aim({min} – {max}) => {aimed}, but expected {expected}" + ); + } + + test_i64((99, 300), 100); + test_i64((300, 99), 100); + test_i64((-99, -300), -100); + test_i64((-99, 123), 0); // Prefer zero + test_i64((4, 9), 5); // Prefer ending on 5 + test_i64((14, 19), 15); // Prefer ending on 5 + test_i64((12, 65), 50); // Prefer leading 5 + test_i64((493, 879), 500); // Prefer leading 5 + test_i64((37, 48), 40); + test_i64((100, 123), 100); + test_i64((101, 1000), 1000); + test_i64((999, 1000), 1000); + test_i64((123, 500), 500); + test_i64((500, 777), 500); + test_i64((500, 999), 500); + test_i64((12345, 12780), 12500); + test_i64((12371, 12376), 12375); + test_i64((12371, 12376), 12375); + + test_f64((7.5, 16.3), 10.0); + test_f64((7.5, 76.3), 10.0); + test_f64((7.5, 763.3), 100.0); + test_f64((7.5, 1_345.0), 100.0); // Geometric mean + test_f64((7.5, 123_456.0), 1_000.0); // Geometric mean + test_f64((-0.2, 0.0), 0.0); // Prefer zero + test_f64((-10_004.23, 4.14), 0.0); // Prefer zero + test_f64((-0.2, 100.0), 0.0); // Prefer zero + test_f64((0.2, 0.0), 0.0); // Prefer zero + test_f64((7.8, 17.8), 10.0); + test_f64((14.1, 19.1), 15.0); // Prefer ending on 5 + test_f64((12.3, 65.9), 50.0); // Prefer leading 5 } diff --git a/crates/epaint/CHANGELOG.md b/crates/epaint/CHANGELOG.md index 0524b87c0..b23c454b4 100644 --- a/crates/epaint/CHANGELOG.md +++ b/crates/epaint/CHANGELOG.md @@ -5,6 +5,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.33.2 - 2025-11-13 +Nothing new + + ## 0.33.0 - 2025-10-09 * Remove the `deadlock_detection` feature [#7497](https://github.com/emilk/egui/pull/7497) by [@lucasmerlin](https://github.com/lucasmerlin) * More even text kerning [#7431](https://github.com/emilk/egui/pull/7431) by [@valadaptive](https://github.com/valadaptive) diff --git a/crates/epaint/src/shapes/bezier_shape.rs b/crates/epaint/src/shapes/bezier_shape.rs index 20f7a4e18..002612dbb 100644 --- a/crates/epaint/src/shapes/bezier_shape.rs +++ b/crates/epaint/src/shapes/bezier_shape.rs @@ -298,7 +298,8 @@ impl CubicBezierShape { /// the number of points is determined by the tolerance. /// the points may not be evenly distributed in the range [0.0,1.0] (t value) pub fn flatten(&self, tolerance: Option) -> Vec { - let tolerance = tolerance.unwrap_or((self.points[0].x - self.points[3].x).abs() * 0.001); + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[3].x).abs() * 0.001); let mut result = vec![self.points[0]]; self.for_each_flattened_with_t(tolerance, &mut |p, _t| { result.push(p); @@ -313,7 +314,8 @@ impl CubicBezierShape { /// The result will be a vec of vec of Pos2. it will store two closed aren in different vec. /// The epsilon is used to compare a float value. pub fn flatten_closed(&self, tolerance: Option, epsilon: Option) -> Vec> { - let tolerance = tolerance.unwrap_or((self.points[0].x - self.points[3].x).abs() * 0.001); + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[3].x).abs() * 0.001); let epsilon = epsilon.unwrap_or(1.0e-5); let mut result = Vec::new(); let mut first_half = Vec::new(); @@ -519,7 +521,8 @@ impl QuadraticBezierShape { /// the number of points is determined by the tolerance. /// the points may not be evenly distributed in the range [0.0,1.0] (t value) pub fn flatten(&self, tolerance: Option) -> Vec { - let tolerance = tolerance.unwrap_or((self.points[0].x - self.points[2].x).abs() * 0.001); + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[2].x).abs() * 0.001); let mut result = vec![self.points[0]]; self.for_each_flattened_with_t(tolerance, &mut |p, _t| { result.push(p); diff --git a/crates/epaint/src/shapes/shape.rs b/crates/epaint/src/shapes/shape.rs index 8ee852c61..fa8a3e75c 100644 --- a/crates/epaint/src/shapes/shape.rs +++ b/crates/epaint/src/shapes/shape.rs @@ -30,7 +30,7 @@ pub enum Shape { /// Recursively nest more shapes - sometimes a convenience to be able to do. /// For performance reasons it is better to avoid it. - Vec(Vec), + Vec(Vec), /// Circle with optional outline and fill. Circle(CircleShape), diff --git a/crates/epaint/src/shapes/text_shape.rs b/crates/epaint/src/shapes/text_shape.rs index 349707eac..92a0a0514 100644 --- a/crates/epaint/src/shapes/text_shape.rs +++ b/crates/epaint/src/shapes/text_shape.rs @@ -137,7 +137,12 @@ impl TextShape { *mesh_bounds = transform.scaling * *mesh_bounds; *intrinsic_size = transform.scaling * *intrinsic_size; - for text::PlacedRow { pos, row } in rows { + for text::PlacedRow { + pos, + row, + ends_with_newline: _, + } in rows + { *pos *= transform.scaling; let text::Row { @@ -145,7 +150,6 @@ impl TextShape { glyphs: _, // TODO(emilk): would it make sense to transform these? size, visuals, - ends_with_newline: _, } = Arc::make_mut(row); *size *= transform.scaling; diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index cf791351a..1db56731d 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -230,6 +230,7 @@ fn layout_section( font_ascent: font_metrics.ascent, uv_rect: glyph_alloc.uv_rect, section_index, + first_vertex: 0, // filled in later }); paragraph.cursor_x_px += glyph_alloc.advance_width_px; @@ -296,8 +297,8 @@ fn rows_from_paragraphs( glyphs: vec![], visuals: Default::default(), size: vec2(0.0, paragraph.empty_paragraph_height), - ends_with_newline: !is_last_paragraph, }), + ends_with_newline: !is_last_paragraph, }); } else { let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x(); @@ -310,14 +311,13 @@ fn rows_from_paragraphs( glyphs: paragraph.glyphs, visuals: Default::default(), size: vec2(paragraph_max_x, 0.0), - ends_with_newline: !is_last_paragraph, }), + ends_with_newline: !is_last_paragraph, }); } else { line_break(¶graph, job, &mut rows, elided); let placed_row = rows.last_mut().unwrap(); - let row = Arc::make_mut(&mut placed_row.row); - row.ends_with_newline = !is_last_paragraph; + placed_row.ends_with_newline = !is_last_paragraph; } } } @@ -363,8 +363,8 @@ fn line_break( glyphs: vec![], visuals: Default::default(), size: Vec2::ZERO, - ends_with_newline: false, }), + ends_with_newline: false, }); row_start_x += first_row_indentation; first_row_indentation = 0.0; @@ -389,8 +389,8 @@ fn line_break( glyphs, visuals: Default::default(), size: vec2(paragraph_max_x, 0.0), - ends_with_newline: false, }), + ends_with_newline: false, }); // Start a new row: @@ -431,8 +431,8 @@ fn line_break( glyphs, visuals: Default::default(), size: vec2(paragraph_max_x - paragraph_min_x, 0.0), - ends_with_newline: false, }), + ends_with_newline: false, }); } } @@ -532,6 +532,7 @@ fn replace_last_glyph_with_overflow_character( font_ascent: font_metrics.ascent, uv_rect: replacement_glyph_alloc.uv_rect, section_index, + first_vertex: 0, // filled in later }); return; } @@ -749,7 +750,7 @@ fn tessellate_row( point_scale: PointScale, job: &LayoutJob, format_summary: &FormatSummary, - row: &Row, + row: &mut Row, ) -> RowVisuals { if row.glyphs.is_empty() { return Default::default(); @@ -844,8 +845,9 @@ fn add_row_backgrounds(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh end_run(run_start.take(), last_rect.right()); } -fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh: &mut Mesh) { - for glyph in &row.glyphs { +fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &mut Row, mesh: &mut Mesh) { + for glyph in &mut row.glyphs { + glyph.first_vertex = mesh.vertices.len() as u32; let uv_rect = glyph.uv_rect; if !uv_rect.is_nothing() { let mut left_top = glyph.pos + uv_rect.offset; diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 1adcc515e..f3963394a 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -572,6 +572,13 @@ pub struct PlacedRow { /// The underlying unpositioned [`Row`]. pub row: Arc, + + /// If true, this [`PlacedRow`] came from a paragraph ending with a `\n`. + /// The `\n` itself is omitted from row's [`Row::glyphs`]. + /// A `\n` in the input text always creates a new [`PlacedRow`] below it, + /// so that text that ends with `\n` has an empty [`PlacedRow`] last. + /// This also implies that the last [`PlacedRow`] in a [`Galley`] always has `ends_with_newline == false`. + pub ends_with_newline: bool, } impl PlacedRow { @@ -617,13 +624,6 @@ pub struct Row { /// The mesh, ready to be rendered. pub visuals: RowVisuals, - - /// If true, this [`Row`] came from a paragraph ending with a `\n`. - /// The `\n` itself is omitted from [`Self::glyphs`]. - /// A `\n` in the input text always creates a new [`Row`] below it, - /// so that text that ends with `\n` has an empty [`Row`] last. - /// This also implies that the last [`Row`] in a [`Galley`] always has `ends_with_newline == false`. - pub ends_with_newline: bool, } /// The tessellated output of a row. @@ -701,6 +701,9 @@ pub struct Glyph { /// enable the paragraph-concat optimization path without having to /// adjust `section_index` when concatting. pub(crate) section_index: u32, + + /// Which is our first vertex in [`RowVisuals::mesh`]. + pub first_vertex: u32, } impl Glyph { @@ -735,12 +738,6 @@ impl Row { self.glyphs.len() } - /// Includes the implicit `\n` after the [`Row`], if any. - #[inline] - pub fn char_count_including_newline(&self) -> usize { - self.glyphs.len() + (self.ends_with_newline as usize) - } - /// Closest char at the desired x coordinate in row-relative coordinates. /// Returns something in the range `[0, char_count_excluding_newline()]`. pub fn char_at(&self, desired_x: f32) -> usize { @@ -776,6 +773,12 @@ impl PlacedRow { pub fn max_y(&self) -> f32 { self.rect().bottom() } + + /// Includes the implicit `\n` after the [`PlacedRow`], if any. + #[inline] + pub fn char_count_including_newline(&self) -> usize { + self.row.glyphs.len() + (self.ends_with_newline as usize) + } } impl Galley { @@ -867,13 +870,15 @@ impl Galley { placed_row.visuals.mesh_bounds.translate(new_pos.to_vec2()); merged_galley.rect |= Rect::from_min_size(new_pos, placed_row.size); - let mut row = placed_row.row.clone(); + let mut ends_with_newline = placed_row.ends_with_newline; let is_last_row_in_galley = row_idx + 1 == galley.rows.len(); - if !is_last_galley && is_last_row_in_galley { - // Since we remove the `\n` when splitting rows, we need to add it back here - Arc::make_mut(&mut row).ends_with_newline = true; + // Since we remove the `\n` when splitting rows, we need to add it back here + ends_with_newline |= !is_last_galley && is_last_row_in_galley; + super::PlacedRow { + pos: new_pos, + row: placed_row.row.clone(), + ends_with_newline, } - super::PlacedRow { pos: new_pos, row } })); merged_galley.num_vertices += galley.num_vertices; diff --git a/crates/epaint_default_fonts/CHANGELOG.md b/crates/epaint_default_fonts/CHANGELOG.md index bb39e4784..3f131cb55 100644 --- a/crates/epaint_default_fonts/CHANGELOG.md +++ b/crates/epaint_default_fonts/CHANGELOG.md @@ -5,6 +5,10 @@ This file is updated upon each release. Changes since the last release can be found at or by running the `scripts/generate_changelog.py` script. +## 0.33.2 - 2025-11-13 +Nothing new + + ## 0.33.0 - 2025-10-09 Nothing new diff --git a/examples/hello_android/src/lib.rs b/examples/hello_android/src/lib.rs index c138b97e1..87007c110 100644 --- a/examples/hello_android/src/lib.rs +++ b/examples/hello_android/src/lib.rs @@ -41,10 +41,12 @@ impl eframe::App for MyApp { // TODO(lucasmerlin): This is a pretty big hack, should be fixed once safe_area implemented // for android: // https://github.com/rust-windowing/winit/issues/3910 - egui::TopBottomPanel::top("status_bar_space").show(ctx, |ui| { + egui::Panel::top("status_bar_space").show(ctx, |ui| { ui.set_height(32.0); }); - self.demo.ui(ctx); + egui::CentralPanel::default().show(ctx, |ui| { + self.demo.ui(ui); + }); } } diff --git a/kittest.toml b/kittest.toml new file mode 100644 index 000000000..4c9076b66 --- /dev/null +++ b/kittest.toml @@ -0,0 +1,10 @@ +output_path = "tests/snapshots" + +# Other OSes get a higher threshold so they can still run tests locally without failures due to small rendering +# differences. +# To update snapshots, update them via ./scripts/update_snapshots_from_ci.sh or via kitdiff +threshold = 2.0 + +[mac] +# Since our CI runs snapshot tests on macOS, this is our source of truth. +threshold = 0.6 diff --git a/lychee.toml b/lychee.toml index 71f49b5e4..21e91f2ef 100644 --- a/lychee.toml +++ b/lychee.toml @@ -42,4 +42,5 @@ accept = [ # Exclude URLs and mail addresses from checking (supports regex). exclude = [ "https://creativecommons.org/.*", # They don't like bots + "https://www.unicode.org/.*", ] diff --git a/scripts/build_demo_web.sh b/scripts/build_demo_web.sh index b6eb7197a..4ba3846db 100755 --- a/scripts/build_demo_web.sh +++ b/scripts/build_demo_web.sh @@ -13,13 +13,13 @@ OPEN=false OPTIMIZE=false BUILD=debug BUILD_FLAGS="" -WGPU=false +GLOW=false WASM_OPT_FLAGS="-O2 --fast-math" while test $# -gt 0; do case "$1" in -h|--help) - echo "build_demo_web.sh [--release] [--wgpu] [--open]" + echo "build_demo_web.sh [--release] [--glow] [--open]" echo "" echo " -g: Keep debug symbols even with --release." echo " These are useful profiling and size trimming." @@ -29,9 +29,7 @@ while test $# -gt 0; do echo " --release: Build with --release, and then run wasm-opt." echo " NOTE: --release also removes debug symbols, unless you also use -g." echo "" - echo " --wgpu: Build a binary using wgpu instead of glow/webgl." - echo " The resulting binary will automatically use WebGPU if available and" - echo " fall back to a WebGL emulation layer otherwise." + echo " --glow: Build a binary using glow instead of wgpu." exit 0 ;; @@ -52,9 +50,9 @@ while test $# -gt 0; do BUILD_FLAGS="--release" ;; - --wgpu) + --glow) shift - WGPU=true + GLOW=true ;; *) @@ -66,10 +64,10 @@ done OUT_FILE_NAME="egui_demo_app" -if [[ "${WGPU}" == true ]]; then - FEATURES="${FEATURES},wgpu" -else +if [[ "${GLOW}" == true ]]; then FEATURES="${FEATURES},glow" +else + FEATURES="${FEATURES},wgpu" fi FINAL_WASM_PATH=web_demo/${OUT_FILE_NAME}_bg.wasm diff --git a/scripts/wasm_bindgen_check.sh b/scripts/wasm_bindgen_check.sh index 5f90c99c6..5043d98e0 100755 --- a/scripts/wasm_bindgen_check.sh +++ b/scripts/wasm_bindgen_check.sh @@ -12,7 +12,7 @@ else fi CRATE_NAME="egui_demo_app" -FEATURES="glow,http,persistence" +FEATURES="wgpu,http,persistence" echo "Building rust…" BUILD=debug # debug builds are faster diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index a407864e7..9e76394cb 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -1,5 +1,5 @@ use egui::accesskit::Role; -use egui::{Align, Color32, Image, Label, Layout, RichText, TextWrapMode, include_image}; +use egui::{Align, Color32, Image, Label, Layout, RichText, Sense, TextWrapMode, include_image}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable as _; @@ -61,3 +61,60 @@ fn text_edit_rtl() { harness.snapshot(format!("text_edit_rtl_{i}")); } } + +#[test] +fn combobox_should_have_value() { + let harness = Harness::new_ui(|ui| { + egui::ComboBox::from_label("Select an option") + .selected_text("Option 1") + .show_ui(ui, |_ui| {}); + }); + + assert_eq!( + harness.get_by_label("Select an option").value().as_deref(), + Some("Option 1") + ); +} + +/// This test ensures that `ui.response().interact(...)` works correctly. +/// +/// This was broken, because there was an optimization in [`egui::Response::interact`] +/// which caused the [`Sense`] of the original response to flip-flop between `click` and `hover` +/// between frames. +/// +/// See for more details. +#[test] +fn interact_on_ui_response_should_be_stable() { + let mut first_frame = true; + let mut click_count = 0; + let mut harness = Harness::new_ui(|ui| { + let ui_response = ui.response(); + if !first_frame { + assert!( + ui_response.sense.contains(Sense::click()), + "ui.response() didn't have click sense even though we called interact(Sense::click()) last frame" + ); + } + + // Add a label so we have something to click with kittest + ui.add( + Label::new("senseless label") + .sense(Sense::hover()) + .selectable(false), + ); + + let click_response = ui_response.interact(Sense::click()); + if click_response.clicked() { + click_count += 1; + } + first_frame = false; + }); + + for i in 0..=10 { + harness.run_steps(i); + harness.get_by_label("senseless label").click(); + } + + drop(harness); + assert_eq!(click_count, 10, "We missed some clicks!"); +} diff --git a/tests/egui_tests/tests/snapshots/button_shortcut.png b/tests/egui_tests/tests/snapshots/button_shortcut.png new file mode 100644 index 000000000..7f39196b8 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/button_shortcut.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5befd84158b582c79a968f36e43c7017187b364824eb4470b048d133e62f9360 +size 1600 diff --git a/tests/egui_tests/tests/snapshots/hovering_should_preserve_text_format.png b/tests/egui_tests/tests/snapshots/hovering_should_preserve_text_format.png index 2b3ac7a50..038ce78db 100644 --- a/tests/egui_tests/tests/snapshots/hovering_should_preserve_text_format.png +++ b/tests/egui_tests/tests/snapshots/hovering_should_preserve_text_format.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cac533a01c65c8eef093efcd4c9036da50f898ea2436612990f4c2365c98ad83 -size 12126 +oid sha256:c83e094b1f0dede0195cc77f5caa3b7d13249364612b03c02f0ef5f2af5e28ad +size 12512 diff --git a/tests/egui_tests/tests/snapshots/layout/text_edit_clip.png b/tests/egui_tests/tests/snapshots/layout/text_edit_clip.png new file mode 100644 index 000000000..0c4327b58 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/text_edit_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f107d95fee9a5fb5fbfd2422452e1820738a84c81774587dbfa8153e91e4c73 +size 414552 diff --git a/tests/egui_tests/tests/snapshots/layout/text_edit_no_clip.png b/tests/egui_tests/tests/snapshots/layout/text_edit_no_clip.png new file mode 100644 index 000000000..ecc6efa8b --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/text_edit_no_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c1aebada9349f8cb4046469b0a6f9796a21f88b6724bd85cd832a40b8007409 +size 540527 diff --git a/tests/egui_tests/tests/snapshots/layout/text_edit_placeholder_clip.png b/tests/egui_tests/tests/snapshots/layout/text_edit_placeholder_clip.png new file mode 100644 index 000000000..780fec82f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/text_edit_placeholder_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:685de2e33ff26aafa87426bcda18bb9963c2deb2a811cd0aae4450af0e245a06 +size 390735 diff --git a/tests/egui_tests/tests/snapshots/visuals/button.png b/tests/egui_tests/tests/snapshots/visuals/button.png index 4c81f62fc..4204dd1d3 100644 --- a/tests/egui_tests/tests/snapshots/visuals/button.png +++ b/tests/egui_tests/tests/snapshots/visuals/button.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c05992e16c1abf6d174fed73d19cad6bb2266e0adb87b8232e765d75fcf3f14 -size 10310 +oid sha256:6d0c3773bc3698fbd1bd1eb1aa1ed45938d5cb94696bfcec56e4e7e865871baf +size 11143 diff --git a/tests/egui_tests/tests/snapshots/visuals/button_image.png b/tests/egui_tests/tests/snapshots/visuals/button_image.png index 00582f3ae..5d1e74292 100644 --- a/tests/egui_tests/tests/snapshots/visuals/button_image.png +++ b/tests/egui_tests/tests/snapshots/visuals/button_image.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7681d33a5a764187c084c966a4e47063136e2832094c44f62718447b1b3027ec -size 11292 +oid sha256:9764ab5549e0775380b1db3c9a9a1d47c6520bcd5b8781f922e97e3524c362aa +size 12133 diff --git a/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut.png b/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut.png index 1429bfd2d..b2f5646d3 100644 --- a/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut.png +++ b/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b99a82e9f3dfa24c079545272d680b55c4285c276befa0efc492fe273422f541 -size 14195 +oid sha256:4d0c7d4b161f7a1f9cadb3e285edcd08588b9e47e10c5579183c824ae4e7be1b +size 15170 diff --git a/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut_selected.png b/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut_selected.png index c73effead..3f20c4379 100644 --- a/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut_selected.png +++ b/tests/egui_tests/tests/snapshots/visuals/button_image_shortcut_selected.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cdf079228b762949dbc67308103f8fe1328b6c0175f312ccc492d4e86d42127b -size 13868 +oid sha256:65359fcb0f01627876e697684b185c60812dd1591b0f42174673712939e2f193 +size 14852 diff --git a/tests/egui_tests/tests/snapshots/visuals/checkbox.png b/tests/egui_tests/tests/snapshots/visuals/checkbox.png index 16d88e546..2145ceee7 100644 --- a/tests/egui_tests/tests/snapshots/visuals/checkbox.png +++ b/tests/egui_tests/tests/snapshots/visuals/checkbox.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bafe4c157696bfb52940b69501416d4da0b4eab52f34f52220d2e9ed01357cf -size 12901 +oid sha256:68347d7eb452a6f30fa93778f9ebd17f20c1425426472d3ebe4c8b55fc0ba8ea +size 13774 diff --git a/tests/egui_tests/tests/snapshots/visuals/checkbox_checked.png b/tests/egui_tests/tests/snapshots/visuals/checkbox_checked.png index fd85297ac..ec012113b 100644 --- a/tests/egui_tests/tests/snapshots/visuals/checkbox_checked.png +++ b/tests/egui_tests/tests/snapshots/visuals/checkbox_checked.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72175bf108135b422d978b701d29e6d9a5348c536e25abc924234bc11b6b7f21 -size 14016 +oid sha256:2c323b3b530be2c4ff195e369e86df49ef28de0696fb33a74361d9dbd95e37ae +size 14889 diff --git a/tests/egui_tests/tests/snapshots/visuals/drag_value.png b/tests/egui_tests/tests/snapshots/visuals/drag_value.png index 5411009f0..05f63136b 100644 --- a/tests/egui_tests/tests/snapshots/visuals/drag_value.png +++ b/tests/egui_tests/tests/snapshots/visuals/drag_value.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:129121534b5f1a2a668898ebb3560820fe50aa4d3546ef46cc764d5513787e9e -size 7529 +oid sha256:8a48d2014ed6295d61f3200389315662b89e7efba27a93fded255cce7bd21e05 +size 8675 diff --git a/tests/egui_tests/tests/snapshots/visuals/radio.png b/tests/egui_tests/tests/snapshots/visuals/radio.png index e00b42d8c..298841d6a 100644 --- a/tests/egui_tests/tests/snapshots/visuals/radio.png +++ b/tests/egui_tests/tests/snapshots/visuals/radio.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9999c7921f8b277f456189ce0f1185120b4cde7c9a01485a5a7d83f12e95527 -size 11710 +oid sha256:cdaeee74db8c9527e6656b4a3026ed18cb58c4761f1155768a456d6d58dc79e2 +size 12549 diff --git a/tests/egui_tests/tests/snapshots/visuals/radio_checked.png b/tests/egui_tests/tests/snapshots/visuals/radio_checked.png index 0021e7d0a..02590ce3d 100644 --- a/tests/egui_tests/tests/snapshots/visuals/radio_checked.png +++ b/tests/egui_tests/tests/snapshots/visuals/radio_checked.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8fec1fd9f80e5fa17b1cda690c0856e7e5fd674d113a10b1d60b14f5a6c6dd6b -size 12401 +oid sha256:3dfbfd35264e4d35a594c72ef0fb9575b090301e112a98228d3070fa85aa4e42 +size 13240 diff --git a/tests/egui_tests/tests/snapshots/visuals/selectable_value.png b/tests/egui_tests/tests/snapshots/visuals/selectable_value.png index a0b480be4..85cb2a451 100644 --- a/tests/egui_tests/tests/snapshots/visuals/selectable_value.png +++ b/tests/egui_tests/tests/snapshots/visuals/selectable_value.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac18e2eef000a80858b2d0811f9ee31304c6ff96f7a91dc60cc1a404ae28ce38 -size 13246 +oid sha256:cbaa88e2769bd9dbffa9b3ced36585c00b4ad6ca91ae61a6becc63a495a812fc +size 14116 diff --git a/tests/egui_tests/tests/snapshots/visuals/selectable_value_selected.png b/tests/egui_tests/tests/snapshots/visuals/selectable_value_selected.png index 291263c44..8f0cfb4a9 100644 --- a/tests/egui_tests/tests/snapshots/visuals/selectable_value_selected.png +++ b/tests/egui_tests/tests/snapshots/visuals/selectable_value_selected.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c11fe0399c85db5a618580ec4c1f2fe76176c6ea0ead3710a430d9a2bf8acc5d -size 13352 +oid sha256:b1bac7bec0c22e9530ef2428c4233be7a1c3554c653b6344a2d7b981c5455920 +size 14142 diff --git a/tests/egui_tests/tests/snapshots/visuals/slider.png b/tests/egui_tests/tests/snapshots/visuals/slider.png index 67b0b365b..fd9b15b73 100644 --- a/tests/egui_tests/tests/snapshots/visuals/slider.png +++ b/tests/egui_tests/tests/snapshots/visuals/slider.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a41bf44780feefa108a230ae617830445791bde16d712ac35530350d5d009481 -size 9045 +oid sha256:3667467ff1cf2ce210ec1e1555b40bba827008c5ee40d25ccaf082d2718c6d77 +size 10144 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit.png b/tests/egui_tests/tests/snapshots/visuals/text_edit.png index d8e56eb2a..649a05fc4 100644 --- a/tests/egui_tests/tests/snapshots/visuals/text_edit.png +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a103b51df184d5480438e8b537106432205a6d86f2927ab1bd507fe8ed3bb29b -size 7656 +oid sha256:d06b03948190e2d6408c339b97ec3f3e2104ffc7da61f5935b7df8bb89c9d7aa +size 8813 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png new file mode 100644 index 000000000..70c4bfe8f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2be8ebcc7d8cc7b3824ae27c57969c0d1bc2d5affb8f3f9df687fb3d1860280 +size 11567 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png new file mode 100644 index 000000000..a5bda4b8f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:934263e4413e48ea3abf8b53e213f3a61459b697b30cf05436e2d2e6a3d48e3c +size 22356 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png new file mode 100644 index 000000000..e49bb4414 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb3230e609246415501d89984bb59ee1dad1241b8054009e7a5108efe3965904 +size 10880 diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index cf2abbe1a..6f4b694e6 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -108,3 +108,14 @@ fn test_intrinsic_size() { } } } + +#[test] +fn test_button_shortcut_text() { + let mut harness = HarnessBuilder::default().build_ui(|ui| { + ui.add(egui::Button::new("Click me").shortcut_text(("1", "2", "3"))); + }); + harness.run(); + harness.fit_contents(); + + harness.snapshot("button_shortcut"); +} diff --git a/tests/egui_tests/tests/test_widgets.rs b/tests/egui_tests/tests/test_widgets.rs index 6a75e36a3..440b1939b 100644 --- a/tests/egui_tests/tests/test_widgets.rs +++ b/tests/egui_tests/tests/test_widgets.rs @@ -2,7 +2,7 @@ use egui::accesskit::Role; use egui::load::SizedTexture; use egui::{ Align, AtomExt as _, AtomLayout, Button, Color32, ColorImage, Direction, DragValue, Event, - Grid, IntoAtoms as _, Layout, PointerButton, Response, Slider, Stroke, StrokeKind, + Grid, IntoAtoms as _, Layout, PointerButton, Response, Slider, Stroke, StrokeKind, TextEdit, TextWrapMode, TextureHandle, TextureOptions, Ui, UiBuilder, Vec2, Widget as _, include_image, }; use egui_kittest::kittest::{Queryable as _, by}; @@ -84,6 +84,37 @@ fn widget_tests() { }, &mut results, ); + test_widget( + "text_edit_clip", + |ui| { + ui.spacing_mut().text_edit_width = 45.0; + TextEdit::singleline(&mut "This is a very very long text".to_owned()) + .clip_text(true) + .ui(ui) + }, + &mut results, + ); + test_widget( + "text_edit_no_clip", + |ui| { + ui.spacing_mut().text_edit_width = 45.0; + TextEdit::singleline(&mut "This is a very very long text".to_owned()) + .clip_text(false) + .ui(ui) + }, + &mut results, + ); + test_widget( + "text_edit_placeholder_clip", + |ui| { + ui.spacing_mut().text_edit_width = 45.0; + TextEdit::singleline(&mut String::new()) + .hint_text("This is a very very long placeholder") + .clip_text(true) + .ui(ui) + }, + &mut results, + ); test_widget( "slider", diff --git a/tests/test_size_pass/src/main.rs b/tests/test_size_pass/src/main.rs index 6bb293302..ce645eb98 100644 --- a/tests/test_size_pass/src/main.rs +++ b/tests/test_size_pass/src/main.rs @@ -9,7 +9,7 @@ fn main() -> eframe::Result { let options = eframe::NativeOptions::default(); eframe::run_simple_native("My egui App", options, move |ctx, _frame| { // A bottom panel to force the tooltips to consider if the fit below or under the widget: - egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| { + egui::Panel::bottom("bottom").show(ctx, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label("Single tooltips:"); diff --git a/tests/test_ui_stack/src/main.rs b/tests/test_ui_stack/src/main.rs index 47b4ee5ca..bb2158297 100644 --- a/tests/test_ui_stack/src/main.rs +++ b/tests/test_ui_stack/src/main.rs @@ -34,7 +34,7 @@ impl eframe::App for MyApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0); - egui::SidePanel::left("side_panel_left").show(ctx, |ui| { + egui::Panel::left("side_panel_left").show(ctx, |ui| { ui.heading("Information"); ui.label( "This is a demo/test environment of the `UiStack` feature. The tables display \ @@ -82,7 +82,7 @@ impl eframe::App for MyApp { }); }); - egui::SidePanel::right("side_panel_right").show(ctx, |ui| { + egui::Panel::right("side_panel_right").show(ctx, |ui| { egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| { stack_ui(ui); @@ -170,7 +170,7 @@ impl eframe::App for MyApp { }); }); - egui::TopBottomPanel::bottom("bottom_panel") + egui::Panel::bottom("bottom_panel") .resizable(true) .show(ctx, |ui| { egui::ScrollArea::vertical() diff --git a/tests/test_viewports/src/main.rs b/tests/test_viewports/src/main.rs index ab31a4ece..49b212e4b 100644 --- a/tests/test_viewports/src/main.rs +++ b/tests/test_viewports/src/main.rs @@ -31,7 +31,7 @@ pub struct ViewportState { pub visible: bool, pub immediate: bool, pub title: String, - pub children: Vec>>, + pub children: Vec>>, } impl ViewportState { @@ -339,7 +339,7 @@ fn drag_and_drop_test(ui: &mut egui::Ui) { } fn insert(&mut self, container: Id, col: usize, value: impl Into) { - assert!(col <= COLS, "The coll should be less then: {COLS}"); + assert!(col < COLS, "The coll should be less than: {COLS}"); let value: String = value.into(); let id = Id::new(format!("%{}% {}", self.counter, &value)); @@ -355,7 +355,7 @@ fn drag_and_drop_test(ui: &mut egui::Ui) { } fn cols(&self, container: Id, col: usize) -> Vec<(Id, String)> { - assert!(col <= COLS, "The col should be less then: {COLS}"); + assert!(col < COLS, "The col should be less than: {COLS}"); let container_data = &self.containers_data[&container]; container_data[col] .iter() @@ -368,7 +368,7 @@ fn drag_and_drop_test(ui: &mut egui::Ui) { let Some(id) = self.is_dragged.take() else { return; }; - assert!(col <= COLS, "The col should be less then: {COLS}"); + assert!(col < COLS, "The col should be less than: {COLS}"); // Should be a better way to do this! #[expect(clippy::iter_over_hash_type)]