From 3f3882612b34cac8a47935840090ffa49c1b9b60 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 17:23:12 +0200 Subject: [PATCH 1/8] Never run an egui pass when nothing will be shown Add `Context::run_logic`, for ticking app logic without running a pass, and use it in the native eframe backends when a viewport is minimized or occluded (and has no visible descendant viewport). Since no pass runs, all ui state is left untouched: nothing to special-case inside egui, and the app finds everything where it left it once the window is visible again. `App::logic` is now always called outside of the egui pass. Any viewport commands it sends (e.g. `ViewportCommand::Focus`) come out of `LogicOutput` when there is no pass to carry them. Co-Authored-By: Claude Opus 5 (1M context) --- crates/eframe/src/native/epi_integration.rs | 122 +++++++++++++++---- crates/eframe/src/native/glow_integration.rs | 100 +++++++++++++-- crates/eframe/src/native/wgpu_integration.rs | 97 ++++++++++++++- crates/egui/src/context.rs | 41 ++++++- crates/egui/src/data/output.rs | 16 +++ crates/egui/src/lib.rs | 2 +- 6 files changed, 335 insertions(+), 43 deletions(-) diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 270c3838871e..9260705061a7 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -158,6 +158,10 @@ pub struct EpiIntegration { pub egui_ctx: egui::Context, pending_full_output: egui::FullOutput, + /// Input that we have received, but not yet given to egui, + /// because we haven't run any pass since (see [`Self::update_logic_only`]). + pending_raw_input: Option, + /// When set, it is time to close the native window. close: bool, @@ -216,6 +220,7 @@ impl EpiIntegration { frame, last_auto_save: Instant::now(), pending_full_output: Default::default(), + pending_raw_input: None, close: false, can_drag_window: false, #[cfg(feature = "persistence")] @@ -262,59 +267,122 @@ impl EpiIntegration { /// Run user code - this can create immediate viewports, so hold no locks over this! /// - /// If `viewport_ui_cb` is None, we are in the root viewport and will call [`crate::App::ui`]. + /// If `viewport_ui_cb` is None, we are in the root viewport and will call + /// [`crate::App::logic`] and [`crate::App::ui`]. + /// + /// Only call this when the ui will actually be shown; + /// use [`Self::update_logic_only`] otherwise. pub fn update( &mut self, app: &mut dyn epi::App, viewport_ui_cb: Option<&DeferredViewportUiCallback>, - mut raw_input: egui::RawInput, - is_visible: bool, + raw_input: egui::RawInput, ) -> egui::FullOutput { - raw_input.time = Some(self.beginning.elapsed().as_secs_f64()); + let raw_input = self.prepare_raw_input(app, raw_input); let close_requested = raw_input.viewport().close_requested(); - app.raw_input_hook(&self.egui_ctx, &mut raw_input); + let is_root_viewport = viewport_ui_cb.is_none(); + if is_root_viewport { + // Note that this is _not_ inside the pass below: + // `App::logic` may not show any ui, and should not affect any ui state. + profiling::scope!("App::logic"); + app.logic(&self.egui_ctx, &mut self.frame); + } + + // Anything `App::logic` asked for (viewport commands etc) is still in the + // `Context`, and will come out of the pass we are about to run. 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 { - profiling::scope!("viewport_callback"); - viewport_ui_cb(ui); - } + profiling::scope!("viewport_callback"); + viewport_ui_cb(ui); } else { - { - profiling::scope!("App::logic"); - app.logic(ui.ctx(), &mut self.frame); - } - - if is_visible { - { - profiling::scope!("App::ui"); - app.ui(ui, &mut self.frame); - } - } + profiling::scope!("App::ui"); + app.ui(ui, &mut self.frame); } }); - let is_root_viewport = viewport_ui_cb.is_none(); if is_root_viewport && close_requested { let canceled = full_output.viewport_output[&ViewportId::ROOT] .commands .contains(&egui::ViewportCommand::CancelClose); - if canceled { - log::debug!("Closing of root viewport canceled with ViewportCommand::CancelClose"); - } else { - log::debug!("Closing root viewport (ViewportCommand::CancelClose was not sent)"); - self.close = true; - } + self.handle_close_request(canceled); } self.pending_full_output.append(full_output); std::mem::take(&mut self.pending_full_output) } + /// Let the app tick its logic without showing any ui, + /// because the window is minimized or occluded. + /// + /// No egui pass is run, so all ui state is left untouched: + /// the app will find everything where it left it once the window is visible again. + /// + /// Only call this for the root viewport: only it has [`crate::App::logic`]. + pub fn update_logic_only( + &mut self, + app: &mut dyn epi::App, + raw_input: egui::RawInput, + ) -> egui::LogicOutput { + let raw_input = self.prepare_raw_input(app, raw_input); + + let close_requested = raw_input.viewport().close_requested(); + + // No pass will consume 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 + .get(&ViewportId::ROOT) + .is_some_and(|commands| commands.contains(&egui::ViewportCommand::CancelClose)); + self.handle_close_request(canceled); + } + + logic_output + } + + /// Set the time, prepend any input we couldn't give to egui earlier, and run the app hook. + fn prepare_raw_input( + &mut self, + app: &mut dyn epi::App, + raw_input: egui::RawInput, + ) -> egui::RawInput { + let mut raw_input = match self.pending_raw_input.take() { + Some(mut pending) => { + pending.append(raw_input); // The new input wins where they overlap + pending + } + None => raw_input, + }; + + raw_input.time = Some(self.beginning.elapsed().as_secs_f64()); + + app.raw_input_hook(&self.egui_ctx, &mut raw_input); + + raw_input + } + + fn handle_close_request(&mut self, canceled: bool) { + if canceled { + log::debug!("Closing of root viewport canceled with ViewportCommand::CancelClose"); + } else { + log::debug!("Closing root viewport (ViewportCommand::CancelClose was not sent)"); + self.close = true; + } + } + pub fn report_frame_time(&mut self, seconds: f32) { self.frame.info.cpu_usage = Some(seconds); } diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index 451aa0d82a1d..f3c3a7cab535 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -579,7 +579,7 @@ impl GlowWinitRunning<'_> { } } - let (raw_input, viewport_ui_cb, is_visible, run_ui) = { + let (raw_input, viewport_ui_cb, is_visible, show_ui) = { let mut glutin = self.glutin.borrow_mut(); let egui_ctx = glutin.egui_ctx.clone(); let Some(viewport) = glutin.viewports.get_mut(&viewport_id) else { @@ -598,7 +598,7 @@ impl GlowWinitRunning<'_> { let mut raw_input = egui_winit.take_egui_input(window); let viewport_ui_cb = viewport.viewport_ui_cb.clone(); - let run_ui = + let show_ui = is_visible || is_viewport_or_descendant_visible(&glutin.viewports, viewport_id); self.integration.pre_update(); @@ -610,9 +610,48 @@ impl GlowWinitRunning<'_> { .map(|(id, viewport)| (*id, viewport.info.clone())) .collect(); - (raw_input, viewport_ui_cb, is_visible, run_ui) + (raw_input, viewport_ui_cb, is_visible, show_ui) }; + if !show_ui { + // Nothing will be shown, so we run no egui pass at all. + // That way all ui state is left untouched, and is still there + // when this viewport becomes visible again. + let is_root_viewport = viewport_ui_cb.is_none(); + if is_root_viewport { + // The app logic keeps ticking, so it can e.g. ask to be shown again: + let egui::LogicOutput { + platform_output, + viewport_commands, + } = self + .integration + .update_logic_only(self.app.as_mut(), raw_input); + + let mut glutin = self.glutin.borrow_mut(); + if let Some(viewport) = glutin.viewports.get_mut(&viewport_id) { + viewport.info.events.clear(); // they should have been processed + if let Some(window) = viewport.window.clone() + && let Some(egui_winit) = viewport.egui_winit.as_mut() + { + egui_winit.handle_platform_output_with_event_loop( + &window, + event_loop, + platform_output, + ); + } + } + glutin.handle_viewport_commands(&self.integration.egui_ctx, viewport_commands); + } + + self.sleep_if_minimized(viewport_id); + + return Ok(if self.integration.should_close() { + EventResult::CloseRequested + } else { + EventResult::Wait + }); + } + // HACK: In order to get the right clear_color, the system theme needs to be set, which // usually only happens in the `update` call. So we call Options::begin_pass early // to set the right theme. Without this there would be a black flash on the first frame. @@ -661,12 +700,9 @@ impl GlowWinitRunning<'_> { // The update function, which could call immediate viewports, // so make sure we don't hold any locks here required by the immediate viewports rendeer. - let full_output = self.integration.update( - self.app.as_mut(), - viewport_ui_cb.as_deref(), - raw_input, - run_ui, - ); + let full_output = + self.integration + .update(self.app.as_mut(), viewport_ui_cb.as_deref(), raw_input); // ------------------------------------------------------------ @@ -816,6 +852,22 @@ impl GlowWinitRunning<'_> { } } + /// On Mac, a minimized Window uses up all CPU: + /// + /// + /// On Windows, an invisible window also uses up all CPU: + /// + fn sleep_if_minimized(&self, viewport_id: ViewportId) { + let glutin = self.glutin.borrow(); + if let Some(viewport) = glutin.viewports.get(&viewport_id) + && let Some(window) = viewport.window.as_ref() + && is_invisible_or_minimized(window) + { + profiling::scope!("minimized_sleep"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + fn on_window_event( &mut self, window_id: WindowId, @@ -1365,6 +1417,36 @@ impl GlutinWindowContext { .retain(|id, _| viewport_output.contains_key(id)); } + /// Apply commands to already existing viewports, without creating or removing any. + /// + /// This is for commands that came out of [`egui::Context::run_logic`], + /// which knows nothing about which viewports should exist. + fn handle_viewport_commands( + &mut self, + egui_ctx: &egui::Context, + viewport_commands: egui::OrderedViewportIdMap>, + ) { + profiling::function_scope!(); + + for (viewport_id, mut commands) in viewport_commands { + let Some(viewport) = self.viewports.get_mut(&viewport_id) else { + continue; + }; + + viewport.deferred_commands.append(&mut commands); + + if let Some(window) = &viewport.window { + egui_winit::process_viewport_commands( + egui_ctx, + &mut viewport.info, + std::mem::take(&mut viewport.deferred_commands), + window, + &mut viewport.actions_requested, + ); + } + } + } + fn handle_viewport_output( &mut self, event_loop: &ActiveEventLoop, diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index e01b4d9a3ce9..5be541d17e76 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -624,7 +624,7 @@ impl WgpuWinitRunning<'_> { let mut frame_timer = crate::stopwatch::Stopwatch::new(); frame_timer.start(); - let (viewport_ui_cb, raw_input, is_visible, run_ui) = { + let (viewport_ui_cb, raw_input, is_visible, show_ui) = { profiling::scope!("Prepare"); let mut shared_lock = shared.borrow_mut(); @@ -680,7 +680,7 @@ impl WgpuWinitRunning<'_> { }; let mut raw_input = egui_winit.take_egui_input(window); - let run_ui = is_visible || is_viewport_or_descendant_visible(viewports, viewport_id); + let show_ui = is_visible || is_viewport_or_descendant_visible(viewports, viewport_id); integration.pre_update(); @@ -692,15 +692,57 @@ impl WgpuWinitRunning<'_> { painter.handle_screenshots(&mut raw_input.events); - (viewport_ui_cb, raw_input, is_visible, run_ui) + (viewport_ui_cb, raw_input, is_visible, show_ui) }; + if !show_ui { + // Nothing will be shown, so we run no egui pass at all. + // That way all ui state is left untouched, and is still there + // when this viewport becomes visible again. + let is_root_viewport = viewport_ui_cb.is_none(); + if is_root_viewport { + // The app logic keeps ticking, so it can e.g. ask to be shown again: + let egui::LogicOutput { + platform_output, + viewport_commands, + } = integration.update_logic_only(app.as_mut(), raw_input); + + let mut shared_mut = shared.borrow_mut(); + let SharedState { viewports, .. } = &mut *shared_mut; + + if let Some(viewport) = viewports.get_mut(&viewport_id) { + viewport.info.events.clear(); // they should have been processed + if let Viewport { + window: Some(window), + egui_winit: Some(egui_winit), + .. + } = viewport + { + egui_winit.handle_platform_output_with_event_loop( + window, + event_loop, + platform_output, + ); + } + } + + handle_viewport_commands(&integration.egui_ctx, viewport_commands, viewports); + } + + sleep_if_minimized(&shared.borrow(), viewport_id); + + return Ok(if integration.should_close() { + EventResult::CloseRequested + } else { + EventResult::Wait + }); + } + // ------------------------------------------------------------ // Runs the update, which could call immediate viewports, // so make sure we hold no locks here! - let full_output = - integration.update(app.as_mut(), viewport_ui_cb.as_deref(), raw_input, run_ui); + let full_output = integration.update(app.as_mut(), viewport_ui_cb.as_deref(), raw_input); // ------------------------------------------------------------ @@ -1208,6 +1250,51 @@ pub(crate) fn remove_viewports_not_in( } /// Add new viewports, and update existing ones: +/// Apply commands to already existing viewports, without creating or removing any. +/// +/// This is for commands that came out of [`egui::Context::run_logic`], +/// which knows nothing about which viewports should exist. +fn handle_viewport_commands( + egui_ctx: &egui::Context, + viewport_commands: egui::OrderedViewportIdMap>, + viewports: &mut Viewports, +) { + profiling::function_scope!(); + + for (viewport_id, mut commands) in viewport_commands { + let Some(viewport) = viewports.get_mut(&viewport_id) else { + continue; + }; + + viewport.deferred_commands.append(&mut commands); + + if let Some(window) = viewport.window.as_ref() { + egui_winit::process_viewport_commands( + egui_ctx, + &mut viewport.info, + std::mem::take(&mut viewport.deferred_commands), + window, + &mut viewport.actions_requested, + ); + } + } +} + +/// On Mac, a minimized Window uses up all CPU: +/// +/// +/// On Windows, an invisible window also uses up all CPU: +/// +fn sleep_if_minimized(shared: &SharedState, viewport_id: ViewportId) { + if let Some(viewport) = shared.viewports.get(&viewport_id) + && let Some(window) = viewport.window.as_ref() + && is_invisible_or_minimized(window) + { + profiling::scope!("minimized_sleep"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + fn handle_viewport_output( egui_ctx: &egui::Context, viewport_output: &OrderedViewportIdMap, diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index dbb714bed098..9fb30c601e04 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -32,7 +32,7 @@ use crate::{ load::{self, Bytes, Loaders, SizedTexture}, memory::{Options, Theme}, os::OperatingSystem, - output::FullOutput, + output::{FullOutput, LogicOutput}, pass_state::PassState, plugin::{self, TypedPluginHandle}, resize, response, scroll_area, @@ -888,6 +888,45 @@ impl Context { output } + /// Run app logic without showing any ui. + /// + /// Use this instead of [`Self::run_ui`] when nothing will be shown, + /// e.g. because the window is minimized or occluded, + /// but you still want to let the app tick its logic + /// (so that it can e.g. ask to be shown again with [`ViewportCommand::Focus`]). + /// + /// 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. + /// + /// 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 { + profiling::function_scope!(); + + // Outside of a pass this is the root viewport: + let viewport_id = self.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)); + + f(self); + + self.write(|ctx| LogicOutput { + platform_output: std::mem::take(&mut ctx.viewport_for(viewport_id).output), + viewport_commands: ctx + .viewports + .iter_mut() + .filter(|(_, viewport)| !viewport.commands.is_empty()) + .map(|(&id, viewport)| (id, std::mem::take(&mut viewport.commands))) + .collect(), + }) + } + /// An alternative to calling [`Self::run_ui`]. /// /// It is usually better to use [`Self::run_ui`], because diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index bbd271b71fad..ae1363641780 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -76,6 +76,22 @@ impl FullOutput { } } +/// What egui emits from [`crate::Context::run_logic`], i.e. from a tick where no ui was shown. +/// +/// There is nothing to paint, but the app may still have asked the integration to do things, +/// e.g. to show a hidden window again with [`crate::ViewportCommand::Focus`]. +#[derive(Clone, Default)] +pub struct LogicOutput { + /// Non-rendering related output. + pub platform_output: PlatformOutput, + + /// The commands sent with [`crate::Context::send_viewport_cmd`] and friends. + /// + /// Note that this contains no information about which viewports exist: + /// the integration should leave its viewports as they are. + pub viewport_commands: OrderedViewportIdMap>, +} + /// Information about text being edited. /// /// Useful for IME. diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 88de74b49dad..27ab7035c0aa 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -467,7 +467,7 @@ pub use self::{ Key, UserData, input::*, output::{ - self, CursorIcon, CustomCursorImage, FullOutput, OpenUrl, OutputCommand, + self, CursorIcon, CustomCursorImage, FullOutput, LogicOutput, OpenUrl, OutputCommand, PlatformOutput, UserAttentionType, WidgetInfo, }, }, From 49d69a19dd5aa36fa31c4e2de22732196ceffa82 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 17:24:45 +0200 Subject: [PATCH 2/8] Run no egui pass in the web backend when the tab is hidden Same as the native backends: tick `App::logic` via `Context::run_logic` and leave all ui state alone. Co-Authored-By: Claude Opus 5 (1M context) --- crates/eframe/src/web/app_runner.rs | 71 +++++++++++++++++++---------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 3364d83cebda..b3edca704889 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -280,12 +280,31 @@ impl AppRunner { .and_then(|v| v.visible()) .unwrap_or(true); - let full_output = self.egui_ctx.run_ui(raw_input, |ui| { - self.app.logic(ui.ctx(), &mut self.frame); + if !is_visible { + // The tab is hidden, so we run no egui pass at all. + // 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.app.logic(ctx, &mut self.frame); + }); - if is_visible { - self.app.ui(ui, &mut self.frame); - } + self.handle_viewport_commands(viewport_commands.into_values().flatten()); + self.handle_platform_output(platform_output); + return; + } + + // `App::logic` may not show any ui, so it is called outside of the pass: + self.app.logic(&self.egui_ctx, &mut self.frame); + + let full_output = self.egui_ctx.run_ui(raw_input, |ui| { + self.app.ui(ui, &mut self.frame); }); let egui::FullOutput { platform_output, @@ -298,27 +317,31 @@ impl AppRunner { if viewport_output.len() > 1 { log::warn!("Multiple viewports not yet supported on the web"); } - for (_viewport_id, viewport_output) in viewport_output { - for command in viewport_output.commands { - match command { - ViewportCommand::Screenshot(user_data) => { - self.screenshot_commands_with_frame_delay - .push((user_data, 1)); - } - _ => { - // TODO(emilk): handle some of the commands - log::warn!( - "Unhandled egui viewport command: {command:?} - not implemented in web backend" - ); - } - } - } - } + self.handle_viewport_commands( + viewport_output + .into_values() + .flat_map(|viewport_output| viewport_output.commands), + ); self.handle_platform_output(platform_output); - if is_visible || !textures_delta.is_empty() { - self.textures_delta.append(textures_delta); - self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point)); + self.textures_delta.append(textures_delta); + self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point)); + } + + fn handle_viewport_commands(&mut self, commands: impl Iterator) { + for command in commands { + match command { + ViewportCommand::Screenshot(user_data) => { + self.screenshot_commands_with_frame_delay + .push((user_data, 1)); + } + _ => { + // TODO(emilk): handle some of the commands + log::warn!( + "Unhandled egui viewport command: {command:?} - not implemented in web backend" + ); + } + } } } From 82d1c63c03d6ea52a8afb7d299a3c2a72773750a Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 17:28:12 +0200 Subject: [PATCH 3/8] 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 9260705061a7..d0a19eb5123c 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 b3edca704889..56e942a80a35 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 9fb30c601e04..7736c7febc2d 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 34527f4cc4a1..26772461e98c 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); +} From 3f4ee3d866347641e9e9cb837ae51e11dcdbef32 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 19:30:44 +0200 Subject: [PATCH 4/8] Deduplicate the new backend code * One shared `sleep_if_invisible_or_minimized`, replacing two new helpers and two inlined copies. * `Viewport::process_commands` in each native backend, used both by the new no-pass path and by `handle_viewport_output`, replacing the commands-only helpers. * `EpiIntegration::pending_raw_input` needs no `Option`. * Assert in the test that the child viewport survives. Co-Authored-By: Claude Opus 5 (1M context) --- crates/eframe/src/native/epi_integration.rs | 22 +-- crates/eframe/src/native/glow_integration.rs | 121 +++++++--------- crates/eframe/src/native/wgpu_integration.rs | 130 +++++++----------- crates/eframe/src/native/winit_integration.rs | 12 ++ tests/egui_tests/tests/regression_tests.rs | 7 + 5 files changed, 121 insertions(+), 171 deletions(-) diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index d0a19eb5123c..97461ead39e4 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -160,7 +160,7 @@ pub struct EpiIntegration { /// Input that we have received, but not yet given to egui, /// because we haven't run any pass since (see [`Self::update_logic_only`]). - pending_raw_input: Option, + pending_raw_input: egui::RawInput, /// When set, it is time to close the native window. close: bool, @@ -220,7 +220,7 @@ impl EpiIntegration { frame, last_auto_save: Instant::now(), pending_full_output: Default::default(), - pending_raw_input: None, + pending_raw_input: Default::default(), close: false, can_drag_window: false, #[cfg(feature = "persistence")] @@ -337,10 +337,7 @@ impl EpiIntegration { }); // 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), - } + self.pending_raw_input = raw_input; if close_requested { let canceled = logic_output @@ -353,19 +350,14 @@ impl EpiIntegration { logic_output } - /// Set the time, prepend any input we couldn't give to egui earlier, and run the app hook. + /// Prepend any input we couldn't give to egui earlier, set the time, and run the app hook. fn prepare_raw_input( &mut self, app: &mut dyn epi::App, - raw_input: egui::RawInput, + new_input: egui::RawInput, ) -> egui::RawInput { - let mut raw_input = match self.pending_raw_input.take() { - Some(mut pending) => { - pending.append(raw_input); // The new input wins where they overlap - pending - } - None => raw_input, - }; + let mut raw_input = std::mem::take(&mut self.pending_raw_input); + raw_input.append(new_input); // The new input wins where they overlap raw_input.time = Some(self.beginning.elapsed().as_secs_f64()); diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index f3c3a7cab535..908cc8c26e49 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -40,7 +40,7 @@ use super::{ use crate::epaint::textures::TexturesDelta; use crate::{ App, AppCreator, CreationContext, NativeOptions, Result, Storage, - native::{epi_integration::EpiIntegration, winit_integration::is_invisible_or_minimized}, + native::{epi_integration::EpiIntegration, winit_integration::sleep_if_invisible_or_minimized}, }; // ---------------------------------------------------------------------------- @@ -139,6 +139,27 @@ struct Viewport { egui_winit: Option, } +impl Viewport { + /// Apply the commands, or defer them until we have a window. + fn process_commands( + &mut self, + egui_ctx: &egui::Context, + mut commands: Vec, + ) { + self.deferred_commands.append(&mut commands); + + if let Some(window) = &self.window { + egui_winit::process_viewport_commands( + egui_ctx, + &mut self.info, + std::mem::take(&mut self.deferred_commands), + window, + &mut self.actions_requested, + ); + } + } +} + impl Drop for Viewport { fn drop(&mut self) { // Avoid debug panic when dropping unapplied deltas on teardown @@ -640,10 +661,20 @@ impl GlowWinitRunning<'_> { ); } } - glutin.handle_viewport_commands(&self.integration.egui_ctx, viewport_commands); + for (id, commands) in viewport_commands { + if let Some(viewport) = glutin.viewports.get_mut(&id) { + viewport.process_commands(&self.integration.egui_ctx, commands); + } + } } - self.sleep_if_minimized(viewport_id); + sleep_if_invisible_or_minimized( + self.glutin + .borrow() + .viewports + .get(&viewport_id) + .and_then(|viewport| viewport.window.as_deref()), + ); return Ok(if self.integration.should_close() { EventResult::CloseRequested @@ -836,14 +867,7 @@ impl GlowWinitRunning<'_> { integration.maybe_autosave(app.as_mut(), Some(&window)); - if is_invisible_or_minimized(&window) { - // On Mac, a minimized Window uses up all CPU: - // https://github.com/emilk/egui/issues/325 - // On Windows, an invisible window also uses up all CPU: - // https://github.com/emilk/egui/issues/7776 - profiling::scope!("minimized_sleep"); - std::thread::sleep(std::time::Duration::from_millis(10)); - } + sleep_if_invisible_or_minimized(Some(&window)); if integration.should_close() { Ok(EventResult::CloseRequested) @@ -852,22 +876,6 @@ impl GlowWinitRunning<'_> { } } - /// On Mac, a minimized Window uses up all CPU: - /// - /// - /// On Windows, an invisible window also uses up all CPU: - /// - fn sleep_if_minimized(&self, viewport_id: ViewportId) { - let glutin = self.glutin.borrow(); - if let Some(viewport) = glutin.viewports.get(&viewport_id) - && let Some(window) = viewport.window.as_ref() - && is_invisible_or_minimized(window) - { - profiling::scope!("minimized_sleep"); - std::thread::sleep(std::time::Duration::from_millis(10)); - } - } - fn on_window_event( &mut self, window_id: WindowId, @@ -1417,36 +1425,6 @@ impl GlutinWindowContext { .retain(|id, _| viewport_output.contains_key(id)); } - /// Apply commands to already existing viewports, without creating or removing any. - /// - /// This is for commands that came out of [`egui::Context::run_logic`], - /// which knows nothing about which viewports should exist. - fn handle_viewport_commands( - &mut self, - egui_ctx: &egui::Context, - viewport_commands: egui::OrderedViewportIdMap>, - ) { - profiling::function_scope!(); - - for (viewport_id, mut commands) in viewport_commands { - let Some(viewport) = self.viewports.get_mut(&viewport_id) else { - continue; - }; - - viewport.deferred_commands.append(&mut commands); - - if let Some(window) = &viewport.window { - egui_winit::process_viewport_commands( - egui_ctx, - &mut viewport.info, - std::mem::take(&mut viewport.deferred_commands), - window, - &mut viewport.actions_requested, - ); - } - } - } - fn handle_viewport_output( &mut self, event_loop: &ActiveEventLoop, @@ -1462,7 +1440,7 @@ impl GlutinWindowContext { class, builder, viewport_ui_cb, - mut commands, + commands, repaint_delay: _, // ignored - we listened to the repaint callback instead }, ) in viewport_output.clone() @@ -1477,25 +1455,18 @@ impl GlutinWindowContext { viewport_ui_cb, ); - if let Some(window) = &viewport.window { - let old_inner_size = window.inner_size(); + let old_inner_size = viewport.window.as_ref().map(|window| window.inner_size()); - viewport.deferred_commands.append(&mut commands); + viewport.process_commands(egui_ctx, commands); - egui_winit::process_viewport_commands( - egui_ctx, - &mut viewport.info, - std::mem::take(&mut viewport.deferred_commands), - window, - &mut viewport.actions_requested, - ); - - // For Wayland : https://github.com/emilk/egui/issues/4196 - if cfg!(target_os = "linux") { - let new_inner_size = window.inner_size(); - if new_inner_size != old_inner_size { - self.resize(viewport_id, new_inner_size); - } + // For Wayland : https://github.com/emilk/egui/issues/4196 + if cfg!(target_os = "linux") + && let Some(window) = &viewport.window + && let Some(old_inner_size) = old_inner_size + { + let new_inner_size = window.inner_size(); + if new_inner_size != old_inner_size { + self.resize(viewport_id, new_inner_size); } } } diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index 5be541d17e76..343c2234aed0 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -30,7 +30,7 @@ use crate::{ App, AppCreator, CreationContext, NativeOptions, Result, Storage, native::{ epi_integration::EpiIntegration, - winit_integration::{EventResult, is_invisible_or_minimized}, + winit_integration::{EventResult, sleep_if_invisible_or_minimized}, }, }; @@ -726,10 +726,20 @@ impl WgpuWinitRunning<'_> { } } - handle_viewport_commands(&integration.egui_ctx, viewport_commands, viewports); + for (id, commands) in viewport_commands { + if let Some(viewport) = viewports.get_mut(&id) { + viewport.process_commands(&integration.egui_ctx, commands); + } + } } - sleep_if_minimized(&shared.borrow(), viewport_id); + sleep_if_invisible_or_minimized( + shared + .borrow() + .viewports + .get(&viewport_id) + .and_then(|viewport| viewport.window.as_deref()), + ); return Ok(if integration.should_close() { EventResult::CloseRequested @@ -863,16 +873,7 @@ impl WgpuWinitRunning<'_> { integration.maybe_autosave(app.as_mut(), window.map(|w| w.as_ref())); - if let Some(window) = window - && is_invisible_or_minimized(window) - { - // On Mac, a minimized Window uses up all CPU: - // https://github.com/emilk/egui/issues/325 - // On Windows, an invisible window also uses up all CPU: - // https://github.com/emilk/egui/issues/7776 - profiling::scope!("minimized_sleep"); - std::thread::sleep(std::time::Duration::from_millis(10)); - } + sleep_if_invisible_or_minimized(window.map(|window| window.as_ref())); if integration.should_close() { Ok(EventResult::CloseRequested) @@ -1027,6 +1028,25 @@ impl WgpuWinitRunning<'_> { } impl Viewport { + /// Apply the commands, or defer them until we have a window. + fn process_commands( + &mut self, + egui_ctx: &egui::Context, + mut commands: Vec, + ) { + self.deferred_commands.append(&mut commands); + + if let Some(window) = self.window.as_ref() { + egui_winit::process_viewport_commands( + egui_ctx, + &mut self.info, + std::mem::take(&mut self.deferred_commands), + window, + &mut self.actions_requested, + ); + } + } + /// Create winit window, if needed. fn initialize_window( &mut self, @@ -1250,51 +1270,6 @@ pub(crate) fn remove_viewports_not_in( } /// Add new viewports, and update existing ones: -/// Apply commands to already existing viewports, without creating or removing any. -/// -/// This is for commands that came out of [`egui::Context::run_logic`], -/// which knows nothing about which viewports should exist. -fn handle_viewport_commands( - egui_ctx: &egui::Context, - viewport_commands: egui::OrderedViewportIdMap>, - viewports: &mut Viewports, -) { - profiling::function_scope!(); - - for (viewport_id, mut commands) in viewport_commands { - let Some(viewport) = viewports.get_mut(&viewport_id) else { - continue; - }; - - viewport.deferred_commands.append(&mut commands); - - if let Some(window) = viewport.window.as_ref() { - egui_winit::process_viewport_commands( - egui_ctx, - &mut viewport.info, - std::mem::take(&mut viewport.deferred_commands), - window, - &mut viewport.actions_requested, - ); - } - } -} - -/// On Mac, a minimized Window uses up all CPU: -/// -/// -/// On Windows, an invisible window also uses up all CPU: -/// -fn sleep_if_minimized(shared: &SharedState, viewport_id: ViewportId) { - if let Some(viewport) = shared.viewports.get(&viewport_id) - && let Some(window) = viewport.window.as_ref() - && is_invisible_or_minimized(window) - { - profiling::scope!("minimized_sleep"); - std::thread::sleep(std::time::Duration::from_millis(10)); - } -} - fn handle_viewport_output( egui_ctx: &egui::Context, viewport_output: &OrderedViewportIdMap, @@ -1309,7 +1284,7 @@ fn handle_viewport_output( class, builder, viewport_ui_cb, - mut commands, + commands, repaint_delay: _, // ignored - we listened to the repaint callback instead }, ) in viewport_output.clone() @@ -1319,30 +1294,23 @@ fn handle_viewport_output( let viewport = initialize_or_update_viewport(viewports, ids, class, builder, viewport_ui_cb, painter); - if let Some(window) = viewport.window.as_ref() { - let old_inner_size = window.inner_size(); + let old_inner_size = viewport.window.as_ref().map(|window| window.inner_size()); - viewport.deferred_commands.append(&mut commands); + viewport.process_commands(egui_ctx, commands); - egui_winit::process_viewport_commands( - egui_ctx, - &mut viewport.info, - std::mem::take(&mut viewport.deferred_commands), - window, - &mut viewport.actions_requested, - ); - - // For Wayland : https://github.com/emilk/egui/issues/4196 - if cfg!(target_os = "linux") { - let new_inner_size = window.inner_size(); - if new_inner_size != old_inner_size - && let (Some(width), Some(height)) = ( - NonZeroU32::new(new_inner_size.width), - NonZeroU32::new(new_inner_size.height), - ) - { - painter.on_window_resized(viewport_id, width, height); - } + // For Wayland : https://github.com/emilk/egui/issues/4196 + if cfg!(target_os = "linux") + && let Some(window) = viewport.window.as_ref() + && let Some(old_inner_size) = old_inner_size + { + let new_inner_size = window.inner_size(); + if new_inner_size != old_inner_size + && let (Some(width), Some(height)) = ( + NonZeroU32::new(new_inner_size.width), + NonZeroU32::new(new_inner_size.height), + ) + { + painter.on_window_resized(viewport_id, width, height); } } } diff --git a/crates/eframe/src/native/winit_integration.rs b/crates/eframe/src/native/winit_integration.rs index b4ec62c090e7..9aa356de2240 100644 --- a/crates/eframe/src/native/winit_integration.rs +++ b/crates/eframe/src/native/winit_integration.rs @@ -17,6 +17,18 @@ pub fn is_invisible_or_minimized(window: &Window) -> bool { window.is_visible() == Some(false) || window.is_minimized() == Some(true) } +/// On Mac, a minimized window uses up all CPU: +/// +/// +/// On Windows, an invisible window also uses up all CPU: +/// +pub fn sleep_if_invisible_or_minimized(window: Option<&Window>) { + if window.is_some_and(is_invisible_or_minimized) { + profiling::scope!("minimized_sleep"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + /// Create an egui context, restoring it from storage if possible. pub fn create_egui_context(storage: Option<&dyn crate::Storage>) -> egui::Context { profiling::function_scope!(); diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index 26772461e98c..f5b8fd792d7d 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -617,6 +617,13 @@ fn run_logic_should_not_disturb_ui_state() { .memory(|m| m.areas().visible_last_frame(&area_layer)), "Area state was reset" ); + assert!( + harness + .ctx + .viewport_for(child_viewport, |viewport| viewport.class) + == egui::ViewportClass::Deferred, + "The child viewport was closed" + ); }; assert_state(&harness); From 2b71fc8790656255b4dc33ff59b429ca84e40179 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 19:37:23 +0200 Subject: [PATCH 5/8] clean up web code slightly --- crates/eframe/src/web/app_runner.rs | 57 ++++++++++++++--------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 56e942a80a35..d47c3ed12ba2 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -280,7 +280,34 @@ impl AppRunner { .and_then(|v| v.visible()) .unwrap_or(true); - if !is_visible { + if is_visible { + // `App::logic` may not show any ui, so it is called outside of the pass: + self.app.logic(&self.egui_ctx, &mut self.frame); + + let full_output = self.egui_ctx.run_ui(raw_input, |ui| { + self.app.ui(ui, &mut self.frame); + }); + let egui::FullOutput { + platform_output, + textures_delta, + shapes, + pixels_per_point, + viewport_output, + } = full_output; + + if viewport_output.len() > 1 { + log::warn!("Multiple viewports not yet supported on the web"); + } + self.handle_viewport_commands( + viewport_output + .into_values() + .flat_map(|viewport_output| viewport_output.commands), + ); + + self.handle_platform_output(platform_output); + self.textures_delta.append(textures_delta); + self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point)); + } else { // The tab is hidden, so we run no egui pass at all. // That way all ui state is left untouched, and is still there // when the tab is shown again. @@ -297,35 +324,7 @@ impl AppRunner { self.handle_viewport_commands(viewport_commands.into_values().flatten()); self.handle_platform_output(platform_output); - return; - } - - // `App::logic` may not show any ui, so it is called outside of the pass: - self.app.logic(&self.egui_ctx, &mut self.frame); - - let full_output = self.egui_ctx.run_ui(raw_input, |ui| { - self.app.ui(ui, &mut self.frame); - }); - let egui::FullOutput { - platform_output, - textures_delta, - shapes, - pixels_per_point, - viewport_output, - } = full_output; - - if viewport_output.len() > 1 { - log::warn!("Multiple viewports not yet supported on the web"); } - self.handle_viewport_commands( - viewport_output - .into_values() - .flat_map(|viewport_output| viewport_output.commands), - ); - - self.handle_platform_output(platform_output); - self.textures_delta.append(textures_delta); - self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point)); } fn handle_viewport_commands(&mut self, commands: impl Iterator) { From d9351120061c7e074dde411b843c213e08997b1d Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 19:45:02 +0200 Subject: [PATCH 6/8] Call `App::logic` from inside the pass when the window is visible That way it sees the current frame's input, like it did before. While hidden there is no pass, and `Context::run_logic` only fills in the window state, so that the app can tell that it is hidden; the ui input is left for the next pass to interpret. Co-Authored-By: Claude Opus 5 (1M context) --- crates/eframe/src/epi.rs | 6 ++++++ crates/eframe/src/native/epi_integration.rs | 19 ++++++++----------- crates/eframe/src/web/app_runner.rs | 4 +--- crates/egui/src/context.rs | 9 +++++---- tests/egui_tests/tests/regression_tests.rs | 12 ++++++++++-- 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index 7de55736aa2c..c10645bea42d 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -155,6 +155,12 @@ pub trait App { /// /// You may NOT show any ui or do any painting during the call to [`Self::logic`]. /// + /// While the window is hidden, `eframe` runs no egui pass at all (so that no ui state is + /// disturbed), and calls this via [`egui::Context::run_logic`] instead. + /// You can then still tell that the window is hidden with + /// [`egui::InputState::viewport`], but the rest of [`egui::Context::input`] + /// (events, time, …) is that of the last shown frame. + /// /// The [`egui::Context`] can be cloned and saved if you like. /// /// To force another call to [`Self::logic`], call [`egui::Context::request_repaint`] at any time (e.g. from another thread). diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 97461ead39e4..5799f2e2dc3a 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -284,23 +284,20 @@ impl EpiIntegration { let is_root_viewport = viewport_ui_cb.is_none(); - if is_root_viewport { - // Note that this is _not_ inside the pass below: - // `App::logic` may not show any ui, and should not affect any ui state. - profiling::scope!("App::logic"); - app.logic(&self.egui_ctx, &mut self.frame); - } - - // Anything `App::logic` asked for (viewport commands etc) is still in the - // `Context`, and will come out of the pass we are about to run. let full_output = self.egui_ctx.run_ui(raw_input, |ui| { if let Some(viewport_ui_cb) = viewport_ui_cb { // Child viewport profiling::scope!("viewport_callback"); viewport_ui_cb(ui); } else { - profiling::scope!("App::ui"); - app.ui(ui, &mut self.frame); + { + profiling::scope!("App::logic"); + app.logic(ui.ctx(), &mut self.frame); + } + { + 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 d47c3ed12ba2..b774bcb1f3c6 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -281,10 +281,8 @@ impl AppRunner { .unwrap_or(true); if is_visible { - // `App::logic` may not show any ui, so it is called outside of the pass: - self.app.logic(&self.egui_ctx, &mut self.frame); - let full_output = self.egui_ctx.run_ui(raw_input, |ui| { + self.app.logic(ui.ctx(), &mut self.frame); self.app.ui(ui, &mut self.frame); }); let egui::FullOutput { diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 7736c7febc2d..d815a83a21a2 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -900,9 +900,10 @@ impl Context { /// no widget state is garbage-collected, no animation advances, /// 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`]. + /// Of `new_input`, only the window state ([`RawInput::viewports`] and + /// [`RawInput::focused`]) is used, so that `f` can tell that the window is hidden. + /// The ui input (events, time, …) is _not_ interpreted, and is left for the next + /// call to [`Self::run_ui`]: [`Self::input`] is otherwise still that of the last pass. /// /// The returned [`LogicOutput`] is what [`FullOutput`] would have carried: /// anything `f` asked the integration to do. @@ -918,7 +919,7 @@ impl Context { // 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: + // Tell `f` 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(); diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index f5b8fd792d7d..1a65254a7a6f 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -630,8 +630,12 @@ fn run_logic_should_not_disturb_ui_state() { // 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(); + for i in 0..2 { + let time = 100.0 + f64::from(i); + let mut raw_input = egui::RawInput { + time: Some(time), + ..Default::default() + }; raw_input .viewports .entry(egui::ViewportId::ROOT) @@ -644,6 +648,10 @@ fn run_logic_should_not_disturb_ui_state() { Some(true), "App logic should be able to tell that the window is occluded" ); + assert!( + ctx.input(|i| i.time) != time, + "The ui input should not be interpreted: it is for the next pass" + ); // The app asks to be shown again: ctx.send_viewport_cmd(egui::ViewportCommand::Focus); From 341c22b0fc7bf9e9d9cc508b57a9bfcd25ee9aa4 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 19:45:45 +0200 Subject: [PATCH 7/8] f -> logic --- crates/egui/src/context.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index d815a83a21a2..50f324588b46 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -909,24 +909,24 @@ impl Context { /// anything `f` asked the integration to do. /// There is nothing to paint. #[must_use] - pub fn run_logic(&self, new_input: &RawInput, f: impl FnOnce(&Self)) -> LogicOutput { + pub fn run_logic(&self, new_input: &RawInput, logic: impl FnOnce(&Self)) -> LogicOutput { profiling::function_scope!(); let viewport_id = new_input.viewport_id; self.write(|ctx| { - // Consume any outstanding repaint request, so that a new request from `f` + // Consume any outstanding repaint request, so that a new request from `logic` // reaches the integration instead of being considered already served: ctx.begin_pass_repaint_logic(viewport_id); - // Tell `f` about the windows, but leave the ui input alone: + // Tell `logic` 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); + logic(self); self.write(|ctx| LogicOutput { platform_output: std::mem::take(&mut ctx.viewport_for(viewport_id).output), From dc6e7f14e5a291063ea76058c365a57a6c64b7d6 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 4 Aug 2026 19:54:15 +0200 Subject: [PATCH 8/8] input before output --- crates/eframe/src/native/epi_integration.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 5799f2e2dc3a..10d64932da32 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -156,12 +156,13 @@ pub struct EpiIntegration { pub beginning: Instant, is_first_frame: bool, pub egui_ctx: egui::Context, - pending_full_output: egui::FullOutput, /// Input that we have received, but not yet given to egui, /// because we haven't run any pass since (see [`Self::update_logic_only`]). pending_raw_input: egui::RawInput, + pending_full_output: egui::FullOutput, + /// When set, it is time to close the native window. close: bool, @@ -219,8 +220,8 @@ impl EpiIntegration { Self { frame, last_auto_save: Instant::now(), - pending_full_output: Default::default(), pending_raw_input: Default::default(), + pending_full_output: Default::default(), close: false, can_drag_window: false, #[cfg(feature = "persistence")]