From c79096ecc4e1a2fb66c30bd15692688ae098dcff Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 15 Oct 2025 11:42:52 +0200 Subject: [PATCH 01/43] Add `egui_kittest::Harness::set_options` (#7638) Makes it easier to set the same options for many tests --------- Co-authored-by: Lucas Meurer --- crates/egui_kittest/src/builder.rs | 12 ++++++++++++ crates/egui_kittest/src/lib.rs | 9 +++++++++ crates/egui_kittest/src/snapshot.rs | 19 ++++++++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs index 75ebd54b2..cc686914f 100644 --- a/crates/egui_kittest/src/builder.rs +++ b/crates/egui_kittest/src/builder.rs @@ -14,6 +14,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 +31,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 +62,12 @@ impl HarnessBuilder { self } + /// Set the default options used for snapshot tests on this harness. + #[cfg(feature = "snapshot")] + pub fn with_options(&mut self, options: crate::SnapshotOptions) { + self.default_snapshot_options = options; + } + /// Override the [`egui::os::OperatingSystem`] reported to egui. /// /// This affects e.g. the way shortcuts are displayed. So for snapshot tests, diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index e331119e3..c8112f47b 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -75,6 +75,9 @@ pub struct Harness<'a, State = ()> { step_dt: f32, wait_for_pending_images: bool, queued_events: EventQueue, + + #[cfg(feature = "snapshot")] + default_snapshot_options: SnapshotOptions, } impl Debug for Harness<'_, State> { @@ -100,6 +103,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 +153,9 @@ impl<'a, State> Harness<'a, State> { step_dt, wait_for_pending_images, queued_events: Default::default(), + + #[cfg(feature = "snapshot")] + default_snapshot_options, }; // Run the harness until it is stable, ensuring that all Areas are shown and animations are done harness.run_ok(); diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index c11533206..f6511c451 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; 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 @@ -556,9 +557,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 +595,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 +609,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. @@ -629,6 +643,9 @@ impl Harness<'_, State> { } /// 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`. From bf5604b3c7c342ba6f1dec5a9f3720ce95aeafbb Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 15 Oct 2025 12:08:49 +0200 Subject: [PATCH 02/43] Release egui_kittest 0.33.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/egui_kittest/CHANGELOG.md | 4 ++++ crates/egui_kittest/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6cd1533c..cecaf6935 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1445,7 +1445,7 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.33.0" +version = "0.33.1" dependencies = [ "dify", "document-features", diff --git a/Cargo.toml b/Cargo.toml index 7aaa81139..dfdb595c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ egui_extras = { version = "0.33.0", path = "crates/egui_extras", default-feature 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 } +egui_kittest = { version = "0.33.1", path = "crates/egui_kittest", default-features = false } eframe = { version = "0.33.0", path = "crates/eframe", default-features = false } accesskit = "0.21.1" diff --git a/crates/egui_kittest/CHANGELOG.md b/crates/egui_kittest/CHANGELOG.md index 9716c726d..aab4d9d27 100644 --- a/crates/egui_kittest/CHANGELOG.md +++ b/crates/egui_kittest/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.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..a81413840 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "egui_kittest" -version.workspace = true +version = "0.33.1" authors = ["Lucas Meurer ", "Emil Ernerfeldt "] description = "Testing library for egui based on kittest and AccessKit" edition.workspace = true From 30eb38ef451bb115572e7672adb51bcf31ab5cc3 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 23 Oct 2025 10:06:37 +0200 Subject: [PATCH 03/43] Fix kitdiff links in pr comments (#7639) The pr data is not accessible in this workflow so I have to hardcode the url with the pr number --- .github/workflows/preview_deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From f6fa74c66578be17c1a2a80eb33b1704f17a3d5f Mon Sep 17 00:00:00 2001 From: Ian Hobson Date: Thu, 23 Oct 2025 10:24:06 +0200 Subject: [PATCH 04/43] Don't enable `arboard` on iOS (#7663) `arboard` [doesn't support support iOS yet](https://github.com/1Password/arboard/pull/103), so this PR adds iOS to the conditions that prevent `arboard` from being enabled. Launching an app on a physical device results in a long timeout (~8s) while trying to connect to the X11 server (the timeout is immediate when launching on a simulator), with the following trace: ``` egui_winit::clipboard: Failed to initialize arboard clipboard: Unknown error while interacting with the clipboard: X11 server connection timed out because it was unreachable ``` * [x] I have followed the instructions in the PR template --- crates/egui-winit/src/clipboard.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) 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!(); From 999e943e594ccb60846b98d1376f61c82d638e78 Mon Sep 17 00:00:00 2001 From: Isse Date: Mon, 27 Oct 2025 11:46:51 +0100 Subject: [PATCH 05/43] Add `is_scrolling`/`is_smooth_scrolling` util, checking for active scroll action. (#7669) * Closes #7657 * [x] I have followed the instructions in the PR template On native this uses a new "touch phase" parameter of the mouse wheel event to know if a scroll action is done. --------- Co-authored-by: Emil Ernerfeldt --- crates/eframe/src/web/events.rs | 1 + crates/egui-winit/src/lib.rs | 32 +++++-- crates/egui/src/data/input.rs | 5 + crates/egui/src/input_state/mod.rs | 144 ++++++++++++++++++++--------- 4 files changed, 127 insertions(+), 55 deletions(-) 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/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index ed293e39a..d72c44245 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, }); } @@ -970,6 +973,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/src/data/input.rs b/crates/egui/src/data/input.rs index 8eecd979d..869945ece 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, }, diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index b23a47828..bca53c721 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -225,6 +225,16 @@ pub struct InputState { /// Time of the last scroll event. last_scroll_time: f64, + /// If we are currently in a scroll action. + /// + /// This is not the same as checking if [`Self::smooth_scroll_delta`], or + /// [`Self::raw_scroll_delta`] are zero. This instead relies on the + /// current touch phase received from the mouse wheel event. + /// + /// This value is only `Some` if we have ever received a [`crate::TouchPhase::Start`] event and then + /// know that the current platform supports it. + is_in_scroll_action: Option, + /// Used for smoothing the scroll delta. unprocessed_scroll_delta: Vec2, @@ -357,6 +367,7 @@ impl Default for InputState { pointer: Default::default(), touch_states: Default::default(), + is_in_scroll_action: None, last_scroll_time: f64::NEG_INFINITY, unprocessed_scroll_delta: Vec2::ZERO, unprocessed_scroll_delta_for_zoom: 0.0, @@ -440,53 +451,68 @@ 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; + match phase { + crate::TouchPhase::Start => { + self.is_in_scroll_action = Some(true); } - } else { - if is_smooth { - smooth_scroll_delta += delta; - } else { - unprocessed_scroll_delta += delta; + crate::TouchPhase::Move => { + 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; + } + } + } + crate::TouchPhase::End | crate::TouchPhase::Cancel => { + if let Some(is_in_scroll_action) = &mut self.is_in_scroll_action { + *is_in_scroll_action = false; + } } } } @@ -542,7 +568,7 @@ impl InputState { } let is_scrolling = raw_scroll_delta != Vec2::ZERO || smooth_scroll_delta != Vec2::ZERO; - let last_scroll_time = if is_scrolling { + let last_scroll_time = if is_scrolling || self.is_in_scroll_action.is_some_and(|b| b) { time } else { self.last_scroll_time @@ -552,6 +578,7 @@ impl InputState { pointer, touch_states: self.touch_states, + is_in_scroll_action: self.is_in_scroll_action, last_scroll_time, unprocessed_scroll_delta, unprocessed_scroll_delta_for_zoom, @@ -708,6 +735,27 @@ impl InputState { .map_or(self.smooth_scroll_delta, |touch| touch.translation_delta) } + /// True if there is an active scroll action that might scroll more when using [`Self::smooth_scroll_delta`]. + pub fn is_smooth_scrolling(&self) -> bool { + self.is_raw_scrolling() || self.smooth_scroll_delta != Vec2::ZERO + } + + /// True if there is an active scroll action that might scroll more. + /// + /// You probably want to use [`Self::is_smooth_scrolling`]. + pub fn is_raw_scrolling(&self) -> bool { + if let Some(is_in_scroll_action) = self.is_in_scroll_action { + is_in_scroll_action + } else { + // 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. So 100 ms should most likely be good here. + self.time_since_last_scroll() < 0.1 + } + } + /// How long has it been (in seconds) since the use last scrolled? #[inline(always)] pub fn time_since_last_scroll(&self) -> f32 { @@ -1599,6 +1647,7 @@ impl InputState { pointer, touch_states, + is_in_scroll_action: _, last_scroll_time, unprocessed_scroll_delta, unprocessed_scroll_delta_for_zoom, @@ -1642,6 +1691,11 @@ impl InputState { }); } + ui.label(format!( + "is_scrolling: raw: {}, smooth: {}", + self.is_raw_scrolling(), + self.is_smooth_scrolling() + )); ui.label(format!( "Time since last scroll: {:.1} s", time - last_scroll_time From 2669344d5c03e3d92050f5de9936f2d85e6f65d1 Mon Sep 17 00:00:00 2001 From: Juan Campa Date: Mon, 27 Oct 2025 09:52:42 -0400 Subject: [PATCH 06/43] Add `Plugin::on_widget_under_pointer` to support widget inspector (#7652) This PR adds `Plugin::on_widget_under_pointer` which gets called whenever a widget is created whose rect contains the pointer. The point of the hook is to capture a stack trace which can be used to map widgets to their corresponding source code so it must be called while the widget is being created. The obvious concern is performance impact. However, since it's only called for rects under the cursor, the effect seems negligible afaict. It's under `debug_assertions` just in case. This change is needed so we can publish the widget inspector we've been working on. Basically a plugin that allows us to jump from any widget back to their corresponding source code. This video shows the plugin configured to open the corresponding code in github, but normally it would open your local editor. Update: [Live demo](https://membrane-io.github.io/egui/) (Firefox/Safari not yet supported. `Cmd-I` to inspect. `Tab` to cycle filters. `Click` to open). It will try to open a file under `/home/runner/work/egui/egui/` so it won't work, but you get the idea. https://github.com/user-attachments/assets/afe4d6af-7f67-44b5-be25-44f7564d9a3a ## What's next After this gets merged I plan to publish the above plugin as its own crate, that way we can iterate and release quickly while things are still changing. I agree it would make sense to eventually merge it into the main egui repo (like @emilk suggested in #4650). * [x] I have followed the instructions in the PR template --------- Co-authored-by: Emil Ernerfeldt --- crates/egui/src/context.rs | 6 ++++++ crates/egui/src/plugin.rs | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index aab81ab2c..5a868dd75 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1198,6 +1198,12 @@ impl Context { #[allow(clippy::let_and_return, clippy::allow_attributes)] let res = self.get_response(w); + #[cfg(debug_assertions)] + if res.contains_pointer() { + let plugins = self.read(|ctx| ctx.plugins.ordered_plugins()); + plugins.on_widget_under_pointer(self, &w); + } + #[cfg(feature = "accesskit")] if allow_focus && w.sense.is_focusable() { // Make sure anything that can receive focus has an AccessKit node. diff --git a/crates/egui/src/plugin.rs b/crates/egui/src/plugin.rs index bebcf892e..3e078d21c 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 { From e861c8ec79e6af6e2e5623ea5b16f39397aaeeba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hubert=20G=C5=82uchowski?= Date: Fri, 31 Oct 2025 09:45:32 +0000 Subject: [PATCH 07/43] Avoid cloning `Row`s during `Galley::concat` (#7649) Moves `ends_with_newline` into `PlacedRow` to avoid clones during layout. I don't think there was a rationale stronger than "don't change too much" for not doing this in https://github.com/emilk/egui/pull/5411, so I should've just done this from the start. This was a significant part of the profile for text layout (as it cloned almost every `Row`, even though it only needed to change a single boolean). Before: image After: image (note that these profiles focus solely on the top-level `Galley::layout_inline` subtree, also don't compare sample count as the duration of these tests was completely arbitrary) egui_demo_lib `*text_layout*` benches: image * [X] I have followed the instructions in the PR template (As usual, the tests fail for me even on master but the failures on master and with these changes seem the same :)) --- crates/egui/src/text_selection/visuals.rs | 5 +-- crates/epaint/src/shapes/text_shape.rs | 8 +++-- crates/epaint/src/text/text_layout.rs | 13 ++++--- crates/epaint/src/text/text_layout_types.rs | 38 +++++++++++---------- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index e3054b19d..0f6d54abd 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -31,7 +31,8 @@ pub fn paint_text_selection( 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 +42,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 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..b1fe895da 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -296,8 +296,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 +310,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 +362,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 +388,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 +430,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, }); } } diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 1adcc515e..d87f9a579 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. @@ -735,12 +735,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 +770,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 +867,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; From c5347f28e40298345945c81846ac4cca87cb00d8 Mon Sep 17 00:00:00 2001 From: ASPCartman Date: Sat, 1 Nov 2025 14:55:56 +0300 Subject: [PATCH 08/43] Fix jittering during window resize on MacOS for WGPU/Metal (#7641) Co-authored-by: Emil Ernerfeldt --- crates/eframe/src/native/wgpu_integration.rs | 38 ++++++++++---- crates/egui-wgpu/Cargo.toml | 5 +- crates/egui-wgpu/src/winit.rs | 55 ++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index 046340bdb..b21b62aca 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; @@ -302,6 +303,7 @@ impl<'app> WgpuWinitApp<'app> { viewports, painter, focused_viewport: Some(ViewportId::ROOT), + resized_viewport: None, })); { @@ -763,20 +765,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 +815,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/egui-wgpu/Cargo.toml b/crates/egui-wgpu/Cargo.toml index 88321e652..cd897b63e 100644 --- a/crates/egui-wgpu/Cargo.toml +++ b/crates/egui-wgpu/Cargo.toml @@ -25,7 +25,7 @@ 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"] ## Enable [`winit`](https://docs.rs/winit) integration. On Linux, requires either `wayland` or `x11` winit = ["dep:winit", "winit/rwh_06"] @@ -43,6 +43,9 @@ 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"] } 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, From fa3457f21c3c404f375e6ff949db3660bd42227c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Pakalns?= Date: Sun, 2 Nov 2025 12:28:33 +0200 Subject: [PATCH 09/43] Fix profiling::scope compile error when profiling using tracing backend (#7646) * Closes https://github.com/emilk/egui/issues/7645 * [x] I have followed the instructions in the PR template --- crates/egui/src/plugin.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/egui/src/plugin.rs b/crates/egui/src/plugin.rs index 3e078d21c..80afc4510 100644 --- a/crates/egui/src/plugin.rs +++ b/crates/egui/src/plugin.rs @@ -231,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); } } @@ -240,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); } } From 1b77d7047e72e825593be838c6b9ce2b17611f79 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 3 Nov 2025 11:53:55 +0100 Subject: [PATCH 10/43] Improve modifier handling when scrolling (#7678) ### Problem Letting go of the modifier key before the last momentum-scroll events arrive will cause the scroll direction to change. This problem can be seen by going to egui.rs and opening the "Scene" example. Hold down shift, start a momentum-scroll (on a Mac trackpad), then quickly let go of shift: you'll see the scroll direction change, which feels wrong. ### Solution Store the modifiers at the start of the event, thanks to the new `phase` info added in * https://github.com/emilk/egui/pull/7669 Note that this solution only works on native; not on web. ### Other * Break out wheel/scroll handling into own file * Simplify it a lot by deciding late on wether an input is a scroll or a zoom * Assume input is already smooth if there are `phase` events --- crates/egui/src/containers/scene.rs | 2 +- crates/egui/src/containers/scroll_area.rs | 10 +- crates/egui/src/containers/tooltip.rs | 2 +- crates/egui/src/input_state/mod.rs | 261 +++++---------------- crates/egui/src/input_state/wheel_state.rs | 233 ++++++++++++++++++ 5 files changed, 302 insertions(+), 206 deletions(-) create mode 100644 crates/egui/src/input_state/wheel_state.rs 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..db64ba03b 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -1127,9 +1127,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 +1143,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; } }); 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/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index bca53c721..7b163a90f 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,41 +225,8 @@ pub struct InputState { // ---------------------------------------------- // Scrolling: - // - /// Time of the last scroll event. - last_scroll_time: f64, - - /// If we are currently in a scroll action. - /// - /// This is not the same as checking if [`Self::smooth_scroll_delta`], or - /// [`Self::raw_scroll_delta`] are zero. This instead relies on the - /// current touch phase received from the mouse wheel event. - /// - /// This value is only `Some` if we have ever received a [`crate::TouchPhase::Start`] event and then - /// know that the current platform supports it. - is_in_scroll_action: Option, - - /// 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. /// @@ -367,11 +338,7 @@ impl Default for InputState { pointer: Default::default(), touch_states: Default::default(), - is_in_scroll_action: None, - 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, @@ -426,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 { @@ -454,67 +417,15 @@ impl InputState { phase, modifiers, } => { - match phase { - crate::TouchPhase::Start => { - self.is_in_scroll_action = Some(true); - } - crate::TouchPhase::Move => { - 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; - } - } - } - crate::TouchPhase::End | crate::TouchPhase::Cancel => { - if let Some(is_in_scroll_action) = &mut self.is_in_scroll_action { - *is_in_scroll_action = false; - } - } - } + self.wheel.on_wheel_event( + viewport_rect, + &options, + time, + *unit, + *delta, + *phase, + *modifiers, + ); } Event::Zoom(factor) => { zoom_factor_delta *= *factor; @@ -534,55 +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 || self.is_in_scroll_action.is_some_and(|b| b) { - time - } else { - self.last_scroll_time - }; - Self { pointer, touch_states: self.touch_states, - is_in_scroll_action: self.is_in_scroll_action, - 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, @@ -654,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 @@ -732,34 +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) } /// True if there is an active scroll action that might scroll more when using [`Self::smooth_scroll_delta`]. - pub fn is_smooth_scrolling(&self) -> bool { - self.is_raw_scrolling() || self.smooth_scroll_delta != Vec2::ZERO + pub fn is_scrolling(&self) -> bool { + self.wheel.is_scrolling() } - /// True if there is an active scroll action that might scroll more. - /// - /// You probably want to use [`Self::is_smooth_scrolling`]. - pub fn is_raw_scrolling(&self) -> bool { - if let Some(is_in_scroll_action) = self.is_in_scroll_action { - is_in_scroll_action - } else { - // 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. So 100 ms should most likely be good here. - self.time_since_last_scroll() < 0.1 - } - } - - /// How long has it been (in seconds) since the use last scrolled? + /// 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. @@ -771,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 @@ -1646,15 +1529,9 @@ impl InputState { raw, pointer, touch_states, - - is_in_scroll_action: _, - 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, @@ -1691,27 +1568,13 @@ impl InputState { }); } - ui.label(format!( - "is_scrolling: raw: {}, smooth: {}", - self.is_raw_scrolling(), - self.is_smooth_scrolling() - )); - 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(); + }); + } +} From 706ce10abdbeab1cf1a27f9f680cd7340301aa2d Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 3 Nov 2025 18:56:18 +0100 Subject: [PATCH 11/43] Fix edge cases in "smart aiming" in sliders (#7680) When dragging slider, we try to pick nice, round values. There were a couple edge cases there that were handled wrong. This is now fixed. --- crates/emath/src/smart_aim.rs | 158 +++++++++++++++++++++++++--------- 1 file changed, 115 insertions(+), 43 deletions(-) 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 } From 31d313572b798d1924329cc074f0f22075fca712 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 5 Nov 2025 10:35:57 +0100 Subject: [PATCH 12/43] Make sure `native_pixels_per_point` is set during app creation (#7683) Useful for things like analytics --- crates/eframe/src/native/glow_integration.rs | 18 ++++++++++++++--- crates/eframe/src/native/wgpu_integration.rs | 21 ++++++++++++++++---- crates/eframe/src/web/app_runner.rs | 8 ++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index 4a3bee46a..b42674052 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -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/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index b21b62aca..c6c715c8c 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -199,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); { @@ -278,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, @@ -289,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), diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index bd245a1fe..4a97235aa 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -65,6 +65,14 @@ 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(), From 1e63bfd65798efda95d88b874586f0f514242a9c Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Fri, 7 Nov 2025 13:34:18 +0100 Subject: [PATCH 13/43] Improve accessibility and testability of `ComboBox` (#7658) Changed it to use labeled_by to avoid kittest finding the label when searching for the ComboBox and also set the value so a screen reader will know what's selected. --- crates/egui/src/containers/combo_box.rs | 18 ++++++++++-------- tests/egui_tests/tests/regression_tests.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) 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/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index a407864e7..1ee197cb5 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -61,3 +61,17 @@ 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") + ); +} From 04913ed651203906622c15588166bb648ab4f1fb Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Fri, 7 Nov 2025 13:34:25 +0100 Subject: [PATCH 14/43] Add some more text edit tests (#7608) Adds tests to text the clip option in text edits and how it behaves with a placeholder --------- Co-authored-by: lucasmerlin <8009393+lucasmerlin@users.noreply.github.com> --- .../tests/snapshots/layout/text_edit_clip.png | 3 ++ .../snapshots/layout/text_edit_no_clip.png | 3 ++ .../layout/text_edit_placeholder_clip.png | 3 ++ .../snapshots/visuals/text_edit_clip.png | 3 ++ .../snapshots/visuals/text_edit_no_clip.png | 3 ++ .../visuals/text_edit_placeholder_clip.png | 3 ++ tests/egui_tests/tests/test_widgets.rs | 33 ++++++++++++++++++- 7 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/egui_tests/tests/snapshots/layout/text_edit_clip.png create mode 100644 tests/egui_tests/tests/snapshots/layout/text_edit_no_clip.png create mode 100644 tests/egui_tests/tests/snapshots/layout/text_edit_placeholder_clip.png create mode 100644 tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png create mode 100644 tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png create mode 100644 tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png 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/text_edit_clip.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png new file mode 100644 index 000000000..f44900fa5 --- /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:cf4236b1a8f63d184cd780c334d9f996e4d47817a96a29f0d81658d2d897597f +size 10529 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..7329c49cf --- /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:c7a63953853f526b83f80d63335b03e60258ea9a3416d19f8ed57d746b5c551d +size 21557 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..e1a15cf7d --- /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:1f7d802a4de7e30f8d254cab6d9ca127866c104c1738103bc4a579917e8f42d3 +size 9850 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", From 1d4d14f18ee402e6eb333cf9c2ddaf2453aaed69 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 7 Nov 2025 14:43:49 +0100 Subject: [PATCH 15/43] Hide scroll bars when dragging other things (#7689) This closes a small visual glitch where scroll bars would show up when dragging something unrelated, like a slider or a panel side. --- crates/egui/src/containers/scroll_area.rs | 131 ++++++++++++---------- 1 file changed, 74 insertions(+), 57 deletions(-) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index db64ba03b..d63a2ab59 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -3,8 +3,8 @@ use std::ops::{Add, AddAssign, BitOr, BitOrAssign}; 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)] @@ -659,6 +659,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, } @@ -772,70 +775,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 +893,7 @@ impl ScrollArea { wheel_scroll_multiplier, stick_to_end, saved_scroll_target, + background_drag_response, animated, } } @@ -1003,6 +1009,7 @@ impl Prepared { wheel_scroll_multiplier, stick_to_end, saved_scroll_target, + background_drag_response, animated, } = self; @@ -1118,7 +1125,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]; @@ -1204,6 +1220,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 From d8dcb316739dbaa520d0b8ed11788d37ee888fa3 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 7 Nov 2025 14:46:09 +0100 Subject: [PATCH 16/43] `kittest`: add drag-and-drop helpers (#7690) --- crates/egui_kittest/src/lib.rs | 57 ++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index c8112f47b..33a188ea4 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -8,9 +8,7 @@ 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 node; @@ -20,19 +18,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, @@ -598,6 +603,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. From fa4cfec777c9ea21b8643f77b6a26561da44aad0 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 7 Nov 2025 15:34:36 +0100 Subject: [PATCH 17/43] Change text color of selected text (#7691) Selected text now gets the color of `visuals.selection.stroke.color`. This means you can have inverted colors for selected text, like in the new test: image It also means the color of selected text in labels matches that of the text color of selected buttons. --- crates/egui/src/style.rs | 3 ++ crates/egui/src/text_selection/visuals.rs | 31 +++++++++++++++++-- crates/egui_demo_lib/tests/misc.rs | 26 ++++++++++++++-- .../image_dark_x1.00.png | 0 .../image_dark_x1.41.png | 0 .../image_dark_x2.00.png | 0 .../image_light_x1.00.png | 0 .../image_light_x1.41.png | 0 .../image_light_x2.00.png | 0 .../tests/snapshots/text_selection.png | 3 ++ crates/emath/src/rect.rs | 3 +- crates/epaint/src/text/text_layout.rs | 9 ++++-- crates/epaint/src/text/text_layout_types.rs | 3 ++ 13 files changed, 70 insertions(+), 8 deletions(-) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_dark_x1.00.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_dark_x1.41.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_dark_x2.00.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_light_x1.00.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_light_x1.41.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_light_x2.00.png (100%) create mode 100644 crates/egui_demo_lib/tests/snapshots/text_selection.png diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 454fc6d89..9982c05bb 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1129,7 +1129,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, } diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index 0f6d54abd..50bb1a34d 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -25,7 +25,9 @@ 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); @@ -53,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: @@ -60,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_demo_lib/tests/misc.rs b/crates/egui_demo_lib/tests/misc.rs index 395baceb6..af8858bca 100644 --- a/crates/egui_demo_lib/tests/misc.rs +++ b/crates/egui_demo_lib/tests/misc.rs @@ -1,4 +1,5 @@ -use egui_kittest::Harness; +use egui::{Color32, accesskit::Role}; +use egui_kittest::{Harness, kittest::Queryable as _}; #[test] fn test_kerning() { @@ -42,7 +43,7 @@ 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", @@ -51,3 +52,24 @@ fn test_italics() { } } } + +#[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/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/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/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/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index b1fe895da..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; @@ -531,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; } @@ -748,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(); @@ -843,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 d87f9a579..f3963394a 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -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 { From 93425ae06b8ea3c4f8f8cc9dc01851c37876495b Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 10 Nov 2025 16:34:58 +0100 Subject: [PATCH 18/43] Allow multiple atoms in `Button::shortcut_text` and `right_text` (#7696) Useful when intermixing text and icons (e.g. for modifiers) --- crates/egui/src/widgets/button.rs | 25 ++++++++++++------- .../tests/snapshots/button_shortcut.png | 3 +++ tests/egui_tests/tests/test_atoms.rs | 11 ++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 tests/egui_tests/tests/snapshots/button_shortcut.png 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/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/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"); +} From 6b79845431ba1f4e2643cd9bba6b9b52c7b3a65e Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 10 Nov 2025 21:49:31 +0100 Subject: [PATCH 19/43] Turn `HarnessBuilder::with_options` into a proper builder method (#7697) My bad when first creating it --- crates/egui_kittest/src/builder.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs index cc686914f..09b91d26d 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, @@ -64,8 +65,10 @@ impl HarnessBuilder { /// Set the default options used for snapshot tests on this harness. #[cfg(feature = "snapshot")] - pub fn with_options(&mut self, options: crate::SnapshotOptions) { + #[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. From f33b0ffe6ebb3a0dc096bd05a7666ef38c37c39a Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 12 Nov 2025 08:52:43 +0100 Subject: [PATCH 20/43] Fix link checker --- lychee.toml | 1 + 1 file changed, 1 insertion(+) 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/.*", ] From 1af5d1d37ecba2c16a354d2d318d74a25c85da95 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 12 Nov 2025 10:51:28 +0100 Subject: [PATCH 21/43] Remove `accesskit` feature and always depend on `accesskit` (#7701) * Closes #3137 With this, `egui` will always depend on `accesskit`, removing a lot of `#[cfg(feature = "accesskit")]` throughout the code. --- crates/eframe/Cargo.toml | 2 +- crates/eframe/src/web/app_runner.rs | 3 +- crates/egui-winit/Cargo.toml | 2 +- crates/egui-winit/src/lib.rs | 4 ++- crates/egui/Cargo.toml | 8 ++--- crates/egui/src/containers/window.rs | 1 - crates/egui/src/context.rs | 28 ++++++--------- crates/egui/src/data/input.rs | 1 - crates/egui/src/data/output.rs | 10 ++---- crates/egui/src/id.rs | 1 - crates/egui/src/input_state/mod.rs | 4 --- crates/egui/src/lib.rs | 2 -- crates/egui/src/memory/mod.rs | 34 +++++++------------ crates/egui/src/pass_state.rs | 9 +---- crates/egui/src/response.rs | 9 ----- .../egui/src/text_selection/accesskit_text.rs | 3 +- .../egui/src/text_selection/cursor_range.rs | 2 -- .../text_selection/label_text_selection.rs | 1 - crates/egui/src/text_selection/mod.rs | 1 - crates/egui/src/ui.rs | 7 ---- crates/egui/src/ui_builder.rs | 9 +---- crates/egui/src/widgets/drag_value.rs | 25 +++++--------- crates/egui/src/widgets/slider.rs | 27 ++++++--------- crates/egui/src/widgets/text_edit/builder.rs | 1 - crates/egui_kittest/Cargo.toml | 2 +- 25 files changed, 56 insertions(+), 140 deletions(-) diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index ce2103cc8..74669e43b 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -40,7 +40,7 @@ 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 diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 4a97235aa..4f4bd518a 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -324,8 +324,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/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/lib.rs b/crates/egui-winit/src/lib.rs index d72c44245..7660e3cef 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -888,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 @@ -947,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) { 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/containers/window.rs b/crates/egui/src/containers/window.rs index da7e65c1b..c3bbb760c 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -899,7 +899,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 5a868dd75..587c3b377 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()); @@ -619,6 +616,7 @@ impl ContextImpl { let parent_builder = builders.get_mut(&parent_id).unwrap(); parent_builder.push_child(id.accesskit_id()); } + builders.get_mut(&id).unwrap() } @@ -1204,7 +1202,6 @@ impl Context { plugins.on_widget_under_pointer(self, &w); } - #[cfg(feature = "accesskit")] 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, @@ -1212,7 +1209,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()); @@ -1220,12 +1216,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(), @@ -1239,16 +1237,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, @@ -1341,7 +1339,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) @@ -2498,7 +2495,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(); @@ -3497,9 +3493,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, @@ -3515,7 +3510,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() { @@ -3525,13 +3519,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); } diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 869945ece..61f6ae00c 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -548,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 7b163a90f..d87788162 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -855,7 +855,6 @@ impl InputState { } } - #[cfg(feature = "accesskit")] pub fn accesskit_action_requests( &self, id: crate::Id, @@ -873,7 +872,6 @@ impl InputState { }) } - #[cfg(feature = "accesskit")] pub fn consume_accesskit_action_requests( &mut self, id: crate::Id, @@ -890,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() } diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 960480b23..3071f7196 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -448,7 +448,6 @@ pub mod widgets; #[cfg(debug_assertions)] mod callstack; -#[cfg(feature = "accesskit")] pub use accesskit; #[deprecated = "Use the ahash crate directly."] @@ -708,7 +707,6 @@ pub fn __run_test_ui(add_contents: impl Fn(&mut 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..6192f3e72 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. 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/response.rs b/crates/egui/src/response.rs index e17c1aff5..0159a1f5e 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -793,7 +793,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 +802,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 +812,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 +830,6 @@ impl Response { } } - #[cfg(feature = "accesskit")] fn fill_accesskit_node_from_widget_info( &self, builder: &mut accesskit::Node, @@ -908,14 +904,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/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/ui.rs b/crates/egui/src/ui.rs index d746b8fec..08bb9cee5 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -133,7 +133,6 @@ impl Ui { sizing_pass, style, sense, - #[cfg(feature = "accesskit")] accessibility_parent, } = ui_builder; @@ -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; @@ -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( 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/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/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..6f2da1baa 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -844,7 +844,6 @@ impl TextEdit<'_> { }); } - #[cfg(feature = "accesskit")] { let role = if password { accesskit::Role::PasswordInput diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index a81413840..38ff7349e 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -35,7 +35,7 @@ x11 = ["eframe?/x11"] [dependencies] kittest.workspace = true -egui = { workspace = true, features = ["accesskit"] } +egui.workspace = true eframe = { workspace = true, optional = true } # wgpu dependencies From df6f35d5682ee0e5e1f8b26c3de210db0267d112 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 12 Nov 2025 10:51:38 +0100 Subject: [PATCH 22/43] eframe: add feature `wgpu_no_default_features` (#7700) * Part of https://github.com/emilk/egui/issues/5889 * Closes https://github.com/emilk/egui/issues/7106 This changes the `eframe/wgpu` feature to also enable all the `default` features of `wgpu` and `egui-wgpu`. This makes switching `eframe` backend from `glow` to `wgpu` a lot easier. To get the old behavior (depend on `wgpu` but you must opt-in to all its features), use the new `wgpu_no_default_features` feature. --- .github/workflows/rust.yml | 4 +- crates/eframe/Cargo.toml | 22 +++---- crates/eframe/src/epi.rs | 66 ++++++++++---------- crates/eframe/src/lib.rs | 30 ++++----- crates/eframe/src/native/epi_integration.rs | 6 +- crates/eframe/src/native/glow_integration.rs | 4 +- crates/eframe/src/native/mod.rs | 2 +- crates/eframe/src/native/run.rs | 4 +- crates/eframe/src/web/app_runner.rs | 8 +-- crates/eframe/src/web/mod.rs | 6 +- 10 files changed, 77 insertions(+), 75 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 85a059160..5d30d7e31 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 diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index 74669e43b..fbd8ffa87 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -33,10 +33,6 @@ default = [ "web_screen_reader", "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/). @@ -82,16 +78,18 @@ web_screen_reader = ["web-sys/SpeechSynthesis", "web-sys/SpeechSynthesisUtteranc ## ## This overrides the `glow` feature. ## -## 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"] } -## ``` -## ## 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 = [ diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index 384b8e918..5e6adb1b4 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), @@ -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, @@ -484,7 +484,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. @@ -524,7 +524,7 @@ impl Default for WebOptions { #[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 +561,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 +571,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: #[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 +622,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 +655,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 +705,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 +764,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 b42674052..c5358527a 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()), 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/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 4f4bd518a..d8c209205 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -84,9 +84,9 @@ impl AppRunner { #[cfg(feature = "glow")] get_proc_address: None, - #[cfg(all(feature = "wgpu", not(feature = "glow")))] + #[cfg(all(feature = "wgpu_no_default_features", not(feature = "glow")))] wgpu_render_state: painter.render_state(), - #[cfg(all(feature = "wgpu", feature = "glow"))] + #[cfg(all(feature = "wgpu_no_default_features", feature = "glow"))] wgpu_render_state: None, }; let app = app_creator(&cc).map_err(|err| err.to_string())?; @@ -98,9 +98,9 @@ impl AppRunner { #[cfg(feature = "glow")] gl: Some(painter.gl().clone()), - #[cfg(all(feature = "wgpu", not(feature = "glow")))] + #[cfg(all(feature = "wgpu_no_default_features", not(feature = "glow")))] wgpu_render_state: painter.render_state(), - #[cfg(all(feature = "wgpu", feature = "glow"))] + #[cfg(all(feature = "wgpu_no_default_features", feature = "glow"))] wgpu_render_state: None, }; diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index fdc9d2123..1cfdbb3f3 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -23,7 +23,7 @@ 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; @@ -33,9 +33,9 @@ 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")))] +#[cfg(all(feature = "wgpu_no_default_features", not(feature = "glow")))] pub(crate) type ActiveWebPainter = web_painter_wgpu::WebPainterWgpu; pub use backend::*; From 115adac41d408bd46be4295c3d837256553dabca Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 12 Nov 2025 22:26:37 +0100 Subject: [PATCH 23/43] Add `Response::total_drag_delta` and `PointerState::total_drag_delta` (#7708) Useful in many cases. In a follow-up PR I will use it to prevent drift when dragging/resizing windows --- crates/egui/src/input_state/mod.rs | 5 +++++ crates/egui/src/response.rs | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index d87788162..0c99b6ac3 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -1263,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)] diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 0159a1f5e..7df843dfc 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. From 5e6615a129ea347b0ec1aece7e5b3a5af30d2e72 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 12 Nov 2025 22:40:04 +0100 Subject: [PATCH 24/43] Prevent drift when resizing and moving windows (#7709) * Follows https://github.com/emilk/egui/pull/7708 * Related to #202 This fixes a particular issue where resizing a window would move it, and resizing it back would not restore it. You can still move a window by resizing it (I didn't focus on that bug here), but at least now the window will return to its original position when you move back the mouse. --- crates/egui/src/containers/area.rs | 12 +++++++++++- crates/egui/src/containers/window.rs | 27 +++++++++++++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 1c3e058b3..f1e12bf73 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()) diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index c3bbb760c..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: From 9a073d939905b9f9a0d2cf63127e23101c9831b5 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 13 Nov 2025 10:52:46 +0100 Subject: [PATCH 25/43] Prevent widgets sometimes appearing to move relative to each other (#7710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sometimes when moving a window, having a tooltip attached to the mouse pointer, or scrolling a `ScrollArea`, you would see this disturbing effect: ![drift-bug](https://github.com/user-attachments/assets/013a5f49-ee02-417c-8441-1e1a0369e8bd) This is caused by us rounding many visual elements (lines, rectangles, text, …) to physical pixels in order to keep them sharp. If the window/tooltip itself is not rounded to a physical pixel, then you can get this behavior. So from now on the position of all areas/windows/tooltips/popups/ScrollArea gets rounded to the closes pixel. * Unlocked by https://github.com/emilk/egui/pull/7709 --- crates/egui/src/containers/area.rs | 25 +++++++++++++------ crates/egui/src/containers/scroll_area.rs | 8 ++++++ .../tests/snapshots/imageviewer.png | 4 +-- .../tests/snapshots/demos/Input Test.png | 2 +- .../tests/snapshots/demos/Misc Demos.png | 4 +-- .../tests/snapshots/demos/Panels.png | 4 +-- .../tests/snapshots/demos/Table.png | 4 +-- .../tests/snapshots/demos/Window Options.png | 4 +-- 8 files changed, 37 insertions(+), 18 deletions(-) diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index f1e12bf73..3ebac1d65 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -553,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(); @@ -580,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/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index d63a2ab59..4d952d315 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -2,6 +2,8 @@ use std::ops::{Add, AddAssign, BitOr, BitOrAssign}; +use emath::GuiRounding as _; + use crate::{ 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, @@ -748,6 +750,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)) diff --git a/crates/egui_demo_app/tests/snapshots/imageviewer.png b/crates/egui_demo_app/tests/snapshots/imageviewer.png index e1d518a96..b0d60672b 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:128ca4e741995ffcdc07b027407d63911ded6c94fe3fe1dd0efecbf9408fb3af +size 99871 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 From 9875f226585c3568c71bd22e976ff5902376270e Mon Sep 17 00:00:00 2001 From: WickedShell Date: Thu, 13 Nov 2025 02:53:39 -0700 Subject: [PATCH 26/43] Fix double negative in documentation (#7711) The double negative of not undefined conflicted with the example given in parens, This just removes the double negative to agree with the rest of the doc line. I have *not* audited to see if this ordering actually is strictly forced elsewhere. (Apologies for the smallest documentation pull request ever) * [x] I have followed the instructions in the PR template --- crates/egui/src/containers/modal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 51b0d0e4b951eefec5c6858d2f52e718f06095a1 Mon Sep 17 00:00:00 2001 From: Stefan Tammer <34222573+StT191@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:16:10 +0100 Subject: [PATCH 27/43] [egui-wgpu] Put the `capture` module behind a feature flag, make the `egui` dependency optional (#7698) This PR enables users of `egui-wgpu` to render `epaint` primitives without having to bring in the complete `egui` crate and all it's dependencies. --------- Co-authored-by: Emil Ernerfeldt --- crates/eframe/Cargo.toml | 3 ++- crates/egui-wgpu/Cargo.toml | 7 +++++-- crates/egui-wgpu/src/lib.rs | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index fbd8ffa87..d5ef8e744 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -135,6 +135,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 @@ -240,7 +241,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/egui-wgpu/Cargo.toml b/crates/egui-wgpu/Cargo.toml index cd897b63e..c514e0a49 100644 --- a/crates/egui-wgpu/Cargo.toml +++ b/crates/egui-wgpu/Cargo.toml @@ -27,8 +27,11 @@ rustdoc-args = ["--generate-link-to-definition"] [features] 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"] @@ -47,7 +50,6 @@ fragile-send-sync-non-atomic-wasm = ["wgpu/fragile-send-sync-non-atomic-wasm"] 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 @@ -62,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`]. From 9ef610e16b243d8f71a21d3e0eb76c195fc224f7 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 13 Nov 2025 11:16:23 +0100 Subject: [PATCH 28/43] Make `wgpu` the default renderer for `eframe` and egui.rs (#7615) * Closes https://github.com/emilk/egui/issues/5889 See the above issue for motivation. To use glow instead, disable the default features of `eframe` and opt-in to `glow`. This also changes egui.rs to use wgpu, which means WebGPU when available, and WebGL otherwise --- Cargo.toml | 2 + crates/eframe/Cargo.toml | 18 +++++-- crates/eframe/src/epi.rs | 11 +++- crates/eframe/src/web/app_runner.rs | 62 ++++++++++++++++++----- crates/eframe/src/web/mod.rs | 4 -- crates/eframe/src/web/web_painter_glow.rs | 2 +- crates/eframe/src/web/web_painter_wgpu.rs | 14 ++--- crates/egui_demo_app/Cargo.toml | 2 +- scripts/build_demo_web.sh | 18 +++---- 9 files changed, 90 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dfdb595c3..1569299ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -232,6 +232,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" @@ -329,6 +330,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/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index d5ef8e744..6924633f1 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -28,9 +28,9 @@ workspace = true default = [ "accesskit", "default_fonts", - "glow", "wayland", # Required for Linux support (including CI!) "web_screen_reader", + "wgpu", "winit/default", "x11", ] @@ -52,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. @@ -74,9 +78,15 @@ 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`. +## +## 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`]. diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index 5e6adb1b4..d6c9a10b8 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -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. @@ -519,6 +523,9 @@ 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")] @@ -592,8 +599,8 @@ impl Default for Renderer { #[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_no_default_features")] return Self::Wgpu; diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index d8c209205..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 { @@ -79,15 +117,13 @@ impl AppRunner { 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_no_default_features", not(feature = "glow")))] - wgpu_render_state: painter.render_state(), - #[cfg(all(feature = "wgpu_no_default_features", 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())?; @@ -96,12 +132,10 @@ impl AppRunner { storage: Some(Box::new(storage)), #[cfg(feature = "glow")] - gl: Some(painter.gl().clone()), + gl, - #[cfg(all(feature = "wgpu_no_default_features", not(feature = "glow")))] - wgpu_render_state: painter.render_state(), - #[cfg(all(feature = "wgpu_no_default_features", feature = "glow"))] - wgpu_render_state: None, + #[cfg(feature = "wgpu_no_default_features")] + wgpu_render_state, }; let needs_repaint: std::sync::Arc = diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index 1cfdbb3f3..ac4c637db 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -30,13 +30,9 @@ mod web_painter; #[cfg(feature = "glow")] mod web_painter_glow; -#[cfg(feature = "glow")] -pub(crate) type ActiveWebPainter = web_painter_glow::WebPainterGlow; #[cfg(feature = "wgpu_no_default_features")] mod web_painter_wgpu; -#[cfg(all(feature = "wgpu_no_default_features", 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_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/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 From ecee85fc6b2dab422ca1a5fe5591238facae7fa2 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 13 Nov 2025 13:52:13 +0100 Subject: [PATCH 29/43] Fix `ui.response().interact(Sense::click())` being flakey (#7713) This fixes calls to `ui.response().interact(Sense::click())` being flakey. Since egui checks widget interactions at the beginning of the frame, based on the responses from last frame, we need to ensure that we always call `create_widget` on `interact` calls, otherwise there can be a feedback loop where the `Sense` egui acts on flips back and forth between frames. Without the fix in `interact`, both the asserts in the new test fail. Here is a video where I experienced the bug, showing the sense switching every frame. Every other click would fail to be detected. https://github.com/user-attachments/assets/6be7ca0e-b50f-4d30-bf87-bbb80c319f3b Also note, usually it's better to use `UiBuilder::sense()` to give a Ui some sense, but sometimes you don't have the flexibility, e.g. in a `Ui` callback from some code external to your project. --- crates/egui/src/response.rs | 7 ++-- tests/egui_tests/tests/regression_tests.rs | 45 +++++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 7df843dfc..e89cb5252 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -724,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 { diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index 1ee197cb5..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 _; @@ -75,3 +75,46 @@ fn combobox_should_have_value() { 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!"); +} From 01770be13ee4513a960a7db8118b6981e907eb64 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 13 Nov 2025 11:58:03 +0100 Subject: [PATCH 30/43] Update changelogs and version for 0.33.2 --- CHANGELOG.md | 16 ++++++++++++ Cargo.lock | 32 ++++++++++++------------ Cargo.toml | 26 +++++++++---------- crates/ecolor/CHANGELOG.md | 4 +++ crates/eframe/CHANGELOG.md | 5 ++++ crates/egui-wgpu/CHANGELOG.md | 4 +++ crates/egui-winit/CHANGELOG.md | 4 +++ crates/egui_extras/CHANGELOG.md | 4 +++ crates/egui_glow/CHANGELOG.md | 4 +++ crates/egui_kittest/CHANGELOG.md | 4 +++ crates/egui_kittest/Cargo.toml | 2 +- crates/emath/CHANGELOG.md | 5 ++++ crates/epaint/CHANGELOG.md | 4 +++ crates/epaint_default_fonts/CHANGELOG.md | 4 +++ 14 files changed, 88 insertions(+), 30 deletions(-) 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 cecaf6935..61bf459b5 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.1" +version = "0.33.2" dependencies = [ "dify", "document-features", @@ -1463,7 +1463,7 @@ dependencies = [ [[package]] name = "egui_tests" -version = "0.33.0" +version = "0.33.2" dependencies = [ "egui", "egui_extras", @@ -1493,7 +1493,7 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "emath" -version = "0.33.0" +version = "0.33.2" dependencies = [ "bytemuck", "document-features", @@ -1591,7 +1591,7 @@ dependencies = [ [[package]] name = "epaint" -version = "0.33.0" +version = "0.33.2" dependencies = [ "ab_glyph", "ahash", @@ -1613,7 +1613,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.33.0" +version = "0.33.2" [[package]] name = "equivalent" @@ -3425,7 +3425,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "popups" -version = "0.33.0" +version = "0.33.2" dependencies = [ "eframe", "env_logger", @@ -5748,7 +5748,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 1569299ec..b33ca445c 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.1", 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" 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/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-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_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_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 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) diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index 38ff7349e..1de8ce7ac 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "egui_kittest" -version = "0.33.1" +version.workspace = true authors = ["Lucas Meurer ", "Emil Ernerfeldt "] description = "Testing library for egui based on kittest and AccessKit" edition.workspace = true 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/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_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 From dc0acd2dd1c8fa0b34b7c03390d589a6206cfb0e Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 17 Nov 2025 05:10:43 +0100 Subject: [PATCH 31/43] clippy +nightly fix (#7723) --- crates/egui/src/containers/menu.rs | 11 +++++------ crates/egui/src/memory/mod.rs | 3 +-- crates/egui/src/menu.rs | 2 +- crates/egui/src/ui_stack.rs | 2 +- crates/egui_demo_lib/src/demo/misc_demo_window.rs | 2 +- crates/epaint/src/shapes/shape.rs | 2 +- tests/test_viewports/src/main.rs | 2 +- 7 files changed, 11 insertions(+), 13 deletions(-) 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/memory/mod.rs b/crates/egui/src/memory/mod.rs index 6192f3e72..d215a3bec 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -1272,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..4d746c074 100644 --- a/crates/egui/src/menu.rs +++ b/crates/egui/src/menu.rs @@ -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/ui_stack.rs b/crates/egui/src/ui_stack.rs index 0122f5681..4136218bd 100644 --- a/crates/egui/src/ui_stack.rs +++ b/crates/egui/src/ui_stack.rs @@ -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_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/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/tests/test_viewports/src/main.rs b/tests/test_viewports/src/main.rs index ab31a4ece..a862dbd32 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 { From f74b7c7e79c82c119141e97dccff6eed9b9b682c Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 18 Nov 2025 06:24:03 +0100 Subject: [PATCH 32/43] Paint mouse cursor in kittest snapshot images (#7721) Very simple triangle shape, but helps understand why a widget has a hovered effect --- .../egui_demo_app/tests/snapshots/clock.png | 4 ++-- .../tests/snapshots/custom3d.png | 4 ++-- .../tests/snapshots/easymarkeditor.png | 4 ++-- .../tests/snapshots/imageviewer.png | 4 ++-- .../tests/snapshots/modals_2.png | 4 ++-- .../tests/snapshots/modals_3.png | 4 ++-- ...rop_should_prevent_focusing_lower_area.png | 4 ++-- crates/egui_kittest/src/lib.rs | 23 ++++++++++++++++++- .../tests/snapshots/combobox_opened.png | 4 ++-- .../tests/snapshots/menu/closed_hovered.png | 4 ++-- .../tests/snapshots/menu/opened.png | 4 ++-- .../tests/snapshots/menu/submenu.png | 4 ++-- .../tests/snapshots/menu/subsubmenu.png | 4 ++-- .../tests/snapshots/readme_example.png | 4 ++-- .../tests/snapshots/test_tooltip_shown.png | 4 ++-- .../hovering_should_preserve_text_format.png | 4 ++-- .../tests/snapshots/visuals/button.png | 4 ++-- .../tests/snapshots/visuals/button_image.png | 4 ++-- .../visuals/button_image_shortcut.png | 4 ++-- .../button_image_shortcut_selected.png | 4 ++-- .../tests/snapshots/visuals/checkbox.png | 4 ++-- .../snapshots/visuals/checkbox_checked.png | 4 ++-- .../tests/snapshots/visuals/drag_value.png | 4 ++-- .../tests/snapshots/visuals/radio.png | 4 ++-- .../tests/snapshots/visuals/radio_checked.png | 4 ++-- .../snapshots/visuals/selectable_value.png | 4 ++-- .../visuals/selectable_value_selected.png | 4 ++-- .../tests/snapshots/visuals/slider.png | 4 ++-- .../tests/snapshots/visuals/text_edit.png | 4 ++-- .../snapshots/visuals/text_edit_clip.png | 4 ++-- .../snapshots/visuals/text_edit_no_clip.png | 4 ++-- .../visuals/text_edit_placeholder_clip.png | 4 ++-- 32 files changed, 84 insertions(+), 63 deletions(-) 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 b0d60672b..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:128ca4e741995ffcdc07b027407d63911ded6c94fe3fe1dd0efecbf9408fb3af -size 99871 +oid sha256:44ea7ac8c8e22eb51fbcb63f00c8510de0e6ae126d19ab44c5d708d979b5362b +size 100345 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_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 33a188ea4..fc8b8efbc 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -657,7 +657,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 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/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/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 index f44900fa5..70c4bfe8f 100644 --- a/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf4236b1a8f63d184cd780c334d9f996e4d47817a96a29f0d81658d2d897597f -size 10529 +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 index 7329c49cf..a5bda4b8f 100644 --- a/tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7a63953853f526b83f80d63335b03e60258ea9a3416d19f8ed57d746b5c551d -size 21557 +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 index e1a15cf7d..e49bb4414 100644 --- a/tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f7d802a4de7e30f8d254cab6d9ca127866c104c1738103bc4a579917e8f42d3 -size 9850 +oid sha256:eb3230e609246415501d89984bb59ee1dad1241b8054009e7a5108efe3965904 +size 10880 From 178f3c91980c1d49b7b55be741dd8a0c8e735ebd Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 18 Nov 2025 15:30:02 +0100 Subject: [PATCH 33/43] Add `ScrollArea::content_margin` (#7722) * Part of https://github.com/emilk/egui/issues/5605 * Part of https://github.com/emilk/egui/issues/3385 --- crates/egui/src/containers/scroll_area.rs | 36 +++++++++++++++++++++-- crates/egui/src/style.rs | 19 ++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 4d952d315..5ed4c31f3 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -1,8 +1,11 @@ +//! 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, Response, Sense, Ui, UiBuilder, @@ -258,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`. /// @@ -294,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. @@ -346,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, } @@ -593,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" @@ -644,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, @@ -683,6 +701,7 @@ impl ScrollArea { on_drag_cursor, scroll_source, wheel_scroll_multiplier, + content_margin: _, // Used elsewhere stick_to_end, animated, } = self; @@ -983,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, diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 9982c05bb..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"); @@ -1824,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(); From 5b6a0196f91bfd5222d42c8f47595a8c297cc815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Par=C3=A9-Simard?= Date: Tue, 18 Nov 2025 09:46:01 -0500 Subject: [PATCH 34/43] Add `Panel` to replace `SidePanel` and `TopBottomPanel` (#5659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This combines `SidePanel` and `TopBottomPanel` into a single `Panel`. The old types are still there as type aliases, but are deprecated. `.min_width(…)` etc are now called `.min_size(…)` etc. Again, the old names are still there, but deprecated. (edited by @emilk) --------- Co-authored-by: Emil Ernerfeldt --- crates/eframe/src/epi.rs | 2 +- crates/egui/src/containers/mod.rs | 4 +- crates/egui/src/containers/panel.rs | 1395 ++++++++--------- crates/egui/src/context.rs | 2 +- crates/egui/src/lib.rs | 6 +- crates/egui/src/menu.rs | 2 +- crates/egui/src/ui.rs | 2 +- crates/egui/src/ui_stack.rs | 8 +- .../src/accessibility_inspector.rs | 11 +- crates/egui_demo_app/src/apps/http_app.rs | 2 +- crates/egui_demo_app/src/apps/image_viewer.rs | 6 +- crates/egui_demo_app/src/wrap_app.rs | 4 +- .../src/demo/demo_app_windows.rs | 10 +- crates/egui_demo_lib/src/demo/panels.rs | 20 +- crates/egui_demo_lib/src/demo/tooltips.rs | 2 +- .../src/easy_mark/easy_mark_editor.rs | 2 +- crates/egui_glow/examples/pure_glow.rs | 2 +- examples/hello_android/src/lib.rs | 2 +- tests/test_size_pass/src/main.rs | 2 +- tests/test_ui_stack/src/main.rs | 6 +- 20 files changed, 695 insertions(+), 795 deletions(-) diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index d6c9a10b8..a7bcfd6ef 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -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. /// 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/panel.rs b/crates/egui/src/containers/panel.rs index 6e582b428..eb60f5f21 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(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!"); @@ -1158,6 +1044,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 +1064,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/context.rs b/crates/egui/src/context.rs index 587c3b377..2ca7af4fc 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -764,7 +764,7 @@ 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`]. + /// 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`]. /// diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 3071f7196..bd4319f0b 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: //! diff --git a/crates/egui/src/menu.rs b/crates/egui/src/menu.rs index 4d746c074..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"] diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 08bb9cee5..3c7fca2f3 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, diff --git a/crates/egui/src/ui_stack.rs b/crates/egui/src/ui_stack.rs index 4136218bd..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`]. diff --git a/crates/egui_demo_app/src/accessibility_inspector.rs b/crates/egui_demo_app/src/accessibility_inspector.rs index f721b710c..9ba3a8082 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| { diff --git a/crates/egui_demo_app/src/apps/http_app.rs b/crates/egui_demo_app/src/apps/http_app.rs index f16aa5969..2630fa862 100644 --- a/crates/egui_demo_app/src/apps/http_app.rs +++ b/crates/egui_demo_app/src/apps/http_app.rs @@ -61,7 +61,7 @@ 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| { + egui::Panel::bottom("http_bottom").show(ctx, |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!()) diff --git a/crates/egui_demo_app/src/apps/image_viewer.rs b/crates/egui_demo_app/src/apps/image_viewer.rs index c341d2385..052996eef 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 { @@ -52,7 +50,7 @@ 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| { + egui::Panel::top("url bar").show(ctx, |ui| { ui.horizontal_centered(|ui| { let label = ui.label("URI:"); ui.text_edit_singleline(&mut self.uri_edit_text) @@ -73,7 +71,7 @@ impl eframe::App for ImageViewer { }); }); - egui::SidePanel::new(Side::Left, "controls").show(ctx, |ui| { + egui::Panel::left("controls").show(ctx, |ui| { // uv ui.label("UV"); ui.add(Slider::new(&mut self.image_options.uv.min.x, 0.0..=1.0).text("min x")); diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index bec0aacf3..c118f280c 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -295,7 +295,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| { @@ -341,7 +341,7 @@ impl WrapApp { let mut cmd = Command::Nothing; - egui::SidePanel::left("backend_panel") + egui::Panel::left("backend_panel") .resizable(false) .show_animated(ctx, is_open, |ui| { ui.add_space(4.0); 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..179543680 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -236,7 +236,7 @@ impl DemoWindows { } fn mobile_top_bar(&mut self, ctx: &Context) { - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + egui::Panel::top("menu_bar").show(ctx, |ui| { menu::MenuBar::new() .config(menu::MenuConfig::new().style(StyleModifier::default())) .ui(ui, |ui| { @@ -262,10 +262,10 @@ impl DemoWindows { } fn desktop_ui(&mut self, ctx: &Context) { - egui::SidePanel::right("egui_demo_panel") + egui::Panel::right("egui_demo_panel") .resizable(false) - .default_width(160.0) - .min_width(160.0) + .default_size(160.0) + .min_size(160.0) .show(ctx, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { @@ -289,7 +289,7 @@ impl DemoWindows { self.demo_list_ui(ui); }); - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + egui::Panel::top("menu_bar").show(ctx, |ui| { menu::MenuBar::new().ui(ui, |ui| { file_menu_button(ui); }); diff --git a/crates/egui_demo_lib/src/demo/panels.rs b/crates/egui_demo_lib/src/demo/panels.rs index f94513866..55771c1a1 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"); 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/easy_mark/easy_mark_editor.rs b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs index 9a66b8bc5..28e12630e 100644 --- a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs +++ b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs @@ -33,7 +33,7 @@ impl Default for EasyMarkEditor { impl EasyMarkEditor { pub fn panels(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::bottom("easy_mark_bottom").show(ctx, |ui| { + egui::Panel::bottom("easy_mark_bottom").show(ctx, |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!()) diff --git a/crates/egui_glow/examples/pure_glow.rs b/crates/egui_glow/examples/pure_glow.rs index a56b85bc1..3671c8d79 100644 --- a/crates/egui_glow/examples/pure_glow.rs +++ b/crates/egui_glow/examples/pure_glow.rs @@ -218,7 +218,7 @@ impl winit::application::ApplicationHandler 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/examples/hello_android/src/lib.rs b/examples/hello_android/src/lib.rs index c138b97e1..1de7684f5 100644 --- a/examples/hello_android/src/lib.rs +++ b/examples/hello_android/src/lib.rs @@ -41,7 +41,7 @@ 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); }); 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() From d53a4a9c1d36bc723754f55b074ab976f0920556 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 18 Nov 2025 15:56:35 +0100 Subject: [PATCH 35/43] Update docs to reflect that wgpu is the default renderer (#7719) I missed a few parts when merging * https://github.com/emilk/egui/pull/7615 --- .github/workflows/rust.yml | 2 +- ARCHITECTURE.md | 5 ++++- README.md | 2 +- crates/eframe/README.md | 6 +++--- scripts/wasm_bindgen_check.sh | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5d30d7e31..f71588545 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -89,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/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/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/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 From d5869bfeaf0a76a70a0abb723f340aa06fff618a Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 21 Nov 2025 20:22:01 +0100 Subject: [PATCH 36/43] Remove some uses of top-level panels in our examples (#7729) We're phasing out top-level panels (panels that use `Context` directly, instead of being inside another `Ui`). As a first step, stop using them in our demo library and application. * Part of https://github.com/emilk/egui/issues/3524 --- crates/egui/src/containers/panel.rs | 14 ++- .../egui_demo_app/src/apps/custom3d_glow.rs | 39 ++++---- .../egui_demo_app/src/apps/custom3d_wgpu.rs | 39 ++++---- crates/egui_demo_app/src/apps/http_app.rs | 10 +-- crates/egui_demo_app/src/apps/image_viewer.rs | 12 +-- crates/egui_demo_app/src/lib.rs | 8 ++ crates/egui_demo_app/src/wrap_app.rs | 88 +++++++++---------- crates/egui_demo_lib/benches/benchmark.rs | 16 +++- .../src/demo/demo_app_windows.rs | 56 ++++++------ crates/egui_demo_lib/src/demo/panels.rs | 1 + .../src/easy_mark/easy_mark_editor.rs | 6 +- crates/egui_demo_lib/src/lib.rs | 8 +- examples/hello_android/src/lib.rs | 4 +- 13 files changed, 165 insertions(+), 136 deletions(-) diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index eb60f5f21..670e4758b 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -976,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, 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 2630fa862..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::Panel::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 052996eef..11cc68b6b 100644 --- a/crates/egui_demo_app/src/apps/image_viewer.rs +++ b/crates/egui_demo_app/src/apps/image_viewer.rs @@ -48,15 +48,15 @@ impl Default for ImageViewer { } } -impl eframe::App for ImageViewer { - fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { - egui::Panel::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(); } @@ -71,7 +71,7 @@ impl eframe::App for ImageViewer { }); }); - egui::Panel::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")); @@ -197,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 c118f280c..1d0a2390e 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,15 +37,12 @@ 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| { +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(Some(crate::seconds_since_midnight()))); }); @@ -64,13 +57,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 +148,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 +202,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 +238,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() @@ -306,11 +299,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 +328,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::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 +387,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_lib/benches/benchmark.rs b/crates/egui_demo_lib/benches/benchmark.rs index 48e7d5207..90468770b 100644 --- a/crates/egui_demo_lib/benches/benchmark.rs +++ b/crates/egui_demo_lib/benches/benchmark.rs @@ -29,7 +29,9 @@ pub fn criterion_benchmark(c: &mut Criterion) { c.bench_function("demo_with_tessellate__realistic", |b| { b.iter(|| { let full_output = ctx.run(RawInput::default(), |ctx| { - demo_windows.ui(ctx); + egui::CentralPanel::default().show(ctx, |ui| { + demo_windows.ui(ui); + }); }); ctx.tessellate(full_output.shapes, full_output.pixels_per_point) }); @@ -38,13 +40,17 @@ 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); + egui::CentralPanel::default().show(ctx, |ui| { + demo_windows.ui(ui); + }); }) }); }); let full_output = ctx.run(RawInput::default(), |ctx| { - demo_windows.ui(ctx); + egui::CentralPanel::default().show(ctx, |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)); @@ -58,7 +64,9 @@ pub fn criterion_benchmark(c: &mut Criterion) { c.bench_function("demo_full_no_tessellate", |b| { b.iter(|| { ctx.run(RawInput::default(), |ctx| { - demo_windows.ui(ctx); + egui::CentralPanel::default().show(ctx, |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 179543680..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::Panel::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) { + fn desktop_ui(&mut self, ui: &mut egui::Ui) { egui::Panel::right("egui_demo_panel") .resizable(false) .default_size(160.0) .min_size(160.0) - .show(ctx, |ui| { + .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::Panel::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/panels.rs b/crates/egui_demo_lib/src/demo/panels.rs index 55771c1a1..957166c4f 100644 --- a/crates/egui_demo_lib/src/demo/panels.rs +++ b/crates/egui_demo_lib/src/demo/panels.rs @@ -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/easy_mark/easy_mark_editor.rs b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs index 28e12630e..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::Panel::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..7ba48b0d8 100644 --- a/crates/egui_demo_lib/src/lib.rs +++ b/crates/egui_demo_lib/src/lib.rs @@ -74,7 +74,9 @@ 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); + egui::CentralPanel::default().show(ctx, |ui| { + demo_windows.ui(ui); + }); }); let clipped_primitives = ctx.tessellate(full_output.shapes, full_output.pixels_per_point); assert!(!clipped_primitives.is_empty()); @@ -93,7 +95,9 @@ 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); + egui::CentralPanel::default().show(ctx, |ui| { + demo_windows.ui(ui); + }); }); let clipped_primitives = ctx.tessellate(full_output.shapes, full_output.pixels_per_point); assert!( diff --git a/examples/hello_android/src/lib.rs b/examples/hello_android/src/lib.rs index 1de7684f5..87007c110 100644 --- a/examples/hello_android/src/lib.rs +++ b/examples/hello_android/src/lib.rs @@ -45,6 +45,8 @@ impl eframe::App for MyApp { ui.set_height(32.0); }); - self.demo.ui(ctx); + egui::CentralPanel::default().show(ctx, |ui| { + self.demo.ui(ui); + }); } } From 8d3539b6da372e966b6d57a474e3a9683c7818f8 Mon Sep 17 00:00:00 2001 From: Yichi Zhang <109252977+YichiZhang0613@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:56:24 +0800 Subject: [PATCH 37/43] test_viewports: fix assertion (#7693) * [x] I have followed the instructions in the PR template These assertions allows col == COLS, while when col == COLS, array may be out of bounds. In `fn init`, `for i in 0..COLS {self.insert(...` confirms the assertions' predicate col <= COLS should be changed into col < COLS. --------- Co-authored-by: Emil Ernerfeldt --- tests/test_viewports/src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_viewports/src/main.rs b/tests/test_viewports/src/main.rs index a862dbd32..49b212e4b 100644 --- a/tests/test_viewports/src/main.rs +++ b/tests/test_viewports/src/main.rs @@ -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)] From a624f37e2da32da4b166f71bc95b3d68836673fe Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 25 Nov 2025 08:52:16 +0100 Subject: [PATCH 38/43] Add `Context::run_ui` (#7736) * Part of https://github.com/emilk/egui/issues/3524 Adds `Context::run_ui` as a convenience wrapper around `Context::run`. This on the path to deprecate `run` and use a top-level `Ui` as the entry-point for all of egui. --- crates/egui/src/context.rs | 51 ++++++++++++++++++++++- crates/egui/src/lib.rs | 10 ++--- crates/egui_demo_lib/benches/benchmark.rs | 24 ++++------- crates/egui_demo_lib/src/lib.rs | 12 ++---- 4 files changed, 66 insertions(+), 31 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 2ca7af4fc..d644b9610 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -758,6 +758,47 @@ impl Context { writer(&mut self.0.write()) } + /// 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. + /// + /// 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`, @@ -781,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()); diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index bd4319f0b..d756caf75 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -691,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()); }); } @@ -700,10 +700,8 @@ 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); }); } diff --git a/crates/egui_demo_lib/benches/benchmark.rs b/crates/egui_demo_lib/benches/benchmark.rs index 90468770b..1d791cd6d 100644 --- a/crates/egui_demo_lib/benches/benchmark.rs +++ b/crates/egui_demo_lib/benches/benchmark.rs @@ -28,10 +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| { - egui::CentralPanel::default().show(ctx, |ui| { - demo_windows.ui(ui); - }); + let full_output = ctx.run_ui(RawInput::default(), |ui| { + demo_windows.ui(ui); }); ctx.tessellate(full_output.shapes, full_output.pixels_per_point) }); @@ -39,18 +37,14 @@ pub fn criterion_benchmark(c: &mut Criterion) { c.bench_function("demo_no_tessellate", |b| { b.iter(|| { - ctx.run(RawInput::default(), |ctx| { - egui::CentralPanel::default().show(ctx, |ui| { - demo_windows.ui(ui); - }); + ctx.run_ui(RawInput::default(), |ui| { + demo_windows.ui(ui); }) }); }); - let full_output = ctx.run(RawInput::default(), |ctx| { - egui::CentralPanel::default().show(ctx, |ui| { - demo_windows.ui(ui); - }); + 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)); @@ -63,10 +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| { - egui::CentralPanel::default().show(ctx, |ui| { - demo_windows.ui(ui); - }); + ctx.run_ui(RawInput::default(), |ui| { + demo_windows.ui(ui); }) }); }); diff --git a/crates/egui_demo_lib/src/lib.rs b/crates/egui_demo_lib/src/lib.rs index 7ba48b0d8..e0a257224 100644 --- a/crates/egui_demo_lib/src/lib.rs +++ b/crates/egui_demo_lib/src/lib.rs @@ -73,10 +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| { - egui::CentralPanel::default().show(ctx, |ui| { - demo_windows.ui(ui); - }); + 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()); @@ -94,10 +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| { - egui::CentralPanel::default().show(ctx, |ui| { - demo_windows.ui(ui); - }); + 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!( From 8b8595b45b4c283a2a654ada081342079170e3ab Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 25 Nov 2025 13:15:28 +0100 Subject: [PATCH 39/43] Treat `.` as a word-splitter in text navigation (#7741) When using option + arrow keys (Mac) or ctrl + arrow keys (Windows), you navigate a full word. Previously egui would ignore `.` in the text, so that `www.example.com` would be considered a full word. This is inconsistent with how the rest of macOS works. With this PR, cursor navigation in `www.example.com` will move the cursor between the dots. This makes editing code with egui a lot nicer. --- .../src/text_selection/text_cursor_state.rs | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) 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, From a19629ef4ac55e36fbaf82ccb871f0a4407b3a84 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Tue, 25 Nov 2025 14:51:18 +0100 Subject: [PATCH 40/43] Add `kittest.toml` config file (#7643) * part of https://github.com/rerun-io/rerun/issues/10991 --------- Co-authored-by: lucasmerlin <8009393+lucasmerlin@users.noreply.github.com> --- Cargo.lock | 32 +++- Cargo.toml | 1 + .../snapshots/rendering_test/dpi_1.67.png | 4 +- crates/egui_kittest/Cargo.toml | 6 +- crates/egui_kittest/README.md | 27 +++ crates/egui_kittest/src/config.rs | 154 ++++++++++++++++++ crates/egui_kittest/src/lib.rs | 1 + crates/egui_kittest/src/snapshot.rs | 55 +++++-- kittest.toml | 10 ++ 9 files changed, 273 insertions(+), 17 deletions(-) create mode 100644 crates/egui_kittest/src/config.rs create mode 100644 kittest.toml diff --git a/Cargo.lock b/Cargo.lock index 61bf459b5..404f3563c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1457,7 +1457,9 @@ dependencies = [ "kittest", "open", "pollster", + "serde", "tempfile", + "toml", "wgpu", ] @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index b33ca445c..b9a3432ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" 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_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index 1de8ce7ac..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 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/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 fc8b8efbc..6b196484a 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -11,6 +11,7 @@ mod snapshot; pub use crate::snapshot::*; mod app_kind; +mod config; mod node; mod renderer; #[cfg(feature = "wgpu")] diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index f6511c451..f26741323 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -1,28 +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, } @@ -30,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 @@ -53,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, @@ -123,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(), } } } 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 From de907612b7dc09351d3e8c3b150fdfcf40f12cbd Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Wed, 26 Nov 2025 14:56:19 +0100 Subject: [PATCH 41/43] Enforce consistent snapshot updates (#7744) * Closes https://github.com/emilk/egui/issues/7647 This collects SnapshotResults within the Harness and adds a check to enforce snapshot results are merged in case multiple Harnesses are constructed within a test. This should make snapshot updates via kitdiff/accept_snapshots.sh way more useful since it should now always update all snapshots instead of only the first one per test. --- .../src/demo/tests/tessellation_test.rs | 3 + .../egui_demo_lib/src/demo/widget_gallery.rs | 5 +- crates/egui_demo_lib/tests/image_blending.rs | 2 + crates/egui_demo_lib/tests/misc.rs | 4 + crates/egui_kittest/src/builder.rs | 5 ++ crates/egui_kittest/src/lib.rs | 11 +++ crates/egui_kittest/src/snapshot.rs | 87 +++++++++++++++---- 7 files changed, 100 insertions(+), 17 deletions(-) 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/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/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 af8858bca..8abc69d19 100644 --- a/crates/egui_demo_lib/tests/misc.rs +++ b/crates/egui_demo_lib/tests/misc.rs @@ -3,6 +3,7 @@ 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() @@ -24,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() @@ -49,6 +52,7 @@ fn test_italics() { egui::Theme::Light => "light", } )); + results.extend_harness(&mut harness); } } } diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs index 09b91d26d..87b199c6d 100644 --- a/crates/egui_kittest/src/builder.rs +++ b/crates/egui_kittest/src/builder.rs @@ -166,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, @@ -195,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, @@ -206,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, @@ -247,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) } @@ -267,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/lib.rs b/crates/egui_kittest/src/lib.rs index 6b196484a..71312c352 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -84,6 +84,8 @@ pub struct Harness<'a, State = ()> { #[cfg(feature = "snapshot")] default_snapshot_options: SnapshotOptions, + #[cfg(feature = "snapshot")] + snapshot_results: SnapshotResults, } impl Debug for Harness<'_, State> { @@ -93,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>, @@ -162,6 +165,9 @@ impl<'a, State> Harness<'a, State> { #[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(); @@ -197,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) } @@ -222,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, @@ -725,6 +734,7 @@ impl<'a> Harness<'a> { /// }); /// }); /// ``` + #[track_caller] pub fn new(app: impl FnMut(&egui::Context) + 'a) -> Self { Self::builder().build(app) } @@ -745,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 f26741323..ede19e5bf 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -663,16 +663,16 @@ 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. @@ -688,12 +688,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. @@ -739,6 +735,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. @@ -765,9 +767,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 { @@ -785,17 +800,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() @@ -807,13 +835,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 } @@ -826,7 +855,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() { @@ -836,5 +864,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 + ); + } + } } } From 3fcdab4ebd8a5985c969780880eb003367820a16 Mon Sep 17 00:00:00 2001 From: Ryan Date: Fri, 5 Dec 2025 02:44:06 -0700 Subject: [PATCH 42/43] Typo fix in drag-and-drop documentation (#7750) * [x] I have followed the instructions in the PR template Adding periods to the end of sentences and fixes a grammar mistake on documentation for the drag-and-drop code to become consistent with the rest of the documentation. The documentation for `Ui::dnd_drop_zone` could've used the word "its" instead of "the" to replace "it", but I think "the" more clearly refers to the `Frame` since "its" has been used to refer to the drop-zone already. --- crates/egui/src/context.rs | 4 ++-- crates/egui/src/response.rs | 2 +- crates/egui/src/ui.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index d644b9610..1369ef616 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -4057,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 { @@ -4071,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/response.rs b/crates/egui/src/response.rs index e89cb5252..6b5daead0 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -472,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 diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 3c7fca2f3..07bd512d1 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -3004,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, From 2dbfe3a0838ac23b69a5e051cf0b30448d042486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20G=C3=B6rtler?= Date: Fri, 5 Dec 2025 10:46:34 +0100 Subject: [PATCH 43/43] Enable `or_fun_call` lint to avoid unnecessary allocations (#7754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What From the [lint description](https://rust-lang.github.io/rust-clippy/master/index.html?search=or_fu#or_fun_call): > The function will always be called. This is only bad if it allocates or does some non-trivial amount of work. But also: > If the function has side-effects, not calling it will change the semantic of the program, but you shouldn’t rely on that. > > The lint also cannot figure out whether the function you call is actually expensive to call or not. Still worth it to keep our happy paths clean, imo. --- Cargo.toml | 1 + crates/eframe/src/native/glow_integration.rs | 8 ++++---- crates/egui/src/atomics/atom_kind.rs | 2 +- crates/egui/src/atomics/atom_layout.rs | 4 ++-- crates/egui/src/containers/panel.rs | 2 +- crates/egui/src/containers/popup.rs | 3 ++- crates/egui/src/context.rs | 2 +- crates/egui/src/response.rs | 2 +- crates/egui/src/ui.rs | 8 ++++---- crates/egui/src/widgets/image.rs | 2 +- crates/egui/src/widgets/label.rs | 4 +++- crates/egui/src/widgets/progress_bar.rs | 2 +- crates/egui/src/widgets/text_edit/builder.rs | 4 ++-- crates/egui_demo_app/src/accessibility_inspector.rs | 4 ++-- crates/egui_demo_app/src/wrap_app.rs | 7 +++++-- crates/egui_extras/src/table.rs | 4 ++-- crates/egui_kittest/src/snapshot.rs | 7 ++++--- crates/egui_kittest/src/wgpu.rs | 2 +- crates/epaint/src/shapes/bezier_shape.rs | 9 ++++++--- 19 files changed, 44 insertions(+), 33 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b9a3432ba..0a78d4354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -278,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" diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index c5358527a..e448c6c19 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -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(); 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/panel.rs b/crates/egui/src/containers/panel.rs index 670e4758b..3c52f63a3 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -941,7 +941,7 @@ impl Panel { PanelState::load(ctx, panel.id) .map(get_rect_state_size) .or(panel.default_size) - .unwrap_or(get_spacing_size()) + .unwrap_or_else(get_spacing_size) } } 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/context.rs b/crates/egui/src/context.rs index 1369ef616..9d9d4b53f 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -611,7 +611,7 @@ 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()); diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 6b5daead0..8190f0006 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -433,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 } diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 07bd512d1..d230ed736 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -136,7 +136,7 @@ impl Ui { 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(), @@ -148,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 { @@ -277,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); @@ -287,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, 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/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 6f2da1baa..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, diff --git a/crates/egui_demo_app/src/accessibility_inspector.rs b/crates/egui_demo_app/src/accessibility_inspector.rs index 9ba3a8082..ab8b9270d 100644 --- a/crates/egui_demo_app/src/accessibility_inspector.rs +++ b/crates/egui_demo_app/src/accessibility_inspector.rs @@ -199,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/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 1d0a2390e..87394b503 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -43,8 +43,11 @@ impl DemoApp for FractalClockApp { .stroke(egui::Stroke::NONE) .corner_radius(0) .show(ui, |ui| { - self.fractal_clock - .ui(ui, self.mock_time.or(Some(crate::seconds_since_midnight()))); + self.fractal_clock.ui( + ui, + self.mock_time + .or_else(|| Some(crate::seconds_since_midnight())), + ); }); } } 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_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index ede19e5bf..4d139fcbb 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -264,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}", @@ -272,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 => { @@ -310,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 } => { 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/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);