From d0cdd589c7eacccfdeaff0f9c57859f0d638531a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:03:31 +0800 Subject: [PATCH] fix(window): keep a fullscreen project window group in one Space on macOS Native fullscreen gives each window its own Space, so switching projects in a fullscreen window group left one Space and entered another: the desktop showed in between, and quick switching could drop out of fullscreen. A grouped window now uses simple fullscreen for every member at once. The group stays in one Space at screen size, and switching projects only shows one window and hides the others. The green button, the system Enter Full Screen item and Toggle Full Screen all take this path for grouped windows; ungrouped windows keep native fullscreen. Menu bar and Dock auto-hide while any window is in simple fullscreen. Detaching or closing a tab keeps the rest of the group fullscreen. --- src-tauri/src/commands/window.rs | 4 +- src-tauri/src/macos.rs | 106 ++++++++++++++++++++++++++++++- src-tauri/src/window_groups.rs | 71 ++++++++++++++++++++- src-tauri/src/windows.rs | 2 + 4 files changed, 178 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 1716669d3..abec5daed 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -124,9 +124,7 @@ pub fn window_cycle(app: AppHandle, window: WebviewWindow, forward: bool) { #[tauri::command] #[specta::specta] pub fn window_toggle_fullscreen(window: WebviewWindow) -> IdeResult { - let on = !window.is_fullscreen().map_err(|e| IdeError::internal(e.to_string()))?; - window.set_fullscreen(on).map_err(|e| IdeError::internal(e.to_string()))?; - Ok(on) + crate::window_groups::toggle_fullscreen(&window) } /// Height of the web title bar in native logical pixels (CSS height × IDE zoom). diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index a38f0be05..6783de550 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -2,7 +2,11 @@ use crate::windows::{Launch, WindowsState}; use ide_core::{IdeError, IdeResult}; use ide_workspace::window_groups::{TabbingPreference, should_group_new_project}; -use objc2::{MainThreadMarker, rc::Retained, runtime::ProtocolObject}; +use objc2::{ + MainThreadMarker, + rc::Retained, + runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel}, +}; use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference}; use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; use tauri::{AppHandle, Manager, WebviewWindow}; @@ -96,3 +100,103 @@ pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::R native.toggleFullScreen(None); }) } + +/// Windows in simple fullscreen. Grouped project windows take turns in one Space; simple +/// fullscreen covers the screen without a Space of its own, so switching projects is a plain +/// show/hide instead of leaving one fullscreen Space and entering another. +static SIMPLE_FULLSCREEN: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +pub fn is_simple_fullscreen(label: &str) -> bool { + SIMPLE_FULLSCREEN.lock().unwrap().iter().any(|l| l == label) +} + +pub fn set_simple_fullscreen(window: &WebviewWindow, on: bool) -> tauri::Result<()> { + window.set_simple_fullscreen(on)?; + let any = { + let mut labels = SIMPLE_FULLSCREEN.lock().unwrap(); + labels.retain(|l| l != window.label()); + if on { + labels.push(window.label().to_owned()); + } + !labels.is_empty() + }; + window.run_on_main_thread(move || present(any)) +} + +/// Forgets a closed window; true when it was in simple fullscreen. +pub fn forget_simple_fullscreen(app: &AppHandle, label: &str) -> bool { + let (was, any) = { + let mut labels = SIMPLE_FULLSCREEN.lock().unwrap(); + let before = labels.len(); + labels.retain(|l| l != label); + (labels.len() != before, !labels.is_empty()) + }; + if was { + let _ = app.run_on_main_thread(move || present(any)); + } + was +} + +/// Each window restores the presentation options it found on entering, which is wrong once +/// fullscreen has passed between windows; derive them from the windows in fullscreen instead. +fn present(fullscreen: bool) { + use objc2_app_kit::{NSApplication, NSApplicationPresentationOptions}; + let Some(mtm) = MainThreadMarker::new() else { return }; + let options = if fullscreen { + NSApplicationPresentationOptions::AutoHideDock | NSApplicationPresentationOptions::AutoHideMenuBar + } else { + NSApplicationPresentationOptions::Default + }; + NSApplication::sharedApplication(mtm).setPresentationOptions(options); +} + +type ToggleFullScreen = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject); + +static NATIVE_TOGGLE_FULLSCREEN: std::sync::OnceLock = std::sync::OnceLock::new(); +static APP: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// The green window button, the Globe-F shortcut and the system's Enter Full Screen item all +/// send `toggleFullScreen:`; a grouped window answers it with simple fullscreen instead. +pub fn intercept_toggle_fullscreen(window: &WebviewWindow) { + let _ = APP.set(window.app_handle().clone()); + let _ = window.with_webview(|webview| unsafe { + let class = objc2::ffi::object_getClass(webview.ns_window().cast()) as *mut AnyClass; + let selector = objc2::sel!(toggleFullScreen:); + if NATIVE_TOGGLE_FULLSCREEN.get().is_some() { + return; + } + let Some(method) = (*class).instance_method(selector) else { return }; + let native: ToggleFullScreen = std::mem::transmute(method.implementation()); + let types = objc2::ffi::method_getTypeEncoding(method); + let _ = NATIVE_TOGGLE_FULLSCREEN.set(native); + let replacement: Imp = std::mem::transmute(toggle_fullscreen as ToggleFullScreen); + objc2::ffi::class_replaceMethod(class, selector, replacement, types); + }); +} + +unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Sel, sender: *mut AnyObject) { + if let Some(window) = APP.get().and_then(|app| simple_fullscreen_window(app, this)) { + if let Err(error) = crate::window_groups::toggle_fullscreen(&window) { + tracing::warn!(message = error.message, "cannot toggle fullscreen"); + } + return; + } + if let Some(native) = NATIVE_TOGGLE_FULLSCREEN.get() { + unsafe { native(this, selector, sender) }; + } +} + +/// `native` as a grouped project window (or one still in simple fullscreen) that is not in +/// native fullscreen; one in native fullscreen leaves it the usual way. +fn simple_fullscreen_window(app: &AppHandle, native: *mut AnyObject) -> Option { + let mask: objc2_app_kit::NSWindowStyleMask = unsafe { objc2::msg_send![native, styleMask] }; + if mask.contains(objc2_app_kit::NSWindowStyleMask::FullScreen) { + return None; + } + let window = app + .webview_windows() + .into_values() + .find(|w| w.ns_window().is_ok_and(|pointer| pointer.cast::() == native))?; + let grouped = app.state::().groups.lock().group(window.label()).is_some(); + (grouped || is_simple_fullscreen(window.label())).then_some(window) +} diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 76ad5e6ec..df588c354 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -80,12 +80,61 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { Ok(()) } +fn simple_fullscreen(label: &str) -> bool { + #[cfg(target_os = "macos")] + return crate::macos::is_simple_fullscreen(label); + #[cfg(not(target_os = "macos"))] + { + let _ = label; + false + } +} + +#[cfg(target_os = "macos")] +fn set_simple_fullscreen(window: &WebviewWindow, on: bool) -> IdeResult<()> { + crate::macos::set_simple_fullscreen(window, on).map_err(|e| IdeError::internal(e.to_string())) +} + +/// Fullscreen for `window`. On macOS a grouped window uses simple fullscreen, for every member +/// at once: the group stays in one Space and each member keeps the screen's size, so switching +/// projects is only showing one window and hiding the others. +pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult { + let err = |e: tauri::Error| IdeError::internal(e.to_string()); + #[cfg(target_os = "macos")] + { + let simple = simple_fullscreen(window.label()); + let app = window.app_handle(); + let members = app.state::().groups.lock().group(window.label()).map(|g| g.members.clone()); + if simple || (members.is_some() && !window.is_fullscreen().map_err(err)?) { + set_simple_fullscreen(window, !simple)?; + for member in members.iter().flatten().filter(|m| *m != window.label()) { + if let Some(other) = app.get_webview_window(member) + && simple_fullscreen(member) == simple + { + set_simple_fullscreen(&other, !simple)?; + } + } + return Ok(!simple); + } + } + let on = !window.is_fullscreen().map_err(err)?; + window.set_fullscreen(on).map_err(err)?; + Ok(on) +} + pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> { let to = app.get_webview_window(target).ok_or_else(|| IdeError::not_found("Window closed"))?; let state = app.state::(); let previous = state.groups.lock().select(caller, target)?; + // In a fullscreen group the target already covers the screen, or does so before it is shown. + let simple = previous.selected != target && simple_fullscreen(&previous.selected); let result = (|| { - if previous.selected != target + #[cfg(target_os = "macos")] + if simple && !simple_fullscreen(target) { + set_simple_fullscreen(&to, true)?; + } + if !simple + && previous.selected != target && let Some(from) = app.get_webview_window(&previous.selected) { copy_frame(&from, &to)?; @@ -137,6 +186,10 @@ pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> { // Native fullscreen puts each window in a separate Space; leave it before grouping. for label in &members { if let Some(window) = app.get_webview_window(label) { + #[cfg(target_os = "macos")] + if label != caller && simple_fullscreen(label) { + set_simple_fullscreen(&window, false)?; + } window.set_fullscreen(false).map_err(|e| IdeError::internal(e.to_string()))?; } } @@ -148,10 +201,20 @@ pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> { let state = app.state::(); let next = state.groups.lock().detach(caller); if let Some(next) = next { + // The tab that stays keeps the fullscreen; the detached window opens beside it. + let simple = simple_fullscreen(caller); if let Some(window) = app.get_webview_window(&next) { + #[cfg(target_os = "macos")] + if simple && !simple_fullscreen(&next) { + set_simple_fullscreen(&window, true)?; + } window.show().map_err(|e| IdeError::internal(e.to_string()))?; } if let Some(window) = app.get_webview_window(caller) { + #[cfg(target_os = "macos")] + if simple { + set_simple_fullscreen(&window, false)?; + } if let Ok(position) = window.outer_position() { let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32)); } @@ -164,9 +227,15 @@ pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> { pub fn destroyed(app: &AppHandle, caller: &str) { let Some(state) = app.try_state::() else { return }; + #[cfg(target_os = "macos")] + let simple = crate::macos::forget_simple_fullscreen(app, caller); let next = state.groups.lock().detach(caller); if !state.quitting() { if let Some(window) = next.and_then(|label| app.get_webview_window(&label)) { + #[cfg(target_os = "macos")] + if simple && !simple_fullscreen(window.label()) { + let _ = set_simple_fullscreen(&window, true); + } windows::focus(&window); } let app = app.clone(); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index c0483b128..a0db3c123 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -602,6 +602,8 @@ fn create_window_for( app.state::().set_launch(&label, launch); let window = build_project_window(app, config)?; #[cfg(target_os = "macos")] + crate::macos::intercept_toggle_fullscreen(&window); + #[cfg(target_os = "macos")] if let Some(target) = tab_target { crate::window_groups::join(app, target.label(), window.label())?; return Ok(window);