From bb37c758c7fcd4096e291f15bbd3eed1d492d020 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Tue, 4 Aug 2026 15:33:14 +0200 Subject: [PATCH] Better test --- crates/egui/src/context.rs | 103 +-------------------- crates/egui/src/memory/mod.rs | 9 +- crates/egui_kittest/src/lib.rs | 16 ++-- tests/egui_tests/tests/regression_tests.rs | 89 ++++++++++++++++++ tests/test_background_logic/src/main.rs | 3 +- 5 files changed, 106 insertions(+), 114 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 045520a05..84e5ea486 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1738,7 +1738,7 @@ impl Context { /// /// This is `true` when the integration runs a pass only to keep app logic ticking, /// e.g. because the window is minimized or occluded. - /// Skip any ui work if this is `true`. + /// Don't show any ui while this is `true`. /// /// See [`RawInput::uiless_pass`]. pub fn is_uiless_pass(&self) -> bool { @@ -2642,7 +2642,6 @@ impl ContextImpl { let pixels_per_point = viewport.input.pixels_per_point; // If no ui was shown this pass we skip all book-keeping that assumes ui was shown. - // See `RawInput::uiless_pass`. let uiless_pass = viewport.input.raw.uiless_pass; if !uiless_pass { @@ -4371,106 +4370,6 @@ fn warn_if_rect_changes_id( mod test { use super::Context; - /// A pass with [`RawInput::uiless_pass`] set must leave all ui state alone, - /// so that nothing thinks it was hidden. See . - #[test] - fn test_uiless_pass_preserves_ui_state() { - use crate::{Id, LayerId, RawInput, Window}; - - let ctx = Context::default(); - let window_layer = std::cell::Cell::new(LayerId::background()); - - let run = |uiless_pass: bool| { - let input = RawInput { - uiless_pass, - ..Default::default() - }; - ctx.run_ui(input, |ui| { - if uiless_pass { - return; // The integration shows no ui during a uiless pass. - } - let response = Window::new("My window") - .show(ui.ctx(), |ui| { - ui.button("Click me").request_focus(); - }) - .expect("The window should be open"); - window_layer.set(response.response.layer_id); - }) - .drop_without_applying_deltas(); - }; - - // Two normal passes, so that all the "previous pass" state has settled: - run(false); - run(false); - - let focused = ctx.memory(|m| m.focused()); - assert!(focused.is_some(), "The button should have focus"); - assert!(ctx.memory(|m| m.areas().visible_last_frame(&window_layer.get()))); - - let popup_id = Id::new("My popup"); - ctx.memory_mut(|m| m.open_popup(popup_id)); - - let pass_nr = ctx.cumulative_pass_nr(); - let frame_nr = ctx.cumulative_frame_nr(); - - // A uiless pass must disturb none of it: - run(true); - - assert_eq!(ctx.cumulative_pass_nr(), pass_nr, "Counted as a pass"); - assert_eq!(ctx.cumulative_frame_nr(), frame_nr, "Counted as a frame"); - assert_eq!(ctx.memory(|m| m.focused()), focused, "Lost focus"); - assert!( - ctx.memory(|m| m.areas().visible_last_frame(&window_layer.get())), - "The window would replay its appear-animation" - ); - assert!( - ctx.memory(|m| m.is_popup_open(popup_id)), - "Closed the popup" - ); - - // …and the window is still there on the next normal pass: - run(false); - assert!(ctx.memory(|m| m.areas().visible_last_frame(&window_layer.get()))); - } - - /// A uiless pass never calls [`Context::show_viewport_deferred`], so it must not - /// garbage-collect child viewports — they would pop back up on the next normal pass. - /// See . - #[test] - fn test_uiless_pass_keeps_deferred_viewports() { - use crate::{RawInput, ViewportBuilder, ViewportId}; - - let ctx = Context::default(); - ctx.set_embed_viewports(false); - - let child_id = ViewportId::from_hash_of("My child viewport"); - - // Runs one pass and returns the viewports the backend is told to keep. - let run = |uiless_pass: bool| { - let input = RawInput { - uiless_pass, - ..Default::default() - }; - let output = ctx.run_ui(input, |ui| { - if uiless_pass { - return; // The integration shows no ui during a uiless pass. - } - ui.ctx().show_viewport_deferred( - child_id, - ViewportBuilder::default(), - |_ui, _class| {}, - ); - }); - let has_child = output.viewport_output.contains_key(&child_id); - output.drop_without_applying_deltas(); - has_child - }; - - assert!(run(false), "The child viewport should have been created"); - assert!(run(true), "The child viewport was closed"); - assert!(run(false), "The child viewport should still be there"); - } - #[test] fn test_single_pass() { let ctx = Context::default(); diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 21ac28e4e..7ff87c0e0 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -797,10 +797,13 @@ impl Memory { self.options.begin_pass(new_raw_input); - let focus = self.focus.entry(self.viewport_id).or_default(); if !new_raw_input.uiless_pass { - // No widget will ask for focus this pass, so leave the focus state alone. - focus.begin_pass(new_raw_input); + // No widget will ask for focus during a uiless pass, + // so leave the focus state alone. + self.focus + .entry(self.viewport_id) + .or_default() + .begin_pass(new_raw_input); } } diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 2a8575ef1..e36c37381 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -259,16 +259,18 @@ impl<'a, State> Harness<'a, State> { fn _step(&mut self, sizing_pass: bool) { self.input.predicted_dt = self.step_dt; + let uiless_pass = self.input.uiless_pass; + let mut output = self.ctx.run_ui(self.input.take(), |ui| { self.response = self.app.run(ui, &mut self.state, sizing_pass); }); - self.kittest.update( - output - .platform_output - .accesskit_update - .take() - .expect("AccessKit was disabled"), - ); + if let Some(accesskit_update) = output.platform_output.accesskit_update.take() { + self.kittest.update(accesskit_update); + } else { + // A uiless pass shows no ui, so there is no accessibility tree to update. + // Keep the tree from the last pass that did show ui. + assert!(uiless_pass, "AccessKit was disabled"); + } self.renderer.handle_delta(&mut output.textures_delta); self.output = output; diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index 34527f4cc..62656e866 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -559,3 +559,92 @@ fn tooltip_should_hand_over_to_neighboring_widget() { "Tooltip A should be hidden when hovering Button B" ); } + +/// An integration runs a pass with [`egui::RawInput::uiless_pass`] set when its window is +/// hidden, minimized, or occluded, so that app logic keeps ticking without showing any ui. +/// +/// Such a pass must leave all ui state alone. Otherwise areas think they were hidden and +/// replay their fade-in, popups close, focus is lost, and child viewports pop back up. +/// See . +#[test] +fn uiless_pass_should_not_disturb_ui_state() { + const MENU: &str = "My menu"; + const MENU_ITEM: &str = "Button in my menu"; + const FOCUSED_BUTTON: &str = "Click me"; + + let child_viewport = egui::ViewportId::from_hash_of("My child viewport"); + let area_id = egui::Id::new("My area"); + let area_layer = egui::LayerId::new(egui::Order::Middle, area_id); + + let mut harness = Harness::builder() + .with_size(Vec2::new(400.0, 300.0)) + .build_ui(move |ui| { + // A backend that can open real windows, like eframe: + ui.ctx().set_embed_viewports(false); + + if ui.ctx().is_uiless_pass() { + // The integration shows no ui and viewports during a uiless pass. + return; + } + + ui.ctx() + .show_viewport_deferred(child_viewport, Default::default(), |_ui, _class| {}); + + ui.menu_button(MENU, |ui| { + _ = ui.button(MENU_ITEM); + }); + + egui::Area::new(area_id) + .fixed_pos((150.0, 120.0)) + .show(ui.ctx(), |ui| { + _ = ui.button(FOCUSED_BUTTON); + }); + }); + + harness.get_by_label(MENU).click(); + harness.run(); + // Nothing asks for focus again, so the test fails if egui ever loses it: + harness.get_by_label(FOCUSED_BUTTON).focus(); + harness.run(); + + let assert_state = |harness: &Harness<'_>| { + assert!( + harness + .get_by_label(FOCUSED_BUTTON) + .accesskit_node() + .is_focused(), + "The button lost focus" + ); + harness.get_by_label(MENU_ITEM); // Panics if the menu closed + assert!( + harness + .output() + .viewport_output + .contains_key(&child_viewport), + "The child viewport closed" + ); + assert!( + harness + .ctx + .memory(|m| m.areas().visible_last_frame(&area_layer)), + "Area state reset" + ); + }; + + assert_state(&harness); + + // The window is now occluded, so the integration runs passes without any ui. + // egui keeps the accessibility tree from the last pass that did show ui, + // so we can still query it. + harness.input_mut().uiless_pass = true; + harness.step(); + harness.step(); + + assert_state(&harness); + + // The window is visible again, and everything should be where we left it: + harness.input_mut().uiless_pass = false; + harness.run(); + + assert_state(&harness); +} diff --git a/tests/test_background_logic/src/main.rs b/tests/test_background_logic/src/main.rs index c2af4d870..6ce5b6037 100644 --- a/tests/test_background_logic/src/main.rs +++ b/tests/test_background_logic/src/main.rs @@ -37,6 +37,7 @@ impl eframe::App for App { fn viewport_info(ctx: &egui::Context) -> String { ctx.input(|i| { + use std::fmt::Write as _; let ViewportInfo { minimized, focused, @@ -56,12 +57,10 @@ fn viewport_info(ctx: &egui::Context) -> String { ]; for (name, value) in flags { if let Some(value) = value { - use std::fmt::Write as _; write!(s, " {name}={value}").ok(); } } - use std::fmt::Write as _; write!(s, " uiless_pass={}", i.raw.uiless_pass).ok(); s