Skip to content

Stabilize native viewport lifecycle and macOS wgpu fullscreen transitions - #8280

Draft
yay wants to merge 2 commits into
emilk:mainfrom
yay:steady/macos-wgpu-fullscreen-resize
Draft

Stabilize native viewport lifecycle and macOS wgpu fullscreen transitions#8280
yay wants to merge 2 commits into
emilk:mainfrom
yay:steady/macos-wgpu-fullscreen-resize

Conversation

@yay

@yay yay commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What started as an investigation into deferred viewport issues during macOS fullscreen transitions uncovered broader problems in how egui and eframe handle native viewport lifecycle.

This PR builds on #8226 by covering the remaining cases where a pass cannot authoritatively determine the complete viewport set.

Issues

Extra ghost window sporadically showing up after exiting fullscreen:
Screenshot 2026-06-30 at 2 21 29 PM

Bad viewport scaling during exit fullscreen animation:
Screenshot 2026-06-30 at 2 22 05 PM

Stretched content during enter full screen animation, with stale content in the bottom left corner:
Screenshot 2026-06-30 at 2 22 46 PM

In a multi-viewport macOS app using eframe and either wgpu or glow, native fullscreen transitions for secondary viewports could produce several failures:

  • fullscreen viewports could become non-interactive after entering native fullscreen
  • entering/exiting fullscreen could show stale or stretched content during AppKit’s Space transition
  • deferred viewports could be pruned or lose their viewport callback during partial child viewport passes (see below)
  • after skipped viewport paints, the first presented frame could use stale surface/viewport state
  • if the first wgpu surface acquisition was skipped before any frame presented, the window could remain blank until another repaint was triggered
  • after live resize, disabling transaction presentation before the final Metal drawable was shown could leave the window briefly non-interactive and then adjust its size

Video of egui 0.35.0 deferred viewport example behavior on macOS Sequoia 15.7.7: https://youtu.be/4vWCNajjFts
The video shows it can sometimes take a few tries to reproduce certain issues, but they inevitably happen.

One notable issue in the linked video: after a deferred viewport exits fullscreen, macOS can leave behind what looks like an extra native window. That window is not an egui viewport that the app still owns intentionally. It is stale state caused by eframe pruning or updating deferred viewport state from a partial child viewport pass while AppKit is still driving the fullscreen transition.

This PR avoids that by treating only root passes whose viewport UI ran as authoritative for pruning native viewports, and by preserving existing deferred viewport callbacks when incomplete child passes do not report them.

On macOS empirical testing shows that fullscreen windows are sometimes reported as:

  • fullscreen=true
  • focused=true
  • occluded=true
  • run_ui=false

Fixes

This changes egui and its native integrations to:

  • represent viewport output as a partial or complete report
  • prune missing native viewports only after a complete root report whose viewport UI ran
  • preserve deferred viewport callbacks across incomplete passes
  • move the current GL context before removing a Glow viewport surface
  • drive Metal transaction presentation from AppKit live-resize and fullscreen state
  • wait for the final transaction-presented Metal drawable before leaving live-resize presentation state
  • retry when the first wgpu surface acquisition is skipped
  • refresh native maximize state at runtime on macOS
  • distinguish AppKit live resize from fullscreen transitions, enabling transaction presentation for both while retaining #8229’s temporary latency bump only for live resize, so it now works like this:
State presentsWithTransaction Latency bump
LiveResize yes yes
FullscreenTransition yes no
Idle no no

API migration

FullOutput::viewport_output is now a ViewportOutputReport so integrations cannot
silently ignore whether a viewport report is authoritative.

  • Use output.viewport_output.entries for the viewport map.
  • Check output.viewport_output.is_complete before removing missing native viewports.
  • Set raw_input.viewport_ui_enabled = false when running a pass without evaluating viewport UI.

Dependency

Depends on rust-windowing/winit#4610 for:

  • WindowExtMacOS::is_live_resizing()
  • WindowExtMacOS::is_fullscreen_transition()
  • cached version of WindowDelegate::is_maximized()

Depends on gfx-rs/wgpu#9828 for:

  • submitted and completed Metal transaction-presentation generations
  • a one-shot notification after a target generation is presented

