From cd3c38cf2a0d65330a645faeadb6f666bc8df8f2 Mon Sep 17 00:00:00 2001 From: Gautier Cailly <109429289+gcailly@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:20:57 +0100 Subject: [PATCH] Improve behavior of invisible windows (#7905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * Closes #5229 * Closes #7776 On Windows, once a window is hidden with `ViewportCommand::Visible(false)`, two problems occur: 1. **Window can never be shown again** — Windows stops sending `RedrawRequested` events to invisible windows, and viewport commands are only processed during `run_ui_and_paint`, which is triggered by `RedrawRequested`. This creates a deadlock: ``` Visible(false) → window hidden → no RedrawRequested → run_ui_and_paint never called → Visible(true) stuck in queue → window stays hidden forever ``` 2. **High CPU usage** — The event loop spins at full speed with `ControlFlow::Poll` even for invisible windows, and repaint requests are scheduled immediately, causing a tight loop that burns CPU. ## Fix **For #5229:** In `check_redraw_requests`, after calling `window.request_redraw()`, detect invisible windows via `window.is_visible() == Some(false)` and call `run_ui_and_paint` directly for them. This ensures pending viewport commands (including `Visible(true)`) are still processed even when the OS doesn't send redraw events. **For #7776:** Three layers of throttling for invisible windows: - **Heartbeat scheduling:** After painting an invisible window, schedule the next repaint 100ms in the future (instead of immediately). This keeps viewport commands flowing while limiting to ~10 repaints/sec. - **Event throttling:** In `user_event`, throttle `RequestRepaint` events for invisible windows to at least 100ms delay, preventing egui's repaint callback from bypassing the heartbeat. - **ControlFlow fix:** Only set `ControlFlow::Poll` for visible windows. Invisible windows use `WaitUntil` instead of spinning. - **Backend sleep:** Add `is_visible() == Some(false)` alongside the existing `is_minimized()` sleep check in both wgpu and glow backends (defense in depth). The fix is platform-agnostic: `is_visible()` returns `Some(false)` only when the platform can confirm invisibility, so it won't trigger on platforms where invisible windows still receive `RedrawRequested`. ## Test plan - [x] `cargo fmt` passes - [x] `cargo clippy -p eframe --all-features` passes with no warnings - [x] Manual test on Windows: window reappears after `Visible(true)` when hidden - [x] Manual test on Windows: CPU stays near 0% while window is invisible (was ~16% before fix) --------- Co-authored-by: Emil Ernerfeldt --- crates/eframe/src/native/glow_integration.rs | 6 +- crates/eframe/src/native/run.rs | 64 +++++++++++++++++-- crates/eframe/src/native/wgpu_integration.rs | 9 ++- crates/eframe/src/native/winit_integration.rs | 8 +++ 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index 724ddc6d5..37e3faa69 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -34,7 +34,7 @@ use egui_winit::accesskit_winit; use crate::{ App, AppCreator, CreationContext, NativeOptions, Result, Storage, - native::epi_integration::EpiIntegration, + native::{epi_integration::EpiIntegration, winit_integration::is_invisible_or_minimized}, }; use super::{ @@ -761,9 +761,11 @@ impl GlowWinitRunning<'_> { integration.maybe_autosave(app.as_mut(), Some(&window)); - if window.is_minimized() == Some(true) { + 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)); } diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index 0597d318c..73b58ae61 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use winit::{ application::ApplicationHandler, @@ -11,9 +11,20 @@ use ahash::HashMap; use super::winit_integration::{UserEvent, WinitApp}; use crate::{ Result, epi, - native::{event_loop_context, winit_integration::EventResult}, + native::{ + event_loop_context, + winit_integration::{EventResult, is_invisible_or_minimized}, + }, }; +/// Minimum interval between repaints for invisible windows. +/// +/// On Windows, invisible windows don't receive `RedrawRequested` events, +/// so we throttle their repaints to avoid busy-looping while still +/// processing viewport commands like `Visible(true)`. +/// See . +const INVISIBLE_WINDOW_REPAINT_INTERVAL: Duration = Duration::from_millis(100); + // ---------------------------------------------------------------------------- fn create_event_loop(native_options: &mut epi::NativeOptions) -> Result> { #[cfg(target_os = "android")] @@ -177,23 +188,54 @@ impl WinitAppWrapper { fn check_redraw_requests(&mut self, event_loop: &ActiveEventLoop) { let now = Instant::now(); + let mut invisible_window_ids = Vec::new(); + self.windows_next_repaint_times .retain(|window_id, repaint_time| { if now < *repaint_time { return true; // not yet ready } - event_loop.set_control_flow(ControlFlow::Poll); - if let Some(window) = self.winit_app.window(*window_id) { - log::trace!("request_redraw for {window_id:?}"); - window.request_redraw(); + // On Windows, invisible windows don't receive RedrawRequested + // events, so pending viewport commands (e.g. Visible(true)) would + // never be processed. We collect these windows to paint them + // directly below. + // See: https://github.com/emilk/egui/issues/5229 + if is_invisible_or_minimized(&window) { + invisible_window_ids.push(*window_id); + } else { + log::trace!("request_redraw for {window_id:?}"); + event_loop.set_control_flow(ControlFlow::Poll); + window.request_redraw(); + } } else { log::trace!("No window found for {window_id:?}"); } false }); + // Paint invisible windows directly, since they won't receive + // RedrawRequested events on Windows. This ensures that viewport + // commands like Visible(true) are still processed. + for window_id in &invisible_window_ids { + let event_result = self.winit_app.run_ui_and_paint(event_loop, *window_id); + self.handle_event_result(event_loop, event_result); + } + + // Throttle any already-scheduled repaints for invisible windows + // to avoid busy-looping. If no repaint was requested by the app, + // the window will simply sleep. + // See: https://github.com/emilk/egui/issues/7776 + if !invisible_window_ids.is_empty() { + let next_paint = Instant::now() + INVISIBLE_WINDOW_REPAINT_INTERVAL; + for window_id in &invisible_window_ids { + self.windows_next_repaint_times + .entry(*window_id) + .and_modify(|t| *t = (*t).min(next_paint)); + } + } + let next_repaint_time = self.windows_next_repaint_times.values().min().copied(); if let Some(next_repaint_time) = next_repaint_time { event_loop.set_control_flow(ControlFlow::WaitUntil(next_repaint_time)); @@ -270,6 +312,16 @@ impl ApplicationHandler for WinitAppWrapper { if let Some(window_id) = self.winit_app.window_id_from_viewport_id(viewport_id) { + // Throttle repaints for invisible windows to prevent + // high CPU usage on Windows. + // See: https://github.com/emilk/egui/issues/7776 + let when = if let Some(window) = self.winit_app.window(window_id) + && is_invisible_or_minimized(&window) + { + when.max(Instant::now() + INVISIBLE_WINDOW_REPAINT_INTERVAL) + } else { + when + }; Ok(EventResult::RepaintAt(window_id, when)) } else { Ok(EventResult::Wait) diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index ea96a1845..6d300d513 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -27,7 +27,10 @@ use winit_integration::UserEvent; use crate::{ App, AppCreator, CreationContext, NativeOptions, Result, Storage, - native::{epi_integration::EpiIntegration, winit_integration::EventResult}, + native::{ + epi_integration::EpiIntegration, + winit_integration::{EventResult, is_invisible_or_minimized}, + }, }; use super::{epi_integration, event_loop_context, winit_integration, winit_integration::WinitApp}; @@ -778,10 +781,12 @@ impl WgpuWinitRunning<'_> { integration.maybe_autosave(app.as_mut(), window.map(|w| w.as_ref())); if let Some(window) = window - && window.is_minimized() == Some(true) + && 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)); } diff --git a/crates/eframe/src/native/winit_integration.rs b/crates/eframe/src/native/winit_integration.rs index 012c22f8e..b4ec62c09 100644 --- a/crates/eframe/src/native/winit_integration.rs +++ b/crates/eframe/src/native/winit_integration.rs @@ -9,6 +9,14 @@ use egui::ViewportId; #[cfg(feature = "accesskit")] use egui_winit::accesskit_winit; +/// Returns `true` if the window is invisible or minimized. +/// +/// These windows don't receive `RedrawRequested` events on Windows, +/// so they need special handling to keep processing viewport commands. +pub fn is_invisible_or_minimized(window: &Window) -> bool { + window.is_visible() == Some(false) || window.is_minimized() == Some(true) +} + /// 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!();