1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

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) <noreply@anthropic.com>
This commit is contained in:
Lucas Meurer
2026-06-25 14:59:45 +02:00
committed by GitHub
parent 26ead4af21
commit 2e26b70ae9
10 changed files with 277 additions and 14 deletions

View File

@@ -1400,6 +1400,7 @@ dependencies = [
"egui_kittest",
"image",
"jiff",
"log",
"mimalloc",
"rand 0.10.1",
"serde",

View File

@@ -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(())
}

View File

@@ -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<dyn FnMut() -> 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,

View File

@@ -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>) {}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<String>, 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())
}

View File

@@ -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))]

View File

@@ -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) {}
}

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2909f098b5edacefef1b5c4d81982b0c84ebd27f896934f0bef92ceb283bbe79
size 60288
oid sha256:a825dc9c62979fbb8d1ca3d441b4d9e7dbbd234b994901026f35fe7d591ff196
size 60217