diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 90ad25d6c..80a074aaa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -78,7 +78,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", "NSButton", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSScreen", "NSView", "NSWindow"] } objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] } 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. 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..36cb01413 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,148 @@ 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<()> { + 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))?; + } + 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); + } + }) +} + +thread_local! { + /// Minimum sizes of windows in simple fullscreen, restored when they leave it. + static MIN_SIZES: std::cell::RefCell> = + std::cell::RefCell::default(); +} + +/// 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. +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(size) = MIN_SIZES.with_borrow_mut(|sizes| sizes.remove(&label)) { + native.setMinSize(size); + } + return; + } + let Some(screen) = native.screen() else { return }; + let frame = screen.frame(); + MIN_SIZES.with_borrow_mut(|sizes| { + sizes.entry(label).or_insert_with(|| native.minSize()); + }); + 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 || { + MIN_SIZES.with_borrow_mut(|sizes| sizes.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 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);