Because egui 0.35 uses wgpu 29 while the upstream wgpu PR targets trunk, this branch temporarily pins an equivalent v29 backport in yay/wgpu.

This is draft because the required winit and wgpu APIs have not reached versions compatible with egui 0.35. macOS checks may fail until those dependency updates are available.

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown

Preview available at https://egui-pr-preview.github.io/pr/8280-steadymacos-wgpu-fullscreen-resize
Note that it might take a couple seconds for the update to show up after the preview_build workflow has completed.

View snapshot changes at kitdiff

@yay yay changed the title Stabilize macOS wgpu viewport transitions Stabilize macOS wgpu/glow viewport transitions Jun 30, 2026
@yay
yay force-pushed the steady/macos-wgpu-fullscreen-resize branch 13 times, most recently from 0863bb1 to d0ba7f2 Compare July 3, 2026 16:29
@yay yay changed the title Stabilize macOS wgpu/glow viewport transitions Stabilize native viewport lifecycle and macOS wgpu fullscreen transitions Jul 4, 2026
@smoe

smoe commented Jul 4, 2026

Copy link
Copy Markdown

I asked Fable for a comment:

Possibly useful corroboration for the "extra ghost window" you documented here: while working on #8259 (native child viewport opened from a fullscreen root, fix in #8286), the baseline repro independently produced the same stale extra window — through a different path than the deferred-viewport fullscreen exit shown in your video.

The trigger there: root in native fullscreen, then a native child viewport is created → macOS starts a Space/Split View negotiation → the child gets tiled into the fullscreen Space (its styleMask actually gains FullScreen without the app ever requesting it), and one stale untitled window is left behind. [AppKit state dump]

So the ghost window seems reachable from any AppKit-driven Space negotiation, not only from fullscreen exits — which supports this PR's diagnosis that eframe state updates during AppKit-driven transitions are the underlying hazard.

#8286 avoids that negotiation at the source for the creation-while-fullscreen case… The two changes should be complementary: this PR stabilizes transitions that do happen; #8286 prevents one class of them from being triggered accidentally. Happy to re-run my repro on top of this branch if a combined test would help.

@yay

yay commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@smoe Thanks. I tested the same flow in my app on top of this PR, but putting the root viewport in fullscreen and then creating a deferred child did not produce a ghost window, abort fullscreen, or trigger visible Split View negotiation.

I did find a separate issue when closing a child while the fullscreen root was in another Space (child window turned black without closing). That turned out to be caused by using root visibility as a proxy for whether root UI had evaluated the viewport tree, so this PR now tracks that explicitly.

So my test does not currently corroborate that the ghost windows in the two reproductions share an underlying cause.

The PRs still appear complementary at the implementation level, but testing your exact reproduction on top of #8280 would be useful to check if there's an overlap.

@yay
yay force-pushed the steady/macos-wgpu-fullscreen-resize branch from 879be28 to 32e0b7f Compare July 5, 2026 03:49
@smoe

smoe commented Jul 6, 2026

Copy link
Copy Markdown

Fable kindly helped out with this comment again.

Ran the exact #8259 reproduction (fullscreen the root via ViewportCommand::Fullscreen(true), then show_viewport_deferred a 300×200 child, then dump NSWindow state via objc2-app-kit) on three configurations. Environment: macOS 26.5.2 (Tahoe), Apple Silicon, wgpu backend, stock winit 0.30.13.

configuration result
egui 0.35.0 baseline child tiled into the fullscreen Space (styleMask gains FullScreen), ghost window left behind
#8280 (32e0b7f) identical failure signature — child fullscreen=true, ghost window present
#8280 + #8286 merged (clean merge, no conflicts) child floats over the still-fullscreen root as a normal window, no ghost window

So on this OS the two reproductions don't share a cause at the window-creation step: #8280 doesn't change window creation, and the Space negotiation fires exactly as on 0.35.0. Two observations that may explain why your test didn't reproduce it:

  1. OS difference: you tested on Sequoia 15.7.7; Tahoe's window-tiling behavior is substantially more aggressive about pulling new windows into fullscreen Spaces. The macOS: native child viewport flickers/aborts when opened from fullscreen or maximized root window #8259 flow may simply not trigger the negotiation on Sequoia the way it does on 26.x.
  2. Build caveat: the pushed branch (32e0b7f) calls WindowExtMacOS::is_live_resizing() / is_fullscreen_transition(), which don't exist in stock winit 0.30.13 — it looks like the companion winit patch didn't make it into the PR (its CI shows the same compile failures). For this test I stubbed native_resize_state to Idle, which disables the present-mode/latency switching but doesn't touch window creation, viewport pruning, or occlusion handling, so it shouldn't affect this particular repro. Happy to re-run unshimmed once the winit side is available.

