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")]