From b18e888cd369ba91d3798b9d4830e28d308c637d Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Tue, 4 Aug 2026 14:28:34 +0200 Subject: [PATCH] Add `uiless_pass` flag to retain state on empty / occluded frames --- crates/eframe/src/native/epi_integration.rs | 10 +- crates/eframe/src/web/app_runner.rs | 4 + crates/egui/src/context.rs | 168 ++++++++++++++++++-- crates/egui/src/data/input/raw_input.rs | 20 +++ crates/egui/src/memory/mod.rs | 9 +- tests/test_background_logic/src/main.rs | 4 + 6 files changed, 196 insertions(+), 19 deletions(-) diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 270c38388..77a8a324b 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -268,10 +268,14 @@ impl EpiIntegration { app: &mut dyn epi::App, viewport_ui_cb: Option<&DeferredViewportUiCallback>, mut raw_input: egui::RawInput, - is_visible: bool, + show_ui: 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); @@ -279,7 +283,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 is_visible { + if show_ui { profiling::scope!("viewport_callback"); viewport_ui_cb(ui); } @@ -289,7 +293,7 @@ impl EpiIntegration { app.logic(ui.ctx(), &mut self.frame); } - if is_visible { + if show_ui { { 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 3364d83ce..28fd4b083 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -280,6 +280,10 @@ 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 dbb714bed..045520a05 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -457,6 +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; + self.memory.begin_pass(&new_raw_input, &all_viewport_ids); viewport.input = std::mem::take(&mut viewport.input).begin_pass( @@ -471,7 +475,7 @@ impl ContextImpl { viewport.this_pass.begin_pass(); - { + if !uiless_pass { // Areas that are not interactable are click-through: skip them in the hit-test. let mut layers: Vec = viewport .prev_pass @@ -516,7 +520,7 @@ impl ContextImpl { }, ); - if self.is_accesskit_enabled { + if self.is_accesskit_enabled && !uiless_pass { profiling::scope!("accesskit"); use crate::pass_state::AccessKitPassState; let id = crate::accesskit_root_id(); @@ -807,9 +811,17 @@ impl Context { ); { - plugins.on_begin_pass(&mut root_ui); + // 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(); + + if show_ui { + plugins.on_begin_pass(&mut root_ui); + } run_ui(&mut root_ui); - plugins.on_end_pass(&mut root_ui); + if show_ui { + plugins.on_end_pass(&mut root_ui); + } } ctx.pass_state_mut(|state| { @@ -823,6 +835,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 max_passes = self.write(|ctx| ctx.memory.options.max_passes.get()); let mut output = FullOutput::default(); @@ -882,7 +895,10 @@ impl Context { } else { viewport.num_multipass_in_row = 0; } - viewport.repaint.cumulative_frame_nr += 1; + if !uiless_pass { + // A uiless pass is not a frame: nothing was shown. See `RawInput::uiless_pass`. + viewport.repaint.cumulative_frame_nr += 1; + } }); output @@ -1718,6 +1734,17 @@ impl Context { }) } + /// Is this a pass where no ui will be shown? + /// + /// 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`. + /// + /// See [`RawInput::uiless_pass`]. + pub fn is_uiless_pass(&self) -> bool { + self.input(|i| i.raw.uiless_pass) + } + /// The total number of completed passes (usually there is one pass per rendered frame). /// /// Starts at zero, and is incremented for each completed pass inside of [`Self::run_ui`] (usually once). @@ -2402,7 +2429,9 @@ impl Context { self.sync_window_theme(); #[cfg(debug_assertions)] - self.debug_painting(); + if !self.is_uiless_pass() { + self.debug_painting(); + } let mut output = self.write(|ctx| ctx.end_pass()); @@ -2612,11 +2641,19 @@ impl ContextImpl { let viewport = self.viewports.entry(ended_viewport_id).or_default(); let pixels_per_point = viewport.input.pixels_per_point; - self.loaders.end_pass(viewport.repaint.cumulative_pass_nr); + // 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; - viewport.repaint.cumulative_pass_nr += 1; + if !uiless_pass { + // A uiless pass 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); - self.memory.end_pass(&viewport.this_pass.used_ids); + viewport.repaint.cumulative_pass_nr += 1; + + self.memory.end_pass(&viewport.this_pass.used_ids); + } if let Some(fonts) = self.fonts.as_mut() { let tex_mngr = &mut self.tex_manager.0.write(); @@ -2668,7 +2705,7 @@ impl ContextImpl { let mut repaint_needed = false; - if self.memory.options.repaint_on_widget_change { + if self.memory.options.repaint_on_widget_change && !uiless_pass { 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 { @@ -2689,7 +2726,11 @@ impl ContextImpl { shapes }; - std::mem::swap(&mut viewport.prev_pass, &mut viewport.this_pass); + if !uiless_pass { + // 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); + } if repaint_needed { self.request_repaint(ended_viewport_id, RepaintCause::new()); @@ -2718,7 +2759,10 @@ impl ContextImpl { } let is_our_child = parent == ended_viewport_id && id != ViewportId::ROOT; - if is_our_child { + if is_our_child && !uiless_pass { + // A uiless pass 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. if !viewport.used { log::debug!( "Removing viewport {:?} ({:?}): it was never used this pass", @@ -4327,6 +4371,106 @@ 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/data/input/raw_input.rs b/crates/egui/src/data/input/raw_input.rs index 7135e90e0..444eb23ff 100644 --- a/crates/egui/src/data/input/raw_input.rs +++ b/crates/egui/src/data/input/raw_input.rs @@ -89,6 +89,20 @@ 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 { @@ -106,6 +120,7 @@ impl Default for RawInput { focused: true, // integrations opt into global focus tracking system_theme: None, safe_area_insets: Default::default(), + uiless_pass: false, } } } @@ -139,6 +154,7 @@ impl RawInput { dropped_files: std::mem::take(&mut self.dropped_files), focused: self.focused, system_theme: self.system_theme, + uiless_pass: self.uiless_pass, } } @@ -157,6 +173,7 @@ impl RawInput { focused, system_theme, safe_area_insets: safe_area, + uiless_pass, } = newer; self.viewport_id = viewport_ids; @@ -171,6 +188,7 @@ impl RawInput { self.focused = focused; self.system_theme = system_theme; self.safe_area_insets = safe_area; + self.uiless_pass = uiless_pass; } } @@ -189,6 +207,7 @@ impl RawInput { focused, system_theme, safe_area_insets: safe_area, + uiless_pass, } = self; ui.label(format!("Active viewport: {viewport_id:?}")); @@ -218,6 +237,7 @@ 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/memory/mod.rs b/crates/egui/src/memory/mod.rs index d963373b4..21ac28e4e 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -797,10 +797,11 @@ impl Memory { self.options.begin_pass(new_raw_input); - self.focus - .entry(self.viewport_id) - .or_default() - .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); + } } pub(crate) fn end_pass(&mut self, used_ids: &IdMap) { diff --git a/tests/test_background_logic/src/main.rs b/tests/test_background_logic/src/main.rs index b1c559916..c2af4d870 100644 --- a/tests/test_background_logic/src/main.rs +++ b/tests/test_background_logic/src/main.rs @@ -60,6 +60,10 @@ fn viewport_info(ctx: &egui::Context) -> String { write!(s, " {name}={value}").ok(); } } + + use std::fmt::Write as _; + write!(s, " uiless_pass={}", i.raw.uiless_pass).ok(); + s }) }