The good news is symmetrical: the ghost window in your reproduction (deferred viewport fullscreen exits) is plausibly fixed by this PR's pruning-authority change and out of #8286's reach, while the creation-time negotiation is prevented by #8286 and out of this PR's reach — and the merged run confirms they compose cleanly. Repro source below.

Repro harness (main.rs)

//! Repro/verification for #8259:
//! fullscreen the root window, then open a native child viewport,
//! and report the resulting AppKit window state.

use std::time::Instant;

use eframe::egui;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSWindowCollectionBehavior, NSWindowStyleMask};

struct TestApp {
start: Instant,
requested_fullscreen: bool,
show_child: bool,
reported: bool,
}

fn dump_windows(label: &str) {
let Some(mtm) = MainThreadMarker::new() else {
println!("RESULT {label}: not on main thread!?");
return;
};
let app = NSApplication::sharedApplication(mtm);
for window in app.windows().iter() {
let title = window.title();
let fullscreen = window
.styleMask()
.contains(NSWindowStyleMask::FullScreen);
let auxiliary = window
.collectionBehavior()
.contains(NSWindowCollectionBehavior::FullScreenAuxiliary);
let on_active_space = window.isOnActiveSpace();
let visible = window.isVisible();
println!(
"RESULT {label}: window={title:?} fullscreen={fullscreen} auxiliary={auxiliary} on_active_space={on_active_space} visible={visible}"
);
}
}

impl eframe::App for TestApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let elapsed = self.start.elapsed().as_secs_f32();

    let want_fullscreen = std::env::var("SKIP_FULLSCREEN").is_err();
    if !self.requested_fullscreen && want_fullscreen {
        self.requested_fullscreen = true;
        ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
    }

    let root_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));

    if elapsed > 3.0 && (root_fullscreen || !want_fullscreen) && !self.show_child {
        self.show_child = true;
        println!("RESULT: root is fullscreen, opening child viewport");
    }

    if self.show_child {
        ctx.show_viewport_deferred(
            egui::ViewportId::from_hash_of("child"),
            egui::ViewportBuilder::default()
                .with_title("Child viewport")
                .with_inner_size([300.0, 200.0]),
            |ui, _class| {
                egui::CentralPanel::default().show(ui, |ui| {
                    ui.label("I am the child viewport");
                });
            },
        );
    }

    ui.label(format!(
        "elapsed: {elapsed:.1}s, root_fullscreen: {root_fullscreen}"
    ));

    if elapsed > 7.0 && !self.reported {
        self.reported = true;
        println!("RESULT: root_fullscreen_after_child={root_fullscreen}");
        dump_windows("final");
    }

    if elapsed > 8.0 {
        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
    }

    ctx.request_repaint_after(std::time::Duration::from_millis(100));
}

}

fn main() -> eframe::Result {
env_logger::init();
let renderer = if std::env::var("USE_GLOW").is_ok() {
eframe::Renderer::Glow
} else {
eframe::Renderer::Wgpu
};
println!("RESULT: renderer={renderer:?}");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("Root window")
.with_inner_size([600.0, 400.0]),
renderer,
..Default::default()
};
eframe::run_native(
"fullscreen-child-test",
options,
Box::new(|_cc| {
Ok(Box::new(TestApp {
start: Instant::now(),
requested_fullscreen: false,
show_child: false,
reported: false,
}))
}),
)
}

@yay

yay commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

@smoe Thanks for testing it.

yay added 2 commits July 7, 2026 16:21
Drive Metal presentation from AppKit transition state, represent viewport-output completeness explicitly, preserve deferred viewport lifecycle across partial passes, and retry painting when the first surface acquisition is skipped.
@yay
yay force-pushed the steady/macos-wgpu-fullscreen-resize branch from 32e0b7f to 2091af2 Compare July 7, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants