emilk / emilk/egui

[REGRESSION] macOS Space switch destroys immediate viewports, resetting their position to screen center

Open
#8,085 0 comments 1 reaction 0 assignees View on GitHub
bug
Dominant language
Rust
Stars
30.6k
Forks
2.1k
Avg merge
1d 9h
Merged PRs (30d)
72

Description

**Platform:** macOS (confirmed on macOS 26, Apple Silicon)
**eframe version:** 0.34.1
**egui version:** 0.34.1
**Backend:** glow (not tested with wgpu)
**Related:** #8043 (different macOS viewport regression in 0.34)

---

## Describe the bug

When a macOS user switches to a different Space (virtual desktop) and then switches back, all child windows opened via `show_viewport_immediate` lose their position and reappear at the screen center.

This happens whenever macOS reports the root window as no longer visible, which covers two distinct gestures:

- **Space switch** (`Ctrl+←/→` or 3-finger swipe): macOS sends `WindowEvent::Occluded(true)`.
- **Minimize to Dock** (yellow traffic-light button): macOS sends `WindowEvent::Minimized(true)`.

Both cause `ViewportInfo::visible()` to return `false`, which is the common trigger. In both cases the child window disappears while the root is hidden, and reappears centered when the root is restored.

> **Note:** Simply backgrounding the app (clicking another app or `Cmd+Tab`) does **not** trigger this bug, macOS sends `Focused(false)` in that case but the root window remains visible (`Occluded` and `Minimized` stay false).

## Root cause

In `eframe/src/native/epi_integration.rs`, the `update()` function receives an `is_visible` flag derived from `ViewportInfo::visible()`, which returns `false` when the window is occluded or minimized.

When `is_visible = false`, **`app.update()` is skipped entirely**:

```rust
// Current upstream code (eframe 0.34.1)
if is_visible {
app.update(ui.ctx(), &mut self.frame); // <— skipped when occluded
app.ui(ui, &mut self.frame);
}
```

Because `app.update()` is not called, no `show_viewport_immediate(...)` calls are made. egui then garbage-collects any viewport that wasn't marked `used = true` that frame, destroying the native OS windows. When the user returns to the Space, the viewports are **recreated from scratch**, without any saved position, so the OS places them at the screen center.

## Steps to reproduce

1. Run the minimal reproduction below on macOS.
2. Click **"Open child window"** and move the child window to a corner of the screen.
3. Switch to a different macOS Space (e.g. with a 3-finger swipe or `Ctrl+←`).
4. Switch back to the original Space.

**Expected:** The child window is still at the corner where you left it.
**Actual:** The child window reappears at the screen center.

## Minimal reproduction

```rust
// Cargo.toml:
// [dependencies]
// eframe = "0.34.1"
// egui = "0.34.1"

use eframe::egui;

fn main() -> eframe::Result {
eframe::run_native(
"Viewport Position Bug",
eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(App::default()))),
)
}

#[derive(Default)]
struct App {
child_open: bool,
}

impl eframe::App for App {
#[allow(deprecated)]
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.label("1. Open the child window below.");
ui.label("2. Move it somewhere (e.g. a corner).");
ui.label("3. Switch to another macOS Space and come back.");
ui.label("-> The child window will have reset to screen center.");
ui.separator();
ui.checkbox(&mut self.child_open, "Open child window");
});

if self.child_open {
let vp_id = egui::ViewportId::from_hash_of("child_viewport");

ctx.show_viewport_immediate(
vp_id,
egui::ViewportBuilder::default()
.with_title("Child Window — move me!")
.with_inner_size([300.0, 150.0]),
|vctx, _class| {
if vctx.input(|i| i.viewport().close_requested()) {
vctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
egui::CentralPanel::default().show(vctx, |ui| {
ui.label("I am a child viewport.");
ui.label("Switch Space and come back:");
ui.label("I will have moved to screen center.");
});
},
);
}
}

// Required by eframe 0.34 trait
fn ui(&mut self, _ui: &mut egui::Ui, _frame: &mut eframe::Frame) {}
}
```

## Proposed fix

Run `app.update()` unconditionally, keeping only `app.ui()` (the painting step) behind the `is_visible` guard. This way, the logical tree of viewports is maintained even while the window is occluded, and the OS preserves their positions natively.

```diff
--- a/crates/eframe/src/native/epi_integration.rs
+++ b/crates/eframe/src/native/epi_integration.rs
@@ -283,15 +283,15 @@ impl EpiIntegration {
app.logic(ui.ctx(), &mut self.frame);
}

- if is_visible {
- {
- profiling::scope!("App::update");
- #[expect(deprecated)]
- app.update(ui.ctx(), &mut self.frame);
- }
-
- {
- profiling::scope!("App::ui");
- app.ui(ui, &mut self.frame);
- }
+ {
+ profiling::scope!("App::update");
+ #[expect(deprecated)]
+ app.update(ui.ctx(), &mut self.frame);
}
+
+ if is_visible {
+ profiling::scope!("App::ui");
+ app.ui(ui, &mut self.frame);
+ }
}
});
```

The same fix should be applied symmetrically to `wgpu_integration.rs` if the same pattern exists there.

## Notes

- `fn logic()` already runs unconditionally, the same should apply to `update()` for viewport lifecycle purposes.
- The rendering path stays correctly gated on `is_visible` in `glow_integration.rs` / `wgpu_integration.rs`, so this does not cause unnecessary GPU work.
- This regression appears to have been introduced alongside PR #7950 (0.34.0).
- Workaround: `ctx.set_embed_viewports(true)` avoids the issue but forces all child viewports to be embedded windows (major UX change).

Contributor guide

Open the contributing guide

Research direction

Start in eframe/src/native/epi_integration.rs at EpiIntegration::update and compare the visibility guards around app.update() and app.ui(). Check wgpu_integration.rs for the same pattern, then run the macOS reproduction with glow and verify that child viewports retain their positions after a Space switch or minimize/restore; the fix is complete when both native paths preserve those windows without painting while hidden.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
desktop
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
70/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.