From 5937a5e1bc2df951338a46052b07608dd644007d Mon Sep 17 00:00:00 2001 From: Steffen Moeller Date: Fri, 3 Jul 2026 23:19:49 +0200 Subject: [PATCH 1/2] Fix native child viewports aborting fullscreen on macOS When a native child viewport was opened while the root window was in native fullscreen, macOS renegotiated the active Space: the new window flickered, could get pulled into Split View tiling (ending up unintentionally fullscreen itself), and could abort the root's fullscreen state (https://github.com/emilk/egui/issues/8259). Fix this by marking such windows as NSWindowCollectionBehaviorFullScreenAuxiliary, which lets them be shown on the same Space as a fullscreen window. Since winit 0.30 does not expose the collection behavior, and it must be set before the window is first ordered on screen, the window is created hidden, marked via objc2-app-kit, and then shown. - New `ViewportBuilder::with_fullscreen_auxiliary` (macOS only): `true` = always auxiliary, `false` = never, unset = auto (auxiliary iff created while the app has a fullscreen window on the active Space and the viewport does not itself request fullscreen). - `ViewportCommand::Fullscreen(true)` / `SetMonitor` clear the flag first, since auxiliary windows cannot enter native fullscreen. Verified on macOS with both the wgpu and glow backends: the root stays fullscreen and the child shows on top of it; behavior with a windowed root is unchanged. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/egui-winit/Cargo.toml | 10 +++ crates/egui-winit/src/lib.rs | 23 +++++++ crates/egui-winit/src/macos.rs | 109 +++++++++++++++++++++++++++++++++ crates/egui/src/viewport.rs | 34 ++++++++++ 5 files changed, 177 insertions(+) create mode 100644 crates/egui-winit/src/macos.rs diff --git a/Cargo.lock b/Cargo.lock index 2085fa9555a3..85c84ebb6fdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1348,6 +1348,7 @@ dependencies = [ "egui", "log", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", diff --git a/crates/egui-winit/Cargo.toml b/crates/egui-winit/Cargo.toml index 7fea2341baf2..1e99c85c7fc6 100644 --- a/crates/egui-winit/Cargo.toml +++ b/crates/egui-winit/Cargo.toml @@ -79,6 +79,16 @@ document-features = { workspace = true, optional = true } serde = { workspace = true, optional = true } webbrowser = { workspace = true, optional = true } +[target.'cfg(target_os = "macos")'.dependencies] +objc2.workspace = true +objc2-app-kit = { workspace = true, default-features = false, features = [ + "std", + "NSApplication", + "NSResponder", + "NSView", + "NSWindow", +] } + [target.'cfg(target_os = "ios")'.dependencies] objc2.workspace = true objc2-foundation = { workspace = true, features = ["std", "NSThread"] } diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 908ac8ff36fe..fd88437f05e2 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -21,6 +21,8 @@ use egui::{Pos2, Rect, Theme, Vec2, ViewportBuilder, ViewportCommand, ViewportId pub use winit; pub mod clipboard; +#[cfg(target_os = "macos")] +mod macos; mod safe_area; mod window_settings; @@ -1826,10 +1828,16 @@ fn process_viewport_command( info.maximized = Some(v); } ViewportCommand::Fullscreen(v) => { + #[cfg(target_os = "macos")] + if v { + macos::clear_fullscreen_auxiliary(window); + } window.set_fullscreen(v.then_some(winit::window::Fullscreen::Borderless(None))); } ViewportCommand::SetMonitor(idx) => { if let Some(monitor) = window.available_monitors().nth(idx) { + #[cfg(target_os = "macos")] + macos::clear_fullscreen_auxiliary(window); window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(Some(monitor)))); } else { log::warn!( @@ -1972,6 +1980,9 @@ pub fn create_winit_window_attributes( ) -> winit::window::WindowAttributes { profiling::function_scope!(); + #[cfg(target_os = "macos")] + let fullscreen_auxiliary = macos::should_be_fullscreen_auxiliary(&viewport_builder); + let ViewportBuilder { title, position, @@ -1998,6 +2009,7 @@ pub fn create_winit_window_attributes( titlebar_buttons_shown: _titlebar_buttons_shown, titlebar_shown: _titlebar_shown, has_shadow: _has_shadow, + fullscreen_auxiliary: _, // decided by `macos::should_be_fullscreen_auxiliary` below // Windows: drag_and_drop: _drag_and_drop, @@ -2156,6 +2168,14 @@ pub fn create_winit_window_attributes( .with_fullsize_content_view(_fullsize_content_view.unwrap_or(false)) .with_movable_by_window_background(_movable_by_window_background.unwrap_or(false)) .with_has_shadow(_has_shadow.unwrap_or(true)); + + if fullscreen_auxiliary { + // The fullscreen-auxiliary collection behavior must be set before the window + // is first ordered on screen, and `winit` shows the window during creation. + // So create the window hidden; + // `apply_viewport_builder_to_window` will mark it and then show it. + window_attributes = window_attributes.with_visible(false); + } } window_attributes @@ -2226,6 +2246,9 @@ pub fn apply_viewport_builder_to_window( window.set_maximized(maximized); } } + + #[cfg(target_os = "macos")] + macos::apply_fullscreen_auxiliary(window, builder); } // --------------------------------------------------------------------------- diff --git a/crates/egui-winit/src/macos.rs b/crates/egui-winit/src/macos.rs new file mode 100644 index 000000000000..bc9c420ff290 --- /dev/null +++ b/crates/egui-winit/src/macos.rs @@ -0,0 +1,109 @@ +//! macOS-specific handling of native viewport windows. +//! +//! `winit` 0.30 does not expose `NSWindowCollectionBehavior`, so we set it here via +//! `objc2-app-kit`. Once egui is on a `winit` version with fullscreen-auxiliary support +//! (proposed upstream for 0.31), most of this module can be replaced by +//! `WindowAttributesMacOS::with_fullscreen_auxiliary` etc., keeping only the default +//! policy in [`should_be_fullscreen_auxiliary`]. + +use egui::ViewportBuilder; +use objc2::{MainThreadMarker, rc::Retained}; +use objc2_app_kit::{ + NSApplication, NSView, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask, +}; +use raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; +use winit::window::Window; + +/// Should the window created for this viewport be marked as a +/// "fullscreen auxiliary" window (`NSWindowCollectionBehaviorFullScreenAuxiliary`)? +/// +/// See [`ViewportBuilder::with_fullscreen_auxiliary`]. +pub(crate) fn should_be_fullscreen_auxiliary(builder: &ViewportBuilder) -> bool { + if let Some(explicit) = builder.fullscreen_auxiliary { + return explicit; + } + + // Default: if the app currently has a native fullscreen window on the active Space, + // showing a normal new window would make macOS renegotiate the Space, + // flickering and potentially aborting the fullscreen state or triggering + // a Split View (https://github.com/emilk/egui/issues/8259). + // So mark the new window as an auxiliary window that can share the fullscreen Space — + // unless it wants to become fullscreen itself, + // which requires the (mutually exclusive) primary fullscreen behavior. + let wants_fullscreen = builder.fullscreen.unwrap_or(false) || builder.monitor.is_some(); + !wants_fullscreen && app_has_fullscreen_window_on_active_space() +} + +fn app_has_fullscreen_window_on_active_space() -> bool { + let Some(mtm) = MainThreadMarker::new() else { + return false; // AppKit windows can only be inspected on the main thread + }; + let app = NSApplication::sharedApplication(mtm); + app.windows().iter().any(|window| { + window.styleMask().contains(NSWindowStyleMask::FullScreen) && window.isOnActiveSpace() + }) +} + +/// Finish the initialization of a window that [`should_be_fullscreen_auxiliary`], +/// and was therefore created hidden (see `create_winit_window_attributes`): +/// mark it as a fullscreen-auxiliary window, +/// so that ordering it on screen won't disturb any active fullscreen Space. +pub(crate) fn apply_fullscreen_auxiliary(window: &Window, builder: &ViewportBuilder) { + if should_be_fullscreen_auxiliary(builder) { + let Some(ns_window) = ns_window_from_winit(window) else { + log::warn!("Failed to get NSWindow to mark the window as fullscreen-auxiliary"); + return; + }; + log::debug!( + "Marking new window {:?} as fullscreen-auxiliary", + builder.title + ); + ns_window.setCollectionBehavior( + ns_window.collectionBehavior() | NSWindowCollectionBehavior::FullScreenAuxiliary, + ); + } + + // The window was created hidden so that the collection behavior above + // takes effect before the window is first ordered on screen. + // Show it now, if the builder asked for a visible window: + if builder.visible.unwrap_or(true) && window.is_visible() == Some(false) { + let Some(ns_window) = ns_window_from_winit(window) else { + log::warn!("Failed to get NSWindow to show the window"); + return; + }; + if builder.active.unwrap_or(true) { + ns_window.makeKeyAndOrderFront(None); + } else { + ns_window.orderFront(None); + } + } +} + +/// A window marked as fullscreen-auxiliary cannot enter native fullscreen, +/// so clear the flag before any attempt to make the window fullscreen. +/// +/// See [`ViewportBuilder::with_fullscreen_auxiliary`]. +pub(crate) fn clear_fullscreen_auxiliary(window: &Window) { + let Some(ns_window) = ns_window_from_winit(window) else { + log::warn!("Failed to get NSWindow to clear the fullscreen-auxiliary state"); + return; + }; + let behavior = ns_window.collectionBehavior(); + if behavior.contains(NSWindowCollectionBehavior::FullScreenAuxiliary) { + ns_window.setCollectionBehavior(behavior - NSWindowCollectionBehavior::FullScreenAuxiliary); + } +} + +fn ns_window_from_winit(window: &Window) -> Option> { + let handle = window.window_handle().ok()?.as_raw(); + let RawWindowHandle::AppKit(handle) = handle else { + return None; + }; + let ns_view = handle.ns_view.as_ptr().cast::(); + + // SAFETY: the pointer comes from winit, and is valid for as long as `window` is + #[expect(unsafe_code)] + let ns_view = unsafe { ns_view.as_ref() }?; + + ns_view.window() +} diff --git a/crates/egui/src/viewport.rs b/crates/egui/src/viewport.rs index 1b1e64fe1266..847e34f736dc 100644 --- a/crates/egui/src/viewport.rs +++ b/crates/egui/src/viewport.rs @@ -317,6 +317,7 @@ pub struct ViewportBuilder { pub titlebar_buttons_shown: Option, pub titlebar_shown: Option, pub has_shadow: Option, + pub fullscreen_auxiliary: Option, // windows: pub drag_and_drop: Option, @@ -515,6 +516,31 @@ impl ViewportBuilder { self } + /// macOS: Set to `true` to mark the window as a "fullscreen auxiliary" window + /// (`NSWindowCollectionBehaviorFullScreenAuxiliary`), + /// which can be shown on the same Space (virtual desktop) as a fullscreen window. + /// + /// Without this, opening a new native window while another window of the app + /// is in native fullscreen on the active Space makes macOS renegotiate the Space: + /// the new window flickers, and the fullscreen state can be aborted or turned + /// into a Split View (see ). + /// + /// The trade-off: an auxiliary window cannot itself enter native fullscreen + /// (the green traffic-light button will zoom the window instead). + /// Sending [`ViewportCommand::Fullscreen`] `(true)` clears the flag again, + /// so requesting fullscreen at runtime still works. + /// + /// When not set, the default is to mark the window as fullscreen-auxiliary + /// if and only if it is created while another window of the app is in native + /// fullscreen on the active Space, and this viewport does not itself request + /// fullscreen ([`Self::with_fullscreen`] / [`Self::with_monitor`]). + /// Set this to `false` to opt out of that behavior. + #[inline] + pub fn with_fullscreen_auxiliary(mut self, fullscreen_auxiliary: bool) -> Self { + self.fullscreen_auxiliary = Some(fullscreen_auxiliary); + self + } + /// windows: Whether show or hide the window icon in the taskbar. #[inline] pub fn with_taskbar(mut self, show: bool) -> Self { @@ -736,6 +762,7 @@ impl ViewportBuilder { titlebar_buttons_shown: new_titlebar_buttons_shown, titlebar_shown: new_titlebar_shown, has_shadow: new_has_shadow, + fullscreen_auxiliary: new_fullscreen_auxiliary, close_button: new_close_button, minimize_button: new_minimize_button, maximize_button: new_maximize_button, @@ -913,6 +940,13 @@ impl ViewportBuilder { recreate_window = true; } + if new_fullscreen_auxiliary.is_some() + && self.fullscreen_auxiliary != new_fullscreen_auxiliary + { + self.fullscreen_auxiliary = new_fullscreen_auxiliary; + recreate_window = true; + } + if new_taskbar.is_some() && self.taskbar != new_taskbar { self.taskbar = new_taskbar; recreate_window = true; From 385e43f9af3c01c50f78df64a14fa5e32f5858f9 Mon Sep 17 00:00:00 2001 From: Steffen Moeller Date: Sat, 4 Jul 2026 00:17:36 +0200 Subject: [PATCH 2/2] Reference upstream winit PR for fullscreen-auxiliary support Co-Authored-By: Claude Fable 5 --- crates/egui-winit/src/macos.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/egui-winit/src/macos.rs b/crates/egui-winit/src/macos.rs index bc9c420ff290..6f543124d78a 100644 --- a/crates/egui-winit/src/macos.rs +++ b/crates/egui-winit/src/macos.rs @@ -2,7 +2,8 @@ //! //! `winit` 0.30 does not expose `NSWindowCollectionBehavior`, so we set it here via //! `objc2-app-kit`. Once egui is on a `winit` version with fullscreen-auxiliary support -//! (proposed upstream for 0.31), most of this module can be replaced by +//! (proposed upstream in ), +//! most of this module can be replaced by //! `WindowAttributesMacOS::with_fullscreen_auxiliary` etc., keeping only the default //! policy in [`should_be_fullscreen_auxiliary`].