From 2e26b70ae95883c93961d8eeb077559f896e8932 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 25 Jun 2026 14:59:45 +0200 Subject: [PATCH] Call `logic` even while browser tab is in background (#8257) * Closes https://github.com/emilk/egui/issues/5112 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/eframe/src/web/events.rs | 30 +++- crates/eframe/src/web/web_runner.rs | 78 +++++++++- crates/egui_demo_app/src/lib.rs | 5 + crates/egui_demo_app/src/wrap_app.rs | 12 ++ crates/egui_demo_lib/Cargo.toml | 1 + .../src/demo/demo_app_windows.rs | 18 +++ .../src/demo/misc_demo_window.rs | 138 ++++++++++++++++++ crates/egui_demo_lib/src/demo/mod.rs | 4 + .../tests/snapshots/demos/Misc Demos.png | 4 +- 10 files changed, 277 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ae343af4..2ef1212e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1400,6 +1400,7 @@ dependencies = [ "egui_kittest", "image", "jiff", + "log", "mimalloc", "rand 0.10.1", "serde", diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index e24e99fee..ec6bf2a07 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -113,12 +113,32 @@ fn install_blur_focus(runner_ref: &WebRunner, target: &EventTarget) -> Result<() // NOTE: because of the text agent we sometime miss 'blur' events, // so we also poll the focus state each frame in `AppRunner::logic`. for event_name in ["blur", "focus", "visibilitychange"] { - let closure = move |_event: web_sys::MouseEvent, runner: &mut AppRunner| { - log::trace!("{} {event_name:?}", runner.canvas().id()); - runner.update_focus(); - }; + let closure = + move |_event: web_sys::MouseEvent, runner: &mut AppRunner, web_runner: &WebRunner| { + log::trace!("{} {event_name:?}", runner.canvas().id()); + runner.update_focus(); - runner_ref.add_event_listener(target, event_name, closure)?; + if event_name == "visibilitychange" { + // The tab was hidden or shown. An in-flight `requestAnimationFrame` is paused + // while hidden, so reschedule the paint loop using the scheduling mechanism + // (`setTimeout` vs `requestAnimationFrame`) appropriate for the new state. + // This keeps `App::update` running while hidden, and switches back to smooth + // animation frames once visible again. + if let Err(err) = web_runner.reschedule_frame() { + log::error!( + "Failed to reschedule frame on visibility change: {}", + super::string_from_js_value(&err) + ); + } + } + }; + + runner_ref.add_event_listener_ex( + target, + event_name, + &web_sys::AddEventListenerOptions::default(), + closure, + )?; } Ok(()) } diff --git a/crates/eframe/src/web/web_runner.rs b/crates/eframe/src/web/web_runner.rs index c9449ab99..99615c97f 100644 --- a/crates/eframe/src/web/web_runner.rs +++ b/crates/eframe/src/web/web_runner.rs @@ -129,8 +129,7 @@ impl WebRunner { self.unsubscribe_from_all_events(); if let Some(frame) = self.frame.take() { - let window = web_sys::window().unwrap(); - window.cancel_animation_frame(frame.id).ok(); + frame.cancel(&web_sys::window().unwrap()); } if let Some(runner) = self.app_runner.replace(None) { @@ -234,6 +233,12 @@ impl WebRunner { /// /// It is safe to call `request_animation_frame` multiple times in quick succession, /// this function guarantees that only one animation frame is scheduled at a time. + /// + /// A hidden browser tab (e.g. a backgrounded tab) does not receive + /// `requestAnimationFrame` callbacks, which would otherwise stop our paint loop + /// and prevent `App::update` from running in response to `request_repaint`. + /// To keep running in that case, we fall back to `setTimeout`, which keeps firing + /// while hidden (the browser throttles it to roughly once per second). pub(crate) fn request_animation_frame(&self) -> Result<(), wasm_bindgen::JsValue> { if self.frame.borrow().is_some() { // there is already an animation frame in flight @@ -252,14 +257,47 @@ impl WebRunner { } }); - let id = window.request_animation_frame(closure.as_ref().unchecked_ref())?; - self.frame.borrow_mut().replace(AnimationFrameRequest { - id, - _closure: closure, - }); + let hidden = window.document().is_some_and(|document| document.hidden()); + + let request = if hidden { + // The tab is hidden: `requestAnimationFrame` would not fire, so use a timer instead. + let id = window.set_timeout_with_callback_and_timeout_and_arguments_0( + closure.as_ref().unchecked_ref(), + // Browsers clamp background timers to ~1s, so the exact value has little effect + // while hidden, but keeps us responsive right after the tab is hidden: + 10, + )?; + AnimationFrameRequest { + id, + kind: AnimationFrameKind::Timeout, + _closure: closure, + } + } else { + let id = window.request_animation_frame(closure.as_ref().unchecked_ref())?; + AnimationFrameRequest { + id, + kind: AnimationFrameKind::AnimationFrame, + _closure: closure, + } + }; + + self.frame.borrow_mut().replace(request); Ok(()) } + + /// Cancel any in-flight frame request and schedule a fresh one. + /// + /// Called on `visibilitychange` so we switch between `requestAnimationFrame` (visible) + /// and `setTimeout` (hidden) scheduling. This is necessary because an in-flight + /// `requestAnimationFrame` is paused while the tab is hidden, which would otherwise + /// stall the paint loop and stop `App::update` from running while hidden. + pub(crate) fn reschedule_frame(&self) -> Result<(), wasm_bindgen::JsValue> { + if let Some(frame) = self.frame.borrow_mut().take() { + frame.cancel(&web_sys::window().unwrap()); + } + self.request_animation_frame() + } } // ---------------------------------------------------------------------------- @@ -269,11 +307,37 @@ struct AnimationFrameRequest { /// Represents the ID of a frame in flight. id: i32, + /// How the frame was scheduled, so we know how to cancel it. + kind: AnimationFrameKind, + /// The callback given to `request_animation_frame`, stored here both to prevent it /// from being canceled, and from having to `.forget()` it. _closure: Closure Result<(), JsValue>>, } +impl AnimationFrameRequest { + /// Cancel the in-flight frame request, using the API matching how it was scheduled. + fn cancel(&self, window: &web_sys::Window) { + match self.kind { + AnimationFrameKind::AnimationFrame => { + window.cancel_animation_frame(self.id).ok(); + } + AnimationFrameKind::Timeout => { + window.clear_timeout_with_handle(self.id); + } + } + } +} + +/// How an [`AnimationFrameRequest`] was scheduled. +enum AnimationFrameKind { + /// Scheduled with `requestAnimationFrame` (visible tab). + AnimationFrame, + + /// Scheduled with `setTimeout` (hidden tab). + Timeout, +} + struct TargetEvent { target: web_sys::EventTarget, event_name: String, diff --git a/crates/egui_demo_app/src/lib.rs b/crates/egui_demo_app/src/lib.rs index 45abccc7f..8ee10058e 100644 --- a/crates/egui_demo_app/src/lib.rs +++ b/crates/egui_demo_app/src/lib.rs @@ -19,6 +19,11 @@ pub(crate) fn seconds_since_midnight() -> f64 { pub trait DemoApp { fn demo_ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame); + /// Run background logic, called every frame even when the app is hidden. + /// + /// See [`eframe::App::logic`]. + fn logic(&mut self, _ctx: &egui::Context) {} + #[cfg(feature = "glow")] fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {} } diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 73fcc5e84..8ed3086d1 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -28,6 +28,10 @@ impl DemoApp for DemoWindows { fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.ui(ui); } + + fn logic(&mut self, ctx: &egui::Context) { + self.logic(ctx); + } } // ---------------------------------------------------------------------------- @@ -280,6 +284,14 @@ impl eframe::App for WrapApp { color.to_normalized_gamma_f32() } + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Run background logic for every app, even the ones not currently shown, + // so they keep working while the app is hidden (e.g. a backgrounded tab). + for (_name, _anchor, app) in self.apps_iter_mut() { + app.logic(ctx); + } + } + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { #[cfg(target_arch = "wasm32")] if let Some(anchor) = frame diff --git a/crates/egui_demo_lib/Cargo.toml b/crates/egui_demo_lib/Cargo.toml index d2586fa11..b5d500b16 100644 --- a/crates/egui_demo_lib/Cargo.toml +++ b/crates/egui_demo_lib/Cargo.toml @@ -42,6 +42,7 @@ syntect = ["egui_extras/syntect"] egui = { workspace = true, default-features = false, features = ["color-hex"] } egui_extras = { workspace = true, features = ["image", "svg"] } +log.workspace = true unicode_names2.workspace = true # this old version has fewer dependencies #! ### Optional dependencies diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 8e59e0933..a874de5b6 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -47,6 +47,12 @@ impl DemoGroup { set_open(open, demo.name(), is_open); } } + + pub fn logic(&mut self, ctx: &egui::Context) { + for demo in &mut self.demos { + demo.logic(ctx); + } + } } fn set_open(open: &mut BTreeSet, key: &'static str, is_open: bool) { @@ -160,6 +166,11 @@ impl DemoGroups { demos.windows(ui, open); tests.windows(ui, open); } + + pub fn logic(&mut self, ctx: &egui::Context) { + self.demos.logic(ctx); + self.tests.logic(ctx); + } } // ---------------------------------------------------------------------------- @@ -212,6 +223,13 @@ impl DemoWindows { } } + /// Run background logic for all demos. + /// + /// Called every frame, even when hidden, so demos can keep working in the background. + pub fn logic(&mut self, ctx: &egui::Context) { + self.groups.logic(ctx); + } + fn about_is_open(&self) -> bool { self.open.contains(About::default().name()) } diff --git a/crates/egui_demo_lib/src/demo/misc_demo_window.rs b/crates/egui_demo_lib/src/demo/misc_demo_window.rs index 8ccc44d36..d035f5186 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -19,6 +19,7 @@ pub struct MiscDemoWindow { tree: Tree, box_painting: BoxPainting, text_rotation: TextRotation, + repaint: Repaint, dummy_bool: bool, dummy_usize: usize, @@ -36,6 +37,7 @@ impl Default for MiscDemoWindow { tree: Tree::demo(), box_painting: Default::default(), text_rotation: Default::default(), + repaint: Default::default(), dummy_bool: false, dummy_usize: 0, @@ -57,6 +59,10 @@ impl Demo for MiscDemoWindow { .constrain_to(ui.available_rect_before_wrap()) .show(ui, |ui| self.ui(ui)); } + + fn logic(&mut self, ctx: &egui::Context) { + self.repaint.logic(ctx); + } } impl View for MiscDemoWindow { @@ -102,6 +108,10 @@ impl View for MiscDemoWindow { .default_open(false) .show(ui, |ui| self.tree.ui(ui)); + CollapsingHeader::new("Repaint") + .default_open(false) + .show(ui, |ui| self.repaint.ui(ui)); + CollapsingHeader::new("Checkboxes") .default_open(false) .show(ui, |ui| { @@ -292,6 +302,134 @@ impl Widgets { // ---------------------------------------------------------------------------- +/// Demonstrates [`egui::Context::request_repaint`] and +/// [`egui::Context::request_repaint_after`]. +#[derive(PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +struct Repaint { + /// Request a repaint every frame, so we run as fast as the integration allows. + repaint_continuously: bool, + + /// Request a repaint after [`Self::delay`]. + repaint_after_delay: bool, + + /// How long to wait before the next repaint when [`Self::repaint_after_delay`] is set. + delay: f64, + + /// Issue the repaint requests from `logic` (which runs even while hidden) instead of `ui`. + in_background: bool, + + /// Log each `ui` and `logic` frame, so background activity is visible in the console. + log_each_frame: bool, + + /// How many times [`Self::ui`] has run since the last reset. + #[cfg_attr(feature = "serde", serde(skip))] + ui_count: u64, + + /// How many times [`Self::logic`] has run since the last reset. + #[cfg_attr(feature = "serde", serde(skip))] + logic_count: u64, +} + +impl Default for Repaint { + fn default() -> Self { + Self { + repaint_continuously: false, + repaint_after_delay: false, + delay: 1.0, + in_background: false, + log_each_frame: false, + ui_count: 0, + logic_count: 0, + } + } +} + +impl Repaint { + fn ui(&mut self, ui: &mut Ui) { + self.ui_count += 1; + if self.log_each_frame { + log::info!("Repaint demo: `ui` frame {}", self.ui_count); + } + + ui.label("Use this to verify if logic is correctly called while in background."); + + ui.horizontal(|ui| { + if ui.button("Reset counts").clicked() { + self.ui_count = 0; + self.logic_count = 0; + } + ui.label(format!( + "`ui`: {}, `logic`: {}", + self.ui_count, self.logic_count + )) + .on_hover_text( + "`ui` is incremented in `App::ui` (only runs while visible), \ + `logic` in `App::logic` (runs even while hidden).", + ); + }); + + ui.separator(); + + ui.checkbox( + &mut self.repaint_continuously, + "Repaint continuously (every frame)", + ); + + ui.horizontal(|ui| { + ui.checkbox(&mut self.repaint_after_delay, "Repaint after"); + ui.add_enabled( + self.repaint_after_delay, + Slider::new(&mut self.delay, 0.0..=5.0) + .suffix(" s") + .text("delay"), + ); + }); + + ui.checkbox(&mut self.in_background, "In the background (during logic)") + .on_hover_text( + "Issue the repaint requests from `App::logic` (which runs even while hidden) \ + instead of `App::ui` (which is skipped while hidden).\n\n\ + With this enabled, hide this tab for a while, then come back: \ + the `logic` count will have kept climbing.", + ); + + ui.checkbox(&mut self.log_each_frame, "Log each frame") + .on_hover_text("Log each `ui` and `logic` frame to the console."); + + // When not in background mode, drive the repaints from here (`ui`), which only + // runs while visible. Otherwise they are driven from `logic` (see below). + if !self.in_background { + self.request_repaint(ui.ctx()); + } + } + + /// Runs even when the app is hidden, unlike [`Self::ui`]. + fn logic(&mut self, ctx: &egui::Context) { + self.logic_count += 1; + if self.log_each_frame { + log::info!("Repaint demo: `logic` frame {}", self.logic_count); + } + + if self.in_background { + self.request_repaint(ctx); + } + } + + /// Request repaints according to the selected options. + fn request_repaint(&self, ctx: &egui::Context) { + if self.repaint_continuously { + ctx.request_repaint(); + } + if self.repaint_after_delay { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(self.delay)); + } + } +} + +// ---------------------------------------------------------------------------- + #[derive(PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index 6d1906166..04103feb8 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -62,4 +62,8 @@ pub trait Demo { /// Show windows, etc fn show(&mut self, ui: &mut egui::Ui, open: &mut bool); + + /// Run background logic, called every frame even when the demo window is closed + /// or the app is hidden. + fn logic(&mut self, _ctx: &egui::Context) {} } diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png b/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png index af5258575..f96f4a8fc 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2909f098b5edacefef1b5c4d81982b0c84ebd27f896934f0bef92ceb283bbe79 -size 60288 +oid sha256:a825dc9c62979fbb8d1ca3d441b4d9e7dbbd234b994901026f35fe7d591ff196 +size 60217