From 82d1c63c03d6ea52a8afb7d299a3c2a72773750a Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 17:28:12 +0200 Subject: [PATCH] Let app logic see the window state, and test it `Context::run_logic` now takes the new `RawInput`, and copies only `RawInput::viewports` into the `InputState`, so that `App::logic` can tell that the window is minimized/occluded. The ui input (events, time) is left for the next pass. Co-Authored-By: Claude Opus 5 (1M context) --- crates/eframe/src/native/epi_integration.rs | 12 +-- crates/eframe/src/web/app_runner.rs | 8 +- crates/egui/src/context.rs | 25 +++-- tests/egui_tests/tests/regression_tests.rs | 101 ++++++++++++++++++++ 4 files changed, 129 insertions(+), 17 deletions(-) diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 926070506..d0a19eb51 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -331,17 +331,17 @@ impl EpiIntegration { let close_requested = raw_input.viewport().close_requested(); - // No pass will consume the input, so save it for the next one: + let logic_output = self.egui_ctx.run_logic(&raw_input, |ctx| { + profiling::scope!("App::logic"); + app.logic(ctx, &mut self.frame); + }); + + // No pass consumed the input, so save it for the next one: match &mut self.pending_raw_input { Some(pending) => pending.append(raw_input), None => self.pending_raw_input = Some(raw_input), } - let logic_output = self.egui_ctx.run_logic(|ctx| { - profiling::scope!("App::logic"); - app.logic(ctx, &mut self.frame); - }); - if close_requested { let canceled = logic_output .viewport_commands diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index b3edca704..56e942a80 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -285,16 +285,16 @@ impl AppRunner { // That way all ui state is left untouched, and is still there // when the tab is shown again. - // No pass will consume the input, so save it for the next one: - self.input.raw.append(raw_input); - let egui::LogicOutput { platform_output, viewport_commands, - } = self.egui_ctx.run_logic(|ctx| { + } = self.egui_ctx.run_logic(&raw_input, |ctx| { self.app.logic(ctx, &mut self.frame); }); + // No pass consumed the input, so save it for the next one: + self.input.raw.append(raw_input); + self.handle_viewport_commands(viewport_commands.into_values().flatten()); self.handle_platform_output(platform_output); return; diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 9fb30c601..7736c7feb 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -898,21 +898,32 @@ impl Context { /// No pass is run, so `f` must not show any ui. /// This means everything egui knows about the ui is left untouched: /// no widget state is garbage-collected, no animation advances, - /// nothing loses focus, and [`Self::input`] still refers to the last pass. + /// and nothing loses focus. + /// + /// Of `new_input`, only [`RawInput::viewports`] is used: `f` can learn about the state of + /// the windows with [`InputState::viewport`], but the ui input (events, time, …) + /// is left as it was, and should be given to the next call to [`Self::run_ui`]. /// /// The returned [`LogicOutput`] is what [`FullOutput`] would have carried: /// anything `f` asked the integration to do. /// There is nothing to paint. #[must_use] - pub fn run_logic(&self, f: impl FnOnce(&Self)) -> LogicOutput { + pub fn run_logic(&self, new_input: &RawInput, f: impl FnOnce(&Self)) -> LogicOutput { profiling::function_scope!(); - // Outside of a pass this is the root viewport: - let viewport_id = self.viewport_id(); + let viewport_id = new_input.viewport_id; - // Consume any outstanding repaint request, so that a new request from `f` - // reaches the integration instead of being considered already served: - self.write(|ctx| ctx.begin_pass_repaint_logic(viewport_id)); + self.write(|ctx| { + // Consume any outstanding repaint request, so that a new request from `f` + // reaches the integration instead of being considered already served: + ctx.begin_pass_repaint_logic(viewport_id); + + // Tell the app about the windows, but leave the ui input alone: + let raw = &mut ctx.viewport_for(viewport_id).input.raw; + raw.viewport_id = viewport_id; + raw.viewports = new_input.viewports.clone(); + raw.focused = new_input.focused; + }); f(self); diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index 34527f4cc..26772461e 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -559,3 +559,104 @@ fn tooltip_should_hand_over_to_neighboring_widget() { "Tooltip A should be hidden when hovering Button B" ); } + +/// When a window is minimized or occluded, the integration runs no pass at all, +/// and instead ticks the app logic with [`egui::Context::run_logic`]. +/// +/// Such a tick must leave all ui state alone. Otherwise areas think they were hidden and +/// replay their fade-in, popups close, focus is lost, and child viewports pop back up. +/// See . +#[test] +fn run_logic_should_not_disturb_ui_state() { + const MENU: &str = "My menu"; + const MENU_ITEM: &str = "Button in my menu"; + const FOCUSED_BUTTON: &str = "Click me"; + + let child_viewport = egui::ViewportId::from_hash_of("My child viewport"); + let area_id = egui::Id::new("My area"); + let area_layer = egui::LayerId::new(egui::Order::Middle, area_id); + + let mut harness = Harness::builder() + .with_size(Vec2::new(400.0, 300.0)) + .build_ui(move |ui| { + // A backend that can open real windows, like eframe: + ui.ctx().set_embed_viewports(false); + + ui.ctx() + .show_viewport_deferred(child_viewport, Default::default(), |_ui, _class| {}); + + ui.menu_button(MENU, |ui| { + _ = ui.button(MENU_ITEM); + }); + + egui::Area::new(area_id) + .fixed_pos((150.0, 120.0)) + .show(ui.ctx(), |ui| { + _ = ui.button(FOCUSED_BUTTON); + }); + }); + + harness.get_by_label(MENU).click(); + harness.run(); + // Nothing asks for focus again, so the test fails if egui ever loses it: + harness.get_by_label(FOCUSED_BUTTON).focus(); + harness.run(); + + let assert_state = |harness: &Harness<'_>| { + assert!( + harness + .get_by_label(FOCUSED_BUTTON) + .accesskit_node() + .is_focused(), + "The button lost focus" + ); + harness.get_by_label(MENU_ITEM); // Panics if the menu closed + assert!( + harness + .ctx + .memory(|m| m.areas().visible_last_frame(&area_layer)), + "Area state was reset" + ); + }; + + assert_state(&harness); + + // The window is now occluded, so the integration runs no pass, + // and only ticks the app logic: + for _ in 0..2 { + let mut raw_input = egui::RawInput::default(); + raw_input + .viewports + .entry(egui::ViewportId::ROOT) + .or_default() + .occluded = Some(true); + + let output = harness.ctx.run_logic(&raw_input, |ctx| { + assert_eq!( + ctx.input(|i| i.viewport().occluded), + Some(true), + "App logic should be able to tell that the window is occluded" + ); + + // The app asks to be shown again: + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + }); + + assert_eq!( + output + .viewport_commands + .into_values() + .flatten() + .collect::>(), + vec![egui::ViewportCommand::Focus], + "The integration should receive the command, even though there was no pass" + ); + + assert_state(&harness); + } + + // The window is visible again, and everything should be where we left it: + harness.run(); + + assert_state(&harness); +}