From ebbda4bb1f2594974b2e257d6a3099714d97f016 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:18:19 +0800 Subject: [PATCH] fix(window): give a fullscreen project window group its own Space on macOS (#1729) * fix(window): give a fullscreen project window group its own Space on macOS A grouped window went fullscreen through simple fullscreen: it covered the current desktop instead of opening a Space, kept its window frame and rounded corners, and the menu bar stayed over the tab bar. The selected member now enters native fullscreen as the group's host and the other members join its Space as borderless auxiliary windows over the host's frame, so switching projects still only shows one window and hides the others. A project opened over the group joins it hidden and appears in that Space, without the window-state plugin restoring a fullscreen state its label kept. Closing the host hands the Space to the next tab once the old one is gone, and a tab moved to a new window stays fullscreen in a Space of its own. * fix(window): keep a fullscreen group's frames unsynced on Windows and Linux fullscreen_host is always None off macOS, so dropping the selected window's own fullscreen check let a fullscreen group copy its frame to the hidden tabs there, taking fullscreen from the visible one. --- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 19 +-- src-tauri/src/macos.rs | 276 ++++++++++++++++++--------------- src-tauri/src/window_groups.rs | 216 +++++++++++++++++++------- src-tauri/src/windows.rs | 28 +++- 5 files changed, 340 insertions(+), 201 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fa3850a25..15d00456c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -82,7 +82,7 @@ block2 = "0.6" # Plugin secrets in the login keychain. security-framework = "3.7" objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSResponder", "NSScreen", "NSToolbar", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSToolbar", "NSView", "NSWindow", "NSWorkspace"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } # Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d8eefb9a..33a9e3796 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -99,13 +99,14 @@ pub fn run() { #[cfg(target_os = "linux")] let app = app.plugin(linux_window_state::plugin()); #[cfg(not(target_os = "linux"))] - let app = app.plugin( - tauri_plugin_window_state::Builder::default() - .with_state_flags(window_state_flags()) - // One remembered geometry per kind of auxiliary window (all projects' settings windows share one). - .map_label(windows::state_key) - .build(), - ); + let window_state = tauri_plugin_window_state::Builder::default() + .with_state_flags(window_state_flags()) + // One remembered geometry per kind of auxiliary window (all projects' settings windows share one). + .map_label(windows::state_key); + #[cfg(target_os = "macos")] + let window_state = window_state.with_filter(windows::keeps_window_state); + #[cfg(not(target_os = "linux"))] + let app = app.plugin(window_state.build()); let app = app // Inert until an endpoint is configured (see `commands::window::app_check_update`). .plugin(tauri_plugin_updater::Builder::new().build()); @@ -200,10 +201,6 @@ pub fn run() { window_groups::frame_changed(window.app_handle(), window.label()); } tauri::WindowEvent::Destroyed => { - #[cfg(target_os = "macos")] - { - macos::forget_fullscreen(window.label()); - } if windows::auxiliary_parent(window.label()).is_none() { window_groups::destroyed(window.app_handle(), window.label()); windows::close_auxiliary_windows(window.app_handle(), window.label()); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 7deeb54b1..3d32a8de1 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -47,9 +47,8 @@ pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult(); host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some() }); - // A project-tab group is fullscreen through simple fullscreen, which the native flag misses. - let fullscreen = - active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false) || is_simple_fullscreen(w.label())); + // A group member other than its fullscreen host is not itself in native fullscreen. + let fullscreen = active.as_ref().is_some_and(|w| crate::window_groups::in_fullscreen(app, w)); let light_edit = launch.project.is_none() && launch.remote.is_none(); Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit) .then_some(active) @@ -66,32 +65,103 @@ impl Drop for FullscreenObserver { } thread_local! { - static FULLSCREEN_TRANSFERS: std::cell::RefCell> = std::cell::RefCell::default(); + /// Fullscreen hosts of window groups, watched for leaving fullscreen. + static FULLSCREEN_HOSTS: std::cell::RefCell> = std::cell::RefCell::default(); } -pub fn forget_fullscreen(label: &str) { - FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.remove(label)); +/// Stops watching `label`; true when it hosted a fullscreen window group. +pub fn forget_fullscreen_host(label: &str) -> bool { + FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.remove(label)).is_some() } -/// AppKit cannot enter another window's fullscreen Space until the old one has exited. -pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { - let source = from.label().to_owned(); - let to = to.clone(); - from.with_webview(move |webview| unsafe { - let native = &*webview.ns_window().cast::(); - forget_fullscreen(&source); - let label = source.clone(); - let block = block2::RcBlock::new(move |_| { - forget_fullscreen(&label); - let app = to.app_handle(); - let selected = app.state::().groups.lock().group(to.label()).map(|g| g.selected.clone()); - if let Some(selected) = selected { - // Exiting fullscreen can show the old window again; reapply group visibility. - let _ = crate::window_groups::select(app, &selected, &selected); - if selected == to.label() { - let _ = to.set_fullscreen(true); - } +/// Runs `f` on the main thread before returning, so it takes effect before what the caller does next. +fn on_main(window: &WebviewWindow, f: impl FnOnce() -> R + Send + 'static) -> tauri::Result { + if MainThreadMarker::new().is_some() { + return Ok(f()); + } + let (tx, rx) = std::sync::mpsc::sync_channel(1); + window.run_on_main_thread(move || { + let _ = tx.send(f()); + })?; + rx.recv().map_err(|_| tauri::Error::FailedToReceiveMessage) +} + +/// The native `toggleFullScreen:`, bypassing the group handling installed on the window class. +pub fn native_toggle_fullscreen(window: &WebviewWindow) -> tauri::Result<()> { + let target = window.clone(); + on_main(window, move || { + let (Ok(pointer), Some(native)) = (target.ns_window(), NATIVE_TOGGLE_FULLSCREEN.get()) else { return }; + unsafe { native(pointer.cast(), objc2::sel!(toggleFullScreen:), std::ptr::null_mut()) }; + }) +} + +/// A window group in fullscreen shares its host's Space: every other member joins it as an +/// auxiliary window over the host's frame, without a frame, window buttons or dragging, like the host. +/// Without a host the window is an ordinary one again. +pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWindow>) -> tauri::Result<()> { + use objc2_app_kit::{NSWindowButton, NSWindowCollectionBehavior as B}; + let target = window.clone(); + let host = host.cloned(); + on_main(window, move || { + let Ok(pointer) = target.ns_window() else { return }; + let native = unsafe { &*pointer.cast::() }; + let frame = + host.and_then(|host| host.ns_window().ok()).map(|host| unsafe { &*host.cast::() }.frame()); + let mut behavior = native.collectionBehavior(); + if frame.is_some() { + behavior.remove(B::FullScreenPrimary); + behavior.insert(B::FullScreenAuxiliary | B::MoveToActiveSpace); + } else { + behavior.remove(B::FullScreenAuxiliary | B::MoveToActiveSpace); + behavior.insert(B::FullScreenPrimary); + } + native.setCollectionBehavior(behavior); + // Without a title bar the window has square corners and no frame, like the host. + let mut style = native.styleMask(); + style.set(objc2_app_kit::NSWindowStyleMask::Titled, frame.is_none()); + native.setStyleMask(style); + native.setMovable(frame.is_none()); + // Shown over the host it must appear at once, as a tab does, not zoom in like a new window. + native.setAnimationBehavior(if frame.is_some() { + objc2_app_kit::NSWindowAnimationBehavior::None + } else { + objc2_app_kit::NSWindowAnimationBehavior::Default + }); + for kind in [NSWindowButton::CloseButton, NSWindowButton::MiniaturizeButton, NSWindowButton::ZoomButton] { + if let Some(button) = native.standardWindowButton(kind) { + button.setHidden(frame.is_some()); } + } + if let Some(frame) = frame { + native.setFrame_display(frame, true); + } + }) +} + +/// Puts a group member shown in its host's fullscreen Space in front of the host, which AppKit +/// otherwise keeps on top of the other windows in its Space. +pub fn raise_over_host(window: &WebviewWindow, host: &WebviewWindow) -> tauri::Result<()> { + let target = window.clone(); + let host = host.clone(); + on_main(window, move || { + let (Ok(pointer), Ok(host)) = (target.ns_window(), host.ns_window()) else { return }; + let native = unsafe { &*pointer.cast::() }; + let host = unsafe { &*host.cast::() }; + native.orderWindow_relativeTo(objc2_app_kit::NSWindowOrderingMode::Above, host.windowNumber()); + }) +} + +/// Watches the group's fullscreen `host` and runs `left` once it is back on the desktop. +pub fn watch_fullscreen_host(host: &WebviewWindow, left: impl Fn() + Send + 'static) -> tauri::Result<()> { + let label = host.label().to_owned(); + let target = host.clone(); + on_main(host, move || unsafe { + let Ok(pointer) = target.ns_window() else { return }; + let native = &*pointer.cast::(); + let key = label.clone(); + let block = block2::RcBlock::new(move |_| { + forget_fullscreen_host(&key); + left(); }); let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( Some(objc2_app_kit::NSWindowDidExitFullScreenNotification), @@ -99,108 +169,60 @@ pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::R None, &block, ); - FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.insert(source, FullscreenObserver(observer))); - native.toggleFullScreen(None); + FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.insert(label, FullscreenObserver(observer))); }) } -/// 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()); +/// An observer on the workspace's notification center, removed when dropped. +struct WorkspaceObserver(Retained, Retained>); -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<()> { - if !on { - // Before the window goes back to its own frame. - let target = window.clone(); - window.run_on_main_thread(move || keep_window_buttons(&target, false))?; +impl Drop for WorkspaceObserver { + fn drop(&mut self) { + let observer: &ProtocolObject = &self.1; + unsafe { self.0.removeObserver(observer.as_ref()) }; } - 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() - }; - let target = window.clone(); - window.run_on_main_thread(move || { - present(any); - if on { - keep_window_buttons(&target, true); - } - }) } -/// A window in simple fullscreen: its `NSWindow` and the minimum size restored when it leaves. -struct Covering { - native: usize, - min_size: objc2_foundation::NSSize, +/// Work waiting for the active Space to change, with the observer that runs it. +struct SpaceChange { + _observer: WorkspaceObserver, + then: Box, } thread_local! { - static COVERING: std::cell::RefCell> = std::cell::RefCell::default(); + static SPACE_CHANGE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } -/// Simple fullscreen drops the title bar and resizing; ours is transparent over the web toolbar, -/// so keep it and its buttons, the green one (enabled only for a resizable window) leading back -/// out of fullscreen. A minimum size of the whole screen keeps the edges from being dragged. -/// A titled window is kept below the menu bar or camera housing; see `constrain_frame_rect`. -fn keep_window_buttons(window: &WebviewWindow, on: bool) { - use objc2_app_kit::NSWindowStyleMask; - let Ok(pointer) = window.ns_window() else { return }; - let native = unsafe { &*pointer.cast::() }; - let label = window.label().to_owned(); - if !on { - if let Some(covering) = COVERING.with_borrow_mut(|windows| windows.remove(&label)) { - native.setMinSize(covering.min_size); +/// Runs `then` on the main thread once the active Space has changed, or after `timeout` when it +/// changed before this was called. AppKit drops a fullscreen request made while a fullscreen +/// Space is being torn down. +pub fn after_space_change(app: &AppHandle, timeout: std::time::Duration, then: impl FnOnce() + Send + 'static) { + fn run() { + if let Some(pending) = SPACE_CHANGE.with_borrow_mut(Option::take) { + (pending.then)(); } - return; } - let Some(screen) = native.screen() else { return }; - let frame = screen.frame(); - COVERING.with_borrow_mut(|windows| { - windows.entry(label).or_insert_with(|| Covering { native: pointer as usize, min_size: native.minSize() }); + let _ = app.run_on_main_thread(move || { + run(); + let block = block2::RcBlock::new(|_| run()); + let center = objc2_app_kit::NSWorkspace::sharedWorkspace().notificationCenter(); + let observer = unsafe { + center.addObserverForName_object_queue_usingBlock( + Some(objc2_app_kit::NSWorkspaceActiveSpaceDidChangeNotification), + None, + None, + &block, + ) + }; + let observer = WorkspaceObserver(center, observer); + SPACE_CHANGE + .with_borrow_mut(|pending| *pending = Some(SpaceChange { _observer: observer, then: Box::new(then) })); + }); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(timeout).await; + let _ = app.run_on_main_thread(run); }); - native.setMinSize(frame.size); - native.setStyleMask(native.styleMask() | NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable); - native.setFrame_display(frame, true); -} - -/// 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 label = label.to_owned(); - let _ = app.run_on_main_thread(move || { - COVERING.with_borrow_mut(|windows| windows.remove(&label)); - 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 ShouldTerminate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize; @@ -246,7 +268,7 @@ static NATIVE_TOGGLE_FULLSCREEN: std::sync::OnceLock = std::sy 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. +/// send `toggleFullScreen:`; a grouped window answers it for the whole group. pub fn intercept_toggle_fullscreen(window: &WebviewWindow) { let _ = APP.set(window.app_handle().clone()); let _ = window.with_webview(|webview| unsafe { @@ -361,17 +383,15 @@ fn apply_window_frame_on_main(from: &WebviewWindow, to: &WebviewWindow, show: bo } /// AppKit keeps a titled window's top edge below the menu bar, and on a notched display below the -/// camera housing, whenever the window is placed, shown or resized. A window in simple fullscreen -/// is titled for its buttons but must still cover the whole screen. A group-tab frame copy also -/// keeps the rectangle it was given, so the window about to be shown is not shifted. +/// camera housing, whenever the window is placed, shown or resized. A group-tab frame copy keeps +/// the rectangle it was given, so the window about to be shown is not shifted. unsafe extern "C-unwind" fn constrain_frame_rect( this: *mut AnyObject, selector: Sel, frame: NSRect, screen: *mut AnyObject, ) -> NSRect { - let exact = EXACT_FRAME.with(|slot| slot.get() == this as usize); - if exact || COVERING.with_borrow(|windows| windows.values().any(|covering| covering.native == this as usize)) { + if EXACT_FRAME.with(|slot| slot.get() == this as usize) { return frame; } match NATIVE_CONSTRAIN_FRAME_RECT.get() { @@ -381,7 +401,7 @@ unsafe extern "C-unwind" fn constrain_frame_rect( } 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 Some(window) = APP.get().and_then(|app| grouped_window(app, this)) { if let Err(error) = crate::window_groups::toggle_fullscreen(&window) { tracing::warn!(message = error.message, "cannot toggle fullscreen"); } @@ -392,17 +412,15 @@ unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Se } } -/// `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() +fn project_window(app: &AppHandle, native: *mut AnyObject) -> Option { + app.webview_windows() .into_values() - .find(|w| w.ns_window().is_ok_and(|pointer| pointer.cast::() == native))?; + .find(|w| w.ns_window().is_ok_and(|pointer| pointer.cast::() == native)) +} + +/// `native` as a project window in a window group. +fn grouped_window(app: &AppHandle, native: *mut AnyObject) -> Option { + let window = project_window(app, native)?; let grouped = app.state::().groups.lock().group(window.label()).is_some(); - (grouped || is_simple_fullscreen(window.label())).then_some(window) + grouped.then_some(window) } diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 993ad606f..0601fb32f 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -16,6 +16,9 @@ use tauri::{AppHandle, Manager, WebviewWindow}; const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50); /// Wait for a drag/resize to stop before copying its frame to the hidden peers. const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100); +/// Longest wait for a closed host's fullscreen Space to go before the next tab takes the screen. +#[cfg(target_os = "macos")] +const SPACE_TEARDOWN: Duration = Duration::from_millis(1500); /// Coalesces the move/resize events a drag emits. static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -74,8 +77,6 @@ enum FrameHost { /// Wayland does not let clients place top-level windows. #[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))] Wayland, - /// macOS simple fullscreen already covers the screen; rewriting the frame would shrink it. - SimpleFullscreen, /// Native fullscreen owns a Space. A different frame is transferred, not assigned. NativeFullscreen, } @@ -93,7 +94,7 @@ enum FrameWrite { /// flags are not a substitute: `show` presents the current rectangle, and a later move is visible. fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite { match host { - FrameHost::Wayland | FrameHost::SimpleFullscreen => FrameWrite::Leave, + FrameHost::Wayland => FrameWrite::Leave, FrameHost::NativeFullscreen if from == to => FrameWrite::Leave, FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen, FrameHost::Desktop if from == to => FrameWrite::Leave, @@ -109,9 +110,6 @@ fn outer_frame(window: &WebviewWindow) -> IdeResult { } fn frame_host(from: &WebviewWindow) -> IdeResult { - if simple_fullscreen(from.label()) { - return Ok(FrameHost::SimpleFullscreen); - } #[cfg(target_os = "linux")] if crate::linux_window_state::is_wayland() { return Ok(FrameHost::Wayland); @@ -146,7 +144,7 @@ fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { /// rectangle appears and then jumps. The frame is written synchronously instead. fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { let host = frame_host(from)?; - if matches!(host, FrameHost::Wayland | FrameHost::SimpleFullscreen) { + if host == FrameHost::Wayland { return Ok(()); } // Tauri's physical rectangles round through the scale factor. On macOS the AppKit frame is @@ -164,8 +162,9 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { let err = |error: tauri::Error| IdeError::internal(error.to_string()); + // A macOS group in fullscreen shares its host's Space, so no member copies the host's frame. #[cfg(target_os = "macos")] - crate::macos::transfer_fullscreen(from, to).map_err(err)?; + let _ = (from, to, err); #[cfg(not(target_os = "macos"))] { to.set_fullscreen(false).map_err(err)?; @@ -232,7 +231,10 @@ fn sync_member_frames(app: &AppHandle, selected: &str) { return; } let Some(from) = app.get_webview_window(selected) else { return }; - if from.is_fullscreen().unwrap_or(false) || from.is_minimized().unwrap_or(false) || simple_fullscreen(selected) { + if from.is_fullscreen().unwrap_or(false) + || from.is_minimized().unwrap_or(false) + || fullscreen_host(app, &group.members).is_some() + { return; } for member in group.members.iter().filter(|member| member.as_str() != selected) { @@ -268,41 +270,53 @@ pub fn frame_changed(app: &AppHandle, label: &str) { }); } -fn simple_fullscreen(label: &str) -> bool { +/// The member of `members` in native fullscreen. On macOS the other members of its group join +/// its Space; elsewhere each member of a fullscreen group is fullscreen itself. +fn fullscreen_host(app: &AppHandle, members: &[String]) -> Option { #[cfg(target_os = "macos")] - return crate::macos::is_simple_fullscreen(label); + return members.iter().filter_map(|m| app.get_webview_window(m)).find(|w| w.is_fullscreen().unwrap_or(false)); #[cfg(not(target_os = "macos"))] { - let _ = label; - false + let _ = (app, members); + None } } +/// The fullscreen host of `label`'s group, when that is another window. #[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())) +pub fn fullscreen_host_of(app: &AppHandle, label: &str) -> Option { + let members = app.state::().groups.lock().group(label).map(|g| g.members.clone())?; + fullscreen_host(app, &members).filter(|host| host.label() != label) } -/// 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. +/// Whether `window` is shown fullscreen: itself, or as a member of a fullscreen group. +#[cfg(target_os = "macos")] +pub fn in_fullscreen(app: &AppHandle, window: &WebviewWindow) -> bool { + if window.is_fullscreen().unwrap_or(false) { + return true; + } + let members = app.state::().groups.lock().group(window.label()).map(|g| g.members.clone()); + members.is_some_and(|members| fullscreen_host(app, &members).is_some()) +} + +/// Fullscreen for `window`. On macOS a group shares one fullscreen Space: the selected member +/// enters native fullscreen and the others join its Space as auxiliary windows over it, so +/// switching projects only shows one window and hides 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)?; - } + let group = app.state::().groups.lock().group(window.label()).cloned(); + if let Some(group) = group { + if let Some(host) = fullscreen_host(app, &group.members) { + // The host's watch brings the members back with it. + crate::macos::native_toggle_fullscreen(&host).map_err(err)?; + return Ok(false); } - return Ok(!simple); + let host = app.get_webview_window(&group.selected).unwrap_or_else(|| window.clone()); + host_fullscreen(app, &host)?; + return Ok(true); } } let on = !window.is_fullscreen().map_err(err)?; @@ -310,18 +324,64 @@ pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult { Ok(on) } +/// Makes `host` the fullscreen window of its group, or of its own Space when ungrouped. +#[cfg(target_os = "macos")] +fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { + let err = |e: tauri::Error| IdeError::internal(e.to_string()); + let members = app.state::().groups.lock().group(host.label()).map(|g| g.members.clone()); + crate::macos::set_fullscreen_auxiliary(host, None).map_err(err)?; + for member in members.iter().flatten().filter(|m| *m != host.label()) { + if let Some(other) = app.get_webview_window(member) { + crate::macos::set_fullscreen_auxiliary(&other, Some(host)).map_err(err)?; + } + } + if members.is_some() { + watch_host(app, host)?; + } + host.show().map_err(err)?; + crate::macos::native_toggle_fullscreen(host).map_err(err) +} + +#[cfg(target_os = "macos")] +fn watch_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { + let app = app.clone(); + let label = host.label().to_owned(); + crate::macos::watch_fullscreen_host(host, move || left_fullscreen(&app, &label)) + .map_err(|e| IdeError::internal(e.to_string())) +} + +/// The group's fullscreen host is back on the desktop: the members leave its Space with it at +/// its frame, and the selected one is shown. +#[cfg(target_os = "macos")] +fn left_fullscreen(app: &AppHandle, host: &str) { + let Some(group) = app.state::().groups.lock().group(host).cloned() else { return }; + let Some(host) = app.get_webview_window(host) else { return }; + for member in group.members.iter().filter(|m| *m != host.label()) { + if let Some(window) = app.get_webview_window(member) { + let _ = crate::macos::set_fullscreen_auxiliary(&window, None); + let _ = copy_frame(&host, &window); + } + } + let _ = select(app, &group.selected, &group.selected); +} + 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); + // In a fullscreen group the target joins the host's Space, covering its screen. + let host = fullscreen_host(app, &previous.members); let result = (|| { #[cfg(target_os = "macos")] - if simple && !simple_fullscreen(target) { - set_simple_fullscreen(&to, true)?; + if let Some(host) = &host { + // Also a window that was fullscreen alone before it was grouped. + watch_host(app, host)?; + if host.label() != target { + crate::macos::set_fullscreen_auxiliary(&to, Some(host)) + .map_err(|e| IdeError::internal(e.to_string()))?; + } } - if !simple + if host.is_none() && previous.selected != target && let Some(from) = app.get_webview_window(&previous.selected) { @@ -329,10 +389,16 @@ pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> { } else { to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?; to.show().map_err(|e| IdeError::internal(e.to_string()))?; + #[cfg(target_os = "macos")] + if let Some(host) = host.as_ref().filter(|h| h.label() != target) { + crate::macos::raise_over_host(&to, host).map_err(|e| IdeError::internal(e.to_string()))?; + } to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?; } for member in &previous.members { + // The host stays in its Space behind the selected member; hiding it would end the Space. if member != target + && host.as_ref().is_none_or(|h| h.label() != member) && let Some(window) = app.get_webview_window(member) { window.hide().map_err(|e| IdeError::internal(e.to_string()))?; @@ -387,10 +453,6 @@ 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()))?; } } @@ -400,23 +462,23 @@ pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> { pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> { let state = app.state::(); + #[cfg(target_os = "macos")] + let host = { + let members = state.groups.lock().group(caller).map(|g| g.members.clone()).unwrap_or_default(); + fullscreen_host(app, &members) + }; let next = state.groups.lock().detach(caller); + #[cfg(target_os = "macos")] + if let Some(host) = host { + detach_fullscreen(app, caller, &host, next.as_deref())?; + changed(app); + return Ok(()); + } if let Some(next) = next { - // The tab that stays keeps the fullscreen; the detached window opens beside it. - #[cfg(target_os = "macos")] - 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)); } @@ -427,17 +489,60 @@ pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> { Ok(()) } +/// A tab moved out of a fullscreen group stays fullscreen in a Space of its own, as a macOS +/// tab moved to a new window does; the tabs left keep theirs. +#[cfg(target_os = "macos")] +fn detach_fullscreen(app: &AppHandle, caller: &str, host: &WebviewWindow, next: Option<&str>) -> IdeResult<()> { + let err = |e: tauri::Error| IdeError::internal(e.to_string()); + let next = next.and_then(|label| app.get_webview_window(label)); + let grouped = |label: &str| app.state::().groups.lock().group(label).is_some(); + if host.label() == caller { + // The host keeps its Space; the tabs left need a host of their own. + crate::macos::forget_fullscreen_host(caller); + if let Some(next) = &next { + host_fullscreen(app, next)?; + } + return Ok(()); + } + if let Some(next) = &next { + if grouped(next.label()) { + select(app, next.label(), next.label())?; + } else { + // The group dissolved; its last window is fullscreen alone. + crate::macos::forget_fullscreen_host(next.label()); + crate::macos::set_fullscreen_auxiliary(next, None).map_err(err)?; + } + } + if let Some(window) = app.get_webview_window(caller) { + host_fullscreen(app, &window)?; + } + Ok(()) +} + 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 hosted = crate::macos::forget_fullscreen_host(caller); let next = state.groups.lock().detach(caller); if !state.quitting() { if let Some(window) = next.and_then(|label| app.get_webview_window(&label)) { + // The Space ended with its host; the tabs left take a new one once it is gone. #[cfg(target_os = "macos")] - if simple && !simple_fullscreen(window.label()) { - let _ = set_simple_fullscreen(&window, true); + if hosted { + let host_app = app.clone(); + crate::macos::after_space_change(app, SPACE_TEARDOWN, move || { + if let Err(error) = host_fullscreen(&host_app, &window) { + tracing::warn!("cannot keep window group fullscreen: {}", error.message); + } + windows::focus(&window); + }); + } else { + windows::focus(&window); + if let Some(host) = fullscreen_host_of(app, window.label()) { + let _ = crate::macos::raise_over_host(&window, &host); + } } + #[cfg(not(target_os = "macos"))] windows::focus(&window); } let app = app.clone(); @@ -666,13 +771,6 @@ mod tests { assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); } - #[test] - fn simple_fullscreen_does_not_copy_a_frame() { - let from = frame(0, 0, 1728, 1117); - let to = frame(100, 80, 1200, 800); - assert_eq!(frame_write(FrameHost::SimpleFullscreen, from, to), FrameWrite::Leave); - } - #[test] fn wayland_does_not_place_a_window() { let from = frame(0, 0, 1200, 800); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index f7bbfa289..b39c61772 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -276,6 +276,18 @@ pub fn settings_label(parent: &str) -> String { format!("{}-{parent}", AuxKind::Settings.name()) } +/// Windows being built to join a window group, which takes their frame from the group. +#[cfg(target_os = "macos")] +static JOINING_GROUP: Mutex> = Mutex::new(Vec::new()); + +/// Whether the window-state plugin restores and tracks `label`'s geometry. A window joining a +/// group takes the group's frame instead; restoring a fullscreen state its label kept from an +/// earlier window would give it a fullscreen Space of its own. +#[cfg(target_os = "macos")] +pub fn keeps_window_state(label: &str) -> bool { + !JOINING_GROUP.lock().iter().any(|l| l == label) +} + /// The window state key: one geometry per auxiliary kind, else the label itself. pub fn state_key(label: &str) -> &str { auxiliary(label).map_or(label, |(kind, _)| kind.name()) @@ -612,7 +624,21 @@ fn create_window_for( config.center = false; } app.state::().set_launch(&label, launch); - let window = build_project_window(app, config)?; + #[cfg(target_os = "macos")] + if tab_target.is_some() { + JOINING_GROUP.lock().push(label.clone()); + // Selecting it in the group shows it; shown before, a window opened over a fullscreen + // group lands on the desktop and takes the screen there. + config.visible = false; + } + let window = build_project_window(app, config); + // Plugins hear of the window from the main thread's queue; forget it after them. + #[cfg(target_os = "macos")] + if tab_target.is_some() { + let joined = label.clone(); + let _ = app.run_on_main_thread(move || JOINING_GROUP.lock().retain(|l| *l != joined)); + } + let window = window?; #[cfg(target_os = "macos")] crate::macos::intercept_toggle_fullscreen(&window); #[cfg(target_os = "macos")]