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:
@@ -1400,6 +1400,7 @@ dependencies = [
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"jiff",
|
||||
"log",
|
||||
"mimalloc",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
|
||||
@@ -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| {
|
||||
let closure =
|
||||
move |_event: web_sys::MouseEvent, runner: &mut AppRunner, web_runner: &WebRunner| {
|
||||
log::trace!("{} {event_name:?}", runner.canvas().id());
|
||||
runner.update_focus();
|
||||
|
||||
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(target, event_name, closure)?;
|
||||
runner_ref.add_event_listener_ex(
|
||||
target,
|
||||
event_name,
|
||||
&web_sys::AddEventListenerOptions::default(),
|
||||
closure,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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,
|
||||
|
||||
@@ -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>) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2909f098b5edacefef1b5c4d81982b0c84ebd27f896934f0bef92ceb283bbe79
|
||||
size 60288
|
||||
oid sha256:a825dc9c62979fbb8d1ca3d441b4d9e7dbbd234b994901026f35fe7d591ff196
|
||||
size 60217
|
||||
|
||||
Reference in New Issue
Block a user