From 51047205d8e4056e16635016cdb61f9cfdf56e72 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 17:02:10 +0200 Subject: [PATCH] Replace `uiless_pass` flag with `Context::is_visible` egui now derives "no ui is shown this pass" from the `minimized` / `occluded` flags the integrations already report in `ViewportInfo`, instead of a new `RawInput` flag that they had to set separately. Co-Authored-By: Claude Opus 5 (1M context) --- crates/eframe/src/native/epi_integration.rs | 10 +-- crates/eframe/src/web/app_runner.rs | 4 -- crates/egui/src/context.rs | 70 ++++++++++++--------- crates/egui/src/data/input/raw_input.rs | 20 ------ crates/egui/src/data/input/viewport_info.rs | 2 + crates/egui/src/memory/mod.rs | 6 +- crates/egui_kittest/src/lib.rs | 8 +-- tests/egui_tests/tests/regression_tests.rs | 23 ++++--- tests/test_background_logic/src/main.rs | 5 +- 9 files changed, 67 insertions(+), 81 deletions(-) diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 77a8a324b..270c38388 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -268,14 +268,10 @@ impl EpiIntegration { app: &mut dyn epi::App, viewport_ui_cb: Option<&DeferredViewportUiCallback>, mut raw_input: egui::RawInput, - show_ui: bool, + is_visible: bool, ) -> egui::FullOutput { raw_input.time = Some(self.beginning.elapsed().as_secs_f64()); - // We still run a pass while hidden so that app logic keeps ticking, - // but we tell egui to skip all book-keeping that assumes ui was shown: - raw_input.uiless_pass = !show_ui; - let close_requested = raw_input.viewport().close_requested(); app.raw_input_hook(&self.egui_ctx, &mut raw_input); @@ -283,7 +279,7 @@ impl EpiIntegration { let full_output = self.egui_ctx.run_ui(raw_input, |ui| { if let Some(viewport_ui_cb) = viewport_ui_cb { // Child viewport - if show_ui { + if is_visible { profiling::scope!("viewport_callback"); viewport_ui_cb(ui); } @@ -293,7 +289,7 @@ impl EpiIntegration { app.logic(ui.ctx(), &mut self.frame); } - if show_ui { + if is_visible { { profiling::scope!("App::ui"); app.ui(ui, &mut self.frame); diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 28fd4b083..3364d83ce 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -280,10 +280,6 @@ impl AppRunner { .and_then(|v| v.visible()) .unwrap_or(true); - // We still run a pass while hidden so that app logic keeps ticking, - // but we tell egui to skip all book-keeping that assumes ui was shown: - raw_input.uiless_pass = !is_visible; - let full_output = self.egui_ctx.run_ui(raw_input, |ui| { self.app.logic(ui.ctx(), &mut self.frame); diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 84e5ea486..93db05e77 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -457,9 +457,10 @@ impl ContextImpl { let viewport = self.viewports.entry(self.viewport_id()).or_default(); - // If no ui will be shown this pass we skip all book-keeping that assumes ui was shown. - // See `RawInput::uiless_pass`. - let uiless_pass = new_raw_input.uiless_pass; + // If the window is minimized or occluded, the integration may still run passes + // to keep app logic ticking, but without showing any ui. + // We then skip all book-keeping that assumes ui was shown. + let is_visible = new_raw_input.viewport().visible().unwrap_or(true); self.memory.begin_pass(&new_raw_input, &all_viewport_ids); @@ -475,7 +476,7 @@ impl ContextImpl { viewport.this_pass.begin_pass(); - if !uiless_pass { + if is_visible { // Areas that are not interactable are click-through: skip them in the hit-test. let mut layers: Vec = viewport .prev_pass @@ -520,7 +521,7 @@ impl ContextImpl { }, ); - if self.is_accesskit_enabled && !uiless_pass { + if self.is_accesskit_enabled && is_visible { profiling::scope!("accesskit"); use crate::pass_state::AccessKitPassState; let id = crate::accesskit_root_id(); @@ -811,15 +812,15 @@ impl Context { ); { - // Plugins may show ui, so don't run them when no ui should be shown. - // See `RawInput::uiless_pass`. - let show_ui = !ctx.is_uiless_pass(); + // Plugins may show ui, so don't run them when nothing is shown. + // See [`Context::is_visible`]. + let is_visible = ctx.is_visible(); - if show_ui { + if is_visible { plugins.on_begin_pass(&mut root_ui); } run_ui(&mut root_ui); - if show_ui { + if is_visible { plugins.on_end_pass(&mut root_ui); } } @@ -835,7 +836,7 @@ impl Context { 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 uiless_pass = new_input.uiless_pass; + let is_visible = new_input.viewport().visible().unwrap_or(true); let max_passes = self.write(|ctx| ctx.memory.options.max_passes.get()); let mut output = FullOutput::default(); @@ -895,8 +896,9 @@ impl Context { } else { viewport.num_multipass_in_row = 0; } - if !uiless_pass { - // A uiless pass is not a frame: nothing was shown. See `RawInput::uiless_pass`. + if is_visible { + // A pass without any ui is not a frame: nothing was shown. + // See [`Context::is_visible`]. viewport.repaint.cumulative_frame_nr += 1; } }); @@ -1734,15 +1736,22 @@ impl Context { }) } - /// Is this a pass where no ui will be shown? + /// Is the current viewport visible, i.e. neither minimized nor occluded? /// - /// This is `true` when the integration runs a pass only to keep app logic ticking, - /// e.g. because the window is minimized or occluded. - /// Don't show any ui while this is `true`. + /// When this is `false`, the integration may still run passes so that app logic keeps + /// ticking (e.g. so the app can send a [`crate::ViewportCommand`] to show itself again), + /// but nothing is painted, and no ui should be shown. /// - /// See [`RawInput::uiless_pass`]. - pub fn is_uiless_pass(&self) -> bool { - self.input(|i| i.raw.uiless_pass) + /// egui also skips all book-keeping that assumes ui was shown, so that widgets don't + /// think they were hidden and replay their appear-animations, popups don't close, + /// focus isn't lost, and so on. + /// + /// Defaults to `true` if the integration doesn't report + /// [`ViewportInfo::minimized`] and [`ViewportInfo::occluded`]. + /// + /// See [`ViewportInfo::visible`]. + pub fn is_visible(&self) -> bool { + self.input(|i| i.viewport().visible().unwrap_or(true)) } /// The total number of completed passes (usually there is one pass per rendered frame). @@ -2429,7 +2438,7 @@ impl Context { self.sync_window_theme(); #[cfg(debug_assertions)] - if !self.is_uiless_pass() { + if self.is_visible() { self.debug_painting(); } @@ -2641,11 +2650,12 @@ impl ContextImpl { let viewport = self.viewports.entry(ended_viewport_id).or_default(); 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. - let uiless_pass = viewport.input.raw.uiless_pass; + // If nothing was shown this pass we skip all book-keeping that assumes ui was shown. + // See [`Context::is_visible`]. + let is_visible = viewport.input.viewport().visible().unwrap_or(true); - if !uiless_pass { - // A uiless pass uses no images and no widget ids, + if is_visible { + // A pass without any ui uses no images and no widget ids, // so garbage-collecting based on it would throw away things we still need. self.loaders.end_pass(viewport.repaint.cumulative_pass_nr); @@ -2704,7 +2714,7 @@ impl ContextImpl { let mut repaint_needed = false; - if self.memory.options.repaint_on_widget_change && !uiless_pass { + if self.memory.options.repaint_on_widget_change && is_visible { profiling::scope!("compare-widget-rects"); #[allow(clippy::allow_attributes, clippy::collapsible_if)] // false positive on wasm if viewport.prev_pass.widgets != viewport.this_pass.widgets { @@ -2725,7 +2735,7 @@ impl ContextImpl { shapes }; - if !uiless_pass { + if is_visible { // Keep `prev_pass` from the last pass that actually showed ui, // so widgets are still found by the next hit-test and interaction. std::mem::swap(&mut viewport.prev_pass, &mut viewport.this_pass); @@ -2758,10 +2768,10 @@ impl ContextImpl { } let is_our_child = parent == ended_viewport_id && id != ViewportId::ROOT; - if is_our_child && !uiless_pass { - // A uiless pass never calls `Context::show_viewport_deferred`, + if is_our_child && is_visible { + // A pass without any ui never calls `Context::show_viewport_deferred`, // so we must not read `used` from it: that would close all our child - // viewports, only for them to pop back up on the next normal pass. + // viewports, only for them to pop back up on the next visible pass. if !viewport.used { log::debug!( "Removing viewport {:?} ({:?}): it was never used this pass", diff --git a/crates/egui/src/data/input/raw_input.rs b/crates/egui/src/data/input/raw_input.rs index 444eb23ff..7135e90e0 100644 --- a/crates/egui/src/data/input/raw_input.rs +++ b/crates/egui/src/data/input/raw_input.rs @@ -89,20 +89,6 @@ pub struct RawInput { /// /// `None` means "don't know". pub system_theme: Option, - - /// Set this if no ui will be shown during this pass. - /// - /// An integration sets this when the window is hidden, minimized, or occluded, - /// but it still wants to run a pass so that background logic keeps ticking - /// (e.g. so the app can send a [`crate::ViewportCommand`] to show itself again). - /// - /// egui then skips all the book-keeping that assumes ui was shown, so that - /// widgets don't think they were hidden and replay their appear-animations, - /// popups don't close, focus isn't lost, and so on. - /// See . - /// - /// If you set this, you must not show any ui during the pass. - pub uiless_pass: bool, } impl Default for RawInput { @@ -120,7 +106,6 @@ impl Default for RawInput { focused: true, // integrations opt into global focus tracking system_theme: None, safe_area_insets: Default::default(), - uiless_pass: false, } } } @@ -154,7 +139,6 @@ impl RawInput { dropped_files: std::mem::take(&mut self.dropped_files), focused: self.focused, system_theme: self.system_theme, - uiless_pass: self.uiless_pass, } } @@ -173,7 +157,6 @@ impl RawInput { focused, system_theme, safe_area_insets: safe_area, - uiless_pass, } = newer; self.viewport_id = viewport_ids; @@ -188,7 +171,6 @@ impl RawInput { self.focused = focused; self.system_theme = system_theme; self.safe_area_insets = safe_area; - self.uiless_pass = uiless_pass; } } @@ -207,7 +189,6 @@ impl RawInput { focused, system_theme, safe_area_insets: safe_area, - uiless_pass, } = self; ui.label(format!("Active viewport: {viewport_id:?}")); @@ -237,7 +218,6 @@ impl RawInput { ui.label(format!("focused: {focused}")); ui.label(format!("system_theme: {system_theme:?}")); ui.label(format!("safe_area: {safe_area:?}")); - ui.label(format!("uiless_pass: {uiless_pass}")); ui.scope(|ui| { ui.set_min_height(150.0); ui.label(format!("events: {events:#?}")) diff --git a/crates/egui/src/data/input/viewport_info.rs b/crates/egui/src/data/input/viewport_info.rs index 774ca1e6e..caa8c4b32 100644 --- a/crates/egui/src/data/input/viewport_info.rs +++ b/crates/egui/src/data/input/viewport_info.rs @@ -92,6 +92,8 @@ impl ViewportInfo { /// A window is not visible if it is minimized or occluded. /// When not visible, the UI is not painted and rendering is skipped, /// but application logic may still be executed by some integrations. + /// + /// See also [`crate::Context::is_visible`]. pub fn visible(&self) -> Option { match (self.minimized, self.occluded) { (Some(true), _) | (_, Some(true)) => Some(false), diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 7ff87c0e0..90d69d097 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -797,9 +797,9 @@ impl Memory { self.options.begin_pass(new_raw_input); - if !new_raw_input.uiless_pass { - // No widget will ask for focus during a uiless pass, - // so leave the focus state alone. + if new_raw_input.viewport().visible().unwrap_or(true) { + // When nothing is shown, no widget will ask for focus, + // so leave the focus state alone. See [`crate::Context::is_visible`]. self.focus .entry(self.viewport_id) .or_default() diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index e36c37381..ba57ddfac 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -259,7 +259,7 @@ 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 is_visible = self.input.viewport().visible().unwrap_or(true); let mut output = self.ctx.run_ui(self.input.take(), |ui| { self.response = self.app.run(ui, &mut self.state, sizing_pass); @@ -267,9 +267,9 @@ impl<'a, State> Harness<'a, State> { 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"); + // An occluded/minimized viewport shows no ui, so there is no accessibility tree + // to update. Keep the tree from the last visible pass. + assert!(!is_visible, "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 62656e866..bc4106af1 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -560,14 +560,14 @@ fn tooltip_should_hand_over_to_neighboring_widget() { ); } -/// 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. +/// When a window is minimized or occluded, an integration may keep running passes so that +/// app logic keeps ticking, but without showing any ui. See [`egui::Context::is_visible`]. /// /// 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() { +fn occluded_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"; @@ -582,8 +582,8 @@ fn uiless_pass_should_not_disturb_ui_state() { // 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. + if !ui.ctx().is_visible() { + // The integration shows no ui and no viewports while occluded. return; } @@ -633,17 +633,22 @@ fn uiless_pass_should_not_disturb_ui_state() { assert_state(&harness); + let set_occluded = |harness: &mut Harness<'_>, occluded: bool| { + let input = harness.input_mut(); + let viewport_id = input.viewport_id; + input.viewports.entry(viewport_id).or_default().occluded = Some(occluded); + }; + // 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; + // egui keeps the accessibility tree from the last visible pass, so we can still query it. + set_occluded(&mut harness, 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; + set_occluded(&mut harness, 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 6ce5b6037..b1c559916 100644 --- a/tests/test_background_logic/src/main.rs +++ b/tests/test_background_logic/src/main.rs @@ -37,7 +37,6 @@ impl eframe::App for App { fn viewport_info(ctx: &egui::Context) -> String { ctx.input(|i| { - use std::fmt::Write as _; let ViewportInfo { minimized, focused, @@ -57,12 +56,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(); } } - - write!(s, " uiless_pass={}", i.raw.uiless_pass).ok(); - s }